Spaces:
Sleeping
Sleeping
Deploy build-511cae9
Browse files- 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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",
|
| 116 |
model_dir / "realworld_efficientnet_b0.pth",
|
| 117 |
model_dir / "pneumo_model.pth",
|
| 118 |
]
|
| 119 |
)
|
| 120 |
-
onnx_report_path = resolve_path(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 306 |
else:
|
| 307 |
base_model = mobilenet_v3_small(weights=None)
|
| 308 |
-
base_model.classifier[3] = torch.nn.Linear(
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 326 |
PYTORCH_MODEL = build_model() if _TORCH_AVAILABLE else None
|
| 327 |
-
ONNX_SESSION, ACTIVE_ONNX_MODEL_NAME =
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 =
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 405 |
|
| 406 |
-
mean_z = abs(image_mean - BASELINE_STATS.get("pixel_mean_mean", 0.5)) / max(
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
| 416 |
-
|
| 417 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 418 |
ks_pvalue = float(ks_pvalue)
|
| 419 |
else:
|
| 420 |
ks_pvalue = 1.0
|
| 421 |
|
| 422 |
-
drift_detected = ks_pvalue < float(
|
| 423 |
-
BASELINE_STATS.get("
|
| 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 = [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
| 452 |
-
|
|
|
|
|
|
|
| 453 |
}
|
| 454 |
for index in range(len(CLASS_NAMES))
|
| 455 |
]
|
| 456 |
-
sorted_pairs = sorted(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
|
|
|
|
|
| 727 |
),
|
| 728 |
}
|
| 729 |
|
|
@@ -746,14 +858,20 @@ def calibration_summary():
|
|
| 746 |
|
| 747 |
|
| 748 |
@app.post("/predict")
|
| 749 |
-
async def predict(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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 =
|
| 777 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
|