{"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"}],"dockerImageVersionId":31192,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Overview\nThis notebook explores computer vision techniques to locate and extract the ECG grid from images of paper ECG printouts. This is part of the PhysioNet Challenge focused on digitizing ECG images into time-series data.\n\n## Problem Statement\n- **Goal**: Detect and locate the ECG grid structure within ECG images\n- **Challenge**: Handle various imaging artifacts.\n- **Approach**: Experiment with OpenCV (cv2) for grid detection\n\n## Notebook Scope\nThis notebook focuses specifically on **ECG grid localization** - the first critical step in the digitization pipeline.","metadata":{}},{"cell_type":"code","source":"import cv2\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nfrom pathlib import Path\nfrom tqdm import tqdm\n\nDATA_PATH = Path(\"/kaggle/input/physionet-ecg-image-digitization\")\nTRAIN_PATH = DATA_PATH / 'train'\n\ntrain = pd.read_csv(DATA_PATH / 'train.csv')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-12T15:35:10.607468Z","iopub.execute_input":"2025-11-12T15:35:10.607696Z","iopub.status.idle":"2025-11-12T15:35:13.030352Z","shell.execute_reply.started":"2025-11-12T15:35:10.607675Z","shell.execute_reply":"2025-11-12T15:35:13.029346Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Step by step on one image to understand more about cv2","metadata":{}},{"cell_type":"code","source":"# Pick one test image to work with.\ntest_row = train.iloc[0]\ntest_id = str(test_row['id'])\n\n# Choose image type.\nimg_type = '0009'  # Try: '0001', '0009', '0010', '0011', etc.\nimg_path = TRAIN_PATH / test_id / f\"{test_id}-{img_type}.png\"\n\n# Load image.\nimg = cv2.imread(str(img_path))\nprint(f\"Image shape: {img.shape}\")\n\n# Display original.\nplt.figure(figsize=(6, 4))\nplt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))\nplt.title(f\"Original Image: {test_id}-{img_type}\")\nplt.axis('off')\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-12T15:35:17.306879Z","iopub.execute_input":"2025-11-12T15:35:17.307355Z","iopub.status.idle":"2025-11-12T15:35:19.651718Z","shell.execute_reply.started":"2025-11-12T15:35:17.307325Z","shell.execute_reply":"2025-11-12T15:35:19.650708Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Step 1: Convert to grayscale.\ngray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n\n# Step 2: Gaussian blur (reduce noise).\nblur_kernel = 5  # Try: 3, 5, 7, 9 (must be odd).\nblurred = cv2.GaussianBlur(gray, (blur_kernel, blur_kernel), 0)\n\n# Step 3: Closing on grayscale (fills dark gaps/valleys), try other values.\nkernel_size = max(img.shape[0], img.shape[1]) // 100\nif kernel_size % 2 == 0:\n    kernel_size += 1\n\nkernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))\nclosed = cv2.morphologyEx(blurred, cv2.MORPH_CLOSE, kernel)\n\n# Step 4: Normalize to handle lighting variations.\ndiv = np.float32(blurred) / (closed + 1e-8)\nnormalized = np.uint8(cv2.normalize(div, div, 0, 255, cv2.NORM_MINMAX))\n\n# Step 5: Adaptive thresholding.\nblock_size = 101  # Try: 11, 21, 31, 51, 101 (must be odd, larger = smoother)\nC = 10  # Try: 0-10 (constant subtracted from mean)\n\nthresh = cv2.adaptiveThreshold(\n    normalized, \n    255, \n    cv2.ADAPTIVE_THRESH_GAUSSIAN_C,  # or ADAPTIVE_THRESH_MEAN_C\n    cv2.THRESH_BINARY_INV, \n    block_size, \n    C\n)\n\n# Visualize.\nfig, axes = plt.subplots(1, 4, figsize=(25, 5))\naxes[0].imshow(blurred, cmap='gray')\naxes[0].set_title(f\"Blurred (k={blur_kernel})\")\naxes[0].axis('off')\n\naxes[1].imshow(closed, cmap='gray')\naxes[1].set_title(f\"Closed (k={kernel_size})\")\naxes[1].axis('off')\n\naxes[2].imshow(normalized, cmap='gray')\naxes[2].set_title(\"Normalized (blurred/closed)\")\naxes[2].axis('off')\n\naxes[3].imshow(thresh, cmap='gray')\naxes[3].set_title(\"Threshold\")\naxes[3].axis('off')\n\nplt.tight_layout()\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-12T15:35:23.879384Z","iopub.execute_input":"2025-11-12T15:35:23.880245Z","iopub.status.idle":"2025-11-12T15:35:28.972552Z","shell.execute_reply.started":"2025-11-12T15:35:23.880212Z","shell.execute_reply":"2025-11-12T15:35:28.971084Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Find all contours.\ncontours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)\n\nprint(f\"Total contours found: {len(contours)}\")\n\n# Draw ALL contours to see what we found.\nimg_all_contours = img.copy()\ncv2.drawContours(img_all_contours, contours, -1, (0, 255, 0), 2)\n\nplt.figure(figsize=(12, 6))\nplt.imshow(cv2.cvtColor(img_all_contours, cv2.COLOR_BGR2RGB))\nplt.title(f\"All {len(contours)} Contours Found\")\nplt.axis('off')\nplt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-12T15:35:32.090009Z","iopub.execute_input":"2025-11-12T15:35:32.090738Z","iopub.status.idle":"2025-11-12T15:35:34.022109Z","shell.execute_reply.started":"2025-11-12T15:35:32.090706Z","shell.execute_reply":"2025-11-12T15:35:34.020996Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Find the largest contour by area.\nif len(contours) == 0:\n    print(\"No contours found!\")\nelse:\n    # Get the largest contour.\n    largest_contour = max(contours, key=cv2.contourArea)\n    largest_area = cv2.contourArea(largest_contour)\n    \n    print(f\"Largest contour area: {largest_area:.0f} px²\")\n    \n    # Visualize.\n    img_display = img.copy()\n    cv2.drawContours(img_display, [largest_contour], -1, (0, 255, 0), 3)\n    \n    plt.figure(figsize=(10, 8))\n    plt.imshow(cv2.cvtColor(img_display, cv2.COLOR_BGR2RGB))\n    plt.title(f\"Largest Contour\\nArea: {largest_area:.0f} px²\")\n    plt.axis('off')\n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-12T15:35:36.750845Z","iopub.execute_input":"2025-11-12T15:35:36.751190Z","iopub.status.idle":"2025-11-12T15:35:38.702132Z","shell.execute_reply.started":"2025-11-12T15:35:36.751147Z","shell.execute_reply":"2025-11-12T15:35:38.700720Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Pipeline with many images","metadata":{}},{"cell_type":"code","source":"# Comment lines to skip some images types.\nimage_types = {\n    #'0001': 'Original color ECG',\n    #'0003': 'Printed & scanned in color',\n    #'0004': 'Printed & scanned in B&W',\n    '0005': 'Mobile photo of printed',\n    '0006': 'Mobile photo of screen',\n    '0009': 'Stained and soaked',\n    '0010': 'Extensively damaged',\n    #'0011': 'Scanned with mold (color)',\n    #'0012': 'Scanned with mold (B&W)'\n}","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-12T15:35:41.976345Z","iopub.execute_input":"2025-11-12T15:35:41.976693Z","iopub.status.idle":"2025-11-12T15:35:41.981914Z","shell.execute_reply.started":"2025-11-12T15:35:41.976667Z","shell.execute_reply":"2025-11-12T15:35:41.980800Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def find_grid_robust(img):\n    \"\"\"\n    Robust grid detection with dynamic kernel\n    \"\"\"\n    # Step 1: Convert to grayscale.\n    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)\n    \n    # Step 2: Gaussian blur (reduce noise).\n    blur_kernel = 5  # Try: 3, 5, 7, 9 (must be odd).\n    blurred = cv2.GaussianBlur(gray, (blur_kernel, blur_kernel), 0)\n    \n    # Step 3: Closing on grayscale (fills dark gaps/valleys).\n    kernel_size = 21\n    \n    kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel_size, kernel_size))\n    closed = cv2.morphologyEx(blurred, cv2.MORPH_CLOSE, kernel)\n    \n    # Step 4: Normalize to handle lighting variations.\n    div = np.float32(blurred) / (closed + 1e-8)\n    normalized = np.uint8(cv2.normalize(div, div, 0, 255, cv2.NORM_MINMAX))\n    \n    # Step 5: Adaptive thresholding (local).\n    block_size = 51  # Try: 11, 21, 31, 51, 101 (must be odd, larger = smoother).\n    C = 10  # Try: 0-10 (constant subtracted from mean).\n    \n    thresh = cv2.adaptiveThreshold(\n        normalized, \n        255, \n        cv2.ADAPTIVE_THRESH_GAUSSIAN_C,  # or ADAPTIVE_THRESH_MEAN_C\n        cv2.THRESH_BINARY_INV, \n        block_size, \n        C\n    )\n\n    contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)\n\n    largest_contour = max(contours, key=cv2.contourArea)\n    largest_area = cv2.contourArea(largest_contour)\n\n    return largest_contour","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-12T15:35:44.276514Z","iopub.execute_input":"2025-11-12T15:35:44.277111Z","iopub.status.idle":"2025-11-12T15:35:44.284350Z","shell.execute_reply.started":"2025-11-12T15:35:44.277084Z","shell.execute_reply":"2025-11-12T15:35:44.283348Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Step 1: Select random samples for each type","metadata":{}},{"cell_type":"code","source":"samples_number = 10\nrandom_samples = train.sample(n=samples_number, random_state=42)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-12T15:35:49.411349Z","iopub.execute_input":"2025-11-12T15:35:49.411662Z","iopub.status.idle":"2025-11-12T15:35:49.420602Z","shell.execute_reply.started":"2025-11-12T15:35:49.411642Z","shell.execute_reply":"2025-11-12T15:35:49.419461Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Step 2: Test each image type","metadata":{}},{"cell_type":"code","source":"all_results = {}\ntotal_iterations = len(image_types) * len(random_samples)\nwith tqdm(total=total_iterations, desc=\"Processing\") as pbar:\n    for img_code, img_description in image_types.items():\n        results = []\n        \n        for idx, row in random_samples.iterrows():\n            sample_id = str(row['id'])\n            img_path = TRAIN_PATH / sample_id / f\"{sample_id}-{img_code}.png\"\n            \n            # Update progress bar description\n            pbar.set_description(f\"Processing {img_code} - {sample_id[:8]}\")\n            \n            # Load and detect\n            img = cv2.imread(str(img_path))\n            img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)  # Convert once here!\n            grid_contour = find_grid_robust(img)\n            \n            results.append({\n                'id': sample_id,\n                'img': img,\n                'img_rgb': img_rgb,  # Store RGB version\n                'contour': grid_contour\n            })\n            \n            pbar.update(1)\n        \n        all_results[img_code] = {\n            'description': img_description,\n            'results': results\n        }\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-12T15:39:23.972534Z","iopub.execute_input":"2025-11-12T15:39:23.972816Z","iopub.status.idle":"2025-11-12T15:40:17.309124Z","shell.execute_reply.started":"2025-11-12T15:39:23.972797Z","shell.execute_reply":"2025-11-12T15:40:17.308185Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Step 3: Visualize all types in separate figures (works with any sample size)","metadata":{}},{"cell_type":"code","source":"for img_code, data in all_results.items():\n    results = data['results']\n    \n    # Calculate grid dimensions - 2 rows per set (original + cropped)\n    n_images = len(results)\n    n_cols = 5\n    n_rows_data = (n_images + n_cols - 1) // n_cols\n    n_rows_total = n_rows_data * 2  # Double rows for original + cropped\n    \n    fig_height = max(6, n_rows_total * 3)\n    \n    # Create figure\n    fig, axes = plt.subplots(n_rows_total, n_cols, figsize=(20, fig_height), dpi=72)\n    axes = axes.reshape(n_rows_total, n_cols)\n    \n    success_count = sum(r['contour'] is not None for r in results)\n    fig.suptitle(f\"{img_code}: {data['description']} | Success: {success_count}/{len(results)} ({100*success_count/len(results):.1f}%)\", \n                 fontsize=16, fontweight='bold')\n    \n    for idx, result in enumerate(results):\n        row = (idx // n_cols) * 2  # Original image row\n        col = idx % n_cols\n        \n        ax_orig = axes[row, col]\n        ax_crop = axes[row + 1, col]\n        \n        img_rgb = result['img_rgb']  # Use pre-converted RGB image\n        \n        # Top row: Original with detection box\n        if result['contour'] is not None:\n            # Draw on RGB image (no conversion needed!)\n            img_display = img_rgb.copy()\n            cv2.drawContours(img_display, [result['contour']], -1, (0, 255, 0), 3)\n            ax_orig.imshow(img_display)\n            ax_orig.set_title(f\"✓ {result['id'][:8]}\", fontsize=10, color='green')\n            \n            # Bottom row: Cropped version\n            x, y, w, h = cv2.boundingRect(result['contour'])\n            cropped = img_rgb[y:y+h, x:x+w]  # Crop from RGB directly\n            ax_crop.imshow(cropped)\n            ax_crop.set_title(f\"Cropped: {w}x{h}px\", fontsize=9, color='blue')\n        else:\n            ax_orig.imshow(img_rgb)\n            ax_orig.set_title(f\"✗ {result['id'][:8]}\", fontsize=10, color='red')\n            \n            # Empty bottom row for failed detections\n            ax_crop.text(0.5, 0.5, 'No crop', ha='center', va='center', \n                        transform=ax_crop.transAxes, fontsize=12, color='red')\n            ax_crop.set_facecolor('#f0f0f0')\n        \n        ax_orig.axis('off')\n        ax_crop.axis('off')\n    \n    # Hide unused subplots\n    total_used = len(results)\n    for idx in range(total_used, n_rows_data * n_cols):\n        row = (idx // n_cols) * 2\n        col = idx % n_cols\n        axes[row, col].axis('off')\n        axes[row + 1, col].axis('off')\n    \n    plt.subplots_adjust(hspace=0.3, wspace=0.1)\n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-12T15:40:17.310950Z","iopub.execute_input":"2025-11-12T15:40:17.311253Z","iopub.status.idle":"2025-11-12T15:41:23.918884Z","shell.execute_reply.started":"2025-11-12T15:40:17.311229Z","shell.execute_reply":"2025-11-12T15:41:23.917934Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Thanks for Reading!\n\nI hope this notebook gave you some useful ideas for ECG grid detection!\n\n**Please comment below if you:**\n- Have questions or suggestions\n- Tried different parameters or approaches\n- Want to discuss results or challenges\n\nFeel free to **fork and experiment** - I'd love to see what you discover!\n\nIf this was helpful, please **upvote!** 👍\n\nHappy coding!","metadata":{}},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}