Prakhar54-byte commited on
Commit
b2e9edd
Β·
verified Β·
1 Parent(s): 50d6286

Deploy build-ae01367

Browse files
.github/workflows/deploy.yml CHANGED
@@ -1,4 +1,4 @@
1
- name: CI / Lint + Deploy to Hugging Face Spaces
2
 
3
  on:
4
  push:
@@ -133,3 +133,22 @@ jobs:
133
  )
134
  print(f"Model artifacts uploaded β†’ https://huggingface.co/models/{model_repo}")
135
  PY
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI / Lint + Deploy
2
 
3
  on:
4
  push:
 
133
  )
134
  print(f"Model artifacts uploaded β†’ https://huggingface.co/models/{model_repo}")
135
  PY
136
+
137
+ # ─────────────────────────────────────────────────────────────────────────────
138
+ # 3. Deploy β€” push backend+frontend to Railway (on main branch only)
139
+ # ─────────────────────────────────────────────────────────────────────────────
140
+ deploy-railway:
141
+ name: Deploy to Railway
142
+ if: (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') && github.event_name == 'push'
143
+ needs: ci
144
+ runs-on: ubuntu-latest
145
+ steps:
146
+ - uses: actions/checkout@v4
147
+
148
+ - name: Install Railway CLI
149
+ run: npm install -g @railway/cli
150
+
151
+ - name: Deploy to Railway
152
+ env:
153
+ RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
154
+ run: railway up --service pneumoops-backend --detach
.gitignore CHANGED
@@ -51,6 +51,10 @@ models/chestmnist_mobilenetv3/*.onnx
51
  models/chestmnist_mobilenetv3/*.npz
52
  models/chestmnist_mobilenetv3/plots/
53
 
54
- # Keep directory structure in Git
 
 
55
  !models/.gitkeep
56
  !data/.gitkeep
 
 
 
51
  models/chestmnist_mobilenetv3/*.npz
52
  models/chestmnist_mobilenetv3/plots/
53
 
54
+ # Keep directory structure and metadata JSON files in Git.
55
+ # The JSON files (training_metrics.json, baseline_stats.json, onnx_export_report.json)
56
+ # are needed by the Railway proxy backend to resolve class names and thresholds at startup.
57
  !models/.gitkeep
58
  !data/.gitkeep
59
+ !models/chestmnist_mobilenetv3/*.json
60
+ !models/chestmnist_mobilenetv3/README.md
Dockerfile.railway ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get upgrade -y && \
6
+ apt-get install -y --no-install-recommends curl && \
7
+ rm -rf /var/lib/apt/lists/*
8
+
9
+ RUN groupadd -r pneumoops && useradd -r -g pneumoops appuser
10
+
11
+ COPY requirements-railway.txt .
12
+ RUN pip install --no-cache-dir --upgrade pip && \
13
+ pip install --no-cache-dir -r requirements-railway.txt
14
+
15
+ COPY --chown=appuser:pneumoops backend ./backend
16
+ COPY --chown=appuser:pneumoops frontend ./frontend
17
+ COPY --chown=appuser:pneumoops models ./models
18
+ COPY --chown=appuser:pneumoops data ./data
19
+ COPY --chown=appuser:pneumoops model_utils.py ./model_utils.py
20
+ COPY --chown=appuser:pneumoops README.md ./README.md
21
+
22
+ ENV PYTHONPATH=/app
23
+ ENV PORT=7860
24
+ ENV MPLCONFIGDIR=/tmp/matplotlib
25
+ ENV PNEUMOOPS_PROFILE=chestmnist
26
+
27
+ EXPOSE 7860
28
+
29
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
30
+ CMD curl -f http://127.0.0.1:7860/health || exit 1
31
+
32
+ USER appuser
33
+
34
+ CMD ["python", "-m", "uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "7860"]
backend/main.py CHANGED
@@ -13,19 +13,25 @@ from pathlib import Path
13
  from typing import Any
14
 
15
  import gradio as gr
 
16
  import numpy as np
17
- import onnxruntime as ort
18
- import torch
19
  from fastapi import BackgroundTasks, FastAPI, File, HTTPException, Request, Response, UploadFile
20
  from fastapi.middleware.cors import CORSMiddleware
21
  from fastapi.responses import PlainTextResponse
22
  from PIL import Image
23
  from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
24
  from scipy.stats import ks_2samp
25
- from torchvision import transforms
26
- from torchvision.models import mobilenet_v3_small, efficientnet_b0
27
 
28
- from model_utils import CalibratedModel
 
 
 
 
 
 
 
 
 
29
 
30
 
31
  BASE_DIR = Path(__file__).resolve().parents[1]
@@ -44,6 +50,9 @@ REQUEST_LOG_HISTORY = deque(maxlen=20)
44
  API_KEY = os.getenv("PNEUMOOPS_API_KEY")
45
  ALLOWED_ORIGINS = [origin.strip() for origin in os.getenv("PNEUMOOPS_ALLOWED_ORIGINS", "*").split(",") if origin.strip()]
46
  TRAFFIC_WEIGHTS = {"pytorch": 60, "onnx": 40}
 
 
 
47
  COLLECT_DATA = os.getenv("PNEUMOOPS_COLLECT_DATA", "true").lower() == "true"
48
  COLLECT_DIR = BASE_DIR / "data" / "collected_images"
49
  LOW_CONFIDENCE_THRESHOLD = float(os.getenv("PNEUMOOPS_LOW_CONFIDENCE_THRESHOLD", "0.60"))
@@ -147,6 +156,23 @@ RUNTIME_PATHS = resolve_runtime_paths(MODEL_DIR)
147
 
148
  def load_checkpoint_metadata(checkpoint_path: Path | None) -> dict[str, Any]:
149
  if checkpoint_path is None or not checkpoint_path.exists():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  return {
151
  "architecture": "mobilenet_v3_small",
152
  "class_names": ["Normal", "Pneumonia"],
@@ -198,16 +224,20 @@ IMAGE_SIZE = int(MODEL_METADATA["image_size"])
198
  THRESHOLDS = np.asarray(MODEL_METADATA["thresholds"], dtype=np.float32)
199
  LOGIT_TEMPERATURE = float(MODEL_METADATA.get("logit_temperature", 1.0))
200
 
201
- TRANSFORM = transforms.Compose(
202
- [
203
- transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),
204
- transforms.Grayscale(num_output_channels=3),
205
- transforms.ToTensor(),
206
- transforms.Normalize(
207
- mean=MODEL_METADATA["normalize_mean"],
208
- std=MODEL_METADATA["normalize_std"],
209
- ),
210
- ]
 
 
 
 
211
  )
212
 
213
  BASELINE_STATS = load_json(
@@ -227,7 +257,7 @@ BASELINE_STATS = load_json(
227
  )
228
 
229
 
230
- def build_model() -> torch.nn.Module | None:
231
  checkpoint_path = RUNTIME_PATHS["checkpoint"]
232
  if checkpoint_path is None or not checkpoint_path.exists():
233
  return None
@@ -256,9 +286,9 @@ def build_onnx_session():
256
  return session, onnx_path.name
257
 
258
 
259
- DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
260
- PYTORCH_MODEL = build_model()
261
- ONNX_SESSION, ACTIVE_ONNX_MODEL_NAME = build_onnx_session()
262
 
263
  app = FastAPI(
264
  title="PneumoOps API",
@@ -438,7 +468,8 @@ def run_onnx_inference(image: Image.Image) -> dict[str, Any]:
438
  }
439
 
440
 
441
- async def benchmark_both_models(image: Image.Image) -> dict[str, Any]:
 
442
  async def safe_call(model_name: str, fn):
443
  try:
444
  result = await asyncio.to_thread(fn, image)
@@ -454,6 +485,45 @@ async def benchmark_both_models(image: Image.Image) -> dict[str, Any]:
454
  return {"pytorch": pytorch_result, "onnx": onnx_result}
455
 
456
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  def build_recommendation(selected_result: dict[str, Any], drift_result: dict[str, Any], dual_results: dict[str, Any]) -> str:
458
  if drift_result["drift_alert"] == "DRIFT_DETECTED":
459
  return "Input distribution differs from the stored training baseline. Manual review is recommended before trusting this result."
@@ -499,6 +569,8 @@ def save_prediction_data(image: Image.Image, payload: dict[str, Any]) -> None:
499
  def health():
500
  return {
501
  "status": "ok",
 
 
502
  "profile": PROFILE,
503
  "model_dir": str(MODEL_DIR),
504
  "pytorch_model_loaded": PYTORCH_MODEL is not None,
@@ -672,6 +744,35 @@ async def predict(request: Request, background_tasks: BackgroundTasks, file: Upl
672
  return response_payload
673
 
674
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
675
  # ─── Mount Gradio UI into FastAPI (single-port for HF Spaces) ───────────────
676
  # This allows the entire app (API + UI) to run on one port (7860).
677
  # - FastAPI REST endpoints remain at /predict, /health, /metrics, etc.
 
13
  from typing import Any
14
 
15
  import gradio as gr
16
+ import httpx
17
  import numpy as np
 
 
18
  from fastapi import BackgroundTasks, FastAPI, File, HTTPException, Request, Response, UploadFile
19
  from fastapi.middleware.cors import CORSMiddleware
20
  from fastapi.responses import PlainTextResponse
21
  from PIL import Image
22
  from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
23
  from scipy.stats import ks_2samp
 
 
24
 
25
+ # Heavy ML deps are optional β€” not installed on the Railway proxy node.
26
+ try:
27
+ import torch
28
+ import onnxruntime as ort
29
+ from torchvision import transforms
30
+ from torchvision.models import mobilenet_v3_small, efficientnet_b0
31
+ from model_utils import CalibratedModel
32
+ _TORCH_AVAILABLE = True
33
+ except ImportError:
34
+ _TORCH_AVAILABLE = False
35
 
36
 
37
  BASE_DIR = Path(__file__).resolve().parents[1]
 
50
  API_KEY = os.getenv("PNEUMOOPS_API_KEY")
51
  ALLOWED_ORIGINS = [origin.strip() for origin in os.getenv("PNEUMOOPS_ALLOWED_ORIGINS", "*").split(",") if origin.strip()]
52
  TRAFFIC_WEIGHTS = {"pytorch": 60, "onnx": 40}
53
+ # When set, all model inference is forwarded to this HF Spaces URL instead of local models.
54
+ # Example: https://prakhar54-byte-pneumoops.hf.space
55
+ HF_SPACES_URL = os.getenv("HF_SPACES_URL", "").rstrip("/")
56
  COLLECT_DATA = os.getenv("PNEUMOOPS_COLLECT_DATA", "true").lower() == "true"
57
  COLLECT_DIR = BASE_DIR / "data" / "collected_images"
58
  LOW_CONFIDENCE_THRESHOLD = float(os.getenv("PNEUMOOPS_LOW_CONFIDENCE_THRESHOLD", "0.60"))
 
156
 
157
  def load_checkpoint_metadata(checkpoint_path: Path | None) -> dict[str, Any]:
158
  if checkpoint_path is None or not checkpoint_path.exists():
159
+ # Proxy/backend-only deployments have no model weights but do have
160
+ # training_metrics.json β€” use it so class names and thresholds are correct.
161
+ tm_path = MODEL_DIR / "training_metrics.json"
162
+ tm = load_json(tm_path)
163
+ if tm.get("class_names"):
164
+ class_names = tm["class_names"]
165
+ n = len(class_names)
166
+ return {
167
+ "architecture": tm.get("architecture", "mobilenet_v3_small"),
168
+ "class_names": class_names,
169
+ "image_size": int(tm.get("image_size", 224)),
170
+ "normalize_mean": [0.5, 0.5, 0.5],
171
+ "normalize_std": [0.5, 0.5, 0.5],
172
+ "thresholds": tm.get("thresholds", [0.5] * n),
173
+ "logit_temperature": 1.0,
174
+ "multi_label": bool(tm.get("multi_label", True)),
175
+ }
176
  return {
177
  "architecture": "mobilenet_v3_small",
178
  "class_names": ["Normal", "Pneumonia"],
 
224
  THRESHOLDS = np.asarray(MODEL_METADATA["thresholds"], dtype=np.float32)
225
  LOGIT_TEMPERATURE = float(MODEL_METADATA.get("logit_temperature", 1.0))
226
 
227
+ TRANSFORM = (
228
+ transforms.Compose(
229
+ [
230
+ transforms.Resize((IMAGE_SIZE, IMAGE_SIZE)),
231
+ transforms.Grayscale(num_output_channels=3),
232
+ transforms.ToTensor(),
233
+ transforms.Normalize(
234
+ mean=MODEL_METADATA["normalize_mean"],
235
+ std=MODEL_METADATA["normalize_std"],
236
+ ),
237
+ ]
238
+ )
239
+ if _TORCH_AVAILABLE
240
+ else None
241
  )
242
 
243
  BASELINE_STATS = load_json(
 
257
  )
258
 
259
 
260
+ def build_model() -> Any:
261
  checkpoint_path = RUNTIME_PATHS["checkpoint"]
262
  if checkpoint_path is None or not checkpoint_path.exists():
263
  return None
 
286
  return session, onnx_path.name
287
 
288
 
289
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") if _TORCH_AVAILABLE else None
290
+ PYTORCH_MODEL = build_model() if _TORCH_AVAILABLE else None
291
+ ONNX_SESSION, ACTIVE_ONNX_MODEL_NAME = build_onnx_session() if _TORCH_AVAILABLE else (None, None)
292
 
293
  app = FastAPI(
294
  title="PneumoOps API",
 
468
  }
469
 
470
 
471
+ async def _run_local_inference(image: Image.Image) -> dict[str, Any]:
472
+ """Run both models in-process β€” used when model weights are available locally."""
473
  async def safe_call(model_name: str, fn):
474
  try:
475
  result = await asyncio.to_thread(fn, image)
 
485
  return {"pytorch": pytorch_result, "onnx": onnx_result}
486
 
487
 
488
+ async def _call_hf_infer(image: Image.Image) -> dict[str, Any]:
489
+ """Forward dual-model inference to the HF Spaces node via /infer."""
490
+ buf = io.BytesIO()
491
+ image.save(buf, format="PNG")
492
+ buf.seek(0)
493
+ async with httpx.AsyncClient(timeout=90.0) as client:
494
+ resp = await client.post(
495
+ f"{HF_SPACES_URL}/infer",
496
+ files={"file": ("xray.png", buf.read(), "image/png")},
497
+ )
498
+ resp.raise_for_status()
499
+ data = resp.json()
500
+
501
+ result: dict[str, Any] = {}
502
+ for key in ("pytorch", "onnx"):
503
+ raw = data.get(key) or {}
504
+ if "error" in raw:
505
+ result[key] = {"model_key": key, "error": raw["error"]}
506
+ else:
507
+ probs = np.asarray(raw.get("probabilities", []), dtype=np.float32)
508
+ latency = float(raw.get("latency_ms") or 0.0)
509
+ LATENCY_HISTOGRAM.labels(model=key).observe(latency)
510
+ result[key] = {
511
+ "model_key": key,
512
+ "model_used": "Baseline PyTorch" if key == "pytorch" else "Optimized ONNX",
513
+ "latency_ms": latency,
514
+ "probabilities": probs.tolist(),
515
+ **postprocess_probabilities(probs),
516
+ }
517
+ return result
518
+
519
+
520
+ async def benchmark_both_models(image: Image.Image) -> dict[str, Any]:
521
+ """Route dual-model inference to HF Spaces (proxy mode) or local models."""
522
+ if HF_SPACES_URL:
523
+ return await _call_hf_infer(image)
524
+ return await _run_local_inference(image)
525
+
526
+
527
  def build_recommendation(selected_result: dict[str, Any], drift_result: dict[str, Any], dual_results: dict[str, Any]) -> str:
528
  if drift_result["drift_alert"] == "DRIFT_DETECTED":
529
  return "Input distribution differs from the stored training baseline. Manual review is recommended before trusting this result."
 
569
  def health():
570
  return {
571
  "status": "ok",
572
+ "deployment_mode": "proxy" if HF_SPACES_URL else "local",
573
+ "hf_spaces_url": HF_SPACES_URL or None,
574
  "profile": PROFILE,
575
  "model_dir": str(MODEL_DIR),
576
  "pytorch_model_loaded": PYTORCH_MODEL is not None,
 
744
  return response_payload
745
 
746
 
747
+ @app.post("/infer")
748
+ async def infer_raw(file: UploadFile = File(...)):
749
+ """Raw dual-model inference for remote backend nodes.
750
+ Returns probabilities from both PyTorch and ONNX arms with no side-effects
751
+ (no metrics, no drift, no history). Only available on the HF Spaces inference node
752
+ (i.e. when HF_SPACES_URL is not set)."""
753
+ if HF_SPACES_URL:
754
+ raise HTTPException(
755
+ status_code=501,
756
+ detail="This node is a proxy backend; /infer is only served by the inference node.",
757
+ )
758
+ image = load_image_from_upload(file)
759
+ dual = await _run_local_inference(image)
760
+ out: dict[str, Any] = {}
761
+ for key in ("pytorch", "onnx"):
762
+ r = dual.get(key, {})
763
+ out[key] = (
764
+ {"probabilities": r.get("probabilities", []), "latency_ms": r.get("latency_ms")}
765
+ if "error" not in r
766
+ else {"error": r["error"]}
767
+ )
768
+ return {
769
+ **out,
770
+ "class_names": CLASS_NAMES,
771
+ "thresholds": THRESHOLDS.tolist(),
772
+ "multi_label": MULTI_LABEL,
773
+ }
774
+
775
+
776
  # ─── Mount Gradio UI into FastAPI (single-port for HF Spaces) ───────────────
777
  # This allows the entire app (API + UI) to run on one port (7860).
778
  # - FastAPI REST endpoints remain at /predict, /health, /metrics, etc.
railway.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "$schema": "https://railway.com/railway.schema.json",
3
+ "build": {
4
+ "dockerfilePath": "Dockerfile.railway"
5
+ }
6
+ }
requirements-railway.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.12
2
+ gradio==5.25.2
3
+ huggingface_hub==0.31.2
4
+ matplotlib==3.10.1
5
+ numpy==1.26.4
6
+ Pillow==11.1.0
7
+ prometheus-client==0.21.1
8
+ python-multipart==0.0.20
9
+ requests==2.32.3
10
+ scipy==1.15.2
11
+ uvicorn[standard]==0.34.1
12
+ httpx==0.28.1