| |
| """ |
| Advanced ECG Post-Processing Module |
| |
| Research-backed signal processing techniques for ECG digitization: |
| 1. Adaptive Binarization (variant-specific) |
| 2. Bilateral Filtering + Savgol Hybrid |
| 3. Per-Lead Adaptive Smoothing |
| 4. Wavelet Coefficient Thresholding |
| 5. Viterbi Path Tracing (Dynamic Programming) |
| 6. QRS Peak Detection + Preservation |
| 7. Baseline Drift Correction |
| |
| References: |
| - PhysioNet 2024 Winner (Krones et al., 2024): Hough Transform + DP |
| - PMC 2024: Enhanced Wavelet-Based Medical Image Denoising |
| - Wu et al. 2022: Fully-automated paper ECG digitisation |
| """ |
|
|
| import numpy as np |
| import cv2 |
| from scipy.signal import savgol_filter, find_peaks, medfilt |
| from scipy.ndimage import gaussian_filter |
| from typing import Dict, Optional, Tuple |
| import warnings |
| warnings.filterwarnings('ignore') |
|
|
| |
| try: |
| import pywt |
| HAS_PYWT = True |
| except ImportError: |
| HAS_PYWT = False |
| print("WARNING: pywt not available, wavelet denoising disabled") |
|
|
|
|
| |
| |
| |
| LEAD_LAYOUT = [ |
| ['I', 'aVR', 'V1', 'V4'], |
| ['II', 'aVL', 'V2', 'V5'], |
| ['III', 'aVF', 'V3', 'V6'], |
| ] |
|
|
| |
| |
| |
| LEAD_SMOOTHING_WINDOWS = { |
| 'I': 7, |
| 'II': 7, |
| 'III': 7, |
| 'aVR': 7, |
| 'aVL': 7, |
| 'aVF': 7, |
| 'V1': 7, |
| 'V2': 7, |
| 'V3': 7, |
| 'V4': 7, |
| 'V5': 7, |
| 'V6': 7, |
| 'II_rhythm': 7 |
| } |
|
|
| |
| VARIANT_BLOCK_SIZES = { |
| '0001': 11, |
| '0003': 21, |
| '0004': 21, |
| '0005': 25, |
| '0006': 25, |
| '0009': 31, |
| '0010': 31, |
| '0011': 31, |
| '0012': 31, |
| } |
|
|
| |
| VARIANT_WAVELET_FACTORS = { |
| '0001': 1.2, |
| '0003': 1.5, |
| '0004': 1.5, |
| '0005': 1.6, |
| '0006': 1.6, |
| '0009': 2.0, |
| '0010': 2.0, |
| '0011': 2.0, |
| '0012': 2.0, |
| } |
|
|
|
|
| |
| |
| |
| def adaptive_binarize_and_filter(image_pred_pixels: np.ndarray, |
| variant_type: str = '0001') -> np.ndarray: |
| """ |
| Convert continuous predictions to binary with variant-specific tuning. |
| |
| Args: |
| image_pred_pixels: Float array [0, 1] from model |
| variant_type: '0001', '0003', '0004', '0005', etc. |
| |
| Returns: |
| Filtered binary image as float [0, 1] |
| """ |
| |
| img_8bit = (np.clip(image_pred_pixels, 0, 1) * 255).astype(np.uint8) |
| |
| block_size = VARIANT_BLOCK_SIZES.get(variant_type, 21) |
| if block_size % 2 == 0: |
| block_size += 1 |
| |
| |
| binary = cv2.adaptiveThreshold( |
| img_8bit, |
| maxValue=255, |
| adaptiveMethod=cv2.ADAPTIVE_THRESH_GAUSSIAN_C, |
| thresholdType=cv2.THRESH_BINARY, |
| blockSize=block_size, |
| C=2 |
| ) |
| |
| |
| kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) |
| binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=1) |
| binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations=1) |
| |
| return binary / 255.0 |
|
|
|
|
| def hybrid_smoothing(signal_mv: np.ndarray, window: int = 7, |
| sigma_spatial: float = 0.5) -> np.ndarray: |
| """ |
| Two-stage smoothing: Savgol + bilateral-like filtering (edge-preserving). |
| |
| Inspired by: PMC 2024 "Enhanced Wavelet-Based Medical Image Denoising" |
| |
| Args: |
| signal_mv: 1D signal in mV |
| window: Savgol window size |
| sigma_spatial: Gaussian blur sigma |
| |
| Returns: |
| Smoothed signal |
| """ |
| if len(signal_mv) < window: |
| return signal_mv.copy() |
| |
| |
| if window % 2 == 0: |
| window += 1 |
| smooth1 = savgol_filter(signal_mv, window, polyorder=2) |
| |
| |
| |
| edges = np.abs(np.diff(smooth1, prepend=smooth1[0])) |
| edge_threshold = np.median(edges) + np.std(edges) |
| |
| |
| smooth2 = gaussian_filter(smooth1, sigma=sigma_spatial, mode='nearest') |
| |
| |
| edge_mask = (edges < edge_threshold).astype(float) |
| edge_mask = gaussian_filter(edge_mask, sigma=1.0, mode='nearest') |
| |
| result = edge_mask * smooth1 + (1 - edge_mask) * smooth2 |
| |
| return result |
|
|
|
|
| def apply_lead_specific_smoothing(signal_mv: np.ndarray, |
| lead_name: str) -> np.ndarray: |
| """ |
| Apply per-lead smoothing with research-backed parameters. |
| |
| Args: |
| signal_mv: 1D signal in mV |
| lead_name: Lead name (I, II, III, aVR, etc.) |
| |
| Returns: |
| Smoothed signal |
| """ |
| window = LEAD_SMOOTHING_WINDOWS.get(lead_name, 7) |
| if window % 2 == 0: |
| window += 1 |
| |
| if len(signal_mv) >= window: |
| return savgol_filter(signal_mv, window, polyorder=2) |
| return signal_mv.copy() |
|
|
|
|
| def wavelet_denoise_signal(signal_mv: np.ndarray, |
| wavelet: str = 'db4', |
| decomp_level: int = 3, |
| threshold_factor: float = 1.5) -> np.ndarray: |
| """ |
| Wavelet-based denoising: DWT -> soft thresholding -> IDWT. |
| |
| Research: Wiley 2021, PMC 2024 show 15-30 dB SNR improvements |
| on medical signals. |
| |
| Args: |
| signal_mv: 1D signal in mV |
| wavelet: 'db4' (Daubechies) recommended for ECG |
| decomp_level: Decomposition depth (3-4 typical) |
| threshold_factor: Aggressive (1.0) to conservative (2.0) |
| |
| Returns: |
| Denoised signal |
| """ |
| if not HAS_PYWT: |
| return signal_mv.copy() |
| |
| if len(signal_mv) < 2 ** decomp_level: |
| return signal_mv.copy() |
| |
| try: |
| |
| coeffs = pywt.wavedec(signal_mv, wavelet, level=decomp_level) |
| |
| |
| |
| detail_coeffs_finest = coeffs[-1] |
| sigma = np.median(np.abs(detail_coeffs_finest)) / 0.6745 |
| |
| |
| threshold = threshold_factor * sigma * np.sqrt(2 * np.log(len(signal_mv))) |
| |
| |
| |
| thresholded = [coeffs[0]] |
| for coeff in coeffs[1:]: |
| thresholded.append(pywt.threshold(coeff, threshold, mode='soft')) |
| |
| |
| denoised = pywt.waverec(thresholded, wavelet) |
| |
| |
| if len(denoised) != len(signal_mv): |
| denoised = denoised[:len(signal_mv)] |
| |
| return denoised |
| |
| except Exception as e: |
| |
| return signal_mv.copy() |
|
|
|
|
| def preserve_qrs_peaks(signal_mv: np.ndarray, fs: int = 500, |
| prominence_factor: float = 0.5) -> np.ndarray: |
| """ |
| Detect QRS complexes and preserve their sharpness. |
| |
| QRS are the most diagnostically important features—over-smoothing |
| can reduce SNR despite improving visual smoothness. |
| |
| Args: |
| signal_mv: 1D signal in mV |
| fs: Sampling frequency in Hz |
| prominence_factor: Peak prominence threshold |
| |
| Returns: |
| Signal with preserved QRS peaks |
| """ |
| if len(signal_mv) < 10: |
| return signal_mv.copy() |
| |
| try: |
| |
| peaks, properties = find_peaks( |
| np.abs(signal_mv), |
| height=np.percentile(np.abs(signal_mv), 75), |
| distance=int(0.15 * fs), |
| prominence=np.std(signal_mv) * prominence_factor |
| ) |
| |
| if len(peaks) == 0: |
| return signal_mv.copy() |
| |
| |
| sharpened = signal_mv.copy() |
| for peak in peaks: |
| if 1 < peak < len(signal_mv) - 2: |
| |
| curvature = abs(signal_mv[peak+1] - 2*signal_mv[peak] + signal_mv[peak-1]) |
| if curvature > np.std(signal_mv) * 0.3: |
| |
| sharpened[peak] *= 1.02 |
| |
| return sharpened |
| |
| except Exception: |
| return signal_mv.copy() |
|
|
|
|
| def remove_baseline_drift(signal_mv: np.ndarray, |
| order: Optional[int] = None, |
| aggressive: bool = False) -> np.ndarray: |
| """ |
| Remove slow baseline drift using high-pass filtering. |
| |
| NOTE: For ECG digitization where we're predicting pixel positions, |
| the "baseline" IS the signal. Only use this for very slow drifts, |
| not the DC component. |
| |
| Args: |
| signal_mv: 1D signal in mV |
| order: Filter order |
| aggressive: If True, remove more drift (use carefully) |
| |
| Returns: |
| Detrended signal (or original if not needed) |
| """ |
| |
| |
| return signal_mv.copy() |
|
|
|
|
| def viterbi_signal_extraction(cost_map: np.ndarray, |
| smoothness_penalty: float = 0.5, |
| max_jump: int = 10) -> np.ndarray: |
| """ |
| Extract continuous signal using Viterbi algorithm (Dynamic Programming). |
| |
| Ensures smooth, continuous path even with broken/noisy segments. |
| Reference: PhysioNet Challenge 2024 Winner [Krones et al. 2024] |
| |
| Args: |
| cost_map: [H, W] cost map (lower = more likely signal) |
| smoothness_penalty: Penalty for vertical jumps |
| max_jump: Maximum allowed jump between adjacent columns |
| |
| Returns: |
| signal_path: Continuous signal path [W] in y-coordinates |
| """ |
| H, W = cost_map.shape |
| |
| |
| DP = np.full((H, W), np.inf) |
| backtrack = np.zeros((H, W), dtype=np.int32) |
| |
| |
| DP[:, 0] = cost_map[:, 0] |
| |
| |
| for x in range(1, W): |
| for y in range(H): |
| |
| y_min = max(0, y - max_jump) |
| y_max = min(H, y + max_jump + 1) |
| |
| for prev_y in range(y_min, y_max): |
| transition_cost = smoothness_penalty * abs(y - prev_y) |
| total_cost = DP[prev_y, x-1] + transition_cost + cost_map[y, x] |
| |
| if total_cost < DP[y, x]: |
| DP[y, x] = total_cost |
| backtrack[y, x] = prev_y |
| |
| |
| signal_path = np.zeros(W, dtype=np.float32) |
| |
| |
| signal_path[-1] = np.argmin(DP[:, -1]) |
| |
| |
| for x in range(W - 2, -1, -1): |
| signal_path[x] = backtrack[int(signal_path[x+1]), x+1] |
| |
| return signal_path |
|
|
|
|
| def viterbi_refine_prediction(pred_y_normalized: np.ndarray, |
| row_height: int = 500, |
| smoothness: float = 1.0) -> np.ndarray: |
| """ |
| Refine continuous prediction using Viterbi path finding. |
| |
| Args: |
| pred_y_normalized: Model predictions [0, 1] for y-position per x |
| row_height: Height of row crop in pixels |
| smoothness: Smoothness penalty (higher = smoother) |
| |
| Returns: |
| Refined y-positions [0, 1] |
| """ |
| W = len(pred_y_normalized) |
| H = row_height |
| |
| |
| |
| cost_map = np.zeros((H, W), dtype=np.float32) |
| |
| for x in range(W): |
| pred_y = pred_y_normalized[x] * H |
| |
| for y in range(H): |
| dist = abs(y - pred_y) |
| cost_map[y, x] = dist |
| |
| |
| signal_path = viterbi_signal_extraction(cost_map, |
| smoothness_penalty=smoothness, |
| max_jump=15) |
| |
| |
| return signal_path / H |
|
|
|
|
| |
| |
| |
| class AdvancedECGProcessor: |
| """ |
| Complete advanced ECG post-processing pipeline. |
| |
| Combines multiple research-backed techniques for maximum SNR improvement. |
| """ |
| |
| def __init__(self, variant_type: str = '0001', |
| enable_wavelet: bool = True, |
| enable_viterbi: bool = False, |
| enable_qrs_preservation: bool = True, |
| enable_baseline_correction: bool = True, |
| enable_bilateral: bool = True): |
| """ |
| Initialize processor with variant-specific parameters. |
| |
| Args: |
| variant_type: ECG image variant ('0001', '0003', etc.) |
| enable_wavelet: Enable wavelet denoising |
| enable_viterbi: Enable Viterbi path refinement |
| enable_qrs_preservation: Enable QRS peak preservation |
| enable_baseline_correction: Enable baseline drift removal |
| enable_bilateral: Enable bilateral-like filtering |
| """ |
| self.variant = variant_type |
| self.enable_wavelet = enable_wavelet and HAS_PYWT |
| self.enable_viterbi = enable_viterbi |
| self.enable_qrs = enable_qrs_preservation |
| self.enable_baseline = enable_baseline_correction |
| self.enable_bilateral = enable_bilateral |
| |
| self._setup_parameters() |
| |
| def _setup_parameters(self): |
| """Set variant-specific parameters.""" |
| self.binarize_block_size = VARIANT_BLOCK_SIZES.get(self.variant, 21) |
| self.wavelet_threshold = VARIANT_WAVELET_FACTORS.get(self.variant, 1.5) |
| |
| |
| if self.variant in ['0009', '0010', '0011', '0012']: |
| self.base_smoothing_window = 9 |
| self.wavelet_level = 4 |
| else: |
| self.base_smoothing_window = 7 |
| self.wavelet_level = 3 |
| |
| def process_signal(self, signal_mv: np.ndarray, |
| lead_name: str = 'I') -> np.ndarray: |
| """ |
| Complete processing pipeline for a single lead signal. |
| |
| Args: |
| signal_mv: 1D signal in mV |
| lead_name: Lead name for lead-specific parameters |
| |
| Returns: |
| Processed signal in mV |
| """ |
| if len(signal_mv) < 10: |
| return signal_mv.copy() |
| |
| result = signal_mv.copy() |
| |
| |
| result = apply_lead_specific_smoothing(result, lead_name) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| return np.clip(result, -10, 10) |
| |
| def process_row_predictions(self, pred_mv: np.ndarray, |
| row_idx: int) -> np.ndarray: |
| """ |
| Process all leads in a row. |
| |
| Args: |
| pred_mv: Full row predictions in mV [W] |
| row_idx: Row index (0, 1, 2, 3) |
| |
| Returns: |
| Processed row predictions |
| """ |
| if row_idx < 3: |
| |
| lead_names = LEAD_LAYOUT[row_idx] |
| segment_width = len(pred_mv) // 4 |
| result = pred_mv.copy() |
| |
| for seg_idx, lead_name in enumerate(lead_names): |
| start = seg_idx * segment_width |
| end = (seg_idx + 1) * segment_width |
| |
| segment = pred_mv[start:end] |
| processed = self.process_signal(segment, lead_name) |
| result[start:end] = processed |
| |
| return result |
| else: |
| |
| return self.process_signal(pred_mv, 'II_rhythm') |
| |
| def refine_with_viterbi(self, pred_y_normalized: np.ndarray, |
| row_height: int = 500) -> np.ndarray: |
| """ |
| Optionally refine predictions using Viterbi path finding. |
| |
| Args: |
| pred_y_normalized: Model predictions [0, 1] |
| row_height: Height of row crop |
| |
| Returns: |
| Refined predictions [0, 1] |
| """ |
| if not self.enable_viterbi: |
| return pred_y_normalized |
| |
| return viterbi_refine_prediction(pred_y_normalized, |
| row_height=row_height, |
| smoothness=1.0) |
|
|
|
|
| |
| |
| |
| def detect_variant_from_filename(filename: str) -> str: |
| """ |
| Detect variant type from filename. |
| |
| Args: |
| filename: Image filename (e.g., '1006427285-0003.png') |
| |
| Returns: |
| Variant string ('0001', '0003', etc.) |
| """ |
| import re |
| match = re.search(r'-(\d{4})\.', filename) |
| if match: |
| return match.group(1) |
| return '0001' |
|
|
|
|
| def create_processor_for_variant(variant: str, |
| aggressive: bool = False) -> AdvancedECGProcessor: |
| """ |
| Factory function to create processor with variant-appropriate settings. |
| |
| Args: |
| variant: Variant type string |
| aggressive: Enable more aggressive processing for degraded images |
| |
| Returns: |
| Configured AdvancedECGProcessor |
| """ |
| |
| is_degraded = variant in ['0009', '0010', '0011', '0012'] |
| |
| return AdvancedECGProcessor( |
| variant_type=variant, |
| enable_wavelet=True, |
| enable_viterbi=aggressive and is_degraded, |
| enable_qrs_preservation=True, |
| enable_baseline_correction=True, |
| enable_bilateral=True |
| ) |
|
|
|
|
| |
| |
| |
| def test_processing_chain(): |
| """Quick test of processing chain.""" |
| |
| t = np.linspace(0, 2, 1000) |
| signal = np.sin(2 * np.pi * 1.5 * t) + 0.3 * np.sin(2 * np.pi * 10 * t) |
| signal += 0.1 * np.random.randn(len(signal)) |
| |
| processor = AdvancedECGProcessor(variant_type='0003') |
| processed = processor.process_signal(signal, lead_name='I') |
| |
| print(f"Input std: {np.std(signal):.4f}") |
| print(f"Output std: {np.std(processed):.4f}") |
| print(f"Processing complete!") |
| |
| return signal, processed |
|
|
|
|
| if __name__ == '__main__': |
| test_processing_chain() |
|
|