{"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":"gpu","dataSources":[{"sourceId":113558,"databundleVersionId":14878066,"isSourceIdPinned":false,"sourceType":"competition"}],"dockerImageVersionId":31240,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# =========================================================\n# RECOD.AI / LUC – Scientific Image Forgery Detection\n# FULL PIPELINE: TRAIN + INFERENCE + SUBMISSION\n# Mask R-CNN (RGB + ELA, 4-channel)\n# =========================================================\n\n# ================= IMPORTS =================\nimport os, gc, time, random\nfrom pathlib import Path\n\nimport cv2\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\nfrom tqdm import tqdm\n\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\n\nimport torchvision.transforms as T\nfrom torchvision.models.detection import maskrcnn_resnet50_fpn\nfrom torchvision.models.detection.faster_rcnn import FastRCNNPredictor\nfrom torchvision.models.detection.mask_rcnn import MaskRCNNPredictor\nfrom torchvision.ops import masks_to_boxes\n\n# ================= CONFIG =================\nROOT = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection\"\n\nTRAIN_IMG_DIR = f\"{ROOT}/train_images/forged\"\nTRAIN_MASK_DIR = f\"{ROOT}/train_masks\"\nTEST_DIR = f\"{ROOT}/test_images\"\n\nELA_DIR = \"/kaggle/working/train_ela\"\nPath(ELA_DIR).mkdir(parents=True, exist_ok=True)\n\nIMG_SIZE = 256\nBATCH_SIZE = 8\nEPOCHS = 10\nLR = 1e-4\nNUM_CLASSES = 2          # background + forgery\nMIN_MASK_AREA = 16\nMASK_THRESH = 0.5\nSCORE_THRESH = 0.5\n\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(\"Using device:\", DEVICE)\n\n# ================= SEED =================\ndef seed_all(seed=42):\n    random.seed(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed_all(seed)\n\nseed_all()\n\n# ================= ELA =================\ndef ela_map(img_bgr, q=95):\n    _, enc = cv2.imencode(\n        \".jpg\", img_bgr,\n        [int(cv2.IMWRITE_JPEG_QUALITY), q]\n    )\n    dec = cv2.imdecode(enc, cv2.IMREAD_COLOR)\n    diff = cv2.absdiff(img_bgr, dec).astype(np.float32)\n    diff /= max(1.0, np.percentile(diff, 99))\n    ela = (diff * 255).astype(np.uint8)\n    return cv2.cvtColor(ela, cv2.COLOR_BGR2GRAY)\n\ndef precompute_ela():\n    files = list(Path(TRAIN_IMG_DIR).glob(\"*\"))\n    todo = [p for p in files if not Path(f\"{ELA_DIR}/{p.stem}.npy\").exists()]\n\n    if len(todo) == 0:\n        print(\"✔ ELA already exists\")\n        return\n\n    print(f\"Precomputing ELA for {len(todo)} images...\")\n    for p in tqdm(todo):\n        img = Image.open(p).convert(\"RGB\")\n        bgr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)\n        ela = ela_map(bgr)\n        np.save(f\"{ELA_DIR}/{p.stem}.npy\", ela)\n\n    print(\"✔ ELA precomputation done\")\n\n# ================= DATASET =================\nclass ForgeryDataset(Dataset):\n    def __init__(self):\n        self.files = sorted(Path(TRAIN_IMG_DIR).glob(\"*\"))\n        self.resize = T.Resize((IMG_SIZE, IMG_SIZE))\n        self.to_tensor = T.ToTensor()\n\n    def __len__(self):\n        return len(self.files)\n\n    def __getitem__(self, idx):\n        p = self.files[idx]\n\n        # ---- RGB ----\n        img = Image.open(p).convert(\"RGB\")\n        img = self.resize(img)\n        rgb = self.to_tensor(img)\n\n        # ---- ELA ----\n        ela_path = f\"{ELA_DIR}/{p.stem}.npy\"\n        if Path(ela_path).exists():\n            ela = np.load(ela_path)\n        else:\n            bgr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)\n            ela = ela_map(bgr)\n\n        ela = cv2.resize(ela, (IMG_SIZE, IMG_SIZE))\n        ela = torch.from_numpy(ela).float().unsqueeze(0) / 255.0\n\n        x4 = torch.cat([rgb, ela], dim=0)\n\n        # ---- MASK ----\n        m = np.load(f\"{TRAIN_MASK_DIR}/{p.stem}.npy\")\n        if m.ndim == 3:\n            m = m.max(axis=0)\n\n        m = cv2.resize(m, (IMG_SIZE, IMG_SIZE),\n                       interpolation=cv2.INTER_NEAREST)\n        m = (m > 0).astype(np.uint8)\n\n        num, labels = cv2.connectedComponents(m)\n        insts = [\n            (labels == i).astype(np.uint8)\n            for i in range(1, num)\n            if (labels == i).sum() >= MIN_MASK_AREA\n        ]\n\n        if len(insts) == 0:\n            return x4, {\n                \"boxes\": torch.zeros((0, 4), dtype=torch.float32),\n                \"labels\": torch.zeros((0,), dtype=torch.int64),\n                \"masks\": torch.zeros((0, IMG_SIZE, IMG_SIZE),\n                                     dtype=torch.uint8)\n            }\n\n        masks = torch.from_numpy(np.stack(insts))\n        boxes = masks_to_boxes(masks)\n\n        return x4, {\n            \"boxes\": boxes,\n            \"labels\": torch.ones(len(masks), dtype=torch.int64),\n            \"masks\": masks\n        }\n\ndef collate_fn(batch):\n    return tuple(zip(*batch))\n\n# ================= MODEL =================\ndef build_model():\n    model = maskrcnn_resnet50_fpn(\n        weights=None,\n        weights_backbone=None\n    )\n\n    # ---- 4-channel normalization ----\n    model.transform.image_mean = [0.485, 0.456, 0.406, 0.0]\n    model.transform.image_std  = [0.229, 0.224, 0.225, 1.0]\n\n    # ---- 3 → 4 channel conv ----\n    old = model.backbone.body.conv1\n    new = nn.Conv2d(\n        4,\n        old.out_channels,\n        kernel_size=old.kernel_size,\n        stride=old.stride,\n        padding=old.padding,\n        bias=False\n    )\n\n    with torch.no_grad():\n        new.weight[:, :3] = old.weight\n        new.weight[:, 3:4] = old.weight[:, :1]\n\n    model.backbone.body.conv1 = new\n\n    # ---- heads ----\n    in_feat = model.roi_heads.box_predictor.cls_score.in_features\n    model.roi_heads.box_predictor = FastRCNNPredictor(in_feat, NUM_CLASSES)\n\n    in_mask = model.roi_heads.mask_predictor.conv5_mask.in_channels\n    model.roi_heads.mask_predictor = MaskRCNNPredictor(\n        in_mask, 256, NUM_CLASSES\n    )\n\n    return model\n\n# ================= TRAIN =================\ndef train():\n    dataset = ForgeryDataset()\n    loader = DataLoader(\n        dataset,\n        batch_size=BATCH_SIZE,\n        shuffle=True,\n        collate_fn=collate_fn,\n        num_workers=4,\n        pin_memory=True,\n        persistent_workers=True,\n        prefetch_factor=4\n    )\n\n    model = build_model().to(DEVICE)\n    optimizer = optim.AdamW(model.parameters(), lr=LR)\n    scaler = torch.cuda.amp.GradScaler()\n\n    for epoch in range(EPOCHS):\n        model.train()\n        losses = []\n        t0 = time.time()\n\n        for imgs, targets in tqdm(loader, desc=f\"Epoch {epoch+1}/{EPOCHS}\"):\n            imgs = [i.to(DEVICE, non_blocking=True) for i in imgs]\n            targets = [\n                {k: v.to(DEVICE, non_blocking=True) for k, v in t.items()}\n                for t in targets\n            ]\n\n            with torch.cuda.amp.autocast():\n                loss_dict = model(imgs, targets)\n                loss = sum(loss_dict.values())\n\n            optimizer.zero_grad(set_to_none=True)\n            scaler.scale(loss).backward()\n            scaler.step(optimizer)\n            scaler.update()\n\n            losses.append(loss.item())\n\n        print(f\"Epoch {epoch+1} | Loss {np.mean(losses):.4f} | \"\n              f\"Time {(time.time()-t0)/60:.2f} min\")\n\n        torch.cuda.empty_cache()\n        gc.collect()\n\n    torch.save(model.state_dict(),\n               \"/kaggle/working/maskrcnn_forgery.pth\")\n    print(\"✔ Model saved\")\n\n# ================= RLE =================\ndef rle_encode(mask):\n    pixels = mask.flatten()\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(map(str, runs))\n\n# ================= INFERENCE =================\n@torch.no_grad()\ndef generate_submission(model):\n    model.eval()\n    results = []\n\n    files = sorted(Path(TEST_DIR).glob(\"*\"))\n\n    for p in tqdm(files, desc=\"Inference\"):\n        img = Image.open(p).convert(\"RGB\")\n        img = T.Resize((IMG_SIZE, IMG_SIZE))(img)\n        rgb = T.ToTensor()(img)\n\n        bgr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)\n        ela = ela_map(bgr)\n        ela = cv2.resize(ela, (IMG_SIZE, IMG_SIZE))\n        ela = torch.from_numpy(ela).float().unsqueeze(0) / 255.0\n\n        x4 = torch.cat([rgb, ela], dim=0).to(DEVICE)\n\n        out = model([x4])[0]\n\n        if len(out[\"masks\"]) == 0:\n            results.append((p.stem, \"authentic\"))\n            continue\n\n        masks = out[\"masks\"].squeeze(1).cpu().numpy()\n        scores = out[\"scores\"].cpu().numpy()\n\n        final_mask = np.zeros((IMG_SIZE, IMG_SIZE), dtype=np.uint8)\n\n        for m, s in zip(masks, scores):\n            if s >= SCORE_THRESH:\n                final_mask |= (m > MASK_THRESH).astype(np.uint8)\n\n        if final_mask.sum() == 0:\n            results.append((p.stem, \"authentic\"))\n        else:\n            results.append((p.stem, rle_encode(final_mask)))\n\n    return results\n","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-12-18T17:09:32.537088Z","iopub.execute_input":"2025-12-18T17:09:32.537342Z","iopub.status.idle":"2025-12-18T17:09:41.208642Z","shell.execute_reply.started":"2025-12-18T17:09:32.537318Z","shell.execute_reply":"2025-12-18T17:09:41.208021Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\ndef save_submission(model):\n    data = generate_submission(model)\n    df = pd.DataFrame(data, columns=[\"case_id\", \"annotation\"])\n    df.to_csv(\"/kaggle/working/submission.csv\", index=False)\n    print(\"✔ submission.csv saved\")\n\n# ================= MAIN =================\nif __name__ == \"__main__\":\n    precompute_ela()\n    train()\n\n    model = build_model().to(DEVICE)\n    model.load_state_dict(\n        torch.load(\"/kaggle/working/maskrcnn_forgery.pth\",\n                   map_location=DEVICE)\n    )\n\n    save_submission(model)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-18T17:09:41.210027Z","iopub.execute_input":"2025-12-18T17:09:41.210307Z","iopub.status.idle":"2025-12-18T18:09:50.170516Z","shell.execute_reply.started":"2025-12-18T17:09:41.210292Z","shell.execute_reply":"2025-12-18T18:09:50.169757Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}