{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceType":"competition","sourceId":97984,"databundleVersionId":14096757},{"sourceType":"datasetVersion","sourceId":15773400,"datasetId":10109573,"databundleVersionId":16718484},{"sourceType":"kernelVersion","sourceId":312109075},{"sourceType":"kernelVersion","sourceId":312121255}],"dockerImageVersionId":31328,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import pandas as pd\nimport numpy as np\nimport cv2\nfrom glob import glob\nimport matplotlib.pyplot as plt\nfrom collections import defaultdict\nfrom tqdm import tqdm\nfrom scipy.signal import medfilt\n\nfrom sklearn.model_selection import KFold\nfrom sklearn.metrics import r2_score\n    \nfrom tensorflow.keras.layers import Input, Dense, Activation, Reshape, GaussianNoise\nfrom tensorflow.keras.initializers import Constant\nfrom tensorflow.keras.models import Model\nfrom tensorflow.keras.optimizers import Adam\nfrom tensorflow.keras.losses import MeanSquaredError\nfrom tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau, TerminateOnNaN","metadata":{"_uuid":"5cb73fc6-1df1-428f-b478-f95c1c700617","_cell_guid":"5812972c-12e2-4f38-9b55-aeec9281aeee","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:12:55.969956Z","iopub.execute_input":"2026-04-20T17:12:55.970806Z","iopub.status.idle":"2026-04-20T17:12:55.975768Z","shell.execute_reply.started":"2026-04-20T17:12:55.970775Z","shell.execute_reply":"2026-04-20T17:12:55.974992Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Competition metric\n# From https://www.kaggle.com/code/metric/physionet-ecg-signal-extraction-metric\nfrom typing import Tuple\n\nimport numpy as np\nimport pandas as pd\n\nimport scipy.optimize\nimport scipy.signal\n\n\nLEADS = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6']\nMAX_TIME_SHIFT = 0.2\nPERFECT_SCORE = 384\n\n\nclass ParticipantVisibleError(Exception):\n    pass\n\n\ndef compute_power(label: np.ndarray, prediction: np.ndarray) -> Tuple[float, float]:\n    if label.ndim != 1 or prediction.ndim != 1:\n        raise ParticipantVisibleError('Inputs must be 1-dimensional arrays.')\n    finite_mask = np.isfinite(prediction)\n    if not np.any(finite_mask):\n        raise ParticipantVisibleError(\"The 'prediction' array contains no finite values (all NaN or inf).\")\n\n    prediction[~np.isfinite(prediction)] = 0\n    noise = label - prediction\n    p_signal = np.sum(label**2)\n    p_noise = np.sum(noise**2)\n    return p_signal, p_noise\n\n\ndef compute_snr(signal: float, noise: float) -> float:\n    if noise == 0:\n        # Perfect reconstruction\n        snr = PERFECT_SCORE\n    elif signal == 0:\n        snr = 0\n    else:\n        snr = min((signal / noise), PERFECT_SCORE)\n    return snr\n\n#it moves the predicted signal horizontally or vertically to align to truth so that score can be calculated based on the shape only\ndef align_signals(label: np.ndarray, pred: np.ndarray, max_shift: float = float('inf')) -> np.ndarray:\n    if np.any(~np.isfinite(label)):\n        raise ParticipantVisibleError('values in label should all be finite')\n    if np.sum(np.isfinite(pred)) == 0:\n        raise ParticipantVisibleError('prediction can not all be infinite')\n\n    # Initialize the reference and digitized signals.\n    label_arr = np.asarray(label, dtype=np.float64)\n    pred_arr = np.asarray(pred, dtype=np.float64)\n\n    label_mean = np.mean(label_arr)\n    pred_mean = np.mean(pred_arr)\n\n    label_arr_centered = label_arr - label_mean\n    pred_arr_centered = pred_arr - pred_mean\n\n    # Compute the correlation between the reference and digitized signals and locate the maximum correlation.\n    correlation = scipy.signal.correlate(label_arr_centered, pred_arr_centered, mode='full')\n\n    n_label = np.size(label_arr)\n    n_pred = np.size(pred_arr)\n\n    lags = scipy.signal.correlation_lags(n_label, n_pred, mode='full')\n    valid_lags_mask = (lags >= -max_shift) & (lags <= max_shift)\n\n    max_correlation = np.nanmax(correlation[valid_lags_mask])\n    all_max_indices = np.flatnonzero(correlation == max_correlation)\n    best_idx = min(all_max_indices, key=lambda i: abs(lags[i]))\n    time_shift = lags[best_idx]\n    start_padding_len = max(time_shift, 0)\n    pred_slice_start = max(-time_shift, 0)\n    pred_slice_end = min(n_label - time_shift, n_pred)\n    end_padding_len = max(n_label - n_pred - time_shift, 0)\n    aligned_pred = np.concatenate((np.full(start_padding_len, np.nan), pred_arr[pred_slice_start:pred_slice_end], np.full(end_padding_len, np.nan)))\n\n    def objective_func(v_shift):\n        return np.nansum((label_arr - (aligned_pred - v_shift)) ** 2)\n\n    if np.any(np.isfinite(label_arr) & np.isfinite(aligned_pred)):\n        results = scipy.optimize.minimize_scalar(objective_func, method='Brent')\n        vertical_shift = results.x\n        aligned_pred -= vertical_shift\n    return aligned_pred\n\n\ndef _calculate_image_score(group: pd.DataFrame) -> float:\n    \"\"\"Helper function to calculate the total SNR score for a single image group.\"\"\"\n\n    unique_fs_values = group['fs'].unique()\n    if len(unique_fs_values) != 1:\n        raise ParticipantVisibleError('Sampling frequency should be consistent across each ecg')\n    sampling_frequency = unique_fs_values[0]\n    if sampling_frequency != int(len(group[group['lead'] == 'II']) / 10):\n        raise ParticipantVisibleError('The sequence_length should be sampling frequency * 10s')\n    sum_signal = 0\n    sum_noise = 0\n    for lead in LEADS:\n        sub = group[group['lead'] == lead]\n        label = sub['value_true'].values\n        pred = sub['value_pred'].values\n\n        aligned_pred = align_signals(label, pred, int(sampling_frequency * MAX_TIME_SHIFT))\n        p_signal, p_noise = compute_power(label, aligned_pred)\n        sum_signal += p_signal\n        sum_noise += p_noise\n    return compute_snr(sum_signal, sum_noise)\n\n\ndef score(solution: pd.DataFrame, submission: pd.DataFrame, row_id_column_name: str) -> float:\n    \"\"\"\n    Compute the mean Signal-to-Noise Ratio (SNR) across multiple ECG leads and images for the PhysioNet 2025 competition.\n    The final score is the average of the sum of SNRs over different lines, averaged over all unique images.\n    Args:\n        solution: DataFrame with ground truth values. Expected columns: 'id' and one for each lead.\n        submission: DataFrame with predicted values. Expected columns: 'id' and one for each lead.\n        row_id_column_name: The name of the unique identifier column, typically 'id'.\n    Returns:\n        The final competition score.\n\n    Examples\n    --------\n    >>> import pandas as pd\n    >>> import numpy as np\n    >>> row_id_column_name = \"id\"\n    >>> solution = pd.DataFrame({'id': ['343_0_I', '343_1_I', '343_2_I', '343_0_III', '343_1_III','343_2_III','343_0_aVR', '343_1_aVR','343_2_aVR',\\\n    '343_0_aVL', '343_1_aVL', '343_2_aVL', '343_0_aVF', '343_1_aVF','343_2_aVF','343_0_V1', '343_1_V1', '343_2_V1','343_0_V2', '343_1_V2','343_2_V2',\\\n    '343_0_V3', '343_1_V3', '343_2_V3','343_0_V4', '343_1_V4', '343_2_V4', '343_0_V5', '343_1_V5','343_2_V5','343_0_V6', '343_1_V6','343_2_V6',\\\n    '343_0_II', '343_1_II','343_2_II', '343_3_II', '343_4_II', '343_5_II','343_6_II', '343_7_II','343_8_II','343_9_II','343_10_II','343_11_II'],\\\n    'fs': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],\\\n    'value':[0.1,0.3,0.4,0.6,0.6,0.4,0.2,0.3,0.4,0.5,0.2,0.7,0.2,0.3,0.4,0.8,0.6,0.7, 0.2,0.3,-0.1,0.5,0.6,0.7,0.2,0.9,0.4,0.5,0.6,0.7,0.1,0.3,0.4,\\\n    0.6,0.6,0.4,0.2,0.3,0.4,0.5,0.2,0.7,0.2,0.3,0.4]})\n    >>> submission = solution.copy()\n    >>> round(score(solution, submission, row_id_column_name), 4)\n    25.8433\n    >>> submission.loc[0, 'value'] = 0.9 # Introduce some noise\n    >>> round(score(solution, submission, row_id_column_name), 4)\n    13.6291\n    >>> submission.loc[4, 'value'] = 0.3 # Introduce some noise\n    >>> round(score(solution, submission, row_id_column_name), 4)\n    13.0576\n\n    >>> solution = pd.DataFrame({'id': ['343_0_I', '343_1_I', '343_2_I', '343_0_III', '343_1_III','343_2_III','343_0_aVR', '343_1_aVR','343_2_aVR',\\\n    '343_0_aVL', '343_1_aVL', '343_2_aVL', '343_0_aVF', '343_1_aVF','343_2_aVF','343_0_V1', '343_1_V1', '343_2_V1','343_0_V2', '343_1_V2','343_2_V2',\\\n    '343_0_V3', '343_1_V3', '343_2_V3','343_0_V4', '343_1_V4', '343_2_V4', '343_0_V5', '343_1_V5','343_2_V5','343_0_V6', '343_1_V6','343_2_V6',\\\n    '343_0_II', '343_1_II','343_2_II', '343_3_II', '343_4_II', '343_5_II','343_6_II', '343_7_II','343_8_II','343_9_II','343_10_II','343_11_II'],\\\n    'fs': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],\\\n    'value':[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]})\n    >>> round(score(solution, submission, row_id_column_name), 4)\n    -384\n    >>> submission = solution.copy()\n    >>> round(score(solution, submission, row_id_column_name), 4)\n    25.8433\n\n    >>> # test alignment\n    >>> label = np.array([0, 1, 2, 1, 0])\n    >>> pred = np.array([0, 1, 2, 1, 0])\n    >>> aligned = align_signals(label, pred)\n    >>> expected_array = np.array([0, 1, 2, 1, 0])\n    >>> np.allclose(aligned, expected_array, equal_nan=True)\n    True\n\n    >>> # Test 2: Vertical shift (DC offset) should be removed\n    >>> label = np.array([0, 1, 2, 1, 0])\n    >>> pred = np.array([10, 11, 12, 11, 10])\n    >>> aligned = align_signals(label, pred)\n    >>> expected_array = np.array([0, 1, 2, 1, 0])\n    >>> np.allclose(aligned, expected_array, equal_nan=True)\n    True\n\n    >>> # Test 3: Time shift should be corrected\n    >>> label = np.array([0, 0, 1, 2, 1, 0., 0.])\n    >>> pred = np.array([1, 2, 1, 0, 0, 0, 0])\n    >>> aligned = align_signals(label, pred)\n    >>> expected_array = np.array([np.nan, np.nan, 1, 2, 1, 0, 0])\n    >>> np.allclose(aligned, expected_array, equal_nan=True)\n    True\n    \n    >>> # Test 4: max_shift constraint prevents optimal alignment\n    >>> label = np.array([0, 0, 0, 0, 1, 2, 1]) # Peak is far\n    >>> pred = np.array([1, 2, 1, 0, 0, 0, 0])\n    >>> aligned = align_signals(label, pred, max_shift=10)\n    >>> expected_array = np.array([ np.nan, np.nan, np.nan, np.nan, 1, 2, 1])\n    >>> np.allclose(aligned, expected_array, equal_nan=True)\n    True\n\n    \"\"\"\n    for df in [solution, submission]:\n        if row_id_column_name not in df.columns:\n            raise ParticipantVisibleError(f\"'{row_id_column_name}' column not found in DataFrame.\")\n        if df['value'].isna().any():\n            raise ParticipantVisibleError('NaN exists in solution/submission')\n        if not np.isfinite(df['value']).all():\n            raise ParticipantVisibleError('Infinity exists in solution/submission')\n\n    submission = submission[['id', 'value']]\n    merged_df = pd.merge(solution, submission, on=row_id_column_name, suffixes=('_true', '_pred'))\n    merged_df['image_id'] = merged_df[row_id_column_name].str.split('_').str[0]\n    merged_df['row_id'] = merged_df[row_id_column_name].str.split('_').str[1].astype('int64')\n    merged_df['lead'] = merged_df[row_id_column_name].str.split('_').str[2]\n    merged_df.sort_values(by=['image_id', 'row_id', 'lead'], inplace=True)\n    image_scores = merged_df.groupby('image_id').apply(_calculate_image_score, include_groups=False)\n    return max(float(10 * np.log10(image_scores.mean())), -PERFECT_SCORE)","metadata":{"_uuid":"c98633ac-5433-4b20-8536-97012c05620f","_cell_guid":"5b22776b-925a-4851-97d1-12b4b59877f4","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:12:55.977062Z","iopub.execute_input":"2026-04-20T17:12:55.977318Z","iopub.status.idle":"2026-04-20T17:12:55.998950Z","shell.execute_reply.started":"2026-04-20T17:12:55.977295Z","shell.execute_reply":"2026-04-20T17:12:55.998137Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def plot_training_history(history):\n    \"\"\"Plot a Keras training history.\"\"\"\n    if len(history['loss']) >= 2:\n        _, axs = plt.subplots(1, 1, figsize=(6, 3), squeeze=False)\n        axs = axs.ravel()\n        axs[0].plot(np.arange(len(history['loss'])) + 1, history['loss'], ':', label='train_loss')\n        axs[0].plot(np.arange(len(history['val_loss'])) + 1, history['val_loss'], label='val_loss')\n        axs[0].legend()\n        axs[0].set_title('Training history')\n        plt.show()","metadata":{"_uuid":"dbf79e53-2aae-495d-ba47-c449f1587512","_cell_guid":"2cf5a19f-09e0-4061-835b-ca9d100421f0","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:12:56.000148Z","iopub.execute_input":"2026-04-20T17:12:56.000416Z","iopub.status.idle":"2026-04-20T17:12:56.013290Z","shell.execute_reply.started":"2026-04-20T17:12:56.000395Z","shell.execute_reply":"2026-04-20T17:12:56.012459Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### dataloader","metadata":{"_uuid":"a199ee06-51b8-46bb-8196-da9c9a81e16a","_cell_guid":"b56107ea-efe9-43be-a2d9-2c98300ba479","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# import os\n# import cv2\n# import torch\n# import numpy as np\n# import pandas as pd\n# from torch.utils.data import Dataset\n# import albumentations as A\n# from albumentations.pytorch import ToTensorV2\n\n# class ECGCropDataset(Dataset):\n#     def __init__(self, metadata_df, img_dir, mask_dir, transforms=None):\n#         \"\"\"\n#         Args:\n#             metadata_df: Pandas DataFrame where each row is a SINGLE LEAD.\n#                          Must contain: ['image_filename', 'x_min', 'y_min', 'x_max', 'y_max']\n#             img_dir: Directory with original Kaggle ECG scans.\n#             mask_dir: Directory with your generated ground-truth signal masks.\n#             transforms: Albumentations composition for augmentation.\n#         \"\"\"\n#         self.metadata = metadata_df\n#         self.img_dir = img_dir\n#         self.mask_dir = mask_dir\n        \n#         # If no transforms are provided, use default validation/test transform\n#         self.transforms = transforms or A.Compose([\n#             A.Resize(256, 512), # Standardize lead aspect ratio\n#             ToTensorV2()\n#         ])\n\n#     def __len__(self):\n#         # This will be 11,484 (957 images * 12 leads), not 957.\n#         return len(self.metadata)\n\n#     def __getitem__(self, idx):\n#         # 1. Fetch metadata for this specific lead crop\n#         row = self.metadata.iloc[idx]\n#         img_name = row['image_filename']\n        \n#         # Bounding box coordinates (parsed from YOLO txt/json earlier)\n#         x_min, y_min = int(row['x_min']), int(row['y_min'])\n#         x_max, y_max = int(row['x_max']), int(row['y_max'])\n\n#         # 2. Load Image and Mask using OpenCV\n#         img_path = os.path.join(self.img_dir, img_name)\n#         mask_path = os.path.join(self.mask_dir, img_name) # Assuming same filename\n        \n#         image = cv2.imread(img_path)\n#         image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        \n#         # Mask should be grayscale (1 channel)\n#         mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE)\n        \n#         # 3. Dynamic Cropping: Extract just the single lead region\n#         cropped_image = image[y_min:y_max, x_min:x_max]\n#         cropped_mask = mask[y_min:y_max, x_min:x_max]\n        \n#         # Normalize mask to 0 and 1 for binary segmentation (assuming 255 is the signal)\n#         cropped_mask = (cropped_mask > 127).astype(np.float32)\n\n#         # 4. Apply heavy augmentations & convert to PyTorch tensors\n#         augmented = self.transforms(image=cropped_image, mask=cropped_mask)\n        \n#         img_tensor = augmented['image']\n#         mask_tensor = augmented['mask']\n        \n#         # PyTorch expects masks to have a channel dimension: (1, H, W)\n#         mask_tensor = mask_tensor.unsqueeze(0)\n\n#         return img_tensor, mask_tensor\n\n\n\n\n\n\nimport os\nimport cv2\nimport torch\nimport numpy as np\nfrom torch.utils.data import Dataset\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nclass ECGLeadDataset(Dataset):\n    def __init__(self, metadata_df, img_dir, label_dir, transforms=None):\n        self.metadata = metadata_df\n        self.img_dir = img_dir\n        self.label_dir = label_dir # Path to the folder containing individual ECG CSVs\n        self.transforms = transforms\n        \n        # Define the 3x4 Layout\n        self.lead_names = ['I', 'II', 'III', 'aVR', 'aVL', 'aVF', 'V1', 'V2', 'V3', 'V4', 'V5', 'V6']\n        self.layout = {\n            'I': (0,0), 'aVR': (0,1), 'V1': (0,2), 'V4': (0,3),\n            'II': (1,0), 'aVL': (1,1), 'V2': (1,2), 'V5': (1,3),\n            'III': (2,0), 'aVF': (2,1), 'V3': (2,2), 'V6': (2,3)\n        }\n\n    def __len__(self):\n        # 12 leads per image\n        return len(self.metadata) * 12\n\n    def __getitem__(self, idx):\n        img_idx = idx // 12\n        lead_idx = idx % 12\n        lead_name = self.lead_names[lead_idx]\n        \n        row = self.metadata.iloc[img_idx]\n        record_id = str(row['id'])\n        \n        # 1. Load Full Image\n        img_path = os.path.join(self.img_dir, record_id, f\"{record_id}-0001.png\")\n        image = cv2.imread(img_path)\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        h, w, _ = image.shape\n\n        # 2. Crop the lead based on 3x4 layout\n        col_w = w // 4\n        row_h = h // 3\n        r, c = self.layout[lead_name]\n        \n        crop = image[r*row_h : (r+1)*row_h, c*col_w : (c+1)*col_w]\n\n        # 3. Generate Mask from CSV Ground Truth\n        csv_path = os.path.join(self.label_dir, record_id, f\"{record_id}.csv\")\n        labels = pd.read_csv(csv_path)\n        \n        # --- THE FIX: Strict typing ---\n        # Coerce everything to a standard float; errors become true NaNs\n        signal = pd.to_numeric(labels[lead_name], errors='coerce').values\n        \n        # Resample signal to match crop width FIRST\n        x_coords = np.linspace(0, len(signal)-1, col_w).astype(int)\n        signal_resampled = signal[x_coords]\n        \n        nan_mask = np.isnan(signal_resampled)\n        valid_idx = np.flatnonzero(~nan_mask)\n\n        if len(valid_idx) == 0:\n        # All values are NaN\n            signal_resampled = np.zeros_like(signal_resampled)\n        \n        elif len(valid_idx) == 1:\n            # Only one valid point → fill everything with that value\n            signal_resampled[:] = signal_resampled[valid_idx[0]]\n\n        else:\n    # Safe interpolation\n            signal_resampled[nan_mask] = np.interp(\n                np.flatnonzero(nan_mask),\n                valid_idx,\n                signal_resampled[valid_idx]\n            )\n\n\n        \n        # FINAL SAFETY\n        signal_resampled = np.nan_to_num(signal_resampled, nan=0.0,  posinf=0.0, neginf=0.0)\n        \n        # Create a black mask for the crop\n        mask = np.zeros((row_h, col_w), dtype=np.float32)\n        \n        # Map signal (mV) to pixels \n        baseline_y = row_h // 2\n        pixels_per_mv = 80 \n        \n        # --- THE FIX: The Bulletproof Loop ---\n        for x, val in enumerate(signal_resampled):\n            try:\n                # Extra check in case np.interp left a sneaky NaN\n                if not np.isfinite(val):\n                    val = 0.0 \n                    \n                y = int(baseline_y - (float(val) * pixels_per_mv))\n                \n                if 0 <= y < row_h:\n                    mask[y, x] = 1.0 # The signal line\n            except (ValueError, TypeError):\n                # If it still can't convert to int, safely ignore this pixel\n                continue\n        # ------------------------------------\n\n        # 4. Augment (Now working on high-res 1:1 texture)\n        if self.transforms:\n            augmented = self.transforms(image=crop, mask=mask)\n            img_tensor = augmented['image']\n            mask_tensor = augmented['mask'].unsqueeze(0)\n        else:\n            img_tensor = torch.from_numpy(crop).permute(2,0,1).float()\n            mask_tensor = torch.from_numpy(mask).unsqueeze(0).float()\n\n        return img_tensor, mask_tensor","metadata":{"_uuid":"6ff341bb-bff9-4c25-985b-ee1e8cd25267","_cell_guid":"3589010d-406e-4002-845a-f35025886320","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:12:56.115776Z","iopub.execute_input":"2026-04-20T17:12:56.116419Z","iopub.status.idle":"2026-04-20T17:12:56.132075Z","shell.execute_reply.started":"2026-04-20T17:12:56.116385Z","shell.execute_reply":"2026-04-20T17:12:56.131142Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### data loader","metadata":{"_uuid":"1f438adc-d4cd-4637-ad07-0501365452ef","_cell_guid":"1d113ab2-c67d-4c8f-a69e-7f29b05b09f5","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"import pandas as pd\n\n\ntrain_df = pd.read_csv('/kaggle/input/competitions/physionet-ecg-image-digitization/train.csv')\n\n\n# train_transforms = A.Compose([\n#     # Resize all crops to a uniform size (width > height for time series)\n#     A.Resize(256, 512),\n    \n#     # Simulate paper folds and warped scans\n#     A.GridDistortion(num_steps=5, distort_limit=0.3, p=0.5),\n    \n#     # Simulate bad lighting and shadows from mobile phone cameras\n#     A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5),\n    \n   \n#     A.CoarseDropout(\n#         num_holes_range=(1, 8), \n#         hole_height_range=(1, 20), \n#         hole_width_range=(1, 20), \n#         fill=255, \n#         p=0.3\n#     ),\n    \n#     # Standardize pixel values for the EfficientNet backbone\n#     A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n#     ToTensorV2()\n# ])\n\n# # Initialize the DataLoader (train_df is now properly defined)\n# train_dataset = ECGCropDataset(\n#     metadata_df=train_df, \n#     img_dir='/kaggle/input/competitions/physionet-ecg-image-digitization/train', # Update to Kaggle image path\n#     mask_dir='/kaggle/working/masks/', # Update to wherever you save your generated masks\n#     transforms=train_transforms\n# )\n\n# train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=16, shuffle=True)\n\n\n\nfrom sklearn.model_selection import train_test_split\nfrom torch.utils.data import DataLoader\n\n# 1. Split your raw dataframe (80% train, 20% val)\ntrain_split_df, val_split_df = train_test_split(train_df, test_size=0.2, random_state=42)\ntrain_split_df = train_split_df.reset_index(drop=True)\nval_split_df = val_split_df.reset_index(drop=True)\n\n# 2. Define transforms for the FULL PAGE\ntrain_transforms = A.Compose([\n    A.Resize(512, 512), \n    A.GridDistortion(num_steps=5, distort_limit=0.3, p=0.5),\n    A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5),\n    A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n    ToTensorV2()\n])\n\nval_transforms = A.Compose([\n    A.Resize(512, 512),\n    A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n    ToTensorV2()\n])\n\n# 3. Initialize the new FullPage Datasets\ntrain_dataset = ECGLeadDataset(\n    metadata_df=train_split_df, \n    img_dir='/kaggle/input/competitions/physionet-ecg-image-digitization/train', # Check if this path is exact\n    label_dir='/kaggle/input/competitions/physionet-ecg-image-digitization/train', \n    #mask_dir='/kaggle/working/masks/', \n    transforms=train_transforms\n)\n\nval_dataset = ECGLeadDataset(\n    metadata_df=val_split_df, \n    img_dir='/kaggle/input/competitions/physionet-ecg-image-digitization/train', \n    label_dir='/kaggle/input/competitions/physionet-ecg-image-digitization/train',\n    #mask_dir='/kaggle/working/masks/', \n    transforms=val_transforms\n)\n\ntrain_loader = DataLoader(train_dataset, batch_size=8, shuffle=True) # Reduced batch size for full pages\nval_loader = DataLoader(val_dataset, batch_size=8, shuffle=False)","metadata":{"_uuid":"0dd09ce8-9524-4f8e-979f-fe522793ed67","_cell_guid":"10e018f0-fcc1-4f00-8baf-5e37446b8bba","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:12:56.133352Z","iopub.execute_input":"2026-04-20T17:12:56.133622Z","iopub.status.idle":"2026-04-20T17:12:56.156820Z","shell.execute_reply.started":"2026-04-20T17:12:56.133599Z","shell.execute_reply":"2026-04-20T17:12:56.156263Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# val_transforms = A.Compose([\n#     A.Resize(256, 512),\n#     A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n#     ToTensorV2()\n# ])\n\n# val_dataset = ECGCropDataset(\n#     metadata_df=val_split_df, \n#     img_dir='/kaggle/input/competitions/physionet-ecg-image-digitization/train', \n#     mask_dir='/kaggle/working/masks/', \n#     transforms=val_transforms # Clean transforms\n# )\n# val_loader = DataLoader(val_dataset, batch_size=16, shuffle=False) # No need to shuffle validation data","metadata":{"_uuid":"5e22d503-88f0-4544-9e62-d08e763b4916","_cell_guid":"965c423d-0f47-40e5-9209-4f1f3f1ac2f6","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-04-20T17:12:56.157612Z","iopub.execute_input":"2026-04-20T17:12:56.157826Z","iopub.status.idle":"2026-04-20T17:12:56.161683Z","shell.execute_reply.started":"2026-04-20T17:12:56.157804Z","shell.execute_reply":"2026-04-20T17:12:56.160780Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print(train_df.head())\nprint(train_df.columns)","metadata":{"_uuid":"dda4d878-47a0-4d03-97f7-04b5924b24d7","_cell_guid":"4ce9aedf-529e-4f61-9bd3-cd22a8e87ad7","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:12:56.163770Z","iopub.execute_input":"2026-04-20T17:12:56.163976Z","iopub.status.idle":"2026-04-20T17:12:56.175118Z","shell.execute_reply.started":"2026-04-20T17:12:56.163956Z","shell.execute_reply":"2026-04-20T17:12:56.174297Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# !pip install segmentation-models-pytorch\n\n!pip install --no-index --find-links /kaggle/input/notebooks/ravnoorsingh101/smp-offline-wheels/smp-wheels segmentation-models-pytorch","metadata":{"_uuid":"c0f5d5a3-a063-446f-aacc-9626727c2947","_cell_guid":"72d0141f-ce6a-452a-b55c-c6adce977413","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:12:56.176048Z","iopub.execute_input":"2026-04-20T17:12:56.176368Z","iopub.status.idle":"2026-04-20T17:12:59.673168Z","shell.execute_reply.started":"2026-04-20T17:12:56.176344Z","shell.execute_reply":"2026-04-20T17:12:59.672385Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\nimport torch\nimport segmentation_models_pytorch as smp\n\n# Initialize the Attention U-Net\nmodel = smp.Unet(\n    encoder_name=\"efficientnet-b0\", # Lightweight, highly accurate backbone\n    encoder_weights=None,     # Transfer learning from general vision\n    in_channels=3,                  # RGB augmented crops\n    classes=1,                      # Binary mask (0=Background, 1=Signal)\n    activation=None,                # We will apply sigmoid in the loss function\n    decoder_attention_type=\"scse\"   # The crucial Attention mechanism\n)\n\nformatted_weights_path = '/kaggle/input/notebooks/ravnoorsingh101/pre-trained-model-efficientnet-b0/smp_weights/smp_efficientnet_b0_imagenet.pth'\n\nmodel.encoder.load_state_dict(torch.load(formatted_weights_path), strict=False)\n\n# Move model to GPU\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nmodel = model.to(device)","metadata":{"_uuid":"f3774392-3d2b-4516-b136-2111875abbea","_cell_guid":"aa222557-0c99-4f58-a531-b9c082a0fd0c","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:12:59.674824Z","iopub.execute_input":"2026-04-20T17:12:59.675145Z","iopub.status.idle":"2026-04-20T17:12:59.860623Z","shell.execute_reply.started":"2026-04-20T17:12:59.675106Z","shell.execute_reply":"2026-04-20T17:12:59.859984Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch.nn as nn\nimport torch.nn.functional as F\n\nclass ECGContinuityLoss(nn.Module):\n    def __init__(self, bce_weight=0.5, dice_weight=0.5, tv_weight=0.1):\n        super(ECGContinuityLoss, self).__init__()\n        self.bce_weight = bce_weight\n        self.dice_weight = dice_weight\n        self.tv_weight = tv_weight\n        self.bce = nn.BCEWithLogitsLoss()\n\n    def forward(self, inputs, targets):\n        # 1. Binary Cross Entropy (Pixel-wise accuracy)\n        bce_loss = self.bce(inputs, targets)\n        \n        # Apply sigmoid to get probabilities for Dice and TV\n        probs = torch.sigmoid(inputs)\n        \n        # 2. Dice Loss (Overlap accuracy)\n        smooth = 1e-6\n        intersection = (probs * targets).sum(dim=(2, 3))\n        union = probs.sum(dim=(2, 3)) + targets.sum(dim=(2, 3))\n        dice_loss = 1 - ((2. * intersection + smooth) / (union + smooth)).mean()\n        \n        # 3. Total Variation (Continuity Penalty)\n        # Penalizes differences between adjacent pixels in the x-direction (time)\n        tv_loss = torch.mean(torch.abs(probs[:, :, :, :-1] - probs[:, :, :, 1:]))\n        \n        # Combine them\n        total_loss = (self.bce_weight * bce_loss) + \\\n                     (self.dice_weight * dice_loss) + \\\n                     (self.tv_weight * tv_loss)\n                     \n        return total_loss\n\n# Initialize your custom loss and optimizer\ncriterion = ECGContinuityLoss(bce_weight=0.4, dice_weight=0.4, tv_weight=0.2)\noptimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5)","metadata":{"_uuid":"6e3df446-ee80-4166-813d-01e6fbad2355","_cell_guid":"8dc34847-3431-4d31-9eba-59579f9aeb51","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:12:59.861519Z","iopub.execute_input":"2026-04-20T17:12:59.861722Z","iopub.status.idle":"2026-04-20T17:12:59.872214Z","shell.execute_reply.started":"2026-04-20T17:12:59.861701Z","shell.execute_reply":"2026-04-20T17:12:59.871656Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### train model function","metadata":{"_uuid":"525c1d10-8999-4991-bd74-9334b7049d36","_cell_guid":"bfbd7f07-b863-4846-9f38-93e391b4e726","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"import torch\nimport numpy as np\nfrom tqdm import tqdm # For progress bars in Kaggle\n\ndef train_model(model, train_loader, val_loader, criterion, optimizer, num_epochs=30, patience=5):\n    device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    \n    # Scheduler: Reduces learning rate if validation loss stops improving\n    scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', factor=0.5, patience=3, verbose=True)\n    \n    best_val_loss = float('inf')\n    epochs_without_improvement = 0\n    \n    # History tracking for plotting later\n    history = {'train_loss': [], 'val_loss': []}\n\n    for epoch in range(num_epochs):\n        print(f\"\\nEpoch {epoch+1}/{num_epochs}\")\n        print(\"-\" * 20)\n        \n        ### 1. TRAINING PHASE ###\n        model.train()\n        train_loss = 0.0\n        \n        # tqdm adds a nice progress bar in the Kaggle console\n        train_bar = tqdm(train_loader, desc=\"Training\")\n        \n        for images, masks in train_bar:\n            images = images.to(device)\n            masks = masks.to(device)\n            \n            # Forward pass\n            optimizer.zero_grad()\n            outputs = model(images)\n            \n            # Calculate custom Continuity Loss\n            loss = criterion(outputs, masks)\n            \n            # Backward pass & optimize\n            loss.backward()\n            optimizer.step()\n            \n            train_loss += loss.item() * images.size(0)\n            train_bar.set_postfix({'loss': loss.item()})\n            \n        epoch_train_loss = train_loss / len(train_loader.dataset)\n        history['train_loss'].append(epoch_train_loss)\n        \n        ### 2. VALIDATION PHASE ###\n        model.eval()\n        val_loss = 0.0\n        \n        val_bar = tqdm(val_loader, desc=\"Validation\")\n        \n        with torch.no_grad(): # Disable gradient calculation for speed and memory\n            for images, masks in val_bar:\n                images = images.to(device)\n                masks = masks.to(device)\n                \n                outputs = model(images)\n                loss = criterion(outputs, masks)\n                \n                val_loss += loss.item() * images.size(0)\n                val_bar.set_postfix({'loss': loss.item()})\n                \n        epoch_val_loss = val_loss / len(val_loader.dataset)\n        history['val_loss'].append(epoch_val_loss)\n        \n        print(f\"Train Loss: {epoch_train_loss:.4f} | Val Loss: {epoch_val_loss:.4f}\")\n        \n        # Update learning rate scheduler\n        scheduler.step(epoch_val_loss)\n        \n        ### 3. EARLY STOPPING & CHECKPOINTING ###\n        if epoch_val_loss < best_val_loss:\n            best_val_loss = epoch_val_loss\n            epochs_without_improvement = 0\n            # Save the best model weights\n            torch.save(model.state_dict(), '/kaggle/working/best_attention_unet.pth')\n            print(\">>> Saved new best model!\")\n        else:\n            epochs_without_improvement += 1\n            print(f\"No improvement in validation loss for {epochs_without_improvement} epoch(s).\")\n            \n            if epochs_without_improvement >= patience:\n                print(\"Early stopping triggered. Halting training.\")\n                break\n                \n    return history\n\n# --- Execution ---\n# Assuming you have split your data and created train_loader and val_loader\n# history = train_model(model, train_loader, val_loader, criterion, optimizer, num_epochs=40)","metadata":{"_uuid":"bc9da2a8-064f-4865-b117-938a5aec20bb","_cell_guid":"4182d1cc-5f15-4078-864e-8996d561c864","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:12:59.873135Z","iopub.execute_input":"2026-04-20T17:12:59.873530Z","iopub.status.idle":"2026-04-20T17:12:59.890919Z","shell.execute_reply.started":"2026-04-20T17:12:59.873505Z","shell.execute_reply":"2026-04-20T17:12:59.890303Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"### Mask and resutls","metadata":{"_uuid":"cdf61ed9-705c-4604-99d8-de39a8da6fc5","_cell_guid":"7e461b9f-fbaa-47c7-8a6b-9dcbef07aa51","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"import torch\nimport matplotlib.pyplot as plt\n\ndef get_visual_mask(model_output, threshold=0.5):\n    # Convert logits to probabilities (0 to 1)\n    probs = torch.sigmoid(model_output)\n    \n    # Binarize the output: pixels > 50% sure are 1 (white), else 0 (black)\n    binary_mask = (probs > threshold).float()\n    \n    # Move to CPU and convert to numpy for visualization\n    mask_np = binary_mask.squeeze().cpu().detach().numpy()\n    return mask_np\n\n# Example usage during evaluation:\n# plt.imshow(mask_np, cmap='gray')\n# plt.title(\"Attention U-Net Predicted Mask\")\n# plt.show()\n\n\nimport numpy as np\nfrom scipy.signal import savgol_filter\n\ndef extract_1d_signal_from_mask(mask_np):\n    \"\"\"\n    Extracts a 1D signal array from a 2D binary mask.\n    mask_np shape: (Height, Width)\n    \"\"\"\n    height, width = mask_np.shape\n    signal_1d = np.zeros(width)\n    prev_val = height // 2\n    \n    for x in range(width):\n        column = mask_np[:, x]\n        \n        # Find the indices (y-coordinates) where the mask is 1\n        y_indices = np.where(column > 0)[0]\n        \n        if len(y_indices) > 0:\n            # Center of mass: average the y-coordinates of the white pixels\n            signal_1d[x] = np.mean(y_indices)\n            prev_val = signal_1d[x]\n        else:\n            # If the model predicted a gap (no white pixels in this column),\n            # we will temporarily set it to NaN to interpolate later.\n            signal_1d[x] = prev_val\n            \n    # Interpolate to fill any NaN gaps (where the U-Net missed the line)\n    nan_mask = np.isnan(signal_1d)\n    if np.any(nan_mask):\n        signal_1d[nan_mask] = np.interp(np.flatnonzero(nan_mask), \n                                        np.flatnonzero(~nan_mask), \n                                        signal_1d[~nan_mask])\n        \n    return signal_1d\n\n\ndef pixels_to_millivolts(signal_1d, pixels_per_mV):\n    \"\"\"\n    Converts pixel coordinates to voltage.\n    Note: In images, y=0 is the TOP of the image. \n    In signals, positive voltage goes UP. We must invert the y-axis.\n    \"\"\"\n    # 1. Find the baseline (assume the median of the signal is 0 mV)\n    baseline_pixel = np.median(signal_1d)\n    \n    # 2. Shift the signal so the baseline is at 0, and INVERT the axis\n    # (so a peak pointing to the top of the image is a positive voltage)\n    signal_shifted = baseline_pixel - signal_1d \n    \n    # 3. Convert to millivolts\n    signal_mv = signal_shifted / pixels_per_mV\n    \n    return signal_mv\n\ndef smooth_ecg_signal(signal_mv):\n    \"\"\"\n    Applies a Savitzky-Golay filter to remove jagged pixelation noise \n    while preserving the sharp peaks of the QRS complex.\n    \"\"\"\n    # window_length must be odd, polyorder is the polynomial degree\n    smoothed_signal = savgol_filter(signal_mv, window_length=11, polyorder=3)\n    return smoothed_signal\n\n\ndef get_dynamic_baseline(crop_gray):\n    \"\"\"\n    Uses morphological operations to isolate horizontal lines (baseline/grid).\n    \"\"\"\n    # 1. Threshold to get black ink\n    _, binary = cv2.threshold(crop_gray, 150, 255, cv2.THRESH_BINARY_INV)\n    \n    # 2. Morphological Kernel: Long horizontal line\n    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 1))\n    \n    # 3. Erode then Dilate (Open)\n    # This removes the sharp vertical QRS complexes and leaves the baseline\n    baseline_map = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)\n    \n    # 4. Find the Y-coordinate of the densest horizontal line\n    horizontal_density = np.sum(baseline_map, axis=1)\n    detected_baseline_y = np.argmax(horizontal_density)\n    \n    return detected_baseline_y","metadata":{"_uuid":"8f7d1c2e-4fe6-45de-a1b2-c71af5063501","_cell_guid":"d858c0a9-ac2b-4815-9c05-73fe032929f4","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:12:59.891810Z","iopub.execute_input":"2026-04-20T17:12:59.892277Z","iopub.status.idle":"2026-04-20T17:12:59.905641Z","shell.execute_reply.started":"2026-04-20T17:12:59.892215Z","shell.execute_reply":"2026-04-20T17:12:59.904828Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\nimport numpy as np\nimport matplotlib.pyplot as plt\n\n# 1. Set model to evaluation mode (turns off dropout, batchnorm updates, etc.)\nmodel.eval()\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\n# 2. Grab a single batch of images from your DataLoader\n# (Using train_loader here just to test that the pipeline runs)\nimages, true_masks = next(iter(train_loader))\nimages = images.to(device)\n\n# 3. Pass the images through the model to get the predictions\nwith torch.no_grad():\n    batch_outputs = model(images)\n\n# 4. Isolate the FIRST image in the batch to process\n# THIS is the missing variable!\nmodel_output = batch_outputs[0] \noriginal_image_tensor = images[0]\n\n# --- NOW WE RUN THE POST-PROCESSING PIPELINE ---\n\n# Step A: Get the 2D binary mask\nraw_mask = get_visual_mask(model_output, threshold=0.3)\n\n# Step B: Extract the 1D pixel heights (Center of Mass)\npixel_signal = extract_1d_signal_from_mask(raw_mask)\n\n# Step C: Convert pixels to Voltage \n# (Note: 50.0 is a placeholder. You'll need to check the Kaggle data to see exactly how many pixels = 1mV)\nvoltage_signal = pixels_to_millivolts(pixel_signal, pixels_per_mV=50.0)\n\n# Step D: Smooth the signal with Savitzky-Golay\nfinal_signal = smooth_ecg_signal(voltage_signal)\n\n\n# --- VISUALIZATION: Let's see if it worked! ---\n\nfig, axs = plt.subplots(3, 1, figsize=(12, 10))\n\n# Plot 1: The Original Cropped Image\n# Convert tensor back to numpy and un-normalize for viewing\nimg_view = original_image_tensor.cpu().permute(1, 2, 0).numpy()\n# Undo the ImageNet normalization so it looks normal\nmean = np.array([0.485, 0.456, 0.406])\nstd = np.array([0.229, 0.224, 0.225])\nimg_view = std * img_view + mean\nimg_view = np.clip(img_view, 0, 1)\n\naxs[0].imshow(img_view)\naxs[0].set_title(\"1. Original Cropped Lead (Input)\")\naxs[0].axis('off')\n\n# Plot 2: The U-Net Binary Mask\naxs[1].imshow(raw_mask, cmap='gray')\naxs[1].set_title(\"2. Attention U-Net Prediction (2D Mask)\")\naxs[1].axis('off')\n\n# Plot 3: The Final Digitized 1D Signal\naxs[2].plot(final_signal, color='red')\naxs[2].set_title(\"3. Final Digitized Signal (1D Voltage)\")\naxs[2].set_ylabel(\"Millivolts (mV)\")\naxs[2].set_xlabel(\"Time (pixels)\")\naxs[2].grid(True)\n\nplt.tight_layout()\nplt.show()","metadata":{"_uuid":"2a7818f7-a3ad-40cb-90a1-40397da2be8c","_cell_guid":"821480d5-f2d4-4d0f-9a37-0e94b5bda646","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:12:59.907634Z","iopub.execute_input":"2026-04-20T17:12:59.907860Z","iopub.status.idle":"2026-04-20T17:13:01.537532Z","shell.execute_reply.started":"2026-04-20T17:12:59.907840Z","shell.execute_reply":"2026-04-20T17:13:01.536690Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 1. Get raw array from model\nraw_mask = get_visual_mask(model_output)\n\n# 2. Extract pixel heights\npixel_signal = extract_1d_signal_from_mask(raw_mask)\n\npixel_signal = np.nan_to_num(pixel_signal, nan=np.median(pixel_signal))\n\n# 3. Convert to Voltage (You'll need to check the Kaggle metadata for the exact pixels_per_mV)\n# For example, if the grid resolution implies 50 pixels = 1 mV:\nvoltage_signal = pixels_to_millivolts(pixel_signal, pixels_per_mV=50.0)\n\n# 4. Smooth for final submission\nfinal_submission_signal = smooth_ecg_signal(voltage_signal)","metadata":{"_uuid":"173a18ec-6377-4b4c-a3e5-42753a3f33cc","_cell_guid":"3ffd4c30-9d0a-4b5d-be1b-c95479d39d57","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:13:01.538507Z","iopub.execute_input":"2026-04-20T17:13:01.539279Z","iopub.status.idle":"2026-04-20T17:13:01.554830Z","shell.execute_reply.started":"2026-04-20T17:13:01.539212Z","shell.execute_reply":"2026-04-20T17:13:01.554283Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# --- GENERATE FINAL KAGGLE SUBMISSION ---\nimport os\nimport cv2\nimport torch\nimport numpy as np\nimport pandas as pd\nfrom tqdm import tqdm\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nprint(\"Generating Kaggle submission...\")\n\n# 1. Load the hidden test set metadata\ntest_df = pd.read_csv('/kaggle/input/competitions/physionet-ecg-image-digitization/test.csv')\n\n# 2. Setup model for pure inference\nmodel.eval()\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\n# 3. Use the exact same transforms we used in validation\ntest_transforms = A.Compose([\n    A.Resize(512, 512),\n    A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n    ToTensorV2()\n])\n\n# --- IMPROVED SUBMISSION LOOP ---\nsubmission_data = []\nold_id = None\n\n# Standard 3x4 Layout lookup\nlayout_map = {\n    'I': (0,0), 'aVR': (0,1), 'V1': (0,2), 'V4': (0,3),\n    'II': (1,0), 'aVL': (1,1), 'V2': (1,2), 'V5': (1,3),\n    'III': (2,0), 'aVF': (2,1), 'V3': (2,2), 'V6': (2,3)\n}\n\nfor idx, row in tqdm(test_df.iterrows(), total=len(test_df)):\n    record_id = str(row['id'])\n    lead_name = row['lead']\n    \n    if record_id != old_id:\n        img_path = os.path.join('/kaggle/input/competitions/physionet-ecg-image-digitization/test', record_id, f\"{record_id}-0001.png\")\n        full_img = cv2.imread(img_path)\n\n        if full_img is None:\n            print(f\"Warning: Could not find image at {img_path}\")\n            full_img = np.zeros((1652, 2200, 3), dtype=np.uint8)\n        \n        full_img_rgb = cv2.cvtColor(full_img, cv2.COLOR_BGR2RGB)\n        h, w, _ = full_img.shape\n        old_id = record_id\n\n    # 1. GET LEAD CROP (Step 1)\n    col_w, row_h = w // 4, h // 3\n    r, c = layout_map[lead_name]\n    crop = full_img_rgb[r*row_h:(r+1)*row_h, c*col_w:(c+1)*col_w]\n    \n    # 2. DETECT BASELINE (Step 2)\n    crop_gray = cv2.cvtColor(crop, cv2.COLOR_RGB2GRAY)\n    actual_baseline_y = get_dynamic_baseline(crop_gray)\n\n    # 3. U-NET INFERENCE (On High-Res Patch)\n    # Resize to model input size (e.g., 256x512) but keep aspect ratio\n    input_tensor = test_transforms(image=crop)['image'].unsqueeze(0).to(device)\n    with torch.no_grad():\n        mask_logits = model(input_tensor)[0]\n    \n    # 4. SIGNAL EXTRACTION\n    mask = get_visual_mask(mask_logits) # Your existing function\n    pixel_signal = extract_1d_signal_from_mask(mask) # Your existing function\n    \n    # Re-scale back to crop height for voltage conversion\n    pixel_signal_scaled = pixel_signal * (row_h / mask.shape[0])\n    \n    # Use detected baseline instead of np.median\n    # Voltage = (Baseline - PixelY) / Scale\n    # 80 is a better estimate for these scans than 50\n    signal_mv = (actual_baseline_y - pixel_signal_scaled) / 80.0\n    \n    # Final cleanup\n    final_signal = smooth_ecg_signal(signal_mv)\n    \n    # Interpolate to Kaggle's required length\n    final_interpolated = np.interp(\n        np.linspace(0, 1, int(row['number_of_rows'])),\n        np.linspace(0, 1, len(final_signal)),\n        final_signal\n    )\n\n    # Append to submission list (Standard formatting)\n    for t, val in enumerate(final_interpolated):\n        submission_data.append({'id': f\"{record_id}_{t}_{lead_name}\", 'value': val})\n\n# Save to CSV exactly as Kaggle expects\nsubmission_df = pd.DataFrame(submission_data)\nprint(f\"\\nCreated submission with {len(submission_df)} rows.\")\nsubmission_df.to_csv('submission.csv', index=False)\nprint(\"Saved to submission.csv! Ready to submit to the leaderboard.\")","metadata":{"_uuid":"37277c66-3a1e-4db6-98da-1f719f333e22","_cell_guid":"246e6659-a25b-4de2-b410-bf7aa7344f6c","trusted":true,"collapsed":false,"execution":{"iopub.status.busy":"2026-04-20T17:13:01.555705Z","iopub.execute_input":"2026-04-20T17:13:01.556112Z","iopub.status.idle":"2026-04-20T17:13:02.667640Z","shell.execute_reply.started":"2026-04-20T17:13:01.556085Z","shell.execute_reply":"2026-04-20T17:13:02.666966Z"},"jupyter":{"outputs_hidden":false}},"outputs":[],"execution_count":null}]}