| |
| """ |
| V16 Inference with TTA and Visualization |
| |
| Same as infer_v16_visualize.py but with Test-Time Augmentation (TTA). |
| TTA averages predictions from multiple augmented versions of each row crop. |
| |
| Usage: |
| python infer_v16_visualize_tta.py --use_holdout |
| python infer_v16_visualize_tta.py --no_tta # Disable TTA for comparison |
| """ |
|
|
| import os |
| import sys |
| import argparse |
| import random |
| import numpy as np |
| import pandas as pd |
| from pathlib import Path |
| from tqdm import tqdm |
| from scipy.signal import savgol_filter |
|
|
| import torch |
| import torch.nn as nn |
| import torch.nn.functional as F |
|
|
| import cv2 |
| import timm |
|
|
|
|
| |
| |
| |
| 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 |
|
|
| |
| 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'], |
| ] |
|
|
|
|
| |
| |
| |
| |
| |
| TTA_AUGMENTATIONS = [ |
| {'name': 'original', 'hflip': False, 'brightness': 1.0, 'contrast': 1.0}, |
| {'name': 'hflip', 'hflip': True, 'brightness': 1.0, 'contrast': 1.0}, |
| |
| |
| |
| ] |
|
|
|
|
| def apply_tta_augmentation(image, hflip=False, brightness=1.0, contrast=1.0): |
| """Apply augmentation to image. |
| |
| Args: |
| image: BGR image [H, W, 3] |
| hflip: Horizontal flip |
| brightness: Multiplicative brightness factor |
| contrast: Multiplicative contrast factor |
| |
| Returns: |
| Augmented image |
| """ |
| img = image.copy() |
| |
| |
| if hflip: |
| img = np.flip(img, axis=1).copy() |
| |
| |
| if brightness != 1.0 or contrast != 1.0: |
| img = img.astype(np.float32) |
| if brightness != 1.0: |
| img = img * brightness |
| if contrast != 1.0: |
| mean = img.mean() |
| img = (img - mean) * contrast + mean |
| img = np.clip(img, 0, 255).astype(np.uint8) |
| |
| return img |
|
|
|
|
| |
| |
| |
| 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 PerLeadNet(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() |
| |
| 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) |
|
|
|
|
| |
| |
| |
| 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 load_model(checkpoint_path, device): |
| """Load trained V16 model.""" |
| model = PerLeadNet(encoder_name='convnext_base.fb_in22k_ft_in1k', pretrained=False) |
| |
| checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False) |
| model.load_state_dict(checkpoint['model']) |
| model = model.to(device) |
| model.eval() |
| |
| print(f"Loaded checkpoint from epoch {checkpoint['epoch']}") |
| print(f" SNR: {checkpoint.get('snr', 'N/A'):.2f} dB") |
| print(f" MAE: {checkpoint.get('mae', 'N/A'):.2f} px") |
| |
| 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 predict_row(model, row_crop, device): |
| """Run inference on a single row crop (no TTA).""" |
| 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.cuda.amp.autocast(): |
| output = model(image_tensor) |
| |
| pred_y_crop = output[0].cpu().numpy() * ROW_HEIGHT |
| |
| return pred_y_crop |
|
|
|
|
| def predict_row_tta(model, row_crop, device): |
| """Run inference with TTA on a single row crop. |
| |
| For horizontal flip: |
| 1. Flip input image horizontally |
| 2. Run inference |
| 3. Reverse the x-axis of predictions (flip back) |
| 4. Average with original predictions |
| |
| This averages out directional biases in the model. |
| """ |
| all_preds = [] |
| |
| for aug in TTA_AUGMENTATIONS: |
| |
| aug_crop = apply_tta_augmentation( |
| row_crop, |
| hflip=aug.get('hflip', False), |
| brightness=aug.get('brightness', 1.0), |
| contrast=aug.get('contrast', 1.0), |
| ) |
| |
| |
| image_tensor = torch.from_numpy(aug_crop.astype(np.float32) / 255.0) |
| image_tensor = image_tensor.permute(2, 0, 1).unsqueeze(0).to(device) |
| |
| with torch.no_grad(): |
| with torch.cuda.amp.autocast(): |
| output = model(image_tensor) |
| |
| pred_y_crop = output[0].cpu().numpy() * ROW_HEIGHT |
| |
| |
| if aug.get('hflip', False): |
| pred_y_crop = pred_y_crop[::-1].copy() |
| |
| all_preds.append(pred_y_crop) |
| |
| |
| avg_pred = np.mean(all_preds, axis=0) |
| |
| return avg_pred |
|
|
|
|
| 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): |
| """Draw predictions as dots 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(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 |
|
|
|
|
| def process_image(model, image_path, csv_path, output_dir, device, |
| apply_smoothing=True, apply_einthoven=True, use_tta=True, negative_dir=None): |
| """Process a single image with optional TTA.""" |
| |
| 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 = {} |
| |
| for row_idx in range(4): |
| row_crop = crop_row(image, row_idx) |
| |
| |
| if use_tta: |
| pred_y_crop = predict_row_tta(model, row_crop, device) |
| else: |
| pred_y_crop = predict_row(model, row_crop, device) |
| |
| 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) |
| |
| |
| if apply_einthoven: |
| pred_mv_rows = apply_einthoven_correction(pred_mv_rows, alpha=0.33) |
| |
| |
| 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) |
| |
| |
| 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) |
| |
| return all_lead_snrs, min_snr, output_path |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument('--checkpoint', type=str, |
| default='/data/ecg-digitization/checkpoints/v16_perlead_best_snr.pth') |
| 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/v16_tta')) |
| 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_tta', action='store_true', |
| help='Disable Test-Time Augmentation') |
| 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('--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/v16_tta/negpreds')) |
| |
| |
| 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) |
| |
| use_tta = not args.no_tta |
| |
| print(f"{'='*70}") |
| print(f"V16 Inference with TTA") |
| print(f"{'='*70}") |
| print(f"Checkpoint: {args.checkpoint}") |
| print(f"Output: {output_dir}") |
| print(f"Device: {device}") |
| print(f"TTA: {'ON (' + str(len(TTA_AUGMENTATIONS)) + ' augmentations)' if use_tta else 'OFF'}") |
| if use_tta: |
| print(f" Augmentations: {[a['name'] for a in TTA_AUGMENTATIONS]}") |
| 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"Low SNR threshold: {LOW_SNR_THRESHOLD} dB") |
| print(f"{'='*70}") |
| |
| |
| model = load_model(args.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...") |
| |
| |
| 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: |
| lead_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, |
| use_tta=use_tta, |
| negative_dir=negative_dir |
| ) |
| if lead_snrs: |
| for lead, snr in lead_snrs.items(): |
| if snr is not None: |
| all_snrs[lead].append(snr) |
| |
| if min_snr is not None and min_snr < LOW_SNR_THRESHOLD: |
| worst_lead = min(lead_snrs, key=lambda k: lead_snrs[k] if lead_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}") |
| |
| |
| print(f"\n{'='*70}") |
| print(f"Per-Lead SNR Statistics (dB) - TTA {'ON' if use_tta else 'OFF'}") |
| 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: |
| mean_snr = np.mean(snrs) |
| std_snr = np.std(snrs) |
| min_snr = np.min(snrs) |
| max_snr = np.max(snrs) |
| print(f"{lead:<12} {mean_snr:>8.2f} {std_snr:>8.2f} {min_snr:>8.2f} {max_snr:>8.2f} {len(snrs):>6}") |
| total_snrs.extend(snrs) |
| else: |
| print(f"{lead:<12} {'N/A':>8} {'N/A':>8} {'N/A':>8} {'N/A':>8} {0:>6}") |
| |
| 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}") |
| |
| |
| snr_report_path = output_dir / 'snr_report.csv' |
| with open(snr_report_path, 'w') as f: |
| f.write("Lead,Mean_SNR,Std_SNR,Min_SNR,Max_SNR,Count\n") |
| 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: |
| f.write(f"{lead},{np.mean(snrs):.2f},{np.std(snrs):.2f},{np.min(snrs):.2f},{np.max(snrs):.2f},{len(snrs)}\n") |
| else: |
| f.write(f"{lead},N/A,N/A,N/A,N/A,0\n") |
| if len(total_snrs) > 0: |
| f.write(f"OVERALL,{np.mean(total_snrs):.2f},{np.std(total_snrs):.2f},{np.min(total_snrs):.2f},{np.max(total_snrs):.2f},{len(total_snrs)}\n") |
| |
| print(f"\nSNR report 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[:20]: |
| print(f"{item['sample']:<15} {item['variant']:<8} {item['min_snr']:>10.2f} {item['worst_lead']:<12}") |
| |
| if len(low_snr_samples) > 20: |
| print(f"... and {len(low_snr_samples) - 20} more") |
| |
| print(f"\nTotal low-SNR samples: {len(low_snr_samples)}") |
| |
| print(f"{'='*70}") |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|