Prakhar54-byte commited on
Commit
09151f6
·
verified ·
1 Parent(s): 247882a

Deploy build-511cae9

Browse files
Files changed (1) hide show
  1. backend/main.py +200 -62
backend/main.py CHANGED
@@ -16,7 +16,15 @@ from typing import Any
16
  import gradio as gr
17
  import httpx
18
  import numpy as np
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
@@ -32,6 +40,7 @@ try:
32
  from torchvision import transforms
33
  from torchvision.models import mobilenet_v3_small, efficientnet_b0
34
  from model_utils import CalibratedModel
 
35
  _TORCH_AVAILABLE = True
36
  except ImportError:
37
  _TORCH_AVAILABLE = False
@@ -51,14 +60,20 @@ MODEL_DIR = Path(os.getenv("PNEUMOOPS_MODEL_DIR", str(_DEFAULT_MODEL_DIR)))
51
  REQUEST_LOG_HISTORY = deque(maxlen=20)
52
 
53
  API_KEY = os.getenv("PNEUMOOPS_API_KEY")
54
- ALLOWED_ORIGINS = [origin.strip() for origin in os.getenv("PNEUMOOPS_ALLOWED_ORIGINS", "*").split(",") if origin.strip()]
 
 
 
 
55
  TRAFFIC_WEIGHTS = {"pytorch": 60, "onnx": 40}
56
  # When set, all model inference is forwarded to this HF Spaces URL instead of local models.
57
  # Example: https://prakhar54-byte-pneumoops.hf.space
58
  HF_SPACES_URL = os.getenv("HF_SPACES_URL", "").rstrip("/")
59
  COLLECT_DATA = os.getenv("PNEUMOOPS_COLLECT_DATA", "true").lower() == "true"
60
  COLLECT_DIR = BASE_DIR / "data" / "collected_images"
61
- LOW_CONFIDENCE_THRESHOLD = float(os.getenv("PNEUMOOPS_LOW_CONFIDENCE_THRESHOLD", "0.60"))
 
 
62
  MIN_UPLOAD_EDGE = int(os.getenv("PNEUMOOPS_MIN_UPLOAD_EDGE", "96"))
63
  MAX_CHANNEL_DELTA = float(os.getenv("PNEUMOOPS_MAX_CHANNEL_DELTA", "0.08"))
64
  MIN_ASPECT_RATIO = float(os.getenv("PNEUMOOPS_MIN_ASPECT_RATIO", "0.6"))
@@ -112,12 +127,17 @@ def load_json(path: Path | None, fallback: dict | None = None) -> dict:
112
  def resolve_runtime_paths(model_dir: Path) -> dict[str, Path | None]:
113
  checkpoint_path = resolve_path(
114
  [
115
- model_dir / "mobilenetv3_chestmnist.pth", # chestmnist profile
116
  model_dir / "realworld_efficientnet_b0.pth",
117
  model_dir / "pneumo_model.pth",
118
  ]
119
  )
120
- onnx_report_path = resolve_path([model_dir / "onnx_export_report.json", LEGACY_MODEL_DIR / "onnx_export_report.json"])
 
 
 
 
 
121
  onnx_report = load_json(onnx_report_path)
122
 
123
  base_onnx = onnx_report.get("base_onnx")
@@ -148,8 +168,18 @@ def resolve_runtime_paths(model_dir: Path) -> dict[str, Path | None]:
148
  return {
149
  "checkpoint": checkpoint_path,
150
  "onnx": resolve_path(onnx_candidates),
151
- "training_metrics": resolve_path([model_dir / "training_metrics.json", LEGACY_MODEL_DIR / "training_metrics.json"]),
152
- "baseline_stats": resolve_path([model_dir / "baseline_stats.json", LEGACY_MODEL_DIR / "baseline_stats.json"]),
 
 
 
 
 
 
 
 
 
 
153
  "onnx_export_report": onnx_report_path,
154
  }
155
 
@@ -220,6 +250,23 @@ def load_checkpoint_metadata(checkpoint_path: Path | None) -> dict[str, Any]:
220
  "multi_label": False,
221
  }
222
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  payload = torch.load(checkpoint_path, map_location="cpu")
224
  if isinstance(payload, dict) and "model_state_dict" in payload:
225
  return {
@@ -229,7 +276,9 @@ def load_checkpoint_metadata(checkpoint_path: Path | None) -> dict[str, Any]:
229
  "image_size": int(payload.get("image_size", 320)),
230
  "normalize_mean": payload.get("normalize_mean", [0.485, 0.456, 0.406]),
231
  "normalize_std": payload.get("normalize_std", [0.229, 0.224, 0.225]),
232
- "thresholds": payload.get("thresholds", [0.5] * len(payload.get("class_names", ["No Finding"]))),
 
 
233
  "logit_temperature": float(payload.get("logit_temperature", 1.0)),
234
  "multi_label": bool(payload.get("multi_label", True)),
235
  }
@@ -252,7 +301,6 @@ def load_checkpoint_metadata(checkpoint_path: Path | None) -> dict[str, Any]:
252
  }
253
 
254
 
255
-
256
  MODEL_METADATA = load_checkpoint_metadata(RUNTIME_PATHS["checkpoint"])
257
  CLASS_NAMES = MODEL_METADATA["class_names"]
258
  MULTI_LABEL = bool(MODEL_METADATA["multi_label"])
@@ -302,10 +350,14 @@ def build_model() -> Any:
302
  num_outputs = len(CLASS_NAMES)
