Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,105 +1,225 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
# ══════════════════════════════════════════════════════════════════
|
| 6 |
-
!pip install -q timm albumentations grad-cam opencv-python-headless 2>/dev/null
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
|
|
|
| 10 |
|
| 11 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
import albumentations as A
|
| 13 |
from albumentations.pytorch import ToTensorV2
|
| 14 |
-
import matplotlib.pyplot as plt
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
class ECGNetV3(nn.Module):
|
| 21 |
def __init__(self, dropout=0.45):
|
| 22 |
super().__init__()
|
| 23 |
-
self.backbone = timm.create_model(
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
| 26 |
self.neck = nn.Sequential(
|
| 27 |
-
nn.Linear(
|
| 28 |
-
nn.Linear(512,256), nn.LayerNorm(256), nn.GELU(), nn.Dropout(dropout*0.7),
|
| 29 |
-
nn.Linear(256,128), nn.LayerNorm(128), nn.GELU(), nn.Dropout(dropout*0.5)
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
| 31 |
def forward(self, x):
|
| 32 |
z = self.neck(self.backbone(x))
|
| 33 |
-
return {
|
|
|
|
|
|
|
| 34 |
|
| 35 |
-
def
|
| 36 |
if EDGE_CROP > 0:
|
| 37 |
-
H, W = img_bgr.shape[:2]
|
|
|
|
| 38 |
img_bgr = img_bgr[int(H*c):int(H*(1-c)), int(W*c):int(W*(1-c))]
|
| 39 |
-
gray
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
| 42 |
sharp = cv2.normalize(sharp, None, 0, 255, cv2.NORM_MINMAX)
|
| 43 |
return cv2.cvtColor(sharp, cv2.COLOR_GRAY2RGB)
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ECG -> ECHO Screening (V3.1)
|
| 3 |
+
Predicts ejection fraction (regression) and significant RWMA (binary screen)
|
| 4 |
+
from a 12-lead ECG image. Feasibility / research demo -- NOT for clinical use.
|
|
|
|
|
|
|
| 5 |
|
| 6 |
+
Model: 5-fold ensemble, EfficientNet-B3 backbone (PTB-XL pretrained),
|
| 7 |
+
multi-task heads (EF regression + binary RWMA).
|
| 8 |
+
"""
|
| 9 |
|
| 10 |
+
import os
|
| 11 |
+
import numpy as np
|
| 12 |
+
import cv2
|
| 13 |
+
from PIL import Image
|
| 14 |
+
import streamlit as st
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn as nn
|
| 17 |
+
import timm
|
| 18 |
import albumentations as A
|
| 19 |
from albumentations.pytorch import ToTensorV2
|
|
|
|
| 20 |
|
| 21 |
+
# Config -- must match training exactly
|
| 22 |
+
MODEL_PATH = "ensemble_clinical_v3_binary.pth"
|
| 23 |
+
BACKBONE = "tf_efficientnet_b3.ns_jft_in1k"
|
| 24 |
+
IMG_SIZE = 384
|
| 25 |
+
EDGE_CROP = 0.05
|
| 26 |
+
DEVICE = torch.device("cpu")
|
| 27 |
+
|
| 28 |
+
st.set_page_config(page_title="ECG -> ECHO Screening", page_icon=":anatomical_heart:", layout="wide")
|
| 29 |
+
|
| 30 |
|
| 31 |
class ECGNetV3(nn.Module):
|
| 32 |
def __init__(self, dropout=0.45):
|
| 33 |
super().__init__()
|
| 34 |
+
self.backbone = timm.create_model(
|
| 35 |
+
BACKBONE, pretrained=False, num_classes=0,
|
| 36 |
+
global_pool="avg", drop_rate=0.2,
|
| 37 |
+
)
|
| 38 |
+
feat = self.backbone.num_features
|
| 39 |
self.neck = nn.Sequential(
|
| 40 |
+
nn.Linear(feat, 512), nn.LayerNorm(512), nn.GELU(), nn.Dropout(dropout),
|
| 41 |
+
nn.Linear(512, 256), nn.LayerNorm(256), nn.GELU(), nn.Dropout(dropout * 0.7),
|
| 42 |
+
nn.Linear(256, 128), nn.LayerNorm(128), nn.GELU(), nn.Dropout(dropout * 0.5),
|
| 43 |
+
)
|
| 44 |
+
self.ef_reg = nn.Linear(128, 1)
|
| 45 |
+
self.rwma_cls = nn.Linear(128, 2)
|
| 46 |
+
|
| 47 |
def forward(self, x):
|
| 48 |
z = self.neck(self.backbone(x))
|
| 49 |
+
return {"ef_norm": self.ef_reg(z).squeeze(-1),
|
| 50 |
+
"rwma_logits": self.rwma_cls(z)}
|
| 51 |
+
|
| 52 |
|
| 53 |
+
def preprocess_image(img_bgr):
|
| 54 |
if EDGE_CROP > 0:
|
| 55 |
+
H, W = img_bgr.shape[:2]
|
| 56 |
+
c = EDGE_CROP
|
| 57 |
img_bgr = img_bgr[int(H*c):int(H*(1-c)), int(W*c):int(W*(1-c))]
|
| 58 |
+
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
|
| 59 |
+
clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8))
|
| 60 |
+
enh = clahe.apply(gray)
|
| 61 |
+
kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]])
|
| 62 |
+
sharp = cv2.filter2D(enh, -1, kernel)
|
| 63 |
sharp = cv2.normalize(sharp, None, 0, 255, cv2.NORM_MINMAX)
|
| 64 |
return cv2.cvtColor(sharp, cv2.COLOR_GRAY2RGB)
|
| 65 |
|
| 66 |
+
|
| 67 |
+
val_tf = A.Compose([
|
| 68 |
+
A.Resize(IMG_SIZE, IMG_SIZE),
|
| 69 |
+
A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
|
| 70 |
+
ToTensorV2(),
|
| 71 |
+
])
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@st.cache_resource(show_spinner=False)
|
| 75 |
+
def load_models():
|
| 76 |
+
ckpt = torch.load(MODEL_PATH, map_location="cpu", weights_only=False)
|
| 77 |
+
states = ckpt["fold_models"]
|
| 78 |
+
models = []
|
| 79 |
+
for s in states:
|
| 80 |
+
m = ECGNetV3().to(DEVICE)
|
| 81 |
+
m.load_state_dict(s)
|
| 82 |
+
m.eval()
|
| 83 |
+
models.append(m)
|
| 84 |
+
return models, ckpt.get("overall", {})
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def predict(img_rgb, models, sig_threshold=0.5):
|
| 88 |
+
tensor = val_tf(image=img_rgb)["image"].unsqueeze(0).to(DEVICE)
|
| 89 |
+
ef_vals, sig_probs = [], []
|
| 90 |
+
with torch.no_grad():
|
| 91 |
+
for m in models:
|
| 92 |
+
out = m(tensor)
|
| 93 |
+
ef_vals.append(float(out["ef_norm"].cpu()) * 100)
|
| 94 |
+
sig_probs.append(float(torch.softmax(out["rwma_logits"], -1)[0, 1].cpu()))
|
| 95 |
+
ef_mean, ef_std = float(np.mean(ef_vals)), float(np.std(ef_vals))
|
| 96 |
+
sig_p = float(np.mean(sig_probs))
|
| 97 |
+
|
| 98 |
+
if ef_mean >= 50: ef_sev = "Normal"
|
| 99 |
+
elif ef_mean >= 40: ef_sev = "Mildly reduced"
|
| 100 |
+
elif ef_mean >= 30: ef_sev = "Moderately reduced"
|
| 101 |
+
else: ef_sev = "Severely reduced"
|
| 102 |
+
|
| 103 |
+
return {
|
| 104 |
+
"ef_value": round(ef_mean, 1),
|
| 105 |
+
"ef_low": round(max(0, ef_mean - 1.96 * ef_std), 1),
|
| 106 |
+
"ef_high": round(min(100, ef_mean + 1.96 * ef_std), 1),
|
| 107 |
+
"ef_sev": ef_sev,
|
| 108 |
+
"sig_prob": round(sig_p, 3),
|
| 109 |
+
"sig_flag": sig_p >= sig_threshold,
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def grad_cam(model, img_rgb, mode="ef"):
|
| 114 |
+
from pytorch_grad_cam import GradCAM
|
| 115 |
+
from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
|
| 116 |
+
|
| 117 |
+
class WrapEF(nn.Module):
|
| 118 |
+
def __init__(s, m): super().__init__(); s.m = m
|
| 119 |
+
def forward(s, x): return s.m(x)["ef_norm"].unsqueeze(1)
|
| 120 |
+
|
| 121 |
+
class WrapRWMA(nn.Module):
|
| 122 |
+
def __init__(s, m): super().__init__(); s.m = m
|
| 123 |
+
def forward(s, x): return s.m(x)["rwma_logits"][:, 1:2]
|
| 124 |
+
|
| 125 |
+
wrapper = WrapEF(model) if mode == "ef" else WrapRWMA(model)
|
| 126 |
+
tensor = val_tf(image=img_rgb)["image"].unsqueeze(0).to(DEVICE)
|
| 127 |
+
with GradCAM(model=wrapper, target_layers=[model.backbone.blocks[-1]]) as cam:
|
| 128 |
+
g = cam(input_tensor=tensor, targets=[ClassifierOutputTarget(0)])[0]
|
| 129 |
+
H, W = img_rgb.shape[:2]
|
| 130 |
+
g = cv2.resize(g, (W, H))
|
| 131 |
+
heat = cv2.applyColorMap(np.uint8(255 * g), cv2.COLORMAP_JET)
|
| 132 |
+
heat = cv2.cvtColor(heat, cv2.COLOR_BGR2RGB)
|
| 133 |
+
return cv2.addWeighted(img_rgb.astype(np.uint8), 0.55, heat, 0.45, 0)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
st.markdown(
|
| 137 |
+
"<h1 style='margin-bottom:0'>ECG -> ECHO Screening</h1>"
|
| 138 |
+
"<p style='color:#666;margin-top:4px'>Estimates ejection fraction and screens for "
|
| 139 |
+
"significant wall-motion abnormality from a 12-lead ECG image.</p>",
|
| 140 |
+
unsafe_allow_html=True,
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
st.warning(
|
| 144 |
+
"**Research / feasibility demo -- NOT a medical device.** "
|
| 145 |
+
"Trained on 500 ECG-echo pairs from a single center. Outputs are not validated "
|
| 146 |
+
"for clinical decisions and must never replace echocardiography or physician judgment."
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
with st.sidebar:
|
| 150 |
+
st.header("Settings")
|
| 151 |
+
threshold = st.slider(
|
| 152 |
+
"RWMA referral threshold", 0.20, 0.70, 0.45, 0.05,
|
| 153 |
+
help="Lower = more sensitive (catches more significant cases, more false referrals).",
|
| 154 |
+
)
|
| 155 |
+
st.caption("A 12-lead ECG image (phone photo or scan) works best.")
|
| 156 |
+
|
| 157 |
+
try:
|
| 158 |
+
models, overall = load_models()
|
| 159 |
+
model_ok = True
|
| 160 |
+
except Exception as e:
|
| 161 |
+
model_ok = False
|
| 162 |
+
st.error(f"Could not load model file `{MODEL_PATH}`. "
|
| 163 |
+
f"Make sure it is uploaded to this Space.\n\n{e}")
|
| 164 |
+
|
| 165 |
+
if model_ok and overall:
|
| 166 |
+
with st.expander("Model performance (cross-validated, n=500)"):
|
| 167 |
+
c1, c2, c3, c4 = st.columns(4)
|
| 168 |
+
c1.metric("EF MAE", f"{overall.get('ef_mae', float('nan')):.1f}%")
|
| 169 |
+
c2.metric("EF within +/-10%", f"{overall.get('ef_within_10', float('nan')):.0f}%")
|
| 170 |
+
c3.metric("RWMA AUROC", f"{overall.get('rwma_auroc', float('nan')):.2f}")
|
| 171 |
+
c4.metric("RWMA recall", f"{overall.get('rwma_recall', float('nan')):.2f}")
|
| 172 |
+
|
| 173 |
+
uploaded = st.file_uploader("Upload a 12-lead ECG image", type=["jpg", "jpeg", "png"])
|
| 174 |
+
|
| 175 |
+
if uploaded and model_ok:
|
| 176 |
+
file_bytes = np.frombuffer(uploaded.read(), np.uint8)
|
| 177 |
+
img_bgr = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR)
|
| 178 |
+
if img_bgr is None:
|
| 179 |
+
st.error("Could not read that image. Try a different file.")
|
| 180 |
+
else:
|
| 181 |
+
proc = preprocess_image(img_bgr)
|
| 182 |
+
with st.spinner("Running 5-model ensemble..."):
|
| 183 |
+
result = predict(proc, models, sig_threshold=threshold)
|
| 184 |
+
|
| 185 |
+
st.subheader("Results")
|
| 186 |
+
col1, col2 = st.columns(2)
|
| 187 |
+
|
| 188 |
+
with col1:
|
| 189 |
+
ef = result["ef_value"]
|
| 190 |
+
ef_color = "#2e7d32" if ef >= 50 else "#f9a825" if ef >= 40 else "#e65100" if ef >= 30 else "#c62828"
|
| 191 |
+
st.markdown(
|
| 192 |
+
f"<div style='border:1px solid #ddd;border-radius:12px;padding:18px'>"
|
| 193 |
+
f"<div style='color:#888;font-size:14px'>EJECTION FRACTION</div>"
|
| 194 |
+
f"<div style='font-size:42px;font-weight:700;color:{ef_color}'>{ef}%</div>"
|
| 195 |
+
f"<div style='color:#555'>95% CI: {result['ef_low']}-{result['ef_high']}%</div>"
|
| 196 |
+
f"<div style='margin-top:6px;font-weight:600;color:{ef_color}'>{result['ef_sev']}</div>"
|
| 197 |
+
f"</div>", unsafe_allow_html=True,
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
with col2:
|
| 201 |
+
flag = result["sig_flag"]
|
| 202 |
+
box = "#c62828" if flag else "#2e7d32"
|
| 203 |
+
label = "SIGNIFICANT -- consider echo referral" if flag else "Non-significant"
|
| 204 |
+
st.markdown(
|
| 205 |
+
f"<div style='border:1px solid #ddd;border-radius:12px;padding:18px'>"
|
| 206 |
+
f"<div style='color:#888;font-size:14px'>WALL-MOTION ABNORMALITY</div>"
|
| 207 |
+
f"<div style='font-size:26px;font-weight:700;color:{box};margin-top:6px'>{label}</div>"
|
| 208 |
+
f"<div style='color:#555;margin-top:8px'>Significant probability: "
|
| 209 |
+
f"{result['sig_prob']:.0%} (threshold {threshold:.0%})</div>"
|
| 210 |
+
f"</div>", unsafe_allow_html=True,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
st.divider()
|
| 214 |
+
st.subheader("Where the model is looking (Grad-CAM)")
|
| 215 |
+
st.caption("Heatmaps should fall on the ECG waveforms, not borders or text.")
|
| 216 |
+
t1, t2, t3 = st.tabs(["Preprocessed input", "EF attention", "RWMA attention"])
|
| 217 |
+
with t1:
|
| 218 |
+
st.image(proc, use_column_width=True)
|
| 219 |
+
with t2:
|
| 220 |
+
st.image(grad_cam(models[0], proc, "ef"), use_column_width=True)
|
| 221 |
+
with t3:
|
| 222 |
+
st.image(grad_cam(models[0], proc, "rwma"), use_column_width=True)
|
| 223 |
+
|
| 224 |
+
elif not uploaded:
|
| 225 |
+
st.info("Upload an ECG image to run the pipeline.")
|