ecg-digitization-experiments / code /scripts /generate_synthetic_ecgkit.py
Ubuntu
Add training scripts and notebooks
b69e447
Raw
History Blame Contribute Delete
12.2 kB
#!/usr/bin/env python3
"""
Synthetic ECG Image Generator using ecg-image-kit
Generates synthetic ECG images from PTB-XL records, then processes through Stage 0/1.
Based on expert advice: use ecg-image-kit for high-res GT, then train on Stage 1 recovered images.
Usage:
python generate_synthetic_ecgkit.py --n_samples 50000 --n_workers 8
"""
import os
import sys
import argparse
import subprocess
import numpy as np
import cv2
import torch
import random
import json
import traceback
from pathlib import Path
from multiprocessing import Pool, cpu_count
from tqdm import tqdm
from concurrent.futures import ThreadPoolExecutor, as_completed
import shutil
# Paths
PROJECT_ROOT = Path(__file__).parent.parent
ECG_IMAGE_KIT = PROJECT_ROOT / 'ecg-image-kit' / 'codes' / 'ecg-image-generator'
BASELINE_PATH = PROJECT_ROOT / 'data' / 'hengck23-submit-physionet' / 'hengck23-submit-physionet'
PTBXL_PATH = Path('/data/ecg-digitization/ptbxl/physionet.org/files/ptb-xl/1.0.3')
sys.path.insert(0, str(BASELINE_PATH))
# Target specifications - match Kaggle format exactly
TARGET_HEIGHT = 1700
TARGET_WIDTH = 2200
def find_ptbxl_records(ptbxl_dir):
"""Find all PTB-XL record files."""
records = []
# Try records500 first (500Hz, higher quality)
records500 = ptbxl_dir / 'records500'
if records500.exists():
for subdir in sorted(records500.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)))
# Fallback to records100
if not records:
records100 = ptbxl_dir / 'records100'
if records100.exists():
for subdir in sorted(records100.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)))
return records
def generate_single_image_ecgkit(args):
"""Generate a single synthetic image using ecg-image-kit."""
hea_file, dat_file, output_dir, sample_idx, style_idx = args
try:
# Create temp output directory for this sample
temp_dir = Path(output_dir) / 'temp' / f'sample_{sample_idx}'
temp_dir.mkdir(parents=True, exist_ok=True)
# Style configurations (matching 0001-0012 versions)
style_configs = {
0: {'grid': True, 'grid_color': 5, 'augment': False, 'wrinkles': False}, # Clean red grid
1: {'grid': True, 'grid_color': 5, 'augment': True, 'wrinkles': True}, # Aged red
2: {'grid': True, 'grid_color': 2, 'augment': False, 'wrinkles': False}, # Green grid
3: {'grid': True, 'grid_color': 2, 'augment': True, 'wrinkles': True}, # Aged green
4: {'grid': True, 'grid_color': 1, 'augment': False, 'wrinkles': False}, # Blue grid
5: {'grid': True, 'grid_color': 3, 'augment': True, 'wrinkles': False}, # Grey
6: {'grid': True, 'grid_color': 4, 'augment': True, 'wrinkles': True}, # Yellow aged
7: {'grid': True, 'grid_color': 2, 'augment': True, 'wrinkles': False}, # Faded green
8: {'grid': True, 'grid_color': 6, 'augment': False, 'wrinkles': False}, # Orange
9: {'grid': True, 'grid_color': 5, 'augment': True, 'wrinkles': False}, # High contrast
10: {'grid': True, 'grid_color': 5, 'augment': True, 'wrinkles': True}, # Low contrast
11: {'grid': False, 'grid_color': 0, 'augment': False, 'wrinkles': False}, # No grid
}
style = style_configs.get(style_idx % 12, style_configs[0])
# Build ecg-image-kit command
cmd = [
'python', 'gen_ecg_image_from_data.py',
'-i', dat_file,
'-hea', hea_file,
'-o', str(temp_dir),
'-st', str(sample_idx),
'-se', str(sample_idx),
'-r', '200', # Resolution
'--num_columns', '4',
'--full_mode', 'II',
'--store_config', '1',
]
if style['grid']:
cmd.extend(['--standard_grid_color', str(style['grid_color'])])
else:
cmd.extend(['--random_grid_present', '0'])
if style['augment']:
cmd.append('--augment')
if style['wrinkles']:
cmd.append('--wrinkles')
# Run ecg-image-kit with isolated matplotlib config to avoid lock conflicts
env = os.environ.copy()
env['MPLBACKEND'] = 'Agg'
env['MPLCONFIGDIR'] = str(temp_dir) # Each worker uses own matplotlib config
result = subprocess.run(
cmd,
cwd=str(ECG_IMAGE_KIT),
capture_output=True,
text=True,
timeout=120,
env=env
)
if result.returncode != 0:
return None
# Find generated image
generated_images = list(temp_dir.glob('*.png'))
if not generated_images:
return None
gen_img_path = generated_images[0]
# Read and resize to target size
img = cv2.imread(str(gen_img_path))
if img is None:
return None
img = cv2.resize(img, (TARGET_WIDTH, TARGET_HEIGHT))
# Save raw image
sample_id = f'syn_{sample_idx:08d}'
style_str = '0001' # Always 0001 type
raw_dir = Path(output_dir) / 'raw'
raw_dir.mkdir(parents=True, exist_ok=True)
raw_path = raw_dir / f'{sample_id}-{style_str}.png'
cv2.imwrite(str(raw_path), img)
# Load config for GT if exists
config_files = list(temp_dir.glob('*.json'))
if config_files:
with open(config_files[0]) as f:
config = json.load(f)
else:
config = {}
# Clean up temp directory
shutil.rmtree(temp_dir, ignore_errors=True)
return {
'sample_id': sample_id,
'style': style_str,
'raw_path': str(raw_path),
'source_record': hea_file,
}
except Exception as e:
return None
def process_through_stage01(raw_dir, output_dir, device='cuda:0'):
"""Process all raw images through Stage 0 and Stage 1."""
from stage0_model import Net as Stage0Net
from stage1_model import Net as Stage1Net
from stage0_common import image_to_batch, output_to_predict, normalise_by_homography, load_net
from stage1_common import output_to_predict as stage1_output_to_predict, rectify_image
raw_dir = Path(raw_dir)
output_dir = Path(output_dir)
# Create output directories
stage0_dir = output_dir / 'stage0'
stage1_dir = output_dir / 'stage1'
stage0_dir.mkdir(parents=True, exist_ok=True)
stage1_dir.mkdir(parents=True, exist_ok=True)
# Load models
weight_dir = BASELINE_PATH / 'weight'
print("Loading Stage 0 model...")
stage0_net = Stage0Net(pretrained=False)
stage0_net = load_net(stage0_net, str(weight_dir / 'stage0-last.checkpoint.pth'))
stage0_net.to(device).eval()
print("Loading Stage 1 model...")
stage1_net = Stage1Net(pretrained=False)
stage1_net = load_net(stage1_net, str(weight_dir / 'stage1-last.checkpoint.pth'))
stage1_net.to(device).eval()
# Process all raw images
raw_images = sorted(raw_dir.glob('*.png'))
print(f"Processing {len(raw_images)} images through Stage 0/1...")
for img_path in tqdm(raw_images, desc="Stage 0/1"):
sample_name = img_path.stem
stage1_path = stage1_dir / f'{sample_name}.png'
if stage1_path.exists():
continue
try:
# Read image
image = cv2.imread(str(img_path))
if image is None:
continue
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
# Stage 0
batch = image_to_batch(image)
with torch.no_grad():
output = stage0_net(batch)
rotated, keypoint = output_to_predict(image, batch, output)
if keypoint is not None and len(keypoint) >= 4:
try:
normalized, _, _ = normalise_by_homography(rotated, keypoint)
except:
normalized = rotated
else:
normalized = rotated
# Save Stage 0 output
stage0_path = stage0_dir / f'{sample_name}.png'
cv2.imwrite(str(stage0_path), cv2.cvtColor(normalized, cv2.COLOR_RGB2BGR))
# Stage 1
batch = {'image': torch.from_numpy(
np.ascontiguousarray(normalized.transpose(2, 0, 1))
).unsqueeze(0)}
with torch.no_grad():
output = stage1_net(batch)
try:
gridpoint_xy, _ = stage1_output_to_predict(normalized, batch, output)
rectified = rectify_image(normalized, gridpoint_xy)
except:
rectified = normalized
# Resize to target
rectified = cv2.resize(rectified, (TARGET_WIDTH, TARGET_HEIGHT))
# Save Stage 1 output
cv2.imwrite(str(stage1_path), cv2.cvtColor(rectified, cv2.COLOR_RGB2BGR))
except Exception as e:
# On error, copy raw image
shutil.copy(img_path, stage1_path)
print(f"Stage 0/1 processing complete. Output: {stage1_dir}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--ptbxl_dir', type=str, default=str(PTBXL_PATH))
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=8)
parser.add_argument('--skip_stage01', action='store_true')
parser.add_argument('--device', type=str, default='cuda:0')
args = parser.parse_args()
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Find PTB-XL records
print(f"Finding PTB-XL records in {args.ptbxl_dir}...")
records = find_ptbxl_records(Path(args.ptbxl_dir))
print(f"Found {len(records)} PTB-XL records")
if not records:
print("No records found!")
return
# Prepare generation tasks
print(f"Preparing {args.n_samples} generation tasks...")
tasks = []
for i in range(args.n_samples):
hea, dat = random.choice(records)
style_idx = 0 # Always 0001 type: clean red grid, no augmentation
tasks.append((hea, dat, str(output_dir), i, style_idx))
# Generate images using ecg-image-kit
print(f"Generating {args.n_samples} synthetic images...")
results = []
with Pool(args.n_workers) as pool:
for result in tqdm(pool.imap_unordered(generate_single_image_ecgkit, tasks),
total=len(tasks), desc="Generating"):
if result:
results.append(result)
print(f"Successfully generated {len(results)} images")
# Save manifest
manifest_path = output_dir / 'manifest.json'
with open(manifest_path, 'w') as f:
json.dump(results, f, indent=2)
# Process through Stage 0/1
if not args.skip_stage01:
print("\nProcessing through Stage 0/1...")
process_through_stage01(output_dir / 'raw', output_dir, args.device)
# Clean up temp directory
temp_dir = output_dir / 'temp'
if temp_dir.exists():
shutil.rmtree(temp_dir, ignore_errors=True)
print("\nDone!")
print(f"Raw images: {output_dir / 'raw'}")
print(f"Stage 1 images: {output_dir / 'stage1'}")
if __name__ == '__main__':
main()