303
  if architecture == "efficientnet_b0":
304
  base_model = efficientnet_b0(weights=None)
305
- base_model.classifier[1] = torch.nn.Linear(base_model.classifier[1].in_features, num_outputs)
 
 
306
  else:
307
  base_model = mobilenet_v3_small(weights=None)
308
- base_model.classifier[3] = torch.nn.Linear(base_model.classifier[3].in_features, num_outputs)
 
 
309
 
310
  base_model.load_state_dict(MODEL_METADATA["state_dict"])
311
  model = CalibratedModel(base_model, LOGIT_TEMPERATURE)
@@ -322,9 +374,15 @@ def build_onnx_session():
322
  return session, onnx_path.name
323
 
324
 
325
- DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu") if _TORCH_AVAILABLE else None
 
 
 
 
326
  PYTORCH_MODEL = build_model() if _TORCH_AVAILABLE else None
327
- ONNX_SESSION, ACTIVE_ONNX_MODEL_NAME = build_onnx_session() if _TORCH_AVAILABLE else (None, None)
 
 
328
 
329
  app = FastAPI(
330
  title="PneumoOps API",
@@ -353,18 +411,23 @@ def load_image_from_upload(upload: UploadFile) -> Image.Image:
353
  content = upload.file.read()
354
  return Image.open(io.BytesIO(content)).convert("RGB")
355
  except Exception as exc:
356
- raise HTTPException(status_code=400, detail="Uploaded file is not a valid image.") from exc
 
 
357
 
358
 
359
  def summarize_image(image: Image.Image) -> dict[str, Any]:
360
  gray = np.asarray(image.convert("L"), dtype=np.float32) / 255.0
361
  width, height = image.size
362
  rgb = np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0
363
- channel_delta = float(
364
- np.mean(np.abs(rgb[:, :, 0] - rgb[:, :, 1]))
365
- + np.mean(np.abs(rgb[:, :, 1] - rgb[:, :, 2]))
366
- + np.mean(np.abs(rgb[:, :, 0] - rgb[:, :, 2]))
367
- ) / 3.0
 
 
 
368
  return {
369
  "width": width,
370
  "height": height,
@@ -401,27 +464,42 @@ def calculate_drift(image: Image.Image) -> dict[str, Any]:
401
  image_std = float(np.std(gray))
402
  hist_bins = int(BASELINE_STATS.get("histogram_bins", 32))
403
  hist, _ = np.histogram(gray, bins=hist_bins, range=(0.0, 1.0), density=True)
404
- baseline_hist = np.asarray(BASELINE_STATS.get("histogram_mean", [1.0 / hist_bins] * hist_bins), dtype=np.float32)
 
 
 
405
 
406
- mean_z = abs(image_mean - BASELINE_STATS.get("pixel_mean_mean", 0.5)) / max(BASELINE_STATS.get("pixel_mean_std", 0.1), 1e-6)
407
- std_z = abs(image_std - BASELINE_STATS.get("pixel_std_mean", 0.2)) / max(BASELINE_STATS.get("pixel_std_std", 0.05), 1e-6)
 
 
 
 
408
  hist_distance = float(np.mean(np.abs(hist - baseline_hist)))
409
  drift_score = round(0.35 * mean_z + 0.35 * std_z + 0.30 * hist_distance, 6)
410
 
411
- reference_sample = np.asarray(BASELINE_STATS.get("pixel_reference_sample", []), dtype=np.float32)
 
 
412
  incoming_sample = gray.reshape(-1)
413
  if reference_sample.size > 0:
414
  sample_size = min(len(incoming_sample), len(reference_sample), 4096)
415
- incoming_idx = np.random.choice(len(incoming_sample), size=sample_size, replace=False)
416
- reference_idx = np.random.choice(len(reference_sample), size=sample_size, replace=False)
417
- _, ks_pvalue = ks_2samp(incoming_sample[incoming_idx], reference_sample[reference_idx])
 
 
 
 
 
 
418
  ks_pvalue = float(ks_pvalue)
419
  else:
420
  ks_pvalue = 1.0
421
 
422
- drift_detected = ks_pvalue < float(BASELINE_STATS.get("drift_ks_pvalue_threshold", 0.05)) or drift_score > float(
423
- BASELINE_STATS.get("drift_threshold", 1.2)
424
- )
425
  return {
426
  "drift_alert": "DRIFT_DETECTED" if drift_detected else "NORMAL",
427
  "drift_score": drift_score,
@@ -435,7 +513,11 @@ def calculate_drift(image: Image.Image) -> dict[str, Any]:
435
  def postprocess_probabilities(probabilities: np.ndarray) -> dict[str, Any]:
436
  probabilities = probabilities.astype(np.float32)
437
  if MULTI_LABEL:
438
- predicted_indices = [index for index, value in enumerate(probabilities) if value >= THRESHOLDS[index]]
 
 
 
 
439
  if not predicted_indices:
440
  predicted_indices = [int(np.argmax(probabilities))]
441
  predicted_labels = [CLASS_NAMES[index] for index in predicted_indices]
@@ -448,12 +530,16 @@ def postprocess_probabilities(probabilities: np.ndarray) -> dict[str, Any]:
448
  {
449
  "label": CLASS_NAMES[index],
450
  "confidence": round(float(probabilities[index]) * 100, 2),
451
- "threshold": round(float(THRESHOLDS[index]) * 100, 2) if index < len(THRESHOLDS) else 50.0,
452
- "detected": index in predicted_indices
 
 
453
  }
454
  for index in range(len(CLASS_NAMES))
455
  ]
456
- sorted_pairs = sorted(all_predictions, key=lambda item: item["confidence"], reverse=True)
 
 
457
  top_confidence = sorted_pairs[0]["confidence"] if sorted_pairs else 0.0
458
 
459
  return {
@@ -465,26 +551,33 @@ def postprocess_probabilities(probabilities: np.ndarray) -> dict[str, Any]:
465
  "inconclusive_scan": top_confidence < 10.0,
466
  }
467
 
 
468
  class CAMHook:
469
  def __init__(self, module):
470
  self.hook_f = module.register_forward_hook(self.hook_fn_fwd)
471
  self.hook_b = module.register_full_backward_hook(self.hook_fn_bwd)
472
  self.features = None
473
  self.gradients = None
 
474
  def hook_fn_fwd(self, module, input, output):
475
  self.features = output
 
476
  def hook_fn_bwd(self, module, grad_in, grad_out):
477
  self.gradients = grad_out[0]
 
478
  def remove(self):
479
  self.hook_f.remove()
480
  self.hook_b.remove()
481
 
 
482
  def generate_cam_overlay(image: Image.Image, cam_tensor: torch.Tensor) -> str:
483
  cam = cam_tensor.detach().cpu().numpy()
484
  cam = np.maximum(cam, 0)
485
  cam = cam - np.min(cam)
486
  cam = cam / (np.max(cam) + 1e-8)
487
- cam_img = Image.fromarray(np.uint8(255 * cam)).resize(image.size, Image.Resampling.BILINEAR)
 
 
488
  cam_resized = np.array(cam_img) / 255.0
489
  colormap = cm.get_cmap("jet")(cam_resized)[:, :, :3]
490
  heatmap = np.uint8(255 * colormap)
@@ -502,27 +595,31 @@ def run_pytorch_inference(image: Image.Image) -> dict[str, Any]:
502
 
503
  tensor = TRANSFORM(image).unsqueeze(0).to(DEVICE)
504
  tensor.requires_grad_(True)
505
-
506
  # Try to hook the last conv layer for MobileNetV3 or EfficientNet
507
  target_layer = None
508
  if hasattr(PYTORCH_MODEL.base_model, "features"):
509
  target_layer = PYTORCH_MODEL.base_model.features[-1]
510
-
511
  cam_hook = CAMHook(target_layer) if target_layer else None
512
 
513
  if torch.cuda.is_available():
514
  torch.cuda.synchronize()
515
  start = time.perf_counter()
516
-
517
  # Enable gradients to compute CAM
518
  with torch.set_grad_enabled(True):
519
  logits = PYTORCH_MODEL(tensor)
520
-
521
  if torch.cuda.is_available():
522
  torch.cuda.synchronize()
523
  latency_ms = round((time.perf_counter() - start) * 1000, 2)
524
-
525
- probabilities = torch.sigmoid(logits).squeeze(0).detach().cpu().numpy() if MULTI_LABEL else torch.softmax(logits, dim=1).squeeze(0).detach().cpu().numpy()
 
 
 
 
526
 
527
  cam_b64 = None
528
  if cam_hook:
@@ -557,7 +654,11 @@ def run_onnx_inference(image: Image.Image) -> dict[str, Any]:
557
  outputs = ONNX_SESSION.run(None, {input_name: tensor})
558
  latency_ms = round((time.perf_counter() - start) * 1000, 2)
559
  logits = torch.from_numpy(outputs[0]).squeeze(0)
560
- probabilities = torch.sigmoid(logits).numpy() if MULTI_LABEL else torch.softmax(logits, dim=0).numpy()
 
 
 
 
561
  return {
562
  "model_key": "onnx",
563
  "model_used": "Optimized ONNX",
@@ -569,6 +670,7 @@ def run_onnx_inference(image: Image.Image) -> dict[str, Any]:
569
 
570
  async def _run_local_inference(image: Image.Image) -> dict[str, Any]:
571
  """Run both models in-process — used when model weights are available locally."""
 
572
  async def safe_call(model_name: str, fn):
573
  try:
574
  result = await asyncio.to_thread(fn, image)
@@ -608,7 +710,9 @@ async def _call_hf_infer(image: Image.Image) -> dict[str, Any]:
608
  LATENCY_HISTOGRAM.labels(model=key).observe(latency)
609
  result[key] = {
610
  "model_key": key,
611
- "model_used": "Baseline PyTorch" if key == "pytorch" else "Optimized ONNX",
 
 
612
  "latency_ms": latency,
613
  "probabilities": probs.tolist(),
614
  **postprocess_probabilities(probs),
@@ -623,7 +727,11 @@ async def benchmark_both_models(image: Image.Image) -> dict[str, Any]:
623
  return await _run_local_inference(image)
624
 
625
 
626
- def build_recommendation(selected_result: dict[str, Any], drift_result: dict[str, Any], dual_results: dict[str, Any]) -> str:
 
 
 
 
627
  if drift_result["drift_alert"] == "DRIFT_DETECTED":
628
  return "Input distribution differs from the stored training baseline. Manual review is recommended before trusting this result."
629
  if selected_result["low_confidence"]:
@@ -631,7 +739,10 @@ def build_recommendation(selected_result: dict[str, Any], drift_result: dict[str
631
 
632
  other_key = "onnx" if selected_result["model_key"] == "pytorch" else "pytorch"
633
  other_result = dual_results.get(other_key, {})
634
- if other_result.get("predicted_labels") and other_result["predicted_labels"] != selected_result["predicted_labels"]:
 
 
 
635
  return "The two serving paths disagree on the predicted findings. Use this as a monitoring alert and fall back to manual review."
636
 
637
  return "Use this output as a triage aid only. PneumoOps monitors latency and drift, but it is not a clinical decision-maker."
@@ -652,11 +763,11 @@ def save_prediction_data(image: Image.Image, payload: dict[str, Any]) -> None:
652
  try:
653
  COLLECT_DIR.mkdir(parents=True, exist_ok=True)
654
  record_id = str(uuid.uuid4())
655
-
656
  # Save image
657
  img_path = COLLECT_DIR / f"{record_id}.png"
658
  image.save(img_path, format="PNG")
659
-
660
  # Save prediction payload
661
  json_path = COLLECT_DIR / f"{record_id}.json"
662
  json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
@@ -715,15 +826,16 @@ def class_prediction_rates():
715
  return {
716
  "window_size": total,
717
  "per_class_prediction_rate": {
718
- label: round(count / total, 4)
719
- for label, count in rates.items()
720
  },
721
  "per_class_avg_confidence": {
722
  label: round(float(sum(vals) / len(vals)), 4) if vals else None
723
  for label, vals in avg_confidence.items()
724
  },
725
  "drift_rate": round(
726
- sum(1 for e in history_list if e.get("drift_alert") == "DRIFT_DETECTED") / total, 4
 
 
727
  ),
728
  }
729
 
@@ -746,14 +858,20 @@ def calibration_summary():
746
 
747
 
748
  @app.post("/predict")
749
- async def predict(request: Request, background_tasks: BackgroundTasks, file: UploadFile = File(...)):
 
 
750
  image = load_image_from_upload(file)
751
  input_summary = summarize_image(image)
752
  validate_image(image, input_summary)
753
 
754
  start = time.perf_counter()
755
  dual_results = await benchmark_both_models(image)
756
- selected_key = random.choices(["pytorch", "onnx"], weights=[TRAFFIC_WEIGHTS["pytorch"], TRAFFIC_WEIGHTS["onnx"]], k=1)[0]
 
 
 
 
757
  selected_result = dual_results[selected_key]
758
  warning_flags = []
759
 
@@ -761,20 +879,33 @@ async def predict(request: Request, background_tasks: BackgroundTasks, file: Upl
761
  fallback_result = dual_results["pytorch"]
762
  if "error" in fallback_result:
763
  REQUEST_COUNTER.labels(model=selected_key, status="failure").inc()
764
- raise HTTPException(status_code=503, detail=f"Both inference backends failed: {dual_results}")
 
 
 
765
  selected_result = fallback_result
766
  warning_flags.append(f"{selected_key.upper()} failed, fallback to PyTorch.")
767
 
768
  # Increment per-class disease prediction counters
769
  for label in selected_result["predicted_labels"]:
770
- DISEASE_PREDICTION_COUNTER.labels(disease=label, model=selected_result["model_key"]).inc()
 
 
771
 
772
  drift_result = calculate_drift(image)
773
  DRIFT_COUNTER.labels(status=drift_result["drift_alert"]).inc()
774
  REQUEST_COUNTER.labels(model=selected_result["model_key"], status="success").inc()
775
 
776
- pytorch_latency = dual_results["pytorch"].get("latency_ms") if "error" not in dual_results["pytorch"] else None
777
- onnx_latency = dual_results["onnx"].get("latency_ms") if "error" not in dual_results["onnx"] else None
 
 
 
 
 
 
 
 
778
  latency_delta = None
779
  if pytorch_latency is not None and onnx_latency is not None:
780
  latency_delta = round(float(onnx_latency) - float(pytorch_latency), 2)
@@ -799,7 +930,9 @@ async def predict(request: Request, background_tasks: BackgroundTasks, file: Upl
799
  "drift": drift_result,
800
  "input_summary": input_summary,
801
  "warning_flags": warning_flags,
802
- "recommendation": build_recommendation(selected_result, drift_result, dual_results),
 
 
803
  "active_onnx_model": ACTIVE_ONNX_MODEL_NAME,
804
  "recent_history": list(REQUEST_LOG_HISTORY),
805
  "cam_b64": dual_results["pytorch"].get("cam_b64"),
@@ -839,11 +972,13 @@ async def predict(request: Request, background_tasks: BackgroundTasks, file: Upl
839
  }
840
  )
841
 
842
- response_payload["request_latency_ms"] = round((time.perf_counter() - start) * 1000, 2)
843
-
 
 
844
  # Trigger background save for fine-tuning
845
  background_tasks.add_task(save_prediction_data, image, response_payload)
846
-
847
  return response_payload
848
 
849
 
@@ -864,7 +999,10 @@ async def infer_raw(file: UploadFile = File(...)):
864
  for key in ("pytorch", "onnx"):
865
  r = dual.get(key, {})
866
  out[key] = (
867
- {"probabilities": r.get("probabilities", []), "latency_ms": r.get("latency_ms")}
 
 
 
868
  if "error" not in r
869
  else {"error": r["error"]}
870
  )
@@ -891,6 +1029,7 @@ try:
891
  from frontend.app import demo as gradio_demo # the gr.Blocks() object
892
 
893
  from fastapi.responses import RedirectResponse
 
894
  @app.get("/")
895
  def redirect_to_ui():
896
  return RedirectResponse(url="/ui")
@@ -904,9 +1043,8 @@ except Exception as _e:
904
  logger.warning(f"Gradio mount skipped ({_e}). API-only mode active.")
905
 
906
 
907
-
908
  if __name__ == "__main__":
909
  import uvicorn
 
910
  port = int(os.getenv("PORT", "7860"))
911
  uvicorn.run(app, host="0.0.0.0", port=port)
912
-
 
16
  import gradio as gr
17
  import httpx
18
  import numpy as np
19
+ from fastapi import (
20
+ BackgroundTasks,
21
+ FastAPI,
22
+ File,
23
+ HTTPException,
24
+ Request,
25
+ Response,
26
+ UploadFile,
27
+ )
28
  from fastapi.middleware.cors import CORSMiddleware
29
  from fastapi.responses import PlainTextResponse
30
  from PIL import Image
 
40
  from torchvision import transforms
41
  from torchvision.models import mobilenet_v3_small, efficientnet_b0
42
  from model_utils import CalibratedModel
43
+
44
  _TORCH_AVAILABLE = True
45
  except ImportError:
46
  _TORCH_AVAILABLE = False
 
60
  REQUEST_LOG_HISTORY = deque(maxlen=20)
61
 
62
  API_KEY = os.getenv("PNEUMOOPS_API_KEY")
63
+ ALLOWED_ORIGINS = [
64
+ origin.strip()
65
+ for origin in os.getenv("PNEUMOOPS_ALLOWED_ORIGINS", "*").split(",")
66
+ if origin.strip()
67
+ ]
68
  TRAFFIC_WEIGHTS = {"pytorch": 60, "onnx": 40}
69
  # When set, all model inference is forwarded to this HF Spaces URL instead of local models.
70
  # Example: https://prakhar54-byte-pneumoops.hf.space
71
  HF_SPACES_URL = os.getenv("HF_SPACES_URL", "").rstrip("/")
72
  COLLECT_DATA = os.getenv("PNEUMOOPS_COLLECT_DATA", "true").lower() == "true"
73
  COLLECT_DIR = BASE_DIR / "data" / "collected_images"
74
+ LOW_CONFIDENCE_THRESHOLD = float(
75
+ os.getenv("PNEUMOOPS_LOW_CONFIDENCE_THRESHOLD", "0.60")
76
+ )
77
  MIN_UPLOAD_EDGE = int(os.getenv("PNEUMOOPS_MIN_UPLOAD_EDGE", "96"))
78
  MAX_CHANNEL_DELTA = float(os.getenv("PNEUMOOPS_MAX_CHANNEL_DELTA", "0.08"))
79
  MIN_ASPECT_RATIO = float(os.getenv("PNEUMOOPS_MIN_ASPECT_RATIO", "0.6"))
 
127
  def resolve_runtime_paths(model_dir: Path) -> dict[str, Path | None]:
128
  checkpoint_path = resolve_path(
129
  [
130
+ model_dir / "mobilenetv3_chestmnist.pth", # chestmnist profile
131
  model_dir / "realworld_efficientnet_b0.pth",
132
  model_dir / "pneumo_model.pth",
133
  ]
134
  )
135
+ onnx_report_path = resolve_path(
136
+ [
137
+ model_dir / "onnx_export_report.json",
138
+ LEGACY_MODEL_DIR / "onnx_export_report.json",
139
+ ]
140
+ )
141
  onnx_report = load_json(onnx_report_path)
142
 
143
  base_onnx = onnx_report.get("base_onnx")
 
168
  return {
169
  "checkpoint": checkpoint_path,
170
  "onnx": resolve_path(onnx_candidates),
171
+ "training_metrics": resolve_path(
172
+ [
173
+ model_dir / "training_metrics.json",
174
+ LEGACY_MODEL_DIR / "training_metrics.json",
175
+ ]
176
+ ),
177
+ "baseline_stats": resolve_path(
178
+ [
179
+ model_dir / "baseline_stats.json",
180
+ LEGACY_MODEL_DIR / "baseline_stats.json",
181
+ ]
182
+ ),
183
  "onnx_export_report": onnx_report_path,
184
  }
185
 
 
250
  "multi_label": False,
251
  }
252
 
253
+ # If torch is not available, fall back to loading metadata from training_metrics.json
254
+ if not _TORCH_AVAILABLE:
255
+ tm_path = MODEL_DIR / "training_metrics.json"
256
+ tm = load_json(tm_path)
257
+ class_names = tm.get("class_names", ["Normal", "Pneumonia"])
258
+ n = len(class_names)
259
+ return {
260
+ "architecture": tm.get("architecture", "mobilenet_v3_small"),
261
+ "class_names": class_names,
262
+ "image_size": int(tm.get("image_size", 224)),
263
+ "normalize_mean": [0.485, 0.456, 0.406],
264
+ "normalize_std": [0.229, 0.224, 0.225],
265
+ "thresholds": tm.get("thresholds", [0.5] * n),
266
+ "logit_temperature": 1.0,
267
+ "multi_label": bool(tm.get("multi_label", True)),
268
+ }
269
+
270
  payload = torch.load(checkpoint_path, map_location="cpu")
271
  if isinstance(payload, dict) and "model_state_dict" in payload:
272
  return {
 
276
  "image_size": int(payload.get("image_size", 320)),
277
  "normalize_mean": payload.get("normalize_mean", [0.485, 0.456, 0.406]),
278
  "normalize_std": payload.get("normalize_std", [0.229, 0.224, 0.225]),
279
+ "thresholds": payload.get(
280
+ "thresholds", [0.5] * len(payload.get("class_names", ["No Finding"]))
281
+ ),
282
  "logit_temperature": float(payload.get("logit_temperature", 1.0)),
283
  "multi_label": bool(payload.get("multi_label", True)),
284
  }
 
301
  }
302
 
303
 
 
304
  MODEL_METADATA = load_checkpoint_metadata(RUNTIME_PATHS["checkpoint"])
305
  CLASS_NAMES = MODEL_METADATA["class_names"]
306
  MULTI_LABEL = bool(MODEL_METADATA["multi_label"])
 
350
  num_outputs = len(CLASS_NAMES)
351
  if architecture == "efficientnet_b0":
352
  base_model = efficientnet_b0(weights=None)
353
+ base_model.classifier[1] = torch.nn.Linear(
354
+ base_model.classifier[1].in_features, num_outputs
355
+ )
356
  else:
357
  base_model = mobilenet_v3_small(weights=None)
358
+ base_model.classifier[3] = torch.nn.Linear(
359
+ base_model.classifier[3].in_features, num_outputs
360
+ )
361
 
362
  base_model.load_state_dict(MODEL_METADATA["state_dict"])
363
  model = CalibratedModel(base_model, LOGIT_TEMPERATURE)
 
374
  return session, onnx_path.name
375
 
376
 
377
+ DEVICE = (
378
+ torch.device("cuda" if torch.cuda.is_available() else "cpu")
379
+ if _TORCH_AVAILABLE
380
+ else None
381
+ )
382
  PYTORCH_MODEL = build_model() if _TORCH_AVAILABLE else None
383
+ ONNX_SESSION, ACTIVE_ONNX_MODEL_NAME = (
384
+ build_onnx_session() if _TORCH_AVAILABLE else (None, None)
385
+ )
386
 
387
  app = FastAPI(
388
  title="PneumoOps API",
 
411
  content = upload.file.read()
412
  return Image.open(io.BytesIO(content)).convert("RGB")
413
  except Exception as exc:
414
+ raise HTTPException(
415
+ status_code=400, detail="Uploaded file is not a valid image."
416
+ ) from exc
417
 
418
 
419
  def summarize_image(image: Image.Image) -> dict[str, Any]:
420
  gray = np.asarray(image.convert("L"), dtype=np.float32) / 255.0
421
  width, height = image.size
422
  rgb = np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0
423
+ channel_delta = (
424
+ float(
425
+ np.mean(np.abs(rgb[:, :, 0] - rgb[:, :, 1]))
426
+ + np.mean(np.abs(rgb[:, :, 1] - rgb[:, :, 2]))
427
+ + np.mean(np.abs(rgb[:, :, 0] - rgb[:, :, 2]))
428
+ )
429
+ / 3.0
430
+ )
431
  return {
432
  "width": width,
433
  "height": height,
 
464
  image_std = float(np.std(gray))
465
  hist_bins = int(BASELINE_STATS.get("histogram_bins", 32))
466
  hist, _ = np.histogram(gray, bins=hist_bins, range=(0.0, 1.0), density=True)
467
+ baseline_hist = np.asarray(
468
+ BASELINE_STATS.get("histogram_mean", [1.0 / hist_bins] * hist_bins),
469
+ dtype=np.float32,
470
+ )
471
 
472
+ mean_z = abs(image_mean - BASELINE_STATS.get("pixel_mean_mean", 0.5)) / max(
473
+ BASELINE_STATS.get("pixel_mean_std", 0.1), 1e-6
474
+ )
475
+ std_z = abs(image_std - BASELINE_STATS.get("pixel_std_mean", 0.2)) / max(
476
+ BASELINE_STATS.get("pixel_std_std", 0.05), 1e-6
477
+ )
478
  hist_distance = float(np.mean(np.abs(hist - baseline_hist)))
479
  drift_score = round(0.35 * mean_z + 0.35 * std_z + 0.30 * hist_distance, 6)
480
 
481
+ reference_sample = np.asarray(
482
+ BASELINE_STATS.get("pixel_reference_sample", []), dtype=np.float32
483
+ )
484
  incoming_sample = gray.reshape(-1)
485
  if reference_sample.size > 0:
486
  sample_size = min(len(incoming_sample), len(reference_sample), 4096)
487
+ incoming_idx = np.random.choice(
488
+ len(incoming_sample), size=sample_size, replace=False
489
+ )
490
+ reference_idx = np.random.choice(
491
+ len(reference_sample), size=sample_size, replace=False
492
+ )
493
+ _, ks_pvalue = ks_2samp(
494
+ incoming_sample[incoming_idx], reference_sample[reference_idx]
495
+ )
496
  ks_pvalue = float(ks_pvalue)
497
  else:
498
  ks_pvalue = 1.0
499
 
500
+ drift_detected = ks_pvalue < float(
501
+ BASELINE_STATS.get("drift_ks_pvalue_threshold", 0.05)
502
+ ) or drift_score > float(BASELINE_STATS.get("drift_threshold", 1.2))
503
  return {
504
  "drift_alert": "DRIFT_DETECTED" if drift_detected else "NORMAL",
505
  "drift_score": drift_score,
 
513
  def postprocess_probabilities(probabilities: np.ndarray) -> dict[str, Any]:
514
  probabilities = probabilities.astype(np.float32)
515
  if MULTI_LABEL:
516
+ predicted_indices = [
517
+ index
518
+ for index, value in enumerate(probabilities)
519
+ if value >= THRESHOLDS[index]
520
+ ]
521
  if not predicted_indices:
522
  predicted_indices = [int(np.argmax(probabilities))]
523
  predicted_labels = [CLASS_NAMES[index] for index in predicted_indices]
 
530
  {
531
  "label": CLASS_NAMES[index],
532
  "confidence": round(float(probabilities[index]) * 100, 2),
533
+ "threshold": round(float(THRESHOLDS[index]) * 100, 2)
534
+ if index < len(THRESHOLDS)
535
+ else 50.0,
536
+ "detected": index in predicted_indices,
537
  }
538
  for index in range(len(CLASS_NAMES))
539
  ]
540
+ sorted_pairs = sorted(
541
+ all_predictions, key=lambda item: item["confidence"], reverse=True
542
+ )
543
  top_confidence = sorted_pairs[0]["confidence"] if sorted_pairs else 0.0
544
 
545
  return {
 
551
  "inconclusive_scan": top_confidence < 10.0,
552
  }
553
 
554
+
555
  class CAMHook:
556
  def __init__(self, module):
557
  self.hook_f = module.register_forward_hook(self.hook_fn_fwd)
558
  self.hook_b = module.register_full_backward_hook(self.hook_fn_bwd)
559
  self.features = None
560
  self.gradients = None
561
+
562
  def hook_fn_fwd(self, module, input, output):
563
  self.features = output
564
+
565
  def hook_fn_bwd(self, module, grad_in, grad_out):
566
  self.gradients = grad_out[0]
567
+
568
  def remove(self):
569
  self.hook_f.remove()
570
  self.hook_b.remove()
571
 
572
+
573
  def generate_cam_overlay(image: Image.Image, cam_tensor: torch.Tensor) -> str:
574
  cam = cam_tensor.detach().cpu().numpy()
575
  cam = np.maximum(cam, 0)
576
  cam = cam - np.min(cam)
577
  cam = cam / (np.max(cam) + 1e-8)
578
+ cam_img = Image.fromarray(np.uint8(255 * cam)).resize(
579
+ image.size, Image.Resampling.BILINEAR
580
+ )
581
  cam_resized = np.array(cam_img) / 255.0
582
  colormap = cm.get_cmap("jet")(cam_resized)[:, :, :3]
583
  heatmap = np.uint8(255 * colormap)
 
595
 
596
  tensor = TRANSFORM(image).unsqueeze(0).to(DEVICE)
597
  tensor.requires_grad_(True)
598
+
599
  # Try to hook the last conv layer for MobileNetV3 or EfficientNet
600
  target_layer = None
601
  if hasattr(PYTORCH_MODEL.base_model, "features"):
602
  target_layer = PYTORCH_MODEL.base_model.features[-1]
603
+
604
  cam_hook = CAMHook(target_layer) if target_layer else None
605
 
606
  if torch.cuda.is_available():
607
  torch.cuda.synchronize()
608
  start = time.perf_counter()
609
+
610
  # Enable gradients to compute CAM
611
  with torch.set_grad_enabled(True):
612
  logits = PYTORCH_MODEL(tensor)
613
+
614
  if torch.cuda.is_available():
615
  torch.cuda.synchronize()
616
  latency_ms = round((time.perf_counter() - start) * 1000, 2)
617
+
618
+ probabilities = (
619
+ torch.sigmoid(logits).squeeze(0).detach().cpu().numpy()
620
+ if MULTI_LABEL
621
+ else torch.softmax(logits, dim=1).squeeze(0).detach().cpu().numpy()
622
+ )
623
 
624
  cam_b64 = None
625
  if cam_hook:
 
654
  outputs = ONNX_SESSION.run(None, {input_name: tensor})
655
  latency_ms = round((time.perf_counter() - start) * 1000, 2)
656
  logits = torch.from_numpy(outputs[0]).squeeze(0)
657
+ probabilities = (
658
+ torch.sigmoid(logits).numpy()
659
+ if MULTI_LABEL
660
+ else torch.softmax(logits, dim=0).numpy()
661
+ )
662
  return {
663
  "model_key": "onnx",
664
  "model_used": "Optimized ONNX",
 
670
 
671
  async def _run_local_inference(image: Image.Image) -> dict[str, Any]:
672
  """Run both models in-process — used when model weights are available locally."""
673
+
674
  async def safe_call(model_name: str, fn):
675
  try:
676
  result = await asyncio.to_thread(fn, image)
 
710
  LATENCY_HISTOGRAM.labels(model=key).observe(latency)
711
  result[key] = {
712
  "model_key": key,
713
+ "model_used": "Baseline PyTorch"
714
+ if key == "pytorch"
715
+ else "Optimized ONNX",
716
  "latency_ms": latency,
717
  "probabilities": probs.tolist(),
718
  **postprocess_probabilities(probs),
 
727
  return await _run_local_inference(image)
728
 
729
 
730
+ def build_recommendation(
731
+ selected_result: dict[str, Any],
732
+ drift_result: dict[str, Any],
733
+ dual_results: dict[str, Any],
734
+ ) -> str:
735
  if drift_result["drift_alert"] == "DRIFT_DETECTED":
736
  return "Input distribution differs from the stored training baseline. Manual review is recommended before trusting this result."
737
  if selected_result["low_confidence"]:
 
739
 
740
  other_key = "onnx" if selected_result["model_key"] == "pytorch" else "pytorch"
741
  other_result = dual_results.get(other_key, {})
742
+ if (
743
+ other_result.get("predicted_labels")
744
+ and other_result["predicted_labels"] != selected_result["predicted_labels"]
745
+ ):
746
  return "The two serving paths disagree on the predicted findings. Use this as a monitoring alert and fall back to manual review."
747
 
748
  return "Use this output as a triage aid only. PneumoOps monitors latency and drift, but it is not a clinical decision-maker."
 
763
  try:
764
  COLLECT_DIR.mkdir(parents=True, exist_ok=True)
765
  record_id = str(uuid.uuid4())
766
+
767
  # Save image
768
  img_path = COLLECT_DIR / f"{record_id}.png"
769
  image.save(img_path, format="PNG")
770
+
771
  # Save prediction payload
772
  json_path = COLLECT_DIR / f"{record_id}.json"
773
  json_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
 
826
  return {
827
  "window_size": total,
828
  "per_class_prediction_rate": {
829
+ label: round(count / total, 4) for label, count in rates.items()
 
830
  },
831
  "per_class_avg_confidence": {
832
  label: round(float(sum(vals) / len(vals)), 4) if vals else None
833
  for label, vals in avg_confidence.items()
834
  },
835
  "drift_rate": round(
836
+ sum(1 for e in history_list if e.get("drift_alert") == "DRIFT_DETECTED")
837
+ / total,
838
+ 4,
839
  ),
840
  }
841
 
 
858
 
859
 
860
  @app.post("/predict")
861
+ async def predict(
862
+ request: Request, background_tasks: BackgroundTasks, file: UploadFile = File(...)
863
+ ):
864
  image = load_image_from_upload(file)
865
  input_summary = summarize_image(image)
866
  validate_image(image, input_summary)
867
 
868
  start = time.perf_counter()
869
  dual_results = await benchmark_both_models(image)
870
+ selected_key = random.choices(
871
+ ["pytorch", "onnx"],
872
+ weights=[TRAFFIC_WEIGHTS["pytorch"], TRAFFIC_WEIGHTS["onnx"]],
873
+ k=1,
874
+ )[0]
875
  selected_result = dual_results[selected_key]
876
  warning_flags = []
877
 
 
879
  fallback_result = dual_results["pytorch"]
880
  if "error" in fallback_result:
881
  REQUEST_COUNTER.labels(model=selected_key, status="failure").inc()
882
+ raise HTTPException(
883
+ status_code=503,
884
+ detail=f"Both inference backends failed: {dual_results}",
885
+ )
886
  selected_result = fallback_result
887
  warning_flags.append(f"{selected_key.upper()} failed, fallback to PyTorch.")
888
 
889
  # Increment per-class disease prediction counters
890
  for label in selected_result["predicted_labels"]:
891
+ DISEASE_PREDICTION_COUNTER.labels(
892
+ disease=label, model=selected_result["model_key"]
893
+ ).inc()
894
 
895
  drift_result = calculate_drift(image)
896
  DRIFT_COUNTER.labels(status=drift_result["drift_alert"]).inc()
897
  REQUEST_COUNTER.labels(model=selected_result["model_key"], status="success").inc()
898
 
899
+ pytorch_latency = (
900
+ dual_results["pytorch"].get("latency_ms")
901
+ if "error" not in dual_results["pytorch"]
902
+ else None
903
+ )
904
+ onnx_latency = (
905
+ dual_results["onnx"].get("latency_ms")
906
+ if "error" not in dual_results["onnx"]
907
+ else None
908
+ )
909
  latency_delta = None
910
  if pytorch_latency is not None and onnx_latency is not None:
911
  latency_delta = round(float(onnx_latency) - float(pytorch_latency), 2)
 
930
  "drift": drift_result,
931
  "input_summary": input_summary,
932
  "warning_flags": warning_flags,
933
+ "recommendation": build_recommendation(
934
+ selected_result, drift_result, dual_results
935
+ ),
936
  "active_onnx_model": ACTIVE_ONNX_MODEL_NAME,
937
  "recent_history": list(REQUEST_LOG_HISTORY),
938
  "cam_b64": dual_results["pytorch"].get("cam_b64"),
 
972
  }
973
  )
974
 
975
+ response_payload["request_latency_ms"] = round(
976
+ (time.perf_counter() - start) * 1000, 2
977
+ )
978
+
979
  # Trigger background save for fine-tuning
980
  background_tasks.add_task(save_prediction_data, image, response_payload)
981
+
982
  return response_payload
983
 
984
 
 
999
  for key in ("pytorch", "onnx"):
1000
  r = dual.get(key, {})
1001
  out[key] = (
1002
+ {
1003
+ "probabilities": r.get("probabilities", []),
1004
+ "latency_ms": r.get("latency_ms"),
1005
+ }
1006
  if "error" not in r
1007
  else {"error": r["error"]}
1008
  )
 
1029
  from frontend.app import demo as gradio_demo # the gr.Blocks() object
1030
 
1031
  from fastapi.responses import RedirectResponse
1032
+
1033
  @app.get("/")
1034
  def redirect_to_ui():
1035
  return RedirectResponse(url="/ui")
 
1043
  logger.warning(f"Gradio mount skipped ({_e}). API-only mode active.")
1044
 
1045
 
 
1046
  if __name__ == "__main__":
1047
  import uvicorn
1048
+
1049
  port = int(os.getenv("PORT", "7860"))
1050
  uvicorn.run(app, host="0.0.0.0", port=port)