jitendra205saini commited on
Commit
3c4611e
·
0 Parent(s):

Production-ready PyTorch image classification pipeline

Browse files

FastAPI backend (MobileNet_V3_Large, top-5 /predict), modern Tailwind web UI
with drag-and-drop and dark/light mode, argparse CLI, multi-stage Dockerfile,
and Render + Hugging Face deployment configs.

Files changed (9) hide show
  1. .dockerignore +17 -0
  2. .gitignore +26 -0
  3. Dockerfile +64 -0
  4. README.md +306 -0
  5. app/__init__.py +0 -0
  6. app/main.py +523 -0
  7. predict.py +132 -0
  8. render.yaml +23 -0
  9. requirements.txt +10 -0
.dockerignore ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ .venv/
6
+ venv/
7
+ env/
8
+ .git/
9
+ .gitignore
10
+ .idea/
11
+ .vscode/
12
+ *.md
13
+ tests/
14
+ *.jpg
15
+ *.jpeg
16
+ *.png
17
+ *.webp
.gitignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .eggs/
6
+
7
+ # Virtual environments
8
+ .venv/
9
+ venv/
10
+ env/
11
+
12
+ # PyTorch / model cache
13
+ *.pth
14
+ .cache/
15
+
16
+ # IDE / OS
17
+ .idea/
18
+ .vscode/
19
+ .DS_Store
20
+ Thumbs.db
21
+
22
+ # Local artifacts
23
+ *.log
24
+
25
+ # Local test image
26
+ sample.jpg
Dockerfile ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+
3
+ # ===========================================================================
4
+ # Stage 1 — builder: install Python deps into an isolated virtualenv
5
+ # ===========================================================================
6
+ FROM python:3.10-slim AS builder
7
+
8
+ ENV PIP_NO_CACHE_DIR=1 \
9
+ PIP_DISABLE_PIP_VERSION_CHECK=1 \
10
+ PYTHONDONTWRITEBYTECODE=1
11
+
12
+ # Build deps only needed at install time.
13
+ RUN apt-get update \
14
+ && apt-get install -y --no-install-recommends build-essential \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # Create a self-contained virtualenv we can copy into the final image.
18
+ RUN python -m venv /opt/venv
19
+ ENV PATH="/opt/venv/bin:$PATH"
20
+
21
+ WORKDIR /install
22
+ COPY requirements.txt .
23
+
24
+ # Install the CPU-only torch wheels to keep the image small and free-tier friendly.
25
+ RUN pip install --upgrade pip \
26
+ && pip install --no-cache-dir \
27
+ --extra-index-url https://download.pytorch.org/whl/cpu \
28
+ -r requirements.txt
29
+
30
+
31
+ # ===========================================================================
32
+ # Stage 2 — runtime: minimal image with just the venv + app code
33
+ # ===========================================================================
34
+ FROM python:3.10-slim AS runtime
35
+
36
+ ENV PYTHONDONTWRITEBYTECODE=1 \
37
+ PYTHONUNBUFFERED=1 \
38
+ PATH="/opt/venv/bin:$PATH" \
39
+ MODEL_NAME=mobilenet_v3_large \
40
+ TORCH_HOME=/home/appuser/.cache/torch \
41
+ TORCH_NUM_THREADS=1 \
42
+ OMP_NUM_THREADS=1 \
43
+ MKL_NUM_THREADS=1
44
+
45
+ # Copy the prebuilt virtualenv from the builder stage.
46
+ COPY --from=builder /opt/venv /opt/venv
47
+
48
+ # Run as a non-root user for security.
49
+ RUN useradd --create-home --uid 1000 appuser
50
+ WORKDIR /app
51
+ COPY --chown=appuser:appuser . /app
52
+ USER appuser
53
+
54
+ # Warm the weights cache at build time so the first request is fast.
55
+ RUN python -c "from app.main import get_model_bundle; get_model_bundle()"
56
+
57
+ EXPOSE 8000
58
+
59
+ # Most PaaS platforms (Render, Railway, Koyeb, HF Spaces) inject a $PORT env var.
60
+ # Bind to it when present, otherwise fall back to 8000 for local Docker runs.
61
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
62
+ CMD python -c "import os,urllib.request,sys; p=os.getenv('PORT','8000'); sys.exit(0 if urllib.request.urlopen(f'http://127.0.0.1:{p}/health').status==200 else 1)"
63
+
64
+ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
README.md ADDED
@@ -0,0 +1,306 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- The block below is Hugging Face Spaces config. It is required for a Docker
2
+ Space and is harmless on GitHub (rendered as a small metadata table). -->
3
+ ---
4
+ title: AI Image Classifier
5
+ emoji: 🖼️
6
+ colorFrom: indigo
7
+ colorTo: blue
8
+ sdk: docker
9
+ app_port: 8000
10
+ pinned: false
11
+ ---
12
+
13
+ # 🖼️ Advanced Image Classification Pipeline
14
+
15
+ A **production-ready PyTorch image classifier** with a modern FastAPI backend, a
16
+ beautiful drag-and-drop web UI (Tailwind CSS), a polished CLI, and a fully
17
+ optimized multi-stage Docker build ready for free / cheap cloud hosting.
18
+
19
+ > Uses **MobileNet_V3_Large** (default) or **EfficientNet_B0** — both more modern,
20
+ > faster and more accurate than the classic ResNet-18, pretrained on ImageNet (1000 classes).
21
+
22
+ ---
23
+
24
+ ## ✨ Features
25
+
26
+ - ⚡ **FastAPI** backend with async upload handling
27
+ - 🧠 Modern CNN with **singleton model caching** (loads weights only once)
28
+ - 📊 `/predict` returns **top-k classes, confidence scores & inference time (ms)**
29
+ - 🎨 **Drag-and-drop web UI** with live results, confidence bars & loading spinner
30
+ - 🖥️ **CLI tool** (`predict.py`) with a clean ASCII results table
31
+ - 📝 Structured **request + timing logging**
32
+ - 🐳 **Multi-stage Dockerfile** (CPU-only torch wheels, slim final image, non-root user)
33
+ - 🩺 `/health` endpoint for cloud readiness probes
34
+
35
+ ---
36
+
37
+ ## 📁 Directory Structure
38
+
39
+ ```
40
+ Image-Classification-Pipeline/
41
+ ├── app/
42
+ │ ├── __init__.py
43
+ │ └── main.py # FastAPI backend + Tailwind single-page UI
44
+ ├── predict.py # Advanced argparse CLI classifier
45
+ ├── requirements.txt # Pinned dependencies
46
+ ├── Dockerfile # Optimized multi-stage build (python:3.10-slim)
47
+ ├── .dockerignore
48
+ ├── .gitignore
49
+ └── README.md # You are here
50
+ ```
51
+
52
+ ---
53
+
54
+ ## 🚀 Local Setup (Windows)
55
+
56
+ > Requires **Python 3.10+**. Commands shown for **PowerShell**.
57
+
58
+ ### 1. Clone / open the project
59
+
60
+ ```powershell
61
+ cd D:\Image-Classification-Pipeline
62
+ ```
63
+
64
+ ### 2. Create & activate a virtual environment
65
+
66
+ ```powershell
67
+ python -m venv .venv
68
+ .\.venv\Scripts\Activate.ps1
69
+ ```
70
+
71
+ > If activation is blocked by execution policy, run once:
72
+ > `Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned`
73
+
74
+ ### 3. Install dependencies
75
+
76
+ ```powershell
77
+ python -m pip install --upgrade pip
78
+ pip install -r requirements.txt
79
+ ```
80
+
81
+ > 💡 For a **smaller, CPU-only** PyTorch install (recommended for laptops/servers
82
+ > without a GPU):
83
+ > ```powershell
84
+ > pip install -r requirements.txt --extra-index-url https://download.pytorch.org/whl/cpu
85
+ > ```
86
+
87
+ ### 4. Run the web server
88
+
89
+ ```powershell
90
+ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
91
+ ```
92
+
93
+ Now open **http://localhost:8000** in your browser and drag in an image. 🎉
94
+
95
+ ---
96
+
97
+ ## 🖥️ CLI Usage
98
+
99
+ Classify any image directly from the terminal:
100
+
101
+ ```powershell
102
+ python predict.py path\to\cat.jpg
103
+ python predict.py path\to\cat.jpg --top_k 10
104
+ python predict.py path\to\cat.jpg --model efficientnet_b0
105
+ ```
106
+
107
+ ### Sample output
108
+
109
+ ```
110
+ Model : mobilenet_v3_large
111
+ Image : path\to\cat.jpg
112
+
113
+ +------+------------------------------+------------+------------------------+
114
+ | # | Class | Conf. | Confidence |
115
+ +------+------------------------------+------------+------------------------+
116
+ | 1 | tabby | 74.18% | ################...... |
117
+ | 2 | tiger cat | 18.55% | ####.................. |
118
+ | 3 | Egyptian cat | 4.92% | #..................... |
119
+ | 4 | lynx | 0.81% | ...................... |
120
+ | 5 | tiger | 0.22% | ...................... |
121
+ +------+------------------------------+------------+------------------------+
122
+
123
+ Top prediction : tabby (74.18%)
124
+ Inference time : 41.3 ms
125
+ ```
126
+
127
+ ---
128
+
129
+ ## 🔌 API Usage
130
+
131
+ ### `POST /predict`
132
+
133
+ ```powershell
134
+ curl -X POST "http://localhost:8000/predict?top_k=5" -F "file=@cat.jpg"
135
+ ```
136
+
137
+ ```json
138
+ {
139
+ "filename": "cat.jpg",
140
+ "model": "mobilenet_v3_large",
141
+ "device": "cpu",
142
+ "inference_time_ms": 41.3,
143
+ "total_time_ms": 58.7,
144
+ "predictions": [
145
+ { "rank": 1, "label": "tabby", "confidence": 0.7418, "confidence_pct": 74.18 },
146
+ { "rank": 2, "label": "tiger cat", "confidence": 0.1855, "confidence_pct": 18.55 }
147
+ ]
148
+ }
149
+ ```
150
+
151
+ ### `GET /health`
152
+
153
+ ```json
154
+ { "status": "ok", "model": "mobilenet_v3_large", "device": "cpu" }
155
+ ```
156
+
157
+ ### Interactive docs
158
+
159
+ FastAPI auto-generates Swagger UI at **http://localhost:8000/docs**.
160
+
161
+ ---
162
+
163
+ ## 🐳 Docker
164
+
165
+ Build and run locally:
166
+
167
+ ```powershell
168
+ docker build -t image-classifier .
169
+ docker run -p 8000:8000 image-classifier
170
+ ```
171
+
172
+ Then visit **http://localhost:8000**.
173
+
174
+ > The build uses a **multi-stage** approach: dependencies are compiled in a
175
+ > builder stage, and only the slim virtualenv + app are copied into the final
176
+ > `python:3.10-slim` runtime. CPU-only torch wheels keep the image small and
177
+ > free-tier friendly.
178
+
179
+ ---
180
+
181
+ ## ☁️ Production Deployment Guides
182
+
183
+ ### a) Hugging Face Spaces (Docker Space)
184
+
185
+ 1. Create a new Space → **SDK: Docker** → **Blank** template.
186
+ 2. Push this repo to the Space (it ships with a `Dockerfile`).
187
+ ```powershell
188
+ git remote add space https://huggingface.co/spaces/<your-username>/<space-name>
189
+ git push space main
190
+ ```
191
+ 3. **Important:** HF Spaces routes traffic to **port 7860** by default. Either:
192
+ - Add this line to your `Dockerfile` and update the `CMD` port:
193
+ ```dockerfile
194
+ ENV PORT=7860
195
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
196
+ ```
197
+ - **or** add `app_port: 8000` to the Space's `README.md` YAML front-matter:
198
+ ```yaml
199
+ ---
200
+ title: AI Image Classifier
201
+ sdk: docker
202
+ app_port: 8000
203
+ ---
204
+ ```
205
+ 4. The Space builds automatically and serves your UI publicly. ✅
206
+
207
+ ---
208
+
209
+ ### b) Render.com (Web Service via GitHub)
210
+
211
+ 1. Push your repo to GitHub (see Git section below).
212
+ 2. On [render.com](https://render.com): **New → Web Service → Connect your repo**.
213
+ 3. Settings:
214
+ - **Environment:** `Docker`
215
+ - **Region:** closest to you
216
+ - **Instance type:** `Free`
217
+ - Render auto-detects the `Dockerfile` — no build/start command needed.
218
+ 4. Render injects a `$PORT` env var. Make uvicorn respect it by changing the
219
+ `CMD` in your `Dockerfile`:
220
+ ```dockerfile
221
+ CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
222
+ ```
223
+ 5. Click **Create Web Service**. Render builds & deploys on every `git push`. ✅
224
+
225
+ ---
226
+
227
+ ### c) Koyeb / Railway (modern alternatives)
228
+
229
+ #### Koyeb
230
+ 1. [app.koyeb.com](https://app.koyeb.com) → **Create App → GitHub** (or Docker image).
231
+ 2. Builder: **Dockerfile**. Koyeb provides `$PORT` (default `8000`).
232
+ 3. Set the exposed port to **8000** in the *Ports* section, health check path `/health`.
233
+ 4. Deploy — Koyeb gives you a public `*.koyeb.app` URL on the free tier. ✅
234
+
235
+ #### Railway
236
+ 1. [railway.app](https://railway.app) → **New Project → Deploy from GitHub repo**.
237
+ 2. Railway detects the `Dockerfile` automatically.
238
+ 3. Add a public domain under **Settings → Networking → Generate Domain**.
239
+ 4. Railway sets `$PORT` — use the same `${PORT:-8000}` `CMD` shown for Render. ✅
240
+
241
+ > 🔑 **General cloud tip:** Most PaaS providers inject a `$PORT` environment
242
+ > variable. The portable production command is:
243
+ > ```dockerfile
244
+ > CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000}"]
245
+ > ```
246
+
247
+ ---
248
+
249
+ ## 🔗 Git: Initialize & Push to GitHub
250
+
251
+ ```powershell
252
+ # 1. Initialize the repository
253
+ git init
254
+ git add .
255
+ git commit -m "Initial commit: advanced PyTorch image classification pipeline"
256
+
257
+ # 2. Rename default branch to main
258
+ git branch -M main
259
+
260
+ # 3. Create a new EMPTY repo on github.com, then link it:
261
+ git remote add origin https://github.com/<your-username>/image-classification-pipeline.git
262
+
263
+ # 4. Push
264
+ git push -u origin main
265
+ ```
266
+
267
+ ### Subsequent updates
268
+
269
+ ```powershell
270
+ git add .
271
+ git commit -m "Describe your change"
272
+ git push
273
+ ```
274
+
275
+ > 💡 Prefer the GitHub CLI? After `gh auth login`:
276
+ > ```powershell
277
+ > gh repo create image-classification-pipeline --public --source=. --remote=origin --push
278
+ > ```
279
+
280
+ ---
281
+
282
+ ## ⚙️ Configuration (Environment Variables)
283
+
284
+ | Variable | Default | Description |
285
+ |-------------------|-----------------------|-----------------------------------------------|
286
+ | `MODEL_NAME` | `mobilenet_v3_large` | `mobilenet_v3_large` or `efficientnet_b0` |
287
+ | `MAX_UPLOAD_BYTES`| `10485760` (10 MB) | Max accepted upload size |
288
+ | `PORT` | `8000` | Port to bind (when using the portable `CMD`) |
289
+
290
+ ---
291
+
292
+ ## 🧪 Quick Smoke Test
293
+
294
+ ```powershell
295
+ # Terminal 1 — start the server
296
+ uvicorn app.main:app --reload
297
+
298
+ # Terminal 2 — hit the health endpoint
299
+ curl http://localhost:8000/health
300
+ ```
301
+
302
+ ---
303
+
304
+ ## 📜 License
305
+
306
+ MIT — free to use, modify and deploy. Happy classifying! 🚀
app/__init__.py ADDED
File without changes
app/main.py ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Advanced FastAPI image-classification backend.
3
+
4
+ Features
5
+ --------
6
+ * Loads a modern, accurate CNN (MobileNet_V3_Large by default, EfficientNet_B0
7
+ optional) exactly once via an lru_cache-backed singleton.
8
+ * Structured logging of every request, the predicted label and the wall-clock
9
+ inference time.
10
+ * /predict -> JSON: top-k classes, confidence scores, inference time (ms).
11
+ * / -> Modern Tailwind single-page UI with drag-and-drop upload,
12
+ live results and a loading spinner.
13
+ * /health -> Lightweight readiness probe for cloud platforms.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import io
19
+ import logging
20
+ import os
21
+ import time
22
+ from functools import lru_cache
23
+ from typing import List, Tuple
24
+
25
+ import torch
26
+ import torch.nn.functional as F
27
+ from fastapi import FastAPI, File, HTTPException, UploadFile
28
+ from fastapi.responses import HTMLResponse, JSONResponse
29
+ from PIL import Image
30
+ from torchvision import models, transforms
31
+
32
+ # --------------------------------------------------------------------------- #
33
+ # Configuration
34
+ # --------------------------------------------------------------------------- #
35
+ MODEL_NAME = os.getenv("MODEL_NAME", "mobilenet_v3_large").lower() # or efficientnet_b0
36
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
37
+ MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_BYTES", str(10 * 1024 * 1024))) # 10 MB
38
+
39
+ # Keep the CPU + memory footprint small on free-tier hosts (e.g. Render's 512 MB).
40
+ # Single-threaded inference uses noticeably less RAM and avoids CPU overcommit.
41
+ torch.set_num_threads(int(os.getenv("TORCH_NUM_THREADS", "1")))
42
+
43
+ # --------------------------------------------------------------------------- #
44
+ # Logging
45
+ # --------------------------------------------------------------------------- #
46
+ logging.basicConfig(
47
+ level=logging.INFO,
48
+ format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
49
+ datefmt="%Y-%m-%d %H:%M:%S",
50
+ )
51
+ logger = logging.getLogger("image-classifier")
52
+
53
+
54
+ # --------------------------------------------------------------------------- #
55
+ # Model + weights (cached so they load only once)
56
+ # --------------------------------------------------------------------------- #
57
+ @lru_cache(maxsize=1)
58
+ def get_model_bundle() -> Tuple[torch.nn.Module, transforms.Compose, List[str]]:
59
+ """Build, cache and return (model, preprocess, categories).
60
+
61
+ The lru_cache guarantees the heavy weights are downloaded / loaded a single
62
+ time for the lifetime of the process, regardless of how many requests hit
63
+ the endpoint concurrently.
64
+ """
65
+ logger.info("Loading model '%s' on device '%s' ...", MODEL_NAME, DEVICE)
66
+ t0 = time.perf_counter()
67
+
68
+ if MODEL_NAME == "efficientnet_b0":
69
+ weights = models.EfficientNet_B0_Weights.IMAGENET1K_V1
70
+ model = models.efficientnet_b0(weights=weights)
71
+ else:
72
+ # Default: MobileNet_V3_Large — modern, fast and accurate.
73
+ weights = models.MobileNet_V3_Large_Weights.IMAGENET1K_V2
74
+ model = models.mobilenet_v3_large(weights=weights)
75
+
76
+ model.eval().to(DEVICE)
77
+
78
+ preprocess = weights.transforms() # the canonical preprocessing for the weights
79
+ categories = list(weights.meta["categories"])
80
+
81
+ logger.info(
82
+ "Model ready in %.0f ms (%d classes).",
83
+ (time.perf_counter() - t0) * 1000,
84
+ len(categories),
85
+ )
86
+ return model, preprocess, categories
87
+
88
+
89
+ # --------------------------------------------------------------------------- #
90
+ # Inference helpers
91
+ # --------------------------------------------------------------------------- #
92
+ def classify_bytes(raw: bytes, top_k: int = 5) -> Tuple[List[dict], float]:
93
+ """Run inference on raw image bytes; return (results, inference_ms)."""
94
+ model, preprocess, categories = get_model_bundle()
95
+
96
+ try:
97
+ image = Image.open(io.BytesIO(raw)).convert("RGB")
98
+ except Exception as exc: # noqa: BLE001
99
+ raise HTTPException(status_code=400, detail=f"Invalid image file: {exc}") from exc
100
+
101
+ tensor = preprocess(image).unsqueeze(0).to(DEVICE)
102
+
103
+ t0 = time.perf_counter()
104
+ with torch.inference_mode():
105
+ logits = model(tensor)
106
+ probs = F.softmax(logits, dim=1)[0]
107
+ inference_ms = (time.perf_counter() - t0) * 1000
108
+
109
+ top_k = max(1, min(top_k, probs.numel()))
110
+ confidences, indices = torch.topk(probs, top_k)
111
+
112
+ results = [
113
+ {
114
+ "rank": rank,
115
+ "label": categories[idx],
116
+ "confidence": round(float(conf), 6),
117
+ "confidence_pct": round(float(conf) * 100, 2),
118
+ }
119
+ for rank, (conf, idx) in enumerate(zip(confidences.tolist(), indices.tolist()), start=1)
120
+ ]
121
+ return results, inference_ms
122
+
123
+
124
+ # --------------------------------------------------------------------------- #
125
+ # FastAPI app
126
+ # --------------------------------------------------------------------------- #
127
+ app = FastAPI(
128
+ title="Advanced Image Classification API",
129
+ description="Production-ready PyTorch image classifier with a modern web UI.",
130
+ version="1.0.0",
131
+ )
132
+
133
+
134
+ @app.on_event("startup")
135
+ def _warm_up() -> None:
136
+ """Pre-load the model at boot so the first request isn't slow.
137
+
138
+ On very small free-tier hosts you can set WARMUP=0 so the service becomes
139
+ healthy immediately and the model loads lazily on the first request.
140
+ """
141
+ if os.getenv("WARMUP", "1") == "1":
142
+ get_model_bundle()
143
+
144
+
145
+ @app.get("/health")
146
+ def health() -> JSONResponse:
147
+ return JSONResponse({"status": "ok", "model": MODEL_NAME, "device": str(DEVICE)})
148
+
149
+
150
+ @app.post("/predict")
151
+ async def predict(file: UploadFile = File(...), top_k: int = 5) -> JSONResponse:
152
+ """Classify an uploaded image and return the top-k predictions."""
153
+ request_start = time.perf_counter()
154
+
155
+ if file.content_type and not file.content_type.startswith("image/"):
156
+ raise HTTPException(status_code=400, detail="Uploaded file must be an image.")
157
+
158
+ raw = await file.read()
159
+ if not raw:
160
+ raise HTTPException(status_code=400, detail="Empty file.")
161
+ if len(raw) > MAX_UPLOAD_BYTES:
162
+ raise HTTPException(
163
+ status_code=413,
164
+ detail=f"File too large (> {MAX_UPLOAD_BYTES // (1024 * 1024)} MB).",
165
+ )
166
+
167
+ results, inference_ms = classify_bytes(raw, top_k=top_k)
168
+ total_ms = (time.perf_counter() - request_start) * 1000
169
+
170
+ logger.info(
171
+ "predict | file=%s size=%dB top1=%s (%.2f%%) inference=%.1fms total=%.1fms",
172
+ file.filename,
173
+ len(raw),
174
+ results[0]["label"],
175
+ results[0]["confidence_pct"],
176
+ inference_ms,
177
+ total_ms,
178
+ )
179
+
180
+ return JSONResponse(
181
+ {
182
+ "filename": file.filename,
183
+ "model": MODEL_NAME,
184
+ "device": str(DEVICE),
185
+ "inference_time_ms": round(inference_ms, 2),
186
+ "total_time_ms": round(total_ms, 2),
187
+ "predictions": results,
188
+ }
189
+ )
190
+
191
+
192
+ @app.get("/", response_class=HTMLResponse)
193
+ def index() -> HTMLResponse:
194
+ """Serve the single-page drag-and-drop UI."""
195
+ return HTMLResponse(INDEX_HTML)
196
+
197
+
198
+ # --------------------------------------------------------------------------- #
199
+ # Front-end (Tailwind via CDN, vanilla JS — zero build step)
200
+ # --------------------------------------------------------------------------- #
201
+ INDEX_HTML = """<!DOCTYPE html>
202
+ <html lang="en" class="h-full">
203
+ <head>
204
+ <meta charset="UTF-8" />
205
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
206
+ <title>VisionAI — Image Classifier</title>
207
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
208
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
209
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
210
+ <script src="https://cdn.tailwindcss.com"></script>
211
+ <script>
212
+ tailwind.config = {
213
+ darkMode: 'class',
214
+ theme: {
215
+ extend: {
216
+ fontFamily: { sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'] },
217
+ colors: { brand: { 50:'#eef2ff',100:'#e0e7ff',500:'#6366f1',600:'#4f46e5',700:'#4338ca' } },
218
+ },
219
+ },
220
+ };
221
+ </script>
222
+ <script>
223
+ // Apply saved theme BEFORE first paint to avoid a flash of the wrong theme.
224
+ (function () {
225
+ const saved = localStorage.getItem('theme');
226
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
227
+ if (saved === 'dark' || (!saved && prefersDark)) {
228
+ document.documentElement.classList.add('dark');
229
+ }
230
+ })();
231
+ </script>
232
+ <style>
233
+ body { font-family: 'Inter', sans-serif; }
234
+ .spinner { border-top-color: transparent; }
235
+ .fade-in { animation: fadeIn .4s ease both; }
236
+ @keyframes fadeIn { from { opacity:0; transform: translateY(8px); } to { opacity:1; transform:none; } }
237
+ .bar-fill { transition: width .8s cubic-bezier(.22,1,.36,1); }
238
+ ::-webkit-scrollbar { width: 8px; }
239
+ ::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius: 8px; }
240
+ </style>
241
+ </head>
242
+ <body class="h-full bg-slate-50 text-slate-800 antialiased transition-colors duration-300
243
+ dark:bg-slate-950 dark:text-slate-100">
244
+ <!-- Top bar -->
245
+ <header class="sticky top-0 z-10 bg-white/80 backdrop-blur border-b border-slate-200
246
+ dark:bg-slate-900/80 dark:border-slate-800">
247
+ <div class="mx-auto max-w-5xl px-5 h-16 flex items-center justify-between">
248
+ <div class="flex items-center gap-2.5">
249
+ <div class="grid place-items-center h-9 w-9 rounded-xl bg-brand-600 text-white shadow-sm shadow-brand-600/30">
250
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
251
+ <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 19.5h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5z" />
252
+ </svg>
253
+ </div>
254
+ <div>
255
+ <p class="font-bold leading-tight tracking-tight">VisionAI</p>
256
+ <p class="text-[11px] text-slate-400 leading-tight">Image Classification</p>
257
+ </div>
258
+ </div>
259
+ <div class="flex items-center gap-3">
260
+ <span id="statusBadge" class="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
261
+ <span class="h-2 w-2 rounded-full bg-slate-300"></span> connecting…
262
+ </span>
263
+ <button id="themeToggle" type="button" title="Toggle dark mode" aria-label="Toggle dark mode"
264
+ class="grid place-items-center h-9 w-9 rounded-xl border border-slate-200 bg-white text-slate-600
265
+ transition hover:bg-slate-100 active:scale-95
266
+ dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700">
267
+ <!-- sun (shown in dark mode) -->
268
+ <svg id="iconSun" class="hidden h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75">
269
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
270
+ </svg>
271
+ <!-- moon (shown in light mode) -->
272
+ <svg id="iconMoon" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75">
273
+ <path stroke-linecap="round" stroke-linejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
274
+ </svg>
275
+ </button>
276
+ </div>
277
+ </div>
278
+ </header>
279
+
280
+ <main class="mx-auto max-w-5xl px-5 py-10">
281
+ <!-- Hero -->
282
+ <div class="text-center max-w-2xl mx-auto mb-10">
283
+ <h1 class="text-3xl sm:text-4xl font-extrabold tracking-tight text-slate-900 dark:text-white">
284
+ Classify any image in milliseconds
285
+ </h1>
286
+ <p class="mt-3 text-slate-500 dark:text-slate-400">
287
+ Upload a photo and our deep-learning model will identify what it sees, ranked by confidence.
288
+ </p>
289
+ </div>
290
+
291
+ <div class="grid lg:grid-cols-5 gap-6 items-start">
292
+ <!-- Left: uploader -->
293
+ <section class="lg:col-span-2 space-y-4">
294
+ <div id="dropzone"
295
+ class="group relative cursor-pointer rounded-2xl border-2 border-dashed border-slate-300 bg-white
296
+ p-8 text-center shadow-sm transition-all duration-200 hover:border-brand-500 hover:shadow-md
297
+ dark:border-slate-700 dark:bg-slate-900 dark:hover:border-brand-500">
298
+ <input id="fileInput" type="file" accept="image/*" class="hidden" />
299
+ <div class="grid place-items-center h-14 w-14 mx-auto rounded-2xl bg-brand-50 text-brand-600 transition group-hover:scale-105 dark:bg-brand-500/10">
300
+ <svg class="h-7 w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75">
301
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5V18a2.25 2.25 0 002.25 2.25h13.5A2.25 2.25 0 0021 18v-1.5M16.5 12L12 7.5 7.5 12M12 7.5V18" />
302
+ </svg>
303
+ </div>
304
+ <p class="mt-4 font-semibold text-slate-700 dark:text-slate-200">Drop your image here</p>
305
+ <p class="text-sm text-slate-400">or <span class="text-brand-600 dark:text-brand-500 font-medium">browse files</span></p>
306
+ <p class="mt-3 text-[11px] text-slate-400">PNG · JPG · WEBP — up to 10&nbsp;MB</p>
307
+ </div>
308
+
309
+ <!-- Preview -->
310
+ <div id="previewCard" class="hidden fade-in rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900">
311
+ <div class="flex items-center gap-4">
312
+ <div class="relative h-20 w-20 shrink-0">
313
+ <img id="preview" alt="preview" class="h-20 w-20 rounded-xl object-cover ring-1 ring-slate-200" />
314
+ <div id="spinner" class="hidden absolute inset-0 grid place-items-center rounded-xl bg-white/70">
315
+ <div class="spinner h-7 w-7 animate-spin rounded-full border-[3px] border-brand-600"></div>
316
+ </div>
317
+ </div>
318
+ <div class="min-w-0 flex-1">
319
+ <p id="fileName" class="truncate font-medium text-slate-700 dark:text-slate-200"></p>
320
+ <p id="fileMeta" class="text-sm text-slate-400"></p>
321
+ </div>
322
+ <button id="resetBtn" title="Remove"
323
+ class="shrink-0 rounded-lg p-2 text-slate-400 hover:bg-slate-100 hover:text-slate-600 transition dark:hover:bg-slate-800 dark:hover:text-slate-300">
324
+ <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.75">
325
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
326
+ </svg>
327
+ </button>
328
+ </div>
329
+ </div>
330
+
331
+ <div id="error" class="hidden fade-in rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300"></div>
332
+ </section>
333
+
334
+ <!-- Right: results -->
335
+ <section class="lg:col-span-3">
336
+ <div id="resultsCard" class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden dark:border-slate-800 dark:bg-slate-900">
337
+ <div class="flex items-center justify-between border-b border-slate-100 px-5 py-4 dark:border-slate-800">
338
+ <h2 class="font-semibold text-slate-800 dark:text-slate-100">Predictions</h2>
339
+ <span id="timing" class="hidden font-mono text-xs text-slate-400"></span>
340
+ </div>
341
+
342
+ <!-- Empty state -->
343
+ <div id="emptyState" class="px-5 py-16 text-center">
344
+ <div class="grid place-items-center h-12 w-12 mx-auto rounded-full bg-slate-100 text-slate-300 dark:bg-slate-800 dark:text-slate-600">
345
+ <svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
346
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
347
+ </svg>
348
+ </div>
349
+ <p class="mt-3 text-sm text-slate-400">Your results will appear here once you upload an image.</p>
350
+ </div>
351
+
352
+ <!-- Results list -->
353
+ <ul id="predList" class="hidden divide-y divide-slate-100 dark:divide-slate-800"></ul>
354
+
355
+ <div id="resultFooter" class="hidden border-t border-slate-100 px-5 py-4 dark:border-slate-800">
356
+ <button id="againBtn"
357
+ class="w-full rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm
358
+ shadow-brand-600/30 transition hover:bg-brand-700 active:scale-[.99]">
359
+ Classify another image
360
+ </button>
361
+ </div>
362
+ </div>
363
+ </section>
364
+ </div>
365
+
366
+ <p class="mt-10 text-center text-xs text-slate-400">
367
+ Powered by <span class="font-medium text-slate-500">PyTorch</span> &amp;
368
+ <span class="font-medium text-slate-500">FastAPI</span> · runs locally on your machine
369
+ </p>
370
+ </main>
371
+
372
+ <script>
373
+ const $ = (id) => document.getElementById(id);
374
+ const dropzone = $('dropzone'), fileInput = $('fileInput');
375
+ const previewCard = $('previewCard'), preview = $('preview');
376
+ const fileName = $('fileName'), fileMeta = $('fileMeta'), spinner = $('spinner');
377
+ const predList = $('predList'), timing = $('timing');
378
+ const emptyState = $('emptyState'), resultFooter = $('resultFooter');
379
+ const errorBox = $('error'), resetBtn = $('resetBtn'), againBtn = $('againBtn');
380
+ const statusBadge = $('statusBadge');
381
+
382
+ // --- dark / light mode toggle ---------------------------------------- //
383
+ const themeToggle = $('themeToggle'), iconSun = $('iconSun'), iconMoon = $('iconMoon');
384
+
385
+ function syncThemeIcons() {
386
+ const dark = document.documentElement.classList.contains('dark');
387
+ iconSun.classList.toggle('hidden', !dark); // sun visible only in dark mode
388
+ iconMoon.classList.toggle('hidden', dark); // moon visible only in light mode
389
+ }
390
+ syncThemeIcons();
391
+
392
+ themeToggle.addEventListener('click', () => {
393
+ const nowDark = document.documentElement.classList.toggle('dark');
394
+ localStorage.setItem('theme', nowDark ? 'dark' : 'light');
395
+ syncThemeIcons();
396
+ });
397
+
398
+ // --- model status badge ---------------------------------------------- //
399
+ fetch('/health').then(r => r.json()).then(d => {
400
+ statusBadge.innerHTML =
401
+ `<span class="h-2 w-2 rounded-full bg-emerald-500"></span> ${d.model} · ${d.device}`;
402
+ statusBadge.className =
403
+ 'inline-flex items-center gap-1.5 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400';
404
+ }).catch(() => {
405
+ statusBadge.innerHTML = `<span class="h-2 w-2 rounded-full bg-red-500"></span> offline`;
406
+ });
407
+
408
+ // --- events ---------------------------------------------------------- //
409
+ dropzone.addEventListener('click', () => fileInput.click());
410
+ fileInput.addEventListener('change', (e) => { if (e.target.files.length) handleFile(e.target.files[0]); });
411
+ resetBtn.addEventListener('click', resetAll);
412
+ againBtn.addEventListener('click', () => { resetAll(); fileInput.click(); });
413
+
414
+ ['dragenter', 'dragover'].forEach(evt =>
415
+ dropzone.addEventListener(evt, (e) => {
416
+ e.preventDefault();
417
+ dropzone.classList.add('border-brand-500', 'bg-brand-50', 'scale-[1.01]');
418
+ }));
419
+ ['dragleave', 'drop'].forEach(evt =>
420
+ dropzone.addEventListener(evt, (e) => {
421
+ e.preventDefault();
422
+ dropzone.classList.remove('border-brand-500', 'bg-brand-50', 'scale-[1.01]');
423
+ }));
424
+ dropzone.addEventListener('drop', (e) => { if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); });
425
+
426
+ // --- helpers --------------------------------------------------------- //
427
+ function humanSize(b) {
428
+ if (b < 1024) return b + ' B';
429
+ if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
430
+ return (b / 1048576).toFixed(2) + ' MB';
431
+ }
432
+
433
+ function resetAll() {
434
+ previewCard.classList.add('hidden');
435
+ errorBox.classList.add('hidden');
436
+ predList.classList.add('hidden');
437
+ resultFooter.classList.add('hidden');
438
+ timing.classList.add('hidden');
439
+ emptyState.classList.remove('hidden');
440
+ predList.innerHTML = '';
441
+ fileInput.value = '';
442
+ }
443
+
444
+ async function handleFile(file) {
445
+ errorBox.classList.add('hidden');
446
+ predList.classList.add('hidden');
447
+ resultFooter.classList.add('hidden');
448
+ emptyState.classList.remove('hidden');
449
+
450
+ if (!file.type.startsWith('image/')) { showError('Please choose a valid image file.'); return; }
451
+
452
+ preview.src = URL.createObjectURL(file);
453
+ fileName.textContent = file.name;
454
+ fileMeta.textContent = humanSize(file.size) + ' · ' + (file.type || 'image');
455
+ previewCard.classList.remove('hidden');
456
+ spinner.classList.remove('hidden');
457
+
458
+ const form = new FormData();
459
+ form.append('file', file);
460
+
461
+ try {
462
+ const res = await fetch('/predict?top_k=5', { method: 'POST', body: form });
463
+ if (!res.ok) {
464
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
465
+ throw new Error(err.detail || 'Request failed');
466
+ }
467
+ renderResults(await res.json());
468
+ } catch (err) {
469
+ showError(err.message);
470
+ } finally {
471
+ spinner.classList.add('hidden');
472
+ }
473
+ }
474
+
475
+ function renderResults(data) {
476
+ emptyState.classList.add('hidden');
477
+ predList.innerHTML = '';
478
+ timing.textContent = `${data.inference_time_ms} ms`;
479
+ timing.classList.remove('hidden');
480
+
481
+ data.predictions.forEach((p, i) => {
482
+ const top = i === 0;
483
+ const li = document.createElement('li');
484
+ li.className = 'px-5 py-4 fade-in' + (top ? ' bg-brand-50/40 dark:bg-brand-500/10' : '');
485
+ li.style.animationDelay = (i * 60) + 'ms';
486
+ li.innerHTML = `
487
+ <div class="flex items-center justify-between mb-2">
488
+ <div class="flex items-center gap-2.5 min-w-0">
489
+ <span class="grid place-items-center h-6 w-6 shrink-0 rounded-md text-xs font-bold
490
+ ${top ? 'bg-brand-600 text-white' : 'bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400'}">${i + 1}</span>
491
+ <span class="truncate font-medium capitalize ${top ? 'text-slate-900 dark:text-white' : 'text-slate-600 dark:text-slate-300'}">${p.label}</span>
492
+ ${top ? '<span class="shrink-0 rounded-full bg-brand-100 px-2 py-0.5 text-[10px] font-semibold text-brand-700 dark:bg-brand-500/20 dark:text-brand-100">TOP MATCH</span>' : ''}
493
+ </div>
494
+ <span class="shrink-0 font-mono text-sm ${top ? 'text-brand-700 dark:text-brand-100 font-semibold' : 'text-slate-500 dark:text-slate-400'}">${p.confidence_pct.toFixed(2)}%</span>
495
+ </div>
496
+ <div class="h-2 w-full rounded-full bg-slate-100 overflow-hidden dark:bg-slate-800">
497
+ <div class="bar-fill h-full rounded-full ${top ? 'bg-brand-600' : 'bg-slate-300 dark:bg-slate-600'}" style="width:0%"></div>
498
+ </div>`;
499
+ predList.appendChild(li);
500
+ // animate bar after paint
501
+ requestAnimationFrame(() => {
502
+ li.querySelector('.bar-fill').style.width = Math.max(p.confidence_pct, 1.5) + '%';
503
+ });
504
+ });
505
+
506
+ predList.classList.remove('hidden');
507
+ resultFooter.classList.remove('hidden');
508
+ }
509
+
510
+ function showError(msg) {
511
+ errorBox.textContent = msg;
512
+ errorBox.classList.remove('hidden');
513
+ }
514
+ </script>
515
+ </body>
516
+ </html>
517
+ """
518
+
519
+
520
+ if __name__ == "__main__":
521
+ import uvicorn
522
+
523
+ uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
predict.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Advanced command-line image classifier.
4
+
5
+ Usage
6
+ -----
7
+ python predict.py path/to/image.jpg
8
+ python predict.py path/to/image.jpg --top_k 10
9
+ python predict.py path/to/image.jpg --model efficientnet_b0
10
+
11
+ Prints a clean, formatted ASCII table of the top-k predictions.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+ from typing import List, Tuple
21
+
22
+ import torch
23
+ import torch.nn.functional as F
24
+ from PIL import Image
25
+ from torchvision import models, transforms
26
+
27
+
28
+ def load_model(model_name: str) -> Tuple[torch.nn.Module, transforms.Compose, List[str]]:
29
+ """Load a pretrained model with its canonical preprocessing and labels."""
30
+ model_name = model_name.lower()
31
+ if model_name == "efficientnet_b0":
32
+ weights = models.EfficientNet_B0_Weights.IMAGENET1K_V1
33
+ model = models.efficientnet_b0(weights=weights)
34
+ elif model_name == "mobilenet_v3_large":
35
+ weights = models.MobileNet_V3_Large_Weights.IMAGENET1K_V2
36
+ model = models.mobilenet_v3_large(weights=weights)
37
+ else:
38
+ raise ValueError(
39
+ f"Unknown model '{model_name}'. "
40
+ "Choose from: mobilenet_v3_large, efficientnet_b0"
41
+ )
42
+
43
+ model.eval()
44
+ return model, weights.transforms(), list(weights.meta["categories"])
45
+
46
+
47
+ def classify(
48
+ image_path: Path, model_name: str, top_k: int
49
+ ) -> Tuple[List[Tuple[str, float]], float]:
50
+ """Return (list of (label, confidence), inference_ms)."""
51
+ model, preprocess, categories = load_model(model_name)
52
+
53
+ image = Image.open(image_path).convert("RGB")
54
+ tensor = preprocess(image).unsqueeze(0)
55
+
56
+ t0 = time.perf_counter()
57
+ with torch.inference_mode():
58
+ probs = F.softmax(model(tensor), dim=1)[0]
59
+ inference_ms = (time.perf_counter() - t0) * 1000
60
+
61
+ top_k = max(1, min(top_k, probs.numel()))
62
+ confidences, indices = torch.topk(probs, top_k)
63
+ predictions = [
64
+ (categories[idx], float(conf))
65
+ for conf, idx in zip(confidences.tolist(), indices.tolist())
66
+ ]
67
+ return predictions, inference_ms
68
+
69
+
70
+ def render_table(predictions: List[Tuple[str, float]]) -> str:
71
+ """Render predictions as a bordered ASCII table with confidence bars."""
72
+ rank_w, label_w, conf_w, bar_w = 4, 28, 10, 22
73
+
74
+ def row(rank: str, label: str, conf: str, bar: str) -> str:
75
+ return (
76
+ f"| {rank:<{rank_w}} | {label:<{label_w}} | "
77
+ f"{conf:<{conf_w}} | {bar:<{bar_w}} |"
78
+ )
79
+
80
+ border = (
81
+ "+" + "-" * (rank_w + 2) + "+" + "-" * (label_w + 2)
82
+ + "+" + "-" * (conf_w + 2) + "+" + "-" * (bar_w + 2) + "+"
83
+ )
84
+
85
+ lines = [border, row("#", "Class", "Conf.", "Confidence"), border]
86
+ for i, (label, conf) in enumerate(predictions, start=1):
87
+ pct = conf * 100
88
+ filled = int(round((bar_w) * conf))
89
+ bar = "#" * filled + "." * (bar_w - filled)
90
+ lines.append(row(str(i), label[:label_w], f"{pct:6.2f}%", bar))
91
+ lines.append(border)
92
+ return "\n".join(lines)
93
+
94
+
95
+ def main(argv: List[str] | None = None) -> int:
96
+ parser = argparse.ArgumentParser(
97
+ description="Classify an image from the command line using a pretrained CNN.",
98
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
99
+ )
100
+ parser.add_argument("image", type=Path, help="Path to the image file.")
101
+ parser.add_argument(
102
+ "--top_k", type=int, default=5, help="Number of top predictions to display."
103
+ )
104
+ parser.add_argument(
105
+ "--model",
106
+ default="mobilenet_v3_large",
107
+ choices=["mobilenet_v3_large", "efficientnet_b0"],
108
+ help="Model architecture to use.",
109
+ )
110
+ args = parser.parse_args(argv)
111
+
112
+ if not args.image.exists():
113
+ print(f"Error: file not found -> {args.image}", file=sys.stderr)
114
+ return 1
115
+
116
+ print(f"\n Model : {args.model}")
117
+ print(f" Image : {args.image}\n")
118
+
119
+ try:
120
+ predictions, inference_ms = classify(args.image, args.model, args.top_k)
121
+ except Exception as exc: # noqa: BLE001
122
+ print(f"Error during inference: {exc}", file=sys.stderr)
123
+ return 1
124
+
125
+ print(render_table(predictions))
126
+ print(f"\n Top prediction : {predictions[0][0]} ({predictions[0][1] * 100:.2f}%)")
127
+ print(f" Inference time : {inference_ms:.1f} ms\n")
128
+ return 0
129
+
130
+
131
+ if __name__ == "__main__":
132
+ raise SystemExit(main())
render.yaml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Render Blueprint — https://render.com/docs/blueprint-spec
2
+ # Deploy: Render Dashboard -> New + -> Blueprint -> pick this repo.
3
+ services:
4
+ - type: web
5
+ name: image-classification-pipeline
6
+ runtime: docker
7
+ plan: free # free tier (512 MB RAM)
8
+ dockerfilePath: ./Dockerfile
9
+ healthCheckPath: /health
10
+ autoDeploy: true # redeploy automatically on every git push
11
+ envVars:
12
+ - key: MODEL_NAME
13
+ value: mobilenet_v3_large
14
+ # --- memory tuning for the 512 MB free tier ---
15
+ - key: TORCH_NUM_THREADS # single-threaded inference = less RAM
16
+ value: "1"
17
+ - key: OMP_NUM_THREADS
18
+ value: "1"
19
+ - key: MKL_NUM_THREADS
20
+ value: "1"
21
+ - key: WARMUP # 0 = lazy-load model on first request (service goes live faster)
22
+ value: "0"
23
+ # Render automatically provides $PORT — the Dockerfile already binds to it.
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # NumPy must stay <2: torch 2.3.x is compiled against the NumPy 1.x ABI and
2
+ # crashes at inference time ("Could not infer dtype of numpy.uint8") with NumPy 2.
3
+ numpy<2
4
+ fastapi==0.111.0
5
+ uvicorn[standard]==0.30.1
6
+ torch==2.3.1
7
+ torchvision==0.18.1
8
+ pillow==10.3.0
9
+ python-multipart==0.0.9
10
+ jinja2==3.1.4