| |
| """ |
| Generate 0001-type synthetic ECG images using ecg-image-kit. |
| |
| Uses PTB-XL dataset records to generate clean baseline images |
| that match the competition format. |
| |
| This runs in parallel with training to generate more data. |
| |
| Usage: |
| python generate_synthetic_0001.py --num_samples 10000 --output_dir /data/ecg-digitization/synthetic_0001_v2 |
| """ |
|
|
| import os |
| import sys |
| import argparse |
| import json |
| import random |
| import subprocess |
| from pathlib import Path |
| from concurrent.futures import ProcessPoolExecutor, as_completed |
| from tqdm import tqdm |
| import numpy as np |
| import cv2 |
| import wfdb |
| from scipy import signal as scipy_signal |
| import shutil |
|
|
| |
| PROJECT_ROOT = Path(__file__).parent.parent |
| PTBXL_DIR = Path('/data/ecg-digitization/ptbxl/physionet.org/files/ptb-xl/1.0.3') |
| ECG_IMAGE_KIT = PROJECT_ROOT / 'ecg-image-kit' / 'codes' / 'ecg-image-generator' |
| BASELINE_PATH = PROJECT_ROOT / 'data' / 'hengck23-submit-physionet' / 'hengck23-submit-physionet' |
| OUTPUT_DIR = '/data/ecg-digitization/synthetic_0001_v2' |
|
|
| |
| TARGET_WIDTH = 4352 |
| TARGET_HEIGHT = 1696 |
|
|
| |
| T0 = 235 |
| T1 = 4161 |
| OUTPUT_GT_WIDTH = T1 - T0 |
|
|
|
|
| def find_ptbxl_records(ptbxl_dir): |
| """Find all PTB-XL record files.""" |
| records = [] |
| ptbxl_dir = Path(ptbxl_dir) |
| |
| |
| records500 = ptbxl_dir / 'records500' |
| if records500.exists(): |
| for subdir in sorted(records500.iterdir()): |
| if subdir.is_dir(): |
| for hea in subdir.glob('*_hr.hea'): |
| dat = hea.with_suffix('.dat') |
| if dat.exists(): |
| records.append((str(hea), str(dat))) |
| |
| |
| if len(records) == 0: |
| records100 = ptbxl_dir / 'records100' |
| if records100.exists(): |
| for subdir in sorted(records100.iterdir()): |
| if subdir.is_dir(): |
| for hea in subdir.glob('*_lr.hea'): |
| dat = hea.with_suffix('.dat') |
| if dat.exists(): |
| records.append((str(hea), str(dat))) |
| |
| return records |
|
|
|
|
| def extract_gt_signal(hea_file, output_width=3926): |
| """Extract ground truth signal from PTB-XL record.""" |
| try: |
| |
| record_path = hea_file.replace('.hea', '') |
| record = wfdb.rdrecord(record_path) |
| |
| signals = record.p_signal |
| sig_names = 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.upper()] = signals[:, i] |
| |
| |
| row_leads = [ |
| ['I', 'AVR', 'V1', 'V4'], |
| ['II', 'AVL', 'V2', 'V5'], |
| ['III', 'AVF', 'V3', 'V6'], |
| ] |
| |
| segment_len = output_width // 3 |
| gt_signal = np.zeros((4, output_width), dtype=np.float32) |
| |
| for row_idx in range(4): |
| leads_for_row = [row_leads[col][row_idx] for col in range(3)] |
| |
| for col_idx, lead_name in enumerate(leads_for_row): |
| if lead_name in lead_signals: |
| lead_data = lead_signals[lead_name] |
| |
| start = col_idx * 1250 |
| end = min(start + 1250, len(lead_data)) |
| segment = lead_data[start:end] |
| |
| col_start = col_idx * segment_len |
| col_end = (col_idx + 1) * segment_len if col_idx < 2 else output_width |
| |
| x_old = np.linspace(0, 1, len(segment)) |
| x_new = np.linspace(0, 1, col_end - col_start) |
| gt_signal[row_idx, col_start:col_end] = np.interp(x_new, x_old, segment) |
| |
| return gt_signal |
| |
| except Exception as e: |
| return None |
|
|
|
|
| def generate_single_image(args): |
| """Generate a single 0001-type synthetic ECG image.""" |
| idx, hea_file, dat_file, output_dir, temp_base = args |
| |
| sample_id = f'syn2_{idx:08d}' |
| out_img = output_dir / 'stage1' / f'{sample_id}-0001.png' |
| out_gt = output_dir / 'gt' / f'{sample_id}.npy' |
| |
| |
| if out_img.exists() and out_gt.exists(): |
| return True, idx, "exists" |
| |
| temp_dir = temp_base / f'temp_{idx}' |
| |
| try: |
| 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', str(idx), |
| '-se', str(idx), |
| '-r', '200', |
| '--num_columns', '4', |
| '--full_mode', 'II', |
| '--standard_grid_color', '5', |
| |
| ] |
| |
| result = subprocess.run( |
| cmd, |
| cwd=str(ECG_IMAGE_KIT), |
| capture_output=True, |
| text=True, |
| timeout=60 |
| ) |
| |
| if result.returncode != 0: |
| shutil.rmtree(temp_dir, ignore_errors=True) |
| return False, idx, f"ecg-kit error: {result.stderr[:100]}" |
| |
| |
| gen_imgs = list(temp_dir.glob('*.png')) |
| if not gen_imgs: |
| shutil.rmtree(temp_dir, ignore_errors=True) |
| return False, idx, "no image generated" |
| |
| |
| img = cv2.imread(str(gen_imgs[0])) |
| if img is None: |
| shutil.rmtree(temp_dir, ignore_errors=True) |
| return False, idx, "failed to read image" |
| |
| img_resized = cv2.resize(img, (TARGET_WIDTH, TARGET_HEIGHT), interpolation=cv2.INTER_LINEAR) |
| |
| |
| out_img.parent.mkdir(parents=True, exist_ok=True) |
| cv2.imwrite(str(out_img), img_resized) |
| |
| |
| gt_signal = extract_gt_signal(hea_file, OUTPUT_GT_WIDTH) |
| if gt_signal is not None: |
| out_gt.parent.mkdir(parents=True, exist_ok=True) |
| np.save(out_gt, gt_signal) |
| |
| |
| shutil.rmtree(temp_dir, ignore_errors=True) |
| |
| return True, idx, "success" |
| |
| except Exception as e: |
| shutil.rmtree(temp_dir, ignore_errors=True) |
| return False, idx, str(e)[:100] |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument('--num_samples', type=int, default=10000) |
| parser.add_argument('--output_dir', type=str, default=OUTPUT_DIR) |
| parser.add_argument('--workers', type=int, default=8) |
| args = parser.parse_args() |
| |
| output_dir = Path(args.output_dir) |
| output_dir.mkdir(parents=True, exist_ok=True) |
| (output_dir / 'stage1').mkdir(exist_ok=True) |
| (output_dir / 'gt').mkdir(exist_ok=True) |
| |
| temp_base = output_dir / 'temp' |
| temp_base.mkdir(exist_ok=True) |
| |
| print(f"Finding PTB-XL records in {PTBXL_DIR}...") |
| records = find_ptbxl_records(PTBXL_DIR) |
| print(f"Found {len(records)} records") |
| |
| if len(records) == 0: |
| print("No records found!") |
| return |
| |
| |
| if len(records) > args.num_samples: |
| records = random.sample(records, args.num_samples) |
| elif len(records) < args.num_samples: |
| |
| while len(records) < args.num_samples: |
| records.extend(random.sample(records[:len(records)//2], |
| min(len(records)//2, args.num_samples - len(records)))) |
| |
| print(f"Generating {len(records)} 0001-type synthetic images...") |
| |
| |
| tasks = [ |
| (i, hea, dat, output_dir, temp_base) |
| for i, (hea, dat) in enumerate(records) |
| ] |
| |
| success = 0 |
| failed = 0 |
| |
| with ProcessPoolExecutor(max_workers=args.workers) as executor: |
| futures = {executor.submit(generate_single_image, task): task for task in tasks} |
| |
| for future in tqdm(as_completed(futures), total=len(futures), desc="Generating"): |
| ok, idx, msg = future.result() |
| if ok: |
| success += 1 |
| else: |
| failed += 1 |
| if failed <= 5: |
| print(f"\n Failed {idx}: {msg}") |
| |
| print(f"\n✓ Done! Success: {success}, Failed: {failed}") |
| print(f" Images: {output_dir / 'stage1'}") |
| print(f" GT: {output_dir / 'gt'}") |
| |
| |
| manifest = [] |
| for img in (output_dir / 'stage1').glob('*.png'): |
| sample_id = img.stem.replace('-0001', '') |
| gt_path = output_dir / 'gt' / f'{sample_id}.npy' |
| if gt_path.exists(): |
| manifest.append({ |
| 'sample_id': sample_id, |
| 'style': '0001', |
| 'path': str(img), |
| 'gt': str(gt_path) |
| }) |
| |
| with open(output_dir / 'manifest.json', 'w') as f: |
| json.dump(manifest, f, indent=2) |
| |
| print(f" Manifest: {len(manifest)} samples") |
| |
| |
| shutil.rmtree(temp_base, ignore_errors=True) |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|