{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"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":113558,"databundleVersionId":14174843,"sourceType":"competition"}],"dockerImageVersionId":31153,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"c68bbac1","cell_type":"markdown","source":"# 🧠 Key Insights & Recommendations\n\n### 📊 What the Data Shows\n- **Median coverage (P50):** ~0.000% of the image.  \n- **90th percentile (P90):** 90% of forged images have coverage ≤ **1.167%**.  \n- **95th percentile (P95):** 95% of forged images have coverage ≤ **9.864%**.  \n- **Mean coverage:** **1.934%**, skewed by a few very large masks.  \n- **Median forged mask area:** 0 px • **P90:** 7,900 px • **P95:** 25,392 px.  \n\nMost forged regions are **tiny** compared to the full image — often less than 1% of total pixels.\n\n---\n\n### 🔬 Practical Recommendations\n\n**1. Model design**\n- Use **loss functions sensitive to small regions** such as `DiceLoss`, `FocalLoss`, or a combination (e.g. BCE + Dice).\n- Train with **higher input resolution** to preserve small texture and boundary details.\n- Consider multi-scale feature extraction or attention-based segmentation models (e.g. U-Net++, DeepLabv3+, Swin-Unet).\n\n**2. Sampling & class balance**\n- **Oversample** forged images with very small mask coverage to help the model learn subtle forgeries.\n- Optionally undersample or weight authentic images to balance the dataset.\n\n**3. Data augmentations**\n- Apply **gentle augmentations** that preserve pixel-level structure:\n  - ✅ Brightness/contrast shift, flip, rotate, crop, resize.\n  - ⚠️ Avoid heavy blur, strong noise, or compression artifacts — these can destroy small forged cues.\n\n**4. Evaluation & visualization**\n- Use **pixel-level metrics** (Dice/F1/IoU) instead of only image-level accuracy.\n- Visualize both correct and incorrect predictions to ensure the model captures tiny anomalies.\n\n---\n\n### 💡 TL;DR\n> Most forgeries are **small and subtle** — treat this as a fine-grained segmentation task.  \n> High-resolution inputs + small-object-aware loss + balanced sampling = better detection accuracy.","metadata":{}},{"id":"596e998e","cell_type":"code","source":"# ===== Cell: Imports & Paths =====\nimport os, warnings, random, gc\nwarnings.filterwarnings(\"ignore\")\n\nfrom pathlib import Path\nimport numpy as np\nimport pandas as pd\nimport matplotlib.pyplot as plt\nimport cv2\nfrom PIL import Image\nfrom tqdm import tqdm\n\nDATA_DIR = Path(\"/kaggle/input/recodai-luc-scientific-image-forgery-detection\")\nTRAIN_IMG_DIR = DATA_DIR / \"train_images\"\nMASK_DIR      = DATA_DIR / \"train_masks\"\nTEST_IMG_DIR  = DATA_DIR / \"test_images\"\n\nassert (TRAIN_IMG_DIR / \"authentic\").exists(), \"Missing train_images/authentic\"\nassert (TRAIN_IMG_DIR / \"forged\").exists(), \"Missing train_images/forged\"\nassert MASK_DIR.exists(), \"Missing train_masks\"\n\ndef case_id_from_path(p: Path) -> str:\n    return p.stem\n\ndef read_image(path: Path):\n    img = cv2.imread(str(path), cv2.IMREAD_COLOR)\n    if img is None:\n        raise FileNotFoundError(path)\n    return img\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-25T23:45:32.879199Z","iopub.execute_input":"2025-10-25T23:45:32.879584Z","iopub.status.idle":"2025-10-25T23:45:35.733174Z","shell.execute_reply.started":"2025-10-25T23:45:32.879543Z","shell.execute_reply":"2025-10-25T23:45:35.732346Z"}},"outputs":[],"execution_count":null},{"id":"605349e4","cell_type":"code","source":"# ===== Cell: Utilities =====\ndef list_image_paths():\n    auth_paths = sorted((TRAIN_IMG_DIR / \"authentic\").glob(\"*.png\"))\n    forg_paths = sorted((TRAIN_IMG_DIR / \"forged\").glob(\"*.png\"))\n    mask_paths = sorted(MASK_DIR.glob(\"*.npy\"))\n    return auth_paths, forg_paths, mask_paths\n\ndef ensure_binary_mask(m):\n    if m.dtype != np.uint8: m = m.astype(np.uint8)\n    return (m > 0).astype(np.uint8)\n\ndef overlay_mask_bgr(img_bgr, mask_bin, alpha=0.35):\n    if mask_bin is None: return img_bgr\n    ov = img_bgr.copy()\n    ov[mask_bin>0] = (0,0,255)\n    return cv2.addWeighted(img_bgr, 1.0, ov, alpha, 0)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-25T23:45:35.734769Z","iopub.execute_input":"2025-10-25T23:45:35.735278Z","iopub.status.idle":"2025-10-25T23:45:35.743181Z","shell.execute_reply.started":"2025-10-25T23:45:35.735252Z","shell.execute_reply":"2025-10-25T23:45:35.742058Z"}},"outputs":[],"execution_count":null},{"id":"944325e4","cell_type":"code","source":"# ===== Cell: Index (Disambiguated) & Sanity =====\nauth_paths, forg_paths, mask_paths = list_image_paths()\n\nprint(f\"authentic images: {len(auth_paths)}\")\nprint(f\"forged images   : {len(forg_paths)}\")\nprint(f\"mask files      : {len(mask_paths)}\")\n\n# Build separate frames\ndf_auth = pd.DataFrame({\n    \"case_id\": [p.stem for p in auth_paths],\n    \"label\": [\"authentic\"]*len(auth_paths),\n    \"img_path\": [str(p) for p in auth_paths],\n})\n\ndf_forg = pd.DataFrame({\n    \"case_id\": [p.stem for p in forg_paths],\n    \"label\": [\"forged\"]*len(forg_paths),\n    \"img_path\": [str(p) for p in forg_paths],\n})\n\ndf_mask = pd.DataFrame({\n    \"case_id\": [m.stem for m in mask_paths],\n    \"mask_path\": [str(m) for m in mask_paths]\n})\n\n# Merge masks ONLY into forged subset\ndf_forg = df_forg.merge(df_mask, on=\"case_id\", how=\"left\")\ndf_forg[\"has_mask\"] = df_forg[\"mask_path\"].notna()\n\n# Authentic subset must never carry masks\ndf_auth[\"mask_path\"] = None\ndf_auth[\"has_mask\"]  = False\n\n# Concatenate\ndf = pd.concat([df_auth, df_forg], axis=0, ignore_index=True)\n\n# Diagnostics for duplicate case_ids across classes\ndup_case_ids = df.groupby(\"case_id\")[\"label\"].nunique()\nn_dups = (dup_case_ids > 1).sum()\nprint(f\"case_ids present in BOTH authentic and forged: {n_dups}\")\n\n# Sanity\nnum_masks = len(df_mask)\nnum_forged = (df_forg[\"label\"]==\"forged\").sum()\nauth_with_mask = df_auth[\"has_mask\"].sum()\nforged_without_mask = ((df_forg[\"label\"]==\"forged\") & ~df_forg[\"has_mask\"]).sum()\n\nprint(\"\\n=== Sanity ===\")\nprint(\"masks == forged?              \", num_masks, \"==\", num_forged)\nprint(\"authentic with mask (should 0):\", auth_with_mask)\nprint(\"forged without mask (should 0):\", forged_without_mask)\n\n# Assertions\nassert num_masks == num_forged, \"Number of masks must equal number of forged images.\"\nassert auth_with_mask == 0, \"Authentic images must have no masks.\"\nassert forged_without_mask == 0, \"All forged images must have exactly one mask.\"\n\ndf.head()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-25T23:45:35.744107Z","iopub.execute_input":"2025-10-25T23:45:35.744666Z","iopub.status.idle":"2025-10-25T23:45:36.057270Z","shell.execute_reply.started":"2025-10-25T23:45:35.744633Z","shell.execute_reply":"2025-10-25T23:45:36.056299Z"}},"outputs":[],"execution_count":null},{"id":"4fdb1040","cell_type":"code","source":"# ===== Cell: Image Size Stats by Class =====\nsize_rows = []\nsample_df = df.sample(min(1500, len(df)), random_state=42)\n\nfor _, r in tqdm(sample_df.iterrows(), total=len(sample_df)):\n    img = read_image(Path(r[\"img_path\"]))\n    h, w = img.shape[:2]\n    size_rows.append({\"case_id\": r[\"case_id\"], \"label\": r[\"label\"], \"img_h\": h, \"img_w\": w})\n\nsizes = pd.DataFrame(size_rows)\ndisplay(sizes.describe())\n\nplt.figure(figsize=(12,5))\nplt.subplot(1,2,1)\nplt.hist(sizes.loc[sizes.label=='authentic','img_h'], bins=40)\nplt.title(\"Image height — authentic\")\nplt.subplot(1,2,2)\nplt.hist(sizes.loc[sizes.label=='forged','img_h'], bins=40)\nplt.title(\"Image height — forged\")\nplt.tight_layout(); plt.show()\n\nplt.figure(figsize=(12,5))\nplt.subplot(1,2,1)\nplt.hist(sizes.loc[sizes.label=='authentic','img_w'], bins=40)\nplt.title(\"Image width — authentic\")\nplt.subplot(1,2,2)\nplt.hist(sizes.loc[sizes.label=='forged','img_w'], bins=40)\nplt.title(\"Image width — forged\")\nplt.tight_layout(); plt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-25T23:45:36.059145Z","iopub.execute_input":"2025-10-25T23:45:36.059414Z","iopub.status.idle":"2025-10-25T23:46:54.635995Z","shell.execute_reply.started":"2025-10-25T23:45:36.059390Z","shell.execute_reply":"2025-10-25T23:46:54.634926Z"}},"outputs":[],"execution_count":null},{"id":"bf18ccaa","cell_type":"code","source":"# ===== Cell: Channel Intensity Stats =====\ndef channel_stats(paths, k=300):\n    paths = list(paths)\n    if len(paths) == 0:\n        return np.array([0,0,0], dtype=float), np.array([0,0,0], dtype=float)\n    paths = random.sample(paths, min(k, len(paths)))\n    m, s = [], []\n    for p in paths:\n        img = cv2.imread(str(p), cv2.IMREAD_COLOR) / 255.0\n        m.append(img.mean(axis=(0,1)))  # BGR\n        s.append(img.std(axis=(0,1)))\n    m, s = np.array(m), np.array(s)\n    return m.mean(axis=0), s.mean(axis=0)\n\nauth_m, auth_s = channel_stats(df.loc[df.label=='authentic','img_path'])\nforg_m, forg_s = channel_stats(df.loc[df.label=='forged','img_path'])\n\nprint(\"authentic mean BGR:\", auth_m, \"std:\", auth_s)\nprint(\"forged mean BGR   :\", forg_m, \"std:\", forg_s)\n\nplt.figure(figsize=(8,4))\nplt.bar(['B-auth','G-auth','R-auth','B-forg','G-forg','R-forg'], np.r_[auth_m, forg_m])\nplt.title(\"Average channel intensity (BGR)\")\nplt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-25T23:46:54.637220Z","iopub.execute_input":"2025-10-25T23:46:54.637535Z","iopub.status.idle":"2025-10-25T23:48:14.128978Z","shell.execute_reply.started":"2025-10-25T23:46:54.637509Z","shell.execute_reply":"2025-10-25T23:48:14.127697Z"}},"outputs":[],"execution_count":null},{"id":"a8019501","cell_type":"code","source":"# ===== Cell: Mask Stats (Forged Only) — clearer charts + auto-explanations =====\nforged_df = df[df.label == 'forged'].copy()\n\nareas, coverages, sizes2 = [], [], []\nbad_masks = []\n\nfor _, r in tqdm(forged_df.iterrows(), total=len(forged_df)):\n    img = read_image(Path(r['img_path']))\n    H, W = img.shape[:2]\n\n    try:\n        m = np.load(r['mask_path'])\n        if m is None or m.size == 0:\n            raise ValueError(\"Empty mask\")\n\n        # Ensure 2D binary mask\n        m = (m > 0).astype(np.uint8)\n        if m.ndim == 3:\n            m = m[..., 0]\n        if m.shape != (H, W):\n            if m.shape[::-1] == (H, W):\n                m = m.T\n            else:\n                m = cv2.resize(m, (W, H), interpolation=cv2.INTER_NEAREST)\n\n        area = int(m.sum())\n        cov = area / (H * W)  # 0..1\n    except Exception as e:\n        bad_masks.append((r['case_id'], str(e)))\n        area, cov = 0, 0.0\n\n    areas.append(area)\n    coverages.append(cov)\n    sizes2.append((H, W))\n\nforged_df['mask_area'] = areas\nforged_df['coverage']  = coverages\nforged_df['img_h']     = [s[0] for s in sizes2]\nforged_df['img_w']     = [s[1] for s in sizes2]\n\n# Basic table\ndisplay(forged_df[['mask_area','coverage','img_h','img_w']].describe())\n\n# --------- Helpful summary stats we will annotate on plots ----------\nimport numpy as np\nN = len(forged_df)\ncov = forged_df['coverage'].values\narea = forged_df['mask_area'].values\ncov_pct = cov * 100.0\n\ndef q(x, p): \n    return float(np.percentile(x, p))\n\nstats = {\n    \"cov_p50\": q(cov_pct, 50),\n    \"cov_p75\": q(cov_pct, 75),\n    \"cov_p90\": q(cov_pct, 90),\n    \"cov_p95\": q(cov_pct, 95),\n    \"area_p50\": q(area, 50),\n    \"area_p90\": q(area, 90),\n    \"area_p95\": q(area, 95),\n    \"mean_cov_pct\": float(cov_pct.mean())\n}\n\n# --------- Coverage histogram (with percentiles marked) ----------\nplt.figure(figsize=(12,4))\nplt.hist(cov_pct, bins=60)\nplt.title(\"How big are forged regions? (Coverage % of image)\")\nplt.xlabel(\"Coverage (%)\"); plt.ylabel(\"Image count\")\n\nfor p in [50, 90, 95]:\n    v = q(cov_pct, p)\n    plt.axvline(v, linestyle=\"--\")\n    plt.text(v, plt.ylim()[1]*0.9, f\"P{p}={v:.3f}%\", rotation=90, va=\"top\")\n\nplt.tight_layout()\nplt.show()\n\n# --------- Cumulative distribution (CDF) of coverage ----------\nsorted_cov = np.sort(cov_pct)\ncdf = np.arange(1, N+1) / N\n\nplt.figure(figsize=(12,4))\nplt.plot(sorted_cov, cdf)\nplt.title(\"Cumulative fraction of images by coverage size\")\nplt.xlabel(\"Coverage (%)\"); plt.ylabel(\"Fraction of images ≤ x\")\n\nfor p in [50, 90, 95]:\n    v = q(cov_pct, p)\n    frac = p/100.0\n    plt.axvline(v, linestyle=\"--\")\n    plt.hlines(frac, xmin=0, xmax=v, linestyles=\"--\")\n    plt.text(v, frac, f\"P{p}={v:.3f}%\", va=\"bottom\", ha=\"right\")\n\nplt.tight_layout()\nplt.show()\n\n# --------- Coverage category bar chart (easier to read) ----------\nbins = [0, 0.1, 0.5, 1.0, 2.0, 5.0, np.inf]  # in percent\nlabels = [\"≤0.1%\", \"0.1–0.5%\", \"0.5–1%\", \"1–2%\", \"2–5%\", \">5%\"]\ncats = pd.cut(cov_pct, bins=bins, labels=labels, include_lowest=True, right=True)\ncounts = cats.value_counts().reindex(labels).fillna(0).astype(int)\n\nplt.figure(figsize=(10,4))\nbars = plt.bar(range(len(labels)), counts.values)\nplt.xticks(range(len(labels)), labels)\nplt.ylabel(\"Image count\")\nplt.title(\"Coverage buckets (how many images fall in each size range)\")\n\n# annotate bars with counts + percentage\nfor i, b in enumerate(bars):\n    cnt = counts.values[i]\n    pct = 100.0 * cnt / N\n    plt.text(b.get_x() + b.get_width()/2, b.get_height() * 0.98, f\"{cnt}\\n({pct:.1f}%)\",\n             ha=\"center\", va=\"top\")\n\nplt.tight_layout()\nplt.show()\n\n# --------- Boxplots (area and coverage) ----------\nplt.figure(figsize=(12,4))\nplt.subplot(1,2,1)\nplt.boxplot(area, vert=True, showfliers=False)\nplt.title(\"Mask pixel area (no outliers)\")\nplt.ylabel(\"Pixels\")\n\nplt.subplot(1,2,2)\nplt.boxplot(cov_pct, vert=True, showfliers=False)\nplt.title(\"Coverage % (no outliers)\")\nplt.ylabel(\"Coverage (%)\")\n\nplt.tight_layout()\nplt.show()\n\n# --------- Textual takeaways printed to output ----------\nprint(\"==== What to read from the charts ====\")\nprint(f\"- Median coverage (P50): ~{stats['cov_p50']:.3f}% of the image.\")\nprint(f\"- 90% of forged images have coverage ≤ {stats['cov_p90']:.3f}% (P90).\")\nprint(f\"- 95% of forged images have coverage ≤ {stats['cov_p95']:.3f}% (P95).\")\nprint(f\"- Mean coverage: {stats['mean_cov_pct']:.3f}% (skewed if a few masks are large).\")\nprint(f\"- Median forged mask area (pixels): {stats['area_p50']:.0f}; P90: {stats['area_p90']:.0f}; P95: {stats['area_p95']:.0f}.\")\n\nprint(\"\\nPractical implications:\")\nprint(\"- Most forged regions are SMALL relative to the image → consider losses like Dice/Focal and higher input resolution.\")\nprint(\"- Sampling: oversample forged images with tiny coverage to help the model learn subtle patterns.\")\nprint(\"- Augmentations should preserve tiny artifacts (avoid heavy blurs that erase small masks).\")\n\n# --------- Bad masks (if any) ----------\nif bad_masks:\n    print(f\"\\n⚠️ {len(bad_masks)} problematic masks detected (showing first 5):\")\n    print(pd.DataFrame(bad_masks, columns=['case_id', 'error']).head())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-25T23:56:51.773166Z","iopub.execute_input":"2025-10-25T23:56:51.773676Z","iopub.status.idle":"2025-10-25T23:58:57.511564Z","shell.execute_reply.started":"2025-10-25T23:56:51.773647Z","shell.execute_reply":"2025-10-25T23:58:57.510417Z"}},"outputs":[],"execution_count":null},{"id":"24c64221","cell_type":"code","source":"# ===== REPLACE the previous triptych cell with this version (fixes boolean assignment) =====\n# Big, clear side-by-side: Authentic | Forged | Overlay — with robust overlay drawing\n\nimport numpy as np, pandas as pd, cv2, matplotlib.pyplot as plt\nfrom pathlib import Path\n\ndef safe_imread(path: str):\n    if path is None or (isinstance(path, float) and np.isnan(path)):\n        raise FileNotFoundError(\"Image path is NaN\")\n    img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)\n    if img is None:\n        raise FileNotFoundError(f\"cv2.imread failed: {path}\")\n    if img.ndim == 2:\n        img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)\n    elif img.shape[2] == 4:\n        img = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)\n    return img\n\ndef safe_load_mask(path: str):\n    if path is None or (isinstance(path, float) and np.isnan(path)):\n        return None\n    m = np.load(str(path))\n    if m is None or m.size == 0:\n        return None\n    m = (m > 0).astype(np.uint8)\n    if m.ndim > 2:\n        m = np.squeeze(m)\n    if m.ndim != 2:\n        return None\n    return m\n\ndef prepare_mask_for_image(mask_arr, H, W):\n    if mask_arr is None:\n        return np.zeros((H, W), dtype=np.uint8)\n    m = mask_arr\n    if m.shape == (H, W):\n        pass\n    elif m.shape == (W, H):\n        m = m.T\n    else:\n        if m.shape[0] == 0 or m.shape[1] == 0:\n            return np.zeros((H, W), dtype=np.uint8)\n        m = cv2.resize(m, (W, H), interpolation=cv2.INTER_NEAREST)\n    return (m > 0).astype(np.uint8)\n\ndef overlay_with_contours(img_bgr, mask_bin, alpha=0.30, fill_color=(0,0,255), ctr_color=(0,255,255), ctr_th=3):\n    \"\"\"\n    Safe overlay: use per-channel assignment to avoid boolean-broadcast errors.\n    \"\"\"\n    H, W = img_bgr.shape[:2]\n    m = prepare_mask_for_image(mask_bin, H, W)  # 2D (H,W)\n    m_bool = m.astype(bool)\n\n    overlay = img_bgr.copy()\n    # Per-channel assignment (avoids NumPy boolean assignment shape issues)\n    overlay[m_bool, 0] = fill_color[0]\n    overlay[m_bool, 1] = fill_color[1]\n    overlay[m_bool, 2] = fill_color[2]\n\n    vis = cv2.addWeighted(img_bgr, 1.0, overlay, alpha, 0)\n\n    # Draw contours\n    m8 = (m * 255).astype(np.uint8)\n    cnts, _ = cv2.findContours(m8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    if len(cnts) > 0:\n        cv2.drawContours(vis, cnts, -1, ctr_color, ctr_th)\n\n    area = int(m.sum())\n    cov = (100.0 * area) / max(1, H*W)\n    return vis, area, cov\n\ndef build_pairs(df):\n    a = df[df.label==\"authentic\"][[\"case_id\",\"img_path\"]].rename(columns={\"img_path\":\"auth_img\"})\n    f = df[df.label==\"forged\"][[\"case_id\",\"img_path\",\"mask_path\"]].rename(columns={\"img_path\":\"forg_img\"})\n    return a.merge(f, on=\"case_id\", how=\"inner\")\n\ndef show_triptychs(df, n=6, seed=42, min_mask_area_px=20, dpi=180):\n    pairs = build_pairs(df)\n    if len(pairs) == 0:\n        print(\"No shared case_id between authentic and forged.\")\n        return\n\n    rng = np.random.RandomState(seed)\n    order = rng.permutation(len(pairs))\n\n    selected, failures = [], []\n    for idx in order:\n        r = pairs.iloc[idx]\n        try:\n            img_a = safe_imread(r[\"auth_img\"])\n            img_f = safe_imread(r[\"forg_img\"])\n            H, W = img_f.shape[:2]\n            raw_m = safe_load_mask(r.get(\"mask_path\", None))\n            m = prepare_mask_for_image(raw_m, H, W)\n            area = int(m.sum())\n            if area < min_mask_area_px:\n                continue\n            cov = (100.0 * area) / max(1, H*W)\n            selected.append((r[\"case_id\"], img_a, img_f, m, area, cov))\n            if len(selected) >= n:\n                break\n        except Exception as e:\n            failures.append((r.get(\"case_id\",\"?\"), str(e)))\n\n    if len(selected) == 0:\n        print(\"No valid triptychs. First few failures:\")\n        print(pd.DataFrame(failures, columns=[\"case_id\",\"error\"]).head())\n        return\n\n    rows = len(selected)\n    fig, axes = plt.subplots(rows, 3, figsize=(18, 5*rows), dpi=dpi)\n    if rows == 1:\n        axes = np.array([axes])\n\n    for rix, (cid, img_a, img_f, m, area, cov) in enumerate(selected):\n        # Authentic\n        axes[rix,0].imshow(cv2.cvtColor(img_a, cv2.COLOR_BGR2RGB), interpolation=\"nearest\")\n        axes[rix,0].set_title(f\"{cid} — Authentic\", fontsize=14)\n        axes[rix,0].axis(\"off\")\n\n        # Forged\n        axes[rix,1].imshow(cv2.cvtColor(img_f, cv2.COLOR_BGR2RGB), interpolation=\"nearest\")\n        axes[rix,1].set_title(f\"{cid} — Forged\", fontsize=14)\n        axes[rix,1].axis(\"off\")\n\n        # Overlay (safe)\n        vis, area_safe, cov_safe = overlay_with_contours(img_f, m, alpha=0.35)\n        axes[rix,2].imshow(cv2.cvtColor(vis, cv2.COLOR_BGR2RGB), interpolation=\"nearest\")\n        axes[rix,2].set_title(f\"{cid} — Overlay\\narea={area_safe} px | cov={cov_safe:.3f}%\", fontsize=14)\n        axes[rix,2].axis(\"off\")\n\n    plt.tight_layout()\n    plt.show()\n\n    if failures:\n        print(f\"⚠️ Skipped {len(failures)} problematic pair(s). Showing first 5:\")\n        print(pd.DataFrame(failures, columns=[\"case_id\",\"error\"]).head())\n\n# Run (larger, clearer; skips tiny masks)\nshow_triptychs(df, n=6, seed=40, min_mask_area_px=20, dpi=180)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-26T00:22:06.990621Z","iopub.execute_input":"2025-10-26T00:22:06.991010Z","iopub.status.idle":"2025-10-26T00:22:11.605098Z","shell.execute_reply.started":"2025-10-26T00:22:06.990986Z","shell.execute_reply":"2025-10-26T00:22:11.602259Z"}},"outputs":[],"execution_count":null},{"id":"d4527b49","cell_type":"code","source":"# ===== Cell: Save Tidy Indices =====\ndf.to_csv(\"/kaggle/working/train_index_images_masks_disambiguated.csv\", index=False)\ndf[df.label=='forged'].to_csv(\"/kaggle/working/forged_with_mask_stats_disambiguated.csv\", index=False)\nprint(\"Saved:\")\nprint(\" - /kaggle/working/train_index_images_masks_disambiguated.csv\")\nprint(\" - /kaggle/working/forged_with_mask_stats_disambiguated.csv\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-26T00:18:33.048056Z","iopub.execute_input":"2025-10-26T00:18:33.048882Z","iopub.status.idle":"2025-10-26T00:18:33.115016Z","shell.execute_reply.started":"2025-10-26T00:18:33.048845Z","shell.execute_reply":"2025-10-26T00:18:33.113937Z"}},"outputs":[],"execution_count":null}]}