#!/usr/bin/env python3 """ V18+V19 Ensemble Inference Ensembles V18 (V16 + Cross-Row Refiner) and V19 (BiLSTM + Deformable Conv) models. Allows configurable blending ratio between the two models. Usage: python scripts/infer_v18_v19_ensemble.py --v18_weight 0.5 --v19_weight 0.5 python scripts/infer_v18_v19_ensemble.py --v18_weight 0.3 --v19_weight 0.7 """ import os import sys import argparse import random import numpy as np import pandas as pd from pathlib import Path from tqdm import tqdm from scipy.signal import savgol_filter import torch import torch.nn as nn import torch.nn.functional as F import cv2 import timm # Try to import torchvision deformable conv try: from torchvision.ops import DeformConv2d HAS_DEFORM_CONV = True except ImportError: HAS_DEFORM_CONV = False # ============================================================================= # Constants # ============================================================================= TARGET_HEIGHT, TARGET_WIDTH = 1696, 4352 ZERO_MV = np.array([703.5, 987.5, 1271.5, 1531.5]) MV_TO_PIXEL = 78.5 T0, T1 = 235, 4161 X0, X1 = 0, 2176 Y0, Y1 = 0, 1696 OUTPUT_WIDTH = T1 - T0 # 3926 # Per-row crop parameters CROP_HALF_HEIGHT = 250 ROW_HEIGHT = 500 # ECG amplitude limits (mV) ECG_MV_MIN, ECG_MV_MAX = -10.0, 10.0 # SNR threshold for "bad" predictions LOW_SNR_THRESHOLD = 5.0 # dB VALID_VARIANTS = ['0001', '0003', '0004', '0005', '0006', '0009', '0010', '0011', '0012'] VAL_SAMPLE_IDS = [ '1006427285', '1006867983', '1012423188', '10140238', '1015663939', '102150619', '1026034238', '1041099777', '104573050', '1048962695', '1052007218', '1053922973', '1059602762', '1063816858', '1067371646', '1067975047', '1068062585', '1072767337', '1084993373', '108599929' ] LEAD_LAYOUT = [ ['I', 'aVR', 'V1', 'V4'], ['II', 'aVL', 'V2', 'V5'], ['III', 'aVF', 'V3', 'V6'], ] # Baseline offsets for V18 (very small, ~0.0003 mV avg) V18_BASELINE_OFFSETS = { 'I': 0.0003, 'II': 0.0005, 'III': 0.0002, 'aVR': 0.0003, 'aVL': 0.0003, 'aVF': 0.0003, 'V1': 0.0003, 'V2': 0.0003, 'V3': 0.0003, 'V4': 0.0003, 'V5': 0.0003, 'V6': 0.0003, } # Baseline offsets for V19 (computed from 977 images, ~0.004 mV avg) V19_BASELINE_OFFSETS = { 'I': 0.0037, 'II': 0.0057, 'III': 0.0029, 'aVR': 0.0043, 'aVL': 0.0037, 'aVF': 0.0038, 'V1': 0.0045, 'V2': 0.0036, 'V3': 0.0038, 'V4': 0.0045, 'V5': 0.0040, 'V6': 0.0038, } # Checkpoint paths LOCAL_CHECKPOINT_DIR = '/data/ecg-digitization/checkpoints' # ============================================================================= # V16 Model Architecture (used by V18) # ============================================================================= class CoordConv2d(nn.Module): def __init__(self, in_channels, out_channels, kernel_size, **kwargs): super().__init__() self.conv = nn.Conv2d(in_channels + 2, out_channels, kernel_size, **kwargs) def forward(self, x): B, C, H, W = x.shape yy = torch.linspace(-1, 1, H, device=x.device).view(1, 1, H, 1).expand(B, 1, H, W) xx = torch.linspace(-1, 1, W, device=x.device).view(1, 1, 1, W).expand(B, 1, H, W) x = torch.cat([x, yy, xx], dim=1) return self.conv(x) class UNetDecoderBlock(nn.Module): def __init__(self, in_ch, skip_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch + skip_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.GELU(), nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.GELU(), ) self.upsample = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True) def forward(self, x, skip=None): x = self.upsample(x) if skip is not None: if x.shape[2:] != skip.shape[2:]: x = F.interpolate(x, size=skip.shape[2:], mode='bilinear', align_corners=True) x = torch.cat([x, skip], dim=1) return self.conv(x) class PerLeadNetV16(nn.Module): """V16 Per-Lead ECG Network (frozen, used as prior for V18).""" def __init__(self, encoder_name='convnext_base.fb_in22k_ft_in1k', pretrained=True): super().__init__() self.encoder = timm.create_model( encoder_name, pretrained=pretrained, features_only=True, out_indices=(0, 1, 2, 3), ) enc_channels = self.encoder.feature_info.channels() decoder_dims = [256, 128, 64, 32] self.dec_blocks = nn.ModuleList() in_ch = enc_channels[-1] skip_channels = enc_channels[:-1][::-1] + [0] for skip_ch, out_ch in zip(skip_channels, decoder_dims): self.dec_blocks.append(UNetDecoderBlock(in_ch, skip_ch, out_ch)) in_ch = out_ch self.final_up = nn.Sequential( nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True), nn.Conv2d(decoder_dims[-1], decoder_dims[-1], 3, padding=1, bias=False), nn.BatchNorm2d(decoder_dims[-1]), nn.GELU(), ) self.height_attention = nn.Sequential( CoordConv2d(decoder_dims[-1], 64, 3, padding=1), nn.BatchNorm2d(64), nn.GELU(), nn.Conv2d(64, 1, 1), ) self.regression_head = nn.Sequential( nn.Conv1d(decoder_dims[-1], 128, 7, padding=3), nn.BatchNorm1d(128), nn.GELU(), nn.Conv1d(128, 64, 5, padding=2), nn.BatchNorm1d(64), nn.GELU(), nn.Conv1d(64, 1, 1), ) def forward(self, x): B, C, H, W = x.shape features = self.encoder(x) d = features[-1] skips = features[:-1][::-1] + [None] for block, skip in zip(self.dec_blocks, skips): d = block(d, skip) d = self.final_up(d) if d.shape[3] != W: d = F.interpolate(d, size=(d.shape[2], W), mode='bilinear', align_corners=True) attn = self.height_attention(d) attn = F.softmax(attn, dim=2) d = (d * attn).sum(dim=2) out = self.regression_head(d) out = torch.sigmoid(out) return out.squeeze(1) # ============================================================================= # V18 Refiner Architecture # ============================================================================= class CrossRowAttention(nn.Module): def __init__(self, embed_dim, num_heads=8, dropout=0.1): super().__init__() self.num_heads = num_heads self.head_dim = embed_dim // num_heads self.scale = self.head_dim ** -0.5 self.qkv = nn.Linear(embed_dim, embed_dim * 3) self.proj = nn.Linear(embed_dim, embed_dim) self.dropout = nn.Dropout(dropout) self.norm = nn.LayerNorm(embed_dim) def forward(self, x): B, num_rows, C, W = x.shape x = x.permute(0, 3, 1, 2).reshape(B * W, num_rows, C) residual = x x = self.norm(x) qkv = self.qkv(x).reshape(B * W, num_rows, 3, self.num_heads, self.head_dim) qkv = qkv.permute(2, 0, 3, 1, 4) q, k, v = qkv[0], qkv[1], qkv[2] attn = (q @ k.transpose(-2, -1)) * self.scale attn = attn.softmax(dim=-1) attn = self.dropout(attn) out = (attn @ v).transpose(1, 2).reshape(B * W, num_rows, C) out = self.proj(out) out = self.dropout(out) out = out + residual out = out.reshape(B, W, num_rows, C).permute(0, 2, 3, 1) return out class CrossRowTransformerBlock(nn.Module): def __init__(self, embed_dim, num_heads=8, mlp_ratio=4.0, dropout=0.1): super().__init__() self.attn = CrossRowAttention(embed_dim, num_heads, dropout) self.norm = nn.LayerNorm(embed_dim) hidden_dim = int(embed_dim * mlp_ratio) self.ffn = nn.Sequential( nn.Linear(embed_dim, hidden_dim), nn.GELU(), nn.Dropout(dropout), nn.Linear(hidden_dim, embed_dim), nn.Dropout(dropout), ) def forward(self, x): x = self.attn(x) B, num_rows, C, W = x.shape residual = x x = x.permute(0, 3, 1, 2).reshape(B * W, num_rows, C) x = self.norm(x) x = self.ffn(x) + residual.permute(0, 3, 1, 2).reshape(B * W, num_rows, C) x = x.reshape(B, W, num_rows, C).permute(0, 2, 3, 1) return x class RefinerEncoder(nn.Module): def __init__(self, encoder_name='efficientnet_b0', pretrained=True): super().__init__() self.encoder = timm.create_model( encoder_name, pretrained=pretrained, features_only=True, out_indices=(1, 2, 3), in_chans=4, ) self.channels = self.encoder.feature_info.channels() def forward(self, x): return self.encoder(x) class V18RefinerNet(nn.Module): """V18 Refiner: Takes V16 predictions and refines them.""" def __init__(self, encoder_name='efficientnet_b0', pretrained=True, cross_row_layers=3, cross_row_dim=128, num_heads=4): super().__init__() self.row_encoder = RefinerEncoder(encoder_name, pretrained) enc_channels = self.row_encoder.channels self.feature_proj = nn.Sequential( nn.AdaptiveAvgPool2d((1, None)), nn.Flatten(1, 2), ) self.channel_proj = nn.Conv1d(enc_channels[-1], cross_row_dim, 1) self.cross_row_blocks = nn.ModuleList([ CrossRowTransformerBlock( embed_dim=cross_row_dim, num_heads=num_heads, mlp_ratio=2.0, dropout=0.1 ) for _ in range(cross_row_layers) ]) self.residual_head = nn.Sequential( nn.Conv1d(cross_row_dim, 64, 5, padding=2), nn.BatchNorm1d(64), nn.GELU(), nn.Conv1d(64, 32, 3, padding=1), nn.BatchNorm1d(32), nn.GELU(), nn.Conv1d(32, 1, 1), nn.Tanh(), ) self.residual_scale = nn.Parameter(torch.tensor(0.1)) def create_guide_channel(self, v16_pred, height, sigma=15.0): B, W = v16_pred.shape device = v16_pred.device y_pred = v16_pred * height y_grid = torch.arange(height, device=device, dtype=torch.float32).view(1, height, 1) y_pred = y_pred.unsqueeze(1) guide = torch.exp(-0.5 * ((y_grid - y_pred) / sigma) ** 2) return guide.unsqueeze(1) def forward(self, images, v16_preds): B, num_rows, C, H, W = images.shape all_features = [] for row_idx in range(num_rows): row_image = images[:, row_idx] row_v16 = v16_preds[:, row_idx] guide = self.create_guide_channel(row_v16, H) row_input = torch.cat([row_image, guide], dim=1) enc_features = self.row_encoder(row_input) row_feat = enc_features[-1] row_feat = self.feature_proj(row_feat) row_feat = F.interpolate(row_feat, size=W, mode='linear', align_corners=True) row_feat = self.channel_proj(row_feat) all_features.append(row_feat) features = torch.stack(all_features, dim=1) for block in self.cross_row_blocks: features = block(features) residuals = [] for row_idx in range(num_rows): row_feat = features[:, row_idx] residual = self.residual_head(row_feat) residuals.append(residual.squeeze(1)) residuals = torch.stack(residuals, dim=1) scaled_residuals = residuals * self.residual_scale * 0.1 refined = torch.clamp(v16_preds + scaled_residuals, 0, 1) return refined, scaled_residuals # ============================================================================= # V19 Model Architecture # ============================================================================= class DeformableConvBlock(nn.Module): def __init__(self, in_ch, out_ch, kernel_size=3, stride=1, padding=1): super().__init__() self.kernel_size = kernel_size self.padding = padding self.stride = stride if HAS_DEFORM_CONV: self.offset_conv = nn.Sequential( nn.Conv2d(in_ch, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.Conv2d(64, 2 * kernel_size * kernel_size, 3, padding=1), ) nn.init.zeros_(self.offset_conv[-1].weight) nn.init.zeros_(self.offset_conv[-1].bias) self.deform_conv = DeformConv2d(in_ch, out_ch, kernel_size, stride=stride, padding=padding) else: self.conv = nn.Conv2d(in_ch, out_ch, kernel_size, stride=stride, padding=padding) self.norm = nn.BatchNorm2d(out_ch) self.act = nn.GELU() def forward(self, x): if HAS_DEFORM_CONV: offset = self.offset_conv(x) out = self.deform_conv(x, offset) else: out = self.conv(x) out = self.norm(out) out = self.act(out) return out class BiLSTMHead(nn.Module): def __init__(self, input_dim, hidden_dim=128, num_layers=2, dropout=0.1): super().__init__() self.lstm = nn.LSTM( input_size=input_dim, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, bidirectional=True, dropout=dropout if num_layers > 1 else 0, ) self.output_proj = nn.Sequential( nn.Linear(hidden_dim * 2, hidden_dim), nn.LayerNorm(hidden_dim), nn.GELU(), ) self.output_dim = hidden_dim def forward(self, x): x = x.permute(0, 2, 1) lstm_out, _ = self.lstm(x) return self.output_proj(lstm_out) class AuxiliaryHeads(nn.Module): def __init__(self, feature_dim): super().__init__() self.grid_head = nn.Sequential( nn.Conv2d(32, 16, 3, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.Conv2d(16, 1, 1), nn.Sigmoid(), ) self.gradient_head = nn.Sequential( nn.Linear(feature_dim, 64), nn.GELU(), nn.Linear(64, 1), nn.Tanh(), ) self.uncertainty_head = nn.Sequential( nn.Linear(feature_dim, 64), nn.GELU(), nn.Linear(64, 1), ) def forward(self, features_2d, features_1d): grid_pred = self.grid_head(features_2d) gradient_pred = self.gradient_head(features_1d).squeeze(-1) log_var = self.uncertainty_head(features_1d).squeeze(-1) return grid_pred, gradient_pred, log_var class UNetDecoderBlockV19(nn.Module): def __init__(self, in_ch, skip_ch, out_ch, use_deform=False): super().__init__() if use_deform and HAS_DEFORM_CONV: self.conv1 = DeformableConvBlock(in_ch + skip_ch, out_ch) else: self.conv1 = nn.Sequential( nn.Conv2d(in_ch + skip_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.GELU(), ) self.conv2 = nn.Sequential( nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.GELU(), ) self.upsample = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True) def forward(self, x, skip=None): x = self.upsample(x) if skip is not None: if x.shape[2:] != skip.shape[2:]: x = F.interpolate(x, size=skip.shape[2:], mode='bilinear', align_corners=True) x = torch.cat([x, skip], dim=1) x = self.conv1(x) x = self.conv2(x) return x class PerLeadNetV19(nn.Module): """V19 Per-Lead ECG Network with BiLSTM + Deformable Conv.""" def __init__(self, encoder_name='convnext_base.fb_in22k_ft_in1k', pretrained=True): super().__init__() self.encoder = timm.create_model( encoder_name, pretrained=pretrained, features_only=True, out_indices=(0, 1, 2, 3), ) enc_channels = self.encoder.feature_info.channels() decoder_dims = [256, 128, 64, 32] self.dec_blocks = nn.ModuleList() in_ch = enc_channels[-1] skip_channels = enc_channels[:-1][::-1] + [0] for i, (skip_ch, out_ch) in enumerate(zip(skip_channels, decoder_dims)): use_deform = (i >= 2) self.dec_blocks.append(UNetDecoderBlockV19(in_ch, skip_ch, out_ch, use_deform)) in_ch = out_ch self.final_up = nn.Sequential( nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True), nn.Conv2d(decoder_dims[-1], decoder_dims[-1], 3, padding=1, bias=False), nn.BatchNorm2d(decoder_dims[-1]), nn.GELU(), ) self.height_attention = nn.Sequential( CoordConv2d(decoder_dims[-1], 64, 3, padding=1), nn.BatchNorm2d(64), nn.GELU(), nn.Conv2d(64, 1, 1), ) self.bilstm = BiLSTMHead( input_dim=decoder_dims[-1], hidden_dim=128, num_layers=2, dropout=0.1, ) self.regression_head = nn.Sequential( nn.Linear(self.bilstm.output_dim, 64), nn.GELU(), nn.Linear(64, 1), nn.Sigmoid(), ) self.aux_heads = AuxiliaryHeads(self.bilstm.output_dim) def forward(self, x, return_aux=False): B, C, H, W = x.shape features = self.encoder(x) d = features[-1] skips = features[:-1][::-1] + [None] for block, skip in zip(self.dec_blocks, skips): d = block(d, skip) features_2d = d d = self.final_up(d) if d.shape[3] != W: d = F.interpolate(d, size=(d.shape[2], W), mode='bilinear', align_corners=True) attn = self.height_attention(d) attn = F.softmax(attn, dim=2) pooled = (d * attn).sum(dim=2) temporal_features = self.bilstm(pooled) y_pred = self.regression_head(temporal_features).squeeze(-1) if return_aux: grid_pred, gradient_pred, log_var = self.aux_heads(features_2d, temporal_features) return y_pred, {'grid': grid_pred, 'gradient': gradient_pred, 'log_var': log_var} return y_pred # ============================================================================= # Post-Processing Functions # ============================================================================= def apply_savgol_smoothing(signal_mv, window=7, polyorder=2): if len(signal_mv) >= window: return savgol_filter(signal_mv, window_length=window, polyorder=polyorder) return signal_mv def apply_einthoven_correction(pred_mv_rows, alpha=0.33): segment_width = len(pred_mv_rows[0]) // 4 lead_I = pred_mv_rows[0][:segment_width].copy() lead_II_short = pred_mv_rows[1][:segment_width].copy() lead_III = pred_mv_rows[2][:segment_width].copy() derived_II = lead_I + lead_III error = lead_II_short - derived_II pred_mv_rows[0][:segment_width] = lead_I + alpha * error pred_mv_rows[2][:segment_width] = lead_III + alpha * error return pred_mv_rows def clamp_ecg_amplitude(signal_mv): return np.clip(signal_mv, ECG_MV_MIN, ECG_MV_MAX) def interpolate_nan(signal_1d): valid_mask = np.isfinite(signal_1d) if valid_mask.all(): return signal_1d if not valid_mask.any(): return np.zeros_like(signal_1d) x = np.arange(len(signal_1d)) signal_1d[~valid_mask] = np.interp(x[~valid_mask], x[valid_mask], signal_1d[valid_mask]) return signal_1d def apply_baseline_correction(pred_mv_rows, offsets): segment_width = len(pred_mv_rows[0]) // 4 for row_idx in range(3): lead_names = LEAD_LAYOUT[row_idx] for seg_idx, lead_name in enumerate(lead_names): offset = offsets.get(lead_name, 0.0) seg_start = seg_idx * segment_width seg_end = (seg_idx + 1) * segment_width pred_mv_rows[row_idx][seg_start:seg_end] -= offset pred_mv_rows[3] -= offsets.get('II', 0.0) return pred_mv_rows # ============================================================================= # Model Loading # ============================================================================= def load_v18_models(v16_checkpoint, v18_checkpoint, device): """Load V16 + V18 refiner models.""" # V16 base model v16_model = PerLeadNetV16(encoder_name='convnext_base.fb_in22k_ft_in1k', pretrained=False) v16_ckpt = torch.load(v16_checkpoint, map_location=device, weights_only=False) state_dict = v16_ckpt['model'] if any(k.startswith('module.') for k in state_dict.keys()): state_dict = {k.replace('module.', ''): v for k, v in state_dict.items()} v16_model.load_state_dict(state_dict) v16_model = v16_model.to(device).eval() # V18 refiner v18_refiner = V18RefinerNet(encoder_name='efficientnet_b0', pretrained=False) v18_ckpt = torch.load(v18_checkpoint, map_location=device, weights_only=False) v18_refiner.load_state_dict(v18_ckpt['refiner']) v18_refiner = v18_refiner.to(device).eval() v16_epoch = v16_ckpt.get('epoch', '?') v16_snr = v16_ckpt.get('snr', 0) v18_epoch = v18_ckpt.get('epoch', '?') v18_snr = v18_ckpt.get('snr', 0) print(f"Loaded V16 epoch {v16_epoch}, SNR: {v16_snr:.2f} dB") print(f"Loaded V18 epoch {v18_epoch}, SNR: {v18_snr:.2f} dB") return v16_model, v18_refiner def load_v19_model(checkpoint, device): """Load V19 model.""" model = PerLeadNetV19(encoder_name='convnext_base.fb_in22k_ft_in1k', pretrained=False) ckpt = torch.load(checkpoint, map_location=device, weights_only=False) state_dict = ckpt['model'] if any(k.startswith('module.') for k in state_dict.keys()): state_dict = {k.replace('module.', ''): v for k, v in state_dict.items()} model.load_state_dict(state_dict) model = model.to(device).eval() epoch = ckpt.get('epoch', '?') snr = ckpt.get('snr', ckpt.get('best_snr', 0)) print(f"Loaded V19 epoch {epoch}, SNR: {snr:.2f} dB") return model # ============================================================================= # Inference Functions # ============================================================================= def crop_row(image, row_idx): baseline_y = int(ZERO_MV[row_idx]) y_start = max(0, baseline_y - CROP_HALF_HEIGHT) y_end = min(TARGET_HEIGHT, baseline_y + CROP_HALF_HEIGHT) row_crop = image[y_start:y_end, T0:T1, :].copy() if row_crop.shape[0] < ROW_HEIGHT: pad_top = max(0, CROP_HALF_HEIGHT - baseline_y) pad_bottom = max(0, (baseline_y + CROP_HALF_HEIGHT) - TARGET_HEIGHT) row_crop = np.pad(row_crop, ((pad_top, pad_bottom), (0, 0), (0, 0)), mode='edge') return row_crop @torch.no_grad() def predict_v18(v16_model, v18_refiner, row_crops, device): """Run V16 + V18 refiner on all 4 row crops.""" row_tensors = [] for crop in row_crops: t = torch.from_numpy(crop.astype(np.float32) / 255.0).permute(2, 0, 1) row_tensors.append(t) images = torch.stack(row_tensors, dim=0).unsqueeze(0).to(device) with torch.amp.autocast('cuda', dtype=torch.float16): v16_preds = [] for row_idx in range(4): row_pred = v16_model(images[:, row_idx]) v16_preds.append(row_pred) v16_preds = torch.stack(v16_preds, dim=1) refined, _ = v18_refiner(images, v16_preds) refined_np = refined[0].cpu().numpy() pred_mv_rows = {} for row_idx in range(4): pred_y_crop = refined_np[row_idx] * ROW_HEIGHT baseline_y = int(ZERO_MV[row_idx]) y_start = max(0, baseline_y - CROP_HALF_HEIGHT) pad_top = max(0, CROP_HALF_HEIGHT - baseline_y) pred_y_full = pred_y_crop - pad_top + y_start pred_mv = (ZERO_MV[row_idx] - pred_y_full) / MV_TO_PIXEL pred_mv_rows[row_idx] = pred_mv return pred_mv_rows @torch.no_grad() def predict_v19(model, row_crops, device): """Run V19 on all 4 row crops.""" pred_mv_rows = {} for row_idx in range(4): row_crop = row_crops[row_idx] image_tensor = torch.from_numpy(row_crop.astype(np.float32) / 255.0) image_tensor = image_tensor.permute(2, 0, 1).unsqueeze(0).to(device) with torch.amp.autocast('cuda'): output = model(image_tensor, return_aux=False) pred_y_crop = output[0].cpu().numpy() * ROW_HEIGHT baseline_y = int(ZERO_MV[row_idx]) y_start = max(0, baseline_y - CROP_HALF_HEIGHT) pad_top = max(0, CROP_HALF_HEIGHT - baseline_y) pred_y_full = pred_y_crop - pad_top + y_start pred_mv = (ZERO_MV[row_idx] - pred_y_full) / MV_TO_PIXEL pred_mv_rows[row_idx] = pred_mv return pred_mv_rows def ensemble_predictions(v18_pred, v19_pred, v18_weight, v19_weight): """Ensemble V18 and V19 predictions with given weights.""" ensemble = {} for row_idx in range(4): ensemble[row_idx] = v18_weight * v18_pred[row_idx] + v19_weight * v19_pred[row_idx] return ensemble def visualize_predictions(image, predictions, output_path): """Draw predictions on the image.""" vis_image = image[:, T0:T1, :].copy() colors = [(255, 0, 0), (0, 255, 0), (255, 0, 255), (0, 165, 255)] for row_idx, pred_mv in predictions.items(): color = colors[row_idx] baseline_y = ZERO_MV[row_idx] pred_y = baseline_y - pred_mv * MV_TO_PIXEL for x in range(len(pred_y)): y = int(round(pred_y[x])) if 0 <= y < vis_image.shape[0]: cv2.circle(vis_image, (x, y), 1, color, -1) cv2.imwrite(str(output_path), vis_image) def compute_snr_per_lead(pred_mv_rows, df, epsilon=1e-10): """Compute SNR in dB for each lead.""" segment_width = OUTPUT_WIDTH // 4 all_lead_snrs = {} for row_idx in range(3): lead_names = LEAD_LAYOUT[row_idx] baseline_y = ZERO_MV[row_idx] for seg_idx, lead_name in enumerate(lead_names): if lead_name not in df.columns: all_lead_snrs[lead_name] = None continue gt_mv = df[lead_name].dropna().values if len(gt_mv) == 0: all_lead_snrs[lead_name] = None continue if lead_name == 'II': ref_lead = 'I' if 'I' in df.columns else 'III' if ref_lead in df.columns: ref_len = len(df[ref_lead].dropna().values) if len(gt_mv) > ref_len: gt_mv = gt_mv[:ref_len] seg_start = seg_idx * segment_width seg_end = (seg_idx + 1) * segment_width pred_mv = pred_mv_rows[row_idx][seg_start:seg_end] x_pred = np.linspace(0, 1, len(pred_mv)) x_gt = np.linspace(0, 1, len(gt_mv)) pred_mv_resampled = np.interp(x_gt, x_pred, pred_mv) signal_power = (gt_mv ** 2).mean() noise_power = ((pred_mv_resampled - gt_mv) ** 2).mean() if noise_power < epsilon: all_lead_snrs[lead_name] = 50.0 else: all_lead_snrs[lead_name] = 10 * np.log10(signal_power / noise_power) # Lead II rhythm strip if 'II' in df.columns: gt_mv = df['II'].dropna().values if len(gt_mv) > 0: baseline_y = ZERO_MV[3] pred_mv = pred_mv_rows[3] x_pred = np.linspace(0, 1, len(pred_mv)) x_gt = np.linspace(0, 1, len(gt_mv)) pred_mv_resampled = np.interp(x_gt, x_pred, pred_mv) signal_power = (gt_mv ** 2).mean() noise_power = ((pred_mv_resampled - gt_mv) ** 2).mean() if noise_power < epsilon: all_lead_snrs['II_rhythm'] = 50.0 else: all_lead_snrs['II_rhythm'] = 10 * np.log10(signal_power / noise_power) return all_lead_snrs def process_image(v16_model, v18_refiner, v19_model, image_path, csv_path, output_dir, device, v18_weight=0.5, v19_weight=0.5, apply_smoothing=True, apply_einthoven=True, apply_baseline_fix=True, negative_dir=None): """Process a single image with ensemble.""" image = cv2.imread(str(image_path), cv2.IMREAD_COLOR) if image is None: print(f"Failed to load: {image_path}") return None, None, None image = image[Y0:Y1, X0:X1] image = cv2.resize(image, (TARGET_WIDTH, TARGET_HEIGHT), interpolation=cv2.INTER_LINEAR) df = pd.read_csv(csv_path) # Crop all 4 rows row_crops = [crop_row(image, row_idx) for row_idx in range(4)] # Get predictions from both models v18_pred = predict_v18(v16_model, v18_refiner, row_crops, device) v19_pred = predict_v19(v19_model, row_crops, device) # Ensemble pred_mv_rows = ensemble_predictions(v18_pred, v19_pred, v18_weight, v19_weight) # Post-processing for row_idx in range(4): if apply_smoothing: pred_mv_rows[row_idx] = apply_savgol_smoothing(pred_mv_rows[row_idx], window=7, polyorder=2) pred_mv_rows[row_idx] = clamp_ecg_amplitude(pred_mv_rows[row_idx]) pred_mv_rows[row_idx] = interpolate_nan(pred_mv_rows[row_idx].copy()) if apply_einthoven: pred_mv_rows = apply_einthoven_correction(pred_mv_rows, alpha=0.33) if apply_baseline_fix: # Blend baseline offsets based on weights blended_offsets = {} for lead in V18_BASELINE_OFFSETS.keys(): blended_offsets[lead] = (v18_weight * V18_BASELINE_OFFSETS[lead] + v19_weight * V19_BASELINE_OFFSETS[lead]) pred_mv_rows = apply_baseline_correction(pred_mv_rows, blended_offsets) # Compute SNR all_lead_snrs = compute_snr_per_lead(pred_mv_rows, df) valid_snrs = [v for v in all_lead_snrs.values() if v is not None] min_snr = min(valid_snrs) if valid_snrs else 0.0 # Visualize sample_id = image_path.parent.name variant = image_path.stem.split('-')[-1] if '-' in image_path.stem else '0000' output_path = output_dir / f"{sample_id}_{variant}.png" visualize_predictions(image, pred_mv_rows, output_path) if negative_dir is not None and min_snr < LOW_SNR_THRESHOLD: neg_output_path = negative_dir / f"{sample_id}_{variant}_snr{min_snr:.1f}.png" visualize_predictions(image, pred_mv_rows, neg_output_path) return all_lead_snrs, min_snr, output_path def main(): parser = argparse.ArgumentParser(description='V18+V19 Ensemble Inference') parser.add_argument('--v16_checkpoint', type=str, default=f'{LOCAL_CHECKPOINT_DIR}/v16_perlead_best_snr.pth', help='V16 checkpoint path') parser.add_argument('--v18_checkpoint', type=str, default=f'{LOCAL_CHECKPOINT_DIR}/v18_refiner_best.pth', help='V18 refiner checkpoint path') parser.add_argument('--v19_checkpoint', type=str, default=f'{LOCAL_CHECKPOINT_DIR}/v19_enhanced_epoch010.pth', help='V19 checkpoint path') parser.add_argument('--v18_weight', type=float, default=0.5, help='Weight for V18 predictions (0 to 1)') parser.add_argument('--v19_weight', type=float, default=0.5, help='Weight for V19 predictions (0 to 1)') parser.add_argument('--kaggle_data', type=str, default='/data/ecg-digitization/stage1_data/train') parser.add_argument('--output_dir', type=str, default=os.path.expanduser('~/tmp/pred/v18v19_ensemble')) parser.add_argument('--num_samples', type=int, default=None) parser.add_argument('--use_holdout', action='store_true', default=True) parser.add_argument('--no_smoothing', action='store_true') parser.add_argument('--no_einthoven', action='store_true') parser.add_argument('--no_baseline_fix', action='store_true') parser.add_argument('--seed', type=int, default=42) args = parser.parse_args() # Normalize weights total_weight = args.v18_weight + args.v19_weight if total_weight <= 0: print("Error: Weights must sum to > 0") sys.exit(1) v18_weight = args.v18_weight / total_weight v19_weight = args.v19_weight / total_weight random.seed(args.seed) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') output_dir = Path(args.output_dir) negative_dir = output_dir / 'negpreds' print(f"{'='*70}") print(f"V18 + V19 Ensemble Inference") print(f"{'='*70}") print(f"V18 weight: {v18_weight:.2%}") print(f"V19 weight: {v19_weight:.2%}") print(f"Device: {device}") print(f"Deformable Conv: {HAS_DEFORM_CONV}") print(f"{'='*70}") # Load models print("\nLoading V18 (V16 + Refiner)...") v16_model, v18_refiner = load_v18_models(args.v16_checkpoint, args.v18_checkpoint, device) print("\nLoading V19...") v19_model = load_v19_model(args.v19_checkpoint, device) # Setup output directory if output_dir.exists(): import shutil shutil.rmtree(output_dir) output_dir.mkdir(parents=True, exist_ok=True) negative_dir.mkdir(parents=True, exist_ok=True) print(f"\nOutput: {output_dir}") print(f"Smoothing: {'OFF' if args.no_smoothing else 'ON'}") print(f"Einthoven: {'OFF' if args.no_einthoven else 'ON'}") print(f"Baseline fix: {'OFF' if args.no_baseline_fix else 'ON'}") print(f"{'='*70}") # Find images kaggle_dir = Path(args.kaggle_data) val_sample_set = set(VAL_SAMPLE_IDS) all_samples = [] for sample_dir in kaggle_dir.iterdir(): if not sample_dir.is_dir(): continue if args.use_holdout and sample_dir.name not in val_sample_set: continue csv_files = list(sample_dir.glob('*.csv')) if len(csv_files) != 1: continue csv_path = csv_files[0] for img_path in sample_dir.glob('*.png'): variant = img_path.stem.split('-')[-1] if '-' in img_path.stem else '0000' if variant in VALID_VARIANTS: all_samples.append((img_path, csv_path)) print(f"Found {len(all_samples)} valid images") if args.num_samples is not None and len(all_samples) > args.num_samples: selected = random.sample(all_samples, args.num_samples) else: selected = all_samples print(f"Processing {len(selected)} images...") all_snrs = {lead: [] for lead in ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6', 'II_rhythm']} for img_path, csv_path in tqdm(selected): try: lead_snrs, min_snr, _ = process_image( v16_model, v18_refiner, v19_model, img_path, csv_path, output_dir, device, v18_weight=v18_weight, v19_weight=v19_weight, apply_smoothing=not args.no_smoothing, apply_einthoven=not args.no_einthoven, apply_baseline_fix=not args.no_baseline_fix, negative_dir=negative_dir ) if lead_snrs: for lead, snr in lead_snrs.items(): if snr is not None: all_snrs[lead].append(snr) except Exception as e: print(f"Error processing {img_path}: {e}") import traceback traceback.print_exc() # Print results print(f"\n{'='*70}") print(f"Per-Lead SNR Statistics (dB) - V18:{v18_weight:.0%} + V19:{v19_weight:.0%}") print(f"{'='*70}") print(f"{'Lead':<12} {'Mean':>8} {'Std':>8} {'Min':>8} {'Max':>8} {'Count':>6}") print(f"{'-'*70}") total_snrs = [] for lead in ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6', 'II_rhythm']: snrs = all_snrs[lead] if len(snrs) > 0: print(f"{lead:<12} {np.mean(snrs):>8.2f} {np.std(snrs):>8.2f} " f"{np.min(snrs):>8.2f} {np.max(snrs):>8.2f} {len(snrs):>6}") total_snrs.extend(snrs) print(f"{'-'*70}") if len(total_snrs) > 0: print(f"{'OVERALL':<12} {np.mean(total_snrs):>8.2f} {np.std(total_snrs):>8.2f} " f"{np.min(total_snrs):>8.2f} {np.max(total_snrs):>8.2f} {len(total_snrs):>6}") print(f"{'='*70}") print(f"\nDone! Saved {len(selected)} visualizations to {output_dir}") if __name__ == '__main__': main()