ecg-digitization-experiments / code /scripts /infer_v20_aV_fix.py
Ubuntu
Add training scripts and notebooks
b69e447
Raw
History Blame Contribute Delete
32.9 kB
#!/usr/bin/env python3
"""
V20 Inference with aVR/aVL/aVF Fix
Runs V20 Integral Regression + TCN model on Kaggle images.
Includes per-segment baseline correction for better aVR/aVL/aVF accuracy.
"""
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
import torch
import torch.nn as nn
import torch.nn.functional as F
import cv2
import timm
# =============================================================================
# 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
# Integral Regression parameters
NUM_BINS = 128
# 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']
# Validation sample IDs (holdout set)
# VAL_SAMPLE_IDS = [
# '1006427285', '1006867983', '1012423188', '10140238', '1015663939',
# '102150619', '1026034238', '1041099777', '104573050', '1048962695',
# '1052007218', '1053922973', '1059602762', '1063816858', '106482869',
# '1067371646', '1067975047', '1068062585', '1072767337', '1079294623',
# '1084993373', '108599929'
# ]
VAL_SAMPLE_IDS = [
'1006427285', '1006867983', '1012423188', '10140238', '1015663939',
'102150619', '1026034238', '1041099777', '104573050', '1048962695',
'1052007218', '1053922973', '1059602762', '1063816858', #'106482869',
'1067371646', '1067975047', '1068062585', '1072767337', #'1079294623',
'1084993373', '108599929'
]
LEAD_LAYOUT = [
['I', 'aVR', 'V1', 'V4'],
['II', 'aVL', 'V2', 'V5'],
['III', 'aVF', 'V3', 'V6'],
]
# Hardcoded baseline offsets (mV) - computed from 977 images (V20 epoch 14)
# These are median(prediction - ground_truth) values
# Positive means model predicts slightly higher than GT
BASELINE_OFFSETS = {
'I': 0.0052, 'II': 0.0071, 'III': 0.0049,
'aVR': 0.0067, 'aVL': 0.0059, 'aVF': 0.0067,
'V1': 0.0066, 'V2': 0.0061, 'V3': 0.0067,
'V4': 0.0068, 'V5': 0.0062, 'V6': 0.0065,
}
# Checkpoint configuration (V20 trains locally)
LOCAL_CHECKPOINT_DIR = '/data/ecg-digitization/checkpoints'
# Remote VM configuration (for V19 which trains remotely)
REMOTE_HOST = os.environ.get('REMOTE_TRAIN_HOST', 'azureuser@172.212.222.231')
REMOTE_CHECKPOINT_DIR = '/data/ecg-digitization/checkpoints'
# =============================================================================
# Model Architecture (from train_v20_integral.py)
# =============================================================================
class CoordConv2d(nn.Module):
"""Adds coordinate channels to convolution."""
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 TCNBlock(nn.Module):
"""
Temporal Convolutional Network block with dilated convolutions.
Uses residual connection for stable training.
"""
def __init__(self, in_channels, out_channels, kernel_size=3, dilation=1, dropout=0.1):
super().__init__()
padding = (kernel_size - 1) * dilation // 2 # Same padding
self.conv1 = nn.Conv1d(in_channels, out_channels, kernel_size,
padding=padding, dilation=dilation)
self.bn1 = nn.BatchNorm1d(out_channels)
self.conv2 = nn.Conv1d(out_channels, out_channels, kernel_size,
padding=padding, dilation=dilation)
self.bn2 = nn.BatchNorm1d(out_channels)
self.dropout = nn.Dropout(dropout)
self.activation = nn.GELU()
# Residual connection
self.residual = nn.Conv1d(in_channels, out_channels, 1) if in_channels != out_channels else nn.Identity()
def forward(self, x):
"""x: (B, C, W)"""
residual = self.residual(x)
out = self.conv1(x)
out = self.bn1(out)
out = self.activation(out)
out = self.dropout(out)
out = self.conv2(out)
out = self.bn2(out)
out = self.activation(out + residual)
return out
class TCNRefiner(nn.Module):
"""
Multi-scale TCN for temporal refinement.
Uses exponentially increasing dilations for large receptive field.
"""
def __init__(self, in_channels, hidden_channels=64, num_layers=4, kernel_size=5, dropout=0.1):
super().__init__()
layers = []
dilations = [2**i for i in range(num_layers)] # [1, 2, 4, 8]
# First layer
layers.append(TCNBlock(in_channels, hidden_channels, kernel_size, dilations[0], dropout))
# Hidden layers with increasing dilation
for dilation in dilations[1:]:
layers.append(TCNBlock(hidden_channels, hidden_channels, kernel_size, dilation, dropout))
self.tcn = nn.Sequential(*layers)
# Output projection back to input channels
self.output_proj = nn.Conv1d(hidden_channels, in_channels, 1)
def forward(self, x):
"""x: (B, C, W) - refines features temporally"""
residual = x
out = self.tcn(x)
out = self.output_proj(out)
return out + residual # Residual connection
class UNetDecoderBlock(nn.Module):
"""U-Net style decoder block with skip connections."""
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 IntegralRegressionHead(nn.Module):
"""
Integral regression head using soft-argmax.
Instead of directly regressing y, we:
1. Predict a heatmap distribution over height bins
2. Use soft-argmax to get expected y-value
This provides smoother gradients and handles uncertainty.
"""
def __init__(self, in_channels, num_bins=NUM_BINS, temperature=1.0):
super().__init__()
self.num_bins = num_bins
self.temperature = temperature
# Register bin centers as buffer (not parameter)
bin_centers = torch.linspace(0, 1, num_bins) # Normalized [0, 1]
self.register_buffer('bin_centers', bin_centers)
# Heatmap prediction head
self.heatmap_conv = nn.Sequential(
CoordConv2d(in_channels, 128, 3, padding=1),
nn.BatchNorm2d(128),
nn.GELU(),
nn.Conv2d(128, 64, 3, padding=1),
nn.BatchNorm2d(64),
nn.GELU(),
nn.Conv2d(64, num_bins, 1), # (B, num_bins, H, W)
)
# Height attention to pool to (B, num_bins, W)
self.height_attention = nn.Sequential(
nn.Conv2d(in_channels, 64, 3, padding=1),
nn.BatchNorm2d(64),
nn.GELU(),
nn.Conv2d(64, 1, 1),
)
def forward(self, x, temperature=None):
"""
x: (B, C, H, W) decoder features
Returns:
coords: (B, W) predicted y-coordinates in [0, 1]
heatmap: (B, num_bins, W) probability distribution
"""
B, C, H, W = x.shape
temp = temperature if temperature is not None else self.temperature
# Predict heatmap logits
heatmap_2d = self.heatmap_conv(x) # (B, num_bins, H, W)
# Height attention for pooling
attn = self.height_attention(x) # (B, 1, H, W)
attn = F.softmax(attn, dim=2) # Softmax over height
# Pool over height using attention
heatmap = (heatmap_2d * attn).sum(dim=2) # (B, num_bins, W)
# Apply temperature and softmax to get distribution
heatmap_prob = F.softmax(heatmap / temp, dim=1) # (B, num_bins, W)
# Soft-argmax: expected y-value
# bin_centers: (num_bins,) -> (1, num_bins, 1)
coords = (heatmap_prob * self.bin_centers.view(1, -1, 1)).sum(dim=1) # (B, W)
return coords, heatmap_prob
class IntegralRegressionNet(nn.Module):
"""
V20: Integral Regression Network with TCN for ECG digitization.
ConvNeXt encoder + U-Net decoder + TCN refiner + Integral Regression.
Input: (B, 3, INPUT_HEIGHT, INPUT_WIDTH) = (B, 3, 500, 3926)
Output:
coords: (B, INPUT_WIDTH) - y-coordinate for each x position [0, 1]
heatmap: (B, NUM_BINS, INPUT_WIDTH) - probability distribution
"""
def __init__(self, encoder_name='convnext_base.fb_in22k_ft_in1k', pretrained=True,
num_bins=NUM_BINS, temperature=1.0, use_tcn=True, tcn_layers=4, use_checkpoint=False):
super().__init__()
self.num_bins = num_bins
self.use_tcn = use_tcn
self.use_checkpoint = use_checkpoint
# Encoder
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()
# U-Net Decoder
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
# Final upsampling
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(),
)
# Height pooling to get 1D features for TCN
self.height_pool = nn.Sequential(
nn.Conv2d(decoder_dims[-1], 64, 3, padding=1),
nn.BatchNorm2d(64),
nn.GELU(),
nn.Conv2d(64, 1, 1), # Attention weights
)
# TCN refiner for temporal smoothing (operates on 1D features)
if use_tcn:
self.tcn_refiner = TCNRefiner(
in_channels=decoder_dims[-1],
hidden_channels=64,
num_layers=tcn_layers,
kernel_size=5,
dropout=0.1
)
# Integral regression head
self.integral_head = IntegralRegressionHead(
decoder_dims[-1], num_bins=num_bins, temperature=temperature
)
def forward(self, x, temperature=None):
B, C, H, W = x.shape
# Encoder
features = self.encoder(x)
# Decoder with skip connections
d = features[-1]
skips = features[:-1][::-1] + [None]
for block, skip in zip(self.dec_blocks, skips):
d = block(d, skip)
# Final upsampling
d = self.final_up(d) # (B, 32, H', W')
# Resize to match input width
if d.shape[3] != W:
d = F.interpolate(d, size=(d.shape[2], W), mode='bilinear', align_corners=True)
# Apply TCN on height-pooled features
if self.use_tcn:
# Height attention pooling
attn = self.height_pool(d) # (B, 1, H', W)
attn = F.softmax(attn, dim=2)
d_1d = (d * attn).sum(dim=2) # (B, C, W)
# TCN refinement
d_1d = self.tcn_refiner(d_1d) # (B, C, W)
# Expand back to 2D for integral head
d = d_1d.unsqueeze(2).expand(-1, -1, d.shape[2], -1) # (B, C, H', W)
# Integral regression
coords, heatmap = self.integral_head(d, temperature)
return coords, heatmap
# =============================================================================
# Post-Processing Functions
# =============================================================================
def apply_savgol_smoothing(signal_mv, window=7, polyorder=2):
"""Apply Savitzky-Golay smoothing."""
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."""
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."""
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_hardcoded_baseline_correction(pred_mv_rows):
"""
Apply hardcoded baseline correction for each lead.
Uses pre-computed median offsets from 977 images. These are tiny
(~0.007 mV) so the effect is minimal, but included for completeness.
This is production-ready - no GT required.
"""
segment_width = len(pred_mv_rows[0]) // 4
for row_idx in range(3): # Only for rows 0-2 (not rhythm strip)
lead_names = LEAD_LAYOUT[row_idx]
for seg_idx, lead_name in enumerate(lead_names):
offset = BASELINE_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
# Rhythm strip uses Lead II offset
pred_mv_rows[3] -= BASELINE_OFFSETS.get('II', 0.0)
return pred_mv_rows
# =============================================================================
# SCP Checkpoint Helper
# =============================================================================
def scp_checkpoint(remote_host, remote_path, local_path):
"""SCP a checkpoint from remote to local."""
local_path = Path(local_path)
local_path.parent.mkdir(parents=True, exist_ok=True)
cmd = ['scp', '-o', 'StrictHostKeyChecking=no',
f'{remote_host}:{remote_path}', str(local_path)]
print(f" Downloading: {remote_path}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f" SCP failed: {result.stderr}")
return None
return str(local_path)
# =============================================================================
# Inference Functions
# =============================================================================
def load_model(checkpoint_path, device, num_bins=NUM_BINS, use_tcn=True, tcn_layers=4):
"""Load V20 model."""
model = IntegralRegressionNet(
encoder_name='convnext_base.fb_in22k_ft_in1k',
pretrained=False,
num_bins=num_bins,
temperature=1.0,
use_tcn=use_tcn,
tcn_layers=tcn_layers
)
checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
# Handle DDP-wrapped model
state_dict = checkpoint['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)
model.eval()
epoch = checkpoint.get('epoch', 'N/A')
snr = checkpoint.get('snr', checkpoint.get('best_snr', 'N/A'))
temp = checkpoint.get('best_temp', 1.0)
print(f"Loaded V20 from epoch {epoch}")
if isinstance(snr, (int, float)):
print(f" Best SNR: {snr:.2f} dB at temperature {temp}")
return model
def crop_row(image, row_idx):
"""Crop a single row centered on its baseline."""
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 predict_row(model, row_crop, device, temperature=1.0):
"""Run inference on a single row crop."""
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.no_grad():
with torch.amp.autocast('cuda:1'):
coords, heatmap = model(image_tensor, temperature=temperature)
# coords is in [0, 1], convert to pixels in crop space
pred_y_crop = coords[0].cpu().numpy() * ROW_HEIGHT
return pred_y_crop, heatmap[0].cpu().numpy()
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, predictions, output_path, heatmaps=None):
"""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_y in enumerate(predictions):
color = colors[row_idx]
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_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
# Special handling for Lead II (10s data in GT, but short strip shows 2.5s)
# Use Lead I length as reference to ensure exact alignment
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] # Use exact same length as 2.5s leads
seg_start = seg_idx * segment_width
seg_end = (seg_idx + 1) * segment_width
pred_segment = pred_y_full[seg_start:seg_end]
pred_mv = (baseline_y - pred_segment) / MV_TO_PIXEL
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:
lead_snrs[lead_name] = 50.0
else:
lead_snrs[lead_name] = 10 * np.log10(signal_power / noise_power)
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 = (baseline_y - pred_y_full) / MV_TO_PIXEL
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:
lead_snrs['II_rhythm'] = 50.0
else:
lead_snrs['II_rhythm'] = 10 * np.log10(signal_power / noise_power)
return lead_snrs
def process_image(model, image_path, csv_path, output_dir, device, temperature=1.0,
apply_smoothing=True, apply_einthoven=True, apply_baseline_fix=True,
negative_dir=None):
"""Process a single image and compute 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
image = image[Y0:Y1, X0:X1]
image = cv2.resize(image, (TARGET_WIDTH, TARGET_HEIGHT), interpolation=cv2.INTER_LINEAR)
df = pd.read_csv(csv_path)
predictions = []
pred_mv_rows = {}
all_heatmaps = []
for row_idx in range(4):
row_crop = crop_row(image, row_idx)
pred_y_crop, heatmap = predict_row(model, row_crop, device, temperature=temperature)
pred_y_full = convert_crop_to_full(pred_y_crop, row_idx)
baseline_y = ZERO_MV[row_idx]
pred_mv = (baseline_y - pred_y_full) / MV_TO_PIXEL
if apply_smoothing:
pred_mv = apply_savgol_smoothing(pred_mv, window=7, polyorder=2)
pred_mv = clamp_ecg_amplitude(pred_mv)
pred_mv = interpolate_nan(pred_mv.copy())
pred_mv_rows[row_idx] = pred_mv
predictions.append(pred_y_full)
all_heatmaps.append(heatmap)
if apply_einthoven:
pred_mv_rows = apply_einthoven_correction(pred_mv_rows, alpha=0.33)
# Apply hardcoded baseline correction (tiny effect, ~0.007 mV)
if apply_baseline_fix:
pred_mv_rows = apply_hardcoded_baseline_correction(pred_mv_rows)
corrected_predictions = []
for row_idx in range(4):
baseline_y = ZERO_MV[row_idx]
pred_y_corrected = baseline_y - pred_mv_rows[row_idx] * MV_TO_PIXEL
corrected_predictions.append(pred_y_corrected)
all_lead_snrs = {}
for row_idx in range(4):
lead_snrs = compute_snr_per_lead(corrected_predictions[row_idx], df, row_idx)
all_lead_snrs.update(lead_snrs)
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
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, corrected_predictions, output_path, all_heatmaps)
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, corrected_predictions, neg_output_path, all_heatmaps)
return all_lead_snrs, min_snr, output_path
def main():
parser = argparse.ArgumentParser(description='V20 Inference with Integral Regression + TCN')
parser.add_argument('--checkpoint', type=str, default=None,
help='Local checkpoint path (if not provided, will SCP from remote)')
parser.add_argument('--remote_host', type=str, default=REMOTE_HOST,
help='Remote 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=LOCAL_CHECKPOINT_DIR,
help='Local checkpoint directory')
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/v20'))
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_scp', action='store_true', default=True,
help='Skip SCP, use local checkpoint (default: True for V20)')
parser.add_argument('--temperature', type=float, default=1.0,
help='Temperature for soft-argmax (lower = sharper)')
parser.add_argument('--num_bins', type=int, default=NUM_BINS,
help='Number of bins for integral regression')
parser.add_argument('--use_tcn', action='store_true', default=True,
help='Use TCN refiner')
parser.add_argument('--no_tcn', action='store_true',
help='Disable TCN refiner')
parser.add_argument('--tcn_layers', type=int, default=4,
help='Number of TCN layers')
parser.add_argument('--no_baseline_fix', action='store_true',
help='Disable hardcoded baseline correction')
parser.add_argument('--seed', type=int, default=42)
args = parser.parse_args()
random.seed(args.seed)
device = torch.device('cuda:1' if torch.cuda.is_available() else 'cpu')
output_dir = Path(args.output_dir)
negative_dir = Path(os.path.expanduser('~/tmp/pred/v20/negpreds'))
use_tcn = args.use_tcn and not args.no_tcn
print(f"{'='*70}")
print(f"V20 Inference: Integral Regression + TCN (aVR/aVL/aVF Baseline Fix)")
print(f"{'='*70}")
# Get checkpoint
checkpoint = args.checkpoint
local_cache = Path(args.local_cache)
if not args.no_scp and checkpoint is None:
print(f"\nFetching checkpoint from {args.remote_host}...")
local_cache.mkdir(parents=True, exist_ok=True)
# Try latest first, then best
for name in ['v20_integral_latest.pth', 'v20_integral_best_snr.pth']:
remote_path = f"{args.remote_dir}/{name}"
local_path = local_cache / name
checkpoint = scp_checkpoint(args.remote_host, remote_path, local_path)
if checkpoint is not None:
break
if checkpoint is None:
print("Failed to download checkpoint. Use --checkpoint to specify local path.")
sys.exit(1)
elif checkpoint is None:
# Look for local checkpoint
for name in ['v20_integral_latest.pth', 'v20_integral_best_snr.pth']:
local_path = local_cache / name
if local_path.exists():
checkpoint = str(local_path)
break
if checkpoint is None:
checkpoint = '/data/ecg-digitization/checkpoints/v20_integral_latest.pth'
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"\nCheckpoint: {checkpoint}")
print(f"Output: {output_dir}")
print(f"Device: {device}")
print(f"Temperature: {args.temperature}")
print(f"TCN: {'ON' if use_tcn else 'OFF'} ({args.tcn_layers} layers)")
print(f"Num bins: {args.num_bins}")
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"Per-segment baseline fix: {'OFF' if args.no_baseline_fix else 'ON'}")
print(f"{'='*70}")
# Load model
model = load_model(checkpoint, device,
num_bins=args.num_bins,
use_tcn=use_tcn,
tcn_layers=args.tcn_layers)
# 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 in holdout 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...")
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(
model, img_path, csv_path, output_dir, device,
temperature=args.temperature,
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) - Temperature={args.temperature}")
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()