Deploy FastAPI image classifier
Browse files- .dockerignore +9 -0
- .github/workflows/test.yml +47 -0
- .gitignore +115 -0
- Dockerfile +27 -0
- README.md +95 -10
- app/__init__.py +0 -0
- app/config.py +45 -0
- app/image_validation.py +55 -0
- app/label_map.py +45 -0
- app/main.py +74 -0
- app/model_backend.py +162 -0
- app/schemas.py +15 -0
- docs/report.md +63 -0
- model/README.md +15 -0
- pytest.ini +3 -0
- requirements.txt +16 -0
- scripts/benchmark_models.py +67 -0
- scripts/deploy_to_hf_spaces.py +34 -0
- scripts/export_models.py +25 -0
.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 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|