mobilal commited on
Commit
4e02a60
·
verified ·
1 Parent(s): 1bdd950

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +94 -433
app.py CHANGED
@@ -1,444 +1,105 @@
1
- import os
2
- import tempfile
3
- import hashlib
4
- from datetime import datetime
 
 
5
 
6
- import streamlit as st
7
- import numpy as np
8
- import torch
9
- import torch.nn as nn
10
- import cv2
11
- from PIL import Image
12
- import timm
13
  import albumentations as A
14
  from albumentations.pytorch import ToTensorV2
 
15
 
16
- from pytorch_grad_cam import GradCAM
17
- from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
18
-
19
-
20
- # ═════════════════════════════════════════════════════════════════════════════
21
- # PAGE CONFIG
22
- # ═════════════════════════════════════════════════════════════════════════════
23
- st.set_page_config(
24
- page_title='ECG → ECHO Screening AI',
25
- page_icon='🫀',
26
- layout='wide',
27
- initial_sidebar_state='collapsed',
28
- )
29
-
30
- st.markdown("""
31
- <style>
32
- #MainMenu {visibility: hidden;}
33
- footer {visibility: hidden;}
34
- header {visibility: hidden;}
35
- html, body, [class*="css"] { font-family: 'Inter', system-ui, sans-serif; }
36
- .hero {
37
- background: linear-gradient(135deg, #0f172a 0%, #1e3a8a 50%, #3b82f6 100%);
38
- color: white; padding: 32px; border-radius: 14px; margin-bottom: 24px;
39
- box-shadow: 0 4px 20px rgba(30, 58, 138, 0.3); text-align: center;
40
- }
41
- .hero h1 { margin: 0; font-size: 2.4em; color: white;}
42
- .hero p { margin: 8px 0 0 0; opacity: 0.95; font-size: 1.1em;}
43
- .hero small { opacity: 0.7; font-size: 0.85em;}
44
- .risk-high {
45
- background: linear-gradient(135deg, #dc2626, #b91c1c); color: white;
46
- padding: 16px; border-radius: 10px; text-align: center;
47
- font-size: 1.4em; font-weight: 600; margin: 10px 0;
48
- }
49
- .risk-low {
50
- background: linear-gradient(135deg, #16a34a, #15803d); color: white;
51
- padding: 16px; border-radius: 10px; text-align: center;
52
- font-size: 1.4em; font-weight: 600; margin: 10px 0;
53
- }
54
- .report-card {
55
- background: white; border: 1px solid #e5e7eb; border-radius: 12px;
56
- padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); margin-bottom: 14px;
57
- }
58
- .report-card h3 { color: #1e3a8a; margin-top: 0;}
59
- .disclaimer {
60
- background: #fef3c7; border-left: 5px solid #f59e0b; padding: 16px;
61
- margin-top: 24px; border-radius: 8px; color: #78350f;
62
- }
63
- .methodology {
64
- background: #f8fafc; border-left: 4px solid #3b82f6;
65
- padding: 12px; border-radius: 6px; font-size: 0.9em; margin: 14px 0;
66
- }
67
- .heatmap-section {
68
- background: linear-gradient(135deg, #fef3c7 0%, #fed7aa 100%);
69
- padding: 20px; border-radius: 12px; margin: 20px 0;
70
- border: 1px solid #fcd34d;
71
- }
72
- .heatmap-section h2 { color: #78350f; margin-top: 0;}
73
- .stButton > button {
74
- background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%);
75
- color: white; font-weight: 600; border: none;
76
- padding: 0.6em 2em; border-radius: 8px; font-size: 1.05em;
77
- }
78
- .stButton > button:hover {
79
- background: linear-gradient(135deg, #1e40af 0%, #2563eb 100%);
80
- transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
81
- }
82
- </style>
83
- """, unsafe_allow_html=True)
84
-
85
-
86
- # ═════════════════════════════════════════════════════════════════════════════
87
- # CONFIG + MODEL + PREPROCESSING
88
- # ═════════════════════════════════════════════════════════════════════════════
89
- IMG_SIZE = 384
90
- BACKBONE = 'tf_efficientnet_b3_ns'
91
- MEAN = [0.485, 0.456, 0.406]
92
- STD = [0.229, 0.224, 0.225]
93
- DEVICE = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
94
- N_TTA = 4 if DEVICE.type == 'cuda' else 1
95
- ENSEMBLE_PATH = 'ensemble_clinical_v2.pth'
96
 
