mobilal commited on
Commit
1f2c4ac
·
verified ·
1 Parent(s): 5a83080

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +207 -87
app.py CHANGED
@@ -1,105 +1,225 @@
1
- # ══════════════════════════════════════════════════════════════════
2
- # STANDALONE ECG PIPELINE TEST — paste into a fresh Colab cell
3
- # Reconnect runtime first (Runtime > Reconnect), then run this whole cell.
4
- # It loads your V3.1 ensemble from Drive and lets you upload an ECG.
5
- # ══════════════════════════════════════════════════════════════════
6
- !pip install -q timm albumentations grad-cam opencv-python-headless 2>/dev/null
7
 
8
- from google.colab import drive, files
9
- drive.mount('/content/drive')
 
10
 
11
- import numpy as np, cv2, torch, torch.nn as nn, timm
 
 
 
 
 
 
 
12
  import albumentations as A
13
  from albumentations.pytorch import ToTensorV2
14
- import matplotlib.pyplot as plt
15
 
16
- MODEL_PATH = '/content/drive/MyDrive/ecg_echo_ai/outputs_clinical_v3_binary/ensemble_clinical_v3_binary.pth'
17
- BACKBONE, IMG_SIZE, EDGE_CROP = 'tf_efficientnet_b3.ns_jft_in1k', 384, 0.05
18
- DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
 
 
 
 
 
 
19
 
20
  class ECGNetV3(nn.Module):
21
  def __init__(self, dropout=0.45):
22
  super().__init__()
23
- self.backbone = timm.create_model(BACKBONE, pretrained=False, num_classes=0,
24
- global_pool='avg', drop_rate=0.2)
25
- f = self.backbone.num_features
 
 
26
  self.neck = nn.Sequential(
27
- nn.Linear(f,512), nn.LayerNorm(512), nn.GELU(), nn.Dropout(dropout),
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
- self.ef_reg = nn.Linear(128,1); self.rwma_cls = nn.Linear(128,2)
 
 
 
31
  def forward(self, x):
32
  z = self.neck(self.backbone(x))
33
- return {'ef_norm': self.ef_reg(z).squeeze(-1), 'rwma_logits': self.rwma_cls(z)}
 
 
34
 
35
- def preprocess(img_bgr):
36
  if EDGE_CROP > 0:
37
- H, W = img_bgr.shape[:2]; c = EDGE_CROP
 
38
  img_bgr = img_bgr[int(H*c):int(H*(1-c)), int(W*c):int(W*(1-c))]
39
- gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
40
- enh = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8,8)).apply(gray)
41
- sharp = cv2.filter2D(enh, -1, np.array([[0,-1,0],[-1,5,-1],[0,-1,0]]))
 
 
42
  sharp = cv2.normalize(sharp, None, 0, 255, cv2.NORM_MINMAX)
43
  return cv2.cvtColor(sharp, cv2.COLOR_GRAY2RGB)
44
 
45
- val_tf = A.Compose([A.Resize(IMG_SIZE, IMG_SIZE),
46
- A.Normalize(mean=[0.485,0.456,0.406], std=[0.229,0.224,0.225]),
47
- ToTensorV2()])
48
-
49
- # Load ensemble
50
- ckpt = torch.load(MODEL_PATH, map_location=DEVICE, weights_only=False)
51
- models = []
52
- for s in ckpt['fold_models']:
53
- m = ECGNetV3().to(DEVICE); m.load_state_dict(s); m.eval(); models.append(m)
54
- print(f'Loaded {len(models)}-fold ensemble. CV: '
55
- f"EF MAE={ckpt['overall']['ef_mae']:.1f}%, RWMA AUROC={ckpt['overall']['rwma_auroc']:.3f}")
56
-
57
- # Upload an ECG
58
- print('\nUpload an ECG image:')
59
- up = files.upload()
60
- fname = list(up.keys())[0]
61
- img_bgr = cv2.imdecode(np.frombuffer(up[fname], np.uint8), cv2.IMREAD_COLOR)
62
- proc = preprocess(img_bgr)
63
-
64
- # Predict (5-fold ensemble)
65
- THRESHOLD = 0.45
66
- tensor = val_tf(image=proc)['image'].unsqueeze(0).to(DEVICE)
67
- ef_vals, sig_probs = [], []
68
- with torch.no_grad():
69
- for m in models:
70
- out = m(tensor)
71
- ef_vals.append(float(out['ef_norm'].cpu())*100)
72
- sig_probs.append(float(torch.softmax(out['rwma_logits'],-1)[0,1].cpu()))
73
- ef_mean, ef_std = np.mean(ef_vals), np.std(ef_vals); sig_p = np.mean(sig_probs)
74
- sev = ('Normal' if ef_mean>=55 else 'Mildly reduced' if ef_mean>=45
75
- else 'Moderately reduced' if ef_mean>=35 else 'Severely reduced')
76
-
77
- print('\n' + '='*50)
78
- print(f' EJECTION FRACTION : {ef_mean:.1f}% (95% CI {ef_mean-1.96*ef_std:.1f}-{ef_mean+1.96*ef_std:.1f})')
79
- print(f' EF severity : {sev}')
80
- print(f' RWMA significant : {sig_p:.0%} probability -> '
81
- f'{"SIGNIFICANT (refer for echo)" if sig_p>=THRESHOLD else "Non-significant"}')
82
- print('='*50)
83
-
84
- # Grad-CAM
85
- from pytorch_grad_cam import GradCAM
86
- from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
87
- class WEF(nn.Module):
88
- def __init__(s,m): super().__init__(); s.m=m
89
- def forward(s,x): return s.m(x)['ef_norm'].unsqueeze(1)
90
- class WRW(nn.Module):
91
- def __init__(s,m): super().__init__(); s.m=m
92
- def forward(s,x): return s.m(x)['rwma_logits'][:,1:2]
93
- def cam(wrap):
94
- t = val_tf(image=proc)['image'].unsqueeze(0).to(DEVICE)
95
- with GradCAM(model=wrap, target_layers=[models[0].backbone.blocks[-1]]) as g:
96
- h = g(input_tensor=t, targets=[ClassifierOutputTarget(0)])[0]
97
- h = cv2.resize(h, (proc.shape[1], proc.shape[0]))
98
- heat = cv2.cvtColor(cv2.applyColorMap(np.uint8(255*h), cv2.COLORMAP_JET), cv2.COLOR_BGR2RGB)
99
- return cv2.addWeighted(proc.astype(np.uint8), 0.55, heat, 0.45, 0)
100
-
101
- fig, ax = plt.subplots(1, 3, figsize=(22, 6))
102
- ax[0].imshow(proc); ax[0].set_title('Preprocessed input'); ax[0].axis('off')
103
- ax[1].imshow(cam(WEF(models[0]))); ax[1].set_title('EF attention'); ax[1].axis('off')
104
- ax[2].imshow(cam(WRW(models[0]))); ax[2].set_title('RWMA attention'); ax[2].axis('off')
105
- plt.tight_layout(); plt.show()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.")