| """Multimodal heart-attack risk app β ensemble router. |
| |
| One ``POST /predict`` endpoint accepts multipart/form-data carrying *optional* |
| Framingham tabular fields and an *optional* ECG image. An internal router picks |
| the path: |
| |
| tabular only -> Model A (Framingham CHD) |
| ECG only -> Model B (ResNet ECG) |
| both -> A + B, averaged |
| neither -> HTTP 400 |
| |
| The predictor modules are imported lazily so the app still boots (and serves the |
| frontend) before the models have been trained. |
| |
| Run: uvicorn app:app --reload |
| """ |
| from __future__ import annotations |
|
|
| from pathlib import Path |
| from typing import Optional |
|
|
| from fastapi import FastAPI, File, Form, HTTPException, UploadFile |
| from fastapi.concurrency import run_in_threadpool |
| from fastapi.responses import FileResponse |
| from fastapi.staticfiles import StaticFiles |
|
|
| from inference.fusion import band_for, combine |
| from inference.validation import validate_tabular |
|
|
| |
| FEATURES = [ |
| "male", "age", "education", "currentSmoker", "cigsPerDay", "BPMeds", |
| "prevalentStroke", "prevalentHyp", "diabetes", "totChol", "sysBP", |
| "diaBP", "BMI", "heartRate", "glucose", |
| ] |
|
|
| app = FastAPI(title="Multimodal Heart Attack Risk β Ensemble Router") |
|
|
|
|
| def _has_value(v: Optional[str]) -> bool: |
| return v is not None and str(v).strip() != "" |
|
|
|
|
| @app.post("/predict") |
| async def predict( |
| |
| male: Optional[str] = Form(None), |
| age: Optional[str] = Form(None), |
| education: Optional[str] = Form(None), |
| currentSmoker: Optional[str] = Form(None), |
| cigsPerDay: Optional[str] = Form(None), |
| BPMeds: Optional[str] = Form(None), |
| prevalentStroke: Optional[str] = Form(None), |
| prevalentHyp: Optional[str] = Form(None), |
| diabetes: Optional[str] = Form(None), |
| totChol: Optional[str] = Form(None), |
| sysBP: Optional[str] = Form(None), |
| diaBP: Optional[str] = Form(None), |
| BMI: Optional[str] = Form(None), |
| heartRate: Optional[str] = Form(None), |
| glucose: Optional[str] = Form(None), |
| |
| ecg: Optional[UploadFile] = File(None), |
| ): |
| fields = { |
| "male": male, "age": age, "education": education, |
| "currentSmoker": currentSmoker, "cigsPerDay": cigsPerDay, "BPMeds": BPMeds, |
| "prevalentStroke": prevalentStroke, "prevalentHyp": prevalentHyp, |
| "diabetes": diabetes, "totChol": totChol, "sysBP": sysBP, "diaBP": diaBP, |
| "BMI": BMI, "heartRate": heartRate, "glucose": glucose, |
| } |
|
|
| has_tabular = any(_has_value(fields[f]) for f in FEATURES) |
| has_ecg = ecg is not None and bool(ecg.filename) |
|
|
| if not has_tabular and not has_ecg: |
| raise HTTPException( |
| status_code=400, |
| detail="Provide tabular patient data, an ECG image, or both.", |
| ) |
|
|
| |
| if has_tabular: |
| errors = validate_tabular(fields) |
| if errors: |
| raise HTTPException(status_code=422, detail="; ".join(errors)) |
|
|
| branches: dict = {} |
|
|
| |
| |
| if has_tabular: |
| try: |
| from inference.framingham import predict_tabular |
| branches["tabular"] = await run_in_threadpool(predict_tabular, fields) |
| except FileNotFoundError as exc: |
| raise HTTPException(status_code=503, detail=str(exc)) from exc |
|
|
| if has_ecg: |
| image_bytes = await ecg.read() |
| try: |
| from inference.ecg import predict_ecg |
| branches["ecg"] = await run_in_threadpool(predict_ecg, image_bytes) |
| except FileNotFoundError as exc: |
| raise HTTPException(status_code=503, detail=str(exc)) from exc |
| except ValueError as exc: |
| raise HTTPException(status_code=400, detail=str(exc)) from exc |
|
|
| |
| if has_tabular and has_ecg: |
| p = combine(branches["tabular"]["p_risk"], branches["ecg"]["p_risk"]) |
| mode, p_head = "multimodal", p |
| elif has_tabular: |
| mode, p_head = "tabular", branches["tabular"]["p_risk"] |
| else: |
| mode, p_head = "ecg", branches["ecg"]["p_risk"] |
|
|
| return { |
| "mode": mode, |
| "risk_level": band_for(p_head), |
| "p_risk": round(p_head, 4), |
| "branches": branches, |
| } |
|
|
|
|
| |
| STATIC_DIR = Path(__file__).parent / "static" |
| STATIC_DIR.mkdir(exist_ok=True) |
| app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") |
|
|
|
|
| @app.get("/") |
| def serve_frontend(): |
| return FileResponse(str(STATIC_DIR / "index.html")) |
|
|