| |
| """ |
| V18 Inference with Visualization |
| |
| Runs V18 (V16 frozen + Refiner with Cross-Row Attention) on Kaggle images. |
| Automatically SCPs the latest checkpoint from remote training VM. |
| |
| Shows both V16 (baseline) and V18 (refined) predictions for comparison. |
| """ |
|
|
| import os |
| import sys |
| import argparse |
| import random |
| import subprocess |
| import numpy as np |
| import pandas as pd |
| from pathlib import Path |
| from tqdm import tqdm |
| from scipy.signal import savgol_filter, find_peaks |
| from scipy.ndimage import gaussian_filter1d |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
| import cv2 |
| import timm |
|
|
|
|
| |
| |
| |
| 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 |
|
|
| |
| CROP_HALF_HEIGHT = 250 |
| ROW_HEIGHT = 500 |
|
|
| |
| INPUT_HEIGHT = ROW_HEIGHT |
| INPUT_WIDTH = OUTPUT_WIDTH |
|
|
| |
| ECG_MV_MIN, ECG_MV_MAX = -10.0, 10.0 |
|
|
| |
| LOW_SNR_THRESHOLD = 5.0 |
|
|
| 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'], |
| ] |
|
|
| |
| |
| |
| V18_BENEFICIAL_LEADS = {'I', 'III', 'aVR', 'aVF', 'V1', 'V6', 'II_rhythm'} |
|
|
| |
| REMOTE_HOST = os.environ.get('REMOTE_TRAIN_HOST', 'azureuser@172.212.222.231') |
| REMOTE_CHECKPOINT_DIR = '/data/ecg-digitization/checkpoints' |
| REMOTE_V16_CHECKPOINT = 'v16_perlead_best_snr.pth' |
| REMOTE_V18_CHECKPOINT_PATTERN = 'v18_refiner' |
|
|
|
|
| |
| |
| |
| def scp_checkpoint(remote_host, remote_path, local_path): |
| """SCP a single checkpoint file from remote VM.""" |
| local_path = Path(local_path) |
| local_path.parent.mkdir(parents=True, exist_ok=True) |
| |
| print(f" Fetching: {remote_host}:{remote_path}") |
| |
| try: |
| |
| if local_path.exists(): |
| size_cmd = f"ssh {remote_host} 'stat -c %s {remote_path}'" |
| size_result = subprocess.run(size_cmd, shell=True, capture_output=True, text=True, timeout=10) |
| if size_result.returncode == 0: |
| remote_size = int(size_result.stdout.strip()) |
| local_size = local_path.stat().st_size |
| if remote_size == local_size: |
| print(f" ✓ Local copy up-to-date: {local_path}") |
| return local_path |
| |
| |
| print(f" Downloading to: {local_path}") |
| scp_cmd = f"scp {remote_host}:{remote_path} {local_path}" |
| result = subprocess.run(scp_cmd, shell=True, capture_output=True, text=True, timeout=300) |
| |
| if result.returncode != 0: |
| print(f"ERROR: SCP failed - {result.stderr}") |
| return None |
| |
| print(f" ✓ Downloaded ({local_path.stat().st_size / 1024 / 1024:.1f} MB)") |
| return local_path |
| |
| except Exception as e: |
| print(f"ERROR: {e}") |
| return None |
|
|
|
|
| def scp_latest_checkpoint(remote_host, remote_dir, pattern, local_dir): |
| """SCP the latest checkpoint matching pattern from remote VM.""" |
| local_dir = Path(local_dir) |
| local_dir.mkdir(parents=True, exist_ok=True) |
| |
| |
| find_cmd = f"ssh {remote_host} 'ls -t {remote_dir}/{pattern}*.pth 2>/dev/null | head -1'" |
| |
| try: |
| result = subprocess.run(find_cmd, shell=True, capture_output=True, text=True, timeout=30) |
| if result.returncode != 0 or not result.stdout.strip(): |
| print(f" No checkpoint found matching '{pattern}'") |
| return None |
| |
| remote_path = result.stdout.strip() |
| filename = os.path.basename(remote_path) |
| local_path = local_dir / filename |
| |
| return scp_checkpoint(remote_host, remote_path, local_path) |
| |
| except Exception as e: |
| print(f"ERROR: {e}") |
| return None |
|
|
|
|
| |
| |
| |
| 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).""" |
| |
| 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) |
|
|
|
|
| |
| |
| |
| class CrossRowAttention(nn.Module): |
| """Cross-Row Multi-Head Self-Attention.""" |
| 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): |
| """Full transformer block with cross-row attention and FFN.""" |
| 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): |
| """Lightweight encoder for refinement (4 input channels: RGB + guide).""" |
| 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 Network: 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): |
| """Create a Gaussian guide channel from V16 prediction.""" |
| B, W = v16_pred.shape |
| device = v16_pred.device |
| |
| y_pred = v16_pred * height |
| y_grid = torch.arange(height, device=device, dtype=torch.float32) |
| y_grid = y_grid.view(1, height, 1) |
| y_pred = y_pred.unsqueeze(1) |
| |
| guide = torch.exp(-0.5 * ((y_grid - y_pred) / sigma) ** 2) |
| guide = guide.unsqueeze(1) |
| |
| return guide |
| |
| 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 |
|
|
|
|
| class V18Pipeline(nn.Module): |
| """Full V18 pipeline: V16 (frozen) + Refiner.""" |
| |
| def __init__(self, v16_checkpoint_path, refiner_encoder='efficientnet_b0'): |
| super().__init__() |
| |
| |
| print(f"Loading V16 from: {v16_checkpoint_path}") |
| self.v16 = PerLeadNetV16(pretrained=False) |
| checkpoint = torch.load(v16_checkpoint_path, map_location='cpu', weights_only=False) |
| state_dict = checkpoint['model'] |
| if list(state_dict.keys())[0].startswith('module.'): |
| state_dict = {k[7:]: v for k, v in state_dict.items()} |
| self.v16.load_state_dict(state_dict) |
| |
| for param in self.v16.parameters(): |
| param.requires_grad = False |
| self.v16.eval() |
| |
| print(f"V16 loaded (epoch {checkpoint.get('epoch', '?')}, SNR {checkpoint.get('snr', 0):.2f} dB)") |
| |
| self.refiner = V18RefinerNet( |
| encoder_name=refiner_encoder, |
| pretrained=False, |
| cross_row_layers=3, |
| cross_row_dim=128, |
| num_heads=4, |
| ) |
| |
| def forward(self, images): |
| B, num_rows, C, H, W = images.shape |
| |
| with torch.no_grad(): |
| v16_preds = [] |
| for row_idx in range(num_rows): |
| row_pred = self.v16(images[:, row_idx]) |
| v16_preds.append(row_pred) |
| v16_preds = torch.stack(v16_preds, dim=1) |
| |
| refined, residuals = self.refiner(images, v16_preds) |
| |
| return v16_preds, refined, residuals |
|
|
|
|
| |
| |
| |
| def apply_savgol_smoothing(signal_mv, window=7, polyorder=2): |
| """Apply Savitzky-Golay smoothing to remove high-frequency noise.""" |
| 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): |
| """Apply Einthoven's law correction on short lead segments.""" |
| 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 |
| |
| lead_I_corrected = lead_I + alpha * error |
| lead_III_corrected = lead_III + alpha * error |
| |
| pred_mv_rows[0][:segment_width] = lead_I_corrected |
| pred_mv_rows[2][:segment_width] = lead_III_corrected |
| |
| return pred_mv_rows |
|
|
|
|
| def clamp_ecg_amplitude(signal_mv): |
| """Clamp signal to reasonable ECG range.""" |
| return np.clip(signal_mv, ECG_MV_MIN, ECG_MV_MAX) |
|
|
|
|
| def interpolate_nan(signal_1d): |
| """Interpolate NaN values from valid neighbors.""" |
| 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_qrs_sharpening(signal_mv, |
| peak_prominence=0.3, |
| sharpening_radius=5, |
| sharpening_strength=0.5, |
| min_peak_distance=20): |
| """ |
| Apply QRS sharpening to enhance R-peaks and S-waves. |
| |
| The idea: V18 refinement might slightly blur sharp QRS complexes. |
| This detects peaks/troughs and enhances them by reducing local smoothing. |
| |
| Args: |
| signal_mv: Signal in mV |
| peak_prominence: Minimum prominence to detect as QRS peak (mV) |
| sharpening_radius: Pixels around peak to sharpen |
| sharpening_strength: How much to enhance (0=none, 1=full unsmoothing) |
| min_peak_distance: Minimum distance between peaks (samples) |
| |
| Returns: |
| Sharpened signal in mV |
| """ |
| signal = signal_mv.copy() |
| |
| |
| derivative = np.gradient(signal) |
| abs_derivative = np.abs(derivative) |
| |
| |
| smoothed_deriv = gaussian_filter1d(abs_derivative, sigma=3) |
| |
| |
| r_peaks, r_props = find_peaks( |
| signal, |
| prominence=peak_prominence, |
| distance=min_peak_distance |
| ) |
| |
| |
| s_peaks, s_props = find_peaks( |
| -signal, |
| prominence=peak_prominence, |
| distance=min_peak_distance |
| ) |
| |
| |
| all_peaks = np.concatenate([r_peaks, s_peaks]) |
| |
| if len(all_peaks) == 0: |
| return signal |
| |
| |
| sharpening_mask = np.zeros_like(signal) |
| |
| for peak_idx in all_peaks: |
| |
| start = max(0, peak_idx - sharpening_radius * 3) |
| end = min(len(signal), peak_idx + sharpening_radius * 3) |
| |
| for i in range(start, end): |
| dist = abs(i - peak_idx) |
| weight = np.exp(-0.5 * (dist / sharpening_radius) ** 2) |
| sharpening_mask[i] = max(sharpening_mask[i], weight) |
| |
| |
| if len(signal) >= 5: |
| sharp_signal = savgol_filter(signal, window_length=5, polyorder=2) |
| else: |
| sharp_signal = signal |
| |
| |
| |
| blend_weight = sharpening_mask * sharpening_strength |
| output = signal * (1 - blend_weight) + sharp_signal * blend_weight |
| |
| |
| for peak_idx in r_peaks: |
| if 0 <= peak_idx < len(output): |
| |
| window_start = max(0, peak_idx - 2) |
| window_end = min(len(output), peak_idx + 3) |
| local_max_idx = window_start + np.argmax(signal[window_start:window_end]) |
| |
| |
| enhancement = signal[local_max_idx] * 0.02 * sharpening_strength |
| output[local_max_idx] += enhancement |
| |
| for peak_idx in s_peaks: |
| if 0 <= peak_idx < len(output): |
| |
| window_start = max(0, peak_idx - 2) |
| window_end = min(len(output), peak_idx + 3) |
| local_min_idx = window_start + np.argmin(signal[window_start:window_end]) |
| |
| |
| enhancement = signal[local_min_idx] * 0.02 * sharpening_strength |
| output[local_min_idx] += enhancement |
| |
| return output |
|
|
|
|
| |
| |
| |
| def load_model(v16_checkpoint, v18_checkpoint, device): |
| """Load V18 pipeline (V16 + refiner).""" |
| model = V18Pipeline(v16_checkpoint, refiner_encoder='efficientnet_b0') |
| |
| |
| checkpoint = torch.load(v18_checkpoint, map_location='cpu', weights_only=False) |
| model.refiner.load_state_dict(checkpoint['refiner']) |
| |
| model = model.to(device) |
| model.eval() |
| |
| print(f"V18 Refiner loaded from epoch {checkpoint['epoch']}") |
| print(f" Refined SNR: {checkpoint.get('snr', 'N/A'):.2f} dB") |
| print(f" Residual scale: {model.refiner.residual_scale.item():.4f}") |
| |
| return model |
|
|
|
|
| def crop_row(image, row_idx): |
| """Crop a single row centered on its baseline, signal region only (T0:T1).""" |
| 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 |
|
|
|
|
| def convert_crop_to_full(pred_y_crop, row_idx): |
| """Convert crop-relative y-coordinates to full image coordinates.""" |
| 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 |
| return pred_y_full |
|
|
|
|
| def visualize_predictions(image, v16_predictions, v18_predictions, output_path): |
| """ |
| Draw both V16 and V18 predictions as dots on the image. |
| |
| V16 = cyan (baseline) |
| V18 = red (refined) |
| """ |
| vis_image = image[:, T0:T1, :].copy() |
| |
| |
| v16_colors = [ |
| (255, 255, 0), |
| (255, 255, 0), |
| (255, 255, 0), |
| (255, 255, 0), |
| ] |
| |
| v18_colors = [ |
| (0, 0, 255), |
| (0, 255, 0), |
| (255, 0, 255), |
| (0, 165, 255), |
| ] |
| |
| |
| for row_idx, pred_y in enumerate(v16_predictions): |
| color = v16_colors[row_idx] |
| for x in range(0, len(pred_y), 2): |
| y = int(np.clip(pred_y[x], 0, TARGET_HEIGHT - 1)) |
| cv2.circle(vis_image, (x, y), 1, color, -1) |
| |
| |
| for row_idx, pred_y in enumerate(v18_predictions): |
| color = v18_colors[row_idx] |
| for x in range(len(pred_y)): |
| y = int(np.clip(pred_y[x], 0, TARGET_HEIGHT - 1)) |
| cv2.circle(vis_image, (x, y), 1, color, -1) |
| |
| cv2.imwrite(str(output_path), vis_image) |
|
|
|
|
| def compute_snr_per_lead(pred_y_full, df, row_idx, epsilon=1e-10): |
| """Compute SNR in dB for each lead in a row.""" |
| baseline_y = ZERO_MV[row_idx] |
| segment_width = OUTPUT_WIDTH // 4 |
| |
| lead_snrs = {} |
| |
| if row_idx < 3: |
| lead_names = LEAD_LAYOUT[row_idx] |
| for seg_idx, lead_name in enumerate(lead_names): |
| if lead_name not in df.columns: |
| lead_snrs[lead_name] = None |
| continue |
| |
| gt_mv = df[lead_name].dropna().values |
| if len(gt_mv) == 0: |
| lead_snrs[lead_name] = None |
| continue |
| |
| if lead_name == 'II': |
| ref_len = len(df['I'].dropna().values) if 'I' in df.columns else len(gt_mv) // 4 |
| if len(gt_mv) > ref_len * 2: |
| quarter_len = len(gt_mv) // 4 |
| gt_mv = gt_mv[:quarter_len] |
| |
| seg_start = seg_idx * segment_width |
| seg_end = (seg_idx + 1) * segment_width |
| pred_y_seg = pred_y_full[seg_start:seg_end] |
| |
| pred_mv_pixels = (baseline_y - pred_y_seg) / MV_TO_PIXEL |
| |
| x_pred = np.linspace(0, 1, len(pred_mv_pixels)) |
| x_gt = np.linspace(0, 1, len(gt_mv)) |
| pred_mv_resampled = np.interp(x_gt, x_pred, pred_mv_pixels) |
| |
| signal_power = (gt_mv ** 2).mean() |
| noise_power = ((pred_mv_resampled - gt_mv) ** 2).mean() |
| |
| if noise_power < epsilon: |
| lead_snrs[lead_name] = 50.0 |
| else: |
| snr = 10 * np.log10(signal_power / (noise_power + epsilon)) |
| lead_snrs[lead_name] = float(snr) |
| else: |
| if 'II' not in df.columns: |
| lead_snrs['II_rhythm'] = None |
| return lead_snrs |
| |
| gt_mv = df['II'].dropna().values |
| if len(gt_mv) == 0: |
| lead_snrs['II_rhythm'] = None |
| return lead_snrs |
| |
| pred_mv_pixels = (baseline_y - pred_y_full) / MV_TO_PIXEL |
| |
| x_pred = np.linspace(0, 1, len(pred_mv_pixels)) |
| x_gt = np.linspace(0, 1, len(gt_mv)) |
| pred_mv_resampled = np.interp(x_gt, x_pred, pred_mv_pixels) |
| |
| signal_power = (gt_mv ** 2).mean() |
| noise_power = ((pred_mv_resampled - gt_mv) ** 2).mean() |
| |
| if noise_power < epsilon: |
| lead_snrs['II_rhythm'] = 50.0 |
| else: |
| snr = 10 * np.log10(signal_power / (noise_power + epsilon)) |
| lead_snrs['II_rhythm'] = float(snr) |
| |
| return lead_snrs |
|
|
|
|
| @torch.no_grad() |
| def process_image(model, image_path, csv_path, output_dir, device, |
| apply_smoothing=True, apply_einthoven=True, |
| apply_qrs_sharp=False, sharpening_strength=0.5, |
| selective_v18=False, negative_dir=None): |
| """Process a single image, save visualization, and compute per-lead SNR.""" |
| |
| image = cv2.imread(str(image_path), cv2.IMREAD_COLOR) |
| if image is None: |
| print(f"Failed to load: {image_path}") |
| return None, 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) |
| |
| |
| row_crops = [] |
| for row_idx in range(4): |
| row_crop = crop_row(image, row_idx) |
| row_crops.append(row_crop) |
| |
| |
| row_crops_np = np.stack(row_crops, axis=0) |
| row_crops_tensor = torch.from_numpy(row_crops_np.astype(np.float32) / 255.0) |
| row_crops_tensor = row_crops_tensor.permute(0, 3, 1, 2) |
| row_crops_tensor = row_crops_tensor.unsqueeze(0).to(device) |
| |
| |
| with torch.cuda.amp.autocast(): |
| v16_preds, v18_preds, residuals = model(row_crops_tensor) |
| |
| |
| v16_preds = v16_preds[0].cpu().numpy() |
| v18_preds = v18_preds[0].cpu().numpy() |
| residuals = residuals[0].cpu().numpy() |
| |
| |
| v16_predictions = [] |
| v18_predictions = [] |
| v18_mv_rows = {} |
| |
| for row_idx in range(4): |
| |
| v16_y_crop = v16_preds[row_idx] * ROW_HEIGHT |
| v16_y_full = convert_crop_to_full(v16_y_crop, row_idx) |
| v16_predictions.append(v16_y_full) |
| |
| |
| v18_y_crop = v18_preds[row_idx] * ROW_HEIGHT |
| v18_y_full = convert_crop_to_full(v18_y_crop, row_idx) |
| |
| |
| baseline_y = ZERO_MV[row_idx] |
| pred_mv = (baseline_y - v18_y_full) / MV_TO_PIXEL |
| |
| if apply_smoothing: |
| pred_mv = apply_savgol_smoothing(pred_mv, window=7, polyorder=2) |
| |
| |
| if apply_qrs_sharp: |
| pred_mv = apply_qrs_sharpening( |
| pred_mv, |
| peak_prominence=0.3, |
| sharpening_radius=5, |
| sharpening_strength=sharpening_strength |
| ) |
| |
| pred_mv = clamp_ecg_amplitude(pred_mv) |
| pred_mv = interpolate_nan(pred_mv.copy()) |
| |
| v18_mv_rows[row_idx] = pred_mv |
| v18_predictions.append(v18_y_full) |
| |
| |
| if apply_einthoven: |
| v18_mv_rows = apply_einthoven_correction(v18_mv_rows, alpha=0.33) |
| |
| |
| corrected_v18_predictions = [] |
| for row_idx in range(4): |
| baseline_y = ZERO_MV[row_idx] |
| pred_y_corrected = baseline_y - v18_mv_rows[row_idx] * MV_TO_PIXEL |
| corrected_v18_predictions.append(pred_y_corrected) |
| |
| |
| if selective_v18: |
| segment_width = OUTPUT_WIDTH // 4 |
| final_predictions = [] |
| for row_idx in range(4): |
| if row_idx == 3: |
| |
| if 'II_rhythm' in V18_BENEFICIAL_LEADS: |
| final_predictions.append(corrected_v18_predictions[row_idx].copy()) |
| else: |
| final_predictions.append(v16_predictions[row_idx].copy()) |
| else: |
| |
| row_pred = v16_predictions[row_idx].copy() |
| for col_idx in range(4): |
| lead_name = LEAD_LAYOUT[row_idx][col_idx] |
| seg_start = col_idx * segment_width |
| seg_end = (col_idx + 1) * segment_width |
| if lead_name in V18_BENEFICIAL_LEADS: |
| |
| row_pred[seg_start:seg_end] = corrected_v18_predictions[row_idx][seg_start:seg_end] |
| final_predictions.append(row_pred) |
| corrected_v18_predictions = final_predictions |
| |
| |
| v16_snrs = {} |
| v18_snrs = {} |
| for row_idx in range(4): |
| v16_lead_snrs = compute_snr_per_lead(v16_predictions[row_idx], df, row_idx) |
| v18_lead_snrs = compute_snr_per_lead(corrected_v18_predictions[row_idx], df, row_idx) |
| v16_snrs.update(v16_lead_snrs) |
| v18_snrs.update(v18_lead_snrs) |
| |
| |
| v16_valid_snrs = [v for v in v16_snrs.values() if v is not None] |
| v18_valid_snrs = [v for v in v18_snrs.values() if v is not None] |
| v16_min_snr = min(v16_valid_snrs) if v16_valid_snrs else 0.0 |
| v18_min_snr = min(v18_valid_snrs) if v18_valid_snrs else 0.0 |
| |
| |
| 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, v16_predictions, corrected_v18_predictions, output_path) |
| |
| |
| if negative_dir is not None and v18_min_snr < LOW_SNR_THRESHOLD: |
| neg_output_path = negative_dir / f"{sample_id}_{variant}_snr{v18_min_snr:.1f}.png" |
| visualize_predictions(image, v16_predictions, corrected_v18_predictions, neg_output_path) |
| |
| return v16_snrs, v18_snrs, v18_min_snr, output_path |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument('--v16_checkpoint', type=str, default=None, |
| help='Local V16 checkpoint path. If not provided, will SCP from remote.') |
| parser.add_argument('--v18_checkpoint', type=str, default=None, |
| help='Local V18 refiner checkpoint. If not provided, will SCP from remote.') |
| parser.add_argument('--remote_host', type=str, default=REMOTE_HOST, |
| help='Remote SSH host for SCP') |
| parser.add_argument('--remote_dir', type=str, default=REMOTE_CHECKPOINT_DIR, |
| help='Remote checkpoint directory') |
| parser.add_argument('--local_cache', type=str, |
| default='/tmp/v18_checkpoints', |
| help='Local directory to cache downloaded checkpoints') |
| 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/v18')) |
| parser.add_argument('--num_samples', type=int, default=None, |
| help='Number of samples (default: all holdout samples)') |
| parser.add_argument('--use_holdout', action='store_true', default=True, |
| help='Use holdout/validation set instead of random samples') |
| parser.add_argument('--no_smoothing', action='store_true', |
| help='Disable Savitzky-Golay smoothing') |
| parser.add_argument('--no_einthoven', action='store_true', |
| help='Disable Einthoven law correction') |
| parser.add_argument('--qrs_sharpening', action='store_true', |
| help='Enable QRS peak sharpening (enhances R-peaks and S-waves)') |
| parser.add_argument('--sharpening_strength', type=float, default=0.5, |
| help='QRS sharpening strength (0-1, default 0.5)') |
| parser.add_argument('--selective_v18', action='store_true', |
| help='Apply V18 only to leads where it helps (I,III,aVR,aVF,V1,V6,II_rhythm)') |
| parser.add_argument('--no_scp', action='store_true', |
| help='Skip SCP, use local checkpoints only') |
| parser.add_argument('--seed', type=int, default=42) |
| args = parser.parse_args() |
| |
| |
| random.seed(args.seed) |
| device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') |
| output_dir = Path(args.output_dir) |
| negative_dir = Path(os.path.expanduser('~/tmp/pred/v18/negpreds')) |
| |
| print(f"{'='*70}") |
| print(f"V18 Inference: V16 (frozen) + Cross-Row Refiner") |
| print(f"{'='*70}") |
| |
| |
| v16_checkpoint = args.v16_checkpoint |
| v18_checkpoint = args.v18_checkpoint |
| local_cache = Path(args.local_cache) |
| |
| if not args.no_scp: |
| print(f"\nFetching checkpoints from {args.remote_host}...") |
| |
| |
| if v16_checkpoint is None: |
| v16_remote = f"{args.remote_dir}/{REMOTE_V16_CHECKPOINT}" |
| v16_checkpoint = scp_checkpoint( |
| args.remote_host, v16_remote, local_cache / REMOTE_V16_CHECKPOINT |
| ) |
| |
| |
| if v18_checkpoint is None: |
| v18_checkpoint = scp_latest_checkpoint( |
| args.remote_host, args.remote_dir, REMOTE_V18_CHECKPOINT_PATTERN, local_cache |
| ) |
| |
| |
| if v16_checkpoint is None: |
| v16_checkpoint = local_cache / REMOTE_V16_CHECKPOINT |
| if v18_checkpoint is None: |
| v18_checkpoints = sorted(local_cache.glob(f'{REMOTE_V18_CHECKPOINT_PATTERN}*.pth')) |
| if v18_checkpoints: |
| v18_checkpoint = v18_checkpoints[-1] |
| |
| |
| if not Path(v16_checkpoint).exists(): |
| print(f"ERROR: V16 checkpoint not found: {v16_checkpoint}") |
| return |
| if not v18_checkpoint or not Path(v18_checkpoint).exists(): |
| print(f"ERROR: V18 checkpoint not found") |
| print(f" Looked for: {REMOTE_V18_CHECKPOINT_PATTERN}*.pth in {local_cache}") |
| return |
| |
| |
| if output_dir.exists(): |
| import shutil |
| shutil.rmtree(output_dir) |
| print(f"Deleted old predictions in {output_dir}") |
| output_dir.mkdir(parents=True, exist_ok=True) |
| negative_dir.mkdir(parents=True, exist_ok=True) |
| |
| print(f"\nV16 Checkpoint: {v16_checkpoint}") |
| print(f"V18 Checkpoint: {v18_checkpoint}") |
| print(f"Output: {output_dir}") |
| print(f"Device: {device}") |
| print(f"Smoothing: {'OFF' if args.no_smoothing else 'ON (Savgol w=7)'}") |
| print(f"Einthoven correction: {'OFF' if args.no_einthoven else 'ON (alpha=0.33)'}") |
| print(f"QRS Sharpening: {'ON (strength=' + str(args.sharpening_strength) + ')' if args.qrs_sharpening else 'OFF'}") |
| print(f"Selective V18: {'ON (I,III,aVR,aVF,V1,V6,II_rhythm only)' if args.selective_v18 else 'OFF (all leads)'}") |
| print(f"{'='*70}") |
| |
| |
| model = load_model(v16_checkpoint, v18_checkpoint, device) |
| |
| |
| 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)) |
| |
| set_type = "holdout" if args.use_holdout else "all" |
| print(f"Found {len(all_samples)} valid images in {set_type} set") |
| |
| 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...") |
| |
| |
| v16_all_snrs = {lead: [] for lead in ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', |
| 'V1', 'V2', 'V3', 'V4', 'V5', 'V6', 'II_rhythm']} |
| v18_all_snrs = {lead: [] for lead in ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', |
| 'V1', 'V2', 'V3', 'V4', 'V5', 'V6', 'II_rhythm']} |
| low_snr_samples = [] |
| |
| for img_path, csv_path in tqdm(selected): |
| try: |
| v16_snrs, v18_snrs, min_snr, output_path = process_image( |
| model, img_path, csv_path, output_dir, device, |
| apply_smoothing=not args.no_smoothing, |
| apply_einthoven=not args.no_einthoven, |
| apply_qrs_sharp=args.qrs_sharpening, |
| sharpening_strength=args.sharpening_strength, |
| selective_v18=args.selective_v18, |
| negative_dir=negative_dir |
| ) |
| if v16_snrs and v18_snrs: |
| for lead in v16_snrs: |
| if v16_snrs[lead] is not None: |
| v16_all_snrs[lead].append(v16_snrs[lead]) |
| if v18_snrs[lead] is not None: |
| v18_all_snrs[lead].append(v18_snrs[lead]) |
| |
| if min_snr is not None and min_snr < LOW_SNR_THRESHOLD: |
| worst_lead = min(v18_snrs, key=lambda k: v18_snrs[k] if v18_snrs[k] is not None else float('inf')) |
| low_snr_samples.append({ |
| 'sample': str(img_path.parent.name), |
| 'variant': img_path.stem.split('-')[-1] if '-' in img_path.stem else '0000', |
| 'min_snr': min_snr, |
| 'worst_lead': worst_lead |
| }) |
| except Exception as e: |
| print(f"Error processing {img_path}: {e}") |
| import traceback |
| traceback.print_exc() |
| |
| |
| print(f"\n{'='*80}") |
| print(f"Per-Lead SNR Comparison (dB): V16 → V18") |
| print(f"{'='*80}") |
| print(f"{'Lead':<12} {'V16 Mean':>10} {'V18 Mean':>10} {'Δ':>8} {'Count':>6}") |
| print(f"{'-'*80}") |
| |
| v16_total_snrs = [] |
| v18_total_snrs = [] |
| |
| for lead in ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6', 'II_rhythm']: |
| v16_snrs = v16_all_snrs[lead] |
| v18_snrs = v18_all_snrs[lead] |
| |
| if len(v16_snrs) > 0 and len(v18_snrs) > 0: |
| v16_mean = np.mean(v16_snrs) |
| v18_mean = np.mean(v18_snrs) |
| delta = v18_mean - v16_mean |
| delta_str = f"{delta:+.2f}" if delta >= 0 else f"{delta:.2f}" |
| print(f"{lead:<12} {v16_mean:>10.2f} {v18_mean:>10.2f} {delta_str:>8} {len(v18_snrs):>6}") |
| v16_total_snrs.extend(v16_snrs) |
| v18_total_snrs.extend(v18_snrs) |
| else: |
| print(f"{lead:<12} {'N/A':>10} {'N/A':>10} {'N/A':>8} {0:>6}") |
| |
| print(f"{'-'*80}") |
| if len(v16_total_snrs) > 0: |
| v16_overall = np.mean(v16_total_snrs) |
| v18_overall = np.mean(v18_total_snrs) |
| delta = v18_overall - v16_overall |
| delta_str = f"{delta:+.2f}" if delta >= 0 else f"{delta:.2f}" |
| print(f"{'OVERALL':<12} {v16_overall:>10.2f} {v18_overall:>10.2f} {delta_str:>8} {len(v18_total_snrs):>6}") |
| print(f"{'='*80}") |
| |
| |
| snr_report_path = output_dir / 'snr_comparison.csv' |
| with open(snr_report_path, 'w') as f: |
| f.write("Lead,V16_Mean,V18_Mean,Delta,Count\n") |
| for lead in ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6', 'II_rhythm']: |
| v16_snrs = v16_all_snrs[lead] |
| v18_snrs = v18_all_snrs[lead] |
| if len(v16_snrs) > 0: |
| v16_mean = np.mean(v16_snrs) |
| v18_mean = np.mean(v18_snrs) |
| delta = v18_mean - v16_mean |
| f.write(f"{lead},{v16_mean:.2f},{v18_mean:.2f},{delta:+.2f},{len(v18_snrs)}\n") |
| if len(v16_total_snrs) > 0: |
| f.write(f"OVERALL,{np.mean(v16_total_snrs):.2f},{np.mean(v18_total_snrs):.2f}," |
| f"{np.mean(v18_total_snrs) - np.mean(v16_total_snrs):+.2f},{len(v18_total_snrs)}\n") |
| |
| print(f"\nSNR comparison saved to: {snr_report_path}") |
| print(f"Done! Saved {len(selected)} visualizations to {output_dir}") |
| |
| |
| if low_snr_samples: |
| print(f"\n{'='*70}") |
| print(f"Low SNR Samples (< {LOW_SNR_THRESHOLD} dB)") |
| print(f"{'='*70}") |
| low_snr_samples.sort(key=lambda x: x['min_snr']) |
| for item in low_snr_samples[:10]: |
| print(f" {item['sample']}_{item['variant']}: {item['min_snr']:.2f} dB ({item['worst_lead']})") |
| if len(low_snr_samples) > 10: |
| print(f" ... and {len(low_snr_samples) - 10} more") |
| |
| print(f"{'='*70}") |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|