Athipt commited on
Commit
299866a
·
verified ·
1 Parent(s): 11ab443

Deploy FastAPI image classifier

Browse files
.dockerignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ .pytest_cache/
3
+ .git/
4
+ .github/
5
+ tests/
6
+ docs/*.json
7
+ *.pyc
8
+ *.pyo
9
+ *.pyd
.github/workflows/test.yml ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: test-and-deploy
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ pull_request:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - name: Check out repository
14
+ uses: actions/checkout@v4
15
+
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.11"
20
+
21
+ - name: Install dependencies
22
+ run: python -m pip install --upgrade pip && pip install -r requirements.txt
23
+
24
+ - name: Run pytest
25
+ run: pytest
26
+
27
+ deploy:
28
+ needs: test
29
+ if: github.event_name == 'push'
30
+ runs-on: ubuntu-latest
31
+ steps:
32
+ - name: Check out repository
33
+ uses: actions/checkout@v4
34
+
35
+ - name: Set up Python
36
+ uses: actions/setup-python@v5
37
+ with:
38
+ python-version: "3.11"
39
+
40
+ - name: Install deployment dependency
41
+ run: python -m pip install --upgrade pip && pip install huggingface-hub
42
+
43
+ - name: Deploy to Hugging Face Spaces
44
+ env:
45
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
46
+ HF_SPACE_REPO: ${{ secrets.HF_SPACE_REPO }}
47
+ run: python scripts/deploy_to_hf_spaces.py
.gitignore ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ env/
12
+ venv/
13
+ ENV/
14
+ env.bak/
15
+ venv.bak/
16
+ pip-wheel-metadata/
17
+ build/
18
+ develop-eggs/
19
+ dist/
20
+ downloads/
21
+ eggs/
22
+ .eggs/
23
+ lib/
24
+ lib64/
25
+ parts/
26
+ sdist/
27
+ var/
28
+ *.egg-info/
29
+ .installed.cfg
30
+ *.egg
31
+
32
+ # PyInstaller
33
+ # Usually these files are written by a python script from a template
34
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
35
+ *.manifest
36
+ *.spec
37
+
38
+ # Installer logs
39
+ pip-log.txt
40
+ pip-delete-this-directory.txt
41
+
42
+ # Unit test / coverage reports
43
+ htmlcov/
44
+ .tox/
45
+ .nox/
46
+ .coverage
47
+ .coverage.*
48
+ .cache
49
+ nosetests.xml
50
+ coverage.xml
51
+ *.cover
52
+ .hypothesis/
53
+ .pytest_cache/
54
+
55
+ # Jupyter Notebook
56
+ .ipynb_checkpoints
57
+
58
+ # pyenv
59
+ .python-version
60
+
61
+ # celery beat schedule file
62
+ celerybeat-schedule
63
+
64
+ # SageMath parsed files
65
+ *.sage.py
66
+
67
+ # Environments
68
+ .env
69
+ .env.*
70
+ .venv
71
+
72
+ # Spyder project settings
73
+ .spyderproject
74
+ .spyproject
75
+
76
+ # Rope project settings
77
+ .ropeproject
78
+
79
+ # mkdocs documentation
80
+ /site
81
+
82
+ # mypy
83
+ .mypy_cache/
84
+ .dmypy.json
85
+ dmypy.json
86
+
87
+ # Pyre type checker
88
+ .pyre/
89
+
90
+ # Docker
91
+ *.log
92
+ docker-compose*.yml
93
+ Dockerfile*
94
+ !Dockerfile
95
+
96
+ # Model artifacts
97
+ model/*.pth
98
+ model/*.pt
99
+ model/*.h5
100
+ model/*.onnx
101
+ model/*.pb
102
+ model/*.tflite
103
+ model/*.joblib
104
+ model/*.pkl
105
+ model/*.ckpt
106
+
107
+ # VS Code
108
+ .vscode/
109
+
110
+ # System files
111
+ Thumbs.db
112
+ ehthumbs.db
113
+ Desktop.ini
114
+ .DS_Store
115
+ __MACOSX/
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM cgr.dev/chainguard/python:latest-dev AS builder
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1
6
+
7
+ WORKDIR /build
8
+
9
+ COPY requirements.txt ./
10
+ RUN python -m pip install --upgrade pip \
11
+ && pip install --prefix=/install -r requirements.txt
12
+
13
+ FROM cgr.dev/chainguard/python:latest
14
+
15
+ ENV PYTHONPATH=/install/lib/python3.12/site-packages
16
+
17
+ WORKDIR /app
18
+
19
+ COPY --from=builder /install /install
20
+ COPY app ./app
21
+ COPY model ./model
22
+ COPY scripts ./scripts
23
+ COPY README.md ./README.md
24
+
25
+ EXPOSE 8000
26
+
27
+ CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
README.md CHANGED
@@ -1,10 +1,95 @@
1
- ---
2
- title: SPACE
3
- emoji: 🐠
4
- colorFrom: yellow
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FastAPI Image Classifier
2
+
3
+ A small CPU-friendly image classification service built around a Hugging Face model, exported to ONNX, and quantized for faster inference.
4
+
5
+ ## What is included
6
+
7
+ - Hugging Face source model: `timm/mobilenetv4_conv_medium.e500_r224_in1k`
8
+ - Optimization scripts for PyTorch, ONNX, and INT8 quantized ONNX
9
+ - Async FastAPI `POST /predict` endpoint
10
+ - ProcessPoolExecutor-based CPU offload
11
+ - Pytest coverage for API behavior and validation errors
12
+ - Dockerfile for container packaging
13
+ - GitHub Actions workflow for CI and Hugging Face Spaces deployment
14
+
15
+ ## Project structure
16
+
17
+ - `app/` FastAPI application and model helpers
18
+ - `model/` exported model artifacts
19
+ - `scripts/` export, benchmark, and deployment utilities
20
+ - `tests/` pytest suite
21
+ - `.github/workflows/` CI/CD workflow
22
+
23
+ ## Setup
24
+
25
+ ```bash
26
+ python -m pip install -r requirements.txt
27
+ ```
28
+
29
+ ## Export model artifacts
30
+
31
+ ```bash
32
+ python scripts/export_models.py
33
+ ```
34
+
35
+ This generates:
36
+
37
+ - `model/model_original.pt`
38
+ - `model/model.onnx`
39
+ - `model/model_quantized.onnx`
40
+
41
+ ## Run the API locally
42
+
43
+ ```bash
44
+ uvicorn app.main:app --reload
45
+ ```
46
+
47
+ ## Predict with curl
48
+
49
+ ```bash
50
+ curl -X POST \
51
+ -F "file=@cat.jpg" \
52
+ http://127.0.0.1:8000/predict
53
+ ```
54
+
55
+ Example response:
56
+
57
+ ```json
58
+ {
59
+ "label": "cat",
60
+ "confidence": 0.98
61
+ }
62
+ ```
63
+
64
+ ## Run tests
65
+
66
+ ```bash
67
+ pytest
68
+ ```
69
+
70
+ ## Docker
71
+
72
+ ```bash
73
+ docker build -t image-classifier .
74
+ docker run -p 8000:8000 image-classifier
75
+ ```
76
+
77
+ ## Benchmarking
78
+
79
+ Run the benchmark script after exporting the models:
80
+
81
+ ```bash
82
+ python scripts/benchmark_models.py --image path/to/sample.jpg
83
+ ```
84
+
85
+ The results are written to `docs/benchmark_results.json`.
86
+
87
+ ## Hugging Face Spaces deployment
88
+
89
+ Set these secrets before the deployment job runs:
90
+
91
+ - `HF_TOKEN`
92
+ - `HF_SPACE_REPO`
93
+
94
+ The workflow uploads the repository to a Docker-based Space.
95
+
app/__init__.py ADDED
File without changes
app/config.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+
6
+ from pydantic import Field
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env", extra="ignore")
12
+
13
+ hf_model_name: str = "timm/mobilenetv4_conv_medium.e500_r224_in1k"
14
+ input_size: int = 224
15
+ max_upload_mb: int = 5
16
+ accepted_extensions: tuple[str, ...] = (".jpg", ".jpeg", ".png", ".webp", ".bmp")
17
+ accepted_content_types: tuple[str, ...] = ("image/jpeg", "image/png", "image/webp", "image/bmp")
18
+ worker_processes: int = 2
19
+ model_dir: Path = Field(default_factory=lambda: Path(__file__).resolve().parents[1] / "model")
20
+ docs_dir: Path = Field(default_factory=lambda: Path(__file__).resolve().parents[1] / "docs")
21
+
22
+ @property
23
+ def max_upload_bytes(self) -> int:
24
+ return self.max_upload_mb * 1024 * 1024
25
+
26
+ @property
27
+ def torch_weights_path(self) -> Path:
28
+ return self.model_dir / "model_original.pt"
29
+
30
+ @property
31
+ def onnx_path(self) -> Path:
32
+ return self.model_dir / "model.onnx"
33
+
34
+ @property
35
+ def quantized_onnx_path(self) -> Path:
36
+ return self.model_dir / "model_quantized.onnx"
37
+
38
+ @property
39
+ def benchmark_output_path(self) -> Path:
40
+ return self.docs_dir / "benchmark_results.json"
41
+
42
+
43
+ @lru_cache(maxsize=1)
44
+ def get_settings() -> Settings:
45
+ return Settings()
app/image_validation.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from io import BytesIO
4
+ from pathlib import Path
5
+
6
+ from fastapi import HTTPException, UploadFile, status
7
+ from PIL import Image, UnidentifiedImageError
8
+ from pydantic import BaseModel, Field, model_validator, ValidationError
9
+
10
+ from .config import Settings
11
+
12
+
13
+ class ImageUploadMetadata(BaseModel):
14
+ filename: str
15
+ content_type: str
16
+ size_bytes: int = Field(gt=0)
17
+
18
+ @model_validator(mode="after")
19
+ def validate_metadata(self) -> "ImageUploadMetadata":
20
+ allowed_extensions = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
21
+ allowed_content_types = {"image/jpeg", "image/png", "image/webp", "image/bmp"}
22
+ suffix = Path(self.filename).suffix.lower()
23
+ if suffix not in allowed_extensions:
24
+ raise ValueError("Unsupported file type")
25
+ if self.content_type not in allowed_content_types:
26
+ raise ValueError("Unsupported file type")
27
+ return self
28
+
29
+
30
+ async def read_and_validate_image(file: UploadFile, settings: Settings) -> bytes:
31
+ if not file.filename:
32
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File name is required")
33
+
34
+ data = await file.read(settings.max_upload_bytes + 1)
35
+ try:
36
+ metadata = ImageUploadMetadata(
37
+ filename=file.filename,
38
+ content_type=file.content_type or "",
39
+ size_bytes=len(data),
40
+ )
41
+ except ValidationError:
42
+ raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail="Unsupported file type")
43
+
44
+ if metadata.size_bytes > settings.max_upload_bytes:
45
+ raise HTTPException(status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="File exceeds 5 MB limit")
46
+
47
+ try:
48
+ with Image.open(BytesIO(data)) as image:
49
+ image.verify()
50
+ with Image.open(BytesIO(data)) as image:
51
+ image.convert("RGB")
52
+ except (UnidentifiedImageError, OSError, ValueError) as exc:
53
+ raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Unable to decode image") from exc
54
+
55
+ return data
app/label_map.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ DOG_HINTS = {
4
+ "terrier",
5
+ "retriever",
6
+ "spaniel",
7
+ "hound",
8
+ "poodle",
9
+ "collie",
10
+ "shepherd",
11
+ "pug",
12
+ "bulldog",
13
+ "beagle",
14
+ "dalmatian",
15
+ "chihuahua",
16
+ "maltese",
17
+ "samoyed",
18
+ "husky",
19
+ "boxer",
20
+ "doberman",
21
+ "shiba",
22
+ "akita",
23
+ "rottweiler",
24
+ "dog",
25
+ "canine",
26
+ }
27
+
28
+ CAT_HINTS = {
29
+ "cat",
30
+ "tabby",
31
+ "tiger cat",
32
+ "persian cat",
33
+ "siamese",
34
+ "lynx",
35
+ }
36
+
37
+
38
+ def coarse_label(raw_label: str) -> str:
39
+ normalized = raw_label.lower()
40
+ if any(keyword in normalized for keyword in DOG_HINTS):
41
+ return "dog"
42
+ if any(keyword in normalized for keyword in CAT_HINTS):
43
+ return "cat"
44
+ cleaned = raw_label.split(",", maxsplit=1)[0].strip().lower()
45
+ return cleaned.replace(" ", "_")
app/main.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from concurrent.futures import ProcessPoolExecutor
5
+ from contextlib import asynccontextmanager
6
+
7
+ from fastapi import FastAPI, File, HTTPException, Request, UploadFile, status
8
+
9
+ from .config import Settings, get_settings
10
+ from .image_validation import read_and_validate_image
11
+ from .model_backend import PredictionResult, predict_image_bytes
12
+ from .schemas import PredictionResponse
13
+
14
+
15
+ def _predict_in_worker(image_bytes: bytes, settings_dict: dict[str, str]) -> PredictionResult:
16
+ settings = Settings(**settings_dict)
17
+ return predict_image_bytes(image_bytes, settings)
18
+
19
+
20
+ @asynccontextmanager
21
+ async def lifespan(app: FastAPI):
22
+ settings = get_settings()
23
+ executor = ProcessPoolExecutor(max_workers=settings.worker_processes)
24
+ app.state.executor = executor
25
+ app.state.settings = settings
26
+
27
+ async def process_pool_predictor(image_bytes: bytes) -> PredictionResult:
28
+ loop = asyncio.get_running_loop()
29
+ return await loop.run_in_executor(
30
+ executor,
31
+ _predict_in_worker,
32
+ image_bytes,
33
+ settings.model_dump(mode="json"),
34
+ )
35
+
36
+ app.state.predictor = process_pool_predictor
37
+ try:
38
+ yield
39
+ finally:
40
+ executor.shutdown(wait=True, cancel_futures=True)
41
+
42
+
43
+ def create_app(use_process_pool: bool = True, predictor=None) -> FastAPI:
44
+ app = FastAPI(title="Image Classifier API", version="1.0.0", lifespan=lifespan if use_process_pool else None)
45
+
46
+ if not use_process_pool:
47
+ app.state.settings = get_settings()
48
+ app.state.predictor = predictor or (lambda image_bytes: predict_image_bytes(image_bytes, app.state.settings))
49
+ elif predictor is not None:
50
+ app.state.predictor = predictor
51
+
52
+ @app.post("/predict", response_model=PredictionResponse)
53
+ async def predict(request: Request, file: UploadFile = File(...)) -> PredictionResponse:
54
+ settings = get_settings()
55
+ image_bytes = await read_and_validate_image(file, settings)
56
+ app_instance = request.app
57
+ predictor_callable = getattr(app_instance.state, "predictor", None)
58
+ if predictor_callable is None:
59
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Predictor is not initialized")
60
+
61
+ result = predictor_callable(image_bytes)
62
+ if asyncio.iscoroutine(result):
63
+ result = await result
64
+
65
+ return PredictionResponse(label=result.label, confidence=result.confidence)
66
+
67
+ @app.get("/healthz")
68
+ async def healthz() -> dict[str, str]:
69
+ return {"status": "ok"}
70
+
71
+ return app
72
+
73
+
74
+ app = create_app()
app/model_backend.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import json
5
+ import statistics
6
+ import time
7
+ from dataclasses import dataclass
8
+ from functools import lru_cache
9
+ from pathlib import Path
10
+ from typing import Callable
11
+
12
+ import numpy as np
13
+ import onnxruntime as ort
14
+ import torch
15
+ import torch.nn as nn
16
+ from PIL import Image
17
+ from onnxruntime.quantization import QuantType, quantize_dynamic
18
+ from transformers import AutoConfig, AutoImageProcessor, AutoModelForImageClassification
19
+
20
+ from .config import Settings, get_settings
21
+ from .label_map import coarse_label
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class PredictionResult:
26
+ label: str
27
+ confidence: float
28
+
29
+
30
+ class _TorchExportWrapper(nn.Module):
31
+ def __init__(self, model: nn.Module) -> None:
32
+ super().__init__()
33
+ self.model = model
34
+
35
+ def forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
36
+ return self.model(pixel_values=pixel_values).logits
37
+
38
+
39
+ class TorchImageClassifier:
40
+ def __init__(self, model_name: str) -> None:
41
+ self.processor = AutoImageProcessor.from_pretrained(model_name)
42
+ self.model = AutoModelForImageClassification.from_pretrained(model_name)
43
+ self.model.eval()
44
+ self.id2label = self.model.config.id2label
45
+
46
+ def predict(self, image_bytes: bytes) -> PredictionResult:
47
+ image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
48
+ inputs = self.processor(images=image, return_tensors="pt")
49
+ with torch.inference_mode():
50
+ outputs = self.model(**inputs)
51
+ probabilities = torch.softmax(outputs.logits, dim=-1)[0]
52
+ top_index = int(torch.argmax(probabilities).item())
53
+ raw_label = self.id2label.get(top_index, str(top_index))
54
+ return PredictionResult(
55
+ label=coarse_label(raw_label),
56
+ confidence=float(probabilities[top_index].item()),
57
+ )
58
+
59
+ def export_checkpoint(self, output_path: Path, model_name: str) -> None:
60
+ output_path.parent.mkdir(parents=True, exist_ok=True)
61
+ payload = {
62
+ "model_name": model_name,
63
+ "state_dict": self.model.state_dict(),
64
+ "id2label": self.id2label,
65
+ }
66
+ torch.save(payload, output_path)
67
+
68
+ def export_onnx(self, output_path: Path, input_size: int) -> None:
69
+ output_path.parent.mkdir(parents=True, exist_ok=True)
70
+ dummy_image = Image.new("RGB", (input_size, input_size), color=(255, 255, 255))
71
+ dummy_inputs = self.processor(images=dummy_image, return_tensors="pt")["pixel_values"]
72
+ wrapper = _TorchExportWrapper(self.model)
73
+ torch.onnx.export(
74
+ wrapper,
75
+ dummy_inputs,
76
+ output_path,
77
+ input_names=["pixel_values"],
78
+ output_names=["logits"],
79
+ dynamic_axes={"pixel_values": {0: "batch_size"}, "logits": {0: "batch_size"}},
80
+ opset_version=17,
81
+ )
82
+
83
+
84
+ class OnnxImageClassifier:
85
+ def __init__(self, model_path: Path, model_name: str) -> None:
86
+ self.session = ort.InferenceSession(str(model_path), providers=["CPUExecutionProvider"])
87
+ self.processor = AutoImageProcessor.from_pretrained(model_name)
88
+ self.id2label = AutoConfig.from_pretrained(model_name).id2label
89
+ self.input_name = self.session.get_inputs()[0].name
90
+
91
+ def predict(self, image_bytes: bytes) -> PredictionResult:
92
+ image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
93
+ inputs = self.processor(images=image, return_tensors="np")
94
+ pixel_values = inputs["pixel_values"].astype(np.float32)
95
+ logits = self.session.run(None, {self.input_name: pixel_values})[0][0]
96
+ probabilities = np.exp(logits - np.max(logits))
97
+ probabilities = probabilities / probabilities.sum()
98
+ top_index = int(np.argmax(probabilities))
99
+ raw_label = self.id2label.get(top_index, str(top_index))
100
+ return PredictionResult(label=coarse_label(raw_label), confidence=float(probabilities[top_index]))
101
+
102
+
103
+ @lru_cache(maxsize=1)
104
+ def get_torch_classifier(model_name: str | None = None) -> TorchImageClassifier:
105
+ settings = get_settings()
106
+ return TorchImageClassifier(model_name or settings.hf_model_name)
107
+
108
+
109
+ @lru_cache(maxsize=1)
110
+ def get_onnx_classifier(model_path: str, model_name: str) -> OnnxImageClassifier:
111
+ return OnnxImageClassifier(Path(model_path), model_name)
112
+
113
+
114
+ def get_best_classifier(settings: Settings | None = None) -> Callable[[bytes], PredictionResult]:
115
+ config = settings or get_settings()
116
+ if config.quantized_onnx_path.exists():
117
+ backend = get_onnx_classifier(str(config.quantized_onnx_path), config.hf_model_name)
118
+ return backend.predict
119
+ if config.onnx_path.exists():
120
+ backend = get_onnx_classifier(str(config.onnx_path), config.hf_model_name)
121
+ return backend.predict
122
+ backend = get_torch_classifier(config.hf_model_name)
123
+ return backend.predict
124
+
125
+
126
+ def predict_image_bytes(image_bytes: bytes, settings: Settings | None = None) -> PredictionResult:
127
+ classifier = get_best_classifier(settings)
128
+ return classifier(image_bytes)
129
+
130
+
131
+ def export_model_assets(settings: Settings | None = None) -> None:
132
+ config = settings or get_settings()
133
+ torch_classifier = get_torch_classifier(config.hf_model_name)
134
+ torch_classifier.export_checkpoint(config.torch_weights_path, config.hf_model_name)
135
+ torch_classifier.export_onnx(config.onnx_path, config.input_size)
136
+ quantize_dynamic(str(config.onnx_path), str(config.quantized_onnx_path), weight_type=QuantType.QInt8)
137
+
138
+
139
+ def benchmark_predictions(
140
+ predictor: Callable[[bytes], PredictionResult],
141
+ image_bytes: bytes,
142
+ runs: int = 20,
143
+ warmup_runs: int = 5,
144
+ ) -> dict[str, float]:
145
+ for _ in range(warmup_runs):
146
+ predictor(image_bytes)
147
+
148
+ timings: list[float] = []
149
+ for _ in range(runs):
150
+ start = time.perf_counter()
151
+ predictor(image_bytes)
152
+ timings.append(time.perf_counter() - start)
153
+
154
+ average_seconds = statistics.mean(timings)
155
+ return {
156
+ "latency_ms": statistics.median(timings) * 1000.0,
157
+ "throughput_rps": 1.0 / average_seconds if average_seconds else 0.0,
158
+ }
159
+
160
+
161
+ def serialize_benchmark_report(rows: list[dict[str, float | str]]) -> str:
162
+ return json.dumps(rows, indent=2)
app/schemas.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+
6
+ class PredictionResponse(BaseModel):
7
+ label: str
8
+ confidence: float = Field(ge=0.0, le=1.0)
9
+
10
+
11
+ class BenchmarkResult(BaseModel):
12
+ variant: str
13
+ model_size_mb: float
14
+ latency_ms: float
15
+ throughput_rps: float
docs/report.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Project Report
2
+
3
+ ## Model Selection
4
+
5
+ - Model: `timm/mobilenetv4_conv_medium.e500_r224_in1k`
6
+ - Task: image classification on ImageNet-1k
7
+ - Input size: `224 x 224`
8
+ - Reason for selection: compact backbone, low CPU cost, and straightforward export to ONNX
9
+
10
+ ## Optimization Phase
11
+
12
+ Run `python scripts/export_models.py` and `python scripts/benchmark_models.py --image path/to/sample.jpg` to produce the measured values below.
13
+
14
+ | Model | Size | Latency |
15
+ | --- | ---: | ---: |
16
+ | Original | TBD MB | TBD ms |
17
+ | ONNX | TBD MB | TBD ms |
18
+ | Quantized | TBD MB | TBD ms |
19
+
20
+ ## Error Handling Strategy
21
+
22
+ - Missing file: `422`
23
+ - Invalid file type: `415`
24
+ - Too large file: `413`
25
+ - Corrupted image: `422`
26
+ - Unexpected server crash: `500`
27
+
28
+ Validation checks cover file extension, MIME type, upload size, and image decodability before inference starts.
29
+
30
+ ## System Architecture
31
+
32
+ ```mermaid
33
+ flowchart TD
34
+ Client --> FastAPI
35
+ FastAPI --> ProcessPool
36
+ ProcessPool --> ONNXModel
37
+ ONNXModel --> Response
38
+ ```
39
+
40
+ ## CI/CD Pipeline
41
+
42
+ ```mermaid
43
+ flowchart TD
44
+ Push --> GitHubActions
45
+ GitHubActions --> Pytest
46
+ Pytest --> DockerBuild
47
+ DockerBuild --> HuggingFaceSpaces
48
+ ```
49
+
50
+ ## Performance Testing
51
+
52
+ Load testing is intended for both local Docker and Hugging Face Spaces with JMeter against `POST /predict`. Collect throughput, request latency, and P95 latency, then identify the CPU saturation point where response time rises sharply.
53
+
54
+ ## Deliverables Checklist
55
+
56
+ - FastAPI application
57
+ - Model export scripts
58
+ - Benchmarking script
59
+ - Quantized ONNX model path
60
+ - Pytest suite
61
+ - Docker packaging
62
+ - GitHub Actions workflow
63
+ - Deployment script for Hugging Face Spaces
model/README.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Model Artifacts
2
+
3
+ This folder is expected to contain the exported model files used by the API and benchmark scripts:
4
+
5
+ - `model_original.pt`
6
+ - `model.onnx`
7
+ - `model_quantized.onnx`
8
+
9
+ Generate them with:
10
+
11
+ ```bash
12
+ python scripts/export_models.py
13
+ ```
14
+
15
+ The Docker image copies this directory so the API can start without exporting the model at runtime.
pytest.ini ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ [pytest]
2
+ asyncio_mode = auto
3
+ testpaths = tests
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.6
2
+ uvicorn[standard]==0.34.0
3
+ python-multipart==0.0.20
4
+ pydantic==2.10.6
5
+ pydantic-settings==2.8.1
6
+ pillow==11.1.0
7
+ numpy==2.2.2
8
+ torch==2.6.0
9
+ torchvision==0.21.0
10
+ transformers==4.48.3
11
+ huggingface-hub==0.28.1
12
+ onnx==1.17.0
13
+ onnxruntime==1.20.1
14
+ pytest==8.3.4
15
+ pytest-asyncio==0.25.3
16
+ httpx==0.28.1
scripts/benchmark_models.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from PIL import Image
7
+
8
+ from app.config import get_settings
9
+ from app.model_backend import (
10
+ benchmark_predictions,
11
+ get_onnx_classifier,
12
+ get_torch_classifier,
13
+ serialize_benchmark_report,
14
+ )
15
+
16
+
17
+ def load_sample_image(image_path: Path) -> bytes:
18
+ with Image.open(image_path) as image:
19
+ rgb_image = image.convert("RGB")
20
+ from io import BytesIO
21
+
22
+ buffer = BytesIO()
23
+ rgb_image.save(buffer, format="JPEG")
24
+ return buffer.getvalue()
25
+
26
+
27
+ def model_size_mb(model_path: Path) -> float:
28
+ return model_path.stat().st_size / (1024 * 1024)
29
+
30
+
31
+ def main() -> None:
32
+ parser = argparse.ArgumentParser(description="Benchmark original, ONNX, and quantized model variants.")
33
+ parser.add_argument("--image", type=Path, required=True, help="Sample image used for benchmarking")
34
+ args = parser.parse_args()
35
+
36
+ settings = get_settings()
37
+ image_bytes = load_sample_image(args.image)
38
+
39
+ torch_backend = get_torch_classifier(settings.hf_model_name)
40
+ onnx_backend = get_onnx_classifier(str(settings.onnx_path), settings.hf_model_name)
41
+ quantized_backend = get_onnx_classifier(str(settings.quantized_onnx_path), settings.hf_model_name)
42
+
43
+ rows = [
44
+ {
45
+ "variant": "Original",
46
+ "model_size_mb": model_size_mb(settings.torch_weights_path),
47
+ **benchmark_predictions(torch_backend.predict, image_bytes),
48
+ },
49
+ {
50
+ "variant": "ONNX",
51
+ "model_size_mb": model_size_mb(settings.onnx_path),
52
+ **benchmark_predictions(onnx_backend.predict, image_bytes),
53
+ },
54
+ {
55
+ "variant": "Quantized",
56
+ "model_size_mb": model_size_mb(settings.quantized_onnx_path),
57
+ **benchmark_predictions(quantized_backend.predict, image_bytes),
58
+ },
59
+ ]
60
+
61
+ settings.docs_dir.mkdir(parents=True, exist_ok=True)
62
+ settings.benchmark_output_path.write_text(serialize_benchmark_report(rows), encoding="utf-8")
63
+ print(serialize_benchmark_report(rows))
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
scripts/deploy_to_hf_spaces.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from huggingface_hub import HfApi
7
+
8
+
9
+ def main() -> None:
10
+ repo_id = os.environ.get("HF_SPACE_REPO")
11
+ token = os.environ.get("HF_TOKEN")
12
+ if not repo_id or not token:
13
+ raise SystemExit("HF_SPACE_REPO and HF_TOKEN must be set")
14
+
15
+ root = Path(__file__).resolve().parents[1]
16
+ api = HfApi(token=token)
17
+ api.create_repo(repo_id=repo_id, repo_type="space", space_sdk="docker", exist_ok=True)
18
+ api.upload_folder(
19
+ repo_id=repo_id,
20
+ repo_type="space",
21
+ folder_path=str(root),
22
+ commit_message="Deploy FastAPI image classifier",
23
+ ignore_patterns=[
24
+ ".git/*",
25
+ ".pytest_cache/*",
26
+ "__pycache__/*",
27
+ "tests/*",
28
+ "docs/benchmark_results.json",
29
+ ],
30
+ )
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
scripts/export_models.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ from app.config import get_settings
7
+ from app.model_backend import export_model_assets
8
+
9
+
10
+ def parse_args() -> argparse.Namespace:
11
+ parser = argparse.ArgumentParser(description="Export PyTorch, ONNX, and quantized ONNX model artifacts.")
12
+ parser.add_argument("--model-dir", type=Path, default=None, help="Override the default model directory")
13
+ return parser.parse_args()
14
+
15
+
16
+ def main() -> None:
17
+ args = parse_args()
18
+ settings = get_settings()
19
+ if args.model_dir is not None:
20
+ settings = settings.model_copy(update={"model_dir": args.model_dir})
21
+ export_model_assets(settings)
22
+
23
+
24
+ if __name__ == "__main__":
25
+ main()