{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.6.4"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceId":97984,"databundleVersionId":14096757,"sourceType":"competition"},{"sourceId":13746387,"sourceType":"datasetVersion","datasetId":8747012},{"sourceId":14580426,"sourceType":"datasetVersion","datasetId":9313777},{"sourceId":272137252,"sourceType":"kernelVersion"}],"dockerImageVersionId":31236,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# ECG Image Digitization: A Multi-Stage Deep Learning Approach\n\nThis notebook presents a solution for the PhysioNet ECG Image Digitization challenge. The goal is to extract numerical ECG signal values (in millivolts) from scanned or photographed 12-lead ECG paper recordings.\n\n## Problem Overview\n\nStandard 12-lead ECG recordings are printed on calibrated grid paper with:\n- **Horizontal axis**: Time (typically 25 mm/s, so 1 small box = 0.04s)\n- **Vertical axis**: Voltage (typically 10 mm/mV, so 1 small box = 0.1 mV)\n\nThe challenge is to reverse this process—given an image, recover the underlying waveform coordinates.\n\n## Solution Architecture\n\nThe pipeline consists of three stages:\n\n1. **Stage 0 — Orientation Correction**: Detects keypoints and applies homography to correct rotation, perspective, and alignment (Directly taken from hengck23's approach)\n2. **Stage 1 — Grid Rectification**: Fine-tunes the geometric alignment using detected grid intersections (Directly taken from hengck23's approach)\n3. **Stage 2 — Signal Extraction (V19)**: A ConvNeXt-Base encoder with U-Net decoder, BiLSTM temporal modeling, and deformable convolutions for per-row waveform regression","metadata":{}},{"cell_type":"code","source":"!uv pip install --no-deps --system --no-index --find-links='/kaggle/input/hengck23-submit-physionet/hengck23-submit-physionet/setup' 'connected-components-3d'","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport sys\nimport numpy as np\nimport pandas as pd\nfrom pathlib import Path\nfrom tqdm import tqdm\nfrom scipy.signal import savgol_filter\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\n\nimport cv2\nimport timm\n\ntry:\n    from torchvision.ops import DeformConv2d\n    HAS_DEFORM_CONV = True\nexcept ImportError:\n    HAS_DEFORM_CONV = False\n    print(\"Warning: DeformConv2d not available, using standard conv\")\n\nBASELINE_PATH = '/kaggle/input/hengck23-submit-physionet/hengck23-submit-physionet'\nWEIGHTS_PATH = '/kaggle/input/v19-ecg-checkpoint-epoch22'\nCOMPETITION_PATH = '/kaggle/input/physionet-ecg-image-digitization'\n\nsys.path.insert(0, BASELINE_PATH)\n\nimport stage0_common as s0c\nimport stage1_common as s1c\nfrom stage0_model import Net as Stage0Net\nfrom stage1_model import Net as Stage1Net\n\ndevice = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\nprint(f\"Device: {device}\")\nprint(f\"Deformable Conv: {HAS_DEFORM_CONV}\")","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Calibration Constants\n\nThe ECG image is normalized to a fixed resolution. The following constants define the coordinate system used for signal extraction.\n\n### Image Geometry\n\nAfter preprocessing, images are resized to $1696 \\times 4352$ pixels. The signal extraction region spans columns $[235, 4161]$, yielding 3926 sample points per row.\n\n### Vertical Calibration\n\nEach of the four rows has a known baseline (0 mV reference line) at a specific Y-coordinate. The conversion from pixel displacement to millivolts follows:\n\n$$\nV_{mV} = \\frac{y_{baseline} - y_{pixel}}{k}\n$$\n\nwhere $k = 78.5$ pixels/mV is the calibration factor derived from the standard ECG grid (10 mm/mV at the target resolution).\n\n### Lead Layout\n\nStandard 12-lead ECGs arrange leads in a 3×4 grid plus a rhythm strip:\n\n| Row | Column 0 | Column 1 | Column 2 | Column 3 |\n|-----|----------|----------|----------|----------|\n| 0   | I        | aVR      | V1       | V4       |\n| 1   | II       | aVL      | V2       | V5       |\n| 2   | III      | aVF      | V3       | V6       |\n| 3   | Lead II rhythm strip (full 10 seconds)   ||||","metadata":{}},{"cell_type":"code","source":"TARGET_HEIGHT = 1696\nTARGET_WIDTH = 4352\n\nX0, X1 = 0, 2176\nY0, Y1 = 0, 1696\n\nT0, T1 = 235, 4161\nOUTPUT_WIDTH = T1 - T0\n\nZERO_MV = np.array([703.5, 987.5, 1271.5, 1531.5])\nMV_TO_PIXEL = 78.5\n\nCROP_HALF_HEIGHT = 250\nROW_HEIGHT = 500\n\nECG_MV_MIN, ECG_MV_MAX = -10.0, 10.0\n\nLEAD_NAMES = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6']\nLEAD_LAYOUT = [\n    ['I', 'aVR', 'V1', 'V4'],\n    ['II', 'aVL', 'V2', 'V5'],\n    ['III', 'aVF', 'V3', 'V6'],\n]\n\nBASELINE_OFFSETS = {\n    'I': 0.0037, 'II': 0.0057, 'III': 0.0029,\n    'aVR': 0.0043, 'aVL': 0.0037, 'aVF': 0.0038,\n    'V1': 0.0045, 'V2': 0.0036, 'V3': 0.0038,\n    'V4': 0.0045, 'V5': 0.0040, 'V6': 0.0038,\n}","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Model Architecture\n\n### Encoder: ConvNeXt-Base\n\nThe backbone is a ConvNeXt-Base pretrained on ImageNet-22k and fine-tuned on ImageNet-1k. ConvNeXt modernizes the ResNet architecture with:\n- Patchify stem (4×4 non-overlapping convolutions)\n- Inverted bottleneck blocks with depthwise convolutions\n- Layer normalization and GELU activations\n\nWe extract multi-scale features at 4 resolution levels for the decoder.\n\n### Decoder: U-Net with Deformable Convolutions\n\nThe decoder follows a U-Net structure with skip connections from the encoder. The final two decoder blocks use **deformable convolutions**, which learn spatial offsets for each kernel position:\n\n$$\ny(p) = \\sum_{k=1}^{K} w_k \\cdot x(p + p_k + \\Delta p_k)\n$$\n\nwhere $\\Delta p_k$ are learned offsets. This allows the network to adapt to local geometric distortions in the ECG traces.\n\n### Temporal Modeling: Bidirectional LSTM\n\nAfter spatial decoding, features are pooled along the height dimension using learned attention weights. The resulting 1D sequence is processed by a 2-layer bidirectional LSTM to capture temporal dependencies across the waveform.\n\n### Output Head\n\nThe final regression head predicts normalized Y-coordinates (0 to 1) for each horizontal position, which are then converted to absolute pixel coordinates and millivolt values.","metadata":{}},{"cell_type":"code","source":"class DeformableConvBlock(nn.Module):\n    def __init__(self, in_ch, out_ch, kernel_size=3, stride=1, padding=1):\n        super().__init__()\n        self.kernel_size = kernel_size\n        self.padding = padding\n        self.stride = stride\n        \n        if HAS_DEFORM_CONV:\n            self.offset_conv = nn.Sequential(\n                nn.Conv2d(in_ch, 64, 3, padding=1),\n                nn.BatchNorm2d(64),\n                nn.ReLU(inplace=True),\n                nn.Conv2d(64, 2 * kernel_size * kernel_size, 3, padding=1),\n            )\n            nn.init.zeros_(self.offset_conv[-1].weight)\n            nn.init.zeros_(self.offset_conv[-1].bias)\n            self.deform_conv = DeformConv2d(in_ch, out_ch, kernel_size, \n                                            stride=stride, padding=padding)\n        else:\n            self.conv = nn.Conv2d(in_ch, out_ch, kernel_size, \n                                  stride=stride, padding=padding)\n        \n        self.norm = nn.BatchNorm2d(out_ch)\n        self.act = nn.GELU()\n    \n    def forward(self, x):\n        if HAS_DEFORM_CONV:\n            offset = self.offset_conv(x)\n            out = self.deform_conv(x, offset)\n        else:\n            out = self.conv(x)\n        out = self.norm(out)\n        out = self.act(out)\n        return out\n\n\nclass BiLSTMHead(nn.Module):\n    def __init__(self, input_dim, hidden_dim=128, num_layers=2, dropout=0.1):\n        super().__init__()\n        self.lstm = nn.LSTM(\n            input_size=input_dim,\n            hidden_size=hidden_dim,\n            num_layers=num_layers,\n            batch_first=True,\n            bidirectional=True,\n            dropout=dropout if num_layers > 1 else 0,\n        )\n        self.output_proj = nn.Sequential(\n            nn.Linear(hidden_dim * 2, hidden_dim),\n            nn.LayerNorm(hidden_dim),\n            nn.GELU(),\n        )\n        self.output_dim = hidden_dim\n    \n    def forward(self, x):\n        x = x.permute(0, 2, 1)\n        lstm_out, _ = self.lstm(x)\n        out = self.output_proj(lstm_out)\n        return out\n\n\nclass AuxiliaryHeads(nn.Module):\n    def __init__(self, feature_dim):\n        super().__init__()\n        self.grid_head = nn.Sequential(\n            nn.Conv2d(32, 16, 3, padding=1),\n            nn.BatchNorm2d(16),\n            nn.ReLU(inplace=True),\n            nn.Conv2d(16, 1, 1),\n            nn.Sigmoid(),\n        )\n        self.gradient_head = nn.Sequential(\n            nn.Linear(feature_dim, 64),\n            nn.GELU(),\n            nn.Linear(64, 1),\n            nn.Tanh(),\n        )\n        self.uncertainty_head = nn.Sequential(\n            nn.Linear(feature_dim, 64),\n            nn.GELU(),\n            nn.Linear(64, 1),\n        )\n    \n    def forward(self, features_2d, features_1d):\n        grid_pred = self.grid_head(features_2d)\n        gradient_pred = self.gradient_head(features_1d).squeeze(-1)\n        log_var = self.uncertainty_head(features_1d).squeeze(-1)\n        return grid_pred, gradient_pred, log_var\n\n\nclass CoordConv2d(nn.Module):\n    def __init__(self, in_channels, out_channels, kernel_size, **kwargs):\n        super().__init__()\n        self.conv = nn.Conv2d(in_channels + 2, out_channels, kernel_size, **kwargs)\n    \n    def forward(self, x):\n        B, C, H, W = x.shape\n        yy = torch.linspace(-1, 1, H, device=x.device).view(1, 1, H, 1).expand(B, 1, H, W)\n        xx = torch.linspace(-1, 1, W, device=x.device).view(1, 1, 1, W).expand(B, 1, H, W)\n        x = torch.cat([x, yy, xx], dim=1)\n        return self.conv(x)\n\n\nclass UNetDecoderBlockV19(nn.Module):\n    def __init__(self, in_ch, skip_ch, out_ch, use_deform=False):\n        super().__init__()\n        if use_deform and HAS_DEFORM_CONV:\n            self.conv1 = DeformableConvBlock(in_ch + skip_ch, out_ch)\n        else:\n            self.conv1 = nn.Sequential(\n                nn.Conv2d(in_ch + skip_ch, out_ch, 3, padding=1, bias=False),\n                nn.BatchNorm2d(out_ch),\n                nn.GELU(),\n            )\n        self.conv2 = nn.Sequential(\n            nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),\n            nn.BatchNorm2d(out_ch),\n            nn.GELU(),\n        )\n        self.upsample = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True)\n    \n    def forward(self, x, skip=None):\n        x = self.upsample(x)\n        if skip is not None:\n            if x.shape[2:] != skip.shape[2:]:\n                x = F.interpolate(x, size=skip.shape[2:], mode='bilinear', align_corners=True)\n            x = torch.cat([x, skip], dim=1)\n        x = self.conv1(x)\n        x = self.conv2(x)\n        return x\n\n\nclass PerLeadNetV19(nn.Module):\n    def __init__(self, encoder_name='convnext_base.fb_in22k_ft_in1k', pretrained=True):\n        super().__init__()\n        \n        self.encoder = timm.create_model(\n            encoder_name,\n            pretrained=pretrained,\n            features_only=True,\n            out_indices=(0, 1, 2, 3),\n        )\n        enc_channels = self.encoder.feature_info.channels()\n        \n        decoder_dims = [256, 128, 64, 32]\n        self.dec_blocks = nn.ModuleList()\n        in_ch = enc_channels[-1]\n        skip_channels = enc_channels[:-1][::-1] + [0]\n        \n        for i, (skip_ch, out_ch) in enumerate(zip(skip_channels, decoder_dims)):\n            use_deform = (i >= 2)\n            self.dec_blocks.append(UNetDecoderBlockV19(in_ch, skip_ch, out_ch, use_deform))\n            in_ch = out_ch\n        \n        self.final_up = nn.Sequential(\n            nn.Upsample(scale_factor=2, mode='bilinear', align_corners=True),\n            nn.Conv2d(decoder_dims[-1], decoder_dims[-1], 3, padding=1, bias=False),\n            nn.BatchNorm2d(decoder_dims[-1]),\n            nn.GELU(),\n        )\n        \n        self.height_attention = nn.Sequential(\n            CoordConv2d(decoder_dims[-1], 64, 3, padding=1),\n            nn.BatchNorm2d(64),\n            nn.GELU(),\n            nn.Conv2d(64, 1, 1),\n        )\n        \n        self.bilstm = BiLSTMHead(\n            input_dim=decoder_dims[-1],\n            hidden_dim=128,\n            num_layers=2,\n            dropout=0.1,\n        )\n        \n        self.regression_head = nn.Sequential(\n            nn.Linear(self.bilstm.output_dim, 64),\n            nn.GELU(),\n            nn.Linear(64, 1),\n            nn.Sigmoid(),\n        )\n        \n        self.aux_heads = AuxiliaryHeads(self.bilstm.output_dim)\n    \n    def forward(self, x, return_aux=False):\n        B, C, H, W = x.shape\n        \n        features = self.encoder(x)\n        \n        d = features[-1]\n        skips = features[:-1][::-1] + [None]\n        \n        for block, skip in zip(self.dec_blocks, skips):\n            d = block(d, skip)\n        \n        features_2d = d\n        d = self.final_up(d)\n        \n        if d.shape[3] != W:\n            d = F.interpolate(d, size=(d.shape[2], W), mode='bilinear', align_corners=True)\n        \n        attn = self.height_attention(d)\n        attn = F.softmax(attn, dim=2)\n        pooled = (d * attn).sum(dim=2)\n        \n        temporal_features = self.bilstm(pooled)\n        y_pred = self.regression_head(temporal_features).squeeze(-1)\n        \n        if return_aux:\n            grid_pred, gradient_pred, log_var = self.aux_heads(features_2d, temporal_features)\n            return y_pred, {'grid': grid_pred, 'gradient': gradient_pred, 'log_var': log_var}\n        \n        return y_pred","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Model Loading\n\nThree models are loaded:\n\n1. **Stage 0**: Keypoint detection network for orientation correction\n2. **Stage 1**: Grid point detection for fine geometric rectification  \n3. **V19**: The main signal extraction network (ConvNeXt-Base backbone)","metadata":{}},{"cell_type":"code","source":"print(\"Loading Stage 0...\")\nstage0_net = s0c.load_net(Stage0Net(pretrained=False), f'{BASELINE_PATH}/weight/stage0-last.checkpoint.pth')\nstage0_net = stage0_net.to(device).eval()\n\nprint(\"Loading Stage 1...\")\nstage1_net = s1c.load_net(Stage1Net(pretrained=False), f'{BASELINE_PATH}/weight/stage1-last.checkpoint.pth')\nstage1_net = stage1_net.to(device).eval()\n\nprint(\"Loading V19 model...\")\nmodel = PerLeadNetV19(encoder_name='convnext_base.fb_in22k_ft_in1k', pretrained=False)\ncheckpoint = torch.load(f'{WEIGHTS_PATH}/v19_enhanced_epoch022.pth', map_location='cpu', weights_only=False)\n\nstate_dict = checkpoint['model']\nif any(k.startswith('module.') for k in state_dict.keys()):\n    state_dict = {k.replace('module.', ''): v for k, v in state_dict.items()}\n\nmodel.load_state_dict(state_dict)\nmodel.to(device).eval()\n\nepoch = checkpoint.get('epoch', '?')\nsnr = checkpoint.get('snr', checkpoint.get('best_snr', 0))\nprint(f\"Loaded V19 epoch {epoch}, SNR: {snr:.2f} dB\")\nprint(\"All models loaded!\")","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Preprocessing Pipeline\n\n### Stage 0: Orientation Correction\n\nInput images may have arbitrary rotation, perspective distortion, or be upside-down. Stage 0 performs:\n\n1. **CLAHE Enhancement**: Contrast-Limited Adaptive Histogram Equalization on the V channel (HSV) to normalize contrast across different scan qualities\n2. **Denoising**: Non-local means denoising to reduce scanner artifacts\n3. **Keypoint Detection**: Locates the four corners of the ECG grid\n4. **Homography Transform**: Warps the image to a canonical orientation\n\n### Stage 1: Grid Rectification\n\nEven after Stage 0, subtle geometric distortions may remain (paper curling, lens distortion). Stage 1 detects internal grid intersection points and applies a fine-grained rectification to align the grid lines.","metadata":{}},{"cell_type":"code","source":"def change_color(image_rgb):\n    hsv = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2HSV)\n    h, s, v = cv2.split(hsv)\n    v_denoised = cv2.fastNlMeansDenoising(v, h=5.46)\n    std = np.std(v_denoised)\n    clip_limit = max(1.0, min(3.5, 2.0 + std / 25))\n    clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(8, 8))\n    v_enhanced = clahe.apply(v_denoised)\n    hsv_enhanced = cv2.merge([h, s, v_enhanced])\n    return cv2.cvtColor(hsv_enhanced, cv2.COLOR_HSV2RGB)\n\n\n@torch.no_grad()\ndef process_stage0(img_bgr):\n    img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)\n    img_for_model = change_color(img_rgb)\n    batch = s0c.image_to_batch(img_for_model)\n    with torch.amp.autocast(device.type, dtype=torch.float32):\n        output = stage0_net(batch)\n    rotated, keypoint = s0c.output_to_predict(img_rgb, batch, output)\n    normalised, _, _ = s0c.normalise_by_homography(rotated, keypoint)\n    return normalised\n\n\n@torch.no_grad()\ndef process_stage1(stage0_img_rgb):\n    batch = {'image': torch.from_numpy(np.ascontiguousarray(stage0_img_rgb.transpose(2, 0, 1))).unsqueeze(0)}\n    with torch.amp.autocast(device.type, dtype=torch.float32):\n        output = stage1_net(batch)\n    gridpoint_xy, _ = s1c.output_to_predict(stage0_img_rgb, batch, output)\n    return s1c.rectify_image(stage0_img_rgb, gridpoint_xy)\n\n\ndef crop_row(image, row_idx):\n    baseline_y = int(ZERO_MV[row_idx])\n    y_start = max(0, baseline_y - CROP_HALF_HEIGHT)\n    y_end = min(TARGET_HEIGHT, baseline_y + CROP_HALF_HEIGHT)\n    row_crop = image[y_start:y_end, T0:T1, :].copy()\n    \n    if row_crop.shape[0] < ROW_HEIGHT:\n        pad_top = max(0, CROP_HALF_HEIGHT - baseline_y)\n        pad_bottom = max(0, (baseline_y + CROP_HALF_HEIGHT) - TARGET_HEIGHT)\n        row_crop = np.pad(row_crop, ((pad_top, pad_bottom), (0, 0), (0, 0)), mode='edge')\n    \n    return row_crop\n\n\n@torch.no_grad()\ndef predict_row(row_crop):\n    image_tensor = torch.from_numpy(row_crop.astype(np.float32) / 255.0)\n    image_tensor = image_tensor.permute(2, 0, 1).unsqueeze(0).to(device)\n    \n    with torch.amp.autocast('cuda'):\n        output = model(image_tensor, return_aux=False)\n    \n    pred_y_crop = output[0].cpu().numpy() * ROW_HEIGHT\n    return pred_y_crop\n\n\ndef convert_crop_to_full(pred_y_crop, row_idx):\n    baseline_y = int(ZERO_MV[row_idx])\n    y_start = max(0, baseline_y - CROP_HALF_HEIGHT)\n    pad_top = max(0, CROP_HALF_HEIGHT - baseline_y)\n    pred_y_full = pred_y_crop - pad_top + y_start\n    return pred_y_full\n\n\ndef convert_y_to_mv(pred_y_full, row_idx):\n    baseline_y = ZERO_MV[row_idx]\n    return (baseline_y - pred_y_full) / MV_TO_PIXEL","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Post-Processing\n\n### Savitzky-Golay Smoothing\n\nThe raw predictions contain high-frequency noise from pixel-level discretization. A Savitzky-Golay filter fits a polynomial of degree $p$ to a sliding window of $w$ points, providing smoothing while preserving waveform morphology (peaks, slopes).\n\n### Einthoven's Law Correction\n\nThe limb leads satisfy the relationship:\n\n$$\n\\text{Lead II} = \\text{Lead I} + \\text{Lead III}\n$$\n\nThis constraint arises from Kirchhoff's voltage law applied to the Einthoven triangle. We use this to reduce prediction errors by distributing the residual:\n\n$$\n\\epsilon = \\text{II}_{pred} - (\\text{I}_{pred} + \\text{III}_{pred})\n$$\n\nThe error is distributed back to Leads I and III with weight $\\alpha = 0.33$.\n\n### Baseline Drift Correction\n\nSystematic offsets learned during training are subtracted per-lead to center the isoelectric line at 0 mV.","metadata":{}},{"cell_type":"code","source":"def apply_savgol_smoothing(signal_mv, window=7, polyorder=2):\n    if len(signal_mv) >= window:\n        return savgol_filter(signal_mv, window_length=window, polyorder=polyorder)\n    return signal_mv\n\n\ndef apply_einthoven_correction(pred_mv_rows, alpha=0.33):\n    segment_width = len(pred_mv_rows[0]) // 4\n    \n    lead_I = pred_mv_rows[0][:segment_width].copy()\n    lead_II_short = pred_mv_rows[1][:segment_width].copy()\n    lead_III = pred_mv_rows[2][:segment_width].copy()\n    \n    derived_II = lead_I + lead_III\n    error = lead_II_short - derived_II\n    \n    pred_mv_rows[0][:segment_width] = lead_I + alpha * error\n    pred_mv_rows[2][:segment_width] = lead_III + alpha * error\n    \n    return pred_mv_rows\n\n\ndef apply_baseline_correction(pred_mv_rows):\n    segment_width = len(pred_mv_rows[0]) // 4\n    \n    for row_idx in range(3):\n        lead_names = LEAD_LAYOUT[row_idx]\n        for seg_idx, lead_name in enumerate(lead_names):\n            offset = BASELINE_OFFSETS.get(lead_name, 0.0)\n            seg_start = seg_idx * segment_width\n            seg_end = (seg_idx + 1) * segment_width\n            pred_mv_rows[row_idx][seg_start:seg_end] -= offset\n    \n    pred_mv_rows[3] -= BASELINE_OFFSETS.get('II', 0.0)\n    \n    return pred_mv_rows\n\n\ndef series_to_leads(pred_mv_rows):\n    leads = {}\n    segment_width = len(pred_mv_rows[0]) // 4\n    \n    for row_idx in range(3):\n        lead_names = LEAD_LAYOUT[row_idx]\n        for seg_idx, lead_name in enumerate(lead_names):\n            seg_start = seg_idx * segment_width\n            seg_end = (seg_idx + 1) * segment_width\n            leads[lead_name] = pred_mv_rows[row_idx][seg_start:seg_end]\n    \n    leads['II'] = pred_mv_rows[3]\n    \n    return leads","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Full Inference Pipeline\n\nThe complete pipeline for a single image:\n\n1. Load image (BGR format from OpenCV)\n2. Stage 0: Orientation correction → RGB normalized image\n3. Stage 1: Grid rectification → Geometrically aligned RGB image\n4. Crop and resize to target dimensions\n5. For each of the 4 rows:\n   - Extract a 500-pixel tall crop centered on the baseline\n   - Run V19 network to predict Y-coordinates\n   - Convert to absolute coordinates and millivolts\n   - Apply Savitzky-Golay smoothing\n6. Apply Einthoven's law correction across limb leads\n7. Apply per-lead baseline offset correction","metadata":{}},{"cell_type":"code","source":"def process_image(image_path):\n    image_bgr = cv2.imread(str(image_path))\n    if image_bgr is None:\n        raise ValueError(f\"Failed to load image: {image_path}\")\n    \n    try:\n        normalized_rgb = process_stage0(image_bgr)\n    except Exception:\n        normalized_rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)\n    \n    try:\n        rectified_rgb = process_stage1(normalized_rgb)\n    except Exception:\n        rectified_rgb = normalized_rgb\n    \n    rectified_bgr = cv2.cvtColor(rectified_rgb, cv2.COLOR_RGB2BGR)\n    image = rectified_bgr[Y0:Y1, X0:X1]\n    image = cv2.resize(image, (TARGET_WIDTH, TARGET_HEIGHT), interpolation=cv2.INTER_LINEAR)\n    \n    pred_mv_rows = {}\n    \n    for row_idx in range(4):\n        row_crop = crop_row(image, row_idx)\n        pred_y_crop = predict_row(row_crop)\n        pred_y_full = convert_crop_to_full(pred_y_crop, row_idx)\n        pred_mv = convert_y_to_mv(pred_y_full, row_idx)\n        pred_mv = apply_savgol_smoothing(pred_mv, window=7, polyorder=2)\n        pred_mv = np.clip(pred_mv, ECG_MV_MIN, ECG_MV_MAX)\n        pred_mv_rows[row_idx] = pred_mv\n    \n    pred_mv_rows = apply_einthoven_correction(pred_mv_rows, alpha=0.33)\n    pred_mv_rows = apply_baseline_correction(pred_mv_rows)\n    \n    return pred_mv_rows","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Submission Generation\n\nThe test set contains images with varying numbers of requested samples per lead. The `resample_signal` function uses linear interpolation to match the predicted waveform length to the required output length for each lead.","metadata":{}},{"cell_type":"code","source":"def resample_signal(signal, target_length):\n    if len(signal) == target_length:\n        return signal\n    x_old = np.linspace(0, 1, len(signal))\n    x_new = np.linspace(0, 1, target_length)\n    return np.interp(x_new, x_old, signal)\n\n\ntest_df = pd.read_csv(f'{COMPETITION_PATH}/test.csv')\ntest_dir = Path(f'{COMPETITION_PATH}/test')\nimage_ids = test_df['id'].unique()\n\nprint(f\"Processing {len(image_ids)} images...\")\nprint(f\"Signal output width: {OUTPUT_WIDTH} (T0={T0} to T1={T1})\")","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"all_rows = []\n\nfor img_id in tqdm(image_ids):\n    img_path = test_dir / f\"{img_id}.png\"\n    \n    if not img_path.exists():\n        print(f\"Missing: {img_path}\")\n        img_df = test_df[test_df['id'] == img_id]\n        for _, row in img_df.iterrows():\n            for i in range(row['number_of_rows']):\n                all_rows.append({'id': f\"{img_id}_{i}_{row['lead']}\", 'value': 0.0})\n        continue\n    \n    img_df = test_df[test_df['id'] == img_id]\n    \n    try:\n        pred_mv_rows = process_image(img_path)\n        leads = series_to_leads(pred_mv_rows)\n    except Exception as e:\n        print(f\"Error {img_id}: {e}\")\n        for _, row in img_df.iterrows():\n            for i in range(row['number_of_rows']):\n                all_rows.append({'id': f\"{img_id}_{i}_{row['lead']}\", 'value': 0.0})\n        continue\n    \n    for _, row in img_df.iterrows():\n        lead = row['lead']\n        num_samples = row['number_of_rows']\n        \n        if lead in leads:\n            signal = resample_signal(leads[lead], num_samples)\n            for i in range(num_samples):\n                val = float(signal[i])\n                if not np.isfinite(val):\n                    val = 0.0\n                all_rows.append({'id': f\"{img_id}_{i}_{lead}\", 'value': val})\n        else:\n            for i in range(num_samples):\n                all_rows.append({'id': f\"{img_id}_{i}_{lead}\", 'value': 0.0})\n\nprint(f\"Total rows: {len(all_rows)}\")","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Output Validation and Export\n\nFinal checks ensure no NaN or infinite values are present in the submission (which would cause scoring failures).","metadata":{}},{"cell_type":"code","source":"submission_df = pd.DataFrame(all_rows)\n\nnan_count = submission_df['value'].isna().sum()\ninf_count = np.isinf(submission_df['value']).sum()\nprint(f\"NaN values: {nan_count}, Inf values: {inf_count}\")\n\nif nan_count > 0 or inf_count > 0:\n    print(\"WARNING: Replacing NaN/Inf with 0.0\")\n    submission_df['value'] = submission_df['value'].replace([np.inf, -np.inf], 0.0)\n    submission_df['value'] = submission_df['value'].fillna(0.0)\n\nassert submission_df['value'].isna().sum() == 0, \"Found NaN values!\"\nassert np.isinf(submission_df['value']).sum() == 0, \"Found Inf values!\"\n\nsubmission_df.to_csv('/kaggle/working/submission.csv', index=False)\n\nprint(f\"\\nSubmission saved!\")\nprint(f\"Shape: {submission_df.shape}\")\nprint(submission_df.head(10))\nprint(f\"\\nValue stats:\")\nprint(submission_df['value'].describe())","metadata":{},"outputs":[],"execution_count":null}]}