| |
| """ |
| Generate 0001-style synthetic ECG images using ecg-image-kit. |
| |
| Uses ecg-image-kit to generate proper PTB-XL-style images with: |
| - Correct 4-column layout (3 short + 1 full lead II) |
| - Lead labels (I, II, III, aVR, aVL, aVF, V1-V6) |
| - Red grid background |
| - Scale markers |
| |
| IMPORTANT: Requires the patched ecg-image-kit (write_wfdb_file commented out) |
| IMPORTANT: Requires ecgkit_venv environment |
| |
| Usage: |
| source ~/ecgkit_venv/bin/activate |
| python generate_synthetic_ecgkit_v2.py --source georgia --workers 8 |
| python generate_synthetic_ecgkit_v2.py --source all --workers 8 |
| """ |
|
|
| import os |
| import sys |
| import argparse |
| import subprocess |
| import shutil |
| from pathlib import Path |
| from concurrent.futures import ProcessPoolExecutor, as_completed |
| from tqdm import tqdm |
| import numpy as np |
| import cv2 |
|
|
| |
| ECG_IMAGE_KIT = Path('/home/azureuser/ecg-digitization/ecg-image-kit/codes/ecg-image-generator') |
|
|
| |
| |
| |
| DATA_ROOT = Path('/data/ecg-digitization') |
|
|
| DATA_SOURCES = { |
| 'chapman': DATA_ROOT / 'chapman', |
| 'georgia': DATA_ROOT / 'georgia', |
| 'cpsc': DATA_ROOT / 'cpsc', |
| 'ningbo': DATA_ROOT / 'ningbo', |
| } |
|
|
| |
| RAW_WIDTH = 2200 |
| RAW_HEIGHT = 1700 |
|
|
| |
| T0 = 235 |
| T1 = 4161 |
| OUTPUT_GT_WIDTH = T1 - T0 |
|
|
| |
| TARGET_WIDTH = 4352 |
| TARGET_HEIGHT = 1696 |
|
|
| |
| ZERO_MV = np.array([703.5, 987.5, 1271.5, 1531.5]) |
| MV_TO_PIXEL = 78.5 |
|
|
|
|
| |
| |
| |
| def find_records(source, data_dir): |
| """Find all ECG record files for a given source.""" |
| records = [] |
| data_dir = Path(data_dir) |
| |
| if 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 extract_gt_from_wfdb(hea_file, output_width=3926): |
| """Extract ground truth signal from WFDB record.""" |
| try: |
| import wfdb |
| from scipy import signal as scipy_signal |
| |
| record_path = hea_file.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) |
| |
| |
| lead_signals = {} |
| for i, name in enumerate(sig_names): |
| |
| if name == 'AVR': |
| name = 'aVR' |
| elif name == 'AVL': |
| name = 'aVL' |
| elif name == 'AVF': |
| name = 'aVF' |
| lead_signals[name] = signals[:, i] |
| |
| |
| |
| |
| |
| |
| |
| row_leads = [ |
| ['I', 'aVR', 'V1', 'V4'], |
| ['II', 'aVL', 'V2', 'V5'], |
| ['III', 'aVF', 'V3', 'V6'], |
| ] |
| |
| segment_len = output_width // 3 |
| gt_data = np.zeros((output_width, 4), dtype=np.float32) |
| |
| for row_idx in range(4): |
| if row_idx < 3: |
| |
| leads_for_row = row_leads[0][row_idx], row_leads[1][row_idx], row_leads[2][row_idx] |
| else: |
| |
| leads_for_row = ['II', 'II', 'II'] |
| |
| for col_idx in range(3): |
| lead_name = leads_for_row[col_idx] if row_idx < 3 else 'II' |
| |
| |
| signal = None |
| for variant in [lead_name, lead_name.upper(), lead_name.lower()]: |
| if variant in lead_signals: |
| signal = lead_signals[variant] |
| break |
| |
| if signal is None: |
| |
| col_start = col_idx * segment_len |
| col_end = (col_idx + 1) * segment_len if col_idx < 2 else output_width |
| gt_data[col_start:col_end, row_idx] = ZERO_MV[row_idx] |
| continue |
| |
| |
| samples_per_segment = 1250 |
| |
| if row_idx < 3: |
| |
| start = col_idx * samples_per_segment |
| else: |
| |
| start = col_idx * samples_per_segment * 3 // 3 |
| |
| end = min(start + samples_per_segment, len(signal)) |
| segment = signal[start:end] |
| |
| if len(segment) == 0: |
| col_start = col_idx * segment_len |
| col_end = (col_idx + 1) * segment_len if col_idx < 2 else output_width |
| gt_data[col_start:col_end, row_idx] = ZERO_MV[row_idx] |
| continue |
| |
| |
| col_start = col_idx * segment_len |
| col_end = (col_idx + 1) * segment_len if col_idx < 2 else output_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 |
| |
| return gt_data |
| |
| except Exception as e: |
| print(f"GT extraction error: {e}") |
| return None |
|
|
|
|
| |
| |
| |
| def generate_single_image(args): |
| """Generate a single ECG image using ecg-image-kit.""" |
| idx, hea_file, source, output_dir, temp_base = 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" |
| |
| temp_dir = temp_base / f'temp_{idx}' |
| dat_file = hea_file.replace('.hea', '.mat') |
| |
| 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', '0', |
| '-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" |
| |
| |
| if img.shape[:2] != (RAW_HEIGHT, RAW_WIDTH): |
| img = cv2.resize(img, (RAW_WIDTH, RAW_HEIGHT), interpolation=cv2.INTER_LINEAR) |
| |
| |
| raw_dir.mkdir(parents=True, exist_ok=True) |
| cv2.imwrite(str(out_img), img) |
| |
| |
| gt_data = extract_gt_from_wfdb(hea_file, OUTPUT_GT_WIDTH) |
| if gt_data is not None: |
| gt_dir.mkdir(parents=True, exist_ok=True) |
| np.savetxt(out_gt, gt_data, delimiter=',', fmt='%.2f') |
| |
| |
| 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, f"error: {str(e)[:80]}" |
|
|
|
|
| |
| |
| |
| def main(): |
| parser = argparse.ArgumentParser(description='Generate 0001-style synthetic ECGs using ecg-image-kit') |
| parser.add_argument('--source', type=str, required=True, |
| choices=['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) |
| temp_base = Path('/tmp/ecg_gen') |
| temp_base.mkdir(parents=True, exist_ok=True) |
| |
| 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") |
| |
| |
| work_args = [ |
| (i, rec, source, output_dir, temp_base) |
| for i, rec in enumerate(records) |
| ] |
| |
| |
| existing = sum(1 for args in work_args |
| if (output_dir / 'raw' / f'syn_{source}_{args[0]:08d}-0001.png').exists()) |
| print(f"Already generated: {existing}") |
| |
| |
| success = existing |
| failed = 0 |
| |
| with ProcessPoolExecutor(max_workers=args.workers) as executor: |
| futures = {executor.submit(generate_single_image, arg): arg for arg in work_args} |
| |
| with tqdm(total=len(records), initial=existing, desc=source) as pbar: |
| for future in as_completed(futures): |
| ok, idx, msg = future.result() |
| if ok: |
| if msg != "exists": |
| success += 1 |
| pbar.update(1) |
| else: |
| failed += 1 |
| if failed <= 10: |
| tqdm.write(f" Failed {idx}: {msg}") |
| pbar.update(1) |
| |
| print(f"\nCompleted: {success} success, {failed} failed") |
| |
| |
| shutil.rmtree(temp_base, ignore_errors=True) |
| print("\nDone!") |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|