| |
| """ |
| Fast Parallel Synthetic ECG Generator using ecg-image-kit |
| |
| Generates synthetic ECG images from PTB-XL records in parallel. |
| Run with venv: source ecgkit_venv/bin/activate && python generate_synthetic_parallel.py |
| |
| Usage: |
| python generate_synthetic_parallel.py --n_samples 50000 --n_workers 16 |
| """ |
|
|
| import os |
| import sys |
| import argparse |
| import subprocess |
| import random |
| import json |
| import shutil |
| import numpy as np |
| import wfdb |
| from scipy import signal as scipy_signal |
| from pathlib import Path |
| from multiprocessing import Pool, cpu_count |
| from tqdm import tqdm |
|
|
| |
| TARGET_HEIGHT = 1696 |
| ZERO_MV = np.array([703.5, 987.5, 1271.5, 1531.5]) |
| MV_TO_PIXEL = 78.5 |
| T0, T1 = 235, 4161 |
| OUTPUT_GT_WIDTH = T1 - T0 |
|
|
| |
| PROJECT_ROOT = Path(__file__).parent.parent |
| ECG_IMAGE_KIT = PROJECT_ROOT / 'ecg-image-kit' / 'codes' / 'ecg-image-generator' |
| PTBXL_DIR = Path('/data/ecg-digitization/ptbxl/physionet.org/files/ptb-xl/1.0.3') |
|
|
| |
| STYLE_CONFIGS = [ |
| {'grid_color': 5, 'augment': False, 'wrinkles': False}, |
| {'grid_color': 5, 'augment': True, 'wrinkles': True}, |
| {'grid_color': 2, 'augment': False, 'wrinkles': False}, |
| {'grid_color': 2, 'augment': True, 'wrinkles': True}, |
| {'grid_color': 1, 'augment': False, 'wrinkles': False}, |
| {'grid_color': 3, 'augment': True, 'wrinkles': False}, |
| {'grid_color': 4, 'augment': True, 'wrinkles': True}, |
| {'grid_color': 2, 'augment': True, 'wrinkles': False}, |
| {'grid_color': 6, 'augment': False, 'wrinkles': False}, |
| {'grid_color': 5, 'augment': True, 'wrinkles': False}, |
| {'grid_color': 5, 'augment': True, 'wrinkles': True}, |
| {'grid_color': 0, 'augment': False, 'wrinkles': False}, |
| ] |
|
|
|
|
| def find_ptbxl_records(): |
| """Find all PTB-XL record files.""" |
| records = [] |
| |
| for records_dir in ['records500', 'records100']: |
| rec_path = PTBXL_DIR / records_dir |
| if rec_path.exists(): |
| for subdir in rec_path.iterdir(): |
| if subdir.is_dir(): |
| for hea in subdir.glob('*.hea'): |
| dat = hea.with_suffix('.dat') |
| if dat.exists(): |
| records.append((str(hea), str(dat))) |
| if records: |
| break |
| |
| return records |
|
|
|
|
| def extract_gt_signal(hea_file): |
| """Extract ground truth signal from PTB-XL record as pixel Y coordinates.""" |
| try: |
| record_path = hea_file.replace('.hea', '') |
| record = wfdb.rdrecord(record_path) |
| |
| signals = record.p_signal |
| sig_names = [n.upper() for n in record.sig_name] |
| fs = record.fs |
| |
| |
| if fs != 500: |
| num_samples = int(signals.shape[0] * 500 / fs) |
| signals = scipy_signal.resample(signals, num_samples, axis=0) |
| |
| |
| lead_signals = {} |
| for i, name in enumerate(sig_names): |
| lead_signals[name] = signals[:, i] |
| |
| |
| |
| |
| |
| |
| row_leads = [ |
| ['I', 'AVR', 'V1', 'V4'], |
| ['II', 'AVL', 'V2', 'V5'], |
| ['III', 'AVF', 'V3', 'V6'], |
| ] |
| |
| segment_width = OUTPUT_GT_WIDTH // 4 |
| gt_pixels = np.zeros((4, OUTPUT_GT_WIDTH), dtype=np.float32) |
| |
| |
| for row_idx in range(3): |
| for col_idx in range(4): |
| lead_name = row_leads[row_idx][col_idx] |
| if lead_name not in lead_signals: |
| continue |
| |
| lead_data = lead_signals[lead_name] |
| |
| start = col_idx * 1250 |
| end = min(start + 1250, len(lead_data)) |
| segment_mv = lead_data[start:end] |
| |
| if len(segment_mv) == 0: |
| continue |
| |
| col_start = col_idx * segment_width |
| col_end = (col_idx + 1) * segment_width if col_idx < 3 else OUTPUT_GT_WIDTH |
| |
| |
| x_old = np.linspace(0, 1, len(segment_mv)) |
| x_new = np.linspace(0, 1, col_end - col_start) |
| segment_resampled = np.interp(x_new, x_old, segment_mv) |
| |
| |
| pixel_y = ZERO_MV[row_idx] - segment_resampled * MV_TO_PIXEL |
| gt_pixels[row_idx, col_start:col_end] = pixel_y |
| |
| |
| if 'II' in lead_signals: |
| lead_ii = lead_signals['II'] |
| x_old = np.linspace(0, 1, len(lead_ii)) |
| x_new = np.linspace(0, 1, OUTPUT_GT_WIDTH) |
| lead_ii_resampled = np.interp(x_new, x_old, lead_ii) |
| gt_pixels[3, :] = ZERO_MV[3] - lead_ii_resampled * MV_TO_PIXEL |
| |
| return gt_pixels |
| |
| except Exception as e: |
| return None |
|
|
|
|
| def generate_single(args): |
| """Generate a single synthetic image.""" |
| idx, hea_file, dat_file, output_dir, style_idx = args |
| |
| try: |
| sample_id = f'syn_{idx:08d}' |
| style_str = f'{(style_idx % 12) + 1:04d}' |
| style_config = STYLE_CONFIGS[style_idx % 12] |
| |
| temp_dir = Path(f'/tmp/ecg_gen_{idx}_{os.getpid()}') |
| temp_dir.mkdir(parents=True, exist_ok=True) |
| |
| |
| |
| cmd = [ |
| 'python', 'gen_ecg_image_from_data.py', |
| '-i', dat_file, |
| '-hea', hea_file, |
| '-o', str(temp_dir), |
| '-st', '0', |
| '-se', str(idx), |
| '-r', '200', |
| '--num_columns', '4', |
| '--full_mode', 'II', |
| ] |
| |
| if style_config['grid_color'] > 0: |
| cmd.extend(['--standard_grid_color', str(style_config['grid_color'])]) |
| else: |
| cmd.extend(['--random_grid_present', '0']) |
| |
| if style_config['augment']: |
| cmd.append('--augment') |
| |
| if style_config['wrinkles']: |
| cmd.append('--wrinkles') |
| |
| |
| env = os.environ.copy() |
| env['MPLCONFIGDIR'] = f'/tmp/mpl_{idx}_{os.getpid()}' |
| |
| result = subprocess.run( |
| cmd, |
| cwd=str(ECG_IMAGE_KIT), |
| capture_output=True, |
| text=True, |
| timeout=120, |
| env=env |
| ) |
| |
| |
| gen_images = list(temp_dir.glob('*.png')) |
| output_path = None |
| gt_path = None |
| |
| if gen_images: |
| output_path = Path(output_dir) / 'raw' / f'{sample_id}-{style_str}.png' |
| shutil.move(str(gen_images[0]), str(output_path)) |
| |
| |
| gt_pixels = extract_gt_signal(hea_file) |
| if gt_pixels is not None: |
| gt_path = Path(output_dir) / 'gt' / f'{sample_id}-{style_str}.csv' |
| gt_path.parent.mkdir(parents=True, exist_ok=True) |
| |
| np.savetxt(gt_path, gt_pixels.T, delimiter=',', fmt='%.2f') |
| |
| |
| shutil.rmtree(temp_dir, ignore_errors=True) |
| mpl_dir = Path(f'/tmp/mpl_{idx}_{os.getpid()}') |
| if mpl_dir.exists(): |
| shutil.rmtree(mpl_dir, ignore_errors=True) |
| |
| if output_path and output_path.exists() and gt_path and gt_path.exists(): |
| return { |
| 'sample_id': sample_id, |
| 'style': style_str, |
| 'path': str(output_path), |
| 'gt_path': str(gt_path), |
| 'source': hea_file, |
| } |
| return None |
| |
| except Exception as e: |
| return None |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument('--output_dir', type=str, default='/data/ecg-digitization/synthetic_ecgkit') |
| parser.add_argument('--n_samples', type=int, default=50000) |
| parser.add_argument('--n_workers', type=int, default=16) |
| parser.add_argument('--resume', action='store_true', help='Resume from existing progress') |
| args = parser.parse_args() |
| |
| output_dir = Path(args.output_dir) |
| (output_dir / 'raw').mkdir(parents=True, exist_ok=True) |
| (output_dir / 'gt').mkdir(parents=True, exist_ok=True) |
| |
| |
| print("Finding PTB-XL records...") |
| records = find_ptbxl_records() |
| print(f"Found {len(records)} records") |
| |
| if not records: |
| print("No PTB-XL records found!") |
| return |
| |
| |
| existing_indices = set() |
| if args.resume: |
| raw_dir = output_dir / 'raw' |
| for f in raw_dir.glob('syn_*.png'): |
| try: |
| |
| idx = int(f.stem.split('_')[1].split('-')[0]) |
| existing_indices.add(idx) |
| except: |
| pass |
| print(f"Found {len(existing_indices)} existing samples, resuming...") |
| |
| |
| print(f"Preparing generation tasks...") |
| tasks = [] |
| random.seed(42) |
| for i in range(args.n_samples): |
| hea, dat = random.choice(records) |
| style_idx = 0 |
| if i not in existing_indices: |
| tasks.append((i, hea, dat, str(output_dir), style_idx)) |
| |
| print(f"Tasks to generate: {len(tasks)} (skipped {len(existing_indices)} existing)") |
| |
| if not tasks: |
| print("All samples already generated!") |
| return |
| |
| |
| print(f"Generating with {args.n_workers} workers...") |
| results = [] |
| |
| with Pool(args.n_workers) as pool: |
| for result in tqdm(pool.imap_unordered(generate_single, tasks), |
| total=len(tasks), desc="Generating"): |
| if result: |
| results.append(result) |
| |
| print(f"\nSuccessfully generated {len(results)} images") |
| |
| |
| manifest_path = output_dir / 'manifest.json' |
| with open(manifest_path, 'w') as f: |
| json.dump(results, f, indent=2) |
| |
| print(f"Manifest saved to {manifest_path}") |
| print(f"Raw images in {output_dir / 'raw'}") |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|