| |
| """ |
| Compute per-segment baseline offsets for V18 model. |
| |
| This analyzes what the typical baseline offset is between V18 predictions |
| and ground truth for each lead. |
| """ |
|
|
| 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 |
| from scipy import stats |
| import json |
|
|
| 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 |
|
|
| ECG_MV_MIN, ECG_MV_MAX = -10.0, 10.0 |
|
|
| VALID_VARIANTS = ['0001', '0003', '0004', '0005', '0006', '0009', '0010', '0011', '0012'] |
|
|
| 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 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): |
| 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): |
| 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) |
| 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 |
|
|
|
|
| |
| |
| |
| 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 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 convert_crop_to_full(pred_y_crop, row_idx): |
| 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 load_v16_model(checkpoint_path, device): |
| model = PerLeadNetV16(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() |
| print(f"Loaded V16 from epoch {checkpoint.get('epoch', 'N/A')}, SNR: {checkpoint.get('snr', 'N/A'):.2f} dB") |
| return model |
|
|
|
|
| def load_v18_refiner(checkpoint_path, device): |
| refiner = V18RefinerNet(encoder_name='efficientnet_b0', pretrained=False, |
| cross_row_layers=3, cross_row_dim=128, num_heads=4) |
| checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False) |
| |
| |
| if 'refiner' in checkpoint: |
| state_dict = checkpoint['refiner'] |
| elif 'model' in checkpoint: |
| state_dict = checkpoint['model'] |
| elif 'state_dict' in checkpoint: |
| state_dict = checkpoint['state_dict'] |
| else: |
| state_dict = checkpoint |
| |
| |
| refiner_state_dict = {} |
| for k, v in state_dict.items(): |
| if k.startswith('refiner.'): |
| refiner_state_dict[k.replace('refiner.', '')] = v |
| elif k.startswith('module.refiner.'): |
| refiner_state_dict[k.replace('module.refiner.', '')] = v |
| elif k.startswith('module.'): |
| refiner_state_dict[k.replace('module.', '')] = v |
| elif not k.startswith('v16.') and not k.startswith('module.v16.'): |
| refiner_state_dict[k] = v |
| |
| refiner.load_state_dict(refiner_state_dict) |
| refiner = refiner.to(device) |
| refiner.eval() |
| print(f"Loaded V18 refiner from epoch {checkpoint.get('epoch', 'N/A')}, SNR: {checkpoint.get('snr', 'N/A'):.2f} dB") |
| return refiner |
|
|
|
|
| def compute_baseline_offset(pred_mv_segment, gt_mv): |
| """Compute baseline offset between prediction and GT.""" |
| 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(v16_model, v18_refiner, image, df, device): |
| """ |
| Process a single image with V18 and return baseline offsets for all 12 leads. |
| """ |
| segment_width = OUTPUT_WIDTH // 4 |
| |
| |
| row_crops = [crop_row(image, row_idx) for row_idx in range(4)] |
| |
| |
| v16_preds = [] |
| for row_crop in row_crops: |
| 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'): |
| pred = v16_model(image_tensor) |
| v16_preds.append(pred) |
| |
| |
| v16_preds_tensor = torch.stack(v16_preds, dim=1) |
| |
| |
| images_tensor = [] |
| for row_crop in row_crops: |
| image_tensor = torch.from_numpy(row_crop.astype(np.float32) / 255.0) |
| image_tensor = image_tensor.permute(2, 0, 1) |
| images_tensor.append(image_tensor) |
| images_tensor = torch.stack(images_tensor, dim=0).unsqueeze(0).to(device) |
| |
| |
| with torch.no_grad(): |
| with torch.amp.autocast('cuda'): |
| v18_preds, residuals = v18_refiner(images_tensor, v16_preds_tensor) |
| |
| |
| pred_mv_rows = {} |
| for row_idx in range(3): |
| pred_normalized = v18_preds[0, row_idx].cpu().numpy() |
| pred_y_crop = pred_normalized * ROW_HEIGHT |
| 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 |
| 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 |
| |
| |
| 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] |
| |
| offset = compute_baseline_offset(pred_segment, gt_mv) |
| offsets[lead_name] = offset |
| |
| return offsets |
|
|
|
|
| def describe_distribution(values, lead_name): |
| values = np.array([v for v in values if not np.isnan(v)]) |
| if len(values) == 0: |
| return None |
| |
| binned = np.round(values * 100) / 100 |
| mode_result = stats.mode(binned, keepdims=True) |
| mode_val = mode_result.mode[0] |
| mode_count = mode_result.count[0] |
| |
| return { |
| 'lead': lead_name, |
| 'count': len(values), |
| 'mean': np.mean(values), |
| 'median': np.median(values), |
| 'mode': mode_val, |
| 'mode_count': mode_count, |
| 'std': np.std(values), |
| 'min': np.min(values), |
| 'max': np.max(values), |
| 'range': np.max(values) - np.min(values), |
| 'p5': np.percentile(values, 5), |
| 'p25': np.percentile(values, 25), |
| 'p75': np.percentile(values, 75), |
| 'p95': np.percentile(values, 95), |
| 'iqr': np.percentile(values, 75) - np.percentile(values, 25), |
| } |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description='Compute per-lead baseline offsets for V18') |
| parser.add_argument('--v16_checkpoint', type=str, required=True) |
| parser.add_argument('--v18_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=4000) |
| parser.add_argument('--output_dir', type=str, default='/home/azureuser/tmp/baseline_analysis_v18') |
| 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"{'='*80}") |
| print(f"Baseline Offset Analysis for V18") |
| print(f"{'='*80}") |
| print(f"V16 Checkpoint: {args.v16_checkpoint}") |
| print(f"V18 Checkpoint: {args.v18_checkpoint}") |
| print(f"Num samples: {args.num_samples}") |
| print(f"Output dir: {output_dir}") |
| print(f"Device: {device}") |
| print(f"{'='*80}") |
| |
| |
| v16_model = load_v16_model(args.v16_checkpoint, device) |
| v18_refiner = load_v18_refiner(args.v18_checkpoint, device) |
| |
| |
| kaggle_dir = Path(args.kaggle_data) |
| sample_paths = [] |
| |
| print("Finding samples...") |
| sample_dirs = list(kaggle_dir.iterdir()) |
| random.shuffle(sample_dirs) |
| |
| for sample_dir in sample_dirs: |
| if not sample_dir.is_dir(): |
| 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") |
| |
| |
| 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(v16_model, v18_refiner, 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_csv_path = output_dir / 'baseline_offsets_raw.csv' |
| raw_df.to_csv(raw_csv_path, index=False) |
| print(f"\nSaved raw data to: {raw_csv_path}") |
| |
| |
| print(f"\n{'='*80}") |
| print(f"BASELINE OFFSET STATISTICS (mV) - V18") |
| print(f"{'='*80}") |
| print(f"Offset = median(prediction - ground_truth)") |
| print(f"Positive offset means model predicts HIGHER than GT") |
| print(f"{'='*80}\n") |
| |
| stats_data = [] |
| |
| print(f"{'Lead':<8} {'Count':>6} {'Mean':>8} {'Median':>8} {'Mode':>8} {'Std':>8} {'Min':>8} {'Max':>8} {'Range':>8} {'IQR':>8}") |
| print("-" * 90) |
| |
| for lead in ALL_LEADS: |
| stats_info = describe_distribution(all_offsets[lead], lead) |
| if stats_info: |
| stats_data.append(stats_info) |
| print(f"{lead:<8} {stats_info['count']:>6} {stats_info['mean']:>8.4f} {stats_info['median']:>8.4f} " |
| f"{stats_info['mode']:>8.4f} {stats_info['std']:>8.4f} {stats_info['min']:>8.4f} " |
| f"{stats_info['max']:>8.4f} {stats_info['range']:>8.4f} {stats_info['iqr']:>8.4f}") |
| |
| |
| print(f"\n{'='*80}") |
| print(f"RECOMMENDED HARDCODED BASELINE OFFSETS (mV) - V18") |
| print(f"{'='*80}") |
| print() |
| |
| recommended = {} |
| print("BASELINE_OFFSETS_V18 = {") |
| for lead in ALL_LEADS: |
| values = all_offsets[lead] |
| if values: |
| median_offset = np.median(values) |
| recommended[lead] = float(median_offset) |
| print(f" '{lead}': {median_offset:.4f},") |
| print("}") |
| |
| |
| stats_json_path = output_dir / 'baseline_stats.json' |
| with open(stats_json_path, 'w') as f: |
| json.dump({ |
| 'stats': stats_data, |
| 'recommended_offsets': recommended, |
| 'num_samples': len(sample_paths), |
| 'v16_checkpoint': args.v16_checkpoint, |
| 'v18_checkpoint': args.v18_checkpoint, |
| }, f, indent=2) |
| print(f"\nSaved stats to: {stats_json_path}") |
| |
| print(f"\n{'='*80}") |
| print("Done!") |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|