| |
| """ |
| Generate a zero-mV ECG image using ECG-image-kit style rendering, |
| then pass through stage01_processor.py to find exact baseline positions. |
| |
| This helps verify/correct the ZERO_MV constants used in training. |
| """ |
|
|
| import os |
| import sys |
| import numpy as np |
| import cv2 |
| import matplotlib |
| matplotlib.use('Agg') |
| import matplotlib.pyplot as plt |
| from pathlib import Path |
| from scipy import signal as scipy_signal |
|
|
| |
| sys.path.insert(0, str(Path(__file__).parent)) |
|
|
| |
| |
| |
| RAW_WIDTH = 2200 |
| RAW_HEIGHT = 1700 |
|
|
| TARGET_WIDTH = 4352 |
| TARGET_HEIGHT = 1696 |
|
|
| T0, T1 = 235, 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 generate_zero_mv_image(): |
| """Generate an ECG image with all signals at exactly 0 mV.""" |
| |
| |
| fs = 500 |
| duration = 10 |
| num_samples = int(fs * duration) |
| |
| |
| lead_signals = { |
| 'I': np.zeros(num_samples), |
| 'II': np.zeros(num_samples), |
| 'III': np.zeros(num_samples), |
| 'aVR': np.zeros(num_samples), |
| 'aVL': np.zeros(num_samples), |
| 'aVF': np.zeros(num_samples), |
| 'V1': np.zeros(num_samples), |
| 'V2': np.zeros(num_samples), |
| 'V3': np.zeros(num_samples), |
| 'V4': np.zeros(num_samples), |
| 'V5': np.zeros(num_samples), |
| 'V6': np.zeros(num_samples), |
| } |
| |
| |
| 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_baselines = ZERO_MV * raw_scale_y |
| raw_mv_to_pixel = MV_TO_PIXEL * raw_scale_y |
| |
| samples_per_column = int(2.5 * fs) |
| column_width = RAW_WIDTH / 4 |
| |
| |
| for col_idx in range(3): |
| for row_idx in range(4): |
| lead_name = LEAD_LAYOUT[col_idx][row_idx] |
| |
| |
| x_start = col_idx * column_width + 50 |
| x_end = (col_idx + 1) * column_width - 10 |
| y = raw_baselines[row_idx] |
| |
| |
| ax.plot([x_start, x_end], [y, y], color=TRACE_COLOR, linewidth=0.8) |
| |
| |
| x_start = 3 * column_width + 20 |
| x_end = RAW_WIDTH - 20 |
| y = raw_baselines[1] |
| ax.plot([x_start, x_end], [y, y], color=TRACE_COLOR, linewidth=0.8) |
| |
| plt.tight_layout(pad=0) |
| |
| |
| fig.canvas.draw() |
| img = np.array(fig.canvas.buffer_rgba())[:, :, :3] |
| plt.close(fig) |
| |
| |
| img = cv2.resize(img, (RAW_WIDTH, RAW_HEIGHT)) |
| |
| return img, lead_signals |
|
|
|
|
| def process_through_stage01(img): |
| """Process image through stage01_processor.py""" |
| from subprocess import run, PIPE |
| import tempfile |
| |
| |
| with tempfile.TemporaryDirectory() as tmpdir: |
| in_path = Path(tmpdir) / 'zero_mv_input.png' |
| out_path = Path(tmpdir) / 'zero_mv_output.png' |
| |
| cv2.imwrite(str(in_path), cv2.cvtColor(img, cv2.COLOR_RGB2BGR)) |
| |
| |
| stage01_dir = Path('/home/azureuser/ecg-digitization/src/stage01') |
| processor_script = stage01_dir / 'stage01_processor.py' |
| |
| if processor_script.exists(): |
| cmd = [ |
| 'python3', str(processor_script), |
| str(in_path), str(out_path) |
| ] |
| result = run(cmd, cwd=str(stage01_dir), capture_output=True, text=True) |
| print("Stage01 stdout:", result.stdout) |
| print("Stage01 stderr:", result.stderr) |
| |
| if out_path.exists(): |
| processed = cv2.imread(str(out_path)) |
| processed = cv2.cvtColor(processed, cv2.COLOR_BGR2RGB) |
| return processed |
| |
| return None |
|
|
|
|
| def find_baselines_from_image(img): |
| """ |
| Find the baseline y-positions from a zero-mV processed image. |
| The black trace lines should be exactly at the baselines. |
| """ |
| |
| if len(img.shape) == 3: |
| gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) |
| else: |
| gray = img |
| |
| |
| dark_mask = gray < 50 |
| |
| height, width = gray.shape |
| print(f"\nProcessed image shape: {height} x {width}") |
| |
| |
| |
| row_height = height // 4 |
| |
| baselines = [] |
| |
| for row_idx in range(4): |
| y_start = row_idx * row_height |
| y_end = (row_idx + 1) * row_height |
| |
| |
| row_region = dark_mask[y_start:y_end, :] |
| y_profile = row_region.sum(axis=1) |
| |
| if y_profile.max() > 0: |
| |
| local_y = np.argmax(y_profile) |
| global_y = y_start + local_y |
| baselines.append(global_y) |
| print(f"Row {row_idx}: baseline at y={global_y:.1f} (local: {local_y})") |
| else: |
| print(f"Row {row_idx}: no trace found, using expected {ZERO_MV[row_idx]:.1f}") |
| baselines.append(ZERO_MV[row_idx]) |
| |
| return np.array(baselines) |
|
|
|
|
| def main(): |
| print("=" * 70) |
| print("Finding Zero-mV Baseline Positions for ECG Image Kit Synthetic Data") |
| print("=" * 70) |
| |
| |
| print("\n1. Generating zero-mV ECG image...") |
| img, signals = generate_zero_mv_image() |
| print(f" Raw image shape: {img.shape}") |
| |
| |
| out_dir = Path('/data/ecg-digitization/baseline_test') |
| out_dir.mkdir(parents=True, exist_ok=True) |
| |
| raw_path = out_dir / 'zero_mv_raw.png' |
| cv2.imwrite(str(raw_path), cv2.cvtColor(img, cv2.COLOR_RGB2BGR)) |
| print(f" Saved: {raw_path}") |
| |
| |
| stage01_processor = Path('/home/azureuser/ecg-digitization/src/stage01/stage01_processor.py') |
| |
| if stage01_processor.exists(): |
| print("\n2. Processing through stage01...") |
| processed = process_through_stage01(img) |
| |
| if processed is not None: |
| proc_path = out_dir / 'zero_mv_processed.png' |
| cv2.imwrite(str(proc_path), cv2.cvtColor(processed, cv2.COLOR_RGB2BGR)) |
| print(f" Saved: {proc_path}") |
| |
| print("\n3. Finding baselines from processed image...") |
| measured_baselines = find_baselines_from_image(processed) |
| else: |
| print(" Stage01 processing failed, using raw image") |
| measured_baselines = find_baselines_from_image(img) |
| else: |
| print("\n2. Stage01 processor not found, analyzing raw image directly") |
| |
| img_scaled = cv2.resize(img, (TARGET_WIDTH, TARGET_HEIGHT)) |
| measured_baselines = find_baselines_from_image(img_scaled) |
| |
| |
| print("\n" + "=" * 70) |
| print("BASELINE COMPARISON") |
| print("=" * 70) |
| print(f"{'Row':<8} {'Expected':<15} {'Measured':<15} {'Diff (px)':<15} {'Diff (mV)':<15}") |
| print("-" * 70) |
| |
| for i in range(4): |
| expected = ZERO_MV[i] |
| measured = measured_baselines[i] |
| diff_px = measured - expected |
| diff_mv = diff_px / MV_TO_PIXEL |
| print(f"{i:<8} {expected:<15.1f} {measured:<15.1f} {diff_px:<+15.2f} {diff_mv:<+15.4f}") |
| |
| print("\n" + "=" * 70) |
| print("RECOMMENDED ZERO_MV VALUES:") |
| print("=" * 70) |
| print(f"ZERO_MV = np.array([{measured_baselines[0]:.1f}, {measured_baselines[1]:.1f}, {measured_baselines[2]:.1f}, {measured_baselines[3]:.1f}])") |
| |
| return measured_baselines |
|
|
|
|
| if __name__ == '__main__': |
| main() |
|
|