ecg-digitization-experiments / code /scripts /infer_v15_visualize.py
Ubuntu
Add training scripts and notebooks
b69e447
Raw
History Blame Contribute Delete
11.8 kB
#!/usr/bin/env python3
"""
V16 Inference with Visualization
Runs V16 per-lead model on Kaggle images and plots predictions as dots on the strips.
V16 uses signal-region-only input (T0:T1) and 500px crop height.
"""
import os
import sys
import argparse
import random
import numpy as np
import pandas as pd
from pathlib import Path
from tqdm import tqdm
import torch
import torch.nn as nn
import torch.nn.functional as F
import cv2
import timm
# =============================================================================
# Constants (from train_v16_perlead.py)
# =============================================================================
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 - V16 input width
# Per-row crop parameters (V16 uses larger crop)
CROP_HALF_HEIGHT = 250
ROW_HEIGHT = 500
VALID_VARIANTS = ['0001', '0003', '0004', '0005', '0006', '0009', '0010', '0011', '0012']
# =============================================================================
# Model Architecture (copy from train_v15_perlead.py)
# =============================================================================
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)
# =============================================================================
# Inference and Visualization
# =============================================================================
def load_model(checkpoint_path, device):
"""Load trained V15 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)
# V16: Crop x to signal region only (T0:T1)
row_crop = image[y_start:y_end, T0:T1, :].copy()
# Pad if necessary
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."""
# Prepare input
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)
# Convert from normalized [0, 1] to crop-relative pixels
pred_y_crop = output[0].cpu().numpy() * ROW_HEIGHT
return pred_y_crop
def convert_crop_to_full(pred_y_crop, row_idx):
"""
Convert crop-relative y-coordinates to full image coordinates.
V16: Output is OUTPUT_WIDTH (3926) values, maps to x positions T0:T1.
"""
baseline_y = int(ZERO_MV[row_idx])
y_start = max(0, baseline_y - CROP_HALF_HEIGHT)
# Adjust for padding
pad_top = max(0, CROP_HALF_HEIGHT - baseline_y)
# Convert to full image 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.
predictions: list of 4 arrays, one per row, each [OUTPUT_WIDTH] in full image y-coords
V16: predictions are already OUTPUT_WIDTH (3926) values for x positions 0 to OUTPUT_WIDTH-1.
Output is cropped to signal region.
Plots EVERY predicted point.
"""
# Crop image to signal region (x: T0 to T1)
vis_image = image[:, T0:T1, :].copy()
# Colors for each row (BGR)
colors = [
(255, 0, 0), # Blue for row 0
(0, 255, 0), # Green for row 1
(255, 0, 255), # Magenta for row 2
(0, 165, 255), # Orange for row 3 (rhythm)
]
for row_idx, pred_y in enumerate(predictions):
color = colors[row_idx]
# V16: pred_y has OUTPUT_WIDTH values, directly maps to vis_image x coords
for x in range(len(pred_y)):
y = int(np.clip(pred_y[x], 0, TARGET_HEIGHT - 1))
# Use radius 1 for dense dots
cv2.circle(vis_image, (x, y), 1, color, -1)
cv2.imwrite(str(output_path), vis_image)
def process_image(model, image_path, output_dir, device):
"""Process a single image and save visualization."""
# Load image
image = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
if image is None:
print(f"Failed to load: {image_path}")
return
# Preprocess
image = image[Y0:Y1, X0:X1]
image = cv2.resize(image, (TARGET_WIDTH, TARGET_HEIGHT), interpolation=cv2.INTER_LINEAR)
# Predict each row
predictions = []
for row_idx in range(4):
row_crop = crop_row(image, row_idx)
pred_y_crop = predict_row(model, row_crop, device)
pred_y_full = convert_crop_to_full(pred_y_crop, row_idx)
predictions.append(pred_y_full)
# Visualize
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, predictions, output_path)
return predictions
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--checkpoint', type=str,
default='/data/ecg-digitization/checkpoints/v16_perlead_latest.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'))
parser.add_argument('--num_samples', type=int, default=50)
parser.add_argument('--seed', type=int, default=42)
args = parser.parse_args()
# Setup
random.seed(args.seed)
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
output_dir = Path(args.output_dir)
# Delete old predictions
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)
print(f"{'='*70}")
print(f"V16 Inference Visualization")
print(f"{'='*70}")
print(f"Checkpoint: {args.checkpoint}")
print(f"Output: {output_dir}")
print(f"Device: {device}")
print(f"{'='*70}")
# Load model
model = load_model(args.checkpoint, device)
# Find all valid images
kaggle_dir = Path(args.kaggle_data)
all_images = []
for sample_dir in kaggle_dir.iterdir():
if not sample_dir.is_dir():
continue
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_images.append(img_path)
print(f"Found {len(all_images)} valid images")
# Random sample
if len(all_images) > args.num_samples:
selected = random.sample(all_images, args.num_samples)
else:
selected = all_images
print(f"Processing {len(selected)} images...")
# Process each image
for img_path in tqdm(selected):
try:
process_image(model, img_path, output_dir, device)
except Exception as e:
print(f"Error processing {img_path}: {e}")
print(f"\n{'='*70}")
print(f"Done! Saved {len(selected)} visualizations to {output_dir}")
print(f"{'='*70}")
if __name__ == '__main__':
main()