{"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":"nvidiaTeslaT4","dataSources":[{"sourceId":113558,"databundleVersionId":14174843,"isSourceIdPinned":false,"sourceType":"competition"},{"sourceId":13511799,"sourceType":"datasetVersion","datasetId":8578895},{"sourceId":271106428,"sourceType":"kernelVersion"}],"dockerImageVersionId":31154,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"84c012e7","cell_type":"markdown","source":"# Scientific Image Forgery Detection — Training ➜ Inference ➜ Submission (No EDA)\n\nThis notebook trains a U-Net model to segment copy–move forgeries in scientific images and generates a submission file.\n- **No EDA / visualization** to save runtime.\n- Assumes: `train_images/authentic/`, `train_images/forged/`, `train_masks/` (flat, one per forged), `test_images/`.\n","metadata":{}},{"id":"e82c555b-68ec-4efb-b065-81fe587caaa2","cell_type":"code","source":"!uv pip install --system --no-index --find-links='/kaggle/input/sif-packages/whls/' \\\n  \"numpy==1.26.4\" \"scipy==1.11.4\" \\\n  \"torch==2.4.1\" \"torchvision==0.19.1\" \\\n  \"timm==0.9.2\" \\\n  \"efficientnet-pytorch==0.7.1\" \"pretrainedmodels==0.7.4\" \"tqdm\" \\\n  \"opencv-python-headless>=4.10.0.84,<5.0.0\" \\\n  \"albumentations==1.4.6\" \\\n  \"segmentation-models-pytorch==0.3.3\"","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-26T21:36:33.757264Z","iopub.execute_input":"2025-10-26T21:36:33.758057Z","iopub.status.idle":"2025-10-26T21:38:20.940080Z","shell.execute_reply.started":"2025-10-26T21:36:33.758032Z","shell.execute_reply":"2025-10-26T21:38:20.939007Z"}},"outputs":[],"execution_count":null},{"id":"32d0daa0","cell_type":"code","source":"# ===== Imports & Paths =====\nimport os, warnings, random, math, gc\nwarnings.filterwarnings(\"ignore\")\n\nfrom pathlib import Path\nimport numpy as np\nimport pandas as pd\nimport cv2\n\nimport torch\nimport torch.nn as nn\nfrom torch.utils.data import Dataset, DataLoader\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nimport segmentation_models_pytorch as smp\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 read_image(path: str):\n    img = cv2.imread(path, cv2.IMREAD_COLOR)\n    if img is None:\n        raise FileNotFoundError(path)\n    return img\n\nprint(\"Ready. Folders checked.\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-26T21:38:20.942091Z","iopub.execute_input":"2025-10-26T21:38:20.942435Z","iopub.status.idle":"2025-10-26T21:38:34.532172Z","shell.execute_reply.started":"2025-10-26T21:38:20.942404Z","shell.execute_reply":"2025-10-26T21:38:34.531395Z"}},"outputs":[],"execution_count":null},{"id":"8724d604","cell_type":"code","source":"# ===== Indexing (attach masks only to forged) =====\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\nauth_paths, forg_paths, mask_paths = list_image_paths()\n\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})\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})\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\ndf_forg = df_forg.merge(df_mask, on=\"case_id\", how=\"left\")\ndf_forg[\"has_mask\"] = df_forg[\"mask_path\"].notna()\ndf_auth[\"mask_path\"] = None\ndf_auth[\"has_mask\"]  = False\n\ndf = pd.concat([df_auth, df_forg], ignore_index=True)\n\n# Basic assertions for integrity\nnum_masks = len(df_mask)\nnum_forged = (df[\"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(\"Counts — authentic:\", (df.label==\"authentic\").sum(), \"| forged:\", num_forged, \"| masks:\", num_masks)\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","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-26T21:38:34.533035Z","iopub.execute_input":"2025-10-26T21:38:34.533311Z","iopub.status.idle":"2025-10-26T21:38:34.714262Z","shell.execute_reply.started":"2025-10-26T21:38:34.533286Z","shell.execute_reply":"2025-10-26T21:38:34.713528Z"}},"outputs":[],"execution_count":null},{"id":"e7996694-3ec8-4d9b-be16-f3e5ea85369a","cell_type":"code","source":"# ===== Config =====\nCFG = {\n    \"seed\": 42,\n    \"img_size\": 512,\n    \"epochs\": 5,\n    \"train_bs\": 4,\n    \"valid_bs\": 4,\n    \"lr\": 1e-4,\n    \"weight_decay\": 1e-4,\n    \"encoder\": \"efficientnet-b3\",\n    \"pretrained\": \"imagenet\",\n    \"loss_dice_weight\": 0.5,\n    \"num_workers\": 2,\n    \"save_dir\": \"/kaggle/working\",\n}\nrandom.seed(CFG[\"seed\"]); np.random.seed(CFG[\"seed\"]); torch.manual_seed(CFG[\"seed\"])\nprint(CFG)\n","metadata":{"execution":{"iopub.status.busy":"2025-10-26T21:38:34.715835Z","iopub.execute_input":"2025-10-26T21:38:34.716122Z","iopub.status.idle":"2025-10-26T21:38:34.724115Z","shell.execute_reply.started":"2025-10-26T21:38:34.716103Z","shell.execute_reply":"2025-10-26T21:38:34.723189Z"},"trusted":true},"outputs":[],"execution_count":null},{"id":"5e7e7281-48b4-4e85-8620-7adfcf82d987","cell_type":"markdown","source":"# ===== Dataset & Augmentations =====\ndef rle_encode(mask: np.ndarray):\n    pixels = mask.flatten(order=\"F\")\n    pixels = np.concatenate([[0], pixels, [0]])\n    runs = np.where(pixels[1:] != pixels[:-1])[0] + 1\n    runs[1::2] -= runs[::2]\n    return \" \".join(str(x) for x in runs)\n\nclass ForgeryDataset(Dataset):\n    def __init__(self, frame, img_size=512, aug=True):\n        self.frame = frame.reset_index(drop=True)\n        self.img_size = img_size\n        self.aug = aug\n        self.tfm_train = A.Compose([\n            A.LongestMaxSize(max_size=img_size),\n            A.PadIfNeeded(min_height=img_size, min_width=img_size, border_mode=cv2.BORDER_CONSTANT, value=0, mask_value=0),\n            A.HorizontalFlip(p=0.5),\n            A.VerticalFlip(p=0.5),\n            A.RandomRotate90(p=0.5),\n            A.ColorJitter(p=0.5, brightness=0.15, contrast=0.15, saturation=0.10, hue=0.05),\n            A.Normalize(),\n            ToTensorV2(),\n        ])\n        self.tfm_valid = A.Compose([\n            A.LongestMaxSize(max_size=img_size),\n            A.PadIfNeeded(min_height=img_size, min_width=img_size, border_mode=cv2.BORDER_CONSTANT, value=0, mask_value=0),\n            A.Normalize(),\n            ToTensorV2(),\n        ])\n\n    def __len__(self): return len(self.frame)\n\n    def __getitem__(self, idx):\n        r = self.frame.iloc[idx]\n        img = cv2.imread(r[\"img_path\"], cv2.IMREAD_COLOR)\n        if img is None: raise FileNotFoundError(r[\"img_path\"])\n        H, W = img.shape[:2]\n\n        if r[\"label\"] == \"forged\":\n            m = np.load(r[\"mask_path\"])\n            m = (m > 0).astype(np.uint8)\n            if m.ndim == 3: 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        else:\n            m = np.zeros((H, W), dtype=np.uint8)\n\n        tfm = self.tfm_train if self.aug else self.tfm_valid\n        out = tfm(image=img, mask=m)\n        img_t = out[\"image\"]\n        mask_t = out[\"mask\"][None]\n        return img_t, mask_t.float(), r[\"case_id\"]\n","metadata":{"execution":{"iopub.status.busy":"2025-10-26T21:09:49.560916Z","iopub.execute_input":"2025-10-26T21:09:49.561095Z","iopub.status.idle":"2025-10-26T21:09:49.572819Z","shell.execute_reply.started":"2025-10-26T21:09:49.561081Z","shell.execute_reply":"2025-10-26T21:09:49.572030Z"}}},{"id":"c4dc1da8-b074-4ee2-a766-0f69cbbbd58a","cell_type":"markdown","source":"# ===== Train/Val Split & Loaders =====\nfrom sklearn.model_selection import StratifiedKFold\nskf = StratifiedKFold(n_splits=5, shuffle=True, random_state=CFG[\"seed\"])\ndf[\"strat\"] = df[\"label\"].values\nfor fold, (tr_idx, va_idx) in enumerate(skf.split(df, df[\"strat\"])):\n    train_df = df.iloc[tr_idx].reset_index(drop=True)\n    valid_df = df.iloc[va_idx].reset_index(drop=True)\n    print(f\"Using fold {fold} — train={len(train_df)} | valid={len(valid_df)}\")\n    break\n\ntrain_ds = ForgeryDataset(train_df, img_size=CFG[\"img_size\"], aug=True)\nvalid_ds = ForgeryDataset(valid_df, img_size=CFG[\"img_size\"], aug=False)\n\ntrain_loader = DataLoader(train_ds, batch_size=CFG[\"train_bs\"], shuffle=True, num_workers=CFG[\"num_workers\"], pin_memory=True)\nvalid_loader = DataLoader(valid_ds, batch_size=CFG[\"valid_bs\"], shuffle=False, num_workers=CFG[\"num_workers\"], pin_memory=True)\n\nlen(train_loader), len(valid_loader)\n","metadata":{"execution":{"iopub.status.busy":"2025-10-26T21:09:49.573465Z","iopub.execute_input":"2025-10-26T21:09:49.573694Z","iopub.status.idle":"2025-10-26T21:09:49.601634Z","shell.execute_reply.started":"2025-10-26T21:09:49.573677Z","shell.execute_reply":"2025-10-26T21:09:49.601018Z"}}},{"id":"bdf0a395-18c5-4f37-9962-e96687cf9b57","cell_type":"markdown","source":"# ===== Model, Loss, Optimizer, Scheduler =====\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\nmodel = smp.Unet(\n    encoder_name=CFG[\"encoder\"],\n    encoder_weights=CFG[\"pretrained\"],\n    in_channels=3,\n    classes=1\n).to(device)\n\nbce = nn.BCEWithLogitsLoss()\ndice = smp.losses.DiceLoss(mode=\"binary\")\ndef mix_loss(logits, targets):\n    return CFG[\"loss_dice_weight\"]*dice(logits, targets) + (1-CFG[\"loss_dice_weight\"])*bce(logits, targets)\n\noptimizer = torch.optim.AdamW(model.parameters(), lr=CFG[\"lr\"], weight_decay=CFG[\"weight_decay\"])\nscheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=CFG[\"epochs\"])\n\nscaler = torch.cuda.amp.GradScaler(enabled=torch.cuda.is_available())\n","metadata":{"execution":{"iopub.status.busy":"2025-10-26T21:09:49.602533Z","iopub.execute_input":"2025-10-26T21:09:49.602739Z","iopub.status.idle":"2025-10-26T21:09:49.872944Z","shell.execute_reply.started":"2025-10-26T21:09:49.602724Z","shell.execute_reply":"2025-10-26T21:09:49.872119Z"}}},{"id":"1107376f-11fc-48e0-887c-4768d87f4bf9","cell_type":"code","source":"# ===== Model (inference) =====\nimport os\nimport torch\nimport torch.nn as nn\nimport segmentation_models_pytorch as smp\n\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\n# Path to your best weights in Kaggle Input (update if your filename/folder differs)\nBEST_PTH = \"/kaggle/input/unet-v1/best_unet.pth\"  # <- change if needed\n\n# Build the model WITHOUT downloading encoder weights\nmodel = smp.Unet(\n    encoder_name=\"efficientnet-b3\",\n    encoder_weights=None,       # <— prevents GitHub download\n    in_channels=3,\n    classes=1\n).to(device)\n\n# Load checkpoint robustly (supports various checkpoint formats)\nckpt = torch.load(BEST_PTH, map_location=device)\n\nif isinstance(ckpt, dict):\n    if \"state_dict\" in ckpt:\n        state = ckpt[\"state_dict\"]\n    elif \"model\" in ckpt:\n        state = ckpt[\"model\"]\n    else:\n        state = ckpt\nelse:\n    state = ckpt\n\n# If keys are prefixed (e.g., 'module.'), strip them\nfrom collections import OrderedDict\nnew_state = OrderedDict()\nfor k, v in state.items():\n    nk = k\n    if k.startswith(\"module.\"):\n        nk = k[len(\"module.\"):]\n    # Some training scripts save head as 'model.' prefix\n    if nk.startswith(\"model.\"):\n        nk = nk[len(\"model.\"):]\n    new_state[nk] = v\n\nmissing, unexpected = model.load_state_dict(new_state, strict=False)\nif missing or unexpected:\n    print(\"Loaded with mismatched keys:\\n  missing:\", missing, \"\\n  unexpected:\", unexpected)\n\nmodel.eval()\ntorch.set_grad_enabled(False)\n\n# ===== (Optional) loss utils if you still need metrics during inference =====\n# bce = nn.BCEWithLogitsLoss()\n# dice = smp.losses.DiceLoss(mode=\"binary\")\n# def mix_loss(logits, targets):\n#     return CFG[\"loss_dice_weight\"]*dice(logits, targets) + (1-CFG[\"loss_dice_weight\"])*bce(logits, targets)\n\n# NOTE: Optimizer/scheduler/scaler are unnecessary for inference and intentionally removed.","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-26T21:38:34.725264Z","iopub.execute_input":"2025-10-26T21:38:34.725554Z","iopub.status.idle":"2025-10-26T21:38:37.154922Z","shell.execute_reply.started":"2025-10-26T21:38:34.725518Z","shell.execute_reply":"2025-10-26T21:38:37.153996Z"}},"outputs":[],"execution_count":null},{"id":"3269742f-86c6-4ee5-81c3-4cb6bda5b874","cell_type":"markdown","source":"# ===== Training Loop =====\nimport numpy as np\ndef dice_coef_np(y_true, y_pred, eps=1e-7):\n    y_true = y_true.astype(np.float32)\n    y_pred = y_pred.astype(np.float32)\n    inter = (y_true * y_pred).sum()\n    return (2*inter + eps) / (y_true.sum() + y_pred.sum() + eps)\n\ndef validate_epoch():\n    model.eval()\n    losses, dices = [], []\n    with torch.no_grad():\n        for imgs, masks, _ in valid_loader:\n            imgs, masks = imgs.to(device), masks.to(device)\n            logits = model(imgs)\n            loss = mix_loss(logits, masks)\n            losses.append(loss.item())\n            probs = torch.sigmoid(logits).cpu().numpy()\n            preds = (probs > 0.5).astype(np.uint8)\n            targs = masks.cpu().numpy().astype(np.uint8)\n            for p, t in zip(preds, targs):\n                dices.append(dice_coef_np(t, p))\n    return float(np.mean(losses)), float(np.mean(dices))\n\nbest_dice = -1.0\nfor epoch in range(1, CFG[\"epochs\"]+1):\n    model.train()\n    train_losses = []\n    for imgs, masks, _ in train_loader:\n        imgs, masks = imgs.to(device), masks.to(device)\n        optimizer.zero_grad(set_to_none=True)\n        with torch.cuda.amp.autocast(enabled=torch.cuda.is_available()):\n            logits = model(imgs)\n            loss = mix_loss(logits, masks)\n        scaler.scale(loss).backward()\n        scaler.step(optimizer)\n        scaler.update()\n        train_losses.append(loss.item())\n    scheduler.step()\n\n    val_loss, val_dice = validate_epoch()\n    print(f\"Epoch {epoch:02d}/{CFG['epochs']} | train_loss={np.mean(train_losses):.4f} | val_loss={val_loss:.4f} | val_dice={val_dice:.4f}\")\n\n    if val_dice > best_dice:\n        best_dice = val_dice\n        torch.save(model.state_dict(), f\"{CFG['save_dir']}/best_unet.pth\")\n        print(f\"  ↳ Saved new best (val_dice={best_dice:.4f})\")\n","metadata":{"execution":{"iopub.status.busy":"2025-10-26T21:34:47.195060Z","iopub.execute_input":"2025-10-26T21:34:47.195338Z","iopub.status.idle":"2025-10-26T21:34:47.217566Z","shell.execute_reply.started":"2025-10-26T21:34:47.195318Z","shell.execute_reply":"2025-10-26T21:34:47.216670Z"}}},{"id":"fc141db3-296e-4008-b409-31f0adbf4fa8","cell_type":"code","source":"import json\nimport numba\n\n@numba.jit(nopython=True)\ndef _rle_encode_jit(x: np.ndarray, fg_val: int = 1) -> list:\n    \"\"\"\n    Official Numba-jitted RLE encoder from competition metric.\n    Encodes in TRANSPOSED order (column-major).\n    \"\"\"\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, fg_val: int = 1) -> str:\n    \"\"\"\n    Official RLE encoding function from competition metric.\n    Adapted from contrails RLE https://www.kaggle.com/code/inversion/contrails-rle-submission\n    \n    Args:\n        masks: list of numpy array of shape (height, width), 1 - mask, 0 - background\n        fg_val: foreground value (default 1)\n    \n    Returns: \n        Run length encodings as a string, with each RLE JSON-encoded and separated by a semicolon.\n        Format: \"[start1, length1, start2, length2, ...]\"\n    \"\"\"\n    return ';'.join([json.dumps(_rle_encode_jit(x, fg_val)) for x in masks])\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-26T21:38:37.155872Z","iopub.execute_input":"2025-10-26T21:38:37.156686Z","iopub.status.idle":"2025-10-26T21:38:38.676242Z","shell.execute_reply.started":"2025-10-26T21:38:37.156662Z","shell.execute_reply":"2025-10-26T21:38:38.675672Z"}},"outputs":[],"execution_count":null},{"id":"e1822966","cell_type":"code","source":"# ===== Inference + Submission =====\nTEST_IMG_DIR = Path(\"/kaggle/input/recodai-luc-scientific-image-forgery-detection/test_images\")\ntest_imgs = sorted(TEST_IMG_DIR.glob(\"*.png\"))\ntest_df = pd.DataFrame({\"case_id\": [p.stem for p in test_imgs], \"img_path\": [str(p) for p in test_imgs]})\nprint(\"Test images:\", len(test_df))\n\ntfm_valid = A.Compose([\n    A.LongestMaxSize(max_size=CFG[\"img_size\"]),\n    A.PadIfNeeded(min_height=CFG[\"img_size\"], min_width=CFG[\"img_size\"], border_mode=cv2.BORDER_CONSTANT, value=0, mask_value=0),\n    A.Normalize(),\n    ToTensorV2(),\n])\n\n#ckpt = Path(f\"{CFG['save_dir']}/best_unet.pth\")\nckpt = Path(\"/kaggle/input/unet-v1/best_unet.pth\")\nassert ckpt.exists(), \"No model checkpoint found. Train first.\"\nmodel.load_state_dict(torch.load(ckpt, map_location=device))\nmodel.eval()\n\ndef predict_mask(img_bgr):\n    H0, W0 = img_bgr.shape[:2]\n    out = tfm_valid(image=img_bgr, mask=np.zeros((H0,W0), dtype=np.uint8))\n    img_t = out[\"image\"].unsqueeze(0).to(device)\n    with torch.no_grad():\n        logit = model(img_t)\n        prob = torch.sigmoid(logit).squeeze().cpu().numpy()\n    prob = cv2.resize(prob, (W0, H0), interpolation=cv2.INTER_LINEAR)\n    return prob\n\nSUB = []\nthr = 0.5\nmin_area = 20\nfor _, r in test_df.iterrows():\n    img = cv2.imread(r[\"img_path\"], cv2.IMREAD_COLOR)\n    prob = predict_mask(img)\n    mask = (prob > thr).astype(np.uint8)\n    if mask.sum() < min_area:\n        SUB.append((r[\"case_id\"], \"authentic\"))\n    else:\n        SUB.append((r[\"case_id\"], rle_encode(mask)))\n\nsub_df = pd.DataFrame(SUB, columns=[\"case_id\",\"annotation\"])\nsub_path = f\"{CFG['save_dir']}/submission.csv\"\nsub_df.to_csv(sub_path, index=False)\nprint(\"Saved submission:\", sub_path)\nprint(sub_df.head(5).to_string(index=False))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-26T21:38:38.676908Z","iopub.execute_input":"2025-10-26T21:38:38.677128Z","iopub.status.idle":"2025-10-26T21:38:39.316151Z","shell.execute_reply.started":"2025-10-26T21:38:38.677112Z","shell.execute_reply":"2025-10-26T21:38:39.315352Z"}},"outputs":[],"execution_count":null}]}