| |
| """ |
| Generate synthetic 0001-type ECG images directly using matplotlib. |
| Much faster than ecg-image-kit, generates clean baseline images. |
| |
| Usage: |
| python generate_synthetic_simple.py --num_samples 20000 --workers 32 |
| """ |
|
|
| import os |
| import sys |
| import argparse |
| import random |
| import numpy as np |
| import cv2 |
| import wfdb |
| import matplotlib |
| matplotlib.use('Agg') |
| import matplotlib.pyplot as plt |
| from matplotlib.patches import Rectangle |
| from pathlib import Path |
| from concurrent.futures import ProcessPoolExecutor, as_completed |
| from tqdm import tqdm |
| from scipy import signal as scipy_signal |
|
|
| |
| PTBXL_DIR = Path('/data/ecg-digitization/ptbxl/physionet.org/files/ptb-xl/1.0.3') |
| OUTPUT_DIR = Path('/data/ecg-digitization/synthetic_0001_v2') |
|
|
| |
| TARGET_WIDTH = 4352 |
| TARGET_HEIGHT = 1696 |
| T0, T1 = 235, 4161 |
| OUTPUT_GT_WIDTH = T1 - T0 |
|
|
| |
| ZERO_MV = [703.5, 987.5, 1271.5, 1531.5] |
| MV_TO_PIXEL = 78.5 |
|
|
|
|
| def find_ptbxl_records(): |
| """Find all PTB-XL record files.""" |
| records = [] |
| for subdir_name in ['records100', 'records500']: |
| subdir = PTBXL_DIR / subdir_name |
| if subdir.exists(): |
| for folder in sorted(subdir.iterdir()): |
| if folder.is_dir(): |
| for hea in folder.glob('*.hea'): |
| dat = hea.with_suffix('.dat') |
| if dat.exists(): |
| records.append(str(hea)) |
| return records |
|
|
|
|
| def load_ecg_signal(hea_path): |
| """Load 12-lead ECG signal from PTB-XL record.""" |
| try: |
| record_path = hea_path.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_map = {name: signals[:, i] for i, name in enumerate(sig_names)} |
| |
| return lead_map, 500 |
| except Exception as e: |
| return None, 0 |
|
|
|
|
| def create_ecg_image(lead_map, sample_rate=500): |
| """Create ECG image with 0001-style (clean red grid).""" |
| |
| fig_width = TARGET_WIDTH / 100 |
| fig_height = TARGET_HEIGHT / 100 |
| |
| fig, ax = plt.subplots(figsize=(fig_width, fig_height), dpi=100) |
| |
| |
| ax.set_facecolor('white') |
| fig.patch.set_facecolor('white') |
| |
| |
| grid_color = '#ffcccc' |
| grid_color_major = '#ff9999' |
| |
| |
| minor_step = 20 |
| for x in range(0, TARGET_WIDTH, minor_step): |
| ax.axvline(x, color=grid_color, linewidth=0.3, alpha=0.5) |
| for y in range(0, TARGET_HEIGHT, minor_step): |
| ax.axhline(y, color=grid_color, linewidth=0.3, alpha=0.5) |
| |
| |
| major_step = 100 |
| for x in range(0, TARGET_WIDTH, major_step): |
| ax.axvline(x, color=grid_color_major, linewidth=0.5, alpha=0.7) |
| for y in range(0, TARGET_HEIGHT, major_step): |
| ax.axhline(y, color=grid_color_major, linewidth=0.5, alpha=0.7) |
| |
| |
| |
| |
| |
| |
| |
| row_leads = [ |
| ['I', 'AVR', 'V1', 'V4'], |
| ['II', 'AVL', 'V2', 'V5'], |
| ['III', 'AVF', 'V3', 'V6'], |
| ] |
| |
| |
| segment_samples = int(2.5 * sample_rate) |
| segment_width = (T1 - T0) // 3 |
| |
| gt_signal = np.zeros((4, OUTPUT_GT_WIDTH), dtype=np.float32) |
| |
| |
| for row_idx, leads in enumerate(row_leads): |
| zero_y = ZERO_MV[row_idx] |
| |
| for col_idx, lead_name in enumerate(leads[:3]): |
| if lead_name not in lead_map: |
| continue |
| |
| lead_data = lead_map[lead_name] |
| |
| |
| start = col_idx * segment_samples |
| end = min(start + segment_samples, len(lead_data)) |
| segment = lead_data[start:end] |
| |
| |
| col_start = T0 + col_idx * segment_width |
| col_end = T0 + (col_idx + 1) * segment_width if col_idx < 2 else T1 |
| |
| |
| x_coords = np.linspace(col_start, col_end, len(segment)) |
| |
| |
| y_coords = zero_y - segment * MV_TO_PIXEL |
| |
| |
| ax.plot(x_coords, y_coords, 'k-', linewidth=0.8) |
| |
| |
| gt_start = col_idx * segment_width |
| gt_end = (col_idx + 1) * segment_width if col_idx < 2 else OUTPUT_GT_WIDTH |
| x_old = np.linspace(0, 1, len(segment)) |
| x_new = np.linspace(0, 1, gt_end - gt_start) |
| gt_signal[row_idx, gt_start:gt_end] = np.interp(x_new, x_old, segment) |
| |
| |
| if 'II' in lead_map: |
| lead_data = lead_map['II'] |
| zero_y = ZERO_MV[3] |
| |
| full_samples = min(len(lead_data), int(10 * sample_rate)) |
| segment = lead_data[:full_samples] |
| |
| x_coords = np.linspace(T0, T1, len(segment)) |
| y_coords = zero_y - segment * MV_TO_PIXEL |
| |
| ax.plot(x_coords, y_coords, 'k-', linewidth=0.8) |
| |
| |
| x_old = np.linspace(0, 1, len(segment)) |
| x_new = np.linspace(0, 1, OUTPUT_GT_WIDTH) |
| gt_signal[3, :] = np.interp(x_new, x_old, segment) |
| |
| |
| ax.set_xlim(0, TARGET_WIDTH) |
| ax.set_ylim(TARGET_HEIGHT, 0) |
| ax.axis('off') |
| |
| |
| fig.tight_layout(pad=0) |
| fig.canvas.draw() |
| |
| |
| w, h = fig.canvas.get_width_height() |
| img = np.frombuffer(fig.canvas.tostring_rgb(), dtype=np.uint8).reshape(h, w, 3) |
| |
| plt.close(fig) |
| |
| |
| if img.shape[:2] != (TARGET_HEIGHT, TARGET_WIDTH): |
| img = cv2.resize(img, (TARGET_WIDTH, TARGET_HEIGHT)) |
| |
| return img, gt_signal |
|
|
|
|
| def generate_single(args): |
| """Generate a single synthetic ECG sample.""" |
| idx, hea_path, output_dir = 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" |
| |
| try: |
| |
| lead_map, fs = load_ecg_signal(hea_path) |
| if lead_map is None: |
| return False, idx, "load failed" |
| |
| |
| img, gt_signal = create_ecg_image(lead_map, fs) |
| |
| |
| out_img.parent.mkdir(parents=True, exist_ok=True) |
| out_gt.parent.mkdir(parents=True, exist_ok=True) |
| |
| cv2.imwrite(str(out_img), cv2.cvtColor(img, cv2.COLOR_RGB2BGR)) |
| np.save(out_gt, gt_signal) |
| |
| return True, idx, "success" |
| |
| except Exception as e: |
| return False, idx, str(e)[:100] |
|
|
|
|
| def main(): |
| parser = argparse.ArgumentParser() |
| parser.add_argument('--num_samples', type=int, default=20000) |
| parser.add_argument('--output_dir', type=str, default=str(OUTPUT_DIR)) |
| parser.add_argument('--workers', type=int, default=16) |
| 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) |
| |
| print(f"Finding PTB-XL records...") |
| records = find_ptbxl_records() |
| print(f"Found {len(records)} records") |
| |
| if not records: |
| print("No records found!") |
| return |
| |
| |
| if len(records) > args.num_samples: |
| records = random.sample(records, args.num_samples) |
| elif len(records) < args.num_samples: |
| records = records * (args.num_samples // len(records) + 1) |
| records = records[:args.num_samples] |
| |
| print(f"Generating {len(records)} 0001-type synthetic images...") |
| |
| tasks = [(i, rec, output_dir) for i, rec in enumerate(records)] |
| |
| success, failed = 0, 0 |
| |
| with ProcessPoolExecutor(max_workers=args.workers) as executor: |
| futures = {executor.submit(generate_single, t): t for t 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}") |
| |
| |
| import json |
| manifest = [] |
| for img in sorted((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") |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|