| |
| """ |
| Generate 0001-style synthetic ECG images directly (no ecg-image-kit). |
| |
| Uses matplotlib to render ECG signals on a red grid background, |
| matching the 0001 competition format. |
| |
| This is simpler and more reliable than ecg-image-kit. |
| |
| Output: |
| - synthetic_0001_{source}/raw/*.png (1700x2200) |
| - synthetic_0001_{source}/gt/*.csv (3926 lines x 4 columns) |
| |
| Usage: |
| python generate_synthetic_0001_direct.py --source georgia --workers 8 |
| python generate_synthetic_0001_direct.py --source all --workers 8 |
| """ |
|
|
| import os |
| import sys |
| import argparse |
| import json |
| from pathlib import Path |
| from concurrent.futures import ProcessPoolExecutor, as_completed |
| from tqdm import tqdm |
| import numpy as np |
| import cv2 |
| import matplotlib |
| matplotlib.use('Agg') |
| import matplotlib.pyplot as plt |
| from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas |
|
|
| try: |
| import wfdb |
| from scipy import signal as scipy_signal |
| from scipy.ndimage import gaussian_filter1d |
| except ImportError: |
| import subprocess |
| subprocess.run([sys.executable, '-m', 'pip', 'install', 'wfdb', 'scipy'], check=True) |
| import wfdb |
| from scipy import signal as scipy_signal |
| from scipy.ndimage import gaussian_filter1d |
|
|
| |
| |
| |
| DATA_ROOT = Path('/data/ecg-digitization') |
|
|
| DATA_SOURCES = { |
| 'ptbxl': DATA_ROOT / 'ptbxl' / 'physionet.org' / 'files' / 'ptb-xl' / '1.0.3', |
| 'chapman': DATA_ROOT / 'chapman', |
| 'georgia': DATA_ROOT / 'georgia', |
| 'cpsc': DATA_ROOT / 'cpsc', |
| 'ningbo': DATA_ROOT / 'ningbo', |
| } |
|
|
| |
| RAW_WIDTH = 2200 |
| RAW_HEIGHT = 1700 |
|
|
| |
| TARGET_WIDTH = 4352 |
| TARGET_HEIGHT = 1696 |
|
|
| |
| T0 = 235 |
| T1 = 4161 |
| OUTPUT_GT_WIDTH = T1 - T0 |
|
|
| |
| ZERO_MV = np.array([703.5, 987.5, 1271.5, 1531.5]) |
| MV_TO_PIXEL = 78.5 |
|
|
| |
| |
| |
| |
| |
| LEAD_LAYOUT = [ |
| ['I', 'aVR', 'V1', 'V4'], |
| ['II', 'aVL', 'V2', 'V5'], |
| ['III', 'aVF', 'V3', 'V6'], |
| ] |
|
|
| |
| GRID_COLOR_MAJOR = '#FFB6B6' |
| GRID_COLOR_MINOR = '#FFD0D0' |
| TRACE_COLOR = '#000000' |
| BG_COLOR = '#FFFFFF' |
|
|
|
|
| |
| |
| |
| def find_records(source, data_dir): |
| """Find all ECG record files for a given source.""" |
| records = [] |
| data_dir = Path(data_dir) |
| |
| if source == 'ptbxl': |
| records500 = data_dir / 'records500' |
| if records500.exists(): |
| for hea in records500.rglob('*_hr.hea'): |
| if hea.with_suffix('.dat').exists(): |
| records.append(str(hea)) |
| elif source == 'chapman': |
| for hea in data_dir.rglob('JS*.hea'): |
| if hea.with_suffix('.mat').exists(): |
| records.append(str(hea)) |
| elif source == 'georgia': |
| for hea in data_dir.rglob('E*.hea'): |
| if hea.with_suffix('.mat').exists(): |
| records.append(str(hea)) |
| elif source == 'cpsc': |
| for hea in data_dir.rglob('A*.hea'): |
| if hea.with_suffix('.mat').exists(): |
| records.append(str(hea)) |
| elif source == 'ningbo': |
| for hea in data_dir.rglob('*.hea'): |
| if hea.with_suffix('.mat').exists(): |
| records.append(str(hea)) |
| |
| return sorted(records) |
|
|
|
|
| |
| |
| |
| def load_ecg_signals(hea_path): |
| """Load ECG signals from WFDB record.""" |
| try: |
| record_path = hea_path.replace('.hea', '') |
| record = wfdb.rdrecord(record_path) |
| |
| signals = record.p_signal |
| sig_names = [name.upper() for name 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) |
| fs = 500 |
| |
| |
| lead_signals = {} |
| for i, name in enumerate(sig_names): |
| |
| name_norm = name.upper() |
| if name_norm == 'AVR': |
| name_norm = 'aVR' |
| elif name_norm == 'AVL': |
| name_norm = 'aVL' |
| elif name_norm == 'AVF': |
| name_norm = 'aVF' |
| lead_signals[name_norm] = signals[:, i] |
| |
| return lead_signals, fs |
| except Exception as e: |
| return None, None |
|
|
|
|
| def get_lead_signal(lead_signals, lead_name): |
| """Get signal for a lead, handling name variations.""" |
| variants = [lead_name, lead_name.upper(), lead_name.lower()] |
| if lead_name.startswith('aV'): |
| variants.extend([lead_name.upper(), 'A' + lead_name[1:].upper()]) |
| |
| for v in variants: |
| if v in lead_signals: |
| return lead_signals[v] |
| return None |
|
|
|
|
| |
| |
| |
| def render_ecg_image(lead_signals, fs=500): |
| """ |
| Render ECG signals to a 0001-style image. |
| |
| Returns: |
| image: numpy array (RAW_HEIGHT, RAW_WIDTH, 3) |
| gt_data: numpy array (OUTPUT_GT_WIDTH, 4) - y-pixel coords |
| """ |
| |
| dpi = 100 |
| fig_width = RAW_WIDTH / dpi |
| fig_height = RAW_HEIGHT / dpi |
| |
| fig, ax = plt.subplots(1, 1, figsize=(fig_width, fig_height), dpi=dpi) |
| fig.patch.set_facecolor(BG_COLOR) |
| ax.set_facecolor(BG_COLOR) |
| |
| |
| ax.set_xlim(0, RAW_WIDTH) |
| ax.set_ylim(RAW_HEIGHT, 0) |
| ax.set_aspect('equal') |
| ax.axis('off') |
| |
| |
| |
| major_spacing = 40 |
| minor_spacing = 8 |
| |
| for x in range(0, RAW_WIDTH + 1, minor_spacing): |
| lw = 0.5 if x % major_spacing == 0 else 0.2 |
| color = GRID_COLOR_MAJOR if x % major_spacing == 0 else GRID_COLOR_MINOR |
| ax.axvline(x, color=color, linewidth=lw) |
| |
| for y in range(0, RAW_HEIGHT + 1, minor_spacing): |
| lw = 0.5 if y % major_spacing == 0 else 0.2 |
| color = GRID_COLOR_MAJOR if y % major_spacing == 0 else GRID_COLOR_MINOR |
| ax.axhline(y, color=color, linewidth=lw) |
| |
| |
| |
| raw_scale_y = RAW_HEIGHT / TARGET_HEIGHT |
| raw_scale_x = RAW_WIDTH / TARGET_WIDTH * 2 |
| |
| |
| raw_baselines = ZERO_MV * raw_scale_y |
| |
| |
| samples_per_column = int(2.5 * fs) |
| column_width = RAW_WIDTH / 4 |
| |
| |
| raw_mv_to_pixel = MV_TO_PIXEL * raw_scale_y |
| |
| |
| gt_data = np.zeros((OUTPUT_GT_WIDTH, 4), dtype=np.float32) |
| pixels_per_column = OUTPUT_GT_WIDTH // 3 |
| |
| |
| for col_idx in range(3): |
| for row_idx in range(4): |
| lead_name = LEAD_LAYOUT[col_idx][row_idx] |
| signal = get_lead_signal(lead_signals, lead_name) |
| |
| if signal is None: |
| |
| col_start = col_idx * pixels_per_column |
| col_end = (col_idx + 1) * pixels_per_column if col_idx < 2 else OUTPUT_GT_WIDTH |
| gt_data[col_start:col_end, row_idx] = ZERO_MV[row_idx] |
| continue |
| |
| |
| seg_start = col_idx * samples_per_column |
| seg_end = min(seg_start + samples_per_column, len(signal)) |
| segment = signal[seg_start:seg_end] |
| |
| if len(segment) == 0: |
| col_start = col_idx * pixels_per_column |
| col_end = (col_idx + 1) * pixels_per_column if col_idx < 2 else OUTPUT_GT_WIDTH |
| gt_data[col_start:col_end, row_idx] = ZERO_MV[row_idx] |
| continue |
| |
| |
| if len(segment) > 10: |
| segment = gaussian_filter1d(segment, sigma=1) |
| |
| |
| x_start = col_idx * column_width + 50 |
| x_end = (col_idx + 1) * column_width - 10 |
| x = np.linspace(x_start, x_end, len(segment)) |
| |
| |
| y = raw_baselines[row_idx] - segment * raw_mv_to_pixel |
| y = np.clip(y, 50, RAW_HEIGHT - 50) |
| |
| |
| ax.plot(x, y, color=TRACE_COLOR, linewidth=0.8) |
| |
| |
| col_start = col_idx * pixels_per_column |
| col_end = (col_idx + 1) * pixels_per_column if col_idx < 2 else OUTPUT_GT_WIDTH |
| num_gt_pixels = col_end - col_start |
| |
| |
| x_old = np.linspace(0, 1, len(segment)) |
| x_new = np.linspace(0, 1, num_gt_pixels) |
| signal_resampled = np.interp(x_new, x_old, segment) |
| |
| |
| y_pixels = ZERO_MV[row_idx] - signal_resampled * MV_TO_PIXEL |
| y_pixels = np.clip(y_pixels, 0, TARGET_HEIGHT - 1) |
| gt_data[col_start:col_end, row_idx] = y_pixels |
| |
| |
| lead_ii = get_lead_signal(lead_signals, 'II') |
| if lead_ii is not None: |
| |
| segment = lead_ii[:int(10 * fs)] |
| if len(segment) > 10: |
| segment = gaussian_filter1d(segment, sigma=1) |
| |
| x_start = 3 * column_width + 20 |
| x_end = RAW_WIDTH - 20 |
| x = np.linspace(x_start, x_end, len(segment)) |
| y = raw_baselines[1] - segment * raw_mv_to_pixel |
| y = np.clip(y, 50, RAW_HEIGHT - 50) |
| ax.plot(x, y, color=TRACE_COLOR, linewidth=0.8) |
| |
| |
| canvas = FigureCanvas(fig) |
| canvas.draw() |
| |
| |
| buf = canvas.buffer_rgba() |
| image = np.asarray(buf) |
| image = cv2.cvtColor(image, cv2.COLOR_RGBA2BGR) |
| |
| plt.close(fig) |
| |
| |
| if image.shape[:2] != (RAW_HEIGHT, RAW_WIDTH): |
| image = cv2.resize(image, (RAW_WIDTH, RAW_HEIGHT)) |
| |
| return image, gt_data |
|
|
|
|
| def generate_single_sample(args): |
| """Generate a single synthetic sample.""" |
| idx, hea_path, source, output_dir = args |
| |
| sample_id = f'syn_{source}_{idx:08d}' |
| style = '0001' |
| |
| raw_dir = output_dir / 'raw' |
| gt_dir = output_dir / 'gt' |
| |
| out_img = raw_dir / f'{sample_id}-{style}.png' |
| out_gt = gt_dir / f'{sample_id}-{style}.csv' |
| |
| |
| if out_img.exists() and out_gt.exists(): |
| return True, idx, "exists" |
| |
| try: |
| |
| lead_signals, fs = load_ecg_signals(hea_path) |
| if lead_signals is None: |
| return False, idx, "failed to load signals" |
| |
| |
| image, gt_data = render_ecg_image(lead_signals, fs) |
| |
| |
| raw_dir.mkdir(parents=True, exist_ok=True) |
| cv2.imwrite(str(out_img), image) |
| |
| |
| gt_dir.mkdir(parents=True, exist_ok=True) |
| np.savetxt(out_gt, gt_data, delimiter=',', fmt='%.2f') |
| |
| return True, idx, "success" |
| |
| except Exception as e: |
| return False, idx, f"error: {str(e)[:80]}" |
|
|
|
|
| |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser(description='Generate 0001-style synthetic ECGs') |
| parser.add_argument('--source', type=str, required=True, |
| choices=['ptbxl', 'chapman', 'georgia', 'cpsc', 'ningbo', 'all']) |
| parser.add_argument('--workers', type=int, default=8) |
| parser.add_argument('--limit', type=int, default=None) |
| parser.add_argument('--output_base', type=str, default='/data/ecg-digitization') |
| args = parser.parse_args() |
| |
| output_base = Path(args.output_base) |
| |
| if args.source == 'all': |
| sources = ['chapman', 'georgia', 'cpsc', 'ningbo'] |
| else: |
| sources = [args.source] |
| |
| for source in sources: |
| print(f"\n{'='*60}") |
| print(f"Processing: {source}") |
| print(f"{'='*60}") |
| |
| data_dir = DATA_SOURCES.get(source) |
| if data_dir is None or not data_dir.exists(): |
| print(f"Data directory not found: {data_dir}") |
| continue |
| |
| output_dir = output_base / f'synthetic_0001_{source}' |
| output_dir.mkdir(parents=True, exist_ok=True) |
| |
| print(f"Finding records...") |
| records = find_records(source, data_dir) |
| print(f"Found {len(records)} records") |
| |
| if len(records) == 0: |
| continue |
| |
| if args.limit: |
| records = records[:args.limit] |
| print(f"Limited to {len(records)} records") |
| |
| tasks = [(i, hea, source, output_dir) for i, hea in enumerate(records)] |
| |
| success = exists = failed = 0 |
| |
| with ProcessPoolExecutor(max_workers=args.workers) as executor: |
| futures = {executor.submit(generate_single_sample, t): t for t in tasks} |
| |
| for future in tqdm(as_completed(futures), total=len(futures), desc=source): |
| try: |
| ok, idx, msg = future.result() |
| if ok: |
| if msg == "exists": |
| exists += 1 |
| else: |
| success += 1 |
| else: |
| failed += 1 |
| if failed <= 3: |
| tqdm.write(f" Failed {idx}: {msg}") |
| except Exception as e: |
| failed += 1 |
| |
| print(f"\n✓ {source}: New={success}, Existing={exists}, Failed={failed}") |
| |
| |
| manifest = [] |
| for img in sorted((output_dir / 'raw').glob('*.png')): |
| gt = output_dir / 'gt' / img.name.replace('.png', '.csv') |
| if gt.exists(): |
| manifest.append({ |
| 'sample_id': img.stem, |
| 'source': source, |
| 'raw': str(img), |
| 'gt': str(gt) |
| }) |
| |
| 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() |
|
|