| """ |
| ECG Dataset and DataLoader for Training |
| |
| Handles: |
| 1. Synthetic data loading (generated from PTB-XL) |
| 2. Competition data loading (Kaggle + Stage 0/1 rectified) |
| 3. Data augmentation pipeline |
| 4. Mixed precision support |
| """ |
|
|
| import os |
| import torch |
| import numpy as np |
| import cv2 |
| import pandas as pd |
| from pathlib import Path |
| from torch.utils.data import Dataset, DataLoader |
| import albumentations as A |
| from albumentations.pytorch import ToTensorV2 |
|
|
|
|
| |
| TARGET_HEIGHT = 1696 |
| TARGET_WIDTH = 4352 |
|
|
| |
| ZERO_MV = [703.5, 987.5, 1271.5, 1531.5] |
| MV_TO_PIXEL = 78.5 |
| T0, T1 = 235, 4161 |
|
|
|
|
| def get_train_transforms(height=TARGET_HEIGHT, width=TARGET_WIDTH): |
| """ |
| Training augmentations for ECG images. |
| |
| Careful not to distort the signal too much - we need |
| to preserve the vertical (mV) relationship. |
| """ |
| return A.Compose([ |
| |
| A.Resize(height, width), |
| |
| |
| A.OneOf([ |
| A.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.1, hue=0.05, p=0.8), |
| A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.8), |
| A.HueSaturationValue(hue_shift_limit=10, sat_shift_limit=20, val_shift_limit=20, p=0.8), |
| ], p=0.7), |
| |
| |
| A.OneOf([ |
| A.GaussNoise(var_limit=(5, 30), p=0.5), |
| A.ISONoise(color_shift=(0.01, 0.05), intensity=(0.1, 0.3), p=0.5), |
| ], p=0.3), |
| |
| |
| A.OneOf([ |
| A.GaussianBlur(blur_limit=(3, 5), p=0.5), |
| A.MotionBlur(blur_limit=3, p=0.3), |
| ], p=0.2), |
| |
| |
| A.OneOf([ |
| A.ImageCompression(quality_lower=70, quality_upper=100, p=0.3), |
| A.Downscale(scale_min=0.8, scale_max=0.95, p=0.3), |
| ], p=0.2), |
| |
| |
| A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), |
| ToTensorV2(), |
| ]) |
|
|
|
|
| def get_val_transforms(height=TARGET_HEIGHT, width=TARGET_WIDTH): |
| """ |
| Validation transforms - just resize and normalize. |
| """ |
| return A.Compose([ |
| A.Resize(height, width), |
| A.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), |
| ToTensorV2(), |
| ]) |
|
|
|
|
| def get_simple_transforms(height=TARGET_HEIGHT, width=TARGET_WIDTH): |
| """ |
| Simple transforms without ImageNet normalization. |
| Used when model expects [0, 1] normalized input. |
| """ |
| return A.Compose([ |
| A.Resize(height, width), |
| A.ToFloat(max_value=255.0), |
| ToTensorV2(), |
| ]) |
|
|
|
|
| class SyntheticECGDataset(Dataset): |
| """ |
| Dataset for synthetic ECG images generated from PTB-XL. |
| |
| Expected directory structure: |
| synthetic_dir/ |
| images/ |
| synth_000001_xxxxx.png |
| ... |
| targets/ |
| synth_000001_xxxxx.npy |
| ... |
| |
| Args: |
| synthetic_dir: Path to synthetic data directory |
| transform: Albumentations transform |
| max_samples: Maximum number of samples (None for all) |
| """ |
| def __init__(self, synthetic_dir, transform=None, max_samples=None): |
| self.synthetic_dir = Path(synthetic_dir) |
| self.transform = transform or get_train_transforms() |
| |
| |
| image_dir = self.synthetic_dir / 'images' |
| self.image_files = sorted(image_dir.glob('*.png')) |
| |
| if max_samples is not None: |
| self.image_files = self.image_files[:max_samples] |
| |
| print(f"Found {len(self.image_files)} synthetic images") |
| |
| def __len__(self): |
| return len(self.image_files) |
| |
| def __getitem__(self, idx): |
| |
| image_path = self.image_files[idx] |
| image = cv2.imread(str(image_path)) |
| image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) |
| |
| |
| target_path = self.synthetic_dir / 'targets' / f"{image_path.stem}.npy" |
| target = np.load(target_path) |
| |
| |
| if self.transform: |
| transformed = self.transform(image=image) |
| image = transformed['image'] |
| |
| |
| |
| target_pixel = np.zeros_like(target) |
| for row_idx in range(4): |
| |
| target_pixel[row_idx] = ZERO_MV[row_idx] - target[row_idx] * MV_TO_PIXEL |
| |
| |
| target_normalized = target_pixel / TARGET_HEIGHT |
| target_normalized = np.clip(target_normalized, 0, 1) |
| |
| |
| output_width = T1 - T0 |
| if target_normalized.shape[1] != output_width: |
| target_resized = np.zeros((4, output_width), dtype=np.float32) |
| for row_idx in range(4): |
| x_old = np.linspace(0, 1, target_normalized.shape[1]) |
| x_new = np.linspace(0, 1, output_width) |
| target_resized[row_idx] = np.interp(x_new, x_old, target_normalized[row_idx]) |
| target_normalized = target_resized |
| |
| return { |
| 'image': image, |
| 'target': torch.from_numpy(target_normalized.astype(np.float32)), |
| 'id': image_path.stem, |
| } |
|
|
|
|
| class KaggleECGDataset(Dataset): |
| """ |
| Dataset for Kaggle competition data (after Stage 0/1 rectification). |
| |
| Expected structure: |
| kaggle_dir/ |
| train/ |
| {id}/ |
| {id}-0001.png |
| {id}.csv |
| stage1_output/ |
| {id}.png (rectified images) |
| |
| Args: |
| kaggle_dir: Path to Kaggle data directory |
| stage1_dir: Path to Stage 1 rectified images |
| df: DataFrame with image IDs and metadata |
| transform: Albumentations transform |
| """ |
| def __init__(self, kaggle_dir, stage1_dir, df, transform=None): |
| self.kaggle_dir = Path(kaggle_dir) |
| self.stage1_dir = Path(stage1_dir) |
| self.df = df |
| self.transform = transform or get_train_transforms() |
| |
| |
| self.image_ids = df['id'].astype(str).unique().tolist() |
| print(f"Found {len(self.image_ids)} Kaggle images") |
| |
| def __len__(self): |
| return len(self.image_ids) |
| |
| def __getitem__(self, idx): |
| image_id = self.image_ids[idx] |
| |
| |
| stage1_path = self.stage1_dir / f"{image_id}.png" |
| if stage1_path.exists(): |
| image = cv2.imread(str(stage1_path)) |
| else: |
| |
| original_path = self.kaggle_dir / 'train' / image_id / f"{image_id}-0001.png" |
| image = cv2.imread(str(original_path)) |
| |
| image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) |
| |
| |
| csv_path = self.kaggle_dir / 'train' / image_id / f"{image_id}.csv" |
| if csv_path.exists(): |
| gt_df = pd.read_csv(csv_path) |
| target = self._csv_to_target(gt_df) |
| else: |
| |
| target = np.zeros((4, T1 - T0), dtype=np.float32) |
| |
| |
| if self.transform: |
| transformed = self.transform(image=image) |
| image = transformed['image'] |
| |
| return { |
| 'image': image, |
| 'target': torch.from_numpy(target.astype(np.float32)), |
| 'id': image_id, |
| } |
| |
| def _csv_to_target(self, df): |
| """Convert ground truth CSV to target array.""" |
| lead_layout = [ |
| ['I', 'aVR', 'V1', 'V4'], |
| ['II', 'aVL', 'V2', 'V5'], |
| ['III', 'aVF', 'V3', 'V6'], |
| ] |
| |
| output_width = T1 - T0 |
| target = np.zeros((4, output_width), dtype=np.float32) |
| |
| |
| for row_idx in range(3): |
| row_signals = [] |
| for lead in lead_layout[row_idx]: |
| if lead in df.columns: |
| signal = df[lead].dropna().values |
| |
| quarter_width = output_width // 4 |
| if len(signal) > 0: |
| x_old = np.linspace(0, 1, len(signal)) |
| x_new = np.linspace(0, 1, quarter_width) |
| signal_resampled = np.interp(x_new, x_old, signal) |
| else: |
| signal_resampled = np.zeros(quarter_width) |
| row_signals.append(signal_resampled) |
| else: |
| row_signals.append(np.zeros(output_width // 4)) |
| |
| |
| target[row_idx] = np.concatenate(row_signals) |
| |
| |
| if 'II' in df.columns: |
| signal_ii = df['II'].dropna().values |
| if len(signal_ii) > 0: |
| x_old = np.linspace(0, 1, len(signal_ii)) |
| x_new = np.linspace(0, 1, output_width) |
| target[3] = np.interp(x_new, x_old, signal_ii) |
| |
| |
| target_pixel = np.zeros_like(target) |
| for row_idx in range(4): |
| target_pixel[row_idx] = ZERO_MV[row_idx] - target[row_idx] * MV_TO_PIXEL |
| |
| target_normalized = target_pixel / TARGET_HEIGHT |
| target_normalized = np.clip(target_normalized, 0, 1) |
| |
| return target_normalized |
|
|
|
|
| class MixedECGDataset(Dataset): |
| """ |
| Combined dataset for training on both synthetic and real data. |
| |
| Useful for fine-tuning: mix real data with synthetic to prevent |
| catastrophic forgetting. |
| |
| Args: |
| synthetic_dataset: SyntheticECGDataset instance |
| kaggle_dataset: KaggleECGDataset instance |
| synthetic_ratio: Ratio of synthetic samples (0-1) |
| """ |
| def __init__(self, synthetic_dataset, kaggle_dataset, synthetic_ratio=0.2): |
| self.synthetic_dataset = synthetic_dataset |
| self.kaggle_dataset = kaggle_dataset |
| self.synthetic_ratio = synthetic_ratio |
| |
| |
| self.n_synthetic = len(synthetic_dataset) |
| self.n_kaggle = len(kaggle_dataset) |
| |
| |
| total_samples = self.n_kaggle + int(self.n_kaggle * synthetic_ratio / (1 - synthetic_ratio)) |
| self.n_synthetic_samples = int(total_samples * synthetic_ratio) |
| self.n_kaggle_samples = total_samples - self.n_synthetic_samples |
| |
| print(f"Mixed dataset: {self.n_kaggle_samples} Kaggle + {self.n_synthetic_samples} synthetic") |
| |
| def __len__(self): |
| return self.n_kaggle_samples + self.n_synthetic_samples |
| |
| def __getitem__(self, idx): |
| if idx < self.n_kaggle_samples: |
| |
| real_idx = idx % self.n_kaggle |
| return self.kaggle_dataset[real_idx] |
| else: |
| |
| synthetic_idx = (idx - self.n_kaggle_samples) % self.n_synthetic |
| return self.synthetic_dataset[synthetic_idx] |
|
|
|
|
| def create_dataloaders( |
| synthetic_dir=None, |
| kaggle_dir=None, |
| stage1_dir=None, |
| train_df=None, |
| val_df=None, |
| batch_size=8, |
| num_workers=4, |
| synthetic_ratio=0.2, |
| distributed=False, |
| ): |
| """ |
| Create DataLoaders for training. |
| |
| Args: |
| synthetic_dir: Path to synthetic data |
| kaggle_dir: Path to Kaggle competition data |
| stage1_dir: Path to Stage 1 rectified images |
| train_df: Training DataFrame |
| val_df: Validation DataFrame |
| batch_size: Batch size per GPU |
| num_workers: Number of data loading workers |
| synthetic_ratio: Ratio of synthetic data in mixed training |
| distributed: Whether using DDP (adds DistributedSampler) |
| |
| Returns: |
| train_loader, val_loader |
| """ |
| train_transform = get_train_transforms() |
| val_transform = get_val_transforms() |
| |
| |
| if synthetic_dir and kaggle_dir: |
| |
| synthetic_dataset = SyntheticECGDataset(synthetic_dir, train_transform) |
| kaggle_dataset = KaggleECGDataset(kaggle_dir, stage1_dir, train_df, train_transform) |
| train_dataset = MixedECGDataset(synthetic_dataset, kaggle_dataset, synthetic_ratio) |
| elif synthetic_dir: |
| |
| train_dataset = SyntheticECGDataset(synthetic_dir, train_transform) |
| elif kaggle_dir: |
| |
| train_dataset = KaggleECGDataset(kaggle_dir, stage1_dir, train_df, train_transform) |
| else: |
| raise ValueError("Must provide either synthetic_dir or kaggle_dir") |
| |
| |
| val_dataset = None |
| if val_df is not None and kaggle_dir: |
| val_dataset = KaggleECGDataset(kaggle_dir, stage1_dir, val_df, val_transform) |
| |
| |
| train_sampler = None |
| val_sampler = None |
| if distributed: |
| train_sampler = torch.utils.data.distributed.DistributedSampler(train_dataset) |
| if val_dataset: |
| val_sampler = torch.utils.data.distributed.DistributedSampler(val_dataset, shuffle=False) |
| |
| |
| train_loader = DataLoader( |
| train_dataset, |
| batch_size=batch_size, |
| shuffle=(train_sampler is None), |
| sampler=train_sampler, |
| num_workers=num_workers, |
| pin_memory=True, |
| drop_last=True, |
| ) |
| |
| val_loader = None |
| if val_dataset: |
| val_loader = DataLoader( |
| val_dataset, |
| batch_size=batch_size, |
| shuffle=False, |
| sampler=val_sampler, |
| num_workers=num_workers, |
| pin_memory=True, |
| ) |
| |
| return train_loader, val_loader |
|
|
|
|
| if __name__ == '__main__': |
| |
| print("Testing Dataset classes...") |
| |
| |
| print("\n1. Testing transforms...") |
| train_tf = get_train_transforms(424, 1088) |
| val_tf = get_val_transforms(424, 1088) |
| |
| |
| dummy_image = np.random.randint(0, 255, (424, 1088, 3), dtype=np.uint8) |
| result = train_tf(image=dummy_image) |
| print(f" Transform output shape: {result['image'].shape}") |
| |
| print("\nDataset tests completed!") |
|
|