97
-
98
- class ECGNetV2(nn.Module):
99
  def __init__(self, dropout=0.45):
100
  super().__init__()
101
- self.backbone = timm.create_model(
102
- BACKBONE, pretrained=False, num_classes=0,
103
- global_pool='avg', drop_rate=0.2,
104
- )
105
- feat = self.backbone.num_features
106
  self.neck = nn.Sequential(
107
- nn.Linear(feat, 512), nn.LayerNorm(512), nn.GELU(), nn.Dropout(dropout),
108
- nn.Linear(512, 256), nn.LayerNorm(256), nn.GELU(), nn.Dropout(dropout * 0.7),
109
- nn.Linear(256, 128), nn.LayerNorm(128), nn.GELU(), nn.Dropout(dropout * 0.5),
110
- )
111
- self.ef_cls = nn.Linear(128, 1)
112
- self.rwma_cls = nn.Linear(128, 1)
113
-
114
  def forward(self, x):
115
- z = self.backbone(x)
116
- z = self.neck(z)
117
- return {
118
- 'ef_logit': self.ef_cls(z).squeeze(-1),
119
- 'rwma_logit': self.rwma_cls(z).squeeze(-1),
120
- }
121
-
122
-
123
- def preprocess_ecg(img_path):
124
- img = cv2.imread(str(img_path))
125
- if img is None:
126
- img = cv2.cvtColor(np.array(Image.open(str(img_path)).convert('RGB')),
127
- cv2.COLOR_RGB2BGR)
128
- gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
129
- clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8))
130
- enh = clahe.apply(gray)
131
- kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]])
132
- sharp = cv2.filter2D(enh, -1, kernel)
133
- sharp = cv2.normalize(sharp, None, 0, 255, cv2.NORM_MINMAX)
134
  return cv2.cvtColor(sharp, cv2.COLOR_GRAY2RGB)
135
 
136
-
137
- tta_aug = A.Compose([
138
- A.Resize(IMG_SIZE, IMG_SIZE),
139
- A.Normalize(mean=MEAN, std=STD),
140
- ToTensorV2(),
141
- ])
142
-
143
-
144
- # ═════════════════════════════════════════════════════════════════════════════
145
- # GRAD-CAM
146
- # ═════════════════════════════════════════════════════════════════════════════
147
- class GradCAMWrapper(nn.Module):
148
- """Wraps ECGNetV2 to expose a single classifier head for Grad-CAM."""
149
- def __init__(self, model, task='ef'):
150
- super().__init__()
151
- self.model = model
152
- self.key = 'ef_logit' if task == 'ef' else 'rwma_logit'
153
-
154
- def forward(self, x):
155
- out = self.model(x)
156
- # Return shape [N, 1] so ClassifierOutputTarget(0) works
157
- logit = out[self.key]
158
- if logit.dim() == 1:
159
- logit = logit.unsqueeze(1)
160
- return logit
161
-
162
-
163
- def generate_gradcam_overlay(model, img_array, task='ef', alpha=0.45):
164
- """
165
- Generate Grad-CAM heatmap and overlay on the preprocessed ECG image.
166
-
167
- Args:
168
- model: ECGNetV2 instance (single model, not ensemble)
169
- img_array: preprocessed ECG image, shape (H, W, 3), uint8
170
- task: 'ef' or 'rwma'
171
- alpha: blend factor for overlay (0=original only, 1=heatmap only)
172
-
173
- Returns:
174
- Overlay image (H, W, 3) uint8 with heatmap blended on original
175
- """
176
- wrapper = GradCAMWrapper(model, task).to(DEVICE).eval()
177
- # conv_head is the 1×1 expansion conv just before global avg pool —
178
- # spatially richer than blocks[-1] and gradient-friendly for GradCAM
179
- target_layers = [model.backbone.conv_head]
180
-
181
- # Prepare model input
182
- tensor = tta_aug(image=img_array)['image'].unsqueeze(0).to(DEVICE)
183
-
184
- # Generate CAM
185
- with GradCAM(model=wrapper, target_layers=target_layers) as cam:
186
- grayscale_cam = cam(
187
- input_tensor=tensor,
188
- targets=[ClassifierOutputTarget(0)],
189
- )[0] # shape (H_cam, W_cam), values in [0, 1]
190
-
191
- # Resize heatmap to original image dimensions
192
- H, W = img_array.shape[:2]
193
- heatmap_resized = cv2.resize(grayscale_cam, (W, H))
194
-
195
- # Apply JET colormap (blue=low attention, red=high attention)
196
- heatmap_uint8 = np.uint8(255 * heatmap_resized)
197
- heatmap_colored = cv2.applyColorMap(heatmap_uint8, cv2.COLORMAP_JET)
198
- heatmap_colored = cv2.cvtColor(heatmap_colored, cv2.COLOR_BGR2RGB)
199
-
200
- # Blend with original
201
- overlay = cv2.addWeighted(
202
- img_array.astype(np.uint8), 1 - alpha,
203
- heatmap_colored, alpha, 0
204
- )
205
- return overlay
206
-
207
-
208
- # ═════════════════════════════════════════════════════════════════════════════
209
- # ENSEMBLE LOADING + PREDICTION
210
- # ═════════════════════════════════════════════════════════════════════════════
211
- @st.cache_resource(show_spinner='Loading AI ensemble (first time only — ~60 seconds)...')
212
- def load_ensemble():
213
- if not os.path.exists(ENSEMBLE_PATH):
214
- st.error(f'Model file {ENSEMBLE_PATH} not found. Upload it to the Space root.')
215
- st.stop()
216
- ckpt = torch.load(ENSEMBLE_PATH, map_location=DEVICE, weights_only=False)
217
- models = []
218
- for sd in ckpt['fold_models']:
219
- m = ECGNetV2(dropout=0.45).to(DEVICE)
220
- m.load_state_dict(sd)
221
- m.eval()
222
- models.append(m)
223
- return {
224
- 'ensemble': models,
225
- 'ef_thr': float(ckpt['config']['ef_threshold']),
226
- 'rwma_thr': float(ckpt['config']['rwma_threshold']),
227
- 'mean_auc': float(np.mean(ckpt['fold_auc'])),
228
- }
229
-
230
-
231
- def predict_from_array(img_array, ensemble):
232
- """Run ensemble prediction on a preprocessed image array."""
233
- ef_probs, rw_probs = [], []
234
- for m in ensemble:
235
- for _ in range(N_TTA):
236
- t = tta_aug(image=img_array)['image'].unsqueeze(0).to(DEVICE)
237
- with torch.no_grad():
238
- out = m(t)
239
- ef_probs.append(float(torch.sigmoid(out['ef_logit']).cpu()))
240
- rw_probs.append(float(torch.sigmoid(out['rwma_logit']).cpu()))
241
- return float(np.mean(ef_probs)), float(np.mean(rw_probs))
242
-
243
-
244
- # ═════════════════════════════════════════════════════════════════════════════
245
- # UI
246
- # ═════════════════════════════════════════════════════════════════════════════
247
- st.markdown("""
248
- <div class="hero">
249
- <h1>🫀 ECG → ECHO Clinical Screening AI</h1>
250
- <p>Detect LV Dysfunction & Regional Wall Motion Abnormalities from a 12-lead ECG</p>
251
- <small>Now with AI Attention Heatmaps • Research Prototype</small>
252
- </div>
253
- """, unsafe_allow_html=True)
254
-
255
- data = load_ensemble()
256
- ensemble = data['ensemble']
257
- EF_THR = data['ef_thr']
258
- RWMA_THR = data['rwma_thr']
259
- MEAN_AUC = data['mean_auc']
260
-
261
- col_left, col_right = st.columns([1, 2], gap='large')
262
-
263
- with col_left:
264
- st.markdown('### 📤 Upload ECG')
265
- uploaded_file = st.file_uploader(
266
- 'Upload a 12-lead ECG image',
267
- type=['jpg', 'jpeg', 'png'],
268
- label_visibility='collapsed',
269
- )
270
-
271
- with st.expander('👤 Patient Information (optional)'):
272
- patient_id = st.text_input('Patient ID', placeholder='e.g. P-001')
273
- col_a, col_b = st.columns(2)
274
- with col_a:
275
- patient_age = st.number_input('Age', min_value=0, max_value=120, value=None)
276
- with col_b:
277
- patient_sex = st.radio('Sex', ['Male', 'Female'], horizontal=True, index=None)
278
-
279
- analyze_btn = st.button('🔬 Analyze ECG', type='primary', use_container_width=True)
280
-
281
- if uploaded_file:
282
- st.image(uploaded_file, caption='Uploaded ECG', use_column_width=True)
283
-
284
-
285
- with col_right:
286
- if analyze_btn and uploaded_file is not None:
287
- with tempfile.NamedTemporaryFile(suffix='.jpg', delete=False) as tmp:
288
- tmp.write(uploaded_file.getvalue())
289
- tmp_path = tmp.name
290
-
291
- try:
292
- # Step 1: Preprocess
293
- img_preprocessed = preprocess_ecg(tmp_path)
294
-
295
- # Step 2: Run inference
296
- with st.spinner(f'🧠 Running inference ({len(ensemble) * N_TTA} votes)...'):
297
- ef_prob, rw_prob = predict_from_array(img_preprocessed, ensemble)
298
-
299
- # Step 3: Patient info bar
300
- info_parts = []
301
- if patient_id: info_parts.append(f'**Patient:** {patient_id}')
302
- if patient_age: info_parts.append(f'**Age:** {patient_age}')
303
- if patient_sex: info_parts.append(f'**Sex:** {patient_sex}')
304
- info_parts.append(f"**Date:** {datetime.now().strftime('%d-%b-%Y %H:%M')}")
305
-
306
- st.markdown('## 📋 Clinical Screening Report')
307
- st.markdown(' &nbsp;|&nbsp; '.join(info_parts))
308
-
309
- # Step 4: Risk cards
310
- ef_pct, rw_pct = ef_prob * 100, rw_prob * 100
311
- ef_thr_pct = EF_THR * 100
312
- rw_thr_pct = RWMA_THR * 100
313
- ef_high = ef_prob >= EF_THR
314
- rw_high = rw_prob >= RWMA_THR
315
-
316
- rc1, rc2 = st.columns(2)
317
- with rc1:
318
- st.markdown('<div class="report-card"><h3>1. Left Ventricular Function</h3>',
319
- unsafe_allow_html=True)
320
- if ef_high:
321
- st.markdown('<div class="risk-high">⚠️ HIGH RISK</div>', unsafe_allow_html=True)
322
- action = '**Recommendation:** Urgent cardiology referral'
323
- else:
324
- st.markdown('<div class="risk-low">✓ LOW RISK</div>', unsafe_allow_html=True)
325
- action = '**Recommendation:** Routine follow-up'
326
- st.markdown(f"""
327
- - **Abnormal probability:** {ef_pct:.1f}%
328
- - **Decision threshold:** {ef_thr_pct:.1f}%
329
- - {action}
330
- """)
331
- st.progress(ef_prob, text=f'{ef_pct:.1f}%')
332
- st.markdown('</div>', unsafe_allow_html=True)
333
-
334
- with rc2:
335
- st.markdown('<div class="report-card"><h3>2. Regional Wall Motion</h3>',
336
- unsafe_allow_html=True)
337
- if rw_high:
338
- st.markdown('<div class="risk-high">⚠️ HIGH RISK</div>', unsafe_allow_html=True)
339
- action = '**Recommendation:** Consider coronary angiography'
340
- else:
341
- st.markdown('<div class="risk-low">✓ LOW RISK</div>', unsafe_allow_html=True)
342
- action = '**Recommendation:** No RWMA detected'
343
- st.markdown(f"""
344
- - **RWMA probability:** {rw_pct:.1f}%
345
- - **Decision threshold:** {rw_thr_pct:.1f}%
346
- - {action}
347
- """)
348
- st.progress(rw_prob, text=f'{rw_pct:.1f}%')
349
- st.markdown('</div>', unsafe_allow_html=True)
350
-
351
- # Step 5: Grad-CAM heatmaps
352
- st.markdown("""
353
- <div class="heatmap-section">
354
- <h2>🔥 AI Attention Heatmaps</h2>
355
- <p>The colored regions show <b>where the AI focused</b> when making its prediction.
356
- Red/yellow areas had the strongest influence, blue areas were less important.
357
- This helps clinicians verify the AI is looking at clinically meaningful regions.</p>
358
- </div>
359
- """, unsafe_allow_html=True)
360
-
361
- try:
362
- with st.spinner('🔥 Generating attention maps (~10-15 sec)...'):
363
- # Use first model from ensemble — Grad-CAM is for visualization, single model is fine
364
- overlay_ef = generate_gradcam_overlay(ensemble[0], img_preprocessed, task='ef')
365
- overlay_rwma = generate_gradcam_overlay(ensemble[0], img_preprocessed, task='rwma')
366
-
367
- tab_ef, tab_rwma, tab_both = st.tabs([
368
- '🫀 EF Attention',
369
- '📊 RWMA Attention',
370
- '👀 Side-by-Side',
371
- ])
372
-
373
- with tab_ef:
374
- st.image(overlay_ef,
375
- caption='Regions influencing Ejection Fraction prediction',
376
- use_column_width=True)
377
- st.caption('Bright/warm areas = stronger influence on the EF prediction')
378
-
379
- with tab_rwma:
380
- st.image(overlay_rwma,
381
- caption='Regions influencing Regional Wall Motion prediction',
382
- use_column_width=True)
383
- st.caption('Bright/warm areas = stronger influence on the RWMA prediction')
384
-
385
- with tab_both:
386
- sub_a, sub_b = st.columns(2)
387
- with sub_a:
388
- st.markdown('**EF Attention**')
389
- st.image(overlay_ef, use_column_width=True)
390
- with sub_b:
391
- st.markdown('**RWMA Attention**')
392
- st.image(overlay_rwma, use_column_width=True)
393
- except Exception as e:
394
- st.warning(f'Grad-CAM generation failed: {e}. Predictions are still valid.')
395
-
396
- # Step 6: Methodology badge
397
- st.markdown(f"""
398
- <div class="methodology">
399
- <b>🧠 Model:</b> 5-fold ensemble × {N_TTA} TTA = {len(ensemble) * N_TTA} votes
400
- &nbsp;|&nbsp; <b>Backbone:</b> EfficientNet-B3 (NoisyStudent)
401
- &nbsp;|&nbsp; <b>Validation AUC:</b> {MEAN_AUC:.3f}
402
- &nbsp;|&nbsp; <b>Device:</b> {DEVICE.type.upper()}
403
- &nbsp;|&nbsp; <b>Explainability:</b> Grad-CAM (conv_head layer)
404
- </div>
405
- """, unsafe_allow_html=True)
406
-
407
- finally:
408
- try: os.unlink(tmp_path)
409
- except: pass
410
-
411
- elif analyze_btn and uploaded_file is None:
412
- st.warning('⚠️ Please upload an ECG image first.')
413
-
414
- else:
415
- st.markdown("""
416
- <div style='padding: 60px 20px; text-align: center; color: #94a3b8;
417
- background: #f8fafc; border: 2px dashed #cbd5e1; border-radius: 12px;'>
418
- <h2 style='color: #94a3b8;'>📋 Awaiting ECG Analysis</h2>
419
- <p>Upload an ECG image and click <b>Analyze</b> to begin</p>
420
- <p style='font-size: 0.85em; margin-top: 20px;'>
421
- <b>NEW:</b> Now includes Grad-CAM attention heatmaps showing
422
- which regions of the ECG the AI used for its predictions.
423
- </p>
424
- </div>
425
- """, unsafe_allow_html=True)
426
-
427
-
428
- # Disclaimer
429
- st.markdown("""
430
- <div class="disclaimer">
431
- <b>⚠️ Important Medical Disclaimer</b><br>
432
- This is an AI <b>research prototype</b> for screening purposes only. It is <b>NOT</b>
433
- a substitute for clinical evaluation by a qualified cardiologist. All predictions must
434
- be reviewed by a healthcare professional. This system is <b>not approved for clinical
435
- diagnostic use</b>.
436
- </div>
437
- """, unsafe_allow_html=True)
438
-
439
- st.markdown("""
440
- <div style='text-align: center; margin-top: 24px; color: #6b7280; font-size: 0.85em;'>
441
- Built with PyTorch • EfficientNet-B3 • 5-fold Multilabel Stratified K-Fold ensemble
442
- • Grad-CAM for explainability
443
- </div>
444
- """, unsafe_allow_html=True)
 
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()