| |
| """ |
| Synthetic ECG Image Generator for Training |
| |
| This script generates synthetic ECG images from PTB-XL signals using ecg-image-kit. |
| The generated images match the "Stage 1 Rectified" format of the competition data. |
| |
| Target Format: |
| - Image Size: 1696 (H) x 4352 (W) |
| - Layout: 4 rows (3 leads per row + 1 rhythm strip) |
| - Standard 12-lead ECG grid |
| |
| Usage: |
| python generate_synthetic.py --n_samples 200000 --n_workers 16 --output_dir ../data/synthetic |
| """ |
|
|
| import os |
| import sys |
| import argparse |
| import numpy as np |
| import pandas as pd |
| import cv2 |
| import wfdb |
| from pathlib import Path |
| from multiprocessing import Pool, cpu_count |
| from tqdm import tqdm |
| import json |
| import random |
|
|
| |
| ECG_IMAGE_KIT_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'ecg-image-kit') |
| sys.path.insert(0, ECG_IMAGE_KIT_PATH) |
|
|
| |
| try: |
| from ecg_image_generator.ecg_plot import ecg_plot |
| except ImportError: |
| print(f"Warning: ecg-image-kit not found at {ECG_IMAGE_KIT_PATH}") |
| print("Please ensure the repository is cloned correctly") |
|
|
|
|
| |
| TARGET_HEIGHT = 1696 |
| TARGET_WIDTH = 4352 |
| LEAD_NAMES = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6'] |
|
|
| |
| ROW_LAYOUT = [ |
| ['I', 'aVR', 'V1', 'V4'], |
| ['II', 'aVL', 'V2', 'V5'], |
| ['III', 'aVF', 'V3', 'V6'], |
| ] |
|
|
| |
| ZERO_MV = [703.5, 987.5, 1271.5, 1531.5] |
| MV_TO_PIXEL = 78.5 |
|
|
|
|
| class AugmentationConfig: |
| """Configuration for image augmentations to make synthetic data realistic""" |
| |
| def __init__(self, difficulty='medium'): |
| if difficulty == 'easy': |
| self.config = { |
| 'grid_color': 'red', |
| 'grid_opacity': 0.8, |
| 'add_noise': False, |
| 'noise_level': 0, |
| 'add_wrinkles': False, |
| 'add_shadows': False, |
| 'rotate': False, |
| 'add_text': True, |
| 'text_opacity': 0.7, |
| } |
| elif difficulty == 'medium': |
| self.config = { |
| 'grid_color': random.choice(['red', 'green', 'lightred', 'lightgreen']), |
| 'grid_opacity': random.uniform(0.5, 0.9), |
| 'add_noise': True, |
| 'noise_level': random.uniform(0.01, 0.05), |
| 'add_wrinkles': random.random() > 0.5, |
| 'add_shadows': random.random() > 0.5, |
| 'rotate': False, |
| 'add_text': True, |
| 'text_opacity': random.uniform(0.5, 0.9), |
| } |
| elif difficulty == 'hard': |
| self.config = { |
| 'grid_color': random.choice(['red', 'green', 'lightred', 'lightgreen', 'faded']), |
| 'grid_opacity': random.uniform(0.3, 1.0), |
| 'add_noise': True, |
| 'noise_level': random.uniform(0.02, 0.10), |
| 'add_wrinkles': random.random() > 0.3, |
| 'add_shadows': random.random() > 0.3, |
| 'rotate': False, |
| 'add_text': True, |
| 'text_opacity': random.uniform(0.3, 1.0), |
| } |
| else: |
| self.config = {} |
| |
| def get(self, key, default=None): |
| return self.config.get(key, default) |
|
|
|
|
| def load_ptbxl_record(record_path): |
| """ |
| Load a PTB-XL record and return the 12-lead signals |
| |
| Args: |
| record_path: Path to the record (without extension) |
| |
| Returns: |
| dict: Dictionary with lead names as keys and signal arrays as values |
| int: Sampling frequency |
| """ |
| try: |
| record = wfdb.rdrecord(record_path) |
| signals = record.p_signal |
| fs = record.fs |
| |
| |
| lead_names = record.sig_name |
| |
| signal_dict = {} |
| for i, lead in enumerate(lead_names): |
| |
| lead_normalized = lead.upper().replace(' ', '') |
| if lead_normalized in LEAD_NAMES: |
| signal_dict[lead_normalized] = signals[:, i] |
| |
| return signal_dict, int(fs) |
| except Exception as e: |
| print(f"Error loading record {record_path}: {e}") |
| return None, None |
|
|
|
|
| def generate_ecg_image_matplotlib(signal_dict, fs, output_path, config=None): |
| """ |
| Generate an ECG image using matplotlib (fallback method) |
| |
| Args: |
| signal_dict: Dictionary with lead signals |
| fs: Sampling frequency |
| output_path: Path to save the image |
| config: AugmentationConfig instance |
| """ |
| import matplotlib.pyplot as plt |
| import matplotlib |
| matplotlib.use('Agg') |
| |
| |
| fig, axes = plt.subplots(4, 1, figsize=(43.52, 16.96), dpi=100) |
| |
| |
| if config: |
| grid_color = config.get('grid_color', 'red') |
| if grid_color in ['red', 'lightred']: |
| major_color = '#ffcccc' |
| minor_color = '#ffe6e6' |
| else: |
| major_color = '#ccffcc' |
| minor_color = '#e6ffe6' |
| else: |
| major_color = '#ffcccc' |
| minor_color = '#ffe6e6' |
| |
| for row_idx, ax in enumerate(axes[:3]): |
| ax.set_facecolor('white') |
| |
| |
| ax.grid(True, which='major', color=major_color, linewidth=0.5) |
| ax.grid(True, which='minor', color=minor_color, linewidth=0.25) |
| ax.minorticks_on() |
| |
| |
| leads_in_row = ROW_LAYOUT[row_idx] |
| samples_per_lead = int(2.5 * fs) |
| |
| for lead_idx, lead in enumerate(leads_in_row): |
| if lead in signal_dict: |
| signal = signal_dict[lead][:samples_per_lead] |
| x_offset = lead_idx * samples_per_lead |
| t = np.arange(len(signal)) + x_offset |
| ax.plot(t, signal, 'k-', linewidth=0.8) |
| |
| ax.set_xlim(0, 4 * samples_per_lead) |
| ax.set_ylim(-2, 2) |
| ax.set_ylabel(f'Row {row_idx + 1}') |
| ax.tick_params(labelbottom=False) |
| |
| |
| ax = axes[3] |
| ax.set_facecolor('white') |
| ax.grid(True, which='major', color=major_color, linewidth=0.5) |
| ax.grid(True, which='minor', color=minor_color, linewidth=0.25) |
| ax.minorticks_on() |
| |
| if 'II' in signal_dict: |
| signal = signal_dict['II'][:int(10 * fs)] |
| ax.plot(signal, 'k-', linewidth=0.8) |
| |
| ax.set_xlim(0, int(10 * fs)) |
| ax.set_ylim(-2, 2) |
| ax.set_ylabel('II (Rhythm)') |
| ax.set_xlabel('Samples') |
| |
| plt.tight_layout() |
| plt.savefig(output_path, dpi=100, bbox_inches='tight', facecolor='white') |
| plt.close(fig) |
| |
| |
| img = cv2.imread(output_path) |
| if img is not None: |
| img_resized = cv2.resize(img, (TARGET_WIDTH, TARGET_HEIGHT), interpolation=cv2.INTER_LINEAR) |
| cv2.imwrite(output_path, img_resized) |
|
|
|
|
| def generate_ecg_image_ecgkit(signal_dict, fs, output_path, config=None): |
| """ |
| Generate an ECG image using ecg-image-kit |
| |
| Args: |
| signal_dict: Dictionary with lead signals |
| fs: Sampling frequency |
| output_path: Path to save the image |
| config: AugmentationConfig instance |
| """ |
| try: |
| from ecg_image_generator.ecg_plot import ecg_plot |
| |
| |
| signals = [] |
| for lead in LEAD_NAMES: |
| if lead in signal_dict: |
| signals.append(signal_dict[lead]) |
| else: |
| |
| if signals: |
| signals.append(np.zeros_like(signals[0])) |
| else: |
| signals.append(np.zeros(int(10 * fs))) |
| |
| signals = np.array(signals).T |
| |
| |
| ecg_plot( |
| signals, |
| sample_rate=fs, |
| lead_index=LEAD_NAMES, |
| columns=4, |
| row_height=4, |
| show_lead_name=True, |
| show_grid=True, |
| show_separate_line=True, |
| output_file=str(output_path), |
| dpi=100, |
| ) |
| |
| |
| img = cv2.imread(str(output_path)) |
| if img is not None: |
| img_resized = cv2.resize(img, (TARGET_WIDTH, TARGET_HEIGHT), interpolation=cv2.INTER_LINEAR) |
| cv2.imwrite(str(output_path), img_resized) |
| |
| except Exception as e: |
| print(f"ecg-image-kit failed, using matplotlib fallback: {e}") |
| generate_ecg_image_matplotlib(signal_dict, fs, output_path, config) |
|
|
|
|
| def apply_augmentations(image_path, config): |
| """ |
| Apply augmentations to make synthetic images more realistic |
| |
| Args: |
| image_path: Path to the image |
| config: AugmentationConfig instance |
| """ |
| img = cv2.imread(str(image_path)) |
| if img is None: |
| return |
| |
| |
| if config.get('add_noise', False): |
| noise_level = config.get('noise_level', 0.02) |
| noise = np.random.normal(0, noise_level * 255, img.shape).astype(np.float32) |
| img = np.clip(img.astype(np.float32) + noise, 0, 255).astype(np.uint8) |
| |
| |
| if config.get('add_shadows', False): |
| shadow_mask = np.ones(img.shape[:2], dtype=np.float32) |
| for _ in range(random.randint(1, 3)): |
| x1 = random.randint(0, img.shape[1]) |
| x2 = random.randint(0, img.shape[1]) |
| y1 = random.randint(0, img.shape[0]) |
| y2 = random.randint(0, img.shape[0]) |
| |
| |
| shadow_intensity = random.uniform(0.85, 0.95) |
| cv2.line(shadow_mask, (x1, y1), (x2, y2), shadow_intensity, random.randint(20, 50)) |
| |
| shadow_mask = cv2.GaussianBlur(shadow_mask, (21, 21), 0) |
| img = (img * shadow_mask[:, :, np.newaxis]).astype(np.uint8) |
| |
| |
| if config.get('add_wrinkles', False): |
| |
| texture = np.random.normal(1.0, 0.02, img.shape[:2]).astype(np.float32) |
| texture = cv2.GaussianBlur(texture, (5, 5), 0) |
| img = np.clip(img * texture[:, :, np.newaxis], 0, 255).astype(np.uint8) |
| |
| cv2.imwrite(str(image_path), img) |
|
|
|
|
| def signal_to_target(signal_dict, fs, target_width): |
| """ |
| Convert signal dictionary to normalized target values for training |
| |
| This creates the ground truth Y-coordinates that the model should predict. |
| |
| Args: |
| signal_dict: Dictionary with lead signals |
| fs: Sampling frequency |
| target_width: Width of the target output |
| |
| Returns: |
| np.ndarray: Shape (4, target_width) with normalized signal values |
| """ |
| |
| T0, T1 = 235, 4161 |
| output_width = T1 - T0 |
| |
| target = np.zeros((4, output_width), dtype=np.float32) |
| |
| |
| for row_idx in range(3): |
| leads_in_row = ROW_LAYOUT[row_idx] |
| quarter_width = output_width // 4 |
| |
| for lead_idx, lead in enumerate(leads_in_row): |
| if lead in signal_dict: |
| signal = signal_dict[lead] |
| |
| |
| samples_needed = int(2.5 * fs) |
| if len(signal) >= samples_needed: |
| signal_segment = signal[:samples_needed] |
| else: |
| signal_segment = np.pad(signal, (0, samples_needed - len(signal))) |
| |
| |
| x_old = np.linspace(0, 1, len(signal_segment)) |
| x_new = np.linspace(0, 1, quarter_width) |
| signal_resampled = np.interp(x_new, x_old, signal_segment) |
| |
| start_idx = lead_idx * quarter_width |
| end_idx = start_idx + quarter_width |
| target[row_idx, start_idx:end_idx] = signal_resampled |
| |
| |
| if 'II' in signal_dict: |
| signal = signal_dict['II'] |
| samples_needed = int(10 * fs) |
| if len(signal) >= samples_needed: |
| signal_segment = signal[:samples_needed] |
| else: |
| signal_segment = np.pad(signal, (0, samples_needed - len(signal))) |
| |
| x_old = np.linspace(0, 1, len(signal_segment)) |
| x_new = np.linspace(0, 1, output_width) |
| target[3, :] = np.interp(x_new, x_old, signal_segment) |
| |
| return target |
|
|
|
|
| def process_single_record(args): |
| """ |
| Process a single PTB-XL record and generate synthetic image + target |
| |
| Args: |
| args: Tuple of (record_path, output_dir, idx, difficulty) |
| |
| Returns: |
| bool: Success status |
| """ |
| record_path, output_dir, idx, difficulty = args |
| |
| try: |
| |
| signal_dict, fs = load_ptbxl_record(record_path) |
| if signal_dict is None or fs is None: |
| return False |
| |
| |
| record_name = Path(record_path).stem |
| output_name = f"synth_{idx:06d}_{record_name}" |
| |
| image_path = Path(output_dir) / 'images' / f"{output_name}.png" |
| target_path = Path(output_dir) / 'targets' / f"{output_name}.npy" |
| |
| |
| if image_path.exists() and target_path.exists(): |
| return True |
| |
| |
| config = AugmentationConfig(difficulty) |
| |
| |
| try: |
| generate_ecg_image_ecgkit(signal_dict, fs, image_path, config) |
| except: |
| generate_ecg_image_matplotlib(signal_dict, fs, image_path, config) |
| |
| |
| apply_augmentations(image_path, config) |
| |
| |
| target = signal_to_target(signal_dict, fs, TARGET_WIDTH) |
| np.save(target_path, target) |
| |
| return True |
| |
| except Exception as e: |
| print(f"Error processing {record_path}: {e}") |
| return False |
|
|
|
|
| def find_ptbxl_records(ptbxl_dir): |
| """ |
| Find all PTB-XL records |
| |
| Args: |
| ptbxl_dir: Path to PTB-XL directory |
| |
| Returns: |
| list: List of record paths (without extensions) |
| """ |
| records = [] |
| ptbxl_path = Path(ptbxl_dir) |
| |
| |
| for folder in sorted(ptbxl_path.rglob('*')): |
| if folder.is_dir() and folder.name.startswith('records'): |
| continue |
| |
| for hea_file in folder.glob('*.hea'): |
| record_path = str(hea_file)[:-4] |
| records.append(record_path) |
| |
| |
| physionet_path = ptbxl_path / 'physionet.org' / 'files' / 'ptb-xl' / '1.0.3' |
| if physionet_path.exists(): |
| for folder in sorted(physionet_path.rglob('records*')): |
| for subfolder in sorted(folder.iterdir()): |
| if subfolder.is_dir(): |
| for hea_file in subfolder.glob('*.hea'): |
| record_path = str(hea_file)[:-4] |
| if record_path not in records: |
| records.append(record_path) |
| |
| return records |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser(description='Generate synthetic ECG images from PTB-XL') |
| parser.add_argument('--ptbxl_dir', type=str, default='../data/ptbxl', |
| help='Path to PTB-XL dataset directory') |
| parser.add_argument('--output_dir', type=str, default='../data/synthetic', |
| help='Output directory for synthetic data') |
| parser.add_argument('--n_samples', type=int, default=200000, |
| help='Number of synthetic samples to generate') |
| parser.add_argument('--n_workers', type=int, default=None, |
| help='Number of parallel workers (default: CPU count)') |
| parser.add_argument('--difficulty', type=str, default='medium', |
| choices=['easy', 'medium', 'hard', 'mixed'], |
| help='Augmentation difficulty level') |
| args = parser.parse_args() |
| |
| |
| output_dir = Path(args.output_dir) |
| (output_dir / 'images').mkdir(parents=True, exist_ok=True) |
| (output_dir / 'targets').mkdir(parents=True, exist_ok=True) |
| |
| |
| print(f"Searching for PTB-XL records in {args.ptbxl_dir}...") |
| records = find_ptbxl_records(args.ptbxl_dir) |
| print(f"Found {len(records)} PTB-XL records") |
| |
| if len(records) == 0: |
| print("No records found! Please check the PTB-XL directory path.") |
| print("Expected structure: ptbxl/physionet.org/files/ptb-xl/1.0.3/records*/") |
| return |
| |
| |
| n_workers = args.n_workers or cpu_count() |
| tasks = [] |
| |
| for i in range(args.n_samples): |
| record_idx = i % len(records) |
| if args.difficulty == 'mixed': |
| difficulty = random.choice(['easy', 'medium', 'hard']) |
| else: |
| difficulty = args.difficulty |
| |
| tasks.append((records[record_idx], str(output_dir), i, difficulty)) |
| |
| print(f"Generating {args.n_samples} synthetic images using {n_workers} workers...") |
| |
| |
| with Pool(n_workers) as pool: |
| results = list(tqdm( |
| pool.imap(process_single_record, tasks), |
| total=len(tasks), |
| desc="Generating" |
| )) |
| |
| success_count = sum(results) |
| print(f"\nGeneration complete!") |
| print(f"Successfully generated: {success_count}/{len(tasks)} images") |
| |
| |
| metadata = { |
| 'n_samples': success_count, |
| 'target_height': TARGET_HEIGHT, |
| 'target_width': TARGET_WIDTH, |
| 'lead_layout': ROW_LAYOUT, |
| 'zero_mv': ZERO_MV, |
| 'mv_to_pixel': MV_TO_PIXEL, |
| } |
| |
| with open(output_dir / 'metadata.json', 'w') as f: |
| json.dump(metadata, f, indent=2) |
| |
| print(f"Metadata saved to {output_dir / 'metadata.json'}") |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|