Spaces:
Sleeping
Sleeping
| """ | |
| ECG -> ECHO Screening (V3.2) | |
| Predicts ejection fraction (regression) and significant RWMA (binary screen) | |
| from a 12-lead ECG image, gives a numerical model-confidence score, and a | |
| cardiologist-referral recommendation. | |
| Research / feasibility prototype -- decision support only, NOT a diagnostic device. | |
| Model: 5-fold ensemble, EfficientNet-B3 (PTB-XL pretrained), multi-task heads. | |
| """ | |
| import os | |
| import numpy as np | |
| import cv2 | |
| from PIL import Image | |
| import streamlit as st | |
| import torch | |
| import torch.nn as nn | |
| import timm | |
| import albumentations as A | |
| from albumentations.pytorch import ToTensorV2 | |
| MODEL_PATH = "ensemble_clinical_v3_2.pth" | |
| BACKBONE = "tf_efficientnet_b3.ns_jft_in1k" | |
| IMG_SIZE = 384 | |
| EDGE_CROP = 0.05 | |
| DEVICE = torch.device("cpu") | |
| VALIDATED_EF_MAE = 9.0 # cross-validated EF mean-absolute-error (EF points) | |
| st.set_page_config(page_title="ECG -> ECHO Screening", page_icon=":anatomical_heart:", layout="wide") | |
| class ECGNetV3(nn.Module): | |
| def __init__(self, dropout=0.45, drop_path=0.1): | |
| super().__init__() | |
| self.backbone = timm.create_model( | |
| BACKBONE, pretrained=False, num_classes=0, | |
| global_pool="avg", drop_rate=0.2, drop_path_rate=drop_path, | |
| ) | |
| feat = self.backbone.num_features | |
| self.neck = nn.Sequential( | |
| nn.Linear(feat, 512), nn.LayerNorm(512), nn.GELU(), nn.Dropout(dropout), | |
| nn.Linear(512, 256), nn.LayerNorm(256), nn.GELU(), nn.Dropout(dropout * 0.7), | |
| nn.Linear(256, 128), nn.LayerNorm(128), nn.GELU(), nn.Dropout(dropout * 0.5), | |
| ) | |
| self.ef_reg = nn.Linear(128, 1) | |
| self.rwma_cls = nn.Linear(128, 2) | |
| def forward(self, x): | |
| z = self.neck(self.backbone(x)) | |
| return {"ef_norm": self.ef_reg(z).squeeze(-1), | |
| "rwma_logits": self.rwma_cls(z)} | |
| def preprocess_image(img_bgr): | |
| if EDGE_CROP > 0: | |
| H, W = img_bgr.shape[:2]; c = EDGE_CROP | |
| img_bgr = img_bgr[int(H*c):int(H*(1-c)), int(W*c):int(W*(1-c))] | |
| gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) | |
| clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8)) | |
| enh = clahe.apply(gray) | |
| kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]]) | |
| sharp = cv2.filter2D(enh, -1, kernel) | |
| sharp = cv2.normalize(sharp, None, 0, 255, cv2.NORM_MINMAX) | |
| return cv2.cvtColor(sharp, cv2.COLOR_GRAY2RGB) | |
| val_tf = A.Compose([ | |
| A.Resize(IMG_SIZE, IMG_SIZE), | |
| A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), | |
| ToTensorV2(), | |
| ]) | |
| def load_models(): | |
| ckpt = torch.load(MODEL_PATH, map_location="cpu", weights_only=False) | |
| models = [] | |
| for s in ckpt["fold_models"]: | |
| m = ECGNetV3().to(DEVICE); m.load_state_dict(s); m.eval() | |
| models.append(m) | |
| return models, ckpt.get("overall", {}) | |
| # ----------------------------------------------------------------------------- predict | |
| def predict(img_rgb, models, sig_threshold=0.5): | |
| tensor = val_tf(image=img_rgb)["image"].unsqueeze(0).to(DEVICE) | |
| ef_vals, sig_probs = [], [] | |
| with torch.no_grad(): | |
| for m in models: | |
| out = m(tensor) | |
| ef_vals.append(float(out["ef_norm"].cpu()) * 100) | |
| sig_probs.append(float(torch.softmax(out["rwma_logits"], -1)[0, 1].cpu())) | |
| ef_vals = np.array(ef_vals); sig_probs = np.array(sig_probs) | |
| ef_mean, ef_std = float(ef_vals.mean()), float(ef_vals.std()) | |
| sig_p, sig_std = float(sig_probs.mean()), float(sig_probs.std()) | |
| if ef_mean >= 50: ef_sev = "Normal" | |
| elif ef_mean >= 40: ef_sev = "Mildly reduced" | |
| elif ef_mean >= 30: ef_sev = "Moderately reduced" | |
| else: ef_sev = "Severely reduced" | |
| return {"ef_mean": ef_mean, "ef_std": ef_std, | |
| "ef_low": round(max(0, ef_mean - 1.96 * ef_std), 1), | |
| "ef_high": round(min(100, ef_mean + 1.96 * ef_std), 1), | |
| "ef_sev": ef_sev, "sig_p": sig_p, "sig_std": sig_std, | |
| "sig_flag": sig_p >= sig_threshold} | |
| # ----------------------------------------------------------------------------- confidence (numerical %) | |
| def ef_confidence_pct(ef_std): | |
| # Tighter agreement across the 5 fold-models -> higher confidence. | |
| return int(np.clip(round(100 - ef_std * 7.0), 40, 99)) | |
| def rwma_confidence_pct(p, sig_std): | |
| # Decisiveness (distance from 0.5) penalised by inter-model disagreement. | |
| decisiveness = max(p, 1 - p) * 100 | |
| return int(np.clip(round(decisiveness - sig_std * 100), 40, 99)) | |
| def conf_color(pct, strong_cut): | |
| if pct >= strong_cut: return "#2e7d32" # strong | |
| if pct >= strong_cut - 15: return "#e8730c" # moderate | |
| return "#c62828" # low | |
| # ----------------------------------------------------------------------------- assessment + referral matrix | |
| def assess(r, threshold, strong_cut): | |
| ef, sig = r["ef_mean"], r["sig_p"] | |
| ef_c = ef_confidence_pct(r["ef_std"]) | |
| rw_c = rwma_confidence_pct(sig, r["sig_std"]) | |
| ef_abn = ef < 50 | |
| rw_abn = sig >= threshold | |
| abnormal = ef_abn or rw_abn | |
| # Overall confidence: for an ABNORMAL call, confidence that something is wrong | |
| # = the strongest abnormal finding. For a NORMAL call, we must be confident on | |
| # BOTH fronts, so take the weaker (min). | |
| if abnormal: | |
| confs = ([ef_c] if ef_abn else []) + ([rw_c] if rw_abn else []) | |
| overall = max(confs) | |
| else: | |
| overall = min(ef_c, rw_c) | |
| strong = overall >= strong_cut | |
| reasons = [] | |
| if ef < 40: reasons.append(f"Predicted EF {ef:.0f}% — moderately-to-severely reduced systolic function") | |
| elif ef < 50: reasons.append(f"Predicted EF {ef:.0f}% — mildly reduced systolic function") | |
| if sig >= 0.60: reasons.append(f"High probability of significant wall-motion abnormality ({sig:.0%})") | |
| elif sig >= threshold: reasons.append(f"Possible significant wall-motion abnormality ({sig:.0%})") | |
| # ---- referral decision matrix (confidence x result) ---- | |
| if abnormal: | |
| priority = (ef < 40) or (sig >= 0.60) | |
| level = "CARDIOLOGIST REFERRAL NEEDED" + (" — PRIORITY" if priority else "") | |
| color = "#c62828" | |
| if not strong: | |
| reasons.append(f"Model confidence is low ({overall}%) — refer and correlate clinically") | |
| elif strong: | |
| level = "NO REFERRAL NEEDED — AI SCREENING SUFFICIENT" | |
| color = "#2e7d32" | |
| reasons.append(f"Predicted EF {ef:.0f}% (normal) and low RWMA probability ({sig:.0%}), with high model confidence ({overall}%)") | |
| else: | |
| level = "CLINICAL CORRELATION ADVISED" | |
| color = "#f9a825" | |
| reasons.append(f"Result appears normal, but model confidence is low ({overall}%) — do not clear on AI alone; clinician review advised") | |
| return {"ef_c": ef_c, "rw_c": rw_c, "overall": overall, "strong": strong, | |
| "abnormal": abnormal, "level": level, "color": color, "reasons": reasons} | |
| # ----------------------------------------------------------------------------- grad-cam | |
| def grad_cam(model, img_rgb, mode="ef"): | |
| from pytorch_grad_cam import GradCAM, LayerCAM | |
| from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget | |
| class WrapEF(nn.Module): | |
| def __init__(s, m): super().__init__(); s.m = m | |
| def forward(s, x): return s.m(x)["ef_norm"].unsqueeze(1) | |
| class WrapRWMA(nn.Module): | |
| def __init__(s, m): super().__init__(); s.m = m | |
| def forward(s, x): return s.m(x)["rwma_logits"][:, 1:2] | |
| if mode == "ef": | |
| wrapper = WrapEF(model); cam_cls = LayerCAM | |
| target_layers = [model.backbone.blocks[-2], model.backbone.blocks[-1]] | |
| else: | |
| wrapper = WrapRWMA(model); cam_cls = GradCAM | |
| target_layers = [model.backbone.blocks[-1]] | |
| tensor = val_tf(image=img_rgb)["image"].unsqueeze(0).to(DEVICE) | |
| with cam_cls(model=wrapper, target_layers=target_layers) as cam: | |
| g = cam(input_tensor=tensor, targets=[ClassifierOutputTarget(0)])[0] | |
| g = np.clip(g, 0, None) | |
| if g.max() > 0: g = g / g.max() | |
| H, W = img_rgb.shape[:2] | |
| g = cv2.resize(g, (W, H)) | |
| heat = cv2.applyColorMap(np.uint8(255 * g), cv2.COLORMAP_JET) | |
| heat = cv2.cvtColor(heat, cv2.COLOR_BGR2RGB) | |
| return cv2.addWeighted(img_rgb.astype(np.uint8), 0.55, heat, 0.45, 0) | |
| # ----------------------------------------------------------------------------- UI | |
| st.markdown( | |
| "<h1 style='margin-bottom:0'>ECG -> ECHO Screening</h1>" | |
| "<p style='color:#666;margin-top:4px'>Estimates ejection fraction, screens for significant " | |
| "wall-motion abnormality, and gives a confidence-scored referral recommendation from a 12-lead ECG image.</p>", | |
| unsafe_allow_html=True, | |
| ) | |
| st.warning( | |
| "**Decision support only — NOT a medical device.** Research/feasibility model trained on 500 " | |
| "ECG-echo pairs from a single center. The referral suggestion and confidence score are aids for a " | |
| "clinician; they do not replace echocardiography or physician judgment. The final decision rests " | |
| "with the treating doctor." | |
| ) | |
| with st.sidebar: | |
| st.header("Settings") | |
| threshold = st.slider( | |
| "RWMA referral threshold", 0.20, 0.70, 0.40, 0.05, | |
| help="Lower = more sensitive (flags more cases). Screening favors higher sensitivity.", | |
| ) | |
| strong_cut = st.slider( | |
| "Strong-confidence cutoff (%)", 50, 90, 70, 5, | |
| help="At or above this, model confidence is treated as 'strong'. A normal result with strong " | |
| "confidence is cleared as 'AI sufficient'; below it, clinician review is advised.", | |
| ) | |
| st.caption("A clear 12-lead ECG image (phone photo or scan) works best.") | |
| with st.expander("Referral logic"): | |
| st.markdown( | |
| "| Confidence | Result | Recommendation |\n|---|---|---|\n" | |
| "| Strong | Normal | No referral — AI sufficient |\n" | |
| "| Strong | Abnormal | Cardiologist referral |\n" | |
| "| Low | Abnormal | Cardiologist referral |\n" | |
| "| Low | Normal | Clinical correlation advised |" | |
| ) | |
| try: | |
| models, overall_metrics = load_models() | |
| model_ok = True | |
| except Exception as e: | |
| model_ok = False | |
| st.error(f"Could not load model file `{MODEL_PATH}`. Make sure it is uploaded to this Space.\n\n{e}") | |
| if model_ok and overall_metrics: | |
| with st.expander("Model performance (cross-validated, n=500)"): | |
| c1, c2, c3, c4 = st.columns(4) | |
| c1.metric("EF severe AUROC", f"{overall_metrics.get('ef_severe_auroc', float('nan')):.2f}") | |
| c2.metric("EF MAE", f"{overall_metrics.get('ef_mae', float('nan')):.1f}%") | |
| c3.metric("RWMA AUROC", f"{overall_metrics.get('rwma_auroc', float('nan')):.2f}") | |
| c4.metric("EF within +/-10%", f"{overall_metrics.get('ef_within_10', float('nan')):.0f}%") | |
| uploaded = st.file_uploader("Upload a 12-lead ECG image", type=["jpg", "jpeg", "png"]) | |
| if uploaded and model_ok: | |
| file_bytes = np.frombuffer(uploaded.read(), np.uint8) | |
| img_bgr = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR) | |
| if img_bgr is None: | |
| st.error("Could not read that image. Try a different file.") | |
| else: | |
| proc = preprocess_image(img_bgr) | |
| with st.spinner("Running 5-model ensemble..."): | |
| r = predict(proc, models, sig_threshold=threshold) | |
| a = assess(r, threshold, strong_cut) | |
| # ---- referral recommendation + overall confidence (top) ---- | |
| conf_tag = "STRONG" if a["strong"] else "LOW" | |
| conf_tag_color = "#2e7d32" if a["strong"] else "#c62828" | |
| reason_html = "".join(f"<li style='margin:2px 0'>{x}</li>" for x in a["reasons"]) | |
| st.markdown( | |
| f"<div style='border-left:8px solid {a['color']};background:#fafafa;border-radius:10px;" | |
| f"padding:16px 20px;margin:6px 0 14px 0'>" | |
| f"<div style='display:flex;justify-content:space-between;align-items:center'>" | |
| f"<div style='color:#888;font-size:13px;letter-spacing:1px'>SCREENING RECOMMENDATION</div>" | |
| f"<div style='font-size:15px;color:#555'>Model confidence: " | |
| f"<b style='color:{conf_tag_color};font-size:22px'>{a['overall']}%</b> " | |
| f"<span style='color:{conf_tag_color};font-weight:700'>({conf_tag})</span></div>" | |
| f"</div>" | |
| f"<div style='font-size:25px;font-weight:800;color:{a['color']};margin:6px 0 8px 0'>{a['level']}</div>" | |
| f"<ul style='margin:0 0 0 18px;color:#333;font-size:14px'>{reason_html}</ul>" | |
| f"</div>", unsafe_allow_html=True, | |
| ) | |
| # ---- EF + RWMA detail cards with numerical confidence ---- | |
| ef = r["ef_mean"] | |
| ef_color = "#2e7d32" if ef >= 50 else "#f9a825" if ef >= 40 else "#e65100" if ef >= 30 else "#c62828" | |
| efc_col = conf_color(a["ef_c"], strong_cut) | |
| rwc_col = conf_color(a["rw_c"], strong_cut) | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| st.markdown( | |
| f"<div style='border:1px solid #ddd;border-radius:12px;padding:18px'>" | |
| f"<div style='color:#888;font-size:14px'>EJECTION FRACTION</div>" | |
| f"<div style='font-size:42px;font-weight:700;color:{ef_color}'>{ef:.1f}%</div>" | |
| f"<div style='color:#555'>{r['ef_sev']}</div>" | |
| f"<div style='margin-top:8px;font-size:14px;color:#444'>Confidence: " | |
| f"<b style='color:{efc_col};font-size:17px'>{a['ef_c']}%</b></div>" | |
| f"<div style='font-size:12px;color:#999;margin-top:3px'>5-model range {r['ef_low']}-{r['ef_high']}%" | |
| f" · validated typical error ±{VALIDATED_EF_MAE:.0f} pts</div>" | |
| f"</div>", unsafe_allow_html=True, | |
| ) | |
| with col2: | |
| flag = r["sig_flag"]; box = "#c62828" if flag else "#2e7d32" | |
| label = "SIGNIFICANT" if flag else "Non-significant" | |
| st.markdown( | |
| f"<div style='border:1px solid #ddd;border-radius:12px;padding:18px'>" | |
| f"<div style='color:#888;font-size:14px'>WALL-MOTION ABNORMALITY</div>" | |
| f"<div style='font-size:30px;font-weight:700;color:{box};margin-top:4px'>{label}</div>" | |
| f"<div style='color:#555;margin-top:6px'>Significant probability: " | |
| f"<b>{r['sig_p']:.0%}</b> (threshold {threshold:.0%})</div>" | |
| f"<div style='margin-top:8px;font-size:14px;color:#444'>Confidence: " | |
| f"<b style='color:{rwc_col};font-size:17px'>{a['rw_c']}%</b></div>" | |
| f"</div>", unsafe_allow_html=True, | |
| ) | |
| with st.expander("How the confidence score is computed"): | |
| st.markdown( | |
| "- The **confidence score (%)** reflects how strongly the 5 ensemble models agree on this " | |
| "ECG — for EF, how tightly their predictions cluster; for RWMA, how decisive and consistent " | |
| "their vote is.\n" | |
| "- **Overall confidence** is the weakest of the two when the result is normal (we must be sure " | |
| "on both fronts to clear a patient) and the strongest abnormal finding when something looks wrong.\n" | |
| "- It measures **model agreement, not guaranteed accuracy.** A high score is reassuring but never " | |
| "a substitute for clinical judgment; a low score is itself a reason to involve a clinician." | |
| ) | |
| st.divider() | |
| st.subheader("Where the model is looking (Grad-CAM)") | |
| st.caption("Heatmaps should fall on the ECG waveforms, not borders or text.") | |
| t1, t2, t3 = st.tabs(["Preprocessed input", "EF attention", "RWMA attention"]) | |
| with t1: st.image(proc, use_column_width=True) | |
| with t2: st.image(grad_cam(models[0], proc, "ef"), use_column_width=True) | |
| with t3: st.image(grad_cam(models[0], proc, "rwma"), use_column_width=True) | |
| elif not uploaded: | |
| st.info("Upload an ECG image to run the pipeline.") |