| |
| """ |
| Compute Per-Lead Baseline Offsets for V23 |
| |
| This script runs V23 on training images and computes the median offset |
| (prediction - ground_truth) for each lead. These offsets can then be |
| subtracted during inference to improve accuracy. |
| |
| The offsets are typically very small (~0.005-0.007 mV) but help with |
| systematic bias in the model. |
| |
| Usage: |
| python compute_baseline_offsets_v23.py \ |
| --checkpoint /data/ecg-digitization/checkpoints/v23_latest.pth \ |
| --num_samples 1000 |
| |
| Output: |
| - Prints BASELINE_OFFSETS dict to copy into inference script |
| - Saves raw data to CSV for analysis |
| """ |
|
|
| 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 import stats |
| from scipy.signal import savgol_filter |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
| from torch.cuda.amp import autocast |
|
|
| 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 |
|
|
| ECG_MV_MIN, ECG_MV_MAX = -10.0, 10.0 |
|
|
| VALID_VARIANTS = ['0001', '0003', '0004', '0005', '0006', '0009', '0010', '0011', '0012'] |
|
|
| |
| VAL_SAMPLE_IDS = set([ |
| '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'], |
| ] |
|
|
| ALL_LEADS = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6'] |
|
|
|
|
| |
| |
| |
| class MultiScaleFeatureFusion(nn.Module): |
| def __init__(self, l3_channels, l4_channels, out_channels=256): |
| super().__init__() |
| self.l3_proj = nn.Sequential( |
| nn.Conv2d(l3_channels, out_channels, 1, bias=False), |
| nn.BatchNorm2d(out_channels), |
| nn.GELU(), |
| ) |
| self.l4_proj = nn.Sequential( |
| nn.Conv2d(l4_channels, out_channels, 1, bias=False), |
| nn.BatchNorm2d(out_channels), |
| nn.GELU(), |
| ) |
| self.fusion = nn.Sequential( |
| nn.Conv2d(out_channels * 2, out_channels, 3, padding=1, bias=False), |
| nn.BatchNorm2d(out_channels), |
| nn.GELU(), |
| nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False), |
| nn.BatchNorm2d(out_channels), |
| nn.GELU(), |
| ) |
| self.out_channels = out_channels |
| |
| def forward(self, l3_feat, l4_feat): |
| l3_proj = self.l3_proj(l3_feat) |
| l4_proj = self.l4_proj(l4_feat) |
| l4_up = F.interpolate(l4_proj, size=l3_proj.shape[2:], mode='bilinear', align_corners=True) |
| fused = torch.cat([l3_proj, l4_up], dim=1) |
| fused = self.fusion(fused) |
| return fused |
|
|
|
|
| class HeightAttention(nn.Module): |
| def __init__(self, in_channels): |
| super().__init__() |
| self.attention = nn.Sequential( |
| nn.Conv2d(in_channels + 2, 64, 3, padding=1), |
| nn.BatchNorm2d(64), |
| nn.GELU(), |
| nn.Conv2d(64, 32, 3, padding=1), |
| nn.BatchNorm2d(32), |
| nn.GELU(), |
| nn.Conv2d(32, 1, 1), |
| ) |
| |
| 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_coord = torch.cat([x, yy, xx], dim=1) |
| attn = self.attention(x_coord) |
| attn = F.softmax(attn, dim=2) |
| pooled = (x * attn).sum(dim=2) |
| return pooled |
|
|
|
|
| class BiLSTMTemporal(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) |
| out = self.output_proj(lstm_out) |
| return out |
|
|
|
|
| class FinalHead(nn.Module): |
| def __init__(self, input_dim, hidden_dim=64): |
| super().__init__() |
| self.conv1d = nn.Sequential( |
| nn.Conv1d(input_dim, hidden_dim, kernel_size=5, padding=2), |
| nn.BatchNorm1d(hidden_dim), |
| nn.GELU(), |
| nn.Conv1d(hidden_dim, hidden_dim, kernel_size=3, padding=1), |
| nn.BatchNorm1d(hidden_dim), |
| nn.GELU(), |
| ) |
| self.linear = nn.Sequential( |
| nn.Linear(hidden_dim, 32), |
| nn.GELU(), |
| nn.Linear(32, 1), |
| nn.Sigmoid(), |
| ) |
| |
| def forward(self, x): |
| x = x.permute(0, 2, 1) |
| x = self.conv1d(x) |
| x = x.permute(0, 2, 1) |
| out = self.linear(x).squeeze(-1) |
| return out |
|
|
|
|
| class ColumnDSNT(nn.Module): |
| def __init__(self, in_channels, hidden_channels=32): |
| super().__init__() |
| self.conv = nn.Sequential( |
| nn.Conv2d(in_channels, hidden_channels, 3, padding=1), |
| nn.BatchNorm2d(hidden_channels), |
| nn.GELU(), |
| nn.Conv2d(hidden_channels, hidden_channels, 3, padding=1), |
| nn.BatchNorm2d(hidden_channels), |
| nn.GELU(), |
| nn.Conv2d(hidden_channels, 1, 1), |
| ) |
| |
| def forward(self, x): |
| B, C, H, W = x.shape |
| logits = self.conv(x) |
| probs = F.softmax(logits, dim=2) |
| y_coords_normalized = torch.linspace(0, 1, H, device=x.device) |
| y_coords_normalized = y_coords_normalized.view(1, 1, H, 1) |
| y_expected = (probs * y_coords_normalized).sum(dim=2) |
| y_expected = y_expected.squeeze(1) |
| return y_expected, probs |
|
|
|
|
| class PerLeadNetV23(nn.Module): |
| 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() |
| |
| self.ms_fusion = MultiScaleFeatureFusion( |
| l3_channels=enc_channels[2], l4_channels=enc_channels[3], out_channels=256, |
| ) |
| |
| self.dec_blocks = nn.ModuleList() |
| self.dec_blocks.append(self._make_dec_block(256, enc_channels[1], 128)) |
| self.dec_blocks.append(self._make_dec_block(128, enc_channels[0], 64)) |
| self.dec_blocks.append(self._make_dec_block(64, 0, 32)) |
| self.dec_blocks.append(self._make_dec_block(32, 0, 32)) |
| |
| self.final_up = nn.Sequential( |
| nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True), |
| nn.Conv2d(32, 32, 3, padding=1, bias=False), |
| nn.BatchNorm2d(32), |
| nn.GELU(), |
| ) |
| |
| self.height_attention = HeightAttention(in_channels=32) |
| self.bilstm = BiLSTMTemporal(input_dim=32, hidden_dim=128, num_layers=2, dropout=0.1) |
| self.head = FinalHead(input_dim=128, hidden_dim=64) |
| self.dsnt_head = ColumnDSNT(in_channels=32, hidden_channels=32) |
| self.dsnt_weight = nn.Parameter(torch.tensor(0.3)) |
| |
| def _make_dec_block(self, in_ch, skip_ch, out_ch): |
| return nn.Sequential( |
| nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True), |
| 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(), |
| ) |
| |
| def forward(self, x): |
| B, C, H, W = x.shape |
| features = self.encoder(x) |
| l1, l2, l3, l4 = features |
| |
| fused = self.ms_fusion(l3, l4) |
| |
| d = self.dec_blocks[0][0](fused) |
| if d.shape[2:] != l2.shape[2:]: |
| d = F.interpolate(d, size=l2.shape[2:], mode='bilinear', align_corners=True) |
| d = torch.cat([d, l2], dim=1) |
| d = self.dec_blocks[0][1:](d) |
| |
| d = self.dec_blocks[1][0](d) |
| if d.shape[2:] != l1.shape[2:]: |
| d = F.interpolate(d, size=l1.shape[2:], mode='bilinear', align_corners=True) |
| d = torch.cat([d, l1], dim=1) |
| d = self.dec_blocks[1][1:](d) |
| |
| d = self.dec_blocks[2](d) |
| d = self.dec_blocks[3](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) |
| |
| y_dsnt, _ = self.dsnt_head(d) |
| pooled = self.height_attention(d) |
| temporal = self.bilstm(pooled) |
| y_v22 = self.head(temporal) |
| |
| w = torch.sigmoid(self.dsnt_weight) |
| y_pred = (1 - w) * y_v22 + w * y_dsnt |
| |
| return y_pred |
|
|
|
|
| |
| |
| |
| 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 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 |
|
|
|
|
| def load_model(checkpoint_path, device): |
| model = PerLeadNetV23(encoder_name='convnext_base.fb_in22k_ft_in1k', pretrained=False) |
| checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False) |
| 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() |
| |
| dsnt_w = torch.sigmoid(model.dsnt_weight).item() |
| print(f"Loaded V23 from epoch {checkpoint.get('epoch', 'N/A')}") |
| print(f" SNR: {checkpoint.get('snr', 'N/A'):.2f} dB") |
| print(f" DSNT weight: {dsnt_w:.3f} (V22: {1-dsnt_w:.3f})") |
| return model |
|
|
|
|
| def compute_baseline_offset(pred_mv_segment, gt_mv): |
| """Compute median offset between prediction and ground truth.""" |
| x_pred = np.linspace(0, 1, len(pred_mv_segment)) |
| x_gt = np.linspace(0, 1, len(gt_mv)) |
| pred_resampled = np.interp(x_gt, x_pred, pred_mv_segment) |
| return np.median(pred_resampled - gt_mv) |
|
|
|
|
| def process_image(model, image, df, device): |
| """Process image and compute per-lead offsets.""" |
| segment_width = OUTPUT_WIDTH // 4 |
| pred_mv_rows = {} |
| |
| |
| for row_idx in range(3): |
| row_crop = crop_row(image, 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.no_grad(): |
| with autocast(): |
| pred = model(image_tensor) |
| |
| pred_y = pred[0].cpu().numpy() * ROW_HEIGHT |
| baseline_y = ZERO_MV[row_idx] |
| pred_mv = (CROP_HALF_HEIGHT - pred_y) / MV_TO_PIXEL |
| pred_mv = apply_savgol_smoothing(pred_mv, window=7, polyorder=2) |
| pred_mv = clamp_ecg_amplitude(pred_mv) |
| pred_mv_rows[row_idx] = pred_mv |
| |
| |
| pred_mv_rows = apply_einthoven_correction(pred_mv_rows, alpha=0.33) |
| |
| |
| offsets = {} |
| for row_idx in range(3): |
| lead_names = LEAD_LAYOUT[row_idx] |
| for seg_idx, lead_name in enumerate(lead_names): |
| if lead_name not in df.columns: |
| offsets[lead_name] = np.nan |
| continue |
| |
| gt_mv = df[lead_name].dropna().values |
| if len(gt_mv) == 0: |
| offsets[lead_name] = np.nan |
| 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_segment = pred_mv_rows[row_idx][seg_start:seg_end] |
| |
| offsets[lead_name] = compute_baseline_offset(pred_segment, gt_mv) |
| |
| return offsets |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description='Compute V23 baseline offsets') |
| parser.add_argument('--checkpoint', type=str, required=True) |
| parser.add_argument('--kaggle_data', type=str, default='/data/ecg-digitization/stage1_data/train') |
| parser.add_argument('--num_samples', type=int, default=1000) |
| parser.add_argument('--output_dir', type=str, default='/home/azureuser/tmp/baseline_analysis_v23') |
| parser.add_argument('--seed', type=int, default=42) |
| args = parser.parse_args() |
| |
| random.seed(args.seed) |
| np.random.seed(args.seed) |
| device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') |
| |
| output_dir = Path(args.output_dir) |
| output_dir.mkdir(parents=True, exist_ok=True) |
| |
| print(f"{'='*70}") |
| print(f"Baseline Offset Computation for V23") |
| print(f"{'='*70}") |
| print(f"Checkpoint: {args.checkpoint}") |
| print(f"Num samples: {args.num_samples}") |
| print(f"Device: {device}") |
| print(f"{'='*70}") |
| |
| model = load_model(args.checkpoint, device) |
| |
| |
| kaggle_dir = Path(args.kaggle_data) |
| sample_paths = [] |
| sample_dirs = list(kaggle_dir.iterdir()) |
| random.shuffle(sample_dirs) |
| |
| for sample_dir in sample_dirs: |
| if not sample_dir.is_dir(): |
| continue |
| if sample_dir.name in VAL_SAMPLE_IDS: |
| 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: |
| sample_paths.append((img_path, csv_path)) |
| break |
| |
| if len(sample_paths) >= args.num_samples: |
| break |
| |
| print(f"Found {len(sample_paths)} samples (excluding validation)") |
| |
| |
| all_offsets = {lead: [] for lead in ALL_LEADS} |
| raw_data = [] |
| |
| for img_path, csv_path in tqdm(sample_paths, desc="Computing offsets"): |
| try: |
| image = cv2.imread(str(img_path), cv2.IMREAD_COLOR) |
| if image is None: |
| continue |
| |
| image = image[Y0:Y1, X0:X1] |
| image = cv2.resize(image, (TARGET_WIDTH, TARGET_HEIGHT), interpolation=cv2.INTER_LINEAR) |
| |
| df = pd.read_csv(csv_path) |
| offsets = process_image(model, image, df, device) |
| |
| row = {'image': img_path.stem} |
| for lead in ALL_LEADS: |
| offset = offsets.get(lead, np.nan) |
| row[lead] = offset |
| if not np.isnan(offset): |
| all_offsets[lead].append(offset) |
| raw_data.append(row) |
| except Exception as e: |
| print(f"Error processing {img_path}: {e}") |
| continue |
| |
| |
| raw_df = pd.DataFrame(raw_data) |
| raw_df.to_csv(output_dir / 'baseline_offsets_raw.csv', index=False) |
| print(f"\nSaved raw offsets to {output_dir / 'baseline_offsets_raw.csv'}") |
| |
| |
| print(f"\n{'='*70}") |
| print("Per-Lead Baseline Offset Statistics (mV)") |
| print(f"{'='*70}") |
| print(f"{'Lead':<6} {'Count':>6} {'Mean':>10} {'Median':>10} {'Std':>10}") |
| print(f"{'-'*50}") |
| |
| median_offsets = {} |
| for lead in ALL_LEADS: |
| values = np.array(all_offsets[lead]) |
| if len(values) == 0: |
| continue |
| median_offsets[lead] = np.median(values) |
| print(f"{lead:<6} {len(values):>6} {np.mean(values):>10.4f} {np.median(values):>10.4f} {np.std(values):>10.4f}") |
| |
| |
| print(f"\n{'='*70}") |
| print("Copy this into inference_v23.py:") |
| print(f"{'='*70}") |
| print("BASELINE_OFFSETS = {") |
| for i, lead in enumerate(ALL_LEADS): |
| offset = median_offsets.get(lead, 0.0) |
| comma = "," if i < len(ALL_LEADS) - 1 else "" |
| print(f" '{lead}': {offset:.4f}{comma}") |
| print("}") |
| print(f"{'='*70}") |
| |
| |
| summary = {lead: {'median': median_offsets.get(lead, 0.0), |
| 'count': len(all_offsets[lead]), |
| 'mean': np.mean(all_offsets[lead]) if all_offsets[lead] else 0.0, |
| 'std': np.std(all_offsets[lead]) if all_offsets[lead] else 0.0} |
| for lead in ALL_LEADS} |
| |
| with open(output_dir / 'baseline_offsets_summary.json', 'w') as f: |
| import json |
| json.dump(summary, f, indent=2) |
| |
| print(f"\nSaved summary to {output_dir / 'baseline_offsets_summary.json'}") |
| print("Done!") |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|