{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","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":"nvidiaTeslaT4","dataSources":[{"sourceId":113558,"databundleVersionId":14456136,"sourceType":"competition"},{"sourceId":621632,"sourceType":"modelInstanceVersion","modelInstanceId":464638,"modelId":480460},{"sourceId":623453,"sourceType":"modelInstanceVersion","modelInstanceId":469082,"modelId":484577},{"sourceId":624316,"sourceType":"modelInstanceVersion","modelInstanceId":469800,"modelId":484577},{"sourceId":626704,"sourceType":"modelInstanceVersion","modelInstanceId":468729,"modelId":484577}],"dockerImageVersionId":31154,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"\n# Scientific Image Forgery — **Submission Notebook (Inference Only)**\n\nThis notebook **loads a trained model** (TorchScript) and **generates `submission.csv`** for the competition.\n\n- No training here — pure inference.  \n- Follows the competition rule:  \n  - Output `\"authentic\"` if no forged region is detected.  \n  - Otherwise, output **RLE-encoded instance masks** using the **official encoding** (semicolons between instances).\n","metadata":{}},{"cell_type":"markdown","source":"## 1) Environment Report","metadata":{}},{"cell_type":"code","source":"\nimport sys, platform, torch, numpy as np, pandas as pd\nprint(\"Python:\", sys.version.split()[0])\nprint(\"OS:\", platform.platform())\nprint(\"Torch:\", torch.__version__)\nprint(\"CUDA available:\", torch.cuda.is_available())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-13T20:46:31.604975Z","iopub.execute_input":"2025-11-13T20:46:31.605667Z","iopub.status.idle":"2025-11-13T20:46:31.610626Z","shell.execute_reply.started":"2025-11-13T20:46:31.605642Z","shell.execute_reply":"2025-11-13T20:46:31.609736Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 2) Paths & Parameters","metadata":{}},{"cell_type":"code","source":"\nfrom pathlib import Path\nimport os\n\n# =========================\n# EDIT THESE TWO DIRECTORIES\n# =========================\nCOMP_DIR   = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection\"\nTEST_DIR   = f\"{COMP_DIR}/test_images\"\n\n# Model artifact directory (where you uploaded your .pt)\nMODEL_DIR_A  = \"/kaggle/input/forged-mt/pytorch/v2/1\" \nMODEL_FILE_A = \"/kaggle/input/scientific-image-forgery-detection/pytorch/default/7/model_deeplabv3_fold0_ts.pt\"               # TorchScript file saved earlier\nMODEL_DIR_B  = \"/kaggle/input/forged-mt/pytorch/v3/1\" \nMODEL_FILE_B = \"/kaggle/input/scientific-image-forgery-detection/pytorch/default/7/model_deeplabv3_fold0_ts.pt\"               # TorchScript file saved earlier\n\n# Output\nOUT_DIR = \"/kaggle/working\"\nos.makedirs(OUT_DIR, exist_ok=True)\nSUB_PATH = f\"{OUT_DIR}/submission.csv\"\n\n# Inference params\nIMAGE_SIZE  = 512\nTHRESHOLD   = 0.5\nMIN_AREA    = 64\nUSE_TTA     = True\n\nDEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-13T20:46:31.628469Z","iopub.execute_input":"2025-11-13T20:46:31.628924Z","iopub.status.idle":"2025-11-13T20:46:31.633691Z","shell.execute_reply.started":"2025-11-13T20:46:31.628905Z","shell.execute_reply":"2025-11-13T20:46:31.633126Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 3) Utilities — RLE, Preprocessing, Post-processing","metadata":{}},{"cell_type":"code","source":"\nimport json\nimport numpy as np\nimport cv2\nfrom PIL import Image\nimport torch\n\n# --- Official RLE encode from the competition metric (user-provided) ---\ntry:\n    import numba\n    from numba import njit\nexcept Exception as e:\n    numba = None\n    def njit(*args, **kwargs):\n        def deco(f): return f\n        return deco\n\n@njit\ndef _rle_encode_jit(x: np.ndarray, fg_val: int = 1) -> list:\n    dots = np.where(x.T.flatten() == fg_val)[0]\n    run_lengths = []\n    prev = -2\n    for b in dots:\n        if b > prev + 1:\n            run_lengths.extend((b + 1, 0))\n        run_lengths[-1] += 1\n        prev = b\n    return run_lengths\n\ndef rle_encode(masks: list[np.ndarray], fg_val: int = 1) -> str:\n    return ';'.join([json.dumps(_rle_encode_jit(m.astype(np.uint8), fg_val)) for m in masks])\n\n\ndef load_rgb(path: str) -> Image.Image:\n    return Image.open(path).convert(\"RGB\")\n\ndef preprocess_pil(img: Image.Image, size: int) -> torch.Tensor:\n    img_r = img.resize((size, size), resample=Image.BILINEAR)\n    x = torch.from_numpy(np.array(img_r, dtype=np.float32) / 255.0).permute(2,0,1).unsqueeze(0)\n    return x\n\ndef predict_prob(model, img: Image.Image, size: int, tta: bool = True) -> np.ndarray:\n    x = preprocess_pil(img, size).to(DEVICE)\n    with torch.no_grad():\n        logits = model(x)  # TorchScript logits [B,1,H,W]\n        prob = torch.sigmoid(logits)[0,0]\n        if tta:\n            xh = torch.flip(x, dims=[3])\n            ph = torch.sigmoid(model(xh))[0,0]; ph = torch.flip(ph, dims=[1])\n            xv = torch.flip(x, dims=[2])\n            pv = torch.sigmoid(model(xv))[0,0]; pv = torch.flip(pv, dims=[0])\n            prob = (prob + ph + pv) / 3.0\n        return prob.detach().cpu().numpy()\n\ndef prob_to_instances(prob: np.ndarray, thr: float = 0.5, min_area: int = 64) -> list[np.ndarray]:\n    mask = (prob > thr).astype(np.uint8)\n    if mask.sum() < min_area:\n        return []\n    num, lbl, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)\n    insts = []\n    for i in range(1, num):\n        area = int(stats[i, cv2.CC_STAT_AREA])\n        if area >= min_area:\n            inst = (lbl == i).astype(np.uint8)\n            insts.append(inst)\n    return insts\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-13T20:46:31.652298Z","iopub.execute_input":"2025-11-13T20:46:31.652776Z","iopub.status.idle":"2025-11-13T20:46:31.664795Z","shell.execute_reply.started":"2025-11-13T20:46:31.652759Z","shell.execute_reply":"2025-11-13T20:46:31.664138Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 4) Load TorchScript Model","metadata":{}},{"cell_type":"code","source":"import torch, os\n# --- Model A ---\nts_path = str(Path(MODEL_DIR_A) / MODEL_FILE_A)\nassert os.path.exists(ts_path), f\"Model file not found: {ts_path}\"\nmodel_a = torch.jit.load(ts_path, map_location=DEVICE).eval()\ntry:\n    for p in model_a.parameters():\n        p.requires_grad_(False)\nexcept Exception:\n    pass\nprint(\"Loaded TorchScript model A from:\", ts_path)\n\n# --- Model B ---\nts_path = str(Path(MODEL_DIR_B) / MODEL_FILE_B)\nassert os.path.exists(ts_path), f\"Model file not found: {ts_path}\"\nmodel_b = torch.jit.load(ts_path, map_location=DEVICE).eval()\ntry:\n    for p in model_b.parameters():\n        p.requires_grad_(False)\nexcept Exception:\n    pass\nprint(\"Loaded TorchScript model B from:\", ts_path)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-13T20:46:31.665861Z","iopub.execute_input":"2025-11-13T20:46:31.666061Z","iopub.status.idle":"2025-11-13T20:46:32.296202Z","shell.execute_reply.started":"2025-11-13T20:46:31.666045Z","shell.execute_reply":"2025-11-13T20:46:32.29541Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import shutil\n\nsrc = '/kaggle/input/scientific-image-forgery-detection/pytorch/default/7/model_deeplabv3_fold0_ts.pt'\ndst = '/kaggle/working/model_deeplabv3_fold0_ts.pt'\n\nshutil.copy(src, dst)\nprint(\"✅ Model saved to Output folder:\", dst)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-13T20:46:32.297421Z","iopub.execute_input":"2025-11-13T20:46:32.297767Z","iopub.status.idle":"2025-11-13T20:46:32.583246Z","shell.execute_reply.started":"2025-11-13T20:46:32.297742Z","shell.execute_reply":"2025-11-13T20:46:32.582191Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 5) Run Inference on Test Set and Build `submission.csv`","metadata":{}},{"cell_type":"code","source":"# 🧪 Inference + Submission (two-model ensemble + orig/H/V flip TTA merge) with low-confidence \"authentic\" gate\nimport os, glob, json\nfrom pathlib import Path\nimport pandas as pd\nimport numpy as np\nfrom PIL import ImageOps\nimport cv2\n\n# --- Sanity checks for required globals from previous cells ---\nneeded_syms = [\n    \"load_rgb\", \"predict_prob\", \"prob_to_instances\", \"rle_encode\",\n    \"IMAGE_SIZE\", \"USE_TTA\", \"THRESHOLD\", \"MIN_AREA\",\n    \"TEST_DIR\", \"COMP_DIR\", \"model_a\", \"model_b\"\n]\nfor _n in needed_syms:\n    assert _n in globals(), f\"Missing `{_n}`. Run the earlier setup/inference utility cells.\"\n\n# Output path (define if not set)\nif \"SUB_PATH\" not in globals():\n    OUT_DIR = \"/kaggle/working\"\n    os.makedirs(OUT_DIR, exist_ok=True)\n    SUB_PATH = str(Path(OUT_DIR) / \"submission.csv\")\n\n# ---- TTA + merge helpers (TWO-MODEL ENSEMBLE) ----\ndef prob_tta_merged_two_models(pil_img, size, tta, model_a, model_b):\n    \"\"\"\n    Return merged prob map using mean over:\n      - orientations: original, H-flip, V-flip\n      - models: model_a and model_b\n    i.e., average of 6 maps after unflipping.\n    Also returns the per-orientation averages (across models) for convenience.\n    \"\"\"\n    # model A\n    a_o = predict_prob(model_a, pil_img, size=size, tta=tta)\n    a_h = np.fliplr(predict_prob(model_a, ImageOps.mirror(pil_img), size=size, tta=tta))\n    a_v = np.flipud(predict_prob(model_a, ImageOps.flip(pil_img),   size=size, tta=tta))\n\n    # model B\n    b_o = predict_prob(model_b, pil_img, size=size, tta=tta)\n    b_h = np.fliplr(predict_prob(model_b, ImageOps.mirror(pil_img), size=size, tta=tta))\n    b_v = np.flipud(predict_prob(model_b, ImageOps.flip(pil_img),   size=size, tta=tta))\n\n    # per-orientation average across models\n    p_o = (a_o + b_o) / 3.6\n    p_h = (a_h + b_h) / 3.6\n    p_v = (a_v + b_v) / 3.6\n\n    # merge orientations\n    p_m = (p_o + p_h + p_v) / 3.0\n    return np.clip(p_m, 0.0, 1.0), p_o, p_h, p_v\n\n# ---- Low-confidence \"authentic\" gate ----\nLOW_CONF_MAX_PROB   = 0.06\nLOW_VIZ_THR         = 0.04\nLOW_CONF_MIN_PIXELS = 256\n\ndef is_low_confidence(prob_resized):\n    if float(prob_resized.max()) >= LOW_CONF_MAX_PROB:\n        return False\n    cover = int((prob_resized >= LOW_VIZ_THR).sum())\n    return cover < LOW_CONF_MIN_PIXELS\n\n# --- Collect test images ---\ntest_paths = sorted(glob.glob(str(Path(TEST_DIR) / \"*\")))\nprint(\"Test images:\", len(test_paths))\n\nrows = []\n\n# If there are test images, run inference\nif len(test_paths) > 0:\n    for p in test_paths:\n        case_id = Path(p).stem\n        img = load_rgb(p)\n\n        # 1) Two-model + flip TTA (resized grid)\n        prob_resized, p_o, p_h, p_v = prob_tta_merged_two_models(\n            img, size=IMAGE_SIZE, tta=USE_TTA, model_a=model_a, model_b=model_b\n        )\n\n        # 2) Low-confidence gate\n        if is_low_confidence(prob_resized):\n            annot = \"authentic\"\n        else:\n            # 3) Instances at standard threshold\n            instances = prob_to_instances(prob_resized, thr=THRESHOLD, min_area=MIN_AREA)\n            annot = \"authentic\" if len(instances) == 0 else rle_encode(instances)\n\n        rows.append({\"case_id\": case_id, \"annotation\": annot})\n\n# Build dataframe (may be empty if no test files found)\nsub = pd.DataFrame(rows, columns=[\"case_id\", \"annotation\"])\n\n# --- Align with sample_submission order & types ---\nss_path = str(Path(COMP_DIR) / \"sample_submission.csv\")\nif os.path.exists(ss_path):\n    ss = pd.read_csv(ss_path)\n    ss[\"case_id\"] = ss[\"case_id\"].astype(str)\n    if not sub.empty:\n        sub[\"case_id\"] = sub[\"case_id\"].astype(str)\n        sub = ss[[\"case_id\"]].merge(sub, on=\"case_id\", how=\"left\")\n        sub[\"annotation\"] = sub[\"annotation\"].fillna(\"authentic\")\n    else:\n        sub = ss[[\"case_id\"]].copy()\n        sub[\"case_id\"] = sub[\"case_id\"].astype(str)\n        sub[\"annotation\"] = \"authentic\"\nelse:\n    if not sub.empty:\n        sub[\"case_id\"] = sub[\"case_id\"].astype(str)\n    else:\n        print(\"Warning: No test images and no sample_submission.csv found.\")\n        sub = pd.DataFrame(columns=[\"case_id\", \"annotation\"])\n\n# --- Save submission ---\nsub.to_csv(SUB_PATH, index=False)\nprint(\"✅ Wrote submission:\", SUB_PATH)\ndisplay(sub.head(10))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-13T20:46:32.58414Z","iopub.execute_input":"2025-11-13T20:46:32.584415Z","iopub.status.idle":"2025-11-13T20:46:35.264844Z","shell.execute_reply.started":"2025-11-13T20:46:32.584393Z","shell.execute_reply":"2025-11-13T20:46:35.264223Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# =====================\n# Single-image visualization (TWO-MODEL ensemble + orig/H/V flips + difference plots)\n# =====================\nimport numpy as np\nimport matplotlib.pyplot as plt\nimport cv2\nfrom PIL import ImageOps\nfrom pathlib import Path\n\nassert \"model_a\" in globals() and \"model_b\" in globals(), \"Missing `model_a`/`model_b`.\"\n\nif len(test_paths) == 1:\n    p = test_paths[0]\n\n    # --- Viz parameters ---\n    VIS_THR  = 0.02\n    CMAP_MAX = 0.05\n\n    # --- local helpers ---\n    def overlay_alpha(img_rgb, mask01, color=(255,0,0), alpha=0.35):\n        if mask01.shape[:2] != img_rgb.shape[:2]:\n            mask01 = cv2.resize(mask01, (img_rgb.shape[1], img_rgb.shape[0]), interpolation=cv2.INTER_NEAREST)\n        if img_rgb.ndim == 2:\n            base = cv2.cvtColor(img_rgb, cv2.COLOR_GRAY2BGR)\n        elif img_rgb.shape[2] == 4:\n            base = cv2.cvtColor(img_rgb, cv2.COLOR_RGBA2BGR)\n        else:\n            base = img_rgb.copy()\n        if mask01.sum() == 0:\n            return base\n        overlay = base.copy()\n        overlay[mask01 > 0] = (0.65*base[mask01 > 0] + 0.35*np.array(color)).astype(np.uint8)\n        return overlay\n\n    def draw_outline(img_rgb, mask01, color=(0,255,0), thickness=2):\n        if mask01.shape[:2] != img_rgb.shape[:2]:\n            mask01 = cv2.resize(mask01, (img_rgb.shape[1], img_rgb.shape[0]), interpolation=cv2.INTER_NEAREST)\n        if img_rgb.ndim == 2:\n            vis = cv2.cvtColor(img_rgb, cv2.COLOR_GRAY2BGR)\n        elif img_rgb.shape[2] == 4:\n            vis = cv2.cvtColor(img_rgb, cv2.COLOR_RGBA2BGR)\n        else:\n            vis = img_rgb.copy()\n        if mask01.sum() == 0:\n            return vis\n        cnts, _ = cv2.findContours(mask01.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n        cv2.drawContours(vis, cnts, -1, color, thickness, lineType=cv2.LINE_AA)\n        return vis\n\n    def to_orig_size(prob_resized, W, H):\n        return cv2.resize(prob_resized, (W, H), interpolation=cv2.INTER_LINEAR)\n\n    def bin_mask(prob_map, thr):\n        return (prob_map >= thr).astype(np.uint8)\n\n    # --- load image ---\n    img_pil = load_rgb(p)\n    img_np  = np.array(img_pil)\n    H, W    = img_np.shape[:2]\n\n    # --- compute TWO-MODEL, per-orientation probabilities ---\n    a_orig = predict_prob(model_a, img_pil, size=IMAGE_SIZE, tta=USE_TTA)\n    b_orig = predict_prob(model_b, img_pil, size=IMAGE_SIZE, tta=USE_TTA)\n\n    a_h = np.fliplr(predict_prob(model_a, ImageOps.mirror(img_pil), size=IMAGE_SIZE, tta=USE_TTA))\n    b_h = np.fliplr(predict_prob(model_b, ImageOps.mirror(img_pil), size=IMAGE_SIZE, tta=USE_TTA))\n\n    a_v = np.flipud(predict_prob(model_a, ImageOps.flip(img_pil), size=IMAGE_SIZE, tta=USE_TTA))\n    b_v = np.flipud(predict_prob(model_b, ImageOps.flip(img_pil), size=IMAGE_SIZE, tta=USE_TTA))\n\n    # Averages\n    prob_orig = (a_orig + b_orig) / 3.6\n    prob_h    = (a_h    + b_h)    / 3.6\n    prob_v    = (a_v    + b_v)    / 3.6\n    prob_m    = np.clip((prob_orig + prob_h + prob_v) / 3.0, 0.0, 1.0)\n\n    # Resize to original\n    p_a = to_orig_size(a_orig, W, H)\n    p_b = to_orig_size(b_orig, W, H)\n    p_m = to_orig_size(prob_m, W, H)\n\n    # --- difference maps ---\n    diff_ab = np.clip(p_a - p_b, -1, 1)\n    diff_aM = np.clip(p_a - p_m, -1, 1)\n    diff_bM = np.clip(p_b - p_m, -1, 1)\n\n    # --- masks ---\n    m_m = bin_mask(p_m, VIS_THR)\n\n    # --- stats ---\n    def pstats(name, p):\n        vals = p.ravel()\n        print(f\"{name:>6} | min={vals.min():.4f} mean={vals.mean():.4f} max={vals.max():.4f} \"\n              f\"p99={np.quantile(vals,0.99):.4f} p99.5={np.quantile(vals,0.995):.4f}\")\n\n    print(f\"File: {Path(p).name} | {W}×{H} | VIS_THR={VIS_THR:.3f} | cmap vmax={CMAP_MAX:.3f}\")\n    pstats(\"modelA\", p_a)\n    pstats(\"modelB\", p_b)\n    pstats(\"merged\", p_m)\n\n    # --- visuals ---\n    fig = plt.figure(figsize=(20, 12))\n\n    ax1 = plt.subplot(2,4,1); im1=ax1.imshow(p_a,cmap='magma',vmin=0,vmax=CMAP_MAX); ax1.set_title('Model A prob'); ax1.axis('off'); plt.colorbar(im1,ax=ax1,fraction=0.046,pad=0.02)\n    ax2 = plt.subplot(2,4,2); im2=ax2.imshow(p_b,cmap='magma',vmin=0,vmax=CMAP_MAX); ax2.set_title('Model B prob'); ax2.axis('off'); plt.colorbar(im2,ax=ax2,fraction=0.046,pad=0.02)\n    ax3 = plt.subplot(2,4,3); im3=ax3.imshow(p_m,cmap='magma',vmin=0,vmax=CMAP_MAX); ax3.set_title('Merged prob'); ax3.axis('off'); plt.colorbar(im3,ax=ax3,fraction=0.046,pad=0.02)\n\n    # Differences (bwr colormap to show +/-)\n    ax4 = plt.subplot(2,4,4); im4=ax4.imshow(diff_ab,cmap='bwr',vmin=-0.1,vmax=0.1); ax4.set_title('Diff A–B'); ax4.axis('off'); plt.colorbar(im4,ax=ax4,fraction=0.046,pad=0.02)\n    ax5 = plt.subplot(2,4,5); im5=ax5.imshow(diff_aM,cmap='bwr',vmin=-0.1,vmax=0.1); ax5.set_title('Diff A–Merged'); ax5.axis('off'); plt.colorbar(im5,ax=ax5,fraction=0.046,pad=0.02)\n    ax6 = plt.subplot(2,4,6); im6=ax6.imshow(diff_bM,cmap='bwr',vmin=-0.1,vmax=0.1); ax6.set_title('Diff B–Merged'); ax6.axis('off'); plt.colorbar(im6,ax=ax6,fraction=0.046,pad=0.02)\n\n    # Overlay and mask\n    ov = overlay_alpha(img_np, m_m, (255,0,0), 0.35)\n    ax7 = plt.subplot(2,4,7); ax7.imshow(ov); ax7.set_title('Overlay (merged mask)'); ax7.axis('off')\n\n    vals = p_m.ravel()\n    ax8 = plt.subplot(2,4,8); ax8.hist(vals,bins=100,range=(0,CMAP_MAX))\n    ax8.axvline(VIS_THR,color='r',ls='--',label=f'VIS_THR={VIS_THR:.3f}')\n    ax8.set_title('Histogram (merged probs)'); ax8.legend()\n\n    plt.tight_layout(); plt.show()\nelse:\n    print(\"⚠️ This cell is for single-image visual only (found\", len(test_paths), \")\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-13T20:46:35.266177Z","iopub.execute_input":"2025-11-13T20:46:35.266443Z","iopub.status.idle":"2025-11-13T20:46:40.100572Z","shell.execute_reply.started":"2025-11-13T20:46:35.266426Z","shell.execute_reply":"2025-11-13T20:46:40.099692Z"}},"outputs":[],"execution_count":null}]}