{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"none","dataSources":[{"sourceId":97984,"databundleVersionId":14096757,"sourceType":"competition"},{"sourceId":14092327,"sourceType":"datasetVersion","datasetId":8973540},{"sourceId":14301609,"sourceType":"datasetVersion","datasetId":9129515}],"dockerImageVersionId":31192,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# This Python 3 environment comes with many helpful analytics libraries installed\n# It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python\n# For example, here's several helpful packages to load\n\nimport numpy as np # linear algebra\nimport pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)\n\n# Input data files are available in the read-only \"../input/\" directory\n# For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory\n\nimport os\nfor dirname, _, filenames in os.walk('/kaggle/input'):\n    for filename in filenames:\n        print(os.path.join(dirname, filename))\n\n# You can write up to 20GB to the current directory (/kaggle/working/) that gets preserved as output when you create a version using \"Save & Run All\" \n# You can also write temporary files to /kaggle/temp/, but they won't be saved outside of the current session","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:22:53.971248Z","iopub.execute_input":"2026-01-16T18:22:53.972192Z","iopub.status.idle":"2026-01-16T18:22:56.756370Z","shell.execute_reply.started":"2026-01-16T18:22:53.972127Z","shell.execute_reply":"2026-01-16T18:22:56.755202Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install segmentation-models-pytorch","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:22:56.758516Z","iopub.execute_input":"2026-01-16T18:22:56.759491Z","iopub.status.idle":"2026-01-16T18:23:01.533915Z","shell.execute_reply.started":"2026-01-16T18:22:56.759462Z","shell.execute_reply":"2026-01-16T18:23:01.532484Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport cv2\nimport numpy as np\nimport torch\nimport segmentation_models_pytorch as smp\nimport matplotlib.pyplot as plt\nfrom torch.utils.data import Dataset, DataLoader","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:01.535451Z","iopub.execute_input":"2026-01-16T18:23:01.535897Z","iopub.status.idle":"2026-01-16T18:23:01.543737Z","shell.execute_reply.started":"2026-01-16T18:23:01.535861Z","shell.execute_reply":"2026-01-16T18:23:01.542609Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"DEVICE='cuda' if torch.cuda.is_available() else 'cpu'\nimg_size= (512,1024)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:01.544787Z","iopub.execute_input":"2026-01-16T18:23:01.545117Z","iopub.status.idle":"2026-01-16T18:23:01.561848Z","shell.execute_reply.started":"2026-01-16T18:23:01.545087Z","shell.execute_reply":"2026-01-16T18:23:01.560876Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"model= smp.Unet(encoder_name='resnet34', encoder_weights='imagenet', in_channels=3, classes=1, activation='sigmoid')\nmodel.to(DEVICE)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:01.565425Z","iopub.execute_input":"2026-01-16T18:23:01.565839Z","iopub.status.idle":"2026-01-16T18:23:02.358414Z","shell.execute_reply.started":"2026-01-16T18:23:01.565814Z","shell.execute_reply":"2026-01-16T18:23:02.357483Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def preprocess_image(image_path):\n    img = cv2.imread(image_path)\n    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n    \n   \n    img_resized = cv2.resize(img, (img_size[1], img_size[0]))\n    \n    img_tensor = (img_resized / 255.0).astype(np.float32)\n    img_tensor = np.transpose(img_tensor, (2, 0, 1))\n    img_tensor = torch.tensor(img_tensor).unsqueeze(0) # Add batch dimension\n    return img_tensor, img ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:02.359747Z","iopub.execute_input":"2026-01-16T18:23:02.360062Z","iopub.status.idle":"2026-01-16T18:23:02.369196Z","shell.execute_reply.started":"2026-01-16T18:23:02.360041Z","shell.execute_reply":"2026-01-16T18:23:02.368204Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import cv2\nimport numpy as np\nimport matplotlib.pyplot as plt\n\ndef crop_to_paper_content(image_path, debug_plot=False):\n    # 1. Load Image\n    original_img = cv2.imread(image_path)\n    if original_img is None: return None\n    \n    # Work on a copy to avoid messing up the original\n    img = original_img.copy()\n    h, w = img.shape[:2]\n    \n    # 2. Preprocessing for Edge Detection\n    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n    \n    # Heavy blur to remove grid lines and text, keeping only the paper edge\n    blur = cv2.GaussianBlur(gray, (5, 5), 0)\n    \n    # 3. Canny Edge Detection\n    # These values (30, 100) are standard for document scanning. \n    # You might need to tweak them if the edges are faint.\n    edges = cv2.Canny(blur, 30, 100)\n    \n    # Dilation: Makes the edge lines thicker so they connect better\n    kernel = np.ones((5,5), np.uint8)\n    edges_dilated = cv2.dilate(edges, kernel, iterations=2)\n    \n    # 4. Find Contours\n    # RETR_LIST gets all contours. We sort by Area (Largest first).\n    contours, _ = cv2.findContours(edges_dilated, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)\n    contours = sorted(contours, key=cv2.contourArea, reverse=True)\n    \n    target_contour = None\n    \n    # 5. Loop through largest contours to find the \"Paper\"\n    for cnt in contours[:5]: # Check top 5 largest shapes\n        # Approximate the contour shape\n        peri = cv2.arcLength(cnt, True)\n        approx = cv2.approxPolyDP(cnt, 0.02 * peri, True)\n        \n        # If it has 4 points, it's likely a rectangle (the paper)\n        if len(approx) == 4:\n            target_contour = approx\n            break\n            \n    # Fallback: If no perfect rectangle found, just take the largest contour\n    if target_contour is None and len(contours) > 0:\n        target_contour = contours[0]\n\n    # 6. Safety Check\n    if target_contour is None or cv2.contourArea(target_contour) < (h * w * 0.1):\n        # If largest shape is less than 10% of image, we probably failed.\n        if debug_plot: print(\"Failed to find a large enough paper object.\")\n        return original_img\n\n    # 7. Crop to Bounding Box\n    x, y, w_box, h_box = cv2.boundingRect(target_contour)\n    \n    # Add a tiny margin (optional)\n    margin = 10\n    x = max(0, x - margin)\n    y = max(0, y - margin)\n    w_box = min(w - x, w_box + 2*margin)\n    h_box = min(h - y, h_box + 2*margin)\n    \n    cropped = original_img[y:y+h_box, x:x+w_box]\n    \n    if debug_plot:\n        plt.figure(figsize=(15,5))\n        plt.subplot(131); plt.title(\"Original\"); plt.imshow(original_img)\n        plt.subplot(132); plt.title(\"Edges\"); plt.imshow(edges_dilated, cmap='gray')\n        \n        # Draw the detected box on the image for visualization\n        debug_vis = original_img.copy()\n        cv2.rectangle(debug_vis, (x,y), (x+w_box, y+h_box), (0,255,0), 5)\n        plt.subplot(133); plt.title(\"Detected Box\"); plt.imshow(debug_vis)\n        plt.show()\n\n    return cropped ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:02.370483Z","iopub.execute_input":"2026-01-16T18:23:02.370773Z","iopub.status.idle":"2026-01-16T18:23:02.392472Z","shell.execute_reply.started":"2026-01-16T18:23:02.370752Z","shell.execute_reply":"2026-01-16T18:23:02.391483Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"img1= crop_to_paper_content('/kaggle/input/physionet-ecg-image-digitization/train/1086830824/1086830824-0005.png')\nplt.imshow(img1)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:02.393591Z","iopub.execute_input":"2026-01-16T18:23:02.394169Z","iopub.status.idle":"2026-01-16T18:23:05.510818Z","shell.execute_reply.started":"2026-01-16T18:23:02.394129Z","shell.execute_reply":"2026-01-16T18:23:05.509695Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"img2= crop_to_paper_content('/kaggle/input/physionet-ecg-image-digitization/train/1084993373/1084993373-0005.png')\nplt.imshow(img2)\nplt.savefig(\"img2_005_cropped.png\", dpi=300, bbox_inches='tight')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:05.511822Z","iopub.execute_input":"2026-01-16T18:23:05.512101Z","iopub.status.idle":"2026-01-16T18:23:11.072392Z","shell.execute_reply.started":"2026-01-16T18:23:05.512073Z","shell.execute_reply":"2026-01-16T18:23:11.071330Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import cv2\nimport numpy as np\nimport matplotlib.pyplot as plt\n\ndef crop_glowing_screen(image_path, debug_plot=False):\n    # 1. Load Image\n    img = cv2.imread(image_path)\n    if img is None: return None\n    \n    # 2. Convert to Grayscale\n    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n    \n    # 3. HEAVY Blur\n    # We don't care about the lines anymore. We just want the \"shape\" of the light.\n    # (51, 51) is a very aggressive blur that merges glare spots into the screen background.\n    blurred = cv2.GaussianBlur(gray, (51, 51), 0)\n    \n    # 4. Thresholding based on Brightness Stats\n    # We assume the screen is in the top 30% of brightness in the image.\n    # We calculate the pixel value cutoff.\n    hist = cv2.calcHist([blurred], [0], None, [256], [0, 256])\n    # Find the brightness value where most of the bright pixels live\n    # Simple heuristic: Threshold at (Max Intensity - Constant) or Mean + StdDev\n    mean_val = np.mean(blurred)\n    max_val = np.max(blurred)\n    \n    # Heuristic: Cutoff is average of mean and max. \n    # Everything brighter than this is likely the screen (or a window).\n    thresh_val = (mean_val + max_val) / 2\n    \n    _, binary = cv2.threshold(blurred, thresh_val, 255, cv2.THRESH_BINARY)\n    \n    # 5. Clean up the blob\n    # Erode to disconnect the screen from any bright background walls\n    kernel = np.ones((11,11), np.uint8)\n    eroded = cv2.erode(binary, kernel, iterations=2)\n    # Dilate back to restore size\n    dilated = cv2.dilate(eroded, kernel, iterations=2)\n    \n    # 6. Find Largest Contour\n    contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    \n    if not contours:\n        if debug_plot: print(\"No bright objects found.\")\n        return img\n        \n    # Pick the largest area\n    largest_contour = max(contours, key=cv2.contourArea)\n    \n    # 7. Get Bounding Box\n    x, y, w, h = cv2.boundingRect(largest_contour)\n    \n    # Safety: If the found box is tiny (just a glare spot), return original\n    img_h, img_w = img.shape[:2]\n    if (w * h) < (img_w * img_h * 0.05): # Less than 5% of image\n        if debug_plot: print(\"Found object too small (likely just a glare spot).\")\n        return img\n\n    cropped = img[y:y+h, x:x+w]\n    \n    if debug_plot:\n        plt.figure(figsize=(12, 4))\n        plt.subplot(131); plt.title(\"Blurred (Light Map)\"); plt.imshow(blurred, cmap='gray')\n        plt.subplot(132); plt.title(f\"Threshold > {int(thresh_val)}\"); plt.imshow(dilated, cmap='gray')\n        plt.subplot(133); plt.title(\"Result\"); plt.imshow(cv2.cvtColor(cropped, cv2.COLOR_BGR2RGB))\n        plt.show()\n        \n    return cropped","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:11.073509Z","iopub.execute_input":"2026-01-16T18:23:11.074281Z","iopub.status.idle":"2026-01-16T18:23:11.089879Z","shell.execute_reply.started":"2026-01-16T18:23:11.074256Z","shell.execute_reply":"2026-01-16T18:23:11.088630Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"img1= crop_glowing_screen('/kaggle/input/physionet-ecg-image-digitization/train/1041099777/1041099777-0006.png')\nplt.imshow(img1)\nplt.savefig(\"img1_cropped.png\", dpi=300, bbox_inches='tight')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:11.090998Z","iopub.execute_input":"2026-01-16T18:23:11.091387Z","iopub.status.idle":"2026-01-16T18:23:14.396527Z","shell.execute_reply.started":"2026-01-16T18:23:11.091357Z","shell.execute_reply":"2026-01-16T18:23:14.395530Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"img2= crop_glowing_screen('/kaggle/input/physionet-ecg-image-digitization/train/1084993373/1084993373-0006.png')\nplt.imshow(img2)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:14.397999Z","iopub.execute_input":"2026-01-16T18:23:14.398523Z","iopub.status.idle":"2026-01-16T18:23:16.513257Z","shell.execute_reply.started":"2026-01-16T18:23:14.398492Z","shell.execute_reply":"2026-01-16T18:23:16.512206Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import cv2\nimport numpy as np\nimport matplotlib.pyplot as plt\n\ndef crop_screen_v2(image_path, debug_plot=False):\n    # 1. Load Image\n    img = cv2.imread(image_path)\n    if img is None: return None\n    \n    # Resize for consistent processing (standardizes kernel sizes)\n    # We work on a small copy for detection, then crop the original\n    scale_h = 800 / img.shape[0]\n    small_img = cv2.resize(img, None, fx=scale_h, fy=scale_h)\n    \n    # 2. Extract Intensity (Value channel)\n    # HSV separates Color (H) from Brightness (V). We only care about V.\n    hsv = cv2.cvtColor(small_img, cv2.COLOR_BGR2HSV)\n    v_channel = hsv[:,:,2]\n    \n    # 3. Apply CLAHE (Glare Flattener)\n    # This is the Key Fix: It lowers the contrast of the super-bright glare spots\n    # and boosts the darker parts of the screen, making the screen look \"uniform\".\n    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))\n    v_clahe = clahe.apply(v_channel)\n    \n    # 4. Blur to remove grid lines and text\n    # We want the screen to look like one solid block\n    blurred = cv2.GaussianBlur(v_clahe, (15, 15), 0)\n    \n    # 5. Otsu's Thresholding\n    # Unlike the previous method (which guessed the threshold), Otsu mathematically\n    # finds the best separator between \"Dark Bezel\" and \"Light Screen\".\n    # It works perfectly even if the screen is dim or the glare is bright.\n    _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)\n    \n    # 6. Morphological Cleanup\n    # \"Close\" small holes (in case text on screen was too dark)\n    kernel = np.ones((7,7), np.uint8)\n    binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations=2)\n    # \"Erode\" slightly to detach from any bright background walls\n    binary = cv2.erode(binary, kernel, iterations=1)\n    \n    # 7. Find the Screen Contour\n    contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    \n    if not contours:\n        return img # Fallback\n        \n    # Pick the largest area\n    largest_contour = max(contours, key=cv2.contourArea)\n    \n    # Get Bounding Box\n    x, y, w, h = cv2.boundingRect(largest_contour)\n    \n    # 8. Scale coordinates back to original image size\n    x = int(x / scale_h)\n    y = int(y / scale_h)\n    w = int(w / scale_h)\n    h = int(h / scale_h)\n    \n    # Optional: Add small margin to ensure we don't cut the axis labels\n    # (Sometimes Otsu cuts a bit too tight on the edge)\n    margin = 15\n    h_orig, w_orig = img.shape[:2]\n    x = max(0, x - margin)\n    y = max(0, y - margin)\n    w = min(w_orig - x, w + 2*margin)\n    h = min(h_orig - y, h + 2*margin)\n\n    cropped = img[y:y+h, x:x+w]\n    \n    if debug_plot:\n        plt.figure(figsize=(12, 4))\n        plt.subplot(131); plt.title(\"V-Channel + CLAHE\"); plt.imshow(v_clahe, cmap='gray')\n        plt.subplot(132); plt.title(\"Otsu Mask\"); plt.imshow(binary, cmap='gray')\n        plt.subplot(133); plt.title(\"Result\"); plt.imshow(cv2.cvtColor(cropped, cv2.COLOR_BGR2RGB))\n        plt.show()\n        \n    return cropped\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:16.514554Z","iopub.execute_input":"2026-01-16T18:23:16.515021Z","iopub.status.idle":"2026-01-16T18:23:16.530576Z","shell.execute_reply.started":"2026-01-16T18:23:16.514988Z","shell.execute_reply":"2026-01-16T18:23:16.529453Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":" img1= crop_screen_v2('/kaggle/input/physionet-ecg-image-digitization/train/1041099777/1041099777-0006.png')\nplt.imshow(img1)\nplt.savefig(\"img1_cropped.png\", dpi=300, bbox_inches='tight')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:16.535385Z","iopub.execute_input":"2026-01-16T18:23:16.535706Z","iopub.status.idle":"2026-01-16T18:23:21.824981Z","shell.execute_reply.started":"2026-01-16T18:23:16.535658Z","shell.execute_reply":"2026-01-16T18:23:21.823751Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"img2= crop_screen_v2('/kaggle/input/physionet-ecg-image-digitization/train/1084993373/1084993373-0006.png')\nplt.imshow(img2)\nplt.savefig(\"img2_cropped.png\", dpi=300, bbox_inches='tight')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:21.826101Z","iopub.execute_input":"2026-01-16T18:23:21.826380Z","iopub.status.idle":"2026-01-16T18:23:27.160345Z","shell.execute_reply.started":"2026-01-16T18:23:21.826359Z","shell.execute_reply":"2026-01-16T18:23:27.159341Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":" img3= crop_screen_v2('/kaggle/input/physionet-ecg-image-digitization/train/1086830824/1086830824-0006.png')\nplt.imshow(img3)\nplt.savefig(\"img3_cropped.png\", dpi=300, bbox_inches='tight')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:27.161425Z","iopub.execute_input":"2026-01-16T18:23:27.161716Z","iopub.status.idle":"2026-01-16T18:23:32.344107Z","shell.execute_reply.started":"2026-01-16T18:23:27.161687Z","shell.execute_reply":"2026-01-16T18:23:32.343037Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport random\nimport numpy as np\nimport pandas as pd\nimport cv2\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\nimport matplotlib.pyplot as plt\n\n\n\n# 2. CONFIGURATION & LAYOUT\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nKAGGLE_DIR = \"/kaggle/input/physionet-ecg-image-digitization\"\n\n# Standard 12-Lead Layout (4 columns x 3 rows)\n# ROI format: (y_start%, x_start%, y_end%, x_end%)\n# Note: I swapped x/y in previous explanations, here is the strict (Top, Left, Bottom, Right) format\n# New Grid Layout: (Row_Index, Column_Index)\n# Row 0 is top, Row 2 is bottom. Column 0 is left, Column 3 is right.\nLEAD_GRID = {\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\"\"\"def find_ecg_rows(img_gray):\n    \n    Scans the image vertically to find the 4 main signal rows.\n    Returns a list of tuples: [(y_start, y_end), ...]\n    \n    # 1. Invert image (Ink = 1, Paper = 0)\n    # Ensure we are working with black ink on white paper logic\n    if np.mean(img_gray) > 127:\n        inv = 255 - img_gray\n    else:\n        inv = img_gray\n        \n    # 2. Horizontal Projection (Sum of pixels in each row)\n    # We reduce the image to a single vertical column of numbers\n    projection = np.sum(inv, axis=1)\n    \n    # 3. Smooth the projection to remove noise (small spikes)\n    kernel_size = int(img_gray.shape[0] * 0.05) # 5% of height\n    smooth_proj = np.convolve(projection, np.ones(kernel_size)/kernel_size, mode='same')\n    \n    # 4. Threshold to find \"Active\" rows\n    # Any row with significant ink (signal) is \"Active\"\n    mask = smooth_proj > (np.max(smooth_proj) * 0.2)\n    \n    # 5. Find start/end of continuous active regions\n    rows = []\n    start = None\n    for y, is_active in enumerate(mask):\n        if is_active and start is None:\n            start = y\n        elif not is_active and start is not None:\n            # Found the end of a block\n            height = y - start\n            # Filter out tiny blocks (noise/text) - must be > 5% of page\n            if height > img_gray.shape[0] * 0.05:\n                rows.append((start, y))\n            start = None\n            \n    # Handle case where the last block goes to the bottom\n    if start is not None:\n        rows.append((start, len(mask)))\n        \n    # We expect 3 or 4 rows typically.\n    # If we found > 4, keep the largest ones.\n    rows.sort(key=lambda x: x[1]-x[0], reverse=True)\n    rows = sorted(rows[:4], key=lambda x: x[0]) # Re-sort by position\n    \n    return rows\"\"\"\n\ndef find_ecg_rows(img_gray):\n    \"\"\"\n    Scans the image vertically to find the 3 main signal rows.\n    Improvement: Filters out Headers/Footers based on HEIGHT.\n    \"\"\"\n    # 1. Invert image (Ink = 1, Paper = 0)\n    if np.mean(img_gray) > 127:\n        inv = 255 - img_gray\n    else:\n        inv = img_gray \n        \n    # 2. Horizontal Projection\n    projection = np.sum(inv, axis=1)\n    \n    # 3. Threshold to find \"Active\" rows\n    # We lower the sensitivity slightly to ensure we catch the full height of signals\n    kernel_size = 9 # Increased smoothing\n    smooth_proj = np.convolve(projection, np.ones(kernel_size)/kernel_size, mode='same')\n    \n    # Threshold: Row must have at least 15% of the max ink density to count\n    mask = smooth_proj > (np.max(smooth_proj) * 0.15)\n    \n    # 4. Identify distinct blocks\n    rows = []\n    start = None\n    \n    # STRICTER MIN HEIGHT:\n    # A real signal row is usually ~25-33% of the image.\n    # A header is usually < 10%.\n    # We set cutoff at 12% to effectively kill headers.\n    min_height = img_gray.shape[0] * 0.12 \n    \n    for y, is_active in enumerate(mask):\n        if is_active and start is None:\n            start = y\n        elif not is_active and start is not None:\n            # End of a block\n            if (y - start) > min_height:\n                rows.append((start, y))\n            start = None\n            \n    if start is not None and (len(mask) - start) > min_height:\n        rows.append((start, len(mask)))\n        \n    # 5. INTELLIGENT SORTING (The Fix)\n    # First, sort by HEIGHT (Tallest blocks first).\n    # This ensures the 3 big signal rows are at the top of the list, \n    # and any remaining small junk (headers/footers) is at the end.\n    rows.sort(key=lambda x: x[1]-x[0], reverse=True)\n    \n    # Keep only the top 3 (The standard 12-lead layout has 3 main rows)\n    rows = rows[:3]\n    \n    # Finally, sort by Y-POSITION (Top to Bottom) \n    # so we correctly map them to Row 0, Row 1, Row 2\n    rows.sort(key=lambda x: x[0])\n    \n    return rows\n  \n  \n\n# 3. DATASET CLASS (Integrated)\n# Map Lead Name -> (Row Index, Column Index)\n# Row 0=Top, Row 2=Bottom. Column 0=Left, Column 3=Right.\nLEAD_GRID = {\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\nclass TwelveLeadDataset(Dataset):\n    def __init__(self, root_dir, img_size=(256, 512), split='train'):\n        self.root_dir = root_dir\n        self.img_size = img_size\n        self.meta_df = pd.read_csv(os.path.join(root_dir, 'train.csv'))\n        \n        split_idx = int(len(self.meta_df) * 0.9)\n        if split == 'train':\n            self.meta_df = self.meta_df.iloc[:split_idx]\n        else:\n            self.meta_df = self.meta_df.iloc[split_idx:]\n            \n        self.ids = self.meta_df['id'].values\n        self.leads = list(LEAD_GRID.keys()) # Use keys from new grid\n\n    def __len__(self):\n        return len(self.ids)\n\n    def __getitem__(self, idx):\n        sample_id = str(self.ids[idx])\n        patient_folder = os.path.join(self.root_dir, 'train', sample_id)\n        \n        # 1. Load Image\n        available_images = [f for f in os.listdir(patient_folder) if f.endswith('.png')]\n        if not available_images: \n            return torch.zeros(1, *self.img_size), torch.zeros(1, *self.img_size)\n        \n        img_path = os.path.join(patient_folder, random.choice(available_images))\n        \n        # 2. Robust Crop (Remove Glare/Bezel)\n        cropped_color = crop_screen_v2(img_path)\n        if cropped_color is None: cropped_color = cv2.imread(img_path)\n        full_image = cv2.cvtColor(cropped_color, cv2.COLOR_BGR2GRAY)\n        h_orig, w_orig = full_image.shape\n        img_clean= remove_grid_color_based(cropped_color)\n        # 3. DYNAMIC ROW DETECTION (The New Part)\n        # We find the actual ink rows instead of guessing percentages\n        detected_rows = find_ecg_rows(img_clean)\n        \n        # Fallback if detection fails (e.g. image too clean or weird)\n        if len(detected_rows) < 3:\n            row_h = h_orig // 3\n            detected_rows = [(0, row_h), (row_h, 2*row_h), (2*row_h, h_orig)]\n            \n        # 4. Select Target Lead\n        target_lead = random.choice(self.leads)\n        \n        # --- GET ROW INDEX & X COORDINATES ---\n        row_idx, col_idx = LEAD_GRID[target_lead]\n        \n        # Safety check: if we only found 3 rows but want row 4 (unlikely), clamp it\n        if row_idx >= len(detected_rows): row_idx = len(detected_rows) - 1\n        \n        # Get Y-Coordinates from Dynamic Detection\n        y1, y2 = detected_rows[row_idx]\n        \n        # Get X-Coordinates from Column Math (4 Columns standard)\n        col_width = w_orig // 4\n        x1 = col_idx * col_width\n        x2 = (col_idx + 1) * col_width\n        # -------------------------------------\n        \n        # 5. Crop the Lead\n        lead_crop = full_image[y1:y2, x1:x2]\n        \n        # 6. Preprocessing (Thickening & Polarity)\n        if lead_crop.size == 0: lead_crop = np.zeros(self.img_size, dtype=np.uint8)\n        \n        # Polarity Check (Ensure Dark Background)\n        if np.mean(lead_crop) > 127: # Light background\n             lead_crop = 255 - lead_crop\n             \n        # Dilation (Thicken lines before resizing)\n        kernel = np.ones((3,3), np.uint8)\n        lead_crop = cv2.dilate(lead_crop, kernel, iterations=1)\n        \n        # Resize & Normalize\n        lead_crop = cv2.resize(lead_crop, (self.img_size[1], self.img_size[0]))\n        lead_crop = lead_crop.astype(np.float32) / 255.0\n        \n        # Contrast Boost\n        lead_crop = np.clip((lead_crop - 0.2) * 3.0, 0, 1)\n        \n        # 7. Load Ground Truth Signal\n        csv_path = os.path.join(patient_folder, f\"{sample_id}.csv\")\n        try:\n            df = pd.read_csv(csv_path)\n            signal = df[target_lead].fillna(0).values\n            fs = 500\n            seg_len = int(2.5 * fs)\n            \n            # Map Column Index to Time Segment\n            # Col 0 = 0.0s, Col 1 = 2.5s, Col 2 = 5.0s, Col 3 = 7.5s\n            start = col_idx * seg_len\n            \n            if len(signal) > start + seg_len:\n                sig_seg = signal[start : start+seg_len]\n            else:\n                sig_seg = signal[0:seg_len]\n        except:\n            sig_seg = np.zeros(1250)\n            \n        # 8. Generate Mask (Standard)\n        mask = np.zeros(self.img_size, dtype=np.float32)\n        th, tw = self.img_size\n        x_steps = np.linspace(0, len(sig_seg)-1, tw)\n        resampled = np.interp(x_steps, np.arange(len(sig_seg)), sig_seg)\n        # Normalize voltage (-2.5 to 2.5 mV) to pixel height\n        s_min, s_max = -2.5, 2.5\n        norm_y = (resampled - s_min) / (s_max - s_min) * th\n        norm_y = np.clip(norm_y, 0, th-1).astype(int)\n        \n        for x in range(tw-1):\n            cv2.line(mask, (x, th - norm_y[x]), (x+1, th - norm_y[x+1]), 1.0, 2)\n            \n        return torch.tensor(lead_crop).unsqueeze(0).float(), torch.tensor(mask).unsqueeze(0).float()\n\n# ==========================================\n# 4. MODEL ARCHITECTURE\n# ==========================================\nclass SimpleUNet(nn.Module):\n    def __init__(self):\n        super(SimpleUNet, self).__init__()\n        def cb(in_c, out_c):\n            return nn.Sequential(nn.Conv2d(in_c, out_c, 3, 1, 1), nn.ReLU(), nn.Conv2d(out_c, out_c, 3, 1, 1), nn.ReLU())\n        self.enc1 = cb(1, 32); self.pool = nn.MaxPool2d(2)\n        self.enc2 = cb(32, 64)\n        self.bottleneck = cb(64, 128)\n        self.up2 = nn.ConvTranspose2d(128, 64, 2, 2); self.dec2 = cb(128, 64)\n        self.up1 = nn.ConvTranspose2d(64, 32, 2, 2); self.dec1 = cb(64, 32)\n        self.final = nn.Conv2d(32, 1, 1)\n    def forward(self, x):\n        e1 = self.enc1(x); p1 = self.pool(e1)\n        e2 = self.enc2(p1); p2 = self.pool(e2)\n        b = self.bottleneck(p2)\n        u2 = self.up2(b); d2 = self.dec2(torch.cat([u2, e2], 1))\n        u1 = self.up1(d2); d1 = self.dec1(torch.cat([u1, e1], 1))\n        return self.final(d1)\n\n\n\n\n        \n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:32.345413Z","iopub.execute_input":"2026-01-16T18:23:32.345788Z","iopub.status.idle":"2026-01-16T18:23:32.392135Z","shell.execute_reply.started":"2026-01-16T18:23:32.345765Z","shell.execute_reply":"2026-01-16T18:23:32.391164Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 1. Re-initialize the Model Architecture\n# (You must run the SimpleUNet class definition cell first!)\nloaded_model = SimpleUNet().to(DEVICE)\n\n# 2. Load the Weights\nmodel_path = \"/kaggle/input/weights/ecg_unet_model.pth\" # Make sure this matches where you uploaded it\n\nif os.path.exists(model_path):\n    # Load weights\n    state_dict = torch.load(model_path, map_location=DEVICE)\n    loaded_model.load_state_dict(state_dict)\n    \n    # 3. Set to Evaluation Mode\n    # CRITICAL: If you forget this, the model behaves weirdly (dropout/batchnorm layers won't work right)\n    loaded_model.eval()\n    \n    print(\"✅ Model loaded and ready for inference!\")\n    \n    # Now you can use 'loaded_model' instead of 'model' in your extraction function\n    # df = extract_full_12_leads(loaded_model, \"my_image.jpg\")\nelse:\n    print(f\"❌ Error: Model file '{model_path}' not found.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:32.393272Z","iopub.execute_input":"2026-01-16T18:23:32.393691Z","iopub.status.idle":"2026-01-16T18:23:32.451694Z","shell.execute_reply.started":"2026-01-16T18:23:32.393636Z","shell.execute_reply":"2026-01-16T18:23:32.450726Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def remove_grid_color_based(image_path):\n    # 1. Load Image\n    if isinstance(image_path, str):\n        img = cv2.imread(image_path)\n    else:\n        img = image_path # Allow passing image object directly\n        \n    if img is None: return None\n\n    # 2. Convert to HSV (Hue, Saturation, Value)\n    hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)\n    \n    # 3. Define the \"Grid Mask\" using Saturation\n    # Grid lines are usually colored (Red/Pink/Orange).\n    # Signal (Black) and Paper (White) are NOT colored (Low Saturation).\n    # We set a threshold: Any pixel with Saturation > 20 is considered \"Grid\".\n    # (You can tweak 20 up to 50 if it's erasing parts of the signal)\n    sensitivity = 25\n    lower_grid = np.array([0, sensitivity, 0])      # Any Hue, High Saturation\n    upper_grid = np.array([179, 255, 255])\n    \n    # Create a mask of the grid\n    grid_mask = cv2.inRange(hsv, lower_grid, upper_grid)\n    \n    # 4. Remove the Grid\n    # Wherever the mask is white (Grid), set the original image to White (255,255,255)\n    img[grid_mask > 0] = (255, 255, 255)\n    \n    # 5. Convert to Grayscale for further processing\n    img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n    \n    # 6. Binarize (Make it strictly Black and White) to kill faint residual noise\n    # Otsu's thresholding automatically finds the best separation\n    _, binary = cv2.threshold(img_gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)\n    \n    return binary\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:32.452639Z","iopub.execute_input":"2026-01-16T18:23:32.452977Z","iopub.status.idle":"2026-01-16T18:23:32.461533Z","shell.execute_reply.started":"2026-01-16T18:23:32.452948Z","shell.execute_reply":"2026-01-16T18:23:32.460522Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# --- VISUAL DEBUGGER FOR ROWS ---\nimage_path = \"/kaggle/working/grid_removal_comparison/05_adaptive_threshold.png\" # Update this to your filename\n\nif os.path.exists(image_path):\n    # Load\n    c = crop_screen_v2(image_path)\n    if c is None: c = cv2.imread(image_path)\n    g = cv2.cvtColor(c, cv2.COLOR_BGR2GRAY)\n    \n    # Run the NEW function\n    rows = find_ecg_rows(g)\n    \n    # Draw\n    debug = c.copy()\n    h, w = c.shape[:2]\n    \n    print(f\"Found {len(rows)} rows.\")\n    for i, (y1, y2) in enumerate(rows):\n        print(f\"Row {i}: Y={y1} to {y2} (Height: {y2-y1})\")\n        # Draw Red Box around the detected row\n        cv2.rectangle(debug, (0, y1), (w, y2), (0, 0, 255), 5)\n        # Label it\n        cv2.putText(debug, f\"Row {i}\", (50, y1 + 50), cv2.FONT_HERSHEY_SIMPLEX, 1.5, (0,0,255), 3)\n\n    plt.figure(figsize=(10, 10))\n    plt.title(\"Detected Rows (Red Boxes MUST cover signals, not header)\")\n    plt.imshow(cv2.cvtColor(debug, cv2.COLOR_BGR2RGB))\n    plt.show()\nelse:\n    print(\"Image not found.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:32.462489Z","iopub.execute_input":"2026-01-16T18:23:32.462769Z","iopub.status.idle":"2026-01-16T18:23:33.287757Z","shell.execute_reply.started":"2026-01-16T18:23:32.462749Z","shell.execute_reply":"2026-01-16T18:23:33.286733Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 5. TRAINING & INFERENCE FUNCTIONS\ndef train_model():\n    print(\"Initializing Dataset...\")\n    ds = TwelveLeadDataset(KAGGLE_DIR, split='train')\n    loader = DataLoader(ds, batch_size=16, shuffle=True)\n    \n    model = SimpleUNet().to(DEVICE)\n    optimizer = optim.Adam(model.parameters(), lr=1e-3)\n    criterion = nn.BCEWithLogitsLoss()\n    \n    print(\"Starting Training (This may take time)...\")\n    model.train()\n    for epoch in range(2): \n        total_loss = 0\n        for i, (img, mask) in enumerate(loader):\n            img, mask = img.to(DEVICE), mask.to(DEVICE)\n            optimizer.zero_grad()\n            out = model(img)\n            loss = criterion(out, mask)\n            loss.backward()\n            optimizer.step()\n            total_loss += loss.item()\n            \n            if i % 50 == 0: print(f\"Epoch {epoch} Step {i} Loss: {loss.item():.4f}\")\n            \n    return model","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:33.288616Z","iopub.execute_input":"2026-01-16T18:23:33.288893Z","iopub.status.idle":"2026-01-16T18:23:33.297361Z","shell.execute_reply.started":"2026-01-16T18:23:33.288867Z","shell.execute_reply":"2026-01-16T18:23:33.296318Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def ensure_dark_background(image):\n    \"\"\"\n    Ensures the image has a dark background and light signal.\n    1. Calculates mean pixel intensity.\n    2. If mean > 0.5 (Light background like paper), it INVERTS.\n    3. If mean < 0.5 (Dark background like screen), it LEAVES IT ALONE.\n    \"\"\"\n    if np.mean(image) > 0.5:\n        return 1.0 - image # Invert to make background black\n    return image # Already black background","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:33.298449Z","iopub.execute_input":"2026-01-16T18:23:33.298781Z","iopub.status.idle":"2026-01-16T18:23:33.316693Z","shell.execute_reply.started":"2026-01-16T18:23:33.298751Z","shell.execute_reply":"2026-01-16T18:23:33.315583Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\"\"\"def extract_full_12_leads(model, image_path):\n    model.eval()\n    \n    # 1.(Glare Removal)\n    cropped_color = crop_screen_v2(image_path)\n    if cropped_color is None: cropped_color = cv2.imread(image_path)\n    img_gray = cv2.cvtColor(cropped_color, cv2.COLOR_BGR2GRAY)\n    h, w = img_gray.shape\n    \n    # 2. FIND ROWS DYNAMICALLY\n    detected_rows = find_ecg_rows(img_gray)\n    \n    if len(detected_rows) < 3:\n        print(\"Warning: Less than 3 rows detected. Header cropping might fail.\")\n        # Fallback: Use the whole image height divided by 3 if detection fails\n        row_h = h // 3\n        detected_rows = [(0, row_h), (row_h, 2*row_h), (2*row_h, h)]\n    \n    results = {}\n    \n    # 3. Iterate through leads using Grid Layout\n    for lead_name, (r_idx, c_idx) in LEAD_GRID.items():\n        if r_idx >= len(detected_rows): continue\n        \n        # Get Y-coords from dynamic detection\n        y1, y2 = detected_rows[r_idx]\n        \n        # Get X-coords (Assume 4 standard columns)\n        col_width = w // 4\n        x1 = c_idx * col_width\n        x2 = (c_idx + 1) * col_width\n        \n        # Crop\n        lead_crop = img_gray[y1:y2, x1:x2]\n        \n        # --- PREPROCESSING (Thickening & Polarity) ---\n        inp_h, inp_w = 256, 512\n        if lead_crop.size == 0: \n            results[lead_name] = np.zeros(inp_w)\n            continue\n            \n        # Resize to fixed input size\n        if np.mean(lead_crop) > 127: # If light background\n            lead_crop = 255 - lead_crop # Invert to Dark Mode\n            \n        # Thicken Lines\n        kernel = np.ones((3,3), np.uint8)\n        lead_crop = cv2.dilate(lead_crop, kernel, iterations=1)\n        \n        lead_resized = cv2.resize(lead_crop, (inp_w, inp_h))\n        lead_norm = lead_resized.astype(np.float32) / 255.0\n        \n        # Boost Contrast (Clip grey noise)\n        lead_norm = np.clip((lead_norm - 0.2) * 3.0, 0, 1)\n        \n        # To Tensor\n        lead_tensor = torch.tensor(lead_norm).float().unsqueeze(0).unsqueeze(0).to(DEVICE)\n        \n        # --- PREDICT ---\n        with torch.no_grad():\n            pred = torch.sigmoid(model(lead_tensor)).cpu().numpy()[0,0]\n            \n        # --- SIGNAL EXTRACTION ---\n        signal = []\n        for col in range(inp_w):\n            col_data = pred[:, col]\n            if np.max(col_data) > 0.1: # Low threshold\n                y_pixel = np.argmax(col_data)\n                volts = ((inp_h - y_pixel) / inp_h * 5.0) - 2.5\n            else:\n                volts = signal[-1] if len(signal) > 0 else 0.0\n            signal.append(volts)\n            \n        results[lead_name] = signal\n        \n    return pd.DataFrame(results)\"\"\"","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:33.317775Z","iopub.execute_input":"2026-01-16T18:23:33.318046Z","iopub.status.idle":"2026-01-16T18:23:33.339635Z","shell.execute_reply.started":"2026-01-16T18:23:33.318025Z","shell.execute_reply":"2026-01-16T18:23:33.338642Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def extract_full_12_leads(model, image_path):\n    model.eval()\n    \n    if not os.path.exists(image_path):\n        print(f\"Error: File {image_path} not found.\")\n        return pd.DataFrame()\n        \n    cropped_color = crop_screen_v2(image_path)\n    if cropped_color is None: \n        print(\"Robust crop failed, using original.\")\n        cropped_color = cv2.imread(image_path)\n        \n    \n    # 2. Dynamic Row Detection\n    detected_rows = find_ecg_rows(cropped_color)\n    img_gray = cv2.cvtColor(cropped_color, cv2.COLOR_BGR2GRAY)\n    if len(detected_rows) < 3:\n        row_h = h // 3\n        detected_rows = [(0, row_h), (row_h, 2*row_h), (2*row_h, h)]\n    \n    results = {}\n    \n    # Debug Plot to show what we cropped\n    debug_plots = []\n\n    for lead_name, (r_idx, c_idx) in LEAD_GRID.items():\n        if r_idx >= len(detected_rows): continue\n        \n        # Coordinates\n        y1, y2 = detected_rows[r_idx]\n        col_width = w // 4\n        x1, x2 = c_idx * col_width, (c_idx + 1) * col_width\n        \n        # Add slight padding to capture lines on the edge\n        lead_crop = img_gray[y1:y2, x1:x2]\n        \n        inp_h, inp_w = 256, 512\n        if lead_crop.size == 0: \n            results[lead_name] = np.zeros(inp_w)\n            continue\n\n        # --- ADAPTIVE PREPROCESSING ---\n        lead_resized = cv2.resize(lead_crop, (inp_w, inp_h))\n        lead_norm = lead_resized.astype(np.float32) / 255.0\n        \n        # Polarity Check\n        if np.mean(lead_norm) > 0.5: \n            lead_norm = 1.0 - lead_norm\n            \n        # --- FIX: DYNAMIC THRESHOLDING ---\n        # Instead of subtracting 0.1 blindly, we find the 95th percentile brightness.\n        # This estimates how bright the \"signal\" is in this specific crop.\n        signal_brightness = np.percentile(lead_norm, 98)\n        background_noise = np.median(lead_norm)\n        \n        # We set the \"floor\" just above the background noise\n        floor = background_noise + 0.02\n        \n        # Apply the dynamic clip\n        lead_norm = np.clip((lead_norm - floor) * 5.0, 0, 1)\n        \n        # Store for debug visualization\n        if len(debug_plots) < 4: debug_plots.append((lead_name, lead_norm))\n\n        # --- ATTEMPT 1: AI MODEL ---\n        lead_tensor = torch.tensor(lead_norm).float().unsqueeze(0).unsqueeze(0).to(DEVICE)\n        with torch.no_grad():\n            pred = torch.sigmoid(model(lead_tensor)).cpu().numpy()[0,0]\n        \n        ai_signal = []\n        for col in range(inp_w):\n            col_data = pred[:, col]\n            if np.max(col_data) > 0.1: \n                #y_pixel = np.argmax(col_data)\n                ys = np.arange(inp_h)\n                weights = col_data\n\n                if weights.sum() > 0:\n                         y_pixel = int(np.sum(ys * weights) / np.sum(weights))\n                else:\n                        y_pixel = prev_y\n                volts = ((inp_h - y_pixel) / inp_h * 5.0) - 2.5\n            else:\n                volts = ai_signal[-1] if len(ai_signal) > 0 else 0.0\n            ai_signal.append(volts)\n            \n        # --- FAIL-SAFE CHECK ---\n        if np.std(ai_signal) < 0.05:\n            use_backup = True\n        else:\n            use_backup = False\n            \n        # --- ATTEMPT 2: MATH BACKUP ---\n        if use_backup:\n            math_signal = []\n            for col in range(inp_w):\n                col_data = lead_norm[:, col]\n                \n                # LOWERED THRESHOLD: We accept faint signals now\n                # Because we already did adaptive clipping, any non-zero pixel is likely signal\n                if np.max(col_data) > 0.05: \n                    ys = np.arange(inp_h)\n                    weights = col_data\n\n                    if weights.sum() > 0:\n                         y_pixel = int(np.sum(ys * weights) / np.sum(weights))\n                    else:\n                        y_pixel = prev_y\n                    volts = ((inp_h - y_pixel) / inp_h * 5.0) - 2.5\n                else:\n                    volts = math_signal[-1] if len(math_signal) > 0 else 0.0\n                math_signal.append(volts)\n            \n            # Simple Smoothing (Moving Average) to reduce \"Jagged\" noise\n            smoothed_signal = np.convolve(math_signal, np.ones(5)/5, mode='same')\n            results[lead_name] = smoothed_signal\n        else:\n            results[lead_name] = ai_signal\n            \n    # Visualize the crops to verify we are looking at the right place\n    plt.figure(figsize=(15, 3))\n    for i, (name, img) in enumerate(debug_plots):\n        plt.subplot(1, 4, i+1)\n        plt.title(f\"Crop: {name}\")\n        plt.imshow(img, cmap='gray')\n    plt.show()\n\n    return pd.DataFrame(results)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:33.340747Z","iopub.execute_input":"2026-01-16T18:23:33.341043Z","iopub.status.idle":"2026-01-16T18:23:33.365794Z","shell.execute_reply.started":"2026-01-16T18:23:33.341016Z","shell.execute_reply":"2026-01-16T18:23:33.364768Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def extract_full_12_leads(model, image_path):\n    model.eval()\n    \n    # 1. Load & Robust Crop\n    if not os.path.exists(image_path):\n        print(f\"Error: File {image_path} not found.\")\n        return pd.DataFrame()\n        \n    cropped_color = crop_screen_v2(image_path)\n    if cropped_color is None: \n        print(\"Robust crop failed, using original.\")\n        cropped_color = cv2.imread(image_path)\n    \n    # Define Dimensions immediately for failsafe\n    h, w = cropped_color.shape[:2]\n\n    # 2. Dynamic Row Detection (Pass COLOR image for V6 logic)\n    try:\n        detected_rows = find_ecg_rows(cropped_color)\n    except Exception as e:\n        print(f\"Row detection error: {e}\")\n        detected_rows = []\n\n    # FAILSAFE: If detection returned < 3 rows, force absolute fallback\n    if len(detected_rows) < 3:\n        print(\"Using geometric fallback for rows.\")\n        row_h = h // 3\n        detected_rows = [(0, row_h), (row_h, 2*row_h), (2*row_h, h)]\n    \n    # 3. Convert to Grayscale for Signal Extraction\n    img_gray = cv2.cvtColor(cropped_color, cv2.COLOR_BGR2GRAY)\n    \n    results = {}\n    debug_plots = [] \n    \n    for lead_name, (r_idx, c_idx) in LEAD_GRID.items():\n        if r_idx >= len(detected_rows): continue\n        \n        # Coordinates\n        y1, y2 = detected_rows[r_idx]\n        col_width = w // 4\n        x1, x2 = c_idx * col_width, (c_idx + 1) * col_width\n        \n        # Crop Lead\n        lead_crop = img_gray[y1:y2, x1:x2]\n        \n        inp_h, inp_w = 256, 512\n        if lead_crop.size == 0: \n            results[lead_name] = np.zeros(inp_w)\n            continue\n\n        # --- ADAPTIVE PREPROCESSING ---\n        lead_resized = cv2.resize(lead_crop, (inp_w, inp_h))\n        lead_norm = lead_resized.astype(np.float32) / 255.0\n        \n        # Polarity Check\n        if np.mean(lead_norm) > 0.5: \n            lead_norm = 1.0 - lead_norm\n            \n        # Dynamic Thresholding (Adaptive Floor)\n        background_noise = np.median(lead_norm)\n        floor = background_noise + 0.02\n        lead_norm = np.clip((lead_norm - floor) * 5.0, 0, 1)\n        \n        # Save for debug plot\n        if len(debug_plots) < 4: debug_plots.append((lead_name, lead_norm))\n\n        # --- ATTEMPT 1: AI MODEL ---\n        # Create 1-Channel Tensor [1, 1, 256, 512]\n        lead_tensor = torch.tensor(lead_norm).float().unsqueeze(0).unsqueeze(0)\n        \n        # --- FIX: REPEAT CHANNELS (1 -> 3) ---\n        # Expand to [1, 3, 256, 512] to satisfy the ResNet encoder\n        lead_tensor = lead_tensor.repeat(1, 3, 1, 1).to(DEVICE)\n        # -------------------------------------\n        \n        with torch.no_grad():\n            pred = torch.sigmoid(model(lead_tensor)).cpu().numpy()[0,0]\n        \n        ai_signal = []\n        for col in range(inp_w):\n            col_data = pred[:, col]\n            if np.max(col_data) > 0.1: \n                y_pixel = np.argmax(col_data)\n                volts = ((inp_h - y_pixel) / inp_h * 5.0) - 2.5\n            else:\n                volts = ai_signal[-1] if len(ai_signal) > 0 else 0.0\n            ai_signal.append(volts)\n            \n        # --- FAIL-SAFE CHECK ---\n        if np.std(ai_signal) < 0.05:\n            use_backup = True\n        else:\n            use_backup = False\n            \n        # --- ATTEMPT 2: MATH BACKUP ---\n        if use_backup:\n            math_signal = []\n            for col in range(inp_w):\n                col_data = lead_norm[:, col]\n                if np.max(col_data) > 0.05: \n                    y_pixel = np.argmax(col_data)\n                    volts = ((inp_h - y_pixel) / inp_h * 5.0) - 2.5\n                else:\n                    volts = math_signal[-1] if len(math_signal) > 0 else 0.0\n                math_signal.append(volts)\n            \n            # Smooth\n            smoothed_signal = np.convolve(math_signal, np.ones(5)/5, mode='same')\n            results[lead_name] = smoothed_signal\n        else:\n            results[lead_name] = ai_signal\n            \n    # Visualize Crops\n    plt.figure(figsize=(15, 3))\n    for i, (name, img) in enumerate(debug_plots):\n        plt.subplot(1, 4, i+1)\n        plt.title(f\"Crop: {name}\")\n        plt.imshow(img, cmap='gray')\n    plt.show()\n\n    # Safety: Return empty dataframe instead of None\n    if not results:\n        return pd.DataFrame()\n\n    return pd.DataFrame(results)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:33.367384Z","iopub.execute_input":"2026-01-16T18:23:33.367745Z","iopub.status.idle":"2026-01-16T18:23:33.390052Z","shell.execute_reply.started":"2026-01-16T18:23:33.367715Z","shell.execute_reply":"2026-01-16T18:23:33.389054Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# model = train_model()\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:33.391231Z","iopub.execute_input":"2026-01-16T18:23:33.391556Z","iopub.status.idle":"2026-01-16T18:23:33.410398Z","shell.execute_reply.started":"2026-01-16T18:23:33.391531Z","shell.execute_reply":"2026-01-16T18:23:33.409082Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 1. Define the path (saves to the current working directory)\nmodel_save_path = \"ecg_unet_model.pth\"\n\n# 2. Save only the weights (State Dictionary) - This is the standard best practice\ntorch.save(model.state_dict(), model_save_path)\n\nprint(f\"✅ Model saved successfully to: {model_save_path}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:33.411515Z","iopub.execute_input":"2026-01-16T18:23:33.411875Z","iopub.status.idle":"2026-01-16T18:23:33.637380Z","shell.execute_reply.started":"2026-01-16T18:23:33.411845Z","shell.execute_reply":"2026-01-16T18:23:33.636200Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def find_ecg_rows(img_input):\n    \"\"\"\n    V10 BLOCK-DROPPER:\n    1. Smears the image horizontally to turn waves/text into solid blocks.\n    2. Finds all blocks.\n    3. INTELLIGENTLY REMOVES THE HEADER based on position and density.\n    4. Returns exactly 3 Signal Rows.\n    \"\"\"\n    # 1. Standardize to Grayscale\n    if len(img_input.shape) == 3:\n        gray = cv2.cvtColor(img_input, cv2.COLOR_BGR2GRAY)\n    else:\n        gray = img_input\n        \n    h, w = gray.shape\n    \n    # 2. Invert (Ink = White, Background = Black)\n    # Your image is Clean (White BG), so we invert it to find Ink.\n    inverted = 255 - gray\n    \n    # 3. \"Smear\" the ink horizontally\n    # We use a wide kernel (100x1) to connect the letters of the header \n    # and the waves of the ECG into single solid horizontal bars.\n    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (100, 1))\n    smeared = cv2.morphologyEx(inverted, cv2.MORPH_CLOSE, kernel)\n    _, binary = cv2.threshold(smeared, 127, 255, cv2.THRESH_BINARY)\n    \n    # 4. Find Active Zones (Projection)\n    projection = np.sum(binary, axis=1)\n    \n    # Find all continuous blocks where projection > 0\n    blocks = []\n    start = None\n    for y, val in enumerate(projection):\n        if val > 0 and start is None:\n            start = y\n        elif val == 0 and start is not None:\n            blocks.append((start, y))\n            start = None\n    if start is not None: blocks.append((start, h))\n    \n    # 5. Filter Noise\n    # A valid row must be at least 5% of the image height\n    valid_blocks = []\n    min_h = h * 0.05\n    for y1, y2 in blocks:\n        if (y2 - y1) > min_h:\n            valid_blocks.append((y1, y2))\n            \n    # 6. THE HEADER KILLER LOGIC\n    # We expect roughly 4 blocks: [Header, Row 1, Row 2, Row 3] (maybe + Rhythm strip)\n    \n    if not valid_blocks:\n        # Fallback if smearing failed\n        return []\n        \n    # If the top block starts very high (top 5% of image), it's likely the header.\n    # AND if we have more than 3 blocks, we can safely drop the first one.\n    if len(valid_blocks) >= 4:\n        print(f\"Detected {len(valid_blocks)} blocks. Dropping the top one (Header).\")\n        valid_blocks = valid_blocks[1:] # Drop index 0\n    elif len(valid_blocks) == 3:\n        # Check if the first block looks like a header (very short compared to others)\n        h0 = valid_blocks[0][1] - valid_blocks[0][0]\n        h1 = valid_blocks[1][1] - valid_blocks[1][0]\n        if h0 < (h1 * 0.6): # If top block is much smaller than 2nd block\n             print(\"Top block is small; treating as Header and dropping.\")\n             valid_blocks = valid_blocks[1:]\n             \n    # Take the next 3 blocks as our signals\n    final_rows = valid_blocks[:3]\n    \n    return final_rows","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:33.638345Z","iopub.execute_input":"2026-01-16T18:23:33.638610Z","iopub.status.idle":"2026-01-16T18:23:33.650640Z","shell.execute_reply.started":"2026-01-16T18:23:33.638591Z","shell.execute_reply":"2026-01-16T18:23:33.649560Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def extract_full_12_leads(model, image_path):\n    \"\"\"\n    V13: LARGEST COMPONENT EXTRACTION (The 'Text Killer').\n    1. Finds rows using projection scanning.\n    2. Crops the rows.\n    3. CRITICAL: Keeps only the 'Largest Connected Component' in the crop.\n       (Since text is disconnected letters, it gets deleted. Signal remains.)\n    \"\"\"\n    # 1. Load Image\n    if not os.path.exists(image_path): return pd.DataFrame()\n    img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)\n    if img is None: return pd.DataFrame()\n    \n    h, w = img.shape\n    \n    # 2. Invert (Ink = 1, Background = 0)\n    if np.mean(img) > 127:\n        _, binary_full = cv2.threshold(img, 200, 1, cv2.THRESH_BINARY_INV)\n    else:\n        _, binary_full = cv2.threshold(img, 50, 1, cv2.THRESH_BINARY)\n\n    # 3. Row Detection (Projection Scanning)\n    projection = np.sum(binary_full, axis=1)\n    \n    # Find Islands\n    islands = []\n    start = None\n    for y, val in enumerate(projection):\n        if val > 0 and start is None:\n            start = y\n        elif val == 0 and start is not None:\n            islands.append((start, y))\n            start = None\n    if start is not None: islands.append((start, h))\n    \n    # Filter tiny noise (<1% height)\n    valid_islands = [x for x in islands if (x[1]-x[0]) > (h*0.01)]\n    \n    # INTELLIGENT SELECTION:\n    # If we see 4+ blocks, the top one is the Header. Drop it.\n    # If we see 3 blocks, we assume they are the signals.\n    if len(valid_islands) >= 4:\n        # Sort by position (Top to Bottom)\n        valid_islands.sort(key=lambda x: x[0])\n        # Drop the first one (Header)\n        signal_rows = valid_islands[1:4]\n    elif len(valid_islands) == 3:\n        signal_rows = valid_islands\n    else:\n        # Fallback: Just divide page into 3\n        print(\"⚠️ Warning: Rows merged/missing. Using fallback thirds.\")\n        row_h = h // 3\n        signal_rows = [(0, row_h), (row_h, 2*row_h), (2*row_h, h)]\n\n    results = {}\n    debug_plots = []\n    \n    for lead_name, (r_idx, c_idx) in LEAD_GRID.items():\n        if r_idx >= len(signal_rows): continue\n        \n        # Get Crop Coordinates\n        ink_y1, ink_y2 = signal_rows[r_idx]\n        ink_center = ink_y1 + (ink_y2 - ink_y1) // 2\n        \n        # Generous Window (25% of page)\n        crop_h = int(h * 0.25)\n        y1 = max(0, ink_center - crop_h // 2)\n        y2 = min(h, ink_center + crop_h // 2)\n        \n        col_width = w // 4\n        x1, x2 = c_idx * col_width, (c_idx + 1) * col_width\n        \n        # Crop from the BINARY image directly\n        # (This ensures we have clean 0/1 pixels for component analysis)\n        lead_crop = binary_full[y1:y2, x1:x2]\n        \n        # Resize to output size\n        out_h, out_w = 256, 512\n        if lead_crop.size == 0: \n            results[lead_name] = np.zeros(out_w)\n            continue\n            \n        # Resize logic (Binary resize requires special care to preserve lines)\n        # We use INTER_NEAREST or INTER_MAX to keep thin lines\n        lead_resized = cv2.resize(lead_crop.astype(np.uint8), (out_w, out_h), interpolation=cv2.INTER_NEAREST)\n        \n        # --- THE MAGIC FIX: KEEP LARGEST COMPONENT ---\n        # 1. Label all connected blobs (letters, waves, noise)\n        num_labels, labels_im, stats, centroids = cv2.connectedComponentsWithStats(lead_resized)\n        \n        # 2. Find the biggest blob (skipping label 0 which is background)\n        if num_labels > 1:\n            # Stats columns: [left, top, width, height, area]\n            # We want the blob with the largest AREA\n            largest_label = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA]) \n            \n            # Create a mask of ONLY the largest blob\n            clean_signal = np.zeros_like(lead_resized)\n            clean_signal[labels_im == largest_label] = 1 # Keep only the big snake\n        else:\n            clean_signal = lead_resized # Fallback if empty\n            \n        # Store for debug (Check this image!)\n        if len(debug_plots) < 4: debug_plots.append((lead_name, clean_signal * 255))\n        \n        # --- SIMPLE TRACING ---\n        signal = []\n        for col in range(out_w):\n            col_data = clean_signal[:, col]\n            ink_pixels = np.where(col_data > 0)[0]\n            \n            if len(ink_pixels) > 0:\n                y_pixel = np.median(ink_pixels)\n                volts = ((out_h - y_pixel) / out_h * 5.0) - 2.5\n            else:\n                volts = signal[-1] if len(signal) > 0 else 0.0\n            signal.append(volts)\n            \n        # Smooth\n        results[lead_name] = np.convolve(signal, np.ones(3)/3, mode='same')\n\n    # Visualize (Look at \"Crop: I\" - text should be GONE)\n    plt.figure(figsize=(15, 3))\n    for i, (name, crop_img) in enumerate(debug_plots):\n        plt.subplot(1, 4, i+1)\n        plt.title(f\"Crop: {name}\")\n        plt.imshow(crop_img, cmap='gray')\n    plt.show()\n\n    return pd.DataFrame(results)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:33.651822Z","iopub.execute_input":"2026-01-16T18:23:33.652155Z","iopub.status.idle":"2026-01-16T18:23:33.674403Z","shell.execute_reply.started":"2026-01-16T18:23:33.652127Z","shell.execute_reply":"2026-01-16T18:23:33.673429Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from scipy.signal import find_peaks\n\ndef extract_full_12_leads(model, image_path):\n    \"\"\"\n    V17: DILATION + PEAK DETECTION (Robust to Broken Lines).\n    1. Dilates (smears) the image to reconnect broken/dashed signal lines.\n    2. Uses projection peaks to find the exact center of the 3 rows.\n    3. Ignores the header by selecting the 3 'heaviest' ink concentrations.\n    \"\"\"\n    # 1. Load Image\n    if not os.path.exists(image_path): return pd.DataFrame()\n    img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)\n    if img is None: return pd.DataFrame()\n    \n    h, w = img.shape\n    \n    # 2. Invert (Ink = White)\n    if np.mean(img) > 127:\n        binary = 255 - img\n    else:\n        binary = img.copy()\n        \n    # Threshold to clear background noise\n    _, binary = cv2.threshold(binary, 50, 255, cv2.THRESH_BINARY)\n\n    # 3. \"HEAL\" THE BROKEN LINES (The Fix)\n    # We use a horizontal kernel to smear dots into lines.\n    # (50, 1) means we connect any dots within 50 pixels of each other.\n    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (50, 1))\n    healed_image = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)\n    \n    # 4. Projection Scan\n    # Sum the ink in the healed image\n    projection = np.sum(healed_image, axis=1)\n    \n    # Smooth the projection curve to remove jagged noise\n    # (Simple moving average)\n    kernel_size = int(h * 0.02)\n    smooth_proj = np.convolve(projection, np.ones(kernel_size)/kernel_size, mode='same')\n    \n    # 5. Find Peaks (The Rows)\n    # We look for peaks that are at least 15% of the page height apart\n    min_dist = int(h * 0.15)\n    peaks, properties = find_peaks(smooth_proj, distance=min_dist, height=100)\n    \n    # We expect [Header, Row1, Row2, Row3] or just [Row1, Row2, Row3]\n    # To filter the Header, we look at 'peak_heights' (Ink Density).\n    # Signal rows have WAY more ink than text headers.\n    \n    if len(peaks) >= 3:\n        # Get the heights (ink density) of each peak\n        peak_heights = properties['peak_heights']\n        \n        # Zip peaks with their heights\n        scored_peaks = list(zip(peaks, peak_heights))\n        \n        # Sort by Height (Density) Descending -> Biggest 3 are Signals\n        scored_peaks.sort(key=lambda x: x[1], reverse=True)\n        \n        # Take top 3\n        top_3 = scored_peaks[:3]\n        \n        # Sort back by Y-position (Top, Mid, Bot)\n        top_3.sort(key=lambda x: x[0])\n        \n        row_centers = [p[0] for p in top_3]\n        print(f\"✅ Found 3 Signal Rows at Y={row_centers}\")\n    else:\n        print(\"⚠️ Warning: Dilation found < 3 rows. Using geometric fallback.\")\n        # Fallback: Assume header is top 12%, split rest\n        start_y = int(h * 0.12)\n        zone = (h - start_y) // 3\n        row_centers = [start_y + zone//2, start_y + zone + zone//2, start_y + 2*zone + zone//2]\n\n    results = {}\n    debug_plots = []\n    \n    # 6. Extract Leads\n    for lead_name, (r_idx, c_idx) in LEAD_GRID.items():\n        if r_idx >= len(row_centers): continue\n        \n        cy = row_centers[r_idx]\n        \n        # Crop Window: 25% of page height centered on the peak\n        crop_h = int(h * 0.25)\n        y1 = max(0, cy - crop_h // 2)\n        y2 = min(h, cy + crop_h // 2)\n        \n        col_width = w // 4\n        x1, x2 = c_idx * col_width, (c_idx + 1) * col_width\n        \n        # Crop from ORIGINAL (Not Healed) image for sharpness\n        lead_crop = img[y1:y2, x1:x2]\n        \n        # Output Setup\n        out_h, out_w = 256, 512\n        if lead_crop.size == 0: \n            results[lead_name] = np.zeros(out_w)\n            continue\n            \n        lead_resized = cv2.resize(lead_crop, (out_w, out_h))\n        \n        # Binarize\n        _, bin_crop = cv2.threshold(lead_resized, 127, 255, cv2.THRESH_BINARY_INV)\n        \n        # NUCLEAR HEADER KILLER (Just in case)\n        if r_idx == 0:\n            bin_crop[:int(out_h*0.12), :] = 0\n            \n        if len(debug_plots) < 4: debug_plots.append((lead_name, bin_crop))\n        \n        # 7. Signal Tracing (Robust to Gaps)\n        signal = []\n        for col in range(out_w):\n            col_data = bin_crop[:, col]\n            ink_pixels = np.where(col_data > 0)[0]\n            \n            if len(ink_pixels) > 0:\n                y_pixel = np.median(ink_pixels)\n                volts = ((out_h - y_pixel) / out_h * 5.0) - 2.5\n            else:\n                # If gap, check neighbors (simple interpolation)\n                volts = signal[-1] if len(signal) > 0 else 0.0\n            signal.append(volts)\n            \n        results[lead_name] = np.convolve(signal, np.ones(3)/3, mode='same')\n\n    # Visualize\n    plt.figure(figsize=(15, 3))\n    for i, (name, crop_img) in enumerate(debug_plots):\n        plt.subplot(1, 4, i+1); plt.title(f\"Crop: {name}\"); plt.imshow(crop_img, cmap='gray')\n    plt.show()\n\n    return pd.DataFrame(results)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:33.675562Z","iopub.execute_input":"2026-01-16T18:23:33.675923Z","iopub.status.idle":"2026-01-16T18:23:33.699729Z","shell.execute_reply.started":"2026-01-16T18:23:33.675893Z","shell.execute_reply":"2026-01-16T18:23:33.698856Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import cv2\nimport numpy as np\nimport pandas as pd\nimport os\nimport matplotlib.pyplot as plt\nfrom scipy.signal import find_peaks, savgol_filter\n\ndef extract_full_12_leads_improved(model, image_path):\n    \"\"\"\n    V18: ROBUST TRACKING + SAVITZKY-GOLAY SMOOTHING\n    1. Row Detection: (Kept your V17 logic as it works well).\n    2. Extraction: Replaced 'Simple Median' with 'Proximity Walker'.\n    3. Post-Processing: Added Linear Interpolation and SavGol filtering.\n    \"\"\"\n    # --- STAGE 1: ROBUST ROW DETECTION (Your V17 Logic) ---\n    if not os.path.exists(image_path): return pd.DataFrame()\n    img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)\n    if img is None: return pd.DataFrame()\n    \n    h, w = img.shape\n    \n    # Invert and Threshold\n    if np.mean(img) > 127:\n        binary = 255 - img\n    else:\n        binary = img.copy()\n    _, binary = cv2.threshold(binary, 50, 255, cv2.THRESH_BINARY)\n\n    # Heal Broken Lines for Detection\n    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (50, 1))\n    healed_image = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)\n    \n    # Projection & Peaks\n    projection = np.sum(healed_image, axis=1)\n    kernel_size = int(h * 0.02)\n    smooth_proj = np.convolve(projection, np.ones(kernel_size)/kernel_size, mode='same')\n    \n    min_dist = int(h * 0.15)\n    peaks, properties = find_peaks(smooth_proj, distance=min_dist, height=100)\n    \n    # Select Top 3 Rows\n    if len(peaks) >= 3:\n        peak_heights = properties['peak_heights']\n        scored_peaks = list(zip(peaks, peak_heights))\n        scored_peaks.sort(key=lambda x: x[1], reverse=True) # Sort by mass\n        top_3 = scored_peaks[:3]\n        top_3.sort(key=lambda x: x[0]) # Sort by Y position\n        row_centers = [p[0] for p in top_3]\n        print(f\"✅ Found 3 Signal Rows at Y={row_centers}\")\n    else:\n        print(\"⚠️ Warning: Using geometric fallback.\")\n        start_y = int(h * 0.12)\n        zone = (h - start_y) // 3\n        row_centers = [start_y + zone//2, start_y + zone + zone//2, start_y + 2*zone + zone//2]\n\n    # --- STAGE 2: IMPROVED EXTRACTION ENGINE ---\n    results = {}\n    debug_plots = []\n    \n    # Define Standard Lead Grid\n    LEAD_GRID = {\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    for lead_name, (r_idx, c_idx) in LEAD_GRID.items():\n        if r_idx >= len(row_centers): continue\n        \n        cy = row_centers[r_idx]\n        crop_h = int(h * 0.25)\n        y1 = max(0, cy - crop_h // 2)\n        y2 = min(h, cy + crop_h // 2)\n        \n        col_width = w // 4\n        x1, x2 = c_idx * col_width, (c_idx + 1) * col_width\n        \n        # Crop from ORIGINAL image (Sharpness matters)\n        lead_crop = img[y1:y2, x1:x2]\n        out_h, out_w = 256, 512\n        \n        if lead_crop.size == 0: \n            results[lead_name] = np.zeros(out_w)\n            continue\n            \n        # Preprocessing for Tracing\n        lead_resized = cv2.resize(lead_crop, (out_w, out_h))\n        # Use OTSU thresholding for dynamic adaptability\n        _, bin_crop = cv2.threshold(lead_resized, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)\n        \n        # Clean Vertical Artifacts (Grid lines causing spikes)\n        # If a column is >50% ink, it's likely a vertical bar, not signal. Delete it.\n        col_sums = np.sum(bin_crop, axis=0) / 255\n        bad_cols = np.where(col_sums > (out_h * 0.5))[0]\n        bin_crop[:, bad_cols] = 0\n        \n        # Mask Header (Safety)\n        if r_idx == 0:\n            bin_crop[:int(out_h*0.15), :] = 0\n            \n        if len(debug_plots) < 4: debug_plots.append((lead_name, bin_crop))\n        \n        # --- THE \"PROXIMITY WALKER\" TRACER ---\n        raw_signal = []\n        prev_y = None\n        \n        for col in range(out_w):\n            col_data = bin_crop[:, col]\n            ink_pixels = np.where(col_data > 0)[0]\n            \n            val = np.nan # Default to NaN (Gap)\n            \n            if len(ink_pixels) > 0:\n                if prev_y is None:\n                    # First point: Take median\n                    val = np.median(ink_pixels)\n                else:\n                    # Tracking: Find pixel closest to previous Y\n                    # This prevents jumping to noise artifacts far away\n                    closest_idx = np.argmin(np.abs(ink_pixels - prev_y))\n                    val = ink_pixels[closest_idx]\n                \n                prev_y = val # Update tracker\n                \n            raw_signal.append(val)\n            \n        # --- POST-PROCESSING ---\n        # 1. Fill Gaps (Linear Interpolation)\n        s = pd.Series(raw_signal)\n        s_interp = s.interpolate(method='linear', limit_direction='both')\n        final_signal = s_interp.to_numpy()\n        \n        # Handle empty signals (if interpolation failed completely)\n        if np.isnan(final_signal).all():\n            final_signal = np.zeros(out_w) + (out_h/2)\n            \n        # 2. Convert to Volts\n        # Inverted Y: (Height - y) because image origin is top-left\n        volts = ((out_h - final_signal) / out_h * 5.0) - 2.5\n        \n        # 3. Savitzky-Golay Smoothing\n        # Window=11, Poly=3 preserves QRS peaks better than simple moving average\n        try:\n            smooth_volts = savgol_filter(volts, window_length=11, polyorder=3)\n        except:\n            smooth_volts = volts # Fallback if signal too short\n            \n        results[lead_name] = smooth_volts\n\n    # Visualization\n    plt.figure(figsize=(15, 3))\n    for i, (name, crop_img) in enumerate(debug_plots):\n        plt.subplot(1, 4, i+1); plt.title(f\"Crop: {name}\"); plt.imshow(crop_img, cmap='gray')\n    plt.show()\n\n    return pd.DataFrame(results)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:33.700801Z","iopub.execute_input":"2026-01-16T18:23:33.701050Z","iopub.status.idle":"2026-01-16T18:23:33.728188Z","shell.execute_reply.started":"2026-01-16T18:23:33.701031Z","shell.execute_reply":"2026-01-16T18:23:33.727140Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import cv2\nimport numpy as np\nimport pandas as pd\nimport os\nimport matplotlib.pyplot as plt\nfrom scipy.signal import find_peaks, savgol_filter\n\ndef extract_and_display_all_12_leads(image_path):\n    \"\"\"\n    V19: COMPLETE 12-LEAD EXTRACTION & VISUALIZATION\n    1. Detects Rows using your robust 'Healed Line' detection.\n    2. Extracts all 12 leads using the 'Proximity Walker' engine.\n    3. Plots a full 3x4 grid of all signals for verification.\n    \"\"\"\n    # --- STAGE 1: ROBUST ROW DETECTION ---\n    if not os.path.exists(image_path): \n        print(f\"❌ File not found: {image_path}\")\n        return pd.DataFrame()\n        \n    img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)\n    if img is None: return pd.DataFrame()\n    \n    h, w = img.shape\n    \n    # Invert to (Ink=White, BG=Black) if needed\n    if np.mean(img) > 127:\n        binary = 255 - img\n    else:\n        binary = img.copy()\n        \n    # Threshold to remove background noise\n    _, binary = cv2.threshold(binary, 50, 255, cv2.THRESH_BINARY)\n\n    # \"Heal\" Broken Lines for Detection (Smearing)\n    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (50, 1))\n    healed_image = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)\n    \n    # Projection & Peaks\n    projection = np.sum(healed_image, axis=1)\n    kernel_size = int(h * 0.02)\n    smooth_proj = np.convolve(projection, np.ones(kernel_size)/kernel_size, mode='same')\n    \n    min_dist = int(h * 0.15)\n    peaks, properties = find_peaks(smooth_proj, distance=min_dist, height=100)\n    \n    # Select Top 3 Rows (Heaviest Ink)\n    if len(peaks) >= 3:\n        peak_heights = properties['peak_heights']\n        scored_peaks = list(zip(peaks, peak_heights))\n        scored_peaks.sort(key=lambda x: x[1], reverse=True) # Sort by mass\n        top_3 = scored_peaks[:3]\n        top_3.sort(key=lambda x: x[0]) # Sort by Y position\n        row_centers = [p[0] for p in top_3]\n        print(f\"✅ Row Detection: Found 3 Signal Rows at Y={row_centers}\")\n    else:\n        print(\"⚠️ Warning: Auto-detection weak. Using geometric fallback.\")\n        start_y = int(h * 0.12)\n        zone = (h - start_y) // 3\n        row_centers = [start_y + zone//2, start_y + zone + zone//2, start_y + 2*zone + zone//2]\n\n    # --- STAGE 2: EXTRACTION ENGINE (ALL 12 LEADS) ---\n    results = {}\n    \n    # Define Standard 3x4 Grid\n    # Format: LeadName: (RowIndex, ColIndex)\n    LEAD_GRID = {\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    # Output Resolution\n    out_h, out_w = 256, 512\n    \n    for lead_name, (r_idx, c_idx) in LEAD_GRID.items():\n        if r_idx >= len(row_centers): continue\n        \n        # 1. Define Crop Area\n        cy = row_centers[r_idx]\n        crop_h = int(h * 0.25) # 25% height window\n        y1 = max(0, cy - crop_h // 2)\n        y2 = min(h, cy + crop_h // 2)\n        \n        col_width = w // 4\n        x1, x2 = c_idx * col_width, (c_idx + 1) * col_width\n        \n        # 2. Crop from ORIGINAL image (Sharpest details)\n        lead_crop = img[y1:y2, x1:x2]\n        \n        if lead_crop.size == 0: \n            results[lead_name] = np.zeros(out_w)\n            continue\n            \n        # 3. Preprocessing (Otsu Thresholding)\n        lead_resized = cv2.resize(lead_crop, (out_w, out_h))\n        _, bin_crop = cv2.threshold(lead_resized, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)\n        \n        # 4. Clean Vertical Artifacts (Grid Lines)\n        # Delete columns that are >50% ink (vertical bars)\n        col_sums = np.sum(bin_crop, axis=0) / 255\n        bad_cols = np.where(col_sums > (out_h * 0.5))[0]\n        bin_crop[:, bad_cols] = 0\n        \n        # 5. Mask Header Text (Safety for Row 1)\n        if r_idx == 0:\n            bin_crop[:int(out_h*0.15), :] = 0\n            \n        # 6. TRACE: Proximity Walker (The \"Magnet\")\n        raw_signal = []\n        prev_y = None\n        \n        for col in range(out_w):\n            col_data = bin_crop[:, col]\n            ink_pixels = np.where(col_data > 0)[0]\n            \n            val = np.nan # Default to Gap\n            \n            if len(ink_pixels) > 0:\n                if prev_y is None:\n                    val = np.median(ink_pixels) # Start with median\n                else:\n                    # Find pixel closest to previous point (Tracking)\n                    closest_idx = np.argmin(np.abs(ink_pixels - prev_y))\n                    val = ink_pixels[closest_idx]\n                prev_y = val\n                \n            raw_signal.append(val)\n            \n        # 7. Post-Process (Interpolate & Smooth)\n        s = pd.Series(raw_signal)\n        s_interp = s.interpolate(method='linear', limit_direction='both')\n        final_signal = s_interp.to_numpy()\n        \n        # Handle empty signals\n        if np.isnan(final_signal).all():\n            final_signal = np.zeros(out_w) + (out_h/2)\n            \n        # Normalize to Volts (+2.5mV to -2.5mV)\n        volts = ((out_h - final_signal) / out_h * 5.0) - 2.5\n        \n        # Savitzky-Golay Filter (Medical Smoothing)\n        try:\n            smooth_volts = savgol_filter(volts, window_length=11, polyorder=3)\n        except:\n            smooth_volts = volts\n            \n        results[lead_name] = smooth_volts\n\n    # --- STAGE 3: VISUALIZATION (FULL 12-LEAD GRID) ---\n    print(\"📊 Plotting all 12 Leads...\")\n    \n    fig, axes = plt.subplots(3, 4, figsize=(20, 10))\n    fig.suptitle(f\"Extracted ECG Signals: {os.path.basename(image_path)}\", fontsize=16)\n    \n    # Plot in the correct order (Column-major or standard grid)\n    # The LEAD_GRID items are iterated, but we can access axes directly\n    for lead_name, (r, c) in LEAD_GRID.items():\n        if lead_name in results:\n            ax = axes[r, c]\n            ax.plot(results[lead_name], color='blue', linewidth=1)\n            ax.set_title(f\"Lead {lead_name}\", fontsize=10, fontweight='bold')\n            ax.grid(True, alpha=0.3)\n            ax.set_ylim(-2.5, 2.5) # Standard ECG Voltage Range\n            \n            # Hide ticks for cleaner look, except on edges\n            if c > 0: ax.set_yticklabels([])\n            if r < 2: ax.set_xticklabels([])\n            \n    plt.tight_layout(rect=[0, 0.03, 1, 0.95])\n    plt.show()\n\n    return pd.DataFrame(results)\n\n# ==========================================\n# HOW TO RUN\n# ==========================================\n# 1. Update the filename below\nfilename = \"/kaggle/working/grid_removal_comparison/02_color-based_(red).png\" \n\n# 2. Run the function\ndf_leads = extract_and_display_all_12_leads(filename)\n\n# 3. Save to CSV\nif not df_leads.empty:\n    df_leads.to_csv(\"full_12_lead_ecg.csv\", index=False)\n    print(\"💾 Saved full data to 'full_12_lead_ecg.csv'\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:33.729474Z","iopub.execute_input":"2026-01-16T18:23:33.730226Z","iopub.status.idle":"2026-01-16T18:23:35.802608Z","shell.execute_reply.started":"2026-01-16T18:23:33.730190Z","shell.execute_reply":"2026-01-16T18:23:35.801599Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import cv2\nimport numpy as np\nimport pandas as pd\nimport os\nimport matplotlib.pyplot as plt\nfrom scipy.signal import find_peaks, savgol_filter\n\ndef extract_leads_with_finetuning(image_path):\n    \"\"\"\n    V20: ANCHOR & FINE-TUNE\n    1. Finds Global Row Centers (Rough Estimate).\n    2. FINE-TUNES Y-position for *every single lead* individually.\n    3. Solves the drift issue for bottom rows (III, aVF, V6).\n    \"\"\"\n    if not os.path.exists(image_path): return pd.DataFrame()\n    img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)\n    if img is None: return pd.DataFrame()\n    \n    h, w = img.shape\n    \n    # 1. Preprocessing (Invert & Threshold)\n    if np.mean(img) > 127:\n        binary = 255 - img\n    else:\n        binary = img.copy()\n    _, binary = cv2.threshold(binary, 50, 255, cv2.THRESH_BINARY)\n    \n    # 2. Global Row Detection (Rough Scan)\n    # We use your proven \"Healed Line\" method to get the general area\n    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (50, 1))\n    healed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)\n    \n    projection = np.sum(healed, axis=1)\n    smooth_proj = np.convolve(projection, np.ones(int(h*0.02))/int(h*0.02), mode='same')\n    \n    # Find peaks with distance constraint\n    min_dist = int(h * 0.15)\n    peaks, props = find_peaks(smooth_proj, distance=min_dist, height=50)\n    \n    # Sort by Mass (Heaviest = Signal Rows)\n    if len(peaks) >= 3:\n        peak_scores = list(zip(peaks, props['peak_heights']))\n        peak_scores.sort(key=lambda x: x[1], reverse=True)\n        top_3 = sorted(peak_scores[:3], key=lambda x: x[0])\n        global_centers = [p[0] for p in top_3]\n    else:\n        # Fallback: Equal Thirds\n        start_y = int(h * 0.12)\n        step = (h - start_y) // 3\n        global_centers = [start_y + step//2, start_y + step + step//2, start_y + 2*step + step//2]\n\n    # --- EXTRACTION WITH LOCAL FINE-TUNING ---\n    results = {}\n    \n    # Grid Definition\n    LEAD_GRID = {\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    out_h, out_w = 256, 512\n    col_width = w // 4\n    \n    # Debug images container\n    debug_crops = []\n\n    for lead_name, (r_idx, c_idx) in LEAD_GRID.items():\n        # A. Start with Global Estimate\n        estimated_cy = global_centers[r_idx]\n        \n        # B. Define Search Window (X-bounds are fixed, Y is flexible)\n        x1 = c_idx * col_width\n        x2 = (c_idx + 1) * col_width\n        \n        # We grab a TALL strip around the estimated center to find the REAL center\n        search_h = int(h * 0.35) # 35% of page height (generous)\n        search_y1 = max(0, estimated_cy - search_h // 2)\n        search_y2 = min(h, estimated_cy + search_h // 2)\n        \n        strip = binary[search_y1:search_y2, x1:x2]\n        \n        # C. LOCAL FINE-TUNING (The Fix)\n        # Project this specific strip to Y-axis\n        strip_proj = np.sum(strip, axis=1)\n        \n        # Find the center of mass of the ink in this specific strip\n        # This ignores the rows above/below and locks onto THIS lead\n        if np.sum(strip_proj) > 0:\n            local_peak = np.argmax(strip_proj)\n            real_cy = search_y1 + local_peak\n        else:\n            real_cy = estimated_cy # Fallback if empty\n            \n        # D. Precise Crop\n        final_crop_h = int(h * 0.25) # Standard window\n        y1 = max(0, real_cy - final_crop_h // 2)\n        y2 = min(h, real_cy + final_crop_h // 2)\n        \n        lead_crop = img[y1:y2, x1:x2]\n        \n        # --- Standard Extraction Pipeline ---\n        if lead_crop.size == 0:\n            results[lead_name] = np.zeros(out_w)\n            continue\n            \n        # Resize & Binary\n        lead_resized = cv2.resize(lead_crop, (out_w, out_h))\n        _, bin_crop = cv2.threshold(lead_resized, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)\n        \n        # Remove Vertical Artifacts (Grid Lines)\n        col_sums = np.sum(bin_crop, axis=0) / 255\n        bad_cols = np.where(col_sums > (out_h * 0.6))[0]\n        bin_crop[:, bad_cols] = 0\n        \n        # Header Mask (Row 0 only)\n        if r_idx == 0:\n            bin_crop[:int(out_h*0.15), :] = 0\n            \n        # Save crop for debugging (First 4 or specific problematic ones)\n        if lead_name in ['I', 'II', 'III', 'V2', 'V6']:\n            debug_crops.append((lead_name, bin_crop))\n            \n        # Proximity Walker Trace\n        raw_signal = []\n        prev_y = None\n        \n        for col in range(out_w):\n            col_data = bin_crop[:, col]\n            ink_pixels = np.where(col_data > 0)[0]\n            \n            val = np.nan\n            if len(ink_pixels) > 0:\n                if prev_y is None:\n                    val = np.median(ink_pixels)\n                else:\n                    closest_idx = np.argmin(np.abs(ink_pixels - prev_y))\n                    val = ink_pixels[closest_idx]\n                prev_y = val\n            raw_signal.append(val)\n            \n        # Post-Processing\n        s = pd.Series(raw_signal)\n        s_interp = s.interpolate(method='linear', limit_direction='both')\n        final = s_interp.to_numpy()\n        \n        if np.isnan(final).all(): final = np.zeros(out_w) + (out_h/2)\n        \n        volts = ((out_h - final) / out_h * 5.0) - 2.5\n        \n        try:\n            smooth_volts = savgol_filter(volts, 11, 3)\n        except:\n            smooth_volts = volts\n            \n        results[lead_name] = smooth_volts\n\n    # --- PLOTTING ---\n    # Plot Debug Crops (Check centering!)\n    plt.figure(figsize=(15, 3))\n    for i, (name, c_img) in enumerate(debug_crops):\n        plt.subplot(1, 5, i+1) # Changed to 5 to show V2, III, V6 etc.\n        plt.title(f\"Crop: {name}\")\n        plt.imshow(c_img, cmap='gray')\n    plt.show()\n\n    # Plot Full 12-Lead Grid\n    print(\"📊 Extracted Leads:\")\n    fig, axes = plt.subplots(3, 4, figsize=(20, 10))\n    for lead_name, (r, c) in LEAD_GRID.items():\n        if lead_name in results:\n            ax = axes[r, c]\n            ax.plot(results[lead_name], color='blue', linewidth=1)\n            ax.set_title(lead_name, fontweight='bold')\n            ax.set_ylim(-2.5, 2.5)\n            ax.grid(True, alpha=0.3)\n            \n            if c > 0: ax.set_yticklabels([])\n            if r < 2: ax.set_xticklabels([])\n            \n    plt.tight_layout()\n    plt.show()\n\n    return pd.DataFrame(results)\n\n# EXECUTE\nfilename = \"/kaggle/working/grid_removal_comparison/02_color-based_(red).png\"\ndf = extract_leads_with_finetuning(filename)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:35.803810Z","iopub.execute_input":"2026-01-16T18:23:35.804181Z","iopub.status.idle":"2026-01-16T18:23:38.257725Z","shell.execute_reply.started":"2026-01-16T18:23:35.804156Z","shell.execute_reply":"2026-01-16T18:23:38.256745Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import cv2\nimport numpy as np\nimport pandas as pd\nimport os\nimport matplotlib.pyplot as plt\nfrom scipy.signal import savgol_filter\n\ndef extract_leads_kmeans_fix(image_path):\n    \"\"\"\n    V24: K-MEANS ADAPTIVE (FIXED)\n    1. Generates 'Initial Labels' by assigning pixels to a theoretical 3x4 grid.\n    2. Runs K-Means to let those clusters migrate to the actual signal centers.\n    3. Handles the 'OpenCV Assertion Failed' error.\n    \"\"\"\n    if not os.path.exists(image_path): \n        print(f\"❌ File not found: {image_path}\")\n        return pd.DataFrame()\n\n    img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)\n    if img is None: return pd.DataFrame()\n    \n    h, w = img.shape\n    \n    # 1. Preprocessing\n    if np.mean(img) > 127:\n        binary = 255 - img\n    else:\n        binary = img.copy()\n        \n    _, binary = cv2.threshold(binary, 50, 255, cv2.THRESH_BINARY)\n    \n    # Morphological Clean (Erode noise, Dilate signal)\n    kernel_clean = np.ones((2,2), np.uint8)\n    binary_clean = cv2.erode(binary, kernel_clean, iterations=1)\n    kernel_connect = cv2.getStructuringElement(cv2.MORPH_RECT, (20, 3))\n    binary_blobs = cv2.dilate(binary_clean, kernel_connect, iterations=2)\n    \n    # Mask Header\n    header_mask_h = int(h * 0.12)\n    binary_blobs[:header_mask_h, :] = 0\n    \n    # 2. Get Points\n    points = np.column_stack(np.where(binary_blobs > 0)).astype(np.float32)\n    \n    if len(points) < 12:\n        print(\"❌ Not enough ink.\")\n        return pd.DataFrame()\n\n    # 3. INITIALIZE GRID (The Fix)\n    # We define where we EXPECT the 12 leads to be (3x4 grid)\n    init_centers = []\n    for r in range(3):\n        for c in range(4):\n            # Calculate theoretical center\n            cy = header_mask_h + (r * (h-header_mask_h)//3) + (h-header_mask_h)//6\n            cx = (c * w//4) + w//8\n            init_centers.append([cy, cx])\n    init_centers = np.array(init_centers, dtype=np.float32)\n    \n    # --- CRITICAL FIX: GENERATE INITIAL LABELS ---\n    # Assign each pixel to the nearest theoretical center\n    # This prevents the \"None\" error and guides K-Means correctly\n    \n    # Calculate distances: points (N,2) vs centers (12,2)\n    # We broaden dimensions to broadcast\n    pts_exp = np.expand_dims(points, axis=1) # Shape (N, 1, 2)\n    ctrs_exp = np.expand_dims(init_centers, axis=0) # Shape (1, 12, 2)\n    \n    # Squared Euclidean Distance\n    dists = np.sum((pts_exp - ctrs_exp)**2, axis=2)\n    \n    # Assign initial labels (Index of nearest center)\n    initial_labels = np.argmin(dists, axis=1).astype(np.int32)\n    \n    # OpenCV requires shape (N, 1)\n    bestLabels = initial_labels.reshape(-1, 1)\n\n    # 4. RUN K-MEANS\n    criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0)\n    ret, labels, centers = cv2.kmeans(\n        points, \n        12, \n        bestLabels, # <--- PASSED VALID LABELS HERE\n        criteria, \n        10, \n        cv2.KMEANS_USE_INITIAL_LABELS\n    )\n    \n    # 5. SORT CENTERS\n    # Sort by X to get columns\n    centers_sorted_x = sorted(centers, key=lambda p: p[1])\n    \n    # Split into 4 cols\n    col1 = sorted(centers_sorted_x[0:3], key=lambda p: p[0])\n    col2 = sorted(centers_sorted_x[3:6], key=lambda p: p[0])\n    col3 = sorted(centers_sorted_x[6:9], key=lambda p: p[0])\n    col4 = sorted(centers_sorted_x[9:12], key=lambda p: p[0])\n    \n    lead_centers_map = {\n        'I': col1[0], 'II': col1[1], 'III': col1[2],\n        'aVR': col2[0], 'aVL': col2[1], 'aVF': col2[2],\n        'V1': col3[0], 'V2': col3[1], 'V3': col3[2],\n        'V4': col4[0], 'V5': col4[1], 'V6': col4[2]\n    }\n\n    # 6. EXTRACT\n    results = {}\n    debug_plots = []\n    out_h, out_w = 256, 512\n    \n    print(\"📍 Adapted Centers:\")\n    for name, center in lead_centers_map.items():\n        cy, cx = int(center[0]), int(center[1])\n        print(f\"   {name}: ({cx}, {cy})\")\n        \n        # Crop Window\n        crop_h = int(h * 0.20)\n        crop_w = int(w * 0.22)\n        \n        y1 = max(0, cy - crop_h // 2)\n        y2 = min(h, cy + crop_h // 2)\n        x1 = max(0, cx - crop_w // 2)\n        x2 = min(w, cx + crop_w // 2)\n        \n        lead_crop = img[y1:y2, x1:x2]\n        \n        if lead_crop.size == 0:\n            results[name] = np.zeros(out_w)\n            continue\n            \n        # Processing\n        lead_resized = cv2.resize(lead_crop, (out_w, out_h))\n        _, bin_crop = cv2.threshold(lead_resized, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)\n        \n        # Clean artifacts\n        col_sums = np.sum(bin_crop, axis=0) / 255\n        bad_cols = np.where(col_sums > (out_h * 0.6))[0]\n        bin_crop[:, bad_cols] = 0\n        \n        if name in ['I', 'aVR', 'V1', 'V4']:\n             bin_crop[:int(out_h*0.1), :] = 0\n             \n        if len(debug_plots) < 4: debug_plots.append((name, bin_crop))\n        \n        # Trace\n        raw_signal = []\n        prev_y = None\n        for col in range(out_w):\n            col_data = bin_crop[:, col]\n            ink_pixels = np.where(col_data > 0)[0]\n            val = np.nan\n            if len(ink_pixels) > 0:\n                if prev_y is None: val = np.median(ink_pixels)\n                else: val = ink_pixels[np.argmin(np.abs(ink_pixels - prev_y))]\n                prev_y = val\n            raw_signal.append(val)\n            \n        s = pd.Series(raw_signal).interpolate(limit_direction='both')\n        final = s.to_numpy()\n        if np.isnan(final).all(): final = np.zeros(out_w) + out_h/2\n        \n        volts = ((out_h - final) / out_h * 5.0) - 2.5\n        try: smooth_volts = savgol_filter(volts, 11, 3)\n        except: smooth_volts = volts\n            \n        results[name] = smooth_volts\n\n    # Plot\n    fig, axes = plt.subplots(3, 4, figsize=(20, 10))\n    grid = [['I','aVR','V1','V4'], ['II','aVL','V2','V5'], ['III','aVF','V3','V6']]\n    for r in range(3):\n        for c in range(4):\n            name = grid[r][c]\n            ax = axes[r,c]\n            if name in results:\n                ax.plot(results[name], 'b-', linewidth=1)\n                ax.set_title(name)\n                ax.set_ylim(-2.5, 2.5)\n                ax.grid(True, alpha=0.3)\n                if c > 0: ax.set_yticklabels([])\n                if r < 2: ax.set_xticklabels([])\n    plt.tight_layout()\n    plt.show()\n    \n    return pd.DataFrame(results)\n\nfilename = \"/kaggle/working/grid_removal_comparison/02_color-based_(red).png\"\ndf = extract_leads_with_finetuning(filename)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:38.258998Z","iopub.execute_input":"2026-01-16T18:23:38.259756Z","iopub.status.idle":"2026-01-16T18:23:42.058036Z","shell.execute_reply.started":"2026-01-16T18:23:38.259729Z","shell.execute_reply":"2026-01-16T18:23:42.057094Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import cv2\nimport numpy as np\nimport pandas as pd\nimport os\nimport matplotlib.pyplot as plt\nfrom scipy.signal import savgol_filter\n\ndef extract_leads_geometric_lock(image_path):\n    \"\"\"\n    V25: GEOMETRIC STABILIZATION\n    1. Detects Leads I and II (Top and Middle rows) accurately.\n    2. Calculates the 'Beat Height' (distance between Row 1 and Row 2).\n    3. MATHEMATICALLY FORCES Row 3 to be at (Row 2 + Beat Height).\n    4. Prevents 'Drifting' into the rhythm strip or footer.\n    \"\"\"\n    if not os.path.exists(image_path): \n        print(f\"❌ File not found: {image_path}\")\n        return pd.DataFrame()\n\n    img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE)\n    if img is None: return pd.DataFrame()\n    \n    h, w = img.shape\n    \n    # 1. Preprocessing\n    if np.mean(img) > 127:\n        binary = 255 - img\n    else:\n        binary = img.copy()\n        \n    _, binary = cv2.threshold(binary, 50, 255, cv2.THRESH_BINARY)\n    \n    # Morphological Clean (Erode noise, Dilate signal)\n    kernel_clean = np.ones((2,2), np.uint8)\n    binary_clean = cv2.erode(binary, kernel_clean, iterations=1)\n    kernel_connect = cv2.getStructuringElement(cv2.MORPH_RECT, (20, 3))\n    binary_blobs = cv2.dilate(binary_clean, kernel_connect, iterations=2)\n    \n    # Mask Header (Top 12%)\n    header_mask_h = int(h * 0.12)\n    binary_blobs[:header_mask_h, :] = 0\n    \n    # 2. Run K-Means (SAME AS V24 - It worked for top rows)\n    points = np.column_stack(np.where(binary_blobs > 0)).astype(np.float32)\n    \n    # Initialize 3x4 grid\n    init_centers = []\n    for r in range(3):\n        for c in range(4):\n            cy = header_mask_h + (r * (h-header_mask_h)//3) + (h-header_mask_h)//6\n            cx = (c * w//4) + w//8\n            init_centers.append([cy, cx])\n    init_centers = np.array(init_centers, dtype=np.float32)\n    \n    # Initial Labels\n    pts_exp = np.expand_dims(points, axis=1)\n    ctrs_exp = np.expand_dims(init_centers, axis=0)\n    dists = np.sum((pts_exp - ctrs_exp)**2, axis=2)\n    bestLabels = np.argmin(dists, axis=1).reshape(-1, 1).astype(np.int32)\n\n    # K-Means\n    criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0)\n    ret, labels, centers = cv2.kmeans(points, 12, bestLabels, criteria, 10, cv2.KMEANS_USE_INITIAL_LABELS)\n    \n    # 3. ORGANIZE CENTERS\n    centers_sorted_x = sorted(centers, key=lambda p: p[1])\n    col1 = sorted(centers_sorted_x[0:3], key=lambda p: p[0])\n    col2 = sorted(centers_sorted_x[3:6], key=lambda p: p[0])\n    col3 = sorted(centers_sorted_x[6:9], key=lambda p: p[0])\n    col4 = sorted(centers_sorted_x[9:12], key=lambda p: p[0])\n    \n    # Raw Centers Map\n    raw_map = {\n        'I': col1[0], 'II': col1[1], 'III': col1[2],\n        'aVR': col2[0], 'aVL': col2[1], 'aVF': col2[2],\n        'V1': col3[0], 'V2': col3[1], 'V3': col3[2],\n        'V4': col4[0], 'V5': col4[1], 'V6': col4[2]\n    }\n    \n    # 4. THE FIX: GEOMETRIC STABILIZATION FOR ROW 3\n    # We trust Row 1 (I, aVR, V1, V4) and Row 2 (II, aVL, V2, V5).\n    # We DO NOT TRUST Row 3 (III, aVF, V3, V6) because K-Means often drifts into the Rhythm Strip.\n    \n    # Calculate the average vertical distance (Step) between Row 1 and Row 2\n    row1_y = np.mean([raw_map['I'][0], raw_map['aVR'][0], raw_map['V1'][0], raw_map['V4'][0]])\n    row2_y = np.mean([raw_map['II'][0], raw_map['aVL'][0], raw_map['V2'][0], raw_map['V5'][0]])\n    \n    grid_step_y = row2_y - row1_y\n    \n    # FORCE Row 3 Position\n    corrected_row3_y = row2_y + grid_step_y\n    \n    print(f\"📏 Grid Logic: Row 1 Y={int(row1_y)}, Row 2 Y={int(row2_y)}\")\n    print(f\"🔒 Locking Row 3 Y to {int(corrected_row3_y)} (prevents drift)\")\n    \n    # Apply Correction to Bottom Row Leads\n    for name in ['III', 'aVF', 'V3', 'V6']:\n        # Keep the X-coordinate found by K-Means (horizontal pos is usually fine)\n        original_x = raw_map[name][1]\n        \n        # Overwrite the Y-coordinate with our calculated lock\n        raw_map[name] = [corrected_row3_y, original_x]\n\n    # 5. EXTRACT\n    results = {}\n    debug_plots = []\n    out_h, out_w = 256, 512\n    \n    for name, center in raw_map.items():\n        cy, cx = int(center[0]), int(center[1])\n        \n        # Crop Window (Adjusted for Bottom Row Safety)\n        crop_h = int(h * 0.20)\n        crop_w = int(w * 0.22)\n        \n        # Safety: Ensure bottom crop doesn't go off-screen\n        y1 = max(0, cy - crop_h // 2)\n        y2 = min(h, cy + crop_h // 2)\n        x1 = max(0, cx - crop_w // 2)\n        x2 = min(w, cx + crop_w // 2)\n        \n        lead_crop = img[y1:y2, x1:x2]\n        \n        if lead_crop.size == 0:\n            results[name] = np.zeros(out_w)\n            continue\n            \n        # Processing\n        lead_resized = cv2.resize(lead_crop, (out_w, out_h))\n        # Otsu for dynamic threshold\n        _, bin_crop = cv2.threshold(lead_resized, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)\n        \n        # Clean Vertical Artifacts\n        col_sums = np.sum(bin_crop, axis=0) / 255\n        bad_cols = np.where(col_sums > (out_h * 0.6))[0]\n        bin_crop[:, bad_cols] = 0\n        \n        # Header Safety (Top Row)\n        if name in ['I', 'aVR', 'V1', 'V4']:\n             bin_crop[:int(out_h*0.1), :] = 0\n             \n        # Footer Safety (Bottom Row - Cleans Rhythm Strip Residue)\n        if name in ['III', 'aVF', 'V3', 'V6']:\n             # Delete bottom 10% of crop to avoid catching the rhythm strip line\n             bin_crop[int(out_h*0.9):, :] = 0\n             \n        if len(debug_plots) < 8: debug_plots.append((name, bin_crop))\n        \n        # Trace (Proximity Walker)\n        raw_signal = []\n        prev_y = None\n        for col in range(out_w):\n            col_data = bin_crop[:, col]\n            ink_pixels = np.where(col_data > 0)[0]\n            val = np.nan\n            if len(ink_pixels) > 0:\n                if prev_y is None: val = np.median(ink_pixels)\n                else: val = ink_pixels[np.argmin(np.abs(ink_pixels - prev_y))]\n                prev_y = val\n            raw_signal.append(val)\n            \n        s = pd.Series(raw_signal).interpolate(limit_direction='both')\n        final = s.to_numpy()\n        if np.isnan(final).all(): final = np.zeros(out_w) + out_h/2\n        \n        volts = ((out_h - final) / out_h * 5.0) - 2.5\n        try: smooth_volts = savgol_filter(volts, 11, 3)\n        except: smooth_volts = volts\n            \n        results[name] = smooth_volts\n\n    # Plot\n    print(\"📊 Extracted 12-Leads (Geometrically Locked):\")\n    fig, axes = plt.subplots(3, 4, figsize=(20, 10))\n    grid = [['I','aVR','V1','V4'], ['II','aVL','V2','V5'], ['III','aVF','V3','V6']]\n    for r in range(3):\n        for c in range(4):\n            name = grid[r][c]\n            ax = axes[r,c]\n            if name in results:\n                ax.plot(results[name], 'b-', linewidth=1)\n                ax.set_title(name, fontweight='bold')\n                ax.set_ylim(-2.5, 2.5)\n                ax.grid(True, alpha=0.3)\n                if c > 0: ax.set_yticklabels([])\n                if r < 2: ax.set_xticklabels([])\n    plt.tight_layout()\n    plt.show()\n    \n    return pd.DataFrame(results)\n\n# EXECUTE\ndf = extract_leads_geometric_lock(\"/kaggle/working/grid_removal_comparison/02_color-based_(red).png\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:42.059186Z","iopub.execute_input":"2026-01-16T18:23:42.059499Z","iopub.status.idle":"2026-01-16T18:23:46.938108Z","shell.execute_reply.started":"2026-01-16T18:23:42.059478Z","shell.execute_reply":"2026-01-16T18:23:46.936961Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"df = extract_leads_geometric_lock(\"/kaggle/working/grid_removal_comparison/02_color-based_(red).png\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:46.939305Z","iopub.execute_input":"2026-01-16T18:23:46.939707Z","iopub.status.idle":"2026-01-16T18:23:51.624566Z","shell.execute_reply.started":"2026-01-16T18:23:46.939649Z","shell.execute_reply":"2026-01-16T18:23:51.623692Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==========================================\n# 6. EXECUTION ON YOUR SINGLE IMAGE\n# ==========================================\n\n# 1. Define the path to your uploaded image\n# Note: Update this filename to match exactly what you uploaded\ntest_path = \"/kaggle/working/grid_removal_comparison/02_color-based_(red).png\" \n\n# Check if the file exists before running\nif os.path.exists(test_path):\n    print(f\"Found image: {test_path}\")\n    print(\"Processing... (This handles cropping, row detection, and signal extraction)\")\n    \n    # 2. Run Inference\n    # The function returns a Pandas DataFrame with all 12 leads\n    df_results = extract_full_12_leads_improved(model, test_path)\n    \n    # 3. Check for Success\n    if df_results.empty:\n        print(\"Extraction failed (returned empty). Check previous error logs.\")\n    else:\n        print(\"Extraction Successful!\")\n        \n        # 4. Save to CSV\n        output_csv = \"my_ecg_digitized.csv\"\n        df_results.to_csv(output_csv, index=False)\n        print(f\"Saved signal data to: {output_csv}\")\n        \n        # 5. Visualize the Result\n        # We plot 4 key leads to verify the signal looks correct\n        leads_to_plot = ['I', 'II', 'V1', 'V6']\n        plt.figure(figsize=(15, 10))\n        \n        for i, lead in enumerate(leads_to_plot):\n            if lead in df_results.columns:\n                plt.subplot(4, 1, i+1)\n                plt.plot(df_results[lead], color='blue', linewidth=1)\n                plt.title(f\"Extracted Signal: Lead {lead}\")\n                plt.grid(True, which='both', linestyle='--', alpha=0.7)\n                plt.ylabel(\"Voltage (mV)\")\n                \n        plt.tight_layout()\n        plt.show()\n\nelse:\n    # Helper to find where your file went\n    print(f\"ERROR: Could not find file '{test_path}'\")\n    print(\"Files in current directory:\")\n    print(os.listdir(\".\")) # Lists files so you can copy the correct name","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:51.625607Z","iopub.execute_input":"2026-01-16T18:23:51.625916Z","iopub.status.idle":"2026-01-16T18:23:53.127970Z","shell.execute_reply.started":"2026-01-16T18:23:51.625896Z","shell.execute_reply":"2026-01-16T18:23:53.126966Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import cv2\nimport numpy as np\nimport matplotlib.pyplot as plt\nimport pandas as pd\nfrom scipy.signal import savgol_filter\n\ndef crop_screen_robust_v2(image_path):\n    \"\"\"\n    Robustly finds the ECG paper area in an image (ignores background).\n    This mimics 'Stage 0' of your reference notebook.\n    \"\"\"\n    img = cv2.imread(image_path)\n    if img is None:\n        return None\n    \n    # Convert to grayscale and blur\n    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n    blurred = cv2.GaussianBlur(gray, (5, 5), 0)\n    \n    # Threshold to find the bright paper\n    _, thresh = cv2.threshold(blurred, 200, 255, cv2.THRESH_BINARY)\n    \n    # Find contours\n    contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    \n    if not contours:\n        return img\n\n    # Assume the largest rectangular contour is the paper\n    c = max(contours, key=cv2.contourArea)\n    x, y, w, h = cv2.boundingRect(c)\n    \n    # Crop\n    return img[y:y+h, x:x+w]\n\ndef remove_grid_lines(img):\n    \"\"\"\n    Removes the background grid (Stage 1 replacement).\n    Converts to grayscale using a threshold that isolates the dark signal.\n    \"\"\"\n    # Convert to grayscale\n    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n    \n    # Apply Otsu's thresholding to separate dark ink from light background/grid\n    # We invert it so the signal is white (255) and background is black (0)\n    _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)\n    \n    # Remove small noise (morphological opening)\n    kernel = np.ones((2,2), np.uint8)\n    clean_signal = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)\n    \n    return clean_signal\n\ndef signal_extraction(binary_image):\n    \"\"\"\n    Scans the image column by column to find the signal trace.\n    (Stage 2 replacement)\n    \"\"\"\n    h, w = binary_image.shape\n    signal = []\n    \n    for col in range(w):\n        # Get all pixels in this column\n        column_data = binary_image[:, col]\n        \n        # Find indices where there is \"ink\" (value 255)\n        ink_indices = np.where(column_data > 0)[0]\n        \n        if len(ink_indices) > 0:\n            # Calculate the median position of the ink (center of the line)\n            # We subtract from h because images indices go Top->Bottom, but graphs go Bottom->Up\n            y_pos = h - np.median(ink_indices)\n            signal.append(y_pos)\n        else:\n            # If no ink found (gap), use the last known value or 0\n            if signal:\n                signal.append(signal[-1])\n            else:\n                signal.append(h/2) # Default to middle\n                \n    return np.array(signal)\n\ndef extract_12_leads(image_path):\n    # 1. Load and Crop\n    original = crop_screen_robust_v2(image_path)\n    if original is None:\n        original = cv2.imread(image_path)\n\n    # 2. Remove Grid\n    binary_clean = remove_grid_lines(original)\n    \n    # 3. Layout Segmentation (Standard 3x4 Layout)\n    # We assume the 12 leads are arranged in 4 columns and 3 rows.\n    # Note: Many ECGs have a long rhythm strip at the bottom. We focus on the top 3x4 grid first.\n    \n    h, w = binary_clean.shape\n    \n    # Define segmentation boundaries (approximate for standard layouts)\n    # You might need to tweak 'header_cutoff' if there is text at the top\n    header_cutoff = int(h * 0.10) \n    footer_cutoff = int(h * 0.90)\n    \n    roi = binary_clean[header_cutoff:footer_cutoff, :]\n    h_roi, w_roi = roi.shape\n    \n    row_height = h_roi // 3\n    col_width = w_roi // 4\n    \n    leads = {}\n    lead_names = [\n        ['I', 'aVR', 'V1', 'V4'],\n        ['II', 'aVL', 'V2', 'V5'],\n        ['III', 'aVF', 'V3', 'V6']\n    ]\n    \n    print(\"Extracting Leads...\")\n    \n    plt.figure(figsize=(20, 10))\n    \n    plot_idx = 1\n    results = {}\n    \n    for r in range(3):\n        for c in range(4):\n            # Crop the specific lead box\n            y1 = r * row_height\n            y2 = (r + 1) * row_height\n            x1 = c * col_width\n            x2 = (c + 1) * col_width\n            \n            lead_img = roi[y1:y2, x1:x2]\n            \n            # Extract Signal\n            raw_signal = signal_extraction(lead_img)\n            \n            # 4. Smoothing (Savitzky-Golay as used in your reference notebook)\n            # window_length must be odd\n            if len(raw_signal) > 15:\n                smooth_signal = savgol_filter(raw_signal, window_length=15, polyorder=3)\n            else:\n                smooth_signal = raw_signal\n            \n            # Normalize signal (centering it around 0)\n            baseline = np.median(smooth_signal)\n            normalized_signal = smooth_signal - baseline\n            \n            lead_id = lead_names[r][c]\n            results[lead_id] = normalized_signal\n            \n            # Visualization for \"Confirmation\"\n            plt.subplot(3, 4, plot_idx)\n            plt.plot(normalized_signal, 'b-', linewidth=1)\n            plt.title(f\"Lead {lead_id}\")\n            plt.grid(True, alpha=0.3)\n            plt.axis('off')\n            plot_idx += 1\n            \n    plt.tight_layout()\n    plt.show()\n    \n    return pd.DataFrame(dict([ (k,pd.Series(v)) for k,v in results.items() ]))\n\n# --- HOW TO RUN ---\n# Replace 'your_ecg_image.jpg' with the actual path to your image file\n# df = extract_12_leads('your_ecg_image.jpg')\n# print(df.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:53.128936Z","iopub.execute_input":"2026-01-16T18:23:53.129190Z","iopub.status.idle":"2026-01-16T18:23:53.151579Z","shell.execute_reply.started":"2026-01-16T18:23:53.129165Z","shell.execute_reply":"2026-01-16T18:23:53.150253Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"df=extract_12_leads('/kaggle/working/grid_removal_comparison/02_color-based_(red).png')\nprint(df.head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:53.152868Z","iopub.execute_input":"2026-01-16T18:23:53.153246Z","iopub.status.idle":"2026-01-16T18:23:54.675712Z","shell.execute_reply.started":"2026-01-16T18:23:53.153217Z","shell.execute_reply":"2026-01-16T18:23:54.674715Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ==========================================\n# VERIFICATION BLOCK\n# ==========================================\nimport os\nimport matplotlib.pyplot as plt\n\npath=\"/kaggle/working/grid_removal_comparison/02_color-based_(red).png\"\n\nif path:\n    print(f\"🔍 Testing on: {path}\")\n    # 2. RUN THE NEW FUNCTION\n    # (Ensure extract_leads_dbscan is defined in your previous cell)\n    df_results = extract_leads_dbscan(path)\n    \n    if not df_results.empty:\n        print(\"\\n✅ Extraction Complete! Plotting results...\")\n        \n        # 3. PLOT THE EXTRACTED SIGNALS\n        # We plot Lead I and Lead V1 to check for smoothness\n        plt.figure(figsize=(15, 6))\n        \n        # Plot Lead I\n        plt.subplot(2, 1, 1)\n        if 'I' in df_results.columns:\n            plt.plot(df_results['I'], color='#00aaff', linewidth=1.5)\n            plt.title(\"Extracted Signal: Lead I (Should be smooth, no square jumps)\")\n            plt.grid(True, alpha=0.3)\n            plt.ylabel(\"mV\")\n        \n        # Plot Lead V1\n        plt.subplot(2, 1, 2)\n        if 'V1' in df_results.columns:\n            plt.plot(df_results['V1'], color='#ffaa00', linewidth=1.5)\n            plt.title(\"Extracted Signal: Lead V1 (Should be centered, no drops to zero)\")\n            plt.grid(True, alpha=0.3)\n            plt.ylabel(\"mV\")\n            \n        plt.tight_layout()\n        plt.show()\n        \n        # Save to CSV\n        df_results.to_csv(\"verified_ecg_output.csv\", index=False)\n        print(\"📁 Data saved to 'verified_ecg_output.csv'\")\n    else:\n        print(\"❌ Extraction returned empty. Check the error logs above.\")\nelse:\n    print(f\"❌ Could not find file: {test_image}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:23:54.676780Z","iopub.execute_input":"2026-01-16T18:23:54.677043Z","iopub.status.idle":"2026-01-16T18:24:01.816863Z","shell.execute_reply.started":"2026-01-16T18:23:54.677024Z","shell.execute_reply":"2026-01-16T18:24:01.815878Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# 2. Test on a sample image\n#test_id = '1067371646' # Use the ID from your second image example\n# Attempt to find the file\n\"\"\"test_path = None\nsearch_dir = os.path.join(KAGGLE_DIR, 'train', test_id)\nif os.path.exists(search_dir):\n    files = [f for f in os.listdir(search_dir) if f.endswith('.png')]\n    if files: test_path = os.path.join(search_dir, files[0])\"\"\"\ntest_path='/kaggle/working/grid_removal_comparison/02_color-based_(red).png'\nif test_path:\n    print(f\"Extracting from: {test_path}\")\n    df_results = extract_full_12_leads(model, test_path)\n    \n    # Plot first 4 leads to verify\n    fig, ax = plt.subplots(4, 1, figsize=(10, 10))\n    leads_to_plot = ['I', 'II', 'V1', 'V6']\n    for i, lead in enumerate(leads_to_plot):\n        if lead in df_results.columns:\n            ax[i].plot(df_results[lead])\n            ax[i].set_title(f\"Extracted Lead {lead}\")\n            ax[i].grid(True)\n    plt.tight_layout()\n    plt.show()\n    \n    # Save CSV\n    df_results.to_csv(\"submission.csv\", index=False)\nelse:\n    print(\"Test image not found. Please update 'test_id' variable.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:24:01.822515Z","iopub.execute_input":"2026-01-16T18:24:01.822921Z","iopub.status.idle":"2026-01-16T18:24:03.290908Z","shell.execute_reply.started":"2026-01-16T18:24:01.822897Z","shell.execute_reply":"2026-01-16T18:24:03.289528Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# import cv2\n# import numpy as np\n# from pathlib import Path\n\n\n# class EnhancedECGPreprocessor:\n#     def __init__(self, target_height=900, target_dpi=300):\n#         self.target_height = target_height\n#         self.target_dpi = target_dpi\n    \n#     def suppress_grid_enhanced(self, image, grid_color='red', debug=False):\n#         \"\"\"\n#         Enhanced grid suppression with better color detection\n        \n#         Args:\n#             image: Input BGR image\n#             grid_color: 'red', 'pink', 'orange', or 'auto' for automatic detection\n#             debug: Save debug images\n#         \"\"\"\n#         if debug:\n#             cv2.imwrite(\"debug_01_original.png\", image)\n        \n#         # Convert to HSV\n#         hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)\n#         h, s, v = cv2.split(hsv)\n        \n#         if debug:\n#             cv2.imwrite(\"debug_02_hue.png\", h)\n#             cv2.imwrite(\"debug_03_saturation.png\", s)\n#             cv2.imwrite(\"debug_04_value.png\", v)\n        \n#         # Define color ranges for different grid colors\n#         color_ranges = {\n#             'red': [([0, 50, 50], [10, 255, 255]), ([170, 50, 50], [180, 255, 255])],\n#             'pink': [([0, 30, 150], [10, 255, 255]), ([170, 30, 150], [180, 255, 255])],\n#             'orange': [([10, 50, 50], [25, 255, 255])],\n#             'brown': [([10, 30, 30], [20, 255, 200])],\n#         }\n        \n#         # Create mask for grid lines\n#         grid_mask = np.zeros_like(h)\n        \n#         if grid_color == 'auto':\n#             # Try multiple color ranges\n#             for color_name, ranges in color_ranges.items():\n#                 for lower, upper in ranges:\n#                     mask = cv2.inRange(hsv, np.array(lower), np.array(upper))\n#                     grid_mask = cv2.bitwise_or(grid_mask, mask)\n#         else:\n#             # Use specific color range\n#             ranges = color_ranges.get(grid_color, color_ranges['red'])\n#             for lower, upper in ranges:\n#                 mask = cv2.inRange(hsv, np.array(lower), np.array(upper))\n#                 grid_mask = cv2.bitwise_or(grid_mask, mask)\n        \n#         if debug:\n#             cv2.imwrite(\"debug_05_grid_mask.png\", grid_mask)\n#             print(f\"Grid pixels detected: {np.sum(grid_mask > 0)}\")\n        \n#         # Method 1: Set saturation to 0 (makes colored pixels white)\n#         s_cleaned = s.copy()\n#         s_cleaned[grid_mask > 0] = 0\n        \n#         # Also boost value channel for grid pixels to make them whiter\n#         v_cleaned = v.copy()\n#         v_cleaned[grid_mask > 0] = 255\n        \n#         # Merge back\n#         hsv_cleaned = cv2.merge([h, s_cleaned, v_cleaned])\n#         result = cv2.cvtColor(hsv_cleaned, cv2.COLOR_HSV2BGR)\n        \n#         if debug:\n#             cv2.imwrite(\"debug_06_after_grid_removal.png\", result)\n        \n#         return result\n    \n#     def suppress_grid_morphology(self, image, debug=False):\n#         \"\"\"\n#         Alternative method: Remove grid using morphological operations\n#         This works by detecting thin lines (gridlines) and removing them\n#         \"\"\"\n#         if debug:\n#             cv2.imwrite(\"debug_morph_01_original.png\", image)\n        \n#         # Convert to grayscale\n#         gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)\n        \n#         # Invert (make grid black, ECG white)\n#         inverted = cv2.bitwise_not(gray)\n        \n#         if debug:\n#             cv2.imwrite(\"debug_morph_02_inverted.png\", inverted)\n        \n#         # Threshold to binary\n#         _, binary = cv2.threshold(inverted, 127, 255, cv2.THRESH_BINARY)\n        \n#         # Detect horizontal lines\n#         horizontal_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (40, 1))\n#         horizontal_lines = cv2.morphologyEx(binary, cv2.MORPH_OPEN, horizontal_kernel)\n        \n#         # Detect vertical lines\n#         vertical_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 40))\n#         vertical_lines = cv2.morphologyEx(binary, cv2.MORPH_OPEN, vertical_kernel)\n        \n#         # Combine grid lines\n#         grid_lines = cv2.add(horizontal_lines, vertical_lines)\n        \n#         if debug:\n#             cv2.imwrite(\"debug_morph_03_grid_detected.png\", grid_lines)\n        \n#         # Remove grid from original\n#         # Where grid exists (white in grid_lines), replace with white in original\n#         result = image.copy()\n#         result[grid_lines > 0] = [255, 255, 255]\n        \n#         if debug:\n#             cv2.imwrite(\"debug_morph_04_after_removal.png\", result)\n        \n#         return result\n    \n#     def suppress_grid_combined(self, image, debug=False):\n#         \"\"\"\n#         Combined approach: Use both color-based and morphology-based methods\n#         \"\"\"\n#         # First, try color-based removal\n#         result = self.suppress_grid_enhanced(image, grid_color='auto', debug=debug)\n        \n#         # Then, apply morphological cleanup for any remaining thin lines\n#         gray = cv2.cvtColor(result, cv2.COLOR_BGR2GRAY)\n        \n#         # Detect remaining thin lines\n#         kernel_h = cv2.getStructuringElement(cv2.MORPH_RECT, (25, 1))\n#         kernel_v = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 25))\n        \n#         # Find horizontal lines\n#         _, thresh = cv2.threshold(gray, 240, 255, cv2.THRESH_BINARY)\n#         h_lines = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel_h)\n        \n#         # Find vertical lines\n#         v_lines = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel_v)\n        \n#         # Combine\n#         thin_lines = cv2.bitwise_and(h_lines, v_lines)\n        \n#         # Remove these thin lines\n#         result[thin_lines == 255] = [255, 255, 255]\n        \n#         if debug:\n#             cv2.imwrite(\"debug_combined_final.png\", result)\n        \n#         return result\n    \n#     def analyze_grid_color(self, image):\n#         \"\"\"\n#         Analyze the image to determine grid color characteristics\n#         Returns statistics about detected colors\n#         \"\"\"\n#         hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)\n#         h, s, v = cv2.split(hsv)\n        \n#         # Find colored pixels (high saturation)\n#         colored_mask = s > 30\n        \n#         if np.sum(colored_mask) == 0:\n#             return {\"message\": \"No colored pixels detected. Grid might be gray/black.\"}\n        \n#         # Get hue values of colored pixels\n#         colored_hues = h[colored_mask]\n        \n#         # Analyze hue distribution\n#         hue_hist, _ = np.histogram(colored_hues, bins=18, range=(0, 180))\n#         dominant_hue_bin = np.argmax(hue_hist)\n#         dominant_hue = dominant_hue_bin * 10\n        \n#         # Determine color name\n#         if dominant_hue < 15 or dominant_hue > 165:\n#             color_name = \"red/pink\"\n#         elif 15 <= dominant_hue < 30:\n#             color_name = \"orange\"\n#         elif 30 <= dominant_hue < 90:\n#             color_name = \"green/yellow\"\n#         elif 90 <= dominant_hue < 130:\n#             color_name = \"blue\"\n#         else:\n#             color_name = \"purple\"\n        \n#         stats = {\n#             \"dominant_hue\": dominant_hue,\n#             \"color_name\": color_name,\n#             \"num_colored_pixels\": int(np.sum(colored_mask)),\n#             \"mean_saturation\": float(np.mean(s[colored_mask])),\n#             \"mean_value\": float(np.mean(v[colored_mask]))\n#         }\n        \n#         return stats\n\n\n# # Test function\n# def test_grid_removal(image_path):\n#     \"\"\"Test all three grid removal methods\"\"\"\n    \n#     # Read image\n#     image = cv2.imread(image_path)\n#     if image is None:\n#         print(f\"Error: Could not read image from {image_path}\")\n#         return\n    \n#     print(\"=\"*60)\n#     print(\"ECG GRID REMOVAL TEST\")\n#     print(\"=\"*60)\n    \n#     preprocessor = EnhancedECGPreprocessor()\n    \n#     # Analyze grid color first\n#     print(\"\\n[1] Analyzing grid color...\")\n#     stats = preprocessor.analyze_grid_color(image)\n#     print(\"Grid color statistics:\")\n#     for key, value in stats.items():\n#         print(f\"  {key}: {value}\")\n    \n#     # Test Method 1: Enhanced color-based\n#     print(\"\\n[2] Testing enhanced color-based removal...\")\n#     result1 = preprocessor.suppress_grid_enhanced(image, grid_color='auto', debug=True)\n#     cv2.imwrite(\"result_01_color_based.png\", result1)\n#     print(\"  ✓ Saved: result_01_color_based.png\")\n    \n#     # Test Method 2: Morphology-based\n#     print(\"\\n[3] Testing morphology-based removal...\")\n#     result2 = preprocessor.suppress_grid_morphology(image, debug=True)\n#     cv2.imwrite(\"result_02_morphology_based.png\", result2)\n#     print(\"  ✓ Saved: result_02_morphology_based.png\")\n    \n#     # Test Method 3: Combined approach\n#     print(\"\\n[4] Testing combined approach...\")\n#     result3 = preprocessor.suppress_grid_combined(image, debug=True)\n#     cv2.imwrite(\"result_03_combined.png\", result3)\n#     print(\"  ✓ Saved: result_03_combined.png\")\n    \n#     print(\"\\n\" + \"=\"*60)\n#     print(\"✓ All tests complete!\")\n#     print(\"Check the output files to see which method works best.\")\n#     print(\"=\"*60)\n\n\n# if __name__ == \"__main__\":\n#     # Replace with your ECG image path\n#     image_path = \"C:\\\\Users\\\\Sahaj Bindal\\\\OneDrive\\\\Desktop\\\\ECG\\\\1006867983-0009.png\"\n#     test_grid_removal(image_path)\n\nimport cv2\nimport numpy as np\nfrom pathlib import Path\nimport matplotlib.pyplot as plt\n\n\nclass ECGGridRemover:\n    \"\"\"Comprehensive grid removal with extensive diagnostics\"\"\"\n    \n    def __init__(self):\n        pass\n    \n    def visualize_hsv_analysis(self, image, output_prefix=\"hsv_analysis\"):\n        \"\"\"\n        Deep analysis of HSV channels to understand the image\n        \"\"\"\n        hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)\n        h, s, v = cv2.split(hsv)\n        \n        # Create visualization\n        fig, axes = plt.subplots(2, 3, figsize=(15, 10))\n        \n        # Row 1: Original channels\n        axes[0, 0].imshow(cv2.cvtColor(image, cv2.COLOR_BGR2RGB))\n        axes[0, 0].set_title('Original Image')\n        axes[0, 0].axis('off')\n        \n        axes[0, 1].imshow(h, cmap='hsv')\n        axes[0, 1].set_title(f'Hue Channel\\nRange: {h.min()}-{h.max()}')\n        axes[0, 1].axis('off')\n        \n        axes[0, 2].imshow(s, cmap='gray')\n        axes[0, 2].set_title(f'Saturation Channel\\nRange: {s.min()}-{s.max()}')\n        axes[0, 2].axis('off')\n        \n        # Row 2: Histograms\n        axes[1, 0].hist(h.ravel(), bins=180, range=(0, 180), color='red', alpha=0.7)\n        axes[1, 0].set_title('Hue Histogram')\n        axes[1, 0].set_xlabel('Hue Value (0-180)')\n        axes[1, 0].set_ylabel('Pixel Count')\n        axes[1, 0].grid(True, alpha=0.3)\n        \n        axes[1, 1].hist(s.ravel(), bins=256, range=(0, 256), color='green', alpha=0.7)\n        axes[1, 1].set_title('Saturation Histogram')\n        axes[1, 1].set_xlabel('Saturation Value (0-255)')\n        axes[1, 1].set_ylabel('Pixel Count')\n        axes[1, 1].grid(True, alpha=0.3)\n        \n        axes[1, 2].imshow(v, cmap='gray')\n        axes[1, 2].set_title(f'Value Channel\\nRange: {v.min()}-{v.max()}')\n        axes[1, 2].axis('off')\n        \n        plt.tight_layout()\n        plt.savefig(f\"{output_prefix}.png\", dpi=150, bbox_inches='tight')\n        plt.close()\n        \n        print(f\"✓ Saved HSV analysis to {output_prefix}.png\")\n        \n        # Print statistics\n        print(\"\\n\" + \"=\"*60)\n        print(\"HSV STATISTICS\")\n        print(\"=\"*60)\n        print(f\"Hue:        min={h.min():3d}, max={h.max():3d}, mean={h.mean():.1f}\")\n        print(f\"Saturation: min={s.min():3d}, max={s.max():3d}, mean={s.mean():.1f}\")\n        print(f\"Value:      min={v.min():3d}, max={v.max():3d}, mean={v.mean():.1f}\")\n        \n        # Detect dominant colors\n        saturated_pixels = s > 20  # Pixels with some color\n        if np.sum(saturated_pixels) > 0:\n            colored_hues = h[saturated_pixels]\n            hue_hist, bins = np.histogram(colored_hues, bins=18, range=(0, 180))\n            dominant_bin = np.argmax(hue_hist)\n            dominant_hue = bins[dominant_bin]\n            \n            print(f\"\\nColored pixels: {np.sum(saturated_pixels):,} ({100*np.sum(saturated_pixels)/h.size:.2f}%)\")\n            print(f\"Dominant hue: {dominant_hue:.0f}° (bin {dominant_bin})\")\n            \n            # Interpret color\n            if dominant_hue < 10 or dominant_hue > 170:\n                color = \"RED\"\n            elif 10 <= dominant_hue < 25:\n                color = \"ORANGE/BROWN\"\n            elif 25 <= dominant_hue < 40:\n                color = \"YELLOW\"\n            elif 40 <= dominant_hue < 80:\n                color = \"GREEN\"\n            elif 80 <= dominant_hue < 130:\n                color = \"CYAN/BLUE\"\n            else:\n                color = \"PURPLE/MAGENTA\"\n            \n            print(f\"Interpreted as: {color}\")\n        else:\n            print(\"\\nNo significantly colored pixels detected (all grayscale)\")\n        print(\"=\"*60)\n    \n    def method1_color_based(self, image, hue_ranges=None, sat_threshold=20):\n        \"\"\"\n        Method 1: Color-based removal using HSV\n        \"\"\"\n        hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)\n        h, s, v = cv2.split(hsv)\n        \n        # Default: red color ranges\n        if hue_ranges is None:\n            hue_ranges = [(0, 10), (170, 180)]\n        \n        # Create mask\n        mask = np.zeros_like(h, dtype=np.uint8)\n        for low, high in hue_ranges:\n            hue_mask = (h >= low) & (h <= high) & (s > sat_threshold)\n            mask = mask | hue_mask.astype(np.uint8) * 255\n        \n        # Apply mask: make grid pixels white\n        result = image.copy()\n        result[mask > 0] = [255, 255, 255]\n        \n        return result, mask\n    \n    def method2_brightness_based(self, image, threshold=230):\n        \"\"\"\n        Method 2: Remove light-colored pixels (grid is usually lighter than ECG)\n        \"\"\"\n        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)\n        \n        # Grid lines are lighter than ECG signal\n        light_mask = gray > threshold\n        \n        result = image.copy()\n        result[light_mask] = [255, 255, 255]\n        \n        return result, light_mask.astype(np.uint8) * 255\n    \n    def method3_morphology(self, image, line_length=40):\n        \"\"\"\n        Method 3: Detect thin lines using morphology\n        \"\"\"\n        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)\n        \n        # Threshold\n        _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)\n        \n        # Invert so lines are white\n        binary_inv = cv2.bitwise_not(binary)\n        \n        # Detect horizontal lines\n        h_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (line_length, 1))\n        h_lines = cv2.morphologyEx(binary_inv, cv2.MORPH_OPEN, h_kernel)\n        \n        # Detect vertical lines\n        v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, line_length))\n        v_lines = cv2.morphologyEx(binary_inv, cv2.MORPH_OPEN, v_kernel)\n        \n        # Combine\n        grid_mask = cv2.bitwise_or(h_lines, v_lines)\n        \n        result = image.copy()\n        result[grid_mask > 0] = [255, 255, 255]\n        \n        return result, grid_mask\n    \n    def method4_adaptive(self, image):\n        \"\"\"\n        Method 4: Adaptive approach based on local statistics\n        \"\"\"\n        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)\n        \n        # Apply bilateral filter to preserve edges\n        filtered = cv2.bilateralFilter(gray, 9, 75, 75)\n        \n        # Adaptive threshold\n        thresh = cv2.adaptiveThreshold(\n            filtered, 255, \n            cv2.ADAPTIVE_THRESH_GAUSSIAN_C, \n            cv2.THRESH_BINARY, \n            21, 10\n        )\n        \n        # Grid is white background (255), ECG is black (0)\n        # So we want to keep only the black parts\n        ecg_mask = thresh == 0\n        \n        result = np.ones_like(image) * 255\n        result[ecg_mask] = image[ecg_mask]\n        \n        return result, cv2.bitwise_not(thresh)\n    \n    def method5_frequency(self, image):\n        \"\"\"\n        Method 5: FFT-based grid removal (detects periodic patterns)\n        \"\"\"\n        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)\n        \n        # FFT\n        f = np.fft.fft2(gray)\n        fshift = np.fft.fftshift(f)\n        \n        # Magnitude spectrum\n        magnitude = 20 * np.log(np.abs(fshift) + 1)\n        \n        # Create mask to remove periodic components (grid)\n        rows, cols = gray.shape\n        crow, ccol = rows // 2, cols // 2\n        \n        # Remove specific frequencies (tunable)\n        mask = np.ones((rows, cols), np.uint8)\n        r = 30\n        center = [crow, ccol]\n        x, y = np.ogrid[:rows, :cols]\n        mask_area = (x - center[0]) ** 2 + (y - center[1]) ** 2 <= r*r\n        mask[mask_area] = 0\n        \n        # Apply mask and inverse FFT\n        fshift_filtered = fshift * mask\n        f_ishift = np.fft.ifftshift(fshift_filtered)\n        img_back = np.fft.ifft2(f_ishift)\n        img_back = np.abs(img_back)\n        \n        # Normalize\n        img_back = np.uint8(cv2.normalize(img_back, None, 0, 255, cv2.NORM_MINMAX))\n        \n        result = cv2.cvtColor(img_back, cv2.COLOR_GRAY2BGR)\n        \n        return result, None\n    \n    def compare_all_methods(self, image_path, output_dir=\"grid_removal_comparison\"):\n        \"\"\"\n        Run ALL methods and save comparison\n        \"\"\"\n        # Create output directory\n        Path(output_dir).mkdir(exist_ok=True)\n        \n        # Load image\n        image = cv2.imread(str(image_path))\n        if image is None:\n            raise ValueError(f\"Could not load image: {image_path}\")\n        \n        print(\"=\"*60)\n        print(\"ECG GRID REMOVAL - COMPREHENSIVE TEST\")\n        print(\"=\"*60)\n        print(f\"Input: {image_path}\")\n        print(f\"Output directory: {output_dir}/\")\n        print(f\"Image size: {image.shape[1]}x{image.shape[0]}\")\n        \n        # Step 1: Analyze HSV\n        print(\"\\n[1/6] Analyzing HSV color space...\")\n        self.visualize_hsv_analysis(image, f\"{output_dir}/01_hsv_analysis\")\n        \n        # Step 2-6: Apply all methods\n        methods = [\n            (\"Color-Based (Red)\", lambda: self.method1_color_based(image)),\n            (\"Brightness-Based\", lambda: self.method2_brightness_based(image)),\n            (\"Morphology-Based\", lambda: self.method3_morphology(image)),\n            (\"Adaptive Threshold\", lambda: self.method4_adaptive(image)),\n            (\"FFT Frequency\", lambda: self.method5_frequency(image))\n        ]\n        \n        results = []\n        for idx, (name, method) in enumerate(methods, 2):\n            print(f\"[{idx}/6] Testing: {name}...\")\n            try:\n                result, mask = method()\n                results.append((name, result, mask))\n                \n                # Save individual results\n                cv2.imwrite(f\"{output_dir}/{idx:02d}_{name.replace(' ', '_').lower()}.png\", result)\n                if mask is not None:\n                    cv2.imwrite(f\"{output_dir}/{idx:02d}_{name.replace(' ', '_').lower()}_mask.png\", mask)\n                \n                print(f\"    ✓ Saved\")\n            except Exception as e:\n                print(f\"    ✗ Failed: {e}\")\n                results.append((name, None, None))\n        \n        # Create comparison figure\n        print(\"\\n[Final] Creating comparison grid...\")\n        self._create_comparison_grid(image, results, f\"{output_dir}/00_COMPARISON.png\")\n        \n        print(\"\\n\" + \"=\"*60)\n        print(\"✓ ALL TESTS COMPLETE!\")\n        print(f\"✓ Check '{output_dir}/' folder for results\")\n        print(\"=\"*60)\n        print(\"\\nRECOMMENDATIONS:\")\n        print(\"1. Open '00_COMPARISON.png' to see all methods side-by-side\")\n        print(\"2. Check '01_hsv_analysis.png' to understand your grid color\")\n        print(\"3. Choose the method that works best for your image\")\n        print(\"4. If none work well, share the HSV analysis image for help!\")\n    \n    def _create_comparison_grid(self, original, results, output_path):\n        \"\"\"Create a comparison grid of all methods\"\"\"\n        fig = plt.figure(figsize=(20, 12))\n        \n        # Original\n        ax = plt.subplot(2, 3, 1)\n        ax.imshow(cv2.cvtColor(original, cv2.COLOR_BGR2RGB))\n        ax.set_title('ORIGINAL', fontsize=14, fontweight='bold')\n        ax.axis('off')\n        \n        # Results\n        for idx, (name, result, mask) in enumerate(results, 2):\n            if idx > 6:\n                break\n            ax = plt.subplot(2, 3, idx)\n            if result is not None:\n                ax.imshow(cv2.cvtColor(result, cv2.COLOR_BGR2RGB))\n            else:\n                ax.text(0.5, 0.5, 'FAILED', ha='center', va='center', fontsize=20, color='red')\n            ax.set_title(name, fontsize=12, fontweight='bold')\n            ax.axis('off')\n        \n        plt.tight_layout()\n        plt.savefig(output_path, dpi=150, bbox_inches='tight')\n        plt.close()\n        print(f\"    ✓ Saved comparison to {output_path}\")\n\n\n# ============================================================================\n# MAIN TESTING FUNCTION\n# ============================================================================\n\ndef test_ecg_grid_removal(image_path):\n    \"\"\"\n    Main function to test grid removal on your ECG image\n    \n    Usage:\n        test_ecg_grid_removal(\"your_ecg_image.png\")\n    \"\"\"\n    remover = ECGGridRemover()\n    remover.compare_all_methods(image_path)\n\n\nif __name__ == \"__main__\":\n    # CHANGE THIS PATH TO YOUR ECG IMAGE\n    ecg_image = \"/kaggle/input/physionet-ecg-image-digitization/test/1053922973.png\"\n    \n    print(\"\"\"\n    ╔════════════════════════════════════════════════════════════╗\n    ║         ECG GRID REMOVAL - DIAGNOSTIC TOOL                 ║\n    ║                                                            ║\n    ║  This will test 5 different grid removal methods and      ║\n    ║  save detailed analysis to help you find what works!      ║\n    ╚════════════════════════════════════════════════════════════╝\n    \"\"\")\n    \n    test_ecg_grid_removal(ecg_image)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:28:52.810929Z","iopub.execute_input":"2026-01-16T18:28:52.811404Z","iopub.status.idle":"2026-01-16T18:29:05.401453Z","shell.execute_reply.started":"2026-01-16T18:28:52.811371Z","shell.execute_reply":"2026-01-16T18:29:05.400564Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sample_path='/kaggle/working/img1_cropped.png'\nif os.path.exists(sample_path):\n    input_tensor, original_img = preprocess_image(sample_path)\n    \n    with torch.no_grad():\n        output = model(input_tensor.to(DEVICE))\n        pred_mask = output.cpu().numpy()[0, 0, :, :]\n    \n    # Binarize output (0.5 threshold)\n    binary_mask = (pred_mask > 0.5).astype(np.uint8) * 255\n    \n    plt.figure(figsize=(10, 5))\n    plt.subplot(1, 2, 1)\n    plt.title(\"Original Image\")\n    plt.imshow(original_img)\n    plt.subplot(1, 2, 2)\n    plt.title(\"Predicted Mask (Untrained)\")\n    plt.imshow(binary_mask, cmap='gray')\n    plt.show()\nelse:\n    print(f\"Sample path {sample_path} not found. Check directory.\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:24:15.590784Z","iopub.execute_input":"2026-01-16T18:24:15.591059Z","iopub.status.idle":"2026-01-16T18:24:18.275875Z","shell.execute_reply.started":"2026-01-16T18:24:15.591040Z","shell.execute_reply":"2026-01-16T18:24:18.274688Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from scipy.interpolate import interp1d\n\ndef extract_signal_hough(binary_mask):\n    \"\"\"\n    Converts a binary mask (Signal=255, BG=0) into a 1D time-series \n    using the Probabilistic Hough Transform.\n    \"\"\"\n    # 1. Detect Lines\n    # Adjust 'threshold' and 'minLineLength' if the detection is too sparse\n    lines = cv2.HoughLinesP(binary_mask, 1, np.pi/180, threshold=10, \n                            minLineLength=10, maxLineGap=20)\n    \n    # Handle case where no lines are found\n    if lines is None:\n        print(\"Warning: No lines detected. Returning flat signal.\")\n        return np.zeros(binary_mask.shape[1]) \n\n    # 2. Convert Lines to Points\n    points_x = []\n    points_y =[]\n    \n    for line in lines:\n        # --- FIX: Unpack from index 0 ---\n       x1, y1, x2, y2 = line[0]\n        \n        # Calculate number of points to interpolate along the line\n       dist = np.hypot(x2 - x1, y2 - y1)\n       num_points = int(dist)\n        \n       if num_points > 0:\n            xs = np.linspace(x1, x2, num_points)\n            ys = np.linspace(y1, y2, num_points)\n            points_x.extend(xs)\n            points_y.extend(ys)\n        \n    # 3. Sort and Filter\n    points_x = np.array(points_x)\n    points_y = np.array(points_y)\n    \n    if len(points_x) == 0:\n        return np.zeros(binary_mask.shape[1])\n        \n    sort_idx = np.argsort(points_x)\n    points_x = points_x[sort_idx]\n    points_y = points_y[sort_idx]\n    \n    # 4. Handle Duplicates (Average Y for same X)\n    unique_x, unique_indices = np.unique(points_x.astype(int), return_inverse=True)\n    averaged_y = np.zeros_like(unique_x, dtype=float)\n    \n    for i in range(len(unique_x)):\n        averaged_y[i] = np.mean(points_y[unique_indices == i])\n        \n    # 5. Interpolate to fill missing X values (Time gaps)\n    # The output signal must match the width of the image (e.g., 1024)\n    target_x = np.arange(0, binary_mask.shape[1])\n    interpolator = interp1d(unique_x, averaged_y, kind='linear', \n                            bounds_error=False, fill_value=\"extrapolate\")\n    final_signal = interpolator(target_x)\n    \n    # Invert Y (Image coordinates have (0,0) at top-left)\n    final_signal = binary_mask.shape[0] - final_signal\n    \n    return final_signal\n\n# --- EXECUTION ---\n# For demonstration, we use a simple threshold mask if U-Net output is empty\n# (Since an untrained U-Net might output all zeros)\nif np.sum(binary_mask) < 100:\n    # Fallback to simple thresholding for the demo to show a result\n    gray = cv2.cvtColor(original_img, cv2.COLOR_RGB2GRAY)\n    _, binary_mask_demo = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV)\nelse:\n    binary_mask_demo = binary_mask\n\n# Extract signal from the binary_mask\ndigitized_signal = extract_signal_hough(binary_mask_demo)\n\n# Visualize\nplt.figure(figsize=(12, 4))\nplt.plot(digitized_signal, color='red', linewidth=1)\nplt.title(\"Recovered 1D Signal (Pixel Units)\")\nplt.xlabel(\"Time (Pixels)\")\nplt.ylabel(\"Amplitude (Pixels)\")\nplt.grid(True, alpha=0.3)\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:24:18.277064Z","iopub.execute_input":"2026-01-16T18:24:18.277344Z","iopub.status.idle":"2026-01-16T18:24:19.090720Z","shell.execute_reply.started":"2026-01-16T18:24:18.277318Z","shell.execute_reply":"2026-01-16T18:24:19.089542Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!git clone https://github.com/Ahus-AIM/Open-ECG-Digitizer.git\n%cd Open-ECG-Digitizer\n!git lfs install\n!git lfs pull","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:24:19.092140Z","iopub.execute_input":"2026-01-16T18:24:19.092999Z","iopub.status.idle":"2026-01-16T18:24:26.626178Z","shell.execute_reply.started":"2026-01-16T18:24:19.092970Z","shell.execute_reply":"2026-01-16T18:24:26.624771Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"pip install python-docx","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:24:26.628227Z","iopub.execute_input":"2026-01-16T18:24:26.628589Z","iopub.status.idle":"2026-01-16T18:24:30.769240Z","shell.execute_reply.started":"2026-01-16T18:24:26.628559Z","shell.execute_reply":"2026-01-16T18:24:30.767874Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from docx import Document\nfrom docx.shared import Pt\nfrom docx.enum.text import WD_ALIGN_PARAGRAPH\n\ndef create_sample_index():\n    doc = Document()\n\n    # Add Title\n    heading = doc.add_heading('INDEX', 0)\n    heading.alignment = WD_ALIGN_PARAGRAPH.CENTER\n\n    # Add a table with 3 columns\n    table = doc.add_table(rows=1, cols=3)\n    table.style = 'Table Grid'\n\n    # Header Row\n    hdr_cells = table.rows[0].cells\n    hdr_cells[0].text = 'S.No'\n    hdr_cells[1].text = 'Particulars'\n    hdr_cells[2].text = 'Page No.'\n\n    # Data to fill\n    data = [\n        ('1', 'Notice of Motion', '1-5'),\n        ('2', 'Affidavit in Support', '6-12'),\n        ('3', 'Annexure A - Copy of FIR', '13-15'),\n        ('4', 'Annexure B - Medical Report', '16-20'),\n        ('5', 'Vakalatnama', '21')\n    ]\n\n    # Add rows\n    for sno, part, page in data:\n        row_cells = table.add_row().cells\n        row_cells[0].text = sno\n        row_cells[1].text = part\n        row_cells[2].text = page\n\n    # Save the file\n    filename = \"sample_index.docx\"\n    doc.save(filename)\n    print(f\"Successfully created {filename}\")\n\nif __name__ == \"__main__\":\n    create_sample_index()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-16T18:24:30.770819Z","iopub.execute_input":"2026-01-16T18:24:30.771142Z","iopub.status.idle":"2026-01-16T18:24:30.832993Z","shell.execute_reply.started":"2026-01-16T18:24:30.771108Z","shell.execute_reply":"2026-01-16T18:24:30.832119Z"}},"outputs":[],"execution_count":null}]}