{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"codemirror_mode":{"name":"ipython","version":3},"file_extension":".py","mimetype":"text/x-python","name":"python","nbconvert_exporter":"python","pygments_lexer":"ipython3","version":"3.11.13"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceId":113558,"databundleVersionId":14878066,"sourceType":"competition"},{"sourceId":13982366,"sourceType":"datasetVersion","datasetId":8912511},{"sourceId":13983500,"sourceType":"datasetVersion","datasetId":8913105},{"sourceId":13983725,"sourceType":"datasetVersion","datasetId":8912670},{"sourceId":14407528,"sourceType":"datasetVersion","datasetId":9153851},{"sourceId":4534,"sourceType":"modelInstanceVersion","modelInstanceId":3326,"modelId":986}],"dockerImageVersionId":31193,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true},"papermill":{"default_parameters":{},"duration":51.085353,"end_time":"2025-11-16T16:15:41.162948","environment_variables":{},"exception":null,"input_path":"__notebook__.ipynb","output_path":"__notebook__.ipynb","parameters":{},"start_time":"2025-11-16T16:14:50.077595","version":"2.6.0"}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Imports and configs","metadata":{}},{"cell_type":"code","source":"!pip uninstall -qy tensorflow","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:48:29.91185Z","iopub.execute_input":"2026-01-12T04:48:29.912108Z","iopub.status.idle":"2026-01-12T04:48:50.769088Z","shell.execute_reply.started":"2026-01-12T04:48:29.912088Z","shell.execute_reply":"2026-01-12T04:48:50.768159Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print('ready')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:48:50.770392Z","iopub.execute_input":"2026-01-12T04:48:50.770745Z","iopub.status.idle":"2026-01-12T04:48:50.774895Z","shell.execute_reply.started":"2026-01-12T04:48:50.770719Z","shell.execute_reply":"2026-01-12T04:48:50.774289Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install /kaggle/input/sam3-dependencies/portalocker-3.2.0-py3-none-any.whl","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:48:50.775733Z","iopub.execute_input":"2026-01-12T04:48:50.776026Z","iopub.status.idle":"2026-01-12T04:48:54.20538Z","shell.execute_reply.started":"2026-01-12T04:48:50.776004Z","shell.execute_reply":"2026-01-12T04:48:54.204508Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install /kaggle/input/sam3-dependencies/ftfy-6.3.1-py3-none-any.whl","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:48:54.207236Z","iopub.execute_input":"2026-01-12T04:48:54.207511Z","iopub.status.idle":"2026-01-12T04:48:57.411896Z","shell.execute_reply.started":"2026-01-12T04:48:54.207457Z","shell.execute_reply":"2026-01-12T04:48:57.411046Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!cp -r /kaggle/input/sam3-dependencies/iopath-0.1.10/iopath-0.1.10 /kaggle/working/iopath\n!cd /kaggle/working/iopath && pip install . --no-build-isolation","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:48:57.41292Z","iopub.execute_input":"2026-01-12T04:48:57.413163Z","iopub.status.idle":"2026-01-12T04:49:02.70213Z","shell.execute_reply.started":"2026-01-12T04:48:57.413137Z","shell.execute_reply":"2026-01-12T04:49:02.701415Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!pip install /kaggle/input/sam3-dependencies/decord-0.6.0-py3-none-manylinux2010_x86_64.whl","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:49:02.703147Z","iopub.execute_input":"2026-01-12T04:49:02.703428Z","iopub.status.idle":"2026-01-12T04:49:06.332674Z","shell.execute_reply.started":"2026-01-12T04:49:02.703399Z","shell.execute_reply":"2026-01-12T04:49:06.331951Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Kaggle annoyingly unzip any gz file. So I need to rezip it because it's hardcoded to look for .gz file.","metadata":{}},{"cell_type":"code","source":"!cp -r /kaggle/input/sam3-2025-12-03 /kaggle/working/sam3-2025-12-03","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:49:06.334461Z","iopub.execute_input":"2026-01-12T04:49:06.334747Z","iopub.status.idle":"2026-01-12T04:49:09.089068Z","shell.execute_reply.started":"2026-01-12T04:49:06.334718Z","shell.execute_reply":"2026-01-12T04:49:09.088293Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!cd /kaggle/working/sam3-2025-12-03/assets/ && gzip bpe_simple_vocab_16e6.txt","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:49:09.090088Z","iopub.execute_input":"2026-01-12T04:49:09.090346Z","iopub.status.idle":"2026-01-12T04:49:09.429051Z","shell.execute_reply.started":"2026-01-12T04:49:09.090317Z","shell.execute_reply":"2026-01-12T04:49:09.428211Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# !cp -r /kaggle/input/sam3-meta /kaggle/working/sam3-meta","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:49:09.430198Z","iopub.execute_input":"2026-01-12T04:49:09.430898Z","iopub.status.idle":"2026-01-12T04:49:09.434654Z","shell.execute_reply.started":"2026-01-12T04:49:09.430867Z","shell.execute_reply":"2026-01-12T04:49:09.433936Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# !cp /kaggle/input/sam3-2025-12-03/assets/bpe_simple_vocab_16e6.txt /kaggle/working/sam3-meta/assets/bpe_simple_vocab_16e6.txt","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:49:09.437259Z","iopub.execute_input":"2026-01-12T04:49:09.437552Z","iopub.status.idle":"2026-01-12T04:49:09.448654Z","shell.execute_reply.started":"2026-01-12T04:49:09.437516Z","shell.execute_reply":"2026-01-12T04:49:09.447927Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# !cd /kaggle/working/sam3-meta/assets/ && gzip bpe_simple_vocab_16e6.txt","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:49:09.449283Z","iopub.execute_input":"2026-01-12T04:49:09.449524Z","iopub.status.idle":"2026-01-12T04:49:09.461807Z","shell.execute_reply.started":"2026-01-12T04:49:09.449505Z","shell.execute_reply":"2026-01-12T04:49:09.461085Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import sys\nsys.path.append(\"/kaggle/working/sam3-2025-12-03\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:49:09.462587Z","iopub.execute_input":"2026-01-12T04:49:09.463434Z","iopub.status.idle":"2026-01-12T04:49:09.475152Z","shell.execute_reply.started":"2026-01-12T04:49:09.463417Z","shell.execute_reply":"2026-01-12T04:49:09.474431Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\n#################################### For Image ####################################\nfrom PIL import Image\nfrom sam3.model_builder import build_sam3_image_model\nfrom sam3.model.sam3_image_processor import Sam3Processor\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.patches import Rectangle","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:49:09.476049Z","iopub.execute_input":"2026-01-12T04:49:09.476281Z","iopub.status.idle":"2026-01-12T04:49:20.074383Z","shell.execute_reply.started":"2026-01-12T04:49:09.476262Z","shell.execute_reply":"2026-01-12T04:49:20.073651Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nfrom pathlib import Path\nimport numpy as np\nimport pandas as pd\nimport cv2\nfrom tqdm.auto import tqdm\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nfrom sklearn.model_selection import GroupKFold\n\nimport json\n\nimport numba\nimport numpy as np\nfrom numba import types\nimport numpy.typing as npt\nimport pandas as pd\nimport scipy.optimize\n\n\nfrom transformers import AutoImageProcessor, AutoModel\nfrom torch.utils.data import Dataset\nfrom tqdm.notebook import tqdm\nfrom pathlib import Path\nfrom PIL import Image\nimport torch.nn as nn\nimport torch.nn.functional as F\nimport pandas as pd\nimport numpy as np\nimport warnings\nimport torch\nimport json\nimport math\nimport cv2\nimport os\n\nwarnings.filterwarnings(\"ignore\")","metadata":{"trusted":true,"_kg_hide-output":true,"execution":{"iopub.status.busy":"2026-01-12T05:37:32.456065Z","iopub.execute_input":"2026-01-12T05:37:32.456727Z","iopub.status.idle":"2026-01-12T05:37:32.465341Z","shell.execute_reply.started":"2026-01-12T05:37:32.456694Z","shell.execute_reply":"2026-01-12T05:37:32.464538Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import gc\n\nimport os\nimport numpy as np\nimport pandas as pd\nimport cv2\nfrom pathlib import Path\nfrom tqdm.auto import tqdm\nimport torch\nfrom PIL import Image\nimport matplotlib.pyplot as plt\nfrom matplotlib.patches import Polygon\nimport itertools\nimport math\nfrom typing import List, Optional, Tuple\n\nimport json\n\nimport numba\nimport numpy as np\nfrom numba import types\nimport numpy.typing as npt\nimport pandas as pd\nimport scipy.optimize","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:37:33.030745Z","iopub.execute_input":"2026-01-12T05:37:33.031076Z","iopub.status.idle":"2026-01-12T05:37:33.038579Z","shell.execute_reply.started":"2026-01-12T05:37:33.031044Z","shell.execute_reply":"2026-01-12T05:37:33.037868Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os, cv2, json, math, random, torch\nimport numpy as np\nimport pandas as pd\nfrom tqdm import tqdm\nfrom pathlib import Path\nfrom PIL import Image\nimport matplotlib.pyplot as plt\nfrom sklearn.model_selection import train_test_split\nfrom torch.utils.data import Dataset, DataLoader\nimport torch.nn as nn, torch.nn.functional as F, torch.optim as optim\nfrom transformers import AutoImageProcessor, AutoModel","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:37:33.2233Z","iopub.execute_input":"2026-01-12T05:37:33.224032Z","iopub.status.idle":"2026-01-12T05:37:33.230033Z","shell.execute_reply.started":"2026-01-12T05:37:33.223995Z","shell.execute_reply":"2026-01-12T05:37:33.229191Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nBASE_DIR  = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection\"\nAUTH_DIR  = f\"{BASE_DIR}/train_images/authentic\"\nFORG_DIR  = f\"{BASE_DIR}/train_images/forged\"\nMASK_DIR  = f\"{BASE_DIR}/train_masks\"\nTEST_DIR  = f\"{BASE_DIR}/test_images\"\nDINO_PATH = \"/kaggle/input/dinov2/pytorch/base/1\"\n\nIMG_SIZE = 518\nBATCH_SIZE = 2\n# MODEL_LOC = '/kaggle/input/cnndinov2-pbd/CNNDINOv2-U54/CNNDINOv2-U54/model_seg_final.pt'  # 0.310\n# MODEL_LOC = '/kaggle/input/cnndinov2-pbd/CNNDINOv2-U52/CNNDINOv2-U52/model_seg_final.pt'  # 0.321\nMODEL_LOC = '/kaggle/input/cnndinov2-pbd/CNNDINOv2-U52/CNNDINOv2-U52/model_seg_final.pt'  # 0.321\n\n# INFERENCE UTILS\nAREA_THR = 200\nMEAN_THR = 0.22\nUSE_TTA = False\nGRID_SEARCH = False\n\nclass ForgerySegDataset(Dataset):\n    def __init__(self, auth_paths, forg_paths, mask_dir, img_size=IMG_SIZE):\n        self.samples = []\n        for p in forg_paths:\n            m = os.path.join(mask_dir, Path(p).stem + \".npy\")\n            if os.path.exists(m):\n                self.samples.append((p, m))\n        for p in auth_paths:\n            self.samples.append((p, None))\n        self.img_size = img_size\n    def __len__(self): return len(self.samples)\n    def __getitem__(self, idx):\n        img_path, mask_path = self.samples[idx]\n        img = Image.open(img_path).convert(\"RGB\")\n        w, h = img.size\n        if mask_path is None:\n            mask = np.zeros((h, w), np.uint8)\n        else:\n            m = np.load(mask_path)\n            if m.ndim == 3: m = np.max(m, axis=0)\n            mask = (m > 0).astype(np.uint8)\n        img_r = img.resize((IMG_SIZE, IMG_SIZE))\n        mask_r = cv2.resize(mask, (IMG_SIZE, IMG_SIZE), interpolation=cv2.INTER_NEAREST)\n        img_t = torch.from_numpy(np.array(img_r, np.float32)/255.).permute(2,0,1)\n        mask_t = torch.from_numpy(mask_r[None, ...].astype(np.float32))\n        return img_t, mask_t\n\n\n#  MODEL (DINOv2 + Decoder)\n\nfrom transformers import AutoImageProcessor, AutoModel\nprocessor = AutoImageProcessor.from_pretrained(DINO_PATH, local_files_only=True, use_fast=False)\nencoder = AutoModel.from_pretrained(DINO_PATH, local_files_only=True).eval().to(device)\n\nclass DinoTinyDecoder(nn.Module):\n    def __init__(self, in_ch=768, out_ch=1):\n        super().__init__()\n        # Block 1: 768 -> 384\n        self.block1 = nn.Sequential(\n            nn.Conv2d(in_ch, 384, kernel_size=3, padding=1),\n            nn.ReLU(inplace=True),\n            nn.Dropout2d(0.1)\n        )\n        # Block 2: 384 -> 192\n        self.block2 = nn.Sequential(\n            nn.Conv2d(384, 192, kernel_size=3, padding=1),\n            nn.ReLU(inplace=True),\n            nn.Dropout2d(0.1)\n        )\n        # Block 3: 192 -> 96\n        self.block3 = nn.Sequential(\n            nn.Conv2d(192, 96, kernel_size=3, padding=1),\n            nn.ReLU(inplace=True)\n        )\n        # Final Output: 96 -> 1\n        self.conv_out = nn.Conv2d(96, out_ch, kernel_size=1)\n    \n    def forward(self, f, target_size):\n        # f: [B, 768, 37, 37]\n        \n        # Step 1: Up to ~74x74\n        x = F.interpolate(self.block1(f), size=(74, 74), mode='bilinear', align_corners=False)\n        \n        # Step 2: Up to ~148x148\n        x = F.interpolate(self.block2(x), size=(148, 148), mode='bilinear', align_corners=False)\n        \n        # Step 3: Up to ~296x296\n        x = F.interpolate(self.block3(x), size=(296, 296), mode='bilinear', align_corners=False)\n        \n        # Step 4: Final jump to 518x518\n        x = self.conv_out(x)\n        x = F.interpolate(x, size=target_size, mode='bilinear', align_corners=False)\n        \n        return x\n    \nclass DinoSegmenter(nn.Module):\n    def __init__(self, encoder, processor):\n        super().__init__()\n        self.encoder, self.processor = encoder, processor\n        for p in self.encoder.parameters(): p.requires_grad = False\n        self.seg_head = DinoTinyDecoder(768,1)\n    def forward_features(self,x):\n        imgs = (x*255).clamp(0,255).byte().permute(0,2,3,1).cpu().numpy()\n        inputs = self.processor(images=list(imgs), return_tensors=\"pt\").to(x.device)\n        # with torch.no_grad(): \n        #     feats = self.encoder(**inputs).last_hidden_state\n        feats = self.encoder(**inputs).last_hidden_state\n        B,N,C = feats.shape\n        fmap = feats[:,1:,:].permute(0,2,1)\n        s = int(math.sqrt(N-1))\n        fmap = fmap.reshape(B,C,s,s)\n        return fmap\n    def forward_seg(self,x):\n        fmap = self.forward_features(x)\n        return self.seg_head(fmap,(IMG_SIZE,IMG_SIZE))\n\n\nauth_imgs = sorted([str(Path(AUTH_DIR)/f) for f in os.listdir(AUTH_DIR)])\nforg_imgs = sorted([str(Path(FORG_DIR)/f) for f in os.listdir(FORG_DIR)])\ntrain_auth, val_auth = train_test_split(auth_imgs, test_size=0.2, random_state=42)\ntrain_forg, val_forg = train_test_split(forg_imgs, test_size=0.2, random_state=42)\n\ntrain_loader = DataLoader(ForgerySegDataset(train_auth, train_forg, MASK_DIR),\n                          batch_size=BATCH_SIZE, shuffle=True, num_workers=2)\nval_loader = DataLoader(ForgerySegDataset(val_auth, val_forg, MASK_DIR),\n                        batch_size=BATCH_SIZE, shuffle=False, num_workers=2)\n\nmodel_seg = DinoSegmenter(encoder, processor).to(device)\n\n# Load pretrained weights if MODEL_LOC is specified\nif MODEL_LOC is not None and os.path.exists(MODEL_LOC):\n    model_seg.load_state_dict(torch.load(MODEL_LOC, map_location=device))\n    print(f\"✅ Loaded pretrained model from: {MODEL_LOC}\")\n    model_seg.eval()  # Set model to evaluation mode\n\n@torch.no_grad()\ndef segment_prob_map(pil):\n    x = torch.from_numpy(np.array(pil.resize((IMG_SIZE, IMG_SIZE)), np.float32)/255.).permute(2,0,1)[None].to(device)\n    prob = torch.sigmoid(model_seg.forward_seg(x))[0,0].cpu().numpy()\n    return prob\n\n@torch.no_grad()\ndef segment_prob_map_with_tta(pil):\n    # 1. Preprocessing: Resize, Normalize, and move to Device\n    x = torch.from_numpy(np.array(pil.resize((IMG_SIZE, IMG_SIZE)), np.float32)/255.).permute(2,0,1)[None].to(device)\n    \n    predictions = []\n\n    # 2. Original Prediction\n    pred_orig = torch.sigmoid(model_seg.forward_seg(x))\n    predictions.append(pred_orig)\n\n    # 3. Horizontal Flip TTA (dim 3)\n    # Flip input -> Predict -> Flip output back\n    pred_h = torch.sigmoid(model_seg.forward_seg(torch.flip(x, dims=[3])))\n    predictions.append(torch.flip(pred_h, dims=[3]))\n\n    # 4. Vertical Flip TTA (dim 2)\n    # Flip input -> Predict -> Flip output back\n    pred_v = torch.sigmoid(model_seg.forward_seg(torch.flip(x, dims=[2])))\n    predictions.append(torch.flip(pred_v, dims=[2]))\n\n    # 5. Average the predictions and format as numpy\n    # We stack the 3 predictions and take the mean across the stack dimension (0)\n    prob = torch.stack(predictions).mean(0)[0, 0].cpu().numpy()\n\n    return prob\n    \ndef enhanced_adaptive_mask(prob, alpha_grad=0.45):\n    gx = cv2.Sobel(prob, cv2.CV_32F, 1, 0, ksize=3)\n    gy = cv2.Sobel(prob, cv2.CV_32F, 0, 1, ksize=3)\n    grad_mag = np.sqrt(gx**2 + gy**2)\n    grad_norm = grad_mag / (grad_mag.max() + 1e-6)\n    enhanced = (1 - alpha_grad) * prob + alpha_grad * grad_norm\n    enhanced = cv2.GaussianBlur(enhanced, (3,3), 0)\n    thr = np.mean(enhanced) + 0.3 * np.std(enhanced)\n    mask = (enhanced > thr).astype(np.uint8)\n    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5,5), np.uint8))\n    mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3,3), np.uint8))\n    return mask, thr\n\ndef finalize_mask(prob, orig_size):\n    mask, thr = enhanced_adaptive_mask(prob)\n    mask = cv2.resize(mask, orig_size, interpolation=cv2.INTER_NEAREST)\n    return mask, thr\n\ndef pipeline_final(pil):\n    if USE_TTA:\n        prob = segment_prob_map_with_tta(pil)\n    else:\n        prob = segment_prob_map(pil)\n    mask, thr = finalize_mask(prob, pil.size)\n    area = int(mask.sum())\n    mean_inside = float(prob[cv2.resize(mask,(IMG_SIZE,IMG_SIZE),interpolation=cv2.INTER_NEAREST)==1].mean()) if area>0 else 0.0\n    if area < AREA_THR or mean_inside < MEAN_THR:\n        return \"authentic\", None, {\"area\": area, \"mean_inside\": mean_inside, \"thr\": thr}\n    return \"forged\", mask, {\"area\": area, \"mean_inside\": mean_inside, \"thr\": thr}\n\nimport itertools\nfrom sklearn.metrics import f1_score\n\ndef grid_search_area_mean(forg_paths, auth_paths, mask_dir):\n    mean_range = [round(x, 2) for x in np.arange(0.20, 0.291, 0.01)]\n    area_range = [200]\n    # 1. Use ALL images from both paths to maximize robustness\n    val_set = [(p, \"forged\") for p in forg_paths] + [(p, \"authentic\") for p in auth_paths]\n    \n    print(f\"🚀 Step 1: Caching probability maps for ALL {len(val_set)} images...\")\n    cache = []\n    for p, label in tqdm(val_set):\n        pil = Image.open(p).convert(\"RGB\")\n        w, h = pil.size\n        \n        # Get raw probability map\n        prob = segment_prob_map_with_tta(pil) if USE_TTA else segment_prob_map(pil)\n        \n        # USE OLD MASK LOGIC: mean + 0.3*std\n        mask_raw, _ = enhanced_adaptive_mask(prob) # Your function using np.mean + 0.3*np.std\n        mask_resized = cv2.resize(mask_raw, (w, h), interpolation=cv2.INTER_NEAREST)\n        \n        # Handle Ground Truth\n        if label == \"forged\":\n            m_gt = np.load(Path(mask_dir)/f\"{Path(p).stem}.npy\")\n            if m_gt.ndim == 3: m_gt = np.max(m_gt, axis=0)\n            m_gt = (m_gt > 0).astype(np.uint8)\n        else:\n            m_gt = np.zeros((h, w), np.uint8) # Authentic = blank GT\n            \n        cache.append({\"prob\": prob, \"mask\": mask_resized, \"gt\": m_gt, \"label\": label})\n\n    # 2. Sweep thresholds\n    best_f1 = -1\n    best_params = {}\n    combinations = list(itertools.product(area_range, mean_range))\n    \n    for a_thr, m_thr in combinations:\n        current_f1s = []\n        for item in cache:\n            mask = item[\"mask\"]\n            area = int(mask.sum()) # OLD AREA LOGIC\n            \n            # OLD MEAN LOGIC\n            mask_small = cv2.resize(mask, (IMG_SIZE, IMG_SIZE), interpolation=cv2.INTER_NEAREST)\n            mean_in = float(item[\"prob\"][mask_small == 1].mean()) if area > 0 else 0.0\n            \n            # Pipeline decision\n            is_forged = (area >= a_thr and mean_in >= m_thr)\n            m_pred = (mask > 0).astype(np.uint8) if is_forged else np.zeros_like(item[\"gt\"])\n            \n            # F1 Calculation (Authentic silence = 1.0, noisy prediction = 0.0)\n            f1 = f1_score(item[\"gt\"].flatten(), m_pred.flatten(), \n                          zero_division=1 if item[\"label\"] == \"authentic\" else 0)\n            current_f1s.append(f1)\n            \n        avg_f1 = np.mean(current_f1s)\n        if avg_f1 > best_f1:\n            best_f1 = avg_f1\n            best_params = {\"AREA_THR\": a_thr, \"MEAN_THR\": m_thr}\n            print(f\"⭐ New Best F1: {best_f1:.4f} -> AREA: {a_thr}, MEAN: {m_thr}\")\n\n    return best_params\n\n# if GRID_SEARCH:\n#     best_cfg = grid_search_area_mean(val_forg, val_auth, MASK_DIR)\n#     AREA_THR = best_cfg['AREA_THR']\n#     MEAN_THR = best_cfg['MEAN_THR']\n\n\n# from sklearn.metrics import f1_score\n# val_items = [(p, 1) for p in val_forg[:10]]\n# results = []\n# for p,_ in tqdm(val_items, desc=\"Validation forged-only\"):\n#     pil = Image.open(p).convert(\"RGB\")\n#     label, m_pred, dbg = pipeline_final(pil)\n#     m_gt = np.load(Path(MASK_DIR)/f\"{Path(p).stem}.npy\")\n#     if m_gt.ndim==3: m_gt=np.max(m_gt,axis=0)\n#     m_gt=(m_gt>0).astype(np.uint8)\n#     m_pred=(m_pred>0).astype(np.uint8) if m_pred is not None else np.zeros_like(m_gt)\n#     f1 = f1_score(m_gt.flatten(), m_pred.flatten(), zero_division=0)\n#     results.append((Path(p).stem, f1, dbg))\n# print(\"\\n F1-score par image falsifiée:\\n\")\n# for cid,f1,dbg in results:\n#     print(f\"{cid} — F1={f1:.4f} | area={dbg['area']} mean={dbg['mean_inside']:.3f} thr={dbg['thr']:.3f}\")\n# print(f\"\\n Moyenne F1 (falsifiées) = {np.mean([r[1] for r in results]):.4f}\")","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:37:34.089932Z","iopub.execute_input":"2026-01-12T05:37:34.090229Z","iopub.status.idle":"2026-01-12T05:37:36.134002Z","shell.execute_reply.started":"2026-01-12T05:37:34.090206Z","shell.execute_reply":"2026-01-12T05:37:36.13335Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os, json, cv2\nimport numpy as np\nimport pandas as pd\nfrom pathlib import Path\nfrom PIL import Image\nimport matplotlib.pyplot as plt\nfrom tqdm import tqdm\n\n# --- RLE Encoder for Kaggle Submission ---\ndef rle_encode(mask: np.ndarray, fg_val: int = 1) -> str:\n    pixels = mask.T.flatten()\n    dots = np.where(pixels == fg_val)[0]\n    if len(dots) == 0:\n        return \"authentic\"\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 json.dumps([int(x) for x in run_lengths])\n\n# --- Paths ---\nTEST_DIR = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection/test_images\"\n#TEST_DIR = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection/supplemental_images\"\nSAMPLE_SUB = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection/sample_submission.csv\"\nOUT_PATH = \"submission.csv\"\n\nrows = []\nfor f in tqdm(sorted(os.listdir(TEST_DIR)), desc=\"Inference on Test Set\"):\n    pil = Image.open(Path(TEST_DIR)/f).convert(\"RGB\")\n    label, mask, dbg = pipeline_final(pil)  # utilise la version améliorée\n\n    # Sécurisation masque\n    if mask is None:\n        mask = np.zeros(pil.size[::-1], np.uint8)\n    else:\n        mask = np.array(mask, dtype=np.uint8)\n\n    # Annotation finale\n    if label == \"authentic\":\n        annot = \"authentic\"\n    else:\n        annot = rle_encode((mask > 0).astype(np.uint8))\n\n    rows.append({\n        \"case_id\": Path(f).stem,\n        \"annotation\": annot,\n        \"area\": int(dbg.get(\"area\", mask.sum())),\n        \"mean\": float(dbg.get(\"mean_inside\", 0.0)),\n        \"thr\": float(dbg.get(\"thr\", 0.0))\n    })\n\n\nsub = pd.DataFrame(rows)\nss = pd.read_csv(SAMPLE_SUB)\nss[\"case_id\"] = ss[\"case_id\"].astype(str)\nsub[\"case_id\"] = sub[\"case_id\"].astype(str)\nfinal = ss[[\"case_id\"]].merge(sub, on=\"case_id\", how=\"left\")\n#final = ss[[\"case_id\"]].merge(sub, on=\"case_id\", how=\"right\") # CHANGE FOR TEST SET!\nfinal[\"annotation\"] = final[\"annotation\"].fillna(\"authentic\")\nfinal[[\"case_id\", \"annotation\"]].to_csv(OUT_PATH, index=False)\n\nprint(f\"\\n✅ Saved submission file: {OUT_PATH}\")\nprint(final.head(10))","metadata":{"trusted":true,"_kg_hide-output":true,"execution":{"iopub.status.busy":"2026-01-12T05:37:37.170741Z","iopub.execute_input":"2026-01-12T05:37:37.171278Z","iopub.status.idle":"2026-01-12T05:37:46.58291Z","shell.execute_reply.started":"2026-01-12T05:37:37.171255Z","shell.execute_reply":"2026-01-12T05:37:46.582255Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"del encoder, model_seg; gc.collect()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T06:07:50.16359Z","iopub.execute_input":"2026-01-12T06:07:50.163856Z","iopub.status.idle":"2026-01-12T06:07:50.514423Z","shell.execute_reply.started":"2026-01-12T06:07:50.163835Z","shell.execute_reply":"2026-01-12T06:07:50.513851Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"submission_dino = final.copy()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Now make submission based on SAM3","metadata":{}},{"cell_type":"code","source":"import torch\n#################################### For Image ####################################\nfrom PIL import Image\nfrom sam3.model_builder import build_sam3_image_model\nfrom sam3.model.sam3_image_processor import Sam3Processor\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.patches import Rectangle\n\nimport os\nfrom pathlib import Path\nimport numpy as np\nimport pandas as pd\nimport cv2\nfrom tqdm.auto import tqdm\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nfrom sklearn.model_selection import GroupKFold\n\nimport json\nimport gc\nimport math\nimport itertools\nimport random\nimport warnings\nimport scipy.optimize\nfrom typing import List, Optional, Tuple\nfrom concurrent.futures import ThreadPoolExecutor\n\nimport numba\nfrom numba import types\nimport numpy.typing as npt\n\nfrom transformers import AutoImageProcessor, AutoModel","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:50:10.459302Z","iopub.execute_input":"2026-01-12T04:50:10.459591Z","iopub.status.idle":"2026-01-12T04:50:10.472247Z","shell.execute_reply.started":"2026-01-12T04:50:10.459575Z","shell.execute_reply":"2026-01-12T04:50:10.47152Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\n#################################### For Image ####################################\nfrom PIL import Image\nfrom sam3.model_builder import build_sam3_image_model\nfrom sam3.model.sam3_image_processor import Sam3Processor\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.patches import Rectangle\n\nimport os\nfrom pathlib import Path\nimport numpy as np\nimport pandas as pd\nimport cv2\nfrom tqdm.auto import tqdm\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nfrom sklearn.model_selection import GroupKFold\n\nimport json\nimport gc\nimport math\nimport itertools\nimport random\nimport warnings\nimport scipy.optimize\nfrom typing import List, Optional, Tuple\nfrom concurrent.futures import ThreadPoolExecutor\nfrom matplotlib.patches import Polygon\n\nimport numba\nfrom numba import types\nimport numpy.typing as npt\n\nfrom transformers import AutoImageProcessor, AutoModel\n\n# Filter warnings\nwarnings.filterwarnings(\"ignore\")\n\ndef boxes_to_rle_masks(duplicate_box_groups, image_height, image_width):\n    mask = np.zeros((len(duplicate_box_groups), image_height, image_width), dtype=np.uint8)\n    for channel, duplicate_group in enumerate(duplicate_box_groups):\n        for (x, y, w, h) in duplicate_group:\n            mask[channel, y:y+h, x:x+w] = 1\n    \n    rle_string = rle_encode(masks=mask)\n    if rle_string == \"[]\":\n        rle_string = \"authentic\"\n    return rle_string\n\n@numba.jit(nopython=True)\ndef _rle_encode_jit(x: npt.NDArray, fg_val: int = 1) -> list[int]:\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[npt.NDArray], fg_val: int = 1) -> str:\n    return ';'.join([json.dumps(_rle_encode_jit(x, fg_val)) for x in masks])\n\ndef aggregate_duplicates(duplicates: List[Tuple[int, int, str, int, float, Tuple, Tuple]]) -> List[List[int]]:\n    if not duplicates: return []\n    parent = {}\n    def find(x):\n        if x not in parent: parent[x] = x\n        if parent[x] != x: parent[x] = find(parent[x])\n        return parent[x]\n    def union(x, y):\n        root_x = find(x); root_y = find(y)\n        if root_x != root_y: parent[root_x] = root_y\n    for idx_i, idx_j, _, _, _, _, _ in duplicates:\n        union(idx_i, idx_j)\n    from collections import defaultdict\n    groups = defaultdict(list)\n    for idx in parent.keys():\n        groups[find(idx)].append(idx)\n    return list(groups.values())\n\n# -------------------------------------------------------------------------\n# GPU WORKER FUNCTION\n# -------------------------------------------------------------------------\ndef process_subset_on_device(device_id, image_paths, prompts, threshold=0.05, visualize=False):\n    \"\"\"\n    Worker function to process a list of images on a dedicated GPU.\n    \"\"\"\n    # Force this thread to use the specific GPU\n    torch.cuda.set_device(device_id)\n    device = torch.device(f'cuda:{device_id}')\n    \n    print(f\"[GPU {device_id}] Initializing SAM3 model...\")\n    \n    try:\n        # Initialize model specifically on this device\n        model = build_sam3_image_model(checkpoint_path='/kaggle/input/sam3-meta/sam3.pt')\n        model.to(device)\n        model.eval()\n        \n        processor = Sam3Processor(model, confidence_threshold=0.375)\n        \n        local_results = []\n        pbar = tqdm(image_paths, desc=f\"GPU {device_id}\", position=device_id, leave=True)\n        \n        for img_path in pbar:\n            try:\n                image = Image.open(img_path).convert('RGB')\n                all_boxes = []\n                \n                with torch.no_grad():\n                    inference_state = processor.set_image(image)\n                    \n                    for prompt_text in prompts:\n                        output = processor.set_text_prompt(state=inference_state, prompt=prompt_text)\n                        \n                        masks, boxes, scores = output[\"masks\"], output[\"boxes\"], output[\"scores\"]\n                        \n                        high_confidence_idx = scores > threshold\n                        filtered_boxes = boxes[high_confidence_idx]\n                        \n                        if filtered_boxes.shape[0] > 0:\n                            all_boxes.append(filtered_boxes.detach().cpu().numpy())\n                            break \n\n                if len(all_boxes) > 0:\n                    all_boxes_concat = np.concatenate(all_boxes, axis=0)\n                    \n                    # Convert (x1, y1, x2, y2) -> (x, y, w, h)\n                    all_boxes_concat[:, 2] = all_boxes_concat[:, 2] - all_boxes_concat[:, 0]\n                    all_boxes_concat[:, 3] = all_boxes_concat[:, 3] - all_boxes_concat[:, 1]\n                    all_boxes_concat = all_boxes_concat.round().astype('int32')\n                    \n                    # Filter out degenerate boxes (width or height <= 0)\n                    valid_idx = (all_boxes_concat[:, 2] > 0) & (all_boxes_concat[:, 3] > 0)\n                    all_boxes_concat = all_boxes_concat[valid_idx]\n                else:\n                    all_boxes_concat = np.array([])\n\n                local_results.append({\n                    'path': img_path,\n                    'is_panel': len(all_boxes_concat) >= 2,\n                    'bbox_preds': all_boxes_concat\n                })\n                \n                del inference_state\n\n            except Exception as e:\n                print(f\"Error processing image {img_path} on GPU {device_id}: {e}\")\n                local_results.append({\n                    'path': img_path,\n                    'is_panel': False,\n                    'bbox_preds': np.array([])\n                })\n\n        del model, processor\n        torch.cuda.empty_cache()\n        return local_results\n\n    except Exception as e:\n        print(f\"CRITICAL FAILURE on GPU {device_id}: {e}\")\n        return []\n\ndef generate_test_ensemble_predictions(\n    test_dir='test_images',\n    model_pattern='best_vitunet_external_data_warmup_fold{}.pth',\n    n_folds=5,\n    cls_threshold=0.76,\n    mask_threshold=0.76,\n    output_csv='submission.csv',\n    device=None,\n    visualize=False\n):\n    if device is None:\n        n_gpus = torch.cuda.device_count()\n        print(f\"Detected {n_gpus} GPUs.\")\n    \n    test_images = sorted(list(Path(test_dir).glob('*.png')))\n    print(f\"Found {len(test_images)} test images\")\n    \n    if len(test_images) == 0:\n        print(f\"⚠️  No images found in {test_dir}\")\n        return\n\n    # ------------------------------------------------------------------\n    # 1. Parallel SAM3 Panel Detection\n    # ------------------------------------------------------------------\n    \n    prompts = [\n        'outlined scientific images',\n        'microscopic images',\n        'outlined images',\n        #'bordered rectangles',\n        #'greyscale rectangular',\n    ]\n\n    n_workers = min(torch.cuda.device_count(), 2) if torch.cuda.is_available() else 1\n    chunk_size = math.ceil(len(test_images) / n_workers)\n    image_chunks = [test_images[i:i + chunk_size] for i in range(0, len(test_images), chunk_size)]\n    \n    raw_or_panel_results = []\n    \n    print(f\"Starting SAM3 inference on {n_workers} GPUs...\")\n    \n    with ThreadPoolExecutor(max_workers=n_workers) as executor:\n        futures = []\n        for i in range(n_workers):\n            if i < len(image_chunks):\n                futures.append(\n                    executor.submit(\n                        process_subset_on_device, \n                        device_id=i, \n                        image_paths=image_chunks[i], \n                        prompts=prompts,\n                        visualize=visualize\n                    )\n                )\n        \n        for future in futures:\n            try:\n                raw_or_panel_results.extend(future.result())\n            except Exception as e:\n                print(f\"Worker thread exception: {e}\")\n\n    raw_or_panel = pd.DataFrame(raw_or_panel_results)\n\n    # ------------------------------------------------------------------\n    # 2. CPU Processing (SIFT Keypoints)\n    # ------------------------------------------------------------------\n    \n    if 'is_panel' not in raw_or_panel.columns:\n        raw_or_panel = pd.DataFrame({'path': test_images, 'is_panel': False, 'bbox_preds': [np.array([])]*len(test_images)})\n\n    test_images_panels = raw_or_panel.loc[raw_or_panel['is_panel'], 'path'].tolist()\n\n    if len(test_images_panels) > 0:\n        \n        # --- PARAMETERS ---\n        SIFT_FEATURES = 2500\n        SIFT_CONTRAST_THRESH = 0.02\n        SIFT_EDGE_THRESH = 10\n        LOWE_RATIO = 0.75\n        MIN_MATCH_COUNT = 25\n        RANSAC_REPROJ_THRESH = 3.0\n        MIN_INLIER_RATIO = 0.8\n        MAX_SCALE_CHANGE = 3.0\n        MIN_SCALE_CHANGE = 0.33\n        IOU_THRESH = 0.5 \n        \n        # --- HELPERS ---\n        def compute_iou(box1, box2):\n            x1, y1, w1, h1 = box1; x2, y2, w2, h2 = box2\n            xi1 = max(x1, x2); yi1 = max(y1, y2)\n            xi2 = min(x1 + w1, x2 + w2); yi2 = min(y1 + h1, y2 + h2)\n            inter_area = max(0, xi2 - xi1) * max(0, yi2 - yi1)\n            return inter_area / ((w1*h1) + (w2*h2) - inter_area + 1e-10)\n        \n        def extract_keypoints_and_descriptors(crop_rgb):\n            # FIX: Check if crop is valid/empty\n            if crop_rgb is None or crop_rgb.size == 0:\n                return [], None\n            \n            # Ensure 3 channels\n            if len(crop_rgb.shape) == 2:\n                crop_rgb = cv2.cvtColor(crop_rgb, cv2.COLOR_GRAY2RGB)\n                \n            try:\n                gray = cv2.cvtColor(crop_rgb, cv2.COLOR_RGB2GRAY)\n            except Exception as e:\n                # Fallback if conversion fails\n                return [], None\n\n            clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))\n            gray = clahe.apply(gray)\n            \n            kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3,3))\n            morph_grad = cv2.morphologyEx(gray, cv2.MORPH_GRADIENT, kernel)\n            \n            mean_val = np.mean(morph_grad)\n            std_val = np.std(morph_grad)\n            _, binary_mask = cv2.threshold(morph_grad, mean_val + 2.5 * std_val, 255, cv2.THRESH_BINARY)\n            \n            dilated_mask = cv2.dilate(binary_mask, kernel, iterations=2)\n            blurred_gray = cv2.GaussianBlur(gray, (15, 15), 0)\n            gray = np.where(dilated_mask > 0, blurred_gray, gray)\n            \n            sift = cv2.SIFT_create(\n                nfeatures=SIFT_FEATURES, \n                contrastThreshold=SIFT_CONTRAST_THRESH, \n                edgeThreshold=SIFT_EDGE_THRESH\n            )\n            return sift.detectAndCompute(gray, None)\n\n        def match_descriptors(desc1, desc2):\n            if desc1 is None or desc2 is None or len(desc1) < 2 or len(desc2) < 2: return []\n            flann = cv2.FlannBasedMatcher(dict(algorithm=1, trees=5), dict(checks=50))\n            try:\n                matches = flann.knnMatch(desc1, desc2, k=2)\n            except Exception:\n                return []\n                \n            return [m for m, n in matches if m.distance < LOWE_RATIO * n.distance]\n\n        def get_keypoint_bounding_box(keypoints, matches, query_or_train='query', padding=10):\n            if not matches: return (0, 0, 0, 0)\n            pts = np.array([keypoints[m.queryIdx if query_or_train == 'query' else m.trainIdx].pt for m in matches])\n            x_min, y_min = pts.min(axis=0); x_max, y_max = pts.max(axis=0)\n            return (int(max(0, x_min-padding)), int(max(0, y_min-padding)), int(x_max-x_min+2*padding), int(y_max-y_min+2*padding))\n\n        def validate_geometric_transformation_with_bbox(kp1, kp2, matches, box1_offset, box2_offset):\n            if len(matches) < MIN_MATCH_COUNT: return False, 0, None, \"\", (0,0,0,0), (0,0,0,0)\n            src_pts = np.float32([kp1[m.queryIdx].pt for m in matches]).reshape(-1, 1, 2)\n            dst_pts = np.float32([kp2[m.trainIdx].pt for m in matches]).reshape(-1, 1, 2)\n            \n            try:\n                H, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, RANSAC_REPROJ_THRESH)\n            except Exception:\n                return False, 0, None, \"\", (0,0,0,0), (0,0,0,0)\n                \n            if H is None: return False, 0, None, \"\", (0,0,0,0), (0,0,0,0)\n            inliers = mask.ravel().tolist()\n            num_inliers = sum(inliers)\n            if (num_inliers / len(matches)) < MIN_INLIER_RATIO or num_inliers < MIN_MATCH_COUNT:\n                return False, num_inliers, None, \"\", (0,0,0,0), (0,0,0,0)\n            \n            inlier_matches = [m for m, is_inlier in zip(matches, inliers) if is_inlier]\n            bb1 = get_keypoint_bounding_box(kp1, inlier_matches, 'query', 10)\n            bb2 = get_keypoint_bounding_box(kp2, inlier_matches, 'train', 10)\n            \n            return True, num_inliers, H, \"affine\", \\\n                   (bb1[0]+box1_offset[0], bb1[1]+box1_offset[1], bb1[2], bb1[3]), \\\n                   (bb2[0]+box2_offset[0], bb2[1]+box2_offset[1], bb2[2], bb2[3])\n\n        def process_panel_images(test_images, raw_or_panel):\n            results = []\n            for img_path in tqdm(test_images, desc=\"Processing panels (SIFT)\"):\n                img_rgb = cv2.cvtColor(cv2.imread(str(img_path)), cv2.COLOR_BGR2RGB)\n                boxes = raw_or_panel.loc[raw_or_panel['path'] == img_path, 'bbox_preds'].iloc[0]\n                n = len(boxes)\n                duplicates = []\n                \n                if n > 1:\n                    # Safely extract crops\n                    crops = []\n                    valid_boxes = []\n                    valid_indices = []\n                    \n                    for i, (x, y, w, h) in enumerate(boxes):\n                        if w <= 0 or h <= 0: continue\n                        # Handle potential out of bounds slicing which returns empty\n                        c = img_rgb[y:y+h, x:x+w]\n                        if c.size > 0:\n                            crops.append(c)\n                            valid_boxes.append((x,y,w,h))\n                            valid_indices.append(i)\n                    \n                    if len(crops) > 1:\n                        kps_descs = [extract_keypoints_and_descriptors(c) for c in crops]\n                        \n                        # Compare combinations\n                        for i_idx, j_idx in itertools.combinations(range(len(crops)), 2):\n                            # Map back to original indices if needed, but here we just need boxes\n                            box_i = valid_boxes[i_idx]\n                            box_j = valid_boxes[j_idx]\n                            \n                            if compute_iou(box_i, box_j) > IOU_THRESH: continue\n                            \n                            matches = match_descriptors(kps_descs[i_idx][1], kps_descs[j_idx][1])\n                            \n                            is_dup, n_in, _, t_desc, bb_i, bb_j = validate_geometric_transformation_with_bbox(\n                                kps_descs[i_idx][0], kps_descs[j_idx][0], matches, \n                                (box_i[0], box_i[1]), (box_j[0], box_j[1])\n                            )\n                            \n                            if is_dup:\n                                # Use original indices if strictly required, but for RLE we just need the boxes\n                                duplicates.append((valid_indices[i_idx], valid_indices[j_idx], t_desc, n_in, n_in/len(matches), bb_i, bb_j))\n                \n                # Filter overlapping duplicates\n                final_dups = [d for d in duplicates if compute_iou(d[5], d[6]) < 0.7]\n                results.append({\"path\": img_path, \"duplicates\": final_dups, \"boxes\": boxes})\n            return results\n\n        results = process_panel_images(test_images_panels, raw_or_panel)\n\n        submission_df_panel = []\n        for r in results:\n            img_name = Path(r['path']).stem\n            if len(r['duplicates']) == 0:\n                rle_string = 'authentic'\n            else:\n                grouped_boxes = []\n                for i, j, _, _, _, bbox_i, bbox_j in r['duplicates']:\n                    grouped_boxes.append([bbox_i, bbox_j])\n                \n                img_shape = cv2.imread(str(r['path'])).shape\n                rle_string = boxes_to_rle_masks(grouped_boxes, img_shape[0], img_shape[1])\n\n            submission_df_panel.append({\n                'case_id': img_name,\n                'annotation': rle_string,\n                'boxes': r['boxes']\n            })\n        submission_df_panel = pd.DataFrame(submission_df_panel)\n    else:\n        submission_df_panel = pd.DataFrame()\n\n    # Raw images\n    test_images_raw = raw_or_panel.loc[~raw_or_panel['is_panel'], 'path'].tolist()\n    submission_df_raw = pd.DataFrame([{'case_id': Path(p).stem, 'annotation': \"authentic\", \"boxes\": None} for p in test_images_raw])\n    \n    submission_df = pd.concat([submission_df_panel, submission_df_raw], axis=0)\n    submission_df.to_csv(output_csv, index=False)\n    \n    print(f\"\\n✅ Saved {len(submission_df)} predictions to {output_csv}\")\n    return submission_df","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:38:07.748116Z","iopub.execute_input":"2026-01-12T05:38:07.748708Z","iopub.status.idle":"2026-01-12T05:38:07.790118Z","shell.execute_reply.started":"2026-01-12T05:38:07.748683Z","shell.execute_reply":"2026-01-12T05:38:07.78942Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print('ready')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:38:10.120103Z","iopub.execute_input":"2026-01-12T05:38:10.120907Z","iopub.status.idle":"2026-01-12T05:38:10.124853Z","shell.execute_reply.started":"2026-01-12T05:38:10.120882Z","shell.execute_reply":"2026-01-12T05:38:10.123976Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# Generate predictions\nsubmission_df = generate_test_ensemble_predictions(\n    #test_dir='/kaggle/input/recodai-luc-scientific-image-forgery-detection/supplemental_images',\n    test_dir='/kaggle/input/recodai-luc-scientific-image-forgery-detection/test_images',\n    model_pattern='/kaggle/input/segformer-b3-copyforge/best_segformerb3_fold{}.pth',\n    n_folds=5,\n    cls_threshold=0.75,\n    mask_threshold=0.70,\n    output_csv='submission.csv',\n    device=None,\n    visualize=False\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T04:59:26.070519Z","iopub.execute_input":"2026-01-12T04:59:26.071075Z","iopub.status.idle":"2026-01-12T05:03:24.922389Z","shell.execute_reply.started":"2026-01-12T04:59:26.071051Z","shell.execute_reply":"2026-01-12T05:03:24.921679Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# plt.ioff()\n# matplotlib.use('agg')\nprint('done')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:03:24.923539Z","iopub.execute_input":"2026-01-12T05:03:24.923838Z","iopub.status.idle":"2026-01-12T05:03:24.927801Z","shell.execute_reply.started":"2026-01-12T05:03:24.923813Z","shell.execute_reply":"2026-01-12T05:03:24.927095Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Combine both subs","metadata":{}},{"cell_type":"code","source":"submission_dino","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:38:12.012107Z","iopub.execute_input":"2026-01-12T05:38:12.012372Z","iopub.status.idle":"2026-01-12T05:38:12.024962Z","shell.execute_reply.started":"2026-01-12T05:38:12.012351Z","shell.execute_reply":"2026-01-12T05:38:12.024299Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"submission_df","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:38:12.684041Z","iopub.execute_input":"2026-01-12T05:38:12.684578Z","iopub.status.idle":"2026-01-12T05:38:12.798913Z","shell.execute_reply.started":"2026-01-12T05:38:12.684552Z","shell.execute_reply":"2026-01-12T05:38:12.798304Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sub = submission_dino.merge(submission_df, on='case_id', how='inner', suffixes=('','detr'))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:38:22.163678Z","iopub.execute_input":"2026-01-12T05:38:22.164215Z","iopub.status.idle":"2026-01-12T05:38:22.170973Z","shell.execute_reply.started":"2026-01-12T05:38:22.164194Z","shell.execute_reply":"2026-01-12T05:38:22.170241Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sub['annotation'] = sub['annotation'].fillna('authentic')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:38:22.190526Z","iopub.execute_input":"2026-01-12T05:38:22.190724Z","iopub.status.idle":"2026-01-12T05:38:22.194892Z","shell.execute_reply.started":"2026-01-12T05:38:22.190709Z","shell.execute_reply":"2026-01-12T05:38:22.194171Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Replace if we found an exact match","metadata":{}},{"cell_type":"code","source":"replace_mask = (sub['annotationdetr'] != 'authentic') & (sub['annotationdetr'].notnull())\nsub.loc[replace_mask, 'annotation'] = sub.loc[replace_mask, 'annotationdetr']\nsub","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:38:23.122818Z","iopub.execute_input":"2026-01-12T05:38:23.123081Z","iopub.status.idle":"2026-01-12T05:38:23.227941Z","shell.execute_reply.started":"2026-01-12T05:38:23.123061Z","shell.execute_reply":"2026-01-12T05:38:23.227352Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"submission_df.to_csv('./submission_sam3.csv',index=False)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:38:33.8716Z","iopub.execute_input":"2026-01-12T05:38:33.871849Z","iopub.status.idle":"2026-01-12T05:38:33.892313Z","shell.execute_reply.started":"2026-01-12T05:38:33.871831Z","shell.execute_reply":"2026-01-12T05:38:33.89177Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Now let's re-run it on things we havent fonud anything on yet.","metadata":{}},{"cell_type":"code","source":"# -------------------------------------------------------------------------\n# GPU WORKER FUNCTION\n# -------------------------------------------------------------------------\ndef process_subset_on_device(device_id, image_paths, prompts, threshold=0.05, visualize=False):\n    \"\"\"\n    Worker function to process a list of images on a dedicated GPU.\n    \"\"\"\n    # Force this thread to use the specific GPU\n    torch.cuda.set_device(device_id)\n    device = torch.device(f'cuda:{device_id}')\n    \n    print(f\"[GPU {device_id}] Initializing SAM3 model...\")\n    \n    try:\n        # Initialize model specifically on this device\n        model = build_sam3_image_model(checkpoint_path='/kaggle/input/sam3-meta/sam3.pt')\n        model.to(device)\n        model.eval()\n        \n        processor = Sam3Processor(model, confidence_threshold=0.375)\n        \n        local_results = []\n        pbar = tqdm(image_paths, desc=f\"GPU {device_id}\", position=device_id, leave=True)\n        \n        for img_path in pbar:\n            try:\n                image = Image.open(img_path).convert('RGB')\n                all_boxes = []\n                \n                with torch.no_grad():\n                    inference_state = processor.set_image(image)\n                    \n                    for prompt_text in prompts:\n                        output = processor.set_text_prompt(state=inference_state, prompt=prompt_text)\n                        \n                        masks, boxes, scores = output[\"masks\"], output[\"boxes\"], output[\"scores\"]\n                        \n                        high_confidence_idx = scores > threshold\n                        filtered_boxes = boxes[high_confidence_idx]\n                        \n                        if filtered_boxes.shape[0] > 0:\n                            all_boxes.append(filtered_boxes.detach().cpu().numpy())\n                            break \n\n                if len(all_boxes) > 0:\n                    all_boxes_concat = np.concatenate(all_boxes, axis=0)\n                    \n                    # Convert (x1, y1, x2, y2) -> (x, y, w, h)\n                    all_boxes_concat[:, 2] = all_boxes_concat[:, 2] - all_boxes_concat[:, 0]\n                    all_boxes_concat[:, 3] = all_boxes_concat[:, 3] - all_boxes_concat[:, 1]\n                    all_boxes_concat = all_boxes_concat.round().astype('int32')\n                    \n                    # Filter out degenerate boxes (width or height <= 0)\n                    valid_idx = (all_boxes_concat[:, 2] > 0) & (all_boxes_concat[:, 3] > 0)\n                    all_boxes_concat = all_boxes_concat[valid_idx]\n                else:\n                    all_boxes_concat = np.array([])\n\n                local_results.append({\n                    'path': img_path,\n                    'is_panel': len(all_boxes_concat) >= 1,\n                    'bbox_preds': all_boxes_concat\n                })\n                \n                del inference_state\n\n            except Exception as e:\n                print(f\"Error processing image {img_path} on GPU {device_id}: {e}\")\n                local_results.append({\n                    'path': img_path,\n                    'is_panel': False,\n                    'bbox_preds': np.array([])\n                })\n\n        del model, processor\n        torch.cuda.empty_cache()\n        return local_results\n\n    except Exception as e:\n        print(f\"CRITICAL FAILURE on GPU {device_id}: {e}\")\n        return []","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import torch\n#################################### For Image ####################################\nfrom PIL import Image\nfrom sam3.model_builder import build_sam3_image_model\nfrom sam3.model.sam3_image_processor import Sam3Processor\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.patches import Rectangle\nfrom matplotlib.patches import Polygon\n\nimport os\nfrom pathlib import Path\nimport numpy as np\nimport pandas as pd\nimport cv2\nfrom tqdm.auto import tqdm\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nfrom sklearn.model_selection import GroupKFold\n\nimport json\nimport gc\nimport math\nimport itertools\n# -------------------------------------------------------------------------\n# MAIN ORCHESTRATOR & CPU SIFT LOGIC\n# -------------------------------------------------------------------------\ndef generate_test_ensemble_predictions(\n    test_dir='test_images',\n    model_pattern='best_vitunet_external_data_warmup_fold{}.pth',\n    n_folds=5,\n    cls_threshold=0.76,\n    mask_threshold=0.76,\n    output_csv='submission.csv',\n    device=None,\n    visualize=False\n):\n    if device is None:\n        n_gpus = torch.cuda.device_count()\n        print(f\"Detected {n_gpus} GPUs.\")\n    \n    test_images = sorted(list(Path(test_dir).glob('*.png')))\n    \n    # Only make predictions on images that we haven't found duplicates for yet.\n    s = pd.read_csv('./submission_sam3.csv')\n    #print(\"AAAAAAAAAAAAAA\", test_images)\n    #print('HELLO!!!!!!!!!!!!', [file.stem for file in test_images])\n    #print('YESSSSSSSSSS', s.loc[s['annotation'] != 'authentic', 'case_id'].values)\n    test_images = [file for file in test_images \n                   if file.stem not in s.loc[s['annotation'] != 'authentic', 'case_id'].map(str).values]\n    print(f\"Found {len(test_images)} test images\")\n    \n    if len(test_images) == 0:\n        return\n\n    # --- PART 1: PARALLEL GPU SAM3 ---\n    prompts = [\n        'greyscale rectangular',\n\t\t'bordered rectangles'\n    ]\n\n    n_workers = min(torch.cuda.device_count(), 2) if torch.cuda.is_available() else 1\n    chunk_size = math.ceil(len(test_images) / n_workers)\n    image_chunks = [test_images[i:i + chunk_size] for i in range(0, len(test_images), chunk_size)]\n    \n    raw_or_panel_results = []\n    \n    print(f\"Starting SAM3 inference on {n_workers} GPUs...\")\n    with ThreadPoolExecutor(max_workers=n_workers) as executor:\n        futures = []\n        for i in range(n_workers):\n            if i < len(image_chunks):\n                futures.append(\n                    executor.submit(\n                        process_subset_on_device, \n                        device_id=i, \n                        image_paths=image_chunks[i], \n                        prompts=prompts,\n                        threshold=0.05\n                    )\n                )\n        for future in futures:\n            try:\n                raw_or_panel_results.extend(future.result())\n            except Exception as e:\n                print(f\"Thread error: {e}\")\n\n    raw_or_panel = pd.DataFrame(raw_or_panel_results)\n\n    # --- PART 2: CPU SIFT PROCESSING (Sequential) ---\n    \n    # Filter for is_panel (which is technically everything now)\n    if 'is_panel' not in raw_or_panel.columns:\n         raw_or_panel = pd.DataFrame({'path': test_images, 'is_panel': False, 'bbox_preds': [np.array([])]*len(test_images)})\n    \n    test_images_panels = raw_or_panel.loc[raw_or_panel['is_panel'], 'path'].tolist()\n\n    if len(test_images_panels) > 0:\n        \n        # --- PARAMETERS ---\n        SIFT_FEATURES = 3250           \n        SIFT_CONTRAST_THRESH = 0.02    \n        SIFT_EDGE_THRESH = 10          \n        LOWE_RATIO = 0.75              \n        MIN_MATCH_COUNT = 10            \n        RANSAC_REPROJ_THRESH = 3.0     \n        MIN_INLIER_RATIO = 0.8         \n        MAX_SCALE_CHANGE = 3.0         \n        MIN_SCALE_CHANGE = 0.33        \n        IOU_THRESH = 0.5 \n\n        # --- HELPERS FOR SIFT ---\n        def compute_iou(box1, box2):\n            x1, y1, w1, h1 = box1; x2, y2, w2, h2 = box2\n            xi1 = max(x1, x2); yi1 = max(y1, y2)\n            xi2 = min(x1 + w1, x2 + w2); yi2 = min(y1 + h1, y2 + h2)\n            inter_area = max(0, xi2 - xi1) * max(0, yi2 - yi1)\n            # Using min_area as per your provided snippet logic for pure intersection\n            min_area = min(w1*h1, w2*h2)\n            return inter_area / (min_area + 1e-10)\n\n        def extract_keypoints_and_descriptors(crop_rgb):\n            if crop_rgb is None or crop_rgb.size == 0: return [], None\n            if len(crop_rgb.shape) == 2: crop_rgb = cv2.cvtColor(crop_rgb, cv2.COLOR_GRAY2RGB)\n            \n            try:\n                gray = cv2.cvtColor(crop_rgb, cv2.COLOR_RGB2GRAY)\n            except:\n                return [], None\n\n            clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))\n            gray = clahe.apply(gray)\n            kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3,3))\n            morph_grad = cv2.morphologyEx(gray, cv2.MORPH_GRADIENT, kernel)\n            mean_val = np.mean(morph_grad); std_val = np.std(morph_grad)\n            _, binary_mask = cv2.threshold(morph_grad, mean_val + 2.5 * std_val, 255, cv2.THRESH_BINARY)\n            dilated_mask = cv2.dilate(binary_mask, kernel, iterations=2)\n            blurred_gray = cv2.GaussianBlur(gray, (15, 15), 0)\n            gray = np.where(dilated_mask > 0, blurred_gray, gray)\n            sift = cv2.SIFT_create(nfeatures=SIFT_FEATURES, contrastThreshold=SIFT_CONTRAST_THRESH, edgeThreshold=SIFT_EDGE_THRESH)\n            return sift.detectAndCompute(gray, None)\n\n        def match_descriptors(desc1, desc2):\n            if desc1 is None or desc2 is None or len(desc1) < 2 or len(desc2) < 2: return []\n            flann = cv2.FlannBasedMatcher(dict(algorithm=1, trees=5), dict(checks=50))\n            try: matches = flann.knnMatch(desc1, desc2, k=2)\n            except: return []\n            good = []\n            for pair in matches:\n                if len(pair) == 2:\n                    if pair[0].distance < LOWE_RATIO * pair[1].distance: good.append(pair[0])\n            return good\n\n        def get_keypoint_bounding_box(keypoints, matches, query_or_train='query', padding=10):\n            if not matches: return (0, 0, 0, 0)\n            pts = np.array([keypoints[m.queryIdx if query_or_train == 'query' else m.trainIdx].pt for m in matches])\n            x_min, y_min = pts.min(axis=0); x_max, y_max = pts.max(axis=0)\n            return (int(max(0, x_min-padding)), int(max(0, y_min-padding)), int(x_max-x_min+2*padding), int(y_max-y_min+2*padding))\n\n        def analyze_homography(H):\n            if H is None: return \"unknown\"\n            A = H[:2, :2]\n            U, S, Vt = np.linalg.svd(A)\n            sx, sy = S[0], S[1]\n            avg_scale = (sx + sy) / 2\n            if avg_scale > MAX_SCALE_CHANGE or avg_scale < MIN_SCALE_CHANGE: return f\"invalid_scale({avg_scale:.2f})\"\n            rotation = U @ Vt\n            angle_deg = math.degrees(math.atan2(rotation[1, 0], rotation[0, 0]))\n            det = np.linalg.det(A)\n            parts = []\n            if abs(angle_deg) > 5: parts.append(f\"rot{int(angle_deg)}°\")\n            if abs(avg_scale - 1.0) > 0.15: parts.append(f\"scale{avg_scale:.2f}x\")\n            if det < 0: parts.append(\"flip\")\n            if not parts: parts.append(\"identity\")\n            return \"+\".join(parts)\n\n        def validate_geometric_transformation_with_bbox(kp1, kp2, matches, box1_offset, box2_offset):\n            if len(matches) < MIN_MATCH_COUNT: return False, 0, None, \"\", (0,0,0,0), (0,0,0,0)\n            src_pts = np.float32([kp1[m.queryIdx].pt for m in matches]).reshape(-1, 1, 2)\n            dst_pts = np.float32([kp2[m.trainIdx].pt for m in matches]).reshape(-1, 1, 2)\n            try:\n                H, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, RANSAC_REPROJ_THRESH)\n            except: return False, 0, None, \"\", (0,0,0,0), (0,0,0,0)\n            if H is None: return False, 0, None, \"\", (0,0,0,0), (0,0,0,0)\n            inliers = mask.ravel().tolist()\n            num_inliers = sum(inliers)\n            if (num_inliers / len(matches)) < MIN_INLIER_RATIO or num_inliers < MIN_MATCH_COUNT:\n                return False, num_inliers, None, \"\", (0,0,0,0), (0,0,0,0)\n            inlier_matches = [m for m, is_inlier in zip(matches, inliers) if is_inlier]\n            bb1 = get_keypoint_bounding_box(kp1, inlier_matches, 'query', 10)\n            bb2 = get_keypoint_bounding_box(kp2, inlier_matches, 'train', 10)\n            \n            t_desc = analyze_homography(H)\n            \n            return True, num_inliers, H, t_desc, \\\n                   (bb1[0]+box1_offset[0], bb1[1]+box1_offset[1], bb1[2], bb1[3]), \\\n                   (bb2[0]+box2_offset[0], bb2[1]+box2_offset[1], bb2[2], bb2[3])\n\n        def find_duplicates_keypoint_based_v2(panel_img_rgb, boxes, verbose=False):\n            duplicates = []\n            \n            # --- NESTED HELPER FOR INTRA-BOX LOGIC ---\n            def check_intra_box_duplicates(crop, box_idx, global_offset):\n                h, w = crop.shape[:2]\n                if h < 32 or w < 32: return []\n                gx, gy = global_offset\n                found_local = []\n                \n                def match_subregions(img_a, offset_a, img_b, offset_b):\n                    kp_a, desc_a = extract_keypoints_and_descriptors(img_a)\n                    kp_b, desc_b = extract_keypoints_and_descriptors(img_b)\n                    matches_sub = match_descriptors(desc_a, desc_b)\n                    global_off_a = (gx + offset_a[0], gy + offset_a[1])\n                    global_off_b = (gx + offset_b[0], gy + offset_b[1])\n                    return validate_geometric_transformation_with_bbox(kp_a, kp_b, matches_sub, global_off_a, global_off_b)\n\n                # Strategy 1: L vs R\n                mid_x = w // 2\n                is_dup, n_in, _, trans, box_a, box_b = match_subregions(crop[:, :mid_x], (0, 0), crop[:, mid_x:], (mid_x, 0))\n                if is_dup: found_local.append((box_idx, box_idx, f\"Intra-LR: {trans}\", n_in, n_in/(n_in+1e-5), box_a, box_b))\n\n                # Strategy 2: T vs B\n                mid_y = h // 2\n                is_dup, n_in, _, trans, box_a, box_b = match_subregions(crop[:mid_y, :], (0, 0), crop[mid_y:, :], (0, mid_y))\n                if is_dup: found_local.append((box_idx, box_idx, f\"Intra-TB: {trans}\", n_in, n_in/(n_in+1e-5), box_a, box_b))\n                \n                # # Strategy 3: Quadrants\n                # quads = [(crop[:mid_y, :mid_x], (0,0)), (crop[:mid_y, mid_x:], (mid_x,0)), \n                #          (crop[mid_y:, :mid_x], (0,mid_y)), (crop[mid_y:, mid_x:], (mid_x,mid_y))]\n                # for i, j in itertools.combinations(range(4), 2):\n                #     is_dup, n_in, _, trans, box_a, box_b = match_subregions(quads[i][0], quads[i][1], quads[j][0], quads[j][1])\n                #     if is_dup: found_local.append((box_idx, box_idx, f\"Intra-Q{i}Q{j}: {trans}\", n_in, n_in/(n_in+1e-5), box_a, box_b))\n\n                # # Strategy 4: H Strips\n                # w3 = w // 3\n                # if w3 > 10:\n                #     h_strips = [(crop[:, :w3], (0,0)), (crop[:, w3:2*w3], (w3,0)), (crop[:, 2*w3:], (2*w3,0))]\n                #     for i, j in itertools.combinations(range(3), 2):\n                #         is_dup, n_in, _, trans, box_a, box_b = match_subregions(h_strips[i][0], h_strips[i][1], h_strips[j][0], h_strips[j][1])\n                #         if is_dup: found_local.append((box_idx, box_idx, f\"Intra-HStrip{i}-{j}: {trans}\", n_in, n_in/(n_in+1e-5), box_a, box_b))\n                \n                # Strategy 5: V Strips\n                h3 = h // 3\n                if h3 > 10:\n                    v_strips = [(crop[:h3, :], (0,0)), (crop[h3:2*h3, :], (0,h3)), (crop[2*h3:, :], (0,2*h3))]\n                    for i, j in itertools.combinations(range(3), 2):\n                        is_dup, n_in, _, trans, box_a, box_b = match_subregions(v_strips[i][0], v_strips[i][1], v_strips[j][0], v_strips[j][1])\n                        if is_dup: found_local.append((box_idx, box_idx, f\"Intra-VStrip{i}-{j}: {trans}\", n_in, n_in/(n_in+1e-5), box_a, box_b))\n\n                # # Strategy 6: Grid\n                # if not found_local and h > 60 and w > 60:\n                #     rows, cols = 2, 3\n                #     h_s, w_s = h//rows, w//cols\n                #     grid = []\n                #     for r in range(rows):\n                #         for c in range(cols):\n                #             y1, x1 = r*h_s, c*w_s\n                #             grid.append((crop[y1:y1+h_s, x1:x1+w_s], (x1,y1)))\n                #     for i, j in itertools.combinations(range(len(grid)), 2):\n                #         is_dup, n_in, _, trans, box_a, box_b = match_subregions(grid[i][0], grid[i][1], grid[j][0], grid[j][1])\n                #         if is_dup: found_local.append((box_idx, box_idx, f\"Intra-Grid{i}-{j}: {trans}\", n_in, n_in/(n_in+1e-5), box_a, box_b))\n                \n                return found_local\n\n            # --- MAIN FIND DUPLICATES LOGIC ---\n            n = len(boxes)\n            if n == 0: return []\n            \n            crops_rgb = []\n            for (x, y, w, h) in boxes:\n                crop = panel_img_rgb[y:y+h, x:x+w].copy()\n                if crop.size == 0: crop = np.zeros((h, w, 3), dtype=np.uint8)\n                crops_rgb.append(crop)\n            \n            keypoints_list, descriptors_list = [], []\n            \n            for i, crop in enumerate(crops_rgb):\n                box_offset = (boxes[i][0], boxes[i][1])\n                intra = check_intra_box_duplicates(crop, i, box_offset)\n                duplicates.extend(intra)\n                \n                kp, desc = extract_keypoints_and_descriptors(crop)\n                keypoints_list.append(kp); descriptors_list.append(desc)\n            \n            if n > 1:\n                for i, j in itertools.combinations(range(n), 2):\n                    if compute_iou(boxes[i], boxes[j]) > IOU_THRESH: continue\n                    matches = match_descriptors(descriptors_list[i], descriptors_list[j])\n                    if len(matches) < MIN_MATCH_COUNT: continue\n                    is_dup, n_in, _, t_desc, bb_i, bb_j = validate_geometric_transformation_with_bbox(\n                        keypoints_list[i], keypoints_list[j], matches, (boxes[i][0], boxes[i][1]), (boxes[j][0], boxes[j][1]))\n                    if is_dup:\n                        duplicates.append((i, j, t_desc, n_in, n_in/len(matches), bb_i, bb_j))\n\n            # Global Intra-Check (Entire Image)\n            #box_offset = (0, 0)\n            #intra_dups = check_intra_box_duplicates(panel_img_rgb, 999, box_offset)\n            #if intra_dups: duplicates.extend(intra_dups)\n            \n            return duplicates\n\n        def remove_overlapping_duplicates_v2(boxes, duplicates, iou_thresh=0.70):\n            if not duplicates: return boxes, duplicates\n            filtered_dups = []\n            for i, j, transform, n_inliers, ratio, bbox_i, bbox_j in duplicates:\n                if compute_iou(bbox_i, bbox_j) < iou_thresh:\n                    filtered_dups.append((i, j, transform, n_inliers, ratio, bbox_i, bbox_j))\n            return boxes, filtered_dups\n\n        def process_panel_images(test_images, raw_or_panel, verbose=False, visualize=False):\n            results = []\n            for img_path in tqdm(test_images, desc=\"Processing panels (SIFT)\"):\n                img = cv2.imread(str(img_path))\n                if img is None: continue\n                img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n                \n                boxes = raw_or_panel.loc[raw_or_panel['path'] == img_path, 'bbox_preds'].iloc[0]\n                \n                duplicates = find_duplicates_keypoint_based_v2(img_rgb, boxes, verbose=verbose)\n                \n                if len(duplicates) > 0:\n                    # Logic to remove overlapping duplicates if needed\n                    # (Simplified here to just call the filter once)\n                    boxes, duplicates = remove_overlapping_duplicates_v2(boxes, duplicates, iou_thresh=0.70)\n\n                if visualize and len(duplicates) > 0:\n                    plot_panel_with_keypoint_duplicates_enhanced_v2(img_rgb, boxes, duplicates)\n                \n                results.append({\"path\": img_path, \"duplicates\": duplicates, \"boxes\": boxes})\n            return results\n\n        # Run the CPU part\n        results = process_panel_images(test_images_panels, raw_or_panel, verbose=visualize, visualize=visualize)\n\n        submission_df_panel = []\n        for r in results:\n            img_name = Path(r['path']).stem\n            if len(r['duplicates']) == 0:\n                rle_string = 'authentic'\n            else:\n                grouped_boxes = []\n                # Collect boxes from duplicates\n                for _, _, _, _, _, bbox_i, bbox_j in r['duplicates']:\n                    grouped_boxes.append([bbox_i, bbox_j])\n                \n                img_shape = cv2.imread(str(r['path'])).shape\n                rle_string = boxes_to_rle_masks(grouped_boxes, img_shape[0], img_shape[1])\n\n            submission_df_panel.append({\n                'case_id': img_name,\n                'annotation': rle_string,\n                'boxes': r['boxes']\n            })\n        submission_df_panel = pd.DataFrame(submission_df_panel)\n    else:\n        submission_df_panel = pd.DataFrame()\n\n    # Raw images (technically none since we force everything to be panels, but kept for structure)\n    test_images_raw = raw_or_panel.loc[~raw_or_panel['is_panel'], 'path'].tolist()\n    submission_df_raw = pd.DataFrame([{'case_id': Path(p).stem, 'annotation': \"authentic\", \"boxes\": None} for p in test_images_raw])\n    \n    submission_df = pd.concat([submission_df_panel, submission_df_raw], axis=0)\n    submission_df.to_csv(output_csv, index=False)\n    \n    print(f\"\\n✅ Saved {len(submission_df)} predictions to {output_csv}\")\n    print(f\"   - Authentic images: {(submission_df['annotation'] == 'authentic').sum()}\")\n    print(f\"   - Forged images: {(submission_df['annotation'] != 'authentic').sum()}\")\n    \n    return submission_df","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:38:34.564221Z","iopub.execute_input":"2026-01-12T05:38:34.564873Z","iopub.status.idle":"2026-01-12T05:38:34.60801Z","shell.execute_reply.started":"2026-01-12T05:38:34.564848Z","shell.execute_reply":"2026-01-12T05:38:34.60729Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# Generate predictions\nsubmission_df_grayscale = generate_test_ensemble_predictions(\n    #test_dir='/kaggle/input/recodai-luc-scientific-image-forgery-detection/supplemental_images',\n    test_dir='/kaggle/input/recodai-luc-scientific-image-forgery-detection/test_images',\n    model_pattern='/kaggle/input/segformer-b3-copyforge/best_segformerb3_fold{}.pth',\n    n_folds=5,\n    cls_threshold=0.75,\n    mask_threshold=0.70,\n    output_csv='submission.csv',\n    device=None,\n    visualize=False\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:08:20.930301Z","iopub.execute_input":"2026-01-12T05:08:20.930904Z","iopub.status.idle":"2026-01-12T05:09:09.675568Z","shell.execute_reply.started":"2026-01-12T05:08:20.930882Z","shell.execute_reply":"2026-01-12T05:09:09.674892Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sub = sub.merge(submission_df_grayscale, on='case_id', how='left', suffixes=('','_grayscale'))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:38:39.980306Z","iopub.execute_input":"2026-01-12T05:38:39.980907Z","iopub.status.idle":"2026-01-12T05:38:39.986981Z","shell.execute_reply.started":"2026-01-12T05:38:39.980883Z","shell.execute_reply":"2026-01-12T05:38:39.98619Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"replace_mask = (sub['annotationdetr'] == 'authentic') & (sub['annotation_grayscale'] != 'authentic') & (sub['annotation_grayscale'].notnull())\nsub.loc[replace_mask, 'annotation'] = sub.loc[replace_mask, 'annotation_grayscale']","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:38:40.159993Z","iopub.execute_input":"2026-01-12T05:38:40.160628Z","iopub.status.idle":"2026-01-12T05:38:40.167319Z","shell.execute_reply.started":"2026-01-12T05:38:40.160597Z","shell.execute_reply":"2026-01-12T05:38:40.166465Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sub","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:38:40.77674Z","iopub.execute_input":"2026-01-12T05:38:40.777415Z","iopub.status.idle":"2026-01-12T05:38:40.900892Z","shell.execute_reply.started":"2026-01-12T05:38:40.777391Z","shell.execute_reply":"2026-01-12T05:38:40.900255Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Once more we run through.","metadata":{}},{"cell_type":"code","source":"import torch\n#################################### For Image ####################################\nfrom PIL import Image\nfrom sam3.model_builder import build_sam3_image_model\nfrom sam3.model.sam3_image_processor import Sam3Processor\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.patches import Rectangle\nfrom matplotlib.patches import Polygon\n\nimport os\nfrom pathlib import Path\nimport numpy as np\nimport pandas as pd\nimport cv2\nfrom tqdm.auto import tqdm\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torchvision import transforms\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nfrom sklearn.model_selection import GroupKFold\n\nimport json\nimport gc\nimport math\nimport itertools\nimport random\nimport warnings\nimport scipy.optimize\nfrom typing import List, Optional, Tuple\nfrom concurrent.futures import ThreadPoolExecutor\n\nimport numba\nfrom numba import types\nimport numpy.typing as npt\n\nfrom transformers import AutoImageProcessor, AutoModel\n\n# Filter warnings\nwarnings.filterwarnings(\"ignore\")\n\n# -------------------------------------------------------------------------\n# HELPER FUNCTIONS (RLE, IOU, PLOTTING)\n# -------------------------------------------------------------------------\n\n@numba.jit(nopython=True)\ndef _rle_encode_jit(x: npt.NDArray, fg_val: int = 1) -> list[int]:\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[npt.NDArray], fg_val: int = 1) -> str:\n    return ';'.join([json.dumps(_rle_encode_jit(x, fg_val)) for x in masks])\n\ndef boxes_to_rle_masks(duplicate_box_groups, image_height, image_width):\n    mask = np.zeros((len(duplicate_box_groups), image_height, image_width), dtype=np.uint8)\n    for channel, duplicate_group in enumerate(duplicate_box_groups):\n        for (x, y, w, h) in duplicate_group:\n            mask[channel, y:y+h, x:x+w] = 1\n    \n    rle_string = rle_encode(masks=mask)\n    if rle_string == \"[]\":\n        rle_string = \"authentic\"\n    return rle_string\n\ndef mask_to_rle(mask):\n    if mask.sum() == 0: return \"\"\n    mask_uint8 = (mask * 255).astype(np.uint8)\n    contours, _ = cv2.findContours(mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    instance_rles = []\n    for cnt in contours:\n        if cv2.contourArea(cnt) < 50: continue\n        instance_mask = np.zeros_like(mask, dtype=np.uint8)\n        cv2.drawContours(instance_mask, [cnt], -1, 1, thickness=-1)\n        # Assuming binary_mask_to_rle is implemented or using logic similar to rle_encode\n        # For compatibility with provided snippets, reusing logic:\n        pixels = instance_mask.T.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        instance_rles.append(' '.join(str(x) for x in runs))\n    return \" \".join(instance_rles)\n\ndef aggregate_duplicates(duplicates):\n    if not duplicates: return []\n    parent = {}\n    def find(x):\n        if x not in parent: parent[x] = x\n        if parent[x] != x: parent[x] = find(parent[x])\n        return parent[x]\n    def union(x, y):\n        root_x = find(x); root_y = find(y)\n        if root_x != root_y: parent[root_x] = root_y\n    for idx_i, idx_j, _, _, _, _, _ in duplicates:\n        union(idx_i, idx_j)\n    from collections import defaultdict\n    groups = defaultdict(list)\n    for idx in parent.keys():\n        groups[find(idx)].append(idx)\n    return list(groups.values())\n\ndef plot_panel_with_keypoint_duplicates_enhanced_v2(panel_img_rgb, boxes, duplicates):\n    # (Visualization logic kept same as provided)\n    fig, ax = plt.subplots(1, figsize=(15, 15))\n    ax.imshow(panel_img_rgb)\n    ax.axis(\"off\")\n    for idx, (x, y, w, h) in enumerate(boxes):\n        rect = plt.Rectangle((x, y), w, h, edgecolor='white', facecolor='none', linewidth=1, linestyle=':', alpha=0.3)\n        ax.add_patch(rect)\n        ax.text(x+3, y+15, f\"P{idx}\", color='white', fontsize=8, bbox=dict(facecolor='gray', alpha=0.4, pad=1))\n    \n    cmap = plt.get_cmap(\"tab10\")\n    for k, (i, j, transform, n_inliers, ratio, bbox_i, bbox_j) in enumerate(duplicates):\n        color = cmap(k % 10)\n        xi, yi, wi, hi = bbox_i; xj, yj, wj, hj = bbox_j\n        ax.add_patch(plt.Rectangle((xi, yi), wi, hi, edgecolor=color, facecolor='none', linewidth=3))\n        ax.add_patch(plt.Rectangle((xj, yj), wj, hj, edgecolor=color, facecolor='none', linewidth=3))\n        ci = (xi + wi/2, yi + hi/2); cj = (xj + wj/2, yj + hj/2)\n        ax.plot([ci[0], cj[0]], [ci[1], cj[1]], color=color, linewidth=2, linestyle='--', alpha=0.7)\n    \n    plt.title(f\"Detected {len(duplicates)} Copy-Move Forgery Pairs\", fontsize=14, fontweight='bold')\n    plt.tight_layout()\n    plt.show()\n    plt.close(fig)\n\n# -------------------------------------------------------------------------\n# GPU WORKER FUNCTION (PARALLEL SAM3)\n# -------------------------------------------------------------------------\ndef process_subset_on_device(device_id, image_paths, prompts, threshold=0.05):\n    \"\"\"\n    Runs SAM3 on a specific GPU for a subset of images.\n    Implements the specific logic:\n    - Try prompts.\n    - If found -> use them.\n    - If NOT found -> use full image as bbox.\n    - Always set 'is_panel' = True.\n    \"\"\"\n    torch.cuda.set_device(device_id)\n    device = torch.device(f'cuda:{device_id}')\n    \n    print(f\"[GPU {device_id}] Initializing SAM3 model for {len(image_paths)} images...\")\n    \n    try:\n        model = build_sam3_image_model(checkpoint_path='/kaggle/input/sam3-meta/sam3.pt')\n        model.to(device)\n        model.eval()\n        processor = Sam3Processor(model, confidence_threshold=0.375)\n        \n        local_results = []\n        pbar = tqdm(image_paths, desc=f\"GPU {device_id}\", position=device_id, leave=True)\n        \n        for img_path in pbar:\n            try:\n                image = Image.open(img_path).convert('RGB')\n                all_boxes = []\n                \n                with torch.no_grad():\n                    inference_state = processor.set_image(image)\n                    \n                    found_boxes = False\n                    for prompt_text in prompts:\n                        output = processor.set_text_prompt(state=inference_state, prompt=prompt_text)\n                        \n                        masks, boxes, scores = output[\"masks\"], output[\"boxes\"], output[\"scores\"]\n                        \n                        high_confidence_idx = scores > threshold\n                        filtered_boxes = boxes[high_confidence_idx]\n                        \n                        if filtered_boxes.shape[0] > 0:\n                            all_boxes.append(filtered_boxes.detach().cpu().numpy())\n                            found_boxes = True\n                            break \n                \n                if found_boxes and len(all_boxes) > 0:\n                    all_boxes_concat = np.concatenate(all_boxes, axis=0)\n                    # Convert (x1, y1, x2, y2) -> (x, y, w, h)\n                    all_boxes_concat[:, 2] = all_boxes_concat[:, 2] - all_boxes_concat[:, 0]\n                    all_boxes_concat[:, 3] = all_boxes_concat[:, 3] - all_boxes_concat[:, 1]\n                    all_boxes_concat = all_boxes_concat.round().astype('int32')\n                    \n                    # Sanity check: remove non-positive dims\n                    valid = (all_boxes_concat[:, 2] > 0) & (all_boxes_concat[:, 3] > 0)\n                    all_boxes_concat = all_boxes_concat[valid]\n                else:\n                    # Fallback to full image\n                    all_boxes_concat = np.array([[0, 0, image.width, image.height]]).astype('int32')\n\n                local_results.append({\n                    'path': img_path,\n                    'is_panel': True, # Per your logic, always treat as panel/candidate\n                    'bbox_preds': all_boxes_concat\n                })\n                \n                del inference_state\n\n            except Exception as e:\n                print(f\"Error processing {img_path} on GPU {device_id}: {e}\")\n                # Fallback on error\n                local_results.append({\n                    'path': img_path,\n                    'is_panel': True,\n                    'bbox_preds': np.array([[0, 0, 100, 100]]) # Minimal fallback\n                })\n\n        del model, processor\n        torch.cuda.empty_cache()\n        return local_results\n\n    except Exception as e:\n        print(f\"CRITICAL FAILURE on GPU {device_id}: {e}\")\n        return []\n\n# -------------------------------------------------------------------------\n# MAIN ORCHESTRATOR & CPU SIFT LOGIC\n# -------------------------------------------------------------------------\ndef generate_test_ensemble_predictions(\n    test_dir='test_images',\n    model_pattern='best_vitunet_external_data_warmup_fold{}.pth',\n    n_folds=5,\n    cls_threshold=0.76,\n    mask_threshold=0.76,\n    output_csv='submission.csv',\n    device=None,\n    visualize=False\n):\n    if device is None:\n        n_gpus = torch.cuda.device_count()\n        print(f\"Detected {n_gpus} GPUs.\")\n    \n    test_images = sorted(list(Path(test_dir).glob('*.png')))\n    \n    # --- Filter already processed images ---\n    existing_ids = sub.loc[\n    ( (sub['annotationdetr']!='authentic') & (sub['annotationdetr'].notnull()) ) |\n    ( (sub['annotation_grayscale']!='authentic') & (sub['annotation_grayscale'].notnull()) ), 'case_id'].map(str).values\n    test_images = [file for file in test_images if file.stem not in existing_ids]\n    print(f\"Filtered down to {len(test_images)} images (removed existing from submission_sam3.csv)\")\n\n    print(f\"Found {len(test_images)} test images to process\")\n    \n    if len(test_images) == 0:\n        return\n\n    # --- PART 1: PARALLEL GPU SAM3 ---\n    prompts = [\n        'biological cells',\n        'scientific cells',\n        'scientific specks',\n        'scientific blobs',\n        'cell',\n        'scientific smudges',\n        'item of interest',\n    ]\n\n    n_workers = min(torch.cuda.device_count(), 2) if torch.cuda.is_available() else 1\n    chunk_size = math.ceil(len(test_images) / n_workers)\n    image_chunks = [test_images[i:i + chunk_size] for i in range(0, len(test_images), chunk_size)]\n    \n    raw_or_panel_results = []\n    \n    print(f\"Starting SAM3 inference on {n_workers} GPUs...\")\n    with ThreadPoolExecutor(max_workers=n_workers) as executor:\n        futures = []\n        for i in range(n_workers):\n            if i < len(image_chunks):\n                futures.append(\n                    executor.submit(\n                        process_subset_on_device, \n                        device_id=i, \n                        image_paths=image_chunks[i], \n                        prompts=prompts,\n                        threshold=0.05\n                    )\n                )\n        for future in futures:\n            try:\n                raw_or_panel_results.extend(future.result())\n            except Exception as e:\n                print(f\"Thread error: {e}\")\n\n    raw_or_panel = pd.DataFrame(raw_or_panel_results)\n\n    # --- PART 2: CPU SIFT PROCESSING (Sequential) ---\n    \n    # Filter for is_panel (which is technically everything now)\n    if 'is_panel' not in raw_or_panel.columns:\n         raw_or_panel = pd.DataFrame({'path': test_images, 'is_panel': True, 'bbox_preds': [np.array([])]*len(test_images)})\n    \n    test_images_panels = raw_or_panel.loc[raw_or_panel['is_panel'], 'path'].tolist()\n\n    if len(test_images_panels) > 0:\n        \n        # --- PARAMETERS ---\n        SIFT_FEATURES = 4500           \n        SIFT_CONTRAST_THRESH = 0.02    \n        SIFT_EDGE_THRESH = 10          \n        LOWE_RATIO = 0.75              \n        MIN_MATCH_COUNT = 10            \n        RANSAC_REPROJ_THRESH = 3.0     \n        MIN_INLIER_RATIO = 0.8         \n        MAX_SCALE_CHANGE = 3.0         \n        MIN_SCALE_CHANGE = 0.33        \n        IOU_THRESH = 0.5 \n\n        # --- HELPERS FOR SIFT ---\n        def compute_iou(box1, box2):\n            x1, y1, w1, h1 = box1; x2, y2, w2, h2 = box2\n            xi1 = max(x1, x2); yi1 = max(y1, y2)\n            xi2 = min(x1 + w1, x2 + w2); yi2 = min(y1 + h1, y2 + h2)\n            inter_area = max(0, xi2 - xi1) * max(0, yi2 - yi1)\n            # Using min_area as per your provided snippet logic for pure intersection\n            min_area = min(w1*h1, w2*h2)\n            return inter_area / (min_area + 1e-10)\n\n        def extract_keypoints_and_descriptors(crop_rgb):\n            if crop_rgb is None or crop_rgb.size == 0: return [], None\n            if len(crop_rgb.shape) == 2: crop_rgb = cv2.cvtColor(crop_rgb, cv2.COLOR_GRAY2RGB)\n            \n            try:\n                gray = cv2.cvtColor(crop_rgb, cv2.COLOR_RGB2GRAY)\n            except:\n                return [], None\n\n            clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))\n            gray = clahe.apply(gray)\n            kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3,3))\n            morph_grad = cv2.morphologyEx(gray, cv2.MORPH_GRADIENT, kernel)\n            mean_val = np.mean(morph_grad); std_val = np.std(morph_grad)\n            _, binary_mask = cv2.threshold(morph_grad, mean_val + 2.5 * std_val, 255, cv2.THRESH_BINARY)\n            dilated_mask = cv2.dilate(binary_mask, kernel, iterations=2)\n            blurred_gray = cv2.GaussianBlur(gray, (15, 15), 0)\n            gray = np.where(dilated_mask > 0, blurred_gray, gray)\n            sift = cv2.SIFT_create(nfeatures=SIFT_FEATURES, contrastThreshold=SIFT_CONTRAST_THRESH, edgeThreshold=SIFT_EDGE_THRESH)\n            return sift.detectAndCompute(gray, None)\n\n        def match_descriptors(desc1, desc2):\n            if desc1 is None or desc2 is None or len(desc1) < 2 or len(desc2) < 2: return []\n            flann = cv2.FlannBasedMatcher(dict(algorithm=1, trees=5), dict(checks=50))\n            try: matches = flann.knnMatch(desc1, desc2, k=2)\n            except: return []\n            good = []\n            for pair in matches:\n                if len(pair) == 2:\n                    if pair[0].distance < LOWE_RATIO * pair[1].distance: good.append(pair[0])\n            return good\n\n        def get_keypoint_bounding_box(keypoints, matches, query_or_train='query', padding=10):\n            if not matches: return (0, 0, 0, 0)\n            pts = np.array([keypoints[m.queryIdx if query_or_train == 'query' else m.trainIdx].pt for m in matches])\n            x_min, y_min = pts.min(axis=0); x_max, y_max = pts.max(axis=0)\n            return (int(max(0, x_min-padding)), int(max(0, y_min-padding)), int(x_max-x_min+2*padding), int(y_max-y_min+2*padding))\n\n        def analyze_homography(H):\n            if H is None: return \"unknown\"\n            A = H[:2, :2]\n            U, S, Vt = np.linalg.svd(A)\n            sx, sy = S[0], S[1]\n            avg_scale = (sx + sy) / 2\n            if avg_scale > MAX_SCALE_CHANGE or avg_scale < MIN_SCALE_CHANGE: return f\"invalid_scale({avg_scale:.2f})\"\n            rotation = U @ Vt\n            angle_deg = math.degrees(math.atan2(rotation[1, 0], rotation[0, 0]))\n            det = np.linalg.det(A)\n            parts = []\n            if abs(angle_deg) > 5: parts.append(f\"rot{int(angle_deg)}°\")\n            if abs(avg_scale - 1.0) > 0.15: parts.append(f\"scale{avg_scale:.2f}x\")\n            if det < 0: parts.append(\"flip\")\n            if not parts: parts.append(\"identity\")\n            return \"+\".join(parts)\n\n        def validate_geometric_transformation_with_bbox(kp1, kp2, matches, box1_offset, box2_offset):\n            if len(matches) < MIN_MATCH_COUNT: return False, 0, None, \"\", (0,0,0,0), (0,0,0,0)\n            src_pts = np.float32([kp1[m.queryIdx].pt for m in matches]).reshape(-1, 1, 2)\n            dst_pts = np.float32([kp2[m.trainIdx].pt for m in matches]).reshape(-1, 1, 2)\n            try:\n                H, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, RANSAC_REPROJ_THRESH)\n            except: return False, 0, None, \"\", (0,0,0,0), (0,0,0,0)\n            if H is None: return False, 0, None, \"\", (0,0,0,0), (0,0,0,0)\n            inliers = mask.ravel().tolist()\n            num_inliers = sum(inliers)\n            if (num_inliers / len(matches)) < MIN_INLIER_RATIO or num_inliers < MIN_MATCH_COUNT:\n                return False, num_inliers, None, \"\", (0,0,0,0), (0,0,0,0)\n            inlier_matches = [m for m, is_inlier in zip(matches, inliers) if is_inlier]\n            bb1 = get_keypoint_bounding_box(kp1, inlier_matches, 'query', 10)\n            bb2 = get_keypoint_bounding_box(kp2, inlier_matches, 'train', 10)\n            \n            t_desc = analyze_homography(H)\n            \n            return True, num_inliers, H, t_desc, \\\n                   (bb1[0]+box1_offset[0], bb1[1]+box1_offset[1], bb1[2], bb1[3]), \\\n                   (bb2[0]+box2_offset[0], bb2[1]+box2_offset[1], bb2[2], bb2[3])\n\n        def find_duplicates_keypoint_based_v2(panel_img_rgb, boxes, verbose=False):\n            duplicates = []\n            \n            # --- NESTED HELPER FOR INTRA-BOX LOGIC ---\n            def check_intra_box_duplicates(crop, box_idx, global_offset):\n                h, w = crop.shape[:2]\n                if h < 32 or w < 32: return []\n                gx, gy = global_offset\n                found_local = []\n                \n                def match_subregions(img_a, offset_a, img_b, offset_b):\n                    kp_a, desc_a = extract_keypoints_and_descriptors(img_a)\n                    kp_b, desc_b = extract_keypoints_and_descriptors(img_b)\n                    matches_sub = match_descriptors(desc_a, desc_b)\n                    global_off_a = (gx + offset_a[0], gy + offset_a[1])\n                    global_off_b = (gx + offset_b[0], gy + offset_b[1])\n                    return validate_geometric_transformation_with_bbox(kp_a, kp_b, matches_sub, global_off_a, global_off_b)\n\n                # Strategy 1: L vs R\n                mid_x = w // 2\n                is_dup, n_in, _, trans, box_a, box_b = match_subregions(crop[:, :mid_x], (0, 0), crop[:, mid_x:], (mid_x, 0))\n                if is_dup: found_local.append((box_idx, box_idx, f\"Intra-LR: {trans}\", n_in, n_in/(n_in+1e-5), box_a, box_b))\n\n                # Strategy 2: T vs B\n                mid_y = h // 2\n                is_dup, n_in, _, trans, box_a, box_b = match_subregions(crop[:mid_y, :], (0, 0), crop[mid_y:, :], (0, mid_y))\n                if is_dup: found_local.append((box_idx, box_idx, f\"Intra-TB: {trans}\", n_in, n_in/(n_in+1e-5), box_a, box_b))\n                \n                # Strategy 3: Quadrants\n                quads = [(crop[:mid_y, :mid_x], (0,0)), (crop[:mid_y, mid_x:], (mid_x,0)), \n                         (crop[mid_y:, :mid_x], (0,mid_y)), (crop[mid_y:, mid_x:], (mid_x,mid_y))]\n                for i, j in itertools.combinations(range(4), 2):\n                    is_dup, n_in, _, trans, box_a, box_b = match_subregions(quads[i][0], quads[i][1], quads[j][0], quads[j][1])\n                    if is_dup: found_local.append((box_idx, box_idx, f\"Intra-Q{i}Q{j}: {trans}\", n_in, n_in/(n_in+1e-5), box_a, box_b))\n\n                # Strategy 4: H Strips\n                w3 = w // 3\n                if w3 > 10:\n                    h_strips = [(crop[:, :w3], (0,0)), (crop[:, w3:2*w3], (w3,0)), (crop[:, 2*w3:], (2*w3,0))]\n                    for i, j in itertools.combinations(range(3), 2):\n                        is_dup, n_in, _, trans, box_a, box_b = match_subregions(h_strips[i][0], h_strips[i][1], h_strips[j][0], h_strips[j][1])\n                        if is_dup: found_local.append((box_idx, box_idx, f\"Intra-HStrip{i}-{j}: {trans}\", n_in, n_in/(n_in+1e-5), box_a, box_b))\n                \n                # Strategy 5: V Strips\n                h3 = h // 3\n                if h3 > 10:\n                    v_strips = [(crop[:h3, :], (0,0)), (crop[h3:2*h3, :], (0,h3)), (crop[2*h3:, :], (0,2*h3))]\n                    for i, j in itertools.combinations(range(3), 2):\n                        is_dup, n_in, _, trans, box_a, box_b = match_subregions(v_strips[i][0], v_strips[i][1], v_strips[j][0], v_strips[j][1])\n                        if is_dup: found_local.append((box_idx, box_idx, f\"Intra-VStrip{i}-{j}: {trans}\", n_in, n_in/(n_in+1e-5), box_a, box_b))\n\n                # Strategy 6: Grid\n                if not found_local and h > 60 and w > 60:\n                    rows, cols = 2, 3\n                    h_s, w_s = h//rows, w//cols\n                    grid = []\n                    for r in range(rows):\n                        for c in range(cols):\n                            y1, x1 = r*h_s, c*w_s\n                            grid.append((crop[y1:y1+h_s, x1:x1+w_s], (x1,y1)))\n                    for i, j in itertools.combinations(range(len(grid)), 2):\n                        is_dup, n_in, _, trans, box_a, box_b = match_subregions(grid[i][0], grid[i][1], grid[j][0], grid[j][1])\n                        if is_dup: found_local.append((box_idx, box_idx, f\"Intra-Grid{i}-{j}: {trans}\", n_in, n_in/(n_in+1e-5), box_a, box_b))\n                \n                return found_local\n\n            # --- MAIN FIND DUPLICATES LOGIC ---\n            n = len(boxes)\n            if n == 0: return []\n            \n            crops_rgb = []\n            for (x, y, w, h) in boxes:\n                crop = panel_img_rgb[y:y+h, x:x+w].copy()\n                if crop.size == 0: crop = np.zeros((h, w, 3), dtype=np.uint8)\n                crops_rgb.append(crop)\n            \n            keypoints_list, descriptors_list = [], []\n            \n            for i, crop in enumerate(crops_rgb):\n                # We skip individual intra-box check here per your snippet structure, \n                # but you can uncomment if you want per-box checking\n                # box_offset = (boxes[i][0], boxes[i][1])\n                # intra = check_intra_box_duplicates(crop, i, box_offset)\n                # duplicates.extend(intra)\n                \n                kp, desc = extract_keypoints_and_descriptors(crop)\n                keypoints_list.append(kp); descriptors_list.append(desc)\n            \n            if n > 1:\n                for i, j in itertools.combinations(range(n), 2):\n                    if compute_iou(boxes[i], boxes[j]) > IOU_THRESH: continue\n                    matches = match_descriptors(descriptors_list[i], descriptors_list[j])\n                    if len(matches) < MIN_MATCH_COUNT: continue\n                    is_dup, n_in, _, t_desc, bb_i, bb_j = validate_geometric_transformation_with_bbox(\n                        keypoints_list[i], keypoints_list[j], matches, (boxes[i][0], boxes[i][1]), (boxes[j][0], boxes[j][1]))\n                    if is_dup:\n                        duplicates.append((i, j, t_desc, n_in, n_in/len(matches), bb_i, bb_j))\n\n            # Global Intra-Check (Entire Image)\n            box_offset = (0, 0)\n            intra_dups = check_intra_box_duplicates(panel_img_rgb, 999, box_offset)\n            if intra_dups: duplicates.extend(intra_dups)\n            \n            return duplicates\n\n        def remove_overlapping_duplicates_v2(boxes, duplicates, iou_thresh=0.70):\n            if not duplicates: return boxes, duplicates\n            filtered_dups = []\n            for i, j, transform, n_inliers, ratio, bbox_i, bbox_j in duplicates:\n                if compute_iou(bbox_i, bbox_j) < iou_thresh:\n                    filtered_dups.append((i, j, transform, n_inliers, ratio, bbox_i, bbox_j))\n            return boxes, filtered_dups\n\n        def process_panel_images(test_images, raw_or_panel, verbose=False, visualize=False):\n            results = []\n            for img_path in tqdm(test_images, desc=\"Processing panels (SIFT)\"):\n                img = cv2.imread(str(img_path))\n                if img is None: continue\n                img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n                \n                boxes = raw_or_panel.loc[raw_or_panel['path'] == img_path, 'bbox_preds'].iloc[0]\n                \n                duplicates = find_duplicates_keypoint_based_v2(img_rgb, boxes, verbose=verbose)\n                \n                if len(duplicates) > 0:\n                    # Logic to remove overlapping duplicates if needed\n                    # (Simplified here to just call the filter once)\n                    boxes, duplicates = remove_overlapping_duplicates_v2(boxes, duplicates, iou_thresh=0.70)\n\n                if visualize and len(duplicates) > 0:\n                    plot_panel_with_keypoint_duplicates_enhanced_v2(img_rgb, boxes, duplicates)\n                \n                results.append({\"path\": img_path, \"duplicates\": duplicates, \"boxes\": boxes})\n            return results\n\n        # Run the CPU part\n        results = process_panel_images(test_images_panels, raw_or_panel, verbose=visualize, visualize=visualize)\n\n        submission_df_panel = []\n        for r in results:\n            img_name = Path(r['path']).stem\n            if len(r['duplicates']) == 0:\n                rle_string = 'authentic'\n            else:\n                grouped_boxes = []\n                # Collect boxes from duplicates\n                for _, _, _, _, _, bbox_i, bbox_j in r['duplicates']:\n                    grouped_boxes.append([bbox_i, bbox_j])\n                \n                img_shape = cv2.imread(str(r['path'])).shape\n                rle_string = boxes_to_rle_masks(grouped_boxes, img_shape[0], img_shape[1])\n\n            submission_df_panel.append({\n                'case_id': img_name,\n                'annotation': rle_string,\n                'boxes': r['boxes']\n            })\n        submission_df_panel = pd.DataFrame(submission_df_panel)\n    else:\n        submission_df_panel = pd.DataFrame()\n\n    # Raw images (technically none since we force everything to be panels, but kept for structure)\n    test_images_raw = raw_or_panel.loc[~raw_or_panel['is_panel'], 'path'].tolist()\n    submission_df_raw = pd.DataFrame([{'case_id': Path(p).stem, 'annotation': \"authentic\", \"boxes\": None} for p in test_images_raw])\n    \n    submission_df = pd.concat([submission_df_panel, submission_df_raw], axis=0)\n    submission_df.to_csv(output_csv, index=False)\n    \n    print(f\"\\n✅ Saved {len(submission_df)} predictions to {output_csv}\")\n    print(f\"   - Authentic images: {(submission_df['annotation'] == 'authentic').sum()}\")\n    print(f\"   - Forged images: {(submission_df['annotation'] != 'authentic').sum()}\")\n    \n    return submission_df","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:55:41.538158Z","iopub.execute_input":"2026-01-12T05:55:41.538623Z","iopub.status.idle":"2026-01-12T05:55:41.603117Z","shell.execute_reply.started":"2026-01-12T05:55:41.538599Z","shell.execute_reply":"2026-01-12T05:55:41.6025Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# Generate predictions\nsubmission_df = generate_test_ensemble_predictions(\n    #test_dir='/kaggle/input/recodai-luc-scientific-image-forgery-detection/supplemental_images',\n    test_dir='/kaggle/input/recodai-luc-scientific-image-forgery-detection/test_images',\n    model_pattern='/kaggle/input/segformer-b3-copyforge/best_segformerb3_fold{}.pth',\n    n_folds=5,\n    cls_threshold=0.75,\n    mask_threshold=0.70,\n    output_csv='submission.csv',\n    device=None,\n    #visualize=True\n    visualize=False\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T05:55:43.314086Z","iopub.execute_input":"2026-01-12T05:55:43.314816Z","iopub.status.idle":"2026-01-12T06:05:16.626248Z","shell.execute_reply.started":"2026-01-12T05:55:43.314778Z","shell.execute_reply":"2026-01-12T06:05:16.625537Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print('DONE!')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T06:05:16.627537Z","iopub.execute_input":"2026-01-12T06:05:16.6278Z","iopub.status.idle":"2026-01-12T06:05:16.631747Z","shell.execute_reply.started":"2026-01-12T06:05:16.627782Z","shell.execute_reply":"2026-01-12T06:05:16.631125Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sub = sub.merge(submission_df, on='case_id', how='left', suffixes=('','_cell'))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T06:05:16.632397Z","iopub.execute_input":"2026-01-12T06:05:16.632596Z","iopub.status.idle":"2026-01-12T06:05:16.656343Z","shell.execute_reply.started":"2026-01-12T06:05:16.63258Z","shell.execute_reply":"2026-01-12T06:05:16.655818Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"replace_mask = (sub['annotationdetr'] == 'authentic') & (sub['annotation_grayscale'] == 'authentic') & (sub['annotation_cell'] != 'authentic') & (sub['annotation_cell'].notnull())\nsub.loc[replace_mask, 'annotation'] = sub.loc[replace_mask, 'annotation_cell']","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T06:09:09.040218Z","iopub.execute_input":"2026-01-12T06:09:09.04055Z","iopub.status.idle":"2026-01-12T06:09:09.045864Z","shell.execute_reply.started":"2026-01-12T06:09:09.040533Z","shell.execute_reply":"2026-01-12T06:09:09.045079Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Finally combine this last one.","metadata":{}},{"cell_type":"code","source":"sub[['case_id','annotation']].to_csv('./submission.csv',index=False)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-12T06:09:10.840543Z","iopub.execute_input":"2026-01-12T06:09:10.841198Z","iopub.status.idle":"2026-01-12T06:09:10.859944Z","shell.execute_reply.started":"2026-01-12T06:09:10.84117Z","shell.execute_reply":"2026-01-12T06:09:10.859434Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # --- Visualisation (seulement si forged) ---\n# # try:\n# for idx, row in sub.iterrows():\n#     case_id = row['case_id']\n#     #f = f'/kaggle/input/recodai-luc-scientific-image-forgery-detection/supplemental_images/{case_id}.png'\n#     f = f'/kaggle/input/recodai-luc-scientific-image-forgery-detection/train_images/forged/{case_id}.png'\n#     pil = Image.open(Path(f)).convert(\"RGB\")\n#     label = row['annotation']\n#     if label == 'authentic':\n#         mask = np.zeros(pil.size[::-1], np.uint8)\n#     else:\n#         label_rles = [rle_decode(x, shape=(pil.height, pil.width)) for x in label.split(';')]\n#         mask = np.logical_or.reduce(label_rles)\n#     if label == \"authentic\":\n#         plt.figure(figsize=(5,5))\n#         plt.imshow(pil)\n#         plt.title(f\"{f} — Authentic\")\n#         plt.axis(\"off\")\n#         plt.show()\n#     else:\n#         plt.figure(figsize=(20,10))\n#         plt.subplot(1,2,1); plt.imshow(pil); plt.title(\"Original\"); plt.axis(\"off\")\n#         plt.subplot(1,2,2); plt.imshow(pil); plt.imshow(mask, alpha=0.45, cmap=\"Blues\"); \n#         plt.title(\"Predicted Mask\"); plt.axis(\"off\")\n#         plt.show()\n\n# #except:\n# #    pass","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:09:16.099086Z","iopub.execute_input":"2026-01-10T15:09:16.099311Z","iopub.status.idle":"2026-01-10T15:09:16.112224Z","shell.execute_reply.started":"2026-01-10T15:09:16.099294Z","shell.execute_reply":"2026-01-10T15:09:16.111516Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}