{"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":"gpu","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-10T15:03:01.893705Z","iopub.execute_input":"2026-01-10T15:03:01.894056Z","iopub.status.idle":"2026-01-10T15:03:23.501062Z","shell.execute_reply.started":"2026-01-10T15:03:01.894031Z","shell.execute_reply":"2026-01-10T15:03:23.499873Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print('ready')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:03:23.50324Z","iopub.execute_input":"2026-01-10T15:03:23.503836Z","iopub.status.idle":"2026-01-10T15:03:23.508987Z","shell.execute_reply.started":"2026-01-10T15:03:23.503798Z","shell.execute_reply":"2026-01-10T15:03:23.508084Z"}},"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-10T15:03:23.509856Z","iopub.execute_input":"2026-01-10T15:03:23.510142Z","iopub.status.idle":"2026-01-10T15:03:31.973182Z","shell.execute_reply.started":"2026-01-10T15:03:23.510115Z","shell.execute_reply":"2026-01-10T15:03:31.972413Z"}},"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-10T15:03:31.975151Z","iopub.execute_input":"2026-01-10T15:03:31.975386Z","iopub.status.idle":"2026-01-10T15:03:35.186109Z","shell.execute_reply.started":"2026-01-10T15:03:31.975359Z","shell.execute_reply":"2026-01-10T15:03:35.185401Z"}},"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-10T15:03:35.187172Z","iopub.execute_input":"2026-01-10T15:03:35.187468Z","iopub.status.idle":"2026-01-10T15:03:40.5697Z","shell.execute_reply.started":"2026-01-10T15:03:35.187437Z","shell.execute_reply":"2026-01-10T15:03:40.568951Z"}},"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-10T15:03:40.570749Z","iopub.execute_input":"2026-01-10T15:03:40.571036Z","iopub.status.idle":"2026-01-10T15:03:44.152682Z","shell.execute_reply.started":"2026-01-10T15:03:40.571003Z","shell.execute_reply":"2026-01-10T15:03:44.152008Z"}},"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-10T15:05:15.790252Z","iopub.execute_input":"2026-01-10T15:05:15.790967Z","iopub.status.idle":"2026-01-10T15:05:17.98251Z","shell.execute_reply.started":"2026-01-10T15:05:15.790934Z","shell.execute_reply":"2026-01-10T15:05:17.981502Z"}},"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-10T15:05:17.983881Z","iopub.execute_input":"2026-01-10T15:05:17.984209Z","iopub.status.idle":"2026-01-10T15:05:18.318296Z","shell.execute_reply.started":"2026-01-10T15:05:17.98418Z","shell.execute_reply":"2026-01-10T15:05:18.317477Z"}},"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-10T15:05:18.319238Z","iopub.execute_input":"2026-01-10T15:05:18.319497Z","iopub.status.idle":"2026-01-10T15:05:18.323641Z","shell.execute_reply.started":"2026-01-10T15:05:18.31946Z","shell.execute_reply":"2026-01-10T15:05:18.322941Z"}},"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-10T15:05:18.32519Z","iopub.execute_input":"2026-01-10T15:05:18.325364Z","iopub.status.idle":"2026-01-10T15:05:18.339535Z","shell.execute_reply.started":"2026-01-10T15:05:18.32535Z","shell.execute_reply":"2026-01-10T15:05:18.33881Z"}},"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-10T15:05:18.340261Z","iopub.execute_input":"2026-01-10T15:05:18.340479Z","iopub.status.idle":"2026-01-10T15:05:18.354289Z","shell.execute_reply.started":"2026-01-10T15:05:18.340457Z","shell.execute_reply":"2026-01-10T15:05:18.353725Z"}},"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-10T15:05:18.355029Z","iopub.execute_input":"2026-01-10T15:05:18.355309Z","iopub.status.idle":"2026-01-10T15:05:18.369399Z","shell.execute_reply.started":"2026-01-10T15:05:18.355285Z","shell.execute_reply":"2026-01-10T15:05:18.368824Z"}},"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-10T15:05:19.587636Z","iopub.execute_input":"2026-01-10T15:05:19.588152Z","iopub.status.idle":"2026-01-10T15:05:30.49619Z","shell.execute_reply.started":"2026-01-10T15:05:19.588128Z","shell.execute_reply":"2026-01-10T15:05:30.495352Z"}},"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-10T15:05:30.497464Z","iopub.execute_input":"2026-01-10T15:05:30.498026Z","iopub.status.idle":"2026-01-10T15:06:11.234509Z","shell.execute_reply.started":"2026-01-10T15:05:30.497998Z","shell.execute_reply":"2026-01-10T15:06:11.233713Z"}},"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-10T15:06:11.235231Z","iopub.execute_input":"2026-01-10T15:06:11.235627Z","iopub.status.idle":"2026-01-10T15:06:11.240334Z","shell.execute_reply.started":"2026-01-10T15:06:11.23561Z","shell.execute_reply":"2026-01-10T15:06:11.239519Z"}},"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-10T15:06:11.242314Z","iopub.execute_input":"2026-01-10T15:06:11.243098Z","iopub.status.idle":"2026-01-10T15:06:11.263211Z","shell.execute_reply.started":"2026-01-10T15:06:11.243066Z","shell.execute_reply":"2026-01-10T15:06:11.262554Z"}},"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-10T15:06:14.433871Z","iopub.execute_input":"2026-01-10T15:06:14.434593Z","iopub.status.idle":"2026-01-10T15:06:29.299613Z","shell.execute_reply.started":"2026-01-10T15:06:14.434568Z","shell.execute_reply":"2026-01-10T15:06:29.298883Z"}},"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\"\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\")\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-10T15:06:40.386552Z","iopub.execute_input":"2026-01-10T15:06:40.386957Z","iopub.status.idle":"2026-01-10T15:06:40.536072Z","shell.execute_reply.started":"2026-01-10T15:06:40.386934Z","shell.execute_reply":"2026-01-10T15:06:40.535268Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"submission_dino = final.copy()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:06:41.633295Z","iopub.execute_input":"2026-01-10T15:06:41.63394Z","iopub.status.idle":"2026-01-10T15:06:41.637931Z","shell.execute_reply.started":"2026-01-10T15:06:41.633917Z","shell.execute_reply":"2026-01-10T15:06:41.637175Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Now make submission based on SAM3","metadata":{}},{"cell_type":"code","source":"model = build_sam3_image_model(checkpoint_path='/kaggle/input/sam3-meta/sam3.pt')\nprocessor = Sam3Processor(model, confidence_threshold=0.375)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:06:44.255137Z","iopub.execute_input":"2026-01-10T15:06:44.255707Z","iopub.status.idle":"2026-01-10T15:07:11.355509Z","shell.execute_reply.started":"2026-01-10T15:06:44.255685Z","shell.execute_reply":"2026-01-10T15:07:11.354905Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def boxes_to_rle_masks(duplicate_box_groups, image_height, image_width):\n    \"\"\"\n    Convert duplicate bounding box groups into RLE masks.\n\n    Args:\n        duplicate_box_groups: list of groups, where each group is a list of bounding boxes.\n                              Example:\n                              [\n                                [(x1,y1,w1,h1), (x2,y2,w2,h2)],   # duplicate pair 1\n                                [(x3,y3,w3,h3), (x4,y4,w4,h4)],   # duplicate pair 2\n                              ]\n        image_height: full image height\n        image_width: full image width\n\n    Returns:\n        list of numpy masks (H, W)\n        list of RLE strings (using your rle_encode)\n    \"\"\"\n\n    # all_masks = []\n    \n\n    # for group in duplicate_box_groups:\n\n    # Create a blank mask for this duplicate group\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            # Fill bounding box region with 1’s\n            mask[channel, y:y+h, x:x+w] = 1\n    \n            # all_masks.append(mask)\n\n    # Convert to RLE using your provided function\n    #print(mask.shape)\n    #print(rle_encode(masks=mask))\n    #print('-'*30)\n    #print(rle_encode(masks=[mask]))\n    rle_string = rle_encode(masks=mask)\n    if rle_string == \"[]\":\n        rle_string = \"authentic\"\n\n    return rle_string","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:07:36.474164Z","iopub.execute_input":"2026-01-10T15:07:36.474796Z","iopub.status.idle":"2026-01-10T15:07:36.480023Z","shell.execute_reply.started":"2026-01-10T15:07:36.474752Z","shell.execute_reply":"2026-01-10T15:07:36.479282Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def _iou_xywh(a, b):\n    ax, ay, aw, ah = a\n    bx, by, bw, bh = b\n    a_x2 = ax + aw\n    a_y2 = ay + ah\n    b_x2 = bx + bw\n    b_y2 = by + bh\n    inter_x1 = max(ax, bx)\n    inter_y1 = max(ay, by)\n    inter_x2 = min(a_x2, b_x2)\n    inter_y2 = min(a_y2, b_y2)\n    if inter_x2 <= inter_x1 or inter_y2 <= inter_y1:\n        return 0.0, 0.0\n    inter_area = (inter_x2 - inter_x1) * (inter_y2 - inter_y1)\n    area_a = aw * ah\n    area_b = bw * bh\n    iou = inter_area / float(area_a + area_b - inter_area + 1e-9)\n    overlap_smaller = inter_area / float(min(area_a, area_b) + 1e-9)\n    return iou, overlap_smaller\n\n\ndef remove_overlapping_duplicates(boxes, dups, iou_thresh=0.70, overlap_thresh=0.70):\n    \"\"\"\n    boxes: list of boxes as (x, y, w, h) (or numpy ints)\n    dups: list of tuples (idx1, idx2, transform, distance)\n    returns: (new_boxes, new_dups)\n    \"\"\"\n    remove_set = set()\n\n    # Decide which boxes to remove for overlapping duplicates\n    for idx1, idx2, transform, distance in dups:\n        # skip pairs if either already marked for removal (optional optimization)\n        if idx1 in remove_set or idx2 in remove_set:\n            continue\n\n        box1, box2 = boxes[idx1], boxes[idx2]\n        iou, overlap_smaller = _iou_xywh(box1, box2)\n\n        if iou >= iou_thresh or overlap_smaller >= overlap_thresh:\n            # remove the smaller area box (tie -> remove idx2)\n            area1 = int(box1[2]) * int(box1[3])\n            area2 = int(box2[2]) * int(box2[3])\n            if area1 <= area2:\n                remove_set.add(idx1)\n            else:\n                remove_set.add(idx2)\n\n    # If you prefer to always remove idx2 instead of smaller area, replace above decision with:\n    # if iou >= iou_thresh: remove_set.add(idx2)\n\n    # Build mapping from old indices to new indices for kept boxes\n    kept_indices = [i for i in range(len(boxes)) if i not in remove_set]\n    old_to_new = {old_idx: new_idx for new_idx, old_idx in enumerate(kept_indices)}\n\n    # Rebuild boxes\n    new_boxes = [boxes[i] for i in kept_indices]\n\n    # Rebuild dups: keep only pairs where both boxes are kept, remap indices\n    new_dups = []\n    for idx1, idx2, transform, distance in dups:\n        if idx1 in remove_set or idx2 in remove_set:\n            continue\n        new_idx1 = old_to_new[idx1]\n        new_idx2 = old_to_new[idx2]\n        new_dups.append((new_idx1, new_idx2, transform, distance))\n\n    return new_boxes, new_dups","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:07:37.863876Z","iopub.execute_input":"2026-01-10T15:07:37.864152Z","iopub.status.idle":"2026-01-10T15:07:37.873599Z","shell.execute_reply.started":"2026-01-10T15:07:37.864136Z","shell.execute_reply":"2026-01-10T15:07:37.872738Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def trim_uniform_borders_from_boxes(img, boxes, threshold=10):\n    \"\"\"\n    Given the full RGB image and a list of bounding boxes (x, y, w, h),\n    return new bounding boxes with uniform borders trimmed off.\n    \"\"\"\n\n    trimmed_boxes = []\n\n    for (x, y, w, h) in boxes:\n\n        # We'll update these values as we trim\n        left, top, width, height = x, y, w, h\n\n        while True:\n            changed = False\n\n            # Extract current patch\n            patch = img[top:top+height, left:left+width]\n\n            # If patch disappears, stop\n            if patch.size == 0:\n                break\n\n            H, W, C = patch.shape\n\n            # ---- LEFT border ----\n            if W > 1:\n                col = patch[:, 0, :]      # shape (H, C)\n                stat = col.sum(axis=1).std()\n                if stat <= threshold:\n                    left += 1\n                    width -= 1\n                    changed = True\n\n            # ---- RIGHT border ----\n            if W > 1:\n                col = patch[:, -1, :]\n                stat = col.sum(axis=1).std()\n                if stat <= threshold:\n                    width -= 1\n                    changed = True\n\n            # Refresh patch dims\n            patch = img[top:top+height, left:left+width]\n            H, W, C = patch.shape\n\n            # ---- TOP border ----\n            if H > 1:\n                row = patch[0, :, :]      # shape (W, C)\n                stat = row.sum(axis=1).std()\n                if stat <= threshold:\n                    top += 1\n                    height -= 1\n                    changed = True\n\n            # ---- BOTTOM border ----\n            if H > 1:\n                row = patch[-1, :, :]\n                stat = row.sum(axis=1).std()\n                if stat <= threshold:\n                    height -= 1\n                    changed = True\n\n            # Stop if no side trimmed this iteration\n            if not changed:\n                break\n\n        # dont add empty boxes\n        if width == 0 or height == 0:\n            continue\n        \n        trimmed_boxes.append((left, top, width, height))\n\n    return trimmed_boxes\n\n\ndef expand_borders_from_boxes(img, boxes, threshold=50, max_expand=100, mean_threshold=700):\n    \"\"\"\n    Expand bounding boxes outward while the border appears *non-uniform* or nearly white.\n    Expansion rule:\n        If std(sum(axis=1)) >= threshold OR mean(sum(axis=1)) <= mean_threshold, expand one pixel outward.\n    Stops when both conditions fail or image boundary reached.\n    \n    max_expand prevents runaway loops.\n    mean_threshold: borders with mean pixel sum <= this value (darker/uniform borders) also get expanded\n    \"\"\"\n\n    H_img, W_img, _ = img.shape\n    expanded_boxes = []\n\n    for (x, y, w, h) in boxes:\n\n        left, top, width, height = x, y, w, h\n\n        for _ in range(max_expand):\n\n            changed = False\n\n            # CURRENT PATCH\n            patch = img[top:top+height, left:left+width]\n            H, W, C = patch.shape\n\n            # ---------------- LEFT SIDE ----------------\n            if left > 0:\n                col = img[top:top+height, left-1, :]\n                col_sum = col.sum(axis=1)\n                stat = col_sum.std()\n                mean_val = col_sum.mean()\n                if stat >= threshold or mean_val <= mean_threshold:\n                    left -= 1\n                    width += 1\n                    changed = True\n\n            # ---------------- RIGHT SIDE ----------------\n            if left + width < W_img:\n                col = img[top:top+height, left+width, :]\n                col_sum = col.sum(axis=1)\n                stat = col_sum.std()\n                mean_val = col_sum.mean()\n                if stat >= threshold or mean_val <= mean_threshold:\n                    width += 1\n                    changed = True\n\n            # Recompute patch dims after horizontal expansion\n            patch = img[top:top+height, left:left+width]\n            H, W, C = patch.shape\n\n            # ---------------- TOP SIDE ----------------\n            if top > 0:\n                row = img[top-1, left:left+width, :]\n                row_sum = row.sum(axis=1)\n                stat = row_sum.std()\n                mean_val = row_sum.mean()\n                if stat >= threshold or mean_val <= mean_threshold:\n                    top -= 1\n                    height += 1\n                    changed = True\n\n            # ---------------- BOTTOM SIDE ----------------\n            if top + height < H_img:\n                row = img[top+height, left:left+width, :]\n                row_sum = row.sum(axis=1)\n                stat = row_sum.std()\n                mean_val = row_sum.mean()\n                if stat >= threshold or mean_val <= mean_threshold:\n                    height += 1\n                    changed = True\n\n            if not changed:\n                break\n\n        expanded_boxes.append((left, top, width, height))\n\n    return expanded_boxes","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:07:39.256257Z","iopub.execute_input":"2026-01-10T15:07:39.256848Z","iopub.status.idle":"2026-01-10T15:07:39.270394Z","shell.execute_reply.started":"2026-01-10T15:07:39.256819Z","shell.execute_reply":"2026-01-10T15:07:39.269806Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from typing import List, Tuple\ndef aggregate_duplicates(duplicates: List[Tuple[int, int, str, int, float, Tuple, Tuple]]) -> List[List[int]]:\n    \"\"\"\n    Aggregate duplicate pairs into connected groups using Union-Find.\n    Updated to handle the new duplicate format with bounding boxes.\n    \n    Args:\n        duplicates: List of (idx_i, idx_j, transform, n_inliers, ratio, bbox_i, bbox_j)\n    \n    Returns:\n        List of groups, where each group is a list of connected panel indices\n    \"\"\"\n    if not duplicates:\n        return []\n    \n    # Union-Find data structure\n    parent = {}\n    \n    def find(x):\n        if x not in parent:\n            parent[x] = x\n        if parent[x] != x:\n            parent[x] = find(parent[x])  # Path compression\n        return parent[x]\n    \n    def union(x, y):\n        root_x = find(x)\n        root_y = find(y)\n        if root_x != root_y:\n            parent[root_x] = root_y\n    \n    # Union all pairs (only use idx_i and idx_j, ignore the rest)\n    for idx_i, idx_j, _, _, _, _, _ in duplicates:\n        union(idx_i, idx_j)\n    \n    # Group by root\n    from collections import defaultdict\n    groups = defaultdict(list)\n    for idx in parent.keys():\n        root = find(idx)\n        groups[root].append(idx)\n    \n    return list(groups.values())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:07:42.065072Z","iopub.execute_input":"2026-01-10T15:07:42.065357Z","iopub.status.idle":"2026-01-10T15:07:42.071748Z","shell.execute_reply.started":"2026-01-10T15:07:42.065339Z","shell.execute_reply":"2026-01-10T15:07:42.070963Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n\n\nclass ParticipantVisibleError(Exception):\n    pass\n\n\n@numba.jit(nopython=True)\ndef _rle_encode_jit(x: npt.NDArray, fg_val: int = 1) -> list[int]:\n    \"\"\"Numba-jitted RLE encoder.\"\"\"\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\n\ndef rle_encode(masks: list[npt.NDArray], fg_val: int = 1) -> str:\n    \"\"\"\n    Adapted from contrails RLE https://www.kaggle.com/code/inversion/contrails-rle-submission\n    Args:\n        masks: list of numpy array of shape (height, width), 1 - mask, 0 - background\n    Returns: run length encodings as a string, with each RLE JSON-encoded and separated by a semicolon.\n    \"\"\"\n    return ';'.join([json.dumps(_rle_encode_jit(x, fg_val)) for x in masks])\n\n\n@numba.njit\ndef _rle_decode_jit(mask_rle: npt.NDArray, height: int, width: int) -> npt.NDArray:\n    \"\"\"\n    s: numpy array of run-length encoding pairs (start, length)\n    shape: (height, width) of array to return\n    Returns numpy array, 1 - mask, 0 - background\n    \"\"\"\n    if len(mask_rle) % 2 != 0:\n        # Numba requires raising a standard exception.\n        raise ValueError('One or more rows has an odd number of values.')\n\n    starts, lengths = mask_rle[0::2], mask_rle[1::2]\n    starts -= 1\n    ends = starts + lengths\n    for i in range(len(starts) - 1):\n        if ends[i] > starts[i + 1]:\n            raise ValueError('Pixels must not be overlapping.')\n    img = np.zeros(height * width, dtype=np.bool_)\n    for lo, hi in zip(starts, ends):\n        img[lo:hi] = 1\n    return img\n\n\ndef rle_decode(mask_rle: str, shape: tuple[int, int]) -> npt.NDArray:\n    \"\"\"\n    mask_rle: run-length as string formatted (start length)\n              empty predictions need to be encoded with '-'\n    shape: (height, width) of array to return\n    Returns numpy array, 1 - mask, 0 - background\n    \"\"\"\n\n    mask_rle = json.loads(mask_rle)\n    mask_rle = np.asarray(mask_rle, dtype=np.int32)\n    starts = mask_rle[0::2]\n    if sorted(starts) != list(starts):\n        raise ParticipantVisibleError('Submitted values must be in ascending order.')\n    try:\n        return _rle_decode_jit(mask_rle, shape[0], shape[1]).reshape(shape, order='F')\n    except ValueError as e:\n        raise ParticipantVisibleError(str(e)) from e\n\n\ndef calculate_f1_score(pred_mask: npt.NDArray, gt_mask: npt.NDArray):\n    pred_flat = pred_mask.flatten()\n    gt_flat = gt_mask.flatten()\n\n    tp = np.sum((pred_flat == 1) & (gt_flat == 1))\n    fp = np.sum((pred_flat == 1) & (gt_flat == 0))\n    fn = np.sum((pred_flat == 0) & (gt_flat == 1))\n\n    precision = tp / (tp + fp) if (tp + fp) > 0 else 0\n    recall = tp / (tp + fn) if (tp + fn) > 0 else 0\n\n    if (precision + recall) > 0:\n        return 2 * (precision * recall) / (precision + recall)\n    else:\n        return 0\n\n\ndef calculate_f1_matrix(pred_masks: list[npt.NDArray], gt_masks: list[npt.NDArray]):\n    \"\"\"\n    Parameters:\n    pred_masks (np.ndarray):\n            First dimension is the number of predicted instances.\n            Each instance is a binary mask of shape (height, width).\n    gt_masks (np.ndarray):\n            First dimension is the number of ground truth instances.\n            Each instance is a binary mask of shape (height, width).\n    \"\"\"\n\n    num_instances_pred = len(pred_masks)\n    num_instances_gt = len(gt_masks)\n    f1_matrix = np.zeros((num_instances_pred, num_instances_gt))\n\n    # Calculate F1 scores for each pair of predicted and ground truth masks\n    for i in range(num_instances_pred):\n        for j in range(num_instances_gt):\n            pred_flat = pred_masks[i].flatten()\n            gt_flat = gt_masks[j].flatten()\n            f1_matrix[i, j] = calculate_f1_score(pred_mask=pred_flat, gt_mask=gt_flat)\n\n    if f1_matrix.shape[0] < len(gt_masks):\n        # Add a row of zeros to the matrix if the number of predicted instances is less than ground truth instances\n        f1_matrix = np.vstack((f1_matrix, np.zeros((len(gt_masks) - len(f1_matrix), num_instances_gt))))\n\n    return f1_matrix\n\n\ndef oF1_score(pred_masks: list[npt.NDArray], gt_masks: list[npt.NDArray]):\n    \"\"\"\n    Calculate the optimal F1 score for a set of predicted masks against\n    ground truth masks which considers the optimal F1 score matching.\n    This function uses the Hungarian algorithm to find the optimal assignment\n    of predicted masks to ground truth masks based on the F1 score matrix.\n    If the number of predicted masks is less than the number of ground truth masks,\n    it will add a row of zeros to the F1 score matrix to ensure that the dimensions match.\n\n    Parameters:\n    pred_masks (list of np.ndarray): List of predicted binary masks.\n    gt_masks (np.ndarray): Array of ground truth binary masks.\n    Returns:\n    float: Optimal F1 score.\n    \"\"\"\n    f1_matrix = calculate_f1_matrix(pred_masks, gt_masks)\n\n    # Find the best matching between predicted and ground truth masks\n    row_ind, col_ind = scipy.optimize.linear_sum_assignment(-f1_matrix)\n    # The linear_sum_assignment discards excess predictions so we need a separate penalty.\n    excess_predictions_penalty = len(gt_masks) / max(len(pred_masks), len(gt_masks))\n    return np.mean(f1_matrix[row_ind, col_ind]) * excess_predictions_penalty\n\n\ndef evaluate_single_image(label_rles: str, prediction_rles: str, shape_str: str) -> float:\n    shape = json.loads(shape_str)\n    label_rles = [rle_decode(x, shape=shape) for x in label_rles.split(';')]\n    prediction_rles = [rle_decode(x, shape=shape) for x in prediction_rles.split(';')]\n    return oF1_score(prediction_rles, label_rles)\n\n\ndef score(solution: pd.DataFrame, submission: pd.DataFrame, row_id_column_name: str) -> float:\n    \"\"\"\n    Args:\n        solution (pd.DataFrame): The ground truth DataFrame.\n        submission (pd.DataFrame): The submission DataFrame.\n        row_id_column_name (str): The name of the column containing row IDs.\n    Returns:\n        float\n\n    Examples\n    --------\n    >>> solution = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['authentic', 'authentic', 'authentic'], 'shape': ['authentic', 'authentic', 'authentic']})\n    >>> submission = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['authentic', 'authentic', 'authentic']})\n    >>> score(solution.copy(), submission.copy(), row_id_column_name='row_id')\n    1.0\n\n    >>> solution = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['authentic', 'authentic', 'authentic'], 'shape': ['authentic', 'authentic', 'authentic']})\n    >>> submission = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 102]', '[101, 102]', '[101, 102]']})\n    >>> score(solution.copy(), submission.copy(), row_id_column_name='row_id')\n    0.0\n\n    >>> solution = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 102]', '[101, 102]', '[101, 102]'], 'shape': ['[720, 960]', '[720, 960]', '[720, 960]']})\n    >>> submission = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 102]', '[101, 102]', '[101, 102]']})\n    >>> score(solution.copy(), submission.copy(), row_id_column_name='row_id')\n    1.0\n\n    >>> solution = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 103]', '[101, 102]', '[101, 102]'], 'shape': ['[720, 960]', '[720, 960]', '[720, 960]']})\n    >>> submission = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 102]', '[101, 102]', '[101, 102]']})\n    >>> score(solution.copy(), submission.copy(), row_id_column_name='row_id')\n    0.9983739837398374\n\n    >>> solution = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 102];[300, 100]', '[101, 102]', '[101, 102]'], 'shape': ['[720, 960]', '[720, 960]', '[720, 960]']})\n    >>> submission = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 102]', '[101, 102]', '[101, 102]']})\n    >>> score(solution.copy(), submission.copy(), row_id_column_name='row_id')\n    0.8333333333333334\n    \"\"\"\n    df = solution\n    df = df.rename(columns={'annotation': 'label'})\n\n    df['prediction'] = submission['annotation']\n    # Check for correct 'authentic' label\n    authentic_indices = (df['label'] == 'authentic') | (df['prediction'] == 'authentic')\n    df['image_score'] = ((df['label'] == df['prediction']) & authentic_indices).astype(float)\n\n    df.loc[~authentic_indices, 'image_score'] = df.loc[~authentic_indices].apply(\n        lambda row: evaluate_single_image(row['label'], row['prediction'], row['shape']), axis=1\n    )\n    return float(np.mean(df['image_score']))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:07:43.833089Z","iopub.execute_input":"2026-01-10T15:07:43.833741Z","iopub.status.idle":"2026-01-10T15:07:43.914003Z","shell.execute_reply.started":"2026-01-10T15:07:43.83372Z","shell.execute_reply":"2026-01-10T15:07:43.9132Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# def compute_iou(box1, box2):\n#     \"\"\"Compute IoU between two boxes in (x, y, w, h) format\"\"\"\n#     x1_min, y1_min = box1[0], box1[1]\n#     x1_max, y1_max = box1[0] + box1[2], box1[1] + box1[3]\n#     x2_min, y2_min = box2[0], box2[1]\n#     x2_max, y2_max = box2[0] + box2[2], box2[1] + box2[3]\n    \n#     inter_x_min = max(x1_min, x2_min)\n#     inter_y_min = max(y1_min, y2_min)\n#     inter_x_max = min(x1_max, x2_max)\n#     inter_y_max = min(y1_max, y2_max)\n    \n#     if inter_x_max <= inter_x_min or inter_y_max <= inter_y_min:\n#         return 0.0\n    \n#     inter_area = (inter_x_max - inter_x_min) * (inter_y_max - inter_y_min)\n#     box1_area = box1[2] * box1[3]\n#     box2_area = box2[2] * box2[3]\n#     union_area = box1_area + box2_area - inter_area\n    \n#     return inter_area / union_area if union_area > 0 else 0.0\n\n# def nms_boxes(boxes, iou_threshold=0.1, return_indices=False):\n#     \"\"\"Remove boxes with IoU > threshold\"\"\"\n#     if len(boxes) == 0:\n#         return boxes\n    \n#     # Sort by area (keep larger boxes)\n#     areas = boxes[:, 2] * boxes[:, 3]\n#     sorted_indices = np.argsort(-areas)\n    \n#     keep = []\n#     while len(sorted_indices) > 0:\n#         current_idx = sorted_indices[0]\n#         keep.append(current_idx)\n        \n#         if len(sorted_indices) == 1:\n#             break\n        \n#         # Compute IoU with remaining boxes\n#         ious = np.array([compute_iou(boxes[current_idx], boxes[idx]) \n#                         for idx in sorted_indices[1:]])\n        \n#         # Keep boxes with IoU <= threshold\n#         sorted_indices = sorted_indices[1:][ious <= iou_threshold]\n\n#     kept_boxes = boxes[keep]\n#     if return_indices:\n#         return kept_boxes, keep\n#     return kept_boxes","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:07:47.888723Z","iopub.execute_input":"2026-01-10T15:07:47.889495Z","iopub.status.idle":"2026-01-10T15:07:47.893297Z","shell.execute_reply.started":"2026-01-10T15:07:47.889468Z","shell.execute_reply":"2026-01-10T15:07:47.892638Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n\ndef binary_mask_to_rle(binary_mask):\n    \"\"\"Convert a single binary instance mask to RLE string.\"\"\"\n    pixels = binary_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    return ' '.join(str(x) for x in runs)\n\n\ndef mask_to_rle(mask):\n    \"\"\"Convert binary mask to RLE string.\"\"\"\n    if mask.sum() == 0:\n        return \"\"\n    \n    mask_uint8 = (mask * 255).astype(np.uint8)\n    contours, _ = cv2.findContours(mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    \n    instance_rles = []\n    for cnt in contours:\n        if cv2.contourArea(cnt) < 50:\n            continue\n        instance_mask = np.zeros_like(mask, dtype=np.uint8)\n        cv2.drawContours(instance_mask, [cnt], -1, 1, thickness=-1)\n        rle = binary_mask_to_rle(instance_mask)\n        instance_rles.append(rle)\n    \n    return \" \".join(instance_rles)\n\n\ndef plot_sam3_masks(image, masks, boxes, scores, title=\"SAM3 Detected Masks\"):\n    \"\"\"\n    Plot all masks detected by SAM3.\n    \n    Args:\n        image: PIL Image or numpy array (RGB)\n        masks: numpy array of binary masks [N, H, W] or [N, 1, H, W]\n        boxes: numpy array of boxes [N, 4] in (x1, y1, x2, y2) format\n        scores: numpy array of confidence scores [N]\n        title: plot title\n    \"\"\"\n    if isinstance(image, Image.Image):\n        image = np.array(image)\n    \n    # Handle masks with extra dimension [N, 1, H, W] -> [N, H, W]\n    if masks.ndim == 4 and masks.shape[1] == 1:\n        masks = masks.squeeze(1)\n    \n    fig, ax = plt.subplots(1, figsize=(12, 12))#, num=1, clear=True)\n    ax.imshow(image)\n    ax.axis(\"off\")\n    \n    # Color map for different masks\n    cmap = plt.get_cmap(\"tab20\")\n    \n    # Plot each mask\n    for idx, (mask, box, score) in enumerate(zip(masks, boxes, scores)):\n        color = cmap(idx % 20)\n        \n        # Ensure mask is 2D\n        if mask.ndim == 3:\n            mask = mask.squeeze()\n        \n        # Create colored overlay for mask\n        colored_mask = np.zeros((*mask.shape, 4))\n        colored_mask[mask > 0] = [*color[:3], 0.4]  # Semi-transparent\n        ax.imshow(colored_mask)\n        \n        # Draw bounding box\n        x1, y1, x2, y2 = box\n        rect = plt.Rectangle((x1, y1), x2-x1, y2-y1, \n                            edgecolor=color, facecolor='none', linewidth=2)\n        ax.add_patch(rect)\n        \n        # Add label with score\n        ax.text(x1, y1-5, f\"#{idx} ({score:.2f})\", \n                color='white', fontsize=10,\n                bbox=dict(facecolor=color, alpha=0.8, pad=2))\n    \n    plt.title(f\"{title}\\nDetected {len(masks)} masks\", \n              fontsize=14, fontweight='bold')\n    plt.tight_layout()\n    #fig.savefig(f'{title}.png')\n    plt.show()\n    plt.close(fig)\n    gc.collect()\n\n\ndef get_duplicate_polygon_hull(boxes, duplicate_indices):\n    \"\"\"\n    Get the convex hull polygon for a set of duplicate boxes.\n    \n    Args:\n        boxes: List of (x, y, w, h) tuples\n        duplicate_indices: List of box indices that are duplicates\n    \n    Returns:\n        numpy array of polygon points [N, 2]\n    \"\"\"\n    # Collect all corner points from the duplicate boxes\n    points = []\n    for idx in duplicate_indices:\n        x, y, w, h = boxes[idx]\n        # Add all 4 corners\n        points.extend([\n            [x, y],\n            [x + w, y],\n            [x + w, y + h],\n            [x, y + h]\n        ])\n    \n    points = np.array(points)\n    \n    # Compute convex hull\n    hull = cv2.convexHull(points.astype(np.float32))\n    hull_points = hull.squeeze()\n    \n    return hull_points\n\n\ndef boxes_to_rle_masks_v2(duplicate_info: List[Tuple], image_height: int, image_width: int) -> str:\n    \"\"\"\n    Convert duplicate bounding boxes to RLE masks.\n    \n    Args:\n        duplicate_info: List of tuples (idx_i, idx_j, transform, inliers, ratio, bbox_i, bbox_j)\n        image_height: Height of the image\n        image_width: Width of the image\n    \n    Returns:\n        RLE string for submission\n    \"\"\"\n    if not duplicate_info:\n        return \"authentic\"\n    \n    # Create a binary mask for the full image\n    full_mask = np.zeros((image_height, image_width), dtype=np.uint8)\n    \n    # Draw all duplicate regions\n    for _, _, _, _, _, bbox_i, bbox_j in duplicate_info:\n        # Draw first region\n        x1, y1, w1, h1 = bbox_i\n        x1, y1 = max(0, x1), max(0, y1)\n        x2, y2 = min(image_width, x1 + w1), min(image_height, y1 + h1)\n        full_mask[y1:y2, x1:x2] = 1\n        \n        # Draw second region\n        x1, y1, w1, h1 = bbox_j\n        x1, y1 = max(0, x1), max(0, y1)\n        x2, y2 = min(image_width, x1 + w1), min(image_height, y1 + h1)\n        full_mask[y1:y2, x1:x2] = 1\n    \n    # Convert to RLE\n    rle_string = mask_to_rle(full_mask)\n    \n    return rle_string if rle_string else \"authentic\"\n\n\ndef plot_panel_with_keypoint_duplicates_v2(\n    panel_img_rgb: np.ndarray,\n    boxes: List[Tuple[int, int, int, int]],\n    duplicates: List[Tuple[int, int, str, int, float, Tuple, Tuple]]\n):\n    \"\"\"\n    Plot panel with tight bounding boxes around matched keypoint regions.\n    \n    Args:\n        panel_img_rgb: RGB image\n        boxes: Original detection boxes (x, y, w, h) - shown in white\n        duplicates: List of (idx_i, idx_j, transform, n_inliers, ratio, bbox_i, bbox_j)\n    \"\"\"\n    fig, ax = plt.subplots(1, figsize=(15, 15))\n    ax.imshow(panel_img_rgb)\n    ax.axis(\"off\")\n    \n    # Draw all original detection boxes in white (for reference)\n    for idx, (x, y, w, h) in enumerate(boxes):\n        rect = plt.Rectangle((x, y), w, h, edgecolor='white', \n                            facecolor='none', linewidth=1, linestyle='--', alpha=0.5)\n        ax.add_patch(rect)\n        ax.text(x+3, y+15, f\"Panel {idx}\", color='white', fontsize=9,\n                bbox=dict(facecolor='gray', alpha=0.5, pad=2))\n    \n    # Highlight duplicate pairs with tight bounding boxes\n    cmap = plt.get_cmap(\"tab10\")\n    \n    for k, (i, j, transform, n_inliers, ratio, bbox_i, bbox_j) in enumerate(duplicates):\n        color = cmap(k % 10)\n        \n        xi, yi, wi, hi = bbox_i\n        xj, yj, wj, hj = bbox_j\n        \n        # Draw tight bounding boxes for duplicated regions (thick colored borders)\n        ax.add_patch(plt.Rectangle((xi, yi), wi, hi, edgecolor=color,\n                                   facecolor='none', linewidth=3))\n        ax.add_patch(plt.Rectangle((xj, yj), wj, hj, edgecolor=color,\n                                   facecolor='none', linewidth=3))\n        \n        # Connect centers of tight boxes\n        ci = (xi + wi/2, yi + hi/2)\n        cj = (xj + wj/2, yj + hj/2)\n        ax.plot([ci[0], cj[0]], [ci[1], cj[1]], color=color,\n                linewidth=2, linestyle='--', alpha=0.7)\n        \n        # Label with detailed information\n        label = f\"Pair {k}\\n{transform}\\n{n_inliers} inliers\\n{ratio:.1%}\"\n        mid_x, mid_y = (ci[0] + cj[0]) / 2, (ci[1] + cj[1]) / 2\n        ax.text(mid_x, mid_y, label, color='white', fontsize=9,\n                bbox=dict(facecolor=color, alpha=0.9, edgecolor='white', pad=3),\n                ha='center', va='center', fontweight='bold')\n        \n        # Add size information to tight boxes\n        ax.text(xi, yi-5, f\"{wi}×{hi}px\", color=color, fontsize=8,\n                bbox=dict(facecolor='black', alpha=0.7, pad=2))\n        ax.text(xj, yj-5, f\"{wj}×{hj}px\", color=color, fontsize=8,\n                bbox=dict(facecolor='black', alpha=0.7, pad=2))\n    \n    title = f\"Detected {len(duplicates)} Copy-Move Forgery Pairs\\n\"\n    title += \"White dashed boxes: Original panels | Colored solid boxes: Matched keypoint regions\"\n    plt.title(title, fontsize=14, fontweight='bold')\n    plt.tight_layout()\n    plt.show()\n    plt.close(fig)\n\n\ndef plot_panel_with_keypoint_duplicates_enhanced_v2(\n    panel_img_rgb: np.ndarray,\n    boxes: List[Tuple[int, int, int, int]],\n    duplicates: List[Tuple[int, int, str, int, float, Tuple, Tuple]]\n):\n    \"\"\"\n    Enhanced visualization with polygon hulls around grouped duplicates.\n    Uses tight bounding boxes from keypoint matches.\n    \n    Args:\n        panel_img_rgb: RGB image\n        boxes: Original detection boxes (x, y, w, h)\n        duplicates: List of (idx_i, idx_j, transform, n_inliers, ratio, bbox_i, bbox_j)\n    \"\"\"\n    fig, ax = plt.subplots(1, figsize=(15, 15))\n    ax.imshow(panel_img_rgb)\n    ax.axis(\"off\")\n    \n    # Draw original detection boxes (faint, for reference)\n    for idx, (x, y, w, h) in enumerate(boxes):\n        rect = plt.Rectangle((x, y), w, h, edgecolor='white', \n                            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,\n                bbox=dict(facecolor='gray', alpha=0.4, pad=1))\n    \n    # Group duplicates by connected components (using panel indices)\n    from collections import defaultdict\n    duplicate_groups = []\n    visited = set()\n    \n    # Build adjacency for grouping (based on panel indices i, j)\n    adj = defaultdict(set)\n    for i, j, _, _, _, _, _ in duplicates:\n        adj[i].add(j)\n        adj[j].add(i)\n    \n    # Find connected components\n    for node in adj:\n        if node not in visited:\n            group = set()\n            stack = [node]\n            while stack:\n                curr = stack.pop()\n                if curr not in visited:\n                    visited.add(curr)\n                    group.add(curr)\n                    stack.extend(adj[curr] - visited)\n            if group:\n                duplicate_groups.append(list(group))\n    \n    # Highlight duplicate pairs with tight bounding boxes\n    cmap = plt.get_cmap(\"tab10\")\n    \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\n        xj, yj, wj, hj = bbox_j\n        \n        # Thick colored borders for tight duplicate regions\n        ax.add_patch(plt.Rectangle((xi, yi), wi, hi, edgecolor=color,\n                                   facecolor='none', linewidth=3))\n        ax.add_patch(plt.Rectangle((xj, yj), wj, hj, edgecolor=color,\n                                   facecolor='none', linewidth=3))\n        \n        # Connect centers\n        ci = (xi + wi/2, yi + hi/2)\n        cj = (xj + wj/2, yj + hj/2)\n        ax.plot([ci[0], cj[0]], [ci[1], cj[1]], color=color,\n                linewidth=2, linestyle='--', alpha=0.7)\n        \n        # Label with score\n        label = f\"Pair {k}\\n{transform}\\n{n_inliers} matches\\n{ratio:.1%}\"\n        mid_x, mid_y = (ci[0] + cj[0]) / 2, (ci[1] + cj[1]) / 2\n        ax.text(mid_x, mid_y, label, color='white', fontsize=9,\n                bbox=dict(facecolor='black', alpha=0.8, edgecolor=color, pad=3),\n                ha='center', va='center')\n    \n    # Draw polygon hulls for duplicate groups using tight bounding boxes\n    for group_idx, group in enumerate(duplicate_groups):\n        if len(group) < 2:\n            continue\n        \n        # Collect all tight bounding boxes for this group\n        tight_boxes = []\n        for i, j, _, _, _, bbox_i, bbox_j in duplicates:\n            if i in group:\n                tight_boxes.append(bbox_i)\n            if j in group:\n                tight_boxes.append(bbox_j)\n        \n        # Remove duplicates\n        tight_boxes = list(set(tight_boxes))\n        \n        if len(tight_boxes) < 2:\n            continue\n        \n        # Get convex hull of all tight boxes in this group\n        points = []\n        for x, y, w, h in tight_boxes:\n            points.extend([\n                [x, y],\n                [x + w, y],\n                [x + w, y + h],\n                [x, y + h]\n            ])\n        \n        points = np.array(points)\n        hull = cv2.convexHull(points.astype(np.float32))\n        hull_points = hull.squeeze()\n        \n        # Draw polygon\n        color = cmap(group_idx % 10)\n        polygon = Polygon(hull_points, edgecolor=color, facecolor=color,\n                         alpha=0.15, linewidth=3, linestyle='-')\n        ax.add_patch(polygon)\n        \n        # Calculate average score for this group\n        group_scores = []\n        for i, j, transform, n_inliers, ratio, _, _ in duplicates:\n            if i in group or j in group:\n                group_scores.append(ratio)\n        avg_score = np.mean(group_scores) if group_scores else 0\n        \n        # Add group label\n        centroid = hull_points.mean(axis=0)\n        ax.text(centroid[0], centroid[1], \n                f\"Group {group_idx}\\nPanels: {sorted(group)}\\nAvg: {avg_score:.1%}\",\n                color='white', fontsize=11, fontweight='bold',\n                bbox=dict(facecolor=color, alpha=0.9, edgecolor='white', \n                         linewidth=2, pad=5),\n                ha='center', va='center')\n    \n    title = f\"Detected {len(duplicates)} Copy-Move Forgery Pairs in {len(duplicate_groups)} Groups\\n\"\n    title += \"Dotted white: Original panels | Solid colored: Keypoint match regions | Shaded: Group hulls\"\n    plt.title(title, fontsize=14, fontweight='bold')\n    plt.tight_layout()\n    plt.show()\n    plt.close(fig)\n\n\n# Update the existing functions to use enhanced visualization\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  # Add visualization flag\n):\n    \"\"\"\n    Generate ensemble predictions with optional visualization.\n    \"\"\"\n    if device is None:\n        device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n    \n    # Get test image paths\n    test_images = sorted(list(Path(test_dir).glob('*.png')))\n    #test_images += sorted(list(Path('/kaggle/input/recodai-luc-scientific-image-forgery-detection/train_images/forged').glob('*.png')))[500:550]\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    # Panel detection with SAM3\n    raw_or_panel = []\n    \n    test_df = pd.DataFrame({'img': test_images})\n    test_df['mask'] = None\n    test_df['group'] = -1\n\n    prompts = [\n        'outlined scientific images',\n        'microscopic images',\n        'outlined images',\n        'bordered rectangles',\n        'greyscale rectangular',\n    ]\n\n    threshold = 0.05\n    \n    for idx, row in tqdm(test_df.iterrows(), desc='SAM3 panel segmentation', total=len(test_df)):\n        image = Image.open(row['img']).convert('RGB')\n\n        all_masks = []\n        all_boxes = []\n        all_scores = []\n\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            # Filter masks by score threshold\n            high_confidence_idx = scores > threshold\n            filtered_masks = masks[high_confidence_idx]\n            filtered_boxes = boxes[high_confidence_idx]\n            filtered_scores = scores[high_confidence_idx]\n\n            # If you found them, then dont go through the other prompts.\n            if filtered_boxes.shape[0] > 0:\n                all_masks.append(filtered_masks.detach().cpu().numpy())\n                all_boxes.append(filtered_boxes.detach().cpu().numpy())\n                all_scores.append(filtered_scores.detach().cpu().numpy())\n\n                # Clear GPU tensors\n                #del masks, boxes, scores, filtered_masks, filtered_boxes, filtered_scores\n                #torch.cuda.empty_cache()\n                break\n            #else:\n            #    pass\n                # Clear even if no boxes found\n                #del masks, boxes, scores, filtered_masks, filtered_boxes, filtered_scores\n\n        if len(all_boxes) > 0:\n            all_masks_concat = np.concatenate(all_masks, axis=0)\n            all_boxes_concat = np.concatenate(all_boxes, axis=0)\n            all_scores_concat = np.concatenate(all_scores, axis=0)\n\n            # Clear the lists after concatenation\n            #del all_masks, all_boxes, all_scores\n            \n            \n\n            # Apply NMS and get indices of kept boxes\n            # curr_img_preds, kept_indices = nms_boxes(curr_img_preds, iou_threshold=0.1, return_indices=True)\n            \n            # Filter masks, boxes, and scores to only kept ones after NMS\n            # if kept_indices is not None:\n            #    all_masks_concat = all_masks_concat[kept_indices]\n            #    all_boxes_concat = all_boxes_concat[kept_indices]\n            #    all_scores_concat = all_scores_concat[kept_indices]\n            \n            # Visualize de-duped SAM3 masks if requested\n            if visualize:\n                plot_sam3_masks(image, all_masks_concat, all_boxes_concat, \n                              all_scores_concat, \n                              title=f\"SAM3 Masks (After NMS) for {Path(row['img']).name}\")\n\n            # Convert from (x1, y1, x2, y2) format to (x, y, w, h) format\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    \n            # Convert to integer pixels\n            all_boxes_concat = all_boxes_concat.round().astype('int32')\n            # Don't keep large masks in memory\n            #del all_masks_concat, all_scores_concat\n        else:\n            all_boxes_concat = np.array([])\n            #del all_masks, all_boxes, all_scores\n\n        #del inference_state, processor\n\n        raw_or_panel.append({\n            'path': row['img'],\n            'is_panel': len(all_boxes_concat) >= 2,\n            'bbox_preds': all_boxes_concat\n        })\n\n        \n\n    raw_or_panel = pd.DataFrame(raw_or_panel)\n        \n    \n\n    ########################################################################\n    ########################################################################\n    # Handle panel images\n    ########################################################################\n    ########################################################################\n    test_images = raw_or_panel.loc[raw_or_panel['is_panel'], 'path'].tolist()\n\n    if len(test_images) > 0:\n        \n        \n        # ---------------------- PARAMETERS ----------------------\n        # Keypoint detection\n        SIFT_FEATURES = 2500           # Max features per crop\n        SIFT_CONTRAST_THRESH = 0.02    # Lower = more keypoints (default 0.04)\n        SIFT_EDGE_THRESH = 10          # Higher = more keypoints (default 10)\n        \n        # Matching\n        LOWE_RATIO = 0.75              # Lowe's ratio test threshold (0.7-0.8 typical)\n        MIN_MATCH_COUNT = 25            # Minimum good matches to consider duplicate\n        RANSAC_REPROJ_THRESH = 3.0     # RANSAC inlier threshold in pixels\n        \n        # Geometric validation\n        MIN_INLIER_RATIO = 0.8         # Min ratio of inliers to total matches\n        MAX_SCALE_CHANGE = 3.0         # Max allowed scale factor change\n        MIN_SCALE_CHANGE = 0.33        # Min allowed scale factor change\n        \n        # Pre-filtering (cheap checks before expensive keypoint matching)\n        AREA_RATIO_TOLERANCE = 0.15    # Allow 15% area difference\n        MEAN_COLOR_DIFF_THRESH = 40.0  # L2 distance of RGB means\n        HIST_DIFF_THRESH = 0.30        # Normalized histogram distance\n        \n        # Overlap filtering\n        IOU_THRESH = 0.5 # So we skip any boxes that overlap more than 10%.\n        \n        # ---------------------- UTILITY FUNCTIONS ----------------------\n        \n        def compute_cheap_descriptors(crop_rgb: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:\n            \"\"\"Compute cheap color statistics for pre-filtering.\"\"\"\n            # Mean RGB\n            mean_vec = np.array([crop_rgb[..., i].mean() for i in range(3)], dtype=np.float32)\n            \n            # Color histogram (16 bins per channel, normalized)\n            hist_list = []\n            for i in range(3):\n                hist = cv2.calcHist([crop_rgb], [i], None, [16], [0, 256])\n                hist = hist.flatten().astype(np.float32)\n                hist /= (hist.sum() + 1e-10)\n                hist_list.append(hist)\n            hist_vec = np.concatenate(hist_list)\n            \n            return mean_vec, hist_vec\n        \n        \n        def compute_iou(box1: Tuple[int, int, int, int], \n                        box2: Tuple[int, int, int, int]) -> float:\n            \"\"\"Compute Intersection over Union of two boxes (x, y, w, h).\"\"\"\n            x1, y1, w1, h1 = box1\n            x2, y2, w2, h2 = box2\n            \n            # Intersection\n            xi1 = max(x1, x2)\n            yi1 = max(y1, y2)\n            xi2 = min(x1 + w1, x2 + w2)\n            yi2 = min(y1 + h1, y2 + h2)\n            \n            inter_area = max(0, xi2 - xi1) * max(0, yi2 - yi1)\n            \n            # Union\n            box1_area = w1 * h1\n            box2_area = w2 * h2\n            union_area = box1_area + box2_area - inter_area\n            \n            return inter_area / (union_area + 1e-10)\n        \n        \n        def extract_keypoints_and_descriptors(crop_rgb: np.ndarray) -> Tuple[List, np.ndarray]:\n            \"\"\"Extract SIFT keypoints and descriptors from a crop.\"\"\"\n            # Convert to grayscale\n            gray = cv2.cvtColor(crop_rgb, cv2.COLOR_RGB2GRAY)\n\n            # 1. Apply CLAHE First (Fixes Brightness issues & pops text)\n            clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))\n            gray = clahe.apply(gray)\n\n            # --- Step 2: Text/Arrow Suppression ---\n            # Scientific text is usually high contrast and high frequency.\n            # We use morphological gradient to find edges, then threshold.\n            kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3,3))\n            morph_grad = cv2.morphologyEx(gray, cv2.MORPH_GRADIENT, kernel)\n            \n            # Threshold to find strong edges (text/arrows)\n            # Dynamic thresholding based on image statistics works better than fixed\n            mean_val = np.mean(morph_grad)\n            std_val = np.std(morph_grad)\n            thresh_val = mean_val + 2.5 * std_val \n            _, binary_mask = cv2.threshold(morph_grad, thresh_val, 255, cv2.THRESH_BINARY)\n            \n            # Dilate slightly to cover the edges of the text\n            dilated_mask = cv2.dilate(binary_mask, kernel, iterations=2)\n            \n            # Inpaint or Blur the masked regions. \n            # Blurring is faster and effectively kills SIFT keypoints in those areas.\n            # We blur the WHOLE image, then combine based on mask.\n            blurred_gray = cv2.GaussianBlur(gray, (15, 15), 0)\n            \n            # Where mask is white (text), use blurred image. Otherwise use original CLAHE image.\n            gray = np.where(dilated_mask > 0, blurred_gray, gray)\n            \n            # Create SIFT detector\n            sift = cv2.SIFT_create(\n                nfeatures=SIFT_FEATURES,\n                contrastThreshold=SIFT_CONTRAST_THRESH,\n                edgeThreshold=SIFT_EDGE_THRESH\n            )\n            \n            # Detect and compute\n            keypoints, descriptors = sift.detectAndCompute(gray, None)\n            \n            return keypoints, descriptors\n        \n        \n        def match_descriptors(desc1: np.ndarray, desc2: np.ndarray) -> List[cv2.DMatch]:\n            \"\"\"Match descriptors using FLANN and apply Lowe's ratio test.\"\"\"\n            if desc1 is None or desc2 is None or len(desc1) < 2 or len(desc2) < 2:\n                return []\n            \n            # FLANN matcher\n            FLANN_INDEX_KDTREE = 1\n            index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)\n            search_params = dict(checks=50)\n            flann = cv2.FlannBasedMatcher(index_params, search_params)\n            \n            # Find 2 nearest neighbors\n            matches = flann.knnMatch(desc1, desc2, k=2)\n            \n            # Lowe's ratio test\n            good_matches = []\n            for match_pair in matches:\n                if len(match_pair) == 2:\n                    m, n = match_pair\n                    if m.distance < LOWE_RATIO * n.distance:\n                        good_matches.append(m)\n            \n            return good_matches\n\n\n        def get_keypoint_bounding_box(keypoints: List, matches: List[cv2.DMatch], \n                                      query_or_train: str = 'query',\n                                      padding: int = 10) -> Tuple[int, int, int, int]:\n            \"\"\"\n            Get bounding box around matched keypoints.\n            \n            Args:\n                keypoints: List of cv2.KeyPoint objects\n                matches: List of cv2.DMatch objects\n                query_or_train: 'query' for queryIdx, 'train' for trainIdx\n                padding: Additional padding around the keypoints in pixels\n            \n            Returns:\n                Tuple of (x, y, w, h) for the bounding box\n            \"\"\"\n            if not matches or not keypoints:\n                return (0, 0, 0, 0)\n            \n            # Extract matched keypoint coordinates\n            if query_or_train == 'query':\n                pts = [keypoints[m.queryIdx].pt for m in matches]\n            else:  # 'train'\n                pts = [keypoints[m.trainIdx].pt for m in matches]\n            \n            if not pts:\n                return (0, 0, 0, 0)\n            \n            # Convert to numpy array for easier computation\n            pts = np.array(pts)\n            \n            # Get bounding box coordinates\n            x_min = int(np.floor(pts[:, 0].min()))\n            y_min = int(np.floor(pts[:, 1].min()))\n            x_max = int(np.ceil(pts[:, 0].max()))\n            y_max = int(np.ceil(pts[:, 1].max()))\n            \n            # Add padding\n            x_min = max(0, x_min - padding)\n            y_min = max(0, y_min - padding)\n            x_max = x_max + padding\n            y_max = y_max + padding\n            \n            # Convert to (x, y, w, h) format\n            w = x_max - x_min\n            h = y_max - y_min\n            \n            return (x_min, y_min, w, h)\n        \n        \n        def validate_geometric_transformation_with_bbox(kp1: List, kp2: List, \n                                                        matches: List[cv2.DMatch],\n                                                        box1_offset: Tuple[int, int],\n                                                        box2_offset: Tuple[int, int]) -> Tuple[bool, int, np.ndarray, str, Tuple, Tuple]:\n            \"\"\"\n            Validate matches using RANSAC and return bounding boxes for matched regions.\n            \n            Args:\n                kp1, kp2: Keypoints from both crops\n                matches: List of matches\n                box1_offset: (x, y) offset of crop1 in the full image\n                box2_offset: (x, y) offset of crop2 in the full image\n            \n            Returns:\n                (is_duplicate, num_inliers, H, transform_desc, bbox1_global, bbox2_global)\n                where bboxes are in global image coordinates (x, y, w, h)\n            \"\"\"\n            if len(matches) < MIN_MATCH_COUNT:\n                return False, 0, None, \"\", (0, 0, 0, 0), (0, 0, 0, 0)\n            \n            # Extract matched keypoint locations\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            # Find homography using RANSAC\n            H, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, RANSAC_REPROJ_THRESH)\n            \n            if H is None:\n                return False, 0, None, \"\", (0, 0, 0, 0), (0, 0, 0, 0)\n            \n            # Count inliers and filter matches to only inliers\n            inliers = mask.ravel().tolist()\n            num_inliers = sum(inliers)\n            inlier_ratio = num_inliers / len(matches)\n            \n            if inlier_ratio < 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            # Filter to only inlier matches\n            inlier_matches = [m for m, is_inlier in zip(matches, inliers) if is_inlier]\n            \n            # Get bounding boxes around matched keypoints (in crop coordinates)\n            bbox1_local = get_keypoint_bounding_box(kp1, inlier_matches, 'query', padding=10)\n            bbox2_local = get_keypoint_bounding_box(kp2, inlier_matches, 'train', padding=10)\n            \n            # Convert to global image coordinates\n            x1_off, y1_off = box1_offset\n            x2_off, y2_off = box2_offset\n            \n            x1, y1, w1, h1 = bbox1_local\n            bbox1_global = (x1 + x1_off, y1 + y1_off, w1, h1)\n            \n            x2, y2, w2, h2 = bbox2_local\n            bbox2_global = (x2 + x2_off, y2 + y2_off, w2, h2)\n            \n            # Analyze transformation\n            transform_desc = analyze_homography(H)\n            \n            return True, num_inliers, H, transform_desc, bbox1_global, bbox2_global\n        \n        \n        def analyze_homography(H: np.ndarray) -> str:\n            \"\"\"\n            Analyze a homography matrix to describe the transformation.\n            Returns a string describing rotation, scale, flip, etc.\n            \"\"\"\n            if H is None:\n                return \"unknown\"\n            \n            # Decompose into rotation, scale, translation\n            # Extract upper-left 2x2 for rotation/scale analysis\n            A = H[:2, :2]\n            \n            # SVD decomposition\n            U, S, Vt = np.linalg.svd(A)\n            \n            # Scale factors\n            sx, sy = S[0], S[1]\n            avg_scale = (sx + sy) / 2\n            \n            # Check for scale validity\n            if avg_scale > MAX_SCALE_CHANGE or avg_scale < MIN_SCALE_CHANGE:\n                return f\"invalid_scale({avg_scale:.2f})\"\n            \n            # Rotation angle (approximate)\n            rotation = U @ Vt\n            angle_rad = math.atan2(rotation[1, 0], rotation[0, 0])\n            angle_deg = math.degrees(angle_rad)\n            \n            # Detect flip\n            det = np.linalg.det(A)\n            has_flip = det < 0\n            \n            # Build description\n            parts = []\n            if abs(angle_deg) > 5:\n                parts.append(f\"rot{int(angle_deg)}°\")\n            if abs(avg_scale - 1.0) > 0.15:\n                parts.append(f\"scale{avg_scale:.2f}x\")\n            if has_flip:\n                parts.append(\"flip\")\n            \n            if not parts:\n                parts.append(\"identity\")\n            \n            return \"+\".join(parts)\n        \n        \n        def find_duplicates_keypoint_based_v2(\n            panel_img_rgb: np.ndarray,\n            boxes: List[Tuple[int, int, int, int]],\n            verbose: bool = False\n        ) -> List[Tuple[int, int, str, int, float, Tuple, Tuple]]:\n            \"\"\"\n            Find duplicate crops using keypoint matching with tight bounding boxes.\n            \n            Returns:\n                List of tuples: (index_i, index_j, transform_description, num_inliers, \n                                inlier_ratio, bbox_i_global, bbox_j_global)\n                where bboxes are (x, y, w, h) in global image coordinates\n            \"\"\"\n            n = len(boxes)\n            if n <= 1:\n                return []\n            \n            # Extract crops\n            crops_rgb = []\n            areas = []\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:\n                    crop = np.zeros((h, w, 3), dtype=np.uint8)\n                crops_rgb.append(crop)\n                areas.append(w * h)\n            \n            # Extract keypoints and descriptors for all crops\n            if verbose:\n                print(\"Extracting keypoints...\")\n            \n            keypoints_list = []\n            descriptors_list = []\n            for i, crop in enumerate(crops_rgb):\n                kp, desc = extract_keypoints_and_descriptors(crop)\n                keypoints_list.append(kp)\n                descriptors_list.append(desc)\n                if verbose:\n                    print(f\"  Crop {i}: {len(kp)} keypoints\")\n            \n            # Compare all pairs\n            duplicates = []\n            \n            if verbose:\n                print(f\"Comparing {n * (n-1) // 2} pairs...\")\n            \n            for i, j in itertools.combinations(range(n), 2):\n                # Skip if boxes overlap significantly\n                iou = compute_iou(boxes[i], boxes[j])\n                if iou > IOU_THRESH:\n                    if verbose:\n                        print(f\"  Pair ({i},{j}): skipped (IoU={iou:.2f})\")\n                    continue\n            \n                \n                # Match descriptors\n                matches = match_descriptors(descriptors_list[i], descriptors_list[j])\n                \n                if verbose:\n                    print(f\"  Pair ({i},{j}): {len(matches)} matches\")\n                \n                if len(matches) < MIN_MATCH_COUNT:\n                    continue\n                \n                # Get box offsets for global coordinate conversion\n                box_i_offset = (boxes[i][0], boxes[i][1])\n                box_j_offset = (boxes[j][0], boxes[j][1])\n                \n                # Geometric validation with bounding boxes\n                is_dup, num_inliers, H, transform_desc, bbox_i, bbox_j = \\\n                    validate_geometric_transformation_with_bbox(\n                        keypoints_list[i], keypoints_list[j], matches,\n                        box_i_offset, box_j_offset\n                    )\n\n                area_i = bbox_i[2]*bbox_i[3]\n                area_j = bbox_j[2]*bbox_j[3]\n                area_ratio = max(area_i/(area_j+1), area_j/(area_i+1))\n                if area_ratio >= 1.5:\n                    is_dup = False\n                \n                if is_dup:\n                    inlier_ratio = num_inliers / len(matches)\n                    duplicates.append((i, j, transform_desc, num_inliers, inlier_ratio, bbox_i, bbox_j))\n                    if verbose:\n                        print(f\"  ✓ DUPLICATE FOUND: ({i},{j}) - {transform_desc}, \"\n                              f\"{num_inliers} inliers ({inlier_ratio:.2%})\")\n                        print(f\"    BBox1: {bbox_i}, BBox2: {bbox_j}\")\n            \n            return duplicates\n        \n        \n        # ---------------------- VISUALIZATION ----------------------\n        \n        def visualize_matches(img1_rgb: np.ndarray, img2_rgb: np.ndarray,\n                             kp1: List, kp2: List, matches: List[cv2.DMatch],\n                             title: str = \"Keypoint Matches\"):\n            \"\"\"Visualize matched keypoints between two images.\"\"\"\n            import matplotlib.pyplot as plt\n            \n            # Convert keypoints to correct format for drawMatches\n            img_matches = cv2.drawMatches(\n                img1_rgb, kp1, img2_rgb, kp2, matches[:50],  # Show top 50\n                None, flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS\n            )\n            \n            plt.figure(figsize=(15, 8))\n            plt.imshow(img_matches)\n            plt.title(f\"{title} (showing {min(50, len(matches))} matches)\")\n            plt.axis('off')\n            plt.tight_layout()\n            plt.show()\n        \n        \n        def plot_panel_with_keypoint_duplicates(\n            panel_img_rgb: np.ndarray,\n            boxes: List[Tuple[int, int, int, int]],\n            duplicates: List[Tuple[int, int, str, int, float]]\n        ):\n            \"\"\"Plot panel with detected duplicate boxes highlighted.\"\"\"\n            import matplotlib.pyplot as plt\n            \n            fig, ax = plt.subplots(1, figsize=(12, 12))\n            ax.imshow(panel_img_rgb)\n            ax.axis(\"off\")\n            \n            # Draw all boxes\n            for idx, (x, y, w, h) in enumerate(boxes):\n                rect = plt.Rectangle((x, y), w, h, edgecolor='white', \n                                    facecolor='none', linewidth=1)\n                ax.add_patch(rect)\n                ax.text(x+3, y+15, f\"{idx}\", color='white', fontsize=11,\n                        bbox=dict(facecolor='black', alpha=0.7, pad=2))\n            \n            # Highlight duplicate pairs\n            cmap = plt.get_cmap(\"tab10\")\n            for k, (i, j, transform, n_inliers, ratio) in enumerate(duplicates):\n                color = cmap(k % 10)\n                xi, yi, wi, hi = boxes[i]\n                xj, yj, wj, hj = boxes[j]\n                \n                # Thick colored borders for duplicates\n                ax.add_patch(plt.Rectangle((xi, yi), wi, hi, edgecolor=color,\n                                           facecolor='none', linewidth=3))\n                ax.add_patch(plt.Rectangle((xj, yj), wj, hj, edgecolor=color,\n                                           facecolor='none', linewidth=3))\n                \n                # Connect centers\n                ci = (xi + wi/2, yi + hi/2)\n                cj = (xj + wj/2, yj + hj/2)\n                ax.plot([ci[0], cj[0]], [ci[1], cj[1]], color=color,\n                        linewidth=2, linestyle='--', alpha=0.7)\n                \n                # Label\n                label = f\"Pair {k}\\n{transform}\\n{n_inliers} matches\\n{ratio:.1%}\"\n                mid_x, mid_y = (ci[0] + cj[0]) / 2, (ci[1] + cj[1]) / 2\n                ax.text(mid_x, mid_y, label, color=color, fontsize=9,\n                        bbox=dict(facecolor='black', alpha=0.8, edgecolor=color, pad=3),\n                        ha='center', va='center')\n            \n            plt.title(f\"Detected {len(duplicates)} Copy-Move Forgery Candidates\", \n                     fontsize=14, fontweight='bold')\n            plt.tight_layout()\n            plt.show()\n            plt.close(fig)\n\n        def process_panel_images(test_images: List[str], \n                                 raw_or_panel,  # Your dataframe\n                                 verbose: bool = False,\n                                 visualize: bool = False) -> List[dict]:\n            \"\"\"\n            Process panel images to detect copy-move forgeries.\n            \n            Args:\n                test_images: List of image paths\n                raw_or_panel: DataFrame with 'path' and 'bbox_preds' columns\n                verbose: Print detailed progress\n                visualize: Show visualization plots\n            \n            Returns:\n                List of dicts with keys: 'path', 'duplicates', 'boxes'\n            \"\"\"\n            results = []\n            \n            for img_path in tqdm(test_images, desc=\"Processing panels\"):\n                if verbose:\n                    print(f\"\\n{'='*60}\")\n                    print(f\"Processing: {img_path}\")\n                    print('='*60)\n                \n                # Load image\n                img = cv2.imread(str(img_path))\n                if img is None:\n                    print(f\"Warning: Could not load {img_path}\")\n                    results.append({\"path\": img_path, \"duplicates\": [], \"boxes\": []})\n                    continue\n                    \n                img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n                \n                # Get bounding boxes from your detection model\n                boxes = raw_or_panel.loc[raw_or_panel['path'] == img_path, 'bbox_preds'].iloc[0]\n                \n                # Your existing preprocessing\n                # boxes = trim_uniform_borders_from_boxes(img_rgb, boxes)\n                # boxes = expand_borders_from_boxes(img_rgb, boxes)\n                \n                # Skip if only one box\n                #if len(boxes) <= 1:\n                #    if verbose:\n                #        print(f\"  Only {len(boxes)} box(es) - skipping\")\n                #    results.append({\"path\": img_path, \"duplicates\": [], \"boxes\": boxes})\n                #    continue\n                \n                if verbose:\n                    print(f\"  Found {len(boxes)} sub-images\")\n                \n                # Run keypoint-based duplicate detection\n                duplicates = find_duplicates_keypoint_based_v2(\n                    img_rgb, \n                    boxes, \n                    verbose=verbose\n                )\n                \n                if verbose:\n                    print(f\"  Detected {len(duplicates)} duplicate pairs\")\n                    for i, j, transform, n_inliers, ratio in duplicates:\n                        print(f\"    - Boxes {i} & {j}: {transform} \"\n                              f\"({n_inliers} matches, {ratio:.1%} inlier ratio)\")\n                \n                # Remove overlapping duplicates if needed\n                # (you might want to adapt this function for the new duplicate format)\n                if len(duplicates) > 0:\n                    prev_len = None\n                    while prev_len != len(boxes):\n                        prev_len = len(boxes)\n                        boxes, duplicates = remove_overlapping_duplicates_v2(\n                            boxes, duplicates, iou_thresh=0.70\n                        )\n                \n                # Visualization\n                if visualize and len(duplicates) > 0:\n                    plot_panel_with_keypoint_duplicates_enhanced_v2(img_rgb, boxes, duplicates)\n                \n                results.append({\n                    \"path\": img_path,\n                    \"duplicates\": duplicates,\n                    \"boxes\": boxes\n                })\n            \n            return results\n        \n        \n        def remove_overlapping_duplicates_v2(\n            boxes: List[Tuple[int, int, int, int]],\n            duplicates: List[Tuple[int, int, str, int, float, Tuple, Tuple]],\n            iou_thresh: float = 0.70\n        ) -> Tuple[List, List]:\n            \"\"\"\n            Remove duplicate pairs where the tight bounding boxes overlap significantly.\n            Updated to handle the new duplicate format with bounding boxes.\n            \n            Args:\n                boxes: Original detection boxes (kept for compatibility, not modified)\n                duplicates: List of (idx_i, idx_j, transform, n_inliers, ratio, bbox_i, bbox_j)\n                iou_thresh: IoU threshold for filtering overlapping tight boxes\n            \n            Returns:\n                Tuple of (boxes, filtered_duplicates)\n            \"\"\"\n            if not duplicates:\n                return boxes, duplicates\n            \n            # Filter duplicates based on overlap of the tight bounding boxes\n            filtered_dups = []\n            for i, j, transform, n_inliers, ratio, bbox_i, bbox_j in duplicates:\n                # Check IoU between the tight bounding boxes (not original panel boxes)\n                iou = compute_iou(bbox_i, bbox_j)\n                if iou < iou_thresh:\n                    filtered_dups.append((i, j, transform, n_inliers, ratio, bbox_i, bbox_j))\n            \n            return boxes, filtered_dups\n        \n        \n        def generate_summary_report(results: List[dict]) -> dict:\n            \"\"\"Generate a summary report of CMF detection results.\"\"\"\n            total_images = len(results)\n            images_with_cmf = sum(1 for r in results if len(r['duplicates']) > 0)\n            total_cmf_pairs = sum(len(r['duplicates']) for r in results)\n            \n            # Analyze transformations\n            transform_counts = {}\n            inlier_counts = []\n            \n            for result in results:\n                for i, j, transform, n_inliers, ratio in result['duplicates']:\n                    # Count transformation types\n                    if transform not in transform_counts:\n                        transform_counts[transform] = 0\n                    transform_counts[transform] += 1\n                    inlier_counts.append(n_inliers)\n            \n            report = {\n                'total_images': total_images,\n                'images_with_cmf': images_with_cmf,\n                'cmf_rate': images_with_cmf / total_images if total_images > 0 else 0,\n                'total_cmf_pairs': total_cmf_pairs,\n                'avg_cmf_per_image': total_cmf_pairs / total_images if total_images > 0 else 0,\n                'transform_distribution': transform_counts,\n                'avg_inliers': np.mean(inlier_counts) if inlier_counts else 0,\n                'median_inliers': np.median(inlier_counts) if inlier_counts else 0\n            }\n            \n            return report\n        \n        \n        def print_summary_report(report: dict):\n            \"\"\"Print a formatted summary report.\"\"\"\n            print(\"\\n\" + \"=\"*70)\n            print(\"COPY-MOVE FORGERY DETECTION SUMMARY\")\n            print(\"=\"*70)\n            print(f\"Total images processed:        {report['total_images']}\")\n            print(f\"Images with CMF detected:      {report['images_with_cmf']} \"\n                  f\"({report['cmf_rate']:.1%})\")\n            print(f\"Total CMF pairs found:         {report['total_cmf_pairs']}\")\n            print(f\"Average CMF pairs per image:   {report['avg_cmf_per_image']:.2f}\")\n            print(f\"Average inlier matches:        {report['avg_inliers']:.1f}\")\n            print(f\"Median inlier matches:         {report['median_inliers']:.1f}\")\n            \n            if report['transform_distribution']:\n                print(\"\\nTransformation Distribution:\")\n                sorted_transforms = sorted(report['transform_distribution'].items(),\n                                          key=lambda x: x[1], reverse=True)\n                for transform, count in sorted_transforms:\n                    print(f\"  {transform:30s} : {count:3d} instances\")\n            \n            print(\"=\"*70 + \"\\n\")\n\n         # Process all images\n        results = process_panel_images(\n            test_images,\n            raw_or_panel,\n            verbose=False,   # Set to False for less output\n            visualize=visualize  # Set to False to skip plots\n        )\n\n\n        submission_df_panel = []\n        for r in results:\n            img_name = r['path'].stem\n            if len(r['duplicates']) == 0:\n                rle_string = 'authentic'\n            else:\n                #print(r['duplicates'])\n                #print(len(r['duplicates']))\n                #print(aggregate_duplicates(r['duplicates']))\n                duplicate_groups = aggregate_duplicates(r['duplicates'])\n                #grouped_boxes = [[r['boxes'][idx] for idx in group] for group in duplicate_groups]\n                grouped_boxes = []\n                for group in duplicate_groups:\n                    curr_group = []\n                    for dup in r['duplicates']:\n                        if dup[0] in set(group):\n                            curr_group.append(dup[5]) # box_i\n                        if dup[1] in set(group):\n                            curr_group.append(dup[6]) # box_j\n                    curr_group = list(set(curr_group))\n                    grouped_boxes.append(curr_group)\n\n                img = cv2.imread(r['path'])\n                rle_string = boxes_to_rle_masks(grouped_boxes, image_height=img.shape[0], image_width=img.shape[1])\n                # print(rle_string)\n\n            submission_df_panel.append({\n                'case_id': img_name,\n                'annotation': rle_string,\n                'boxes': r['boxes']\n            })\n\n        submission_df_panel = pd.DataFrame(submission_df_panel)\n            \n    else:\n        submission_df_panel = pd.DataFrame()\n\n    ########################################################################\n    ########################################################################\n    # Handle raw images\n    ########################################################################\n    ########################################################################\n    test_images = raw_or_panel.loc[~raw_or_panel['is_panel'], 'path'].tolist()\n\n    if len(test_images) > 0:\n        all_predictions = []\n        for img_path in test_images:\n            img_name = Path(img_path).stem\n            all_predictions.append({\n                'case_id': img_name,\n                'annotation': \"authentic\",\n                \"boxes\": None,\n            })\n        \n        # Create submission DataFrame\n        submission_df = pd.DataFrame(all_predictions)\n\n    else:\n        submission_df = pd.DataFrame()\n    \n    \n    # Finally, concatenate the two dataframes.\n    submission_df = pd.concat([\n        submission_df_panel,\n        submission_df\n    ], axis=0)\n    \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-10T15:07:49.717258Z","iopub.execute_input":"2026-01-10T15:07:49.717924Z","iopub.status.idle":"2026-01-10T15:07:49.810903Z","shell.execute_reply.started":"2026-01-10T15:07:49.717895Z","shell.execute_reply":"2026-01-10T15:07:49.810202Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print('ready')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:07:54.031905Z","iopub.execute_input":"2026-01-10T15:07:54.032203Z","iopub.status.idle":"2026-01-10T15:07:54.03647Z","shell.execute_reply.started":"2026-01-10T15:07:54.03218Z","shell.execute_reply":"2026-01-10T15:07:54.035636Z"}},"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-10T15:08:04.834471Z","iopub.execute_input":"2026-01-10T15:08:04.834751Z","iopub.status.idle":"2026-01-10T15:08:07.588879Z","shell.execute_reply.started":"2026-01-10T15:08:04.834731Z","shell.execute_reply":"2026-01-10T15:08:07.588193Z"}},"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-10T15:08:07.589947Z","iopub.execute_input":"2026-01-10T15:08:07.59016Z","iopub.status.idle":"2026-01-10T15:08:07.593699Z","shell.execute_reply.started":"2026-01-10T15:08:07.590144Z","shell.execute_reply":"2026-01-10T15:08:07.593102Z"}},"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-10T15:08:09.159838Z","iopub.execute_input":"2026-01-10T15:08:09.160125Z","iopub.status.idle":"2026-01-10T15:08:09.175009Z","shell.execute_reply.started":"2026-01-10T15:08:09.160105Z","shell.execute_reply":"2026-01-10T15:08:09.174394Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"submission_df","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:08:09.943407Z","iopub.execute_input":"2026-01-10T15:08:09.943689Z","iopub.status.idle":"2026-01-10T15:08:09.952462Z","shell.execute_reply.started":"2026-01-10T15:08:09.943668Z","shell.execute_reply":"2026-01-10T15:08:09.951808Z"}},"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-10T15:08:13.664013Z","iopub.execute_input":"2026-01-10T15:08:13.664292Z","iopub.status.idle":"2026-01-10T15:08:13.670584Z","shell.execute_reply.started":"2026-01-10T15:08:13.664272Z","shell.execute_reply":"2026-01-10T15:08:13.669772Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sub['annotation'] = sub['annotation'].fillna('authentic')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:08:17.023489Z","iopub.execute_input":"2026-01-10T15:08:17.024102Z","iopub.status.idle":"2026-01-10T15:08:17.028657Z","shell.execute_reply.started":"2026-01-10T15:08:17.024076Z","shell.execute_reply":"2026-01-10T15:08:17.027715Z"}},"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-10T15:08:32.32057Z","iopub.execute_input":"2026-01-10T15:08:32.321338Z","iopub.status.idle":"2026-01-10T15:08:32.326015Z","shell.execute_reply.started":"2026-01-10T15:08:32.321315Z","shell.execute_reply":"2026-01-10T15:08:32.32526Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Now let's run something: zero-out any pixels where the DETR did not detect any panels.","metadata":{}},{"cell_type":"code","source":"import 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\nclass ParticipantVisibleError(Exception):\n    pass\n\n\n@numba.jit(nopython=True)\ndef _rle_encode_jit(x: npt.NDArray, fg_val: int = 1) -> list[int]:\n    \"\"\"Numba-jitted RLE encoder.\"\"\"\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\n\ndef rle_encode(masks: list[npt.NDArray], fg_val: int = 1) -> str:\n    \"\"\"\n    Adapted from contrails RLE https://www.kaggle.com/code/inversion/contrails-rle-submission\n    Args:\n        masks: list of numpy array of shape (height, width), 1 - mask, 0 - background\n    Returns: run length encodings as a string, with each RLE JSON-encoded and separated by a semicolon.\n    \"\"\"\n    return ';'.join([json.dumps(_rle_encode_jit(x, fg_val)) for x in masks])\n\n\n@numba.njit\ndef _rle_decode_jit(mask_rle: npt.NDArray, height: int, width: int) -> npt.NDArray:\n    \"\"\"\n    s: numpy array of run-length encoding pairs (start, length)\n    shape: (height, width) of array to return\n    Returns numpy array, 1 - mask, 0 - background\n    \"\"\"\n    if len(mask_rle) % 2 != 0:\n        # Numba requires raising a standard exception.\n        raise ValueError('One or more rows has an odd number of values.')\n\n    starts, lengths = mask_rle[0::2], mask_rle[1::2]\n    starts -= 1\n    ends = starts + lengths\n    for i in range(len(starts) - 1):\n        if ends[i] > starts[i + 1]:\n            raise ValueError('Pixels must not be overlapping.')\n    img = np.zeros(height * width, dtype=np.bool_)\n    for lo, hi in zip(starts, ends):\n        img[lo:hi] = 1\n    return img\n\n\ndef rle_decode(mask_rle: str, shape: tuple[int, int]) -> npt.NDArray:\n    \"\"\"\n    mask_rle: run-length as string formatted (start length)\n              empty predictions need to be encoded with '-'\n    shape: (height, width) of array to return\n    Returns numpy array, 1 - mask, 0 - background\n    \"\"\"\n\n    mask_rle = json.loads(mask_rle)\n    mask_rle = np.asarray(mask_rle, dtype=np.int32)\n    starts = mask_rle[0::2]\n    if sorted(starts) != list(starts):\n        raise ParticipantVisibleError('Submitted values must be in ascending order.')\n    try:\n        return _rle_decode_jit(mask_rle, shape[0], shape[1]).reshape(shape, order='F')\n    except ValueError as e:\n        raise ParticipantVisibleError(str(e)) from e\n\n\ndef calculate_f1_score(pred_mask: npt.NDArray, gt_mask: npt.NDArray):\n    pred_flat = pred_mask.flatten()\n    gt_flat = gt_mask.flatten()\n\n    tp = np.sum((pred_flat == 1) & (gt_flat == 1))\n    fp = np.sum((pred_flat == 1) & (gt_flat == 0))\n    fn = np.sum((pred_flat == 0) & (gt_flat == 1))\n\n    precision = tp / (tp + fp) if (tp + fp) > 0 else 0\n    recall = tp / (tp + fn) if (tp + fn) > 0 else 0\n\n    if (precision + recall) > 0:\n        return 2 * (precision * recall) / (precision + recall)\n    else:\n        return 0\n\n\ndef calculate_f1_matrix(pred_masks: list[npt.NDArray], gt_masks: list[npt.NDArray]):\n    \"\"\"\n    Parameters:\n    pred_masks (np.ndarray):\n            First dimension is the number of predicted instances.\n            Each instance is a binary mask of shape (height, width).\n    gt_masks (np.ndarray):\n            First dimension is the number of ground truth instances.\n            Each instance is a binary mask of shape (height, width).\n    \"\"\"\n\n    num_instances_pred = len(pred_masks)\n    num_instances_gt = len(gt_masks)\n    f1_matrix = np.zeros((num_instances_pred, num_instances_gt))\n\n    # Calculate F1 scores for each pair of predicted and ground truth masks\n    for i in range(num_instances_pred):\n        for j in range(num_instances_gt):\n            pred_flat = pred_masks[i].flatten()\n            gt_flat = gt_masks[j].flatten()\n            f1_matrix[i, j] = calculate_f1_score(pred_mask=pred_flat, gt_mask=gt_flat)\n\n    if f1_matrix.shape[0] < len(gt_masks):\n        # Add a row of zeros to the matrix if the number of predicted instances is less than ground truth instances\n        f1_matrix = np.vstack((f1_matrix, np.zeros((len(gt_masks) - len(f1_matrix), num_instances_gt))))\n\n    return f1_matrix\n\n\ndef oF1_score(pred_masks: list[npt.NDArray], gt_masks: list[npt.NDArray]):\n    \"\"\"\n    Calculate the optimal F1 score for a set of predicted masks against\n    ground truth masks which considers the optimal F1 score matching.\n    This function uses the Hungarian algorithm to find the optimal assignment\n    of predicted masks to ground truth masks based on the F1 score matrix.\n    If the number of predicted masks is less than the number of ground truth masks,\n    it will add a row of zeros to the F1 score matrix to ensure that the dimensions match.\n\n    Parameters:\n    pred_masks (list of np.ndarray): List of predicted binary masks.\n    gt_masks (np.ndarray): Array of ground truth binary masks.\n    Returns:\n    float: Optimal F1 score.\n    \"\"\"\n    f1_matrix = calculate_f1_matrix(pred_masks, gt_masks)\n\n    # Find the best matching between predicted and ground truth masks\n    row_ind, col_ind = scipy.optimize.linear_sum_assignment(-f1_matrix)\n    # The linear_sum_assignment discards excess predictions so we need a separate penalty.\n    excess_predictions_penalty = len(gt_masks) / max(len(pred_masks), len(gt_masks))\n    return np.mean(f1_matrix[row_ind, col_ind]) * excess_predictions_penalty\n\n\ndef evaluate_single_image(label_rles: str, prediction_rles: str, shape_str: str) -> float:\n    shape = json.loads(shape_str)\n    label_rles = [rle_decode(x, shape=shape) for x in label_rles.split(';')]\n    prediction_rles = [rle_decode(x, shape=shape) for x in prediction_rles.split(';')]\n    return oF1_score(prediction_rles, label_rles)\n\n\ndef score(solution: pd.DataFrame, submission: pd.DataFrame, row_id_column_name: str) -> float:\n    \"\"\"\n    Args:\n        solution (pd.DataFrame): The ground truth DataFrame.\n        submission (pd.DataFrame): The submission DataFrame.\n        row_id_column_name (str): The name of the column containing row IDs.\n    Returns:\n        float\n\n    Examples\n    --------\n    >>> solution = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['authentic', 'authentic', 'authentic'], 'shape': ['authentic', 'authentic', 'authentic']})\n    >>> submission = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['authentic', 'authentic', 'authentic']})\n    >>> score(solution.copy(), submission.copy(), row_id_column_name='row_id')\n    1.0\n\n    >>> solution = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['authentic', 'authentic', 'authentic'], 'shape': ['authentic', 'authentic', 'authentic']})\n    >>> submission = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 102]', '[101, 102]', '[101, 102]']})\n    >>> score(solution.copy(), submission.copy(), row_id_column_name='row_id')\n    0.0\n\n    >>> solution = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 102]', '[101, 102]', '[101, 102]'], 'shape': ['[720, 960]', '[720, 960]', '[720, 960]']})\n    >>> submission = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 102]', '[101, 102]', '[101, 102]']})\n    >>> score(solution.copy(), submission.copy(), row_id_column_name='row_id')\n    1.0\n\n    >>> solution = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 103]', '[101, 102]', '[101, 102]'], 'shape': ['[720, 960]', '[720, 960]', '[720, 960]']})\n    >>> submission = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 102]', '[101, 102]', '[101, 102]']})\n    >>> score(solution.copy(), submission.copy(), row_id_column_name='row_id')\n    0.9983739837398374\n\n    >>> solution = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 102];[300, 100]', '[101, 102]', '[101, 102]'], 'shape': ['[720, 960]', '[720, 960]', '[720, 960]']})\n    >>> submission = pd.DataFrame({'row_id': [0, 1, 2], 'annotation': ['[101, 102]', '[101, 102]', '[101, 102]']})\n    >>> score(solution.copy(), submission.copy(), row_id_column_name='row_id')\n    0.8333333333333334\n    \"\"\"\n    df = solution\n    df = df.rename(columns={'annotation': 'label'})\n\n    df['prediction'] = submission['annotation']\n    # Check for correct 'authentic' label\n    authentic_indices = (df['label'] == 'authentic') | (df['prediction'] == 'authentic')\n    df['image_score'] = ((df['label'] == df['prediction']) & authentic_indices).astype(float)\n\n    df.loc[~authentic_indices, 'image_score'] = df.loc[~authentic_indices].apply(\n        lambda row: evaluate_single_image(row['label'], row['prediction'], row['shape']), axis=1\n    )\n    return float(np.mean(df['image_score']))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:08:34.259945Z","iopub.execute_input":"2026-01-10T15:08:34.260448Z","iopub.status.idle":"2026-01-10T15:08:34.278775Z","shell.execute_reply.started":"2026-01-10T15:08:34.260427Z","shell.execute_reply":"2026-01-10T15:08:34.277981Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Before masking:","metadata":{}},{"cell_type":"code","source":"# sub","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:08:37.159065Z","iopub.execute_input":"2026-01-10T15:08:37.159348Z","iopub.status.idle":"2026-01-10T15:08:37.162754Z","shell.execute_reply.started":"2026-01-10T15:08:37.159327Z","shell.execute_reply":"2026-01-10T15:08:37.162113Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# import os, json, cv2\n# import numpy as np\n# import pandas as pd\n# from pathlib import Path\n# from PIL import Image\n# import matplotlib.pyplot as plt\n# from tqdm import tqdm\n\n# # --- Visualisation (seulement si forged) ---\n# try:\n#     f = '/kaggle/input/recodai-luc-scientific-image-forgery-detection/supplemental_images/64908.png'\n#     pil = Image.open(Path(f)).convert(\"RGB\")\n#     label = sub['annotation'].iloc[47]\n#     mask = rle_decode(label, shape=(pil.height, pil.width)) if label != 'authentic' else np.zeros(pil.size[::-1], np.uint8)\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=(10,5))\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:08:37.712922Z","iopub.execute_input":"2026-01-10T15:08:37.71353Z","iopub.status.idle":"2026-01-10T15:08:37.716975Z","shell.execute_reply.started":"2026-01-10T15:08:37.713509Z","shell.execute_reply":"2026-01-10T15:08:37.716261Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# # --- Visualisation (seulement si forged) ---\n# # try:\n# f = '/kaggle/input/recodai-luc-scientific-image-forgery-detection/supplemental_images/64908.png'\n# pil = Image.open(Path(f)).convert(\"RGB\")\n# label = sub['annotation'].iloc[47]\n# mask = np.logical_and(\n#     rle_decode(label, shape=(pil.height, pil.width)),\n#     rle_decode(boxes_to_rle_masks([sub['boxes'].iloc[47]], pil.height, pil.width), shape=(pil.height, pil.width))\n# )\n# # mask = rle_decode(label, shape=(pil.height, pil.width)) if label != 'authentic' else np.zeros(pil.size[::-1], np.uint8)\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=(10,5))\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:08:38.246992Z","iopub.execute_input":"2026-01-10T15:08:38.247763Z","iopub.status.idle":"2026-01-10T15:08:38.251302Z","shell.execute_reply.started":"2026-01-10T15:08:38.247738Z","shell.execute_reply":"2026-01-10T15:08:38.250638Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Go through and run the intersection","metadata":{}},{"cell_type":"code","source":"#all_images = pd.DataFrame({'path':sorted(os.listdir('/kaggle/input/recodai-luc-scientific-image-forgery-detection/supplemental_images/'))})\nall_images = pd.DataFrame({'path':sorted(os.listdir('/kaggle/input/recodai-luc-scientific-image-forgery-detection/test_images/'))})\nall_images['case_id'] = all_images['path'].str.split('/').str[-1].str.split('.').str[0]\ncase_id_to_file_dict = all_images.set_index('case_id')['path'].to_dict()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:08:38.943021Z","iopub.execute_input":"2026-01-10T15:08:38.943726Z","iopub.status.idle":"2026-01-10T15:08:38.950393Z","shell.execute_reply.started":"2026-01-10T15:08:38.9437Z","shell.execute_reply":"2026-01-10T15:08:38.949786Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"list_of_dicts = []\nfor idx, row in tqdm(sub.iterrows(), total=len(sub)):\n    case_id = row['case_id']\n    dino_sub = row['annotation']\n    boxes = row['boxes']\n    if dino_sub == 'authentic' or not isinstance(boxes, list):\n        continue\n    else:\n        if len(boxes) <= 0:\n            continue\n        f = case_id_to_file_dict[case_id]\n        pil = Image.open(Path(f)).convert(\"RGB\")\n        mask = np.logical_and(\n            rle_decode(dino_sub, shape=(pil.height, pil.width)),\n            rle_decode(boxes_to_rle_masks([boxes], pil.height, pil.width), shape=(pil.height, pil.width))\n        )\n        list_of_dicts.append({\n            'case_id': case_id,\n            'dino_intersect_DETR': rle_encode([mask])\n        })\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:08:40.551839Z","iopub.execute_input":"2026-01-10T15:08:40.552121Z","iopub.status.idle":"2026-01-10T15:08:40.560546Z","shell.execute_reply.started":"2026-01-10T15:08:40.552103Z","shell.execute_reply":"2026-01-10T15:08:40.559803Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# If there are rows to correct, then correct them.\nif len(list_of_dicts) > 0:\n    intersect = pd.DataFrame(list_of_dicts)\n    sub = sub.merge(intersect, on='case_id', how='left')\n    intersect_mask = sub['dino_intersect_DETR'].notnull()\n    sub.loc[intersect_mask, 'annotation'] = sub.loc[intersect_mask, 'dino_intersect_DETR']","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:08:41.600227Z","iopub.execute_input":"2026-01-10T15:08:41.600943Z","iopub.status.idle":"2026-01-10T15:08:41.604877Z","shell.execute_reply.started":"2026-01-10T15:08:41.600907Z","shell.execute_reply":"2026-01-10T15:08:41.604081Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## now do the final replacement if we found an exact match with DETR","metadata":{}},{"cell_type":"code","source":"replace_mask = sub['annotationdetr'] != 'authentic'","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:08:42.680409Z","iopub.execute_input":"2026-01-10T15:08:42.68102Z","iopub.status.idle":"2026-01-10T15:08:42.684553Z","shell.execute_reply.started":"2026-01-10T15:08:42.680994Z","shell.execute_reply":"2026-01-10T15:08:42.683946Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sub.loc[replace_mask, 'annotation'] = sub.loc[replace_mask, 'annotationdetr']","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:08:42.880213Z","iopub.execute_input":"2026-01-10T15:08:42.88074Z","iopub.status.idle":"2026-01-10T15:08:42.885273Z","shell.execute_reply.started":"2026-01-10T15:08:42.880716Z","shell.execute_reply":"2026-01-10T15:08:42.884607Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sub","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:08:43.256863Z","iopub.execute_input":"2026-01-10T15:08:43.257483Z","iopub.status.idle":"2026-01-10T15:08:43.267152Z","shell.execute_reply.started":"2026-01-10T15:08:43.257461Z","shell.execute_reply":"2026-01-10T15:08:43.266562Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Once more we run through.","metadata":{}},{"cell_type":"code","source":"import 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\n\ndef binary_mask_to_rle(binary_mask):\n    \"\"\"Convert a single binary instance mask to RLE string.\"\"\"\n    pixels = binary_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    return ' '.join(str(x) for x in runs)\n\n\ndef mask_to_rle(mask):\n    \"\"\"Convert binary mask to RLE string.\"\"\"\n    if mask.sum() == 0:\n        return \"\"\n    \n    mask_uint8 = (mask * 255).astype(np.uint8)\n    contours, _ = cv2.findContours(mask_uint8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    \n    instance_rles = []\n    for cnt in contours:\n        if cv2.contourArea(cnt) < 50:\n            continue\n        instance_mask = np.zeros_like(mask, dtype=np.uint8)\n        cv2.drawContours(instance_mask, [cnt], -1, 1, thickness=-1)\n        rle = binary_mask_to_rle(instance_mask)\n        instance_rles.append(rle)\n    \n    return \" \".join(instance_rles)\n\n\ndef plot_sam3_masks(image, masks, boxes, scores, title=\"SAM3 Detected Masks\"):\n    \"\"\"\n    Plot all masks detected by SAM3.\n    \n    Args:\n        image: PIL Image or numpy array (RGB)\n        masks: numpy array of binary masks [N, H, W] or [N, 1, H, W]\n        boxes: numpy array of boxes [N, 4] in (x1, y1, x2, y2) format\n        scores: numpy array of confidence scores [N]\n        title: plot title\n    \"\"\"\n    if isinstance(image, Image.Image):\n        image = np.array(image)\n    \n    # Handle masks with extra dimension [N, 1, H, W] -> [N, H, W]\n    if masks.ndim == 4 and masks.shape[1] == 1:\n        masks = masks.squeeze(1)\n    \n    fig, ax = plt.subplots(1, figsize=(12, 12))#, num=1, clear=True)\n    ax.imshow(image)\n    ax.axis(\"off\")\n    \n    # Color map for different masks\n    cmap = plt.get_cmap(\"tab20\")\n    \n    # Plot each mask\n    for idx, (mask, box, score) in enumerate(zip(masks, boxes, scores)):\n        print(\"UUUUUUUUUUUUUUUUUUUUUUUUU\", idx)\n        color = cmap(idx % 20)\n        \n        # Ensure mask is 2D\n        if mask.ndim == 3:\n            mask = mask.squeeze()\n        \n        # Create colored overlay for mask\n        colored_mask = np.zeros((*mask.shape, 4))\n        colored_mask[mask > 0] = [*color[:3], 0.4]  # Semi-transparent\n        ax.imshow(colored_mask)\n        \n        # Draw bounding box\n        x1, y1, x2, y2 = box\n        rect = plt.Rectangle((x1, y1), x2-x1, y2-y1, \n                            edgecolor=color, facecolor='none', linewidth=2)\n        ax.add_patch(rect)\n        \n        # Add label with score\n        ax.text(x1, y1-5, f\"#{idx} ({score:.2f})\", \n                color='white', fontsize=10,\n                bbox=dict(facecolor=color, alpha=0.8, pad=2))\n    \n    plt.title(f\"{title}\\nDetected {len(masks)} masks\", \n              fontsize=14, fontweight='bold')\n    plt.tight_layout()\n    #fig.savefig(f'{title}.png')\n    plt.show()\n    plt.close(fig)\n    gc.collect()\n\n\ndef get_duplicate_polygon_hull(boxes, duplicate_indices):\n    \"\"\"\n    Get the convex hull polygon for a set of duplicate boxes.\n    \n    Args:\n        boxes: List of (x, y, w, h) tuples\n        duplicate_indices: List of box indices that are duplicates\n    \n    Returns:\n        numpy array of polygon points [N, 2]\n    \"\"\"\n    # Collect all corner points from the duplicate boxes\n    points = []\n    for idx in duplicate_indices:\n        x, y, w, h = boxes[idx]\n        # Add all 4 corners\n        points.extend([\n            [x, y],\n            [x + w, y],\n            [x + w, y + h],\n            [x, y + h]\n        ])\n    \n    points = np.array(points)\n    \n    # Compute convex hull\n    hull = cv2.convexHull(points.astype(np.float32))\n    hull_points = hull.squeeze()\n    \n    return hull_points\n\n\ndef boxes_to_rle_masks_v2(duplicate_info: List[Tuple], image_height: int, image_width: int) -> str:\n    \"\"\"\n    Convert duplicate bounding boxes to RLE masks.\n    \n    Args:\n        duplicate_info: List of tuples (idx_i, idx_j, transform, inliers, ratio, bbox_i, bbox_j)\n        image_height: Height of the image\n        image_width: Width of the image\n    \n    Returns:\n        RLE string for submission\n    \"\"\"\n    if not duplicate_info:\n        return \"authentic\"\n    \n    # Create a binary mask for the full image\n    full_mask = np.zeros((image_height, image_width), dtype=np.uint8)\n    \n    # Draw all duplicate regions\n    for _, _, _, _, _, bbox_i, bbox_j in duplicate_info:\n        # Draw first region\n        x1, y1, w1, h1 = bbox_i\n        x1, y1 = max(0, x1), max(0, y1)\n        x2, y2 = min(image_width, x1 + w1), min(image_height, y1 + h1)\n        full_mask[y1:y2, x1:x2] = 1\n        \n        # Draw second region\n        x1, y1, w1, h1 = bbox_j\n        x1, y1 = max(0, x1), max(0, y1)\n        x2, y2 = min(image_width, x1 + w1), min(image_height, y1 + h1)\n        full_mask[y1:y2, x1:x2] = 1\n    \n    # Convert to RLE\n    rle_string = mask_to_rle(full_mask)\n    \n    return rle_string if rle_string else \"authentic\"\n\n\ndef plot_panel_with_keypoint_duplicates_v2(\n    panel_img_rgb: np.ndarray,\n    boxes: List[Tuple[int, int, int, int]],\n    duplicates: List[Tuple[int, int, str, int, float, Tuple, Tuple]]\n):\n    \"\"\"\n    Plot panel with tight bounding boxes around matched keypoint regions.\n    \n    Args:\n        panel_img_rgb: RGB image\n        boxes: Original detection boxes (x, y, w, h) - shown in white\n        duplicates: List of (idx_i, idx_j, transform, n_inliers, ratio, bbox_i, bbox_j)\n    \"\"\"\n    fig, ax = plt.subplots(1, figsize=(15, 15))\n    ax.imshow(panel_img_rgb)\n    ax.axis(\"off\")\n    \n    # Draw all original detection boxes in white (for reference)\n    for idx, (x, y, w, h) in enumerate(boxes):\n        rect = plt.Rectangle((x, y), w, h, edgecolor='white', \n                            facecolor='none', linewidth=1, linestyle='--', alpha=0.5)\n        ax.add_patch(rect)\n        ax.text(x+3, y+15, f\"Panel {idx}\", color='white', fontsize=9,\n                bbox=dict(facecolor='gray', alpha=0.5, pad=2))\n    \n    # Highlight duplicate pairs with tight bounding boxes\n    cmap = plt.get_cmap(\"tab10\")\n    \n    for k, (i, j, transform, n_inliers, ratio, bbox_i, bbox_j) in enumerate(duplicates):\n        color = cmap(k % 10)\n        \n        xi, yi, wi, hi = bbox_i\n        xj, yj, wj, hj = bbox_j\n        \n        # Draw tight bounding boxes for duplicated regions (thick colored borders)\n        ax.add_patch(plt.Rectangle((xi, yi), wi, hi, edgecolor=color,\n                                   facecolor='none', linewidth=3))\n        ax.add_patch(plt.Rectangle((xj, yj), wj, hj, edgecolor=color,\n                                   facecolor='none', linewidth=3))\n        \n        # Connect centers of tight boxes\n        ci = (xi + wi/2, yi + hi/2)\n        cj = (xj + wj/2, yj + hj/2)\n        ax.plot([ci[0], cj[0]], [ci[1], cj[1]], color=color,\n                linewidth=2, linestyle='--', alpha=0.7)\n        \n        # Label with detailed information\n        label = f\"Pair {k}\\n{transform}\\n{n_inliers} inliers\\n{ratio:.1%}\"\n        mid_x, mid_y = (ci[0] + cj[0]) / 2, (ci[1] + cj[1]) / 2\n        ax.text(mid_x, mid_y, label, color='white', fontsize=9,\n                bbox=dict(facecolor=color, alpha=0.9, edgecolor='white', pad=3),\n                ha='center', va='center', fontweight='bold')\n        \n        # Add size information to tight boxes\n        ax.text(xi, yi-5, f\"{wi}×{hi}px\", color=color, fontsize=8,\n                bbox=dict(facecolor='black', alpha=0.7, pad=2))\n        ax.text(xj, yj-5, f\"{wj}×{hj}px\", color=color, fontsize=8,\n                bbox=dict(facecolor='black', alpha=0.7, pad=2))\n    \n    title = f\"Detected {len(duplicates)} Copy-Move Forgery Pairs\\n\"\n    title += \"White dashed boxes: Original panels | Colored solid boxes: Matched keypoint regions\"\n    plt.title(title, fontsize=14, fontweight='bold')\n    plt.tight_layout()\n    plt.show()\n    plt.close(fig)\n\n\ndef plot_panel_with_keypoint_duplicates_enhanced_v2(\n    panel_img_rgb: np.ndarray,\n    boxes: List[Tuple[int, int, int, int]],\n    duplicates: List[Tuple[int, int, str, int, float, Tuple, Tuple]]\n):\n    \"\"\"\n    Enhanced visualization with polygon hulls around grouped duplicates.\n    Uses tight bounding boxes from keypoint matches.\n    \n    Args:\n        panel_img_rgb: RGB image\n        boxes: Original detection boxes (x, y, w, h)\n        duplicates: List of (idx_i, idx_j, transform, n_inliers, ratio, bbox_i, bbox_j)\n    \"\"\"\n    fig, ax = plt.subplots(1, figsize=(15, 15))\n    ax.imshow(panel_img_rgb)\n    ax.axis(\"off\")\n    \n    # Draw original detection boxes (faint, for reference)\n    for idx, (x, y, w, h) in enumerate(boxes):\n        rect = plt.Rectangle((x, y), w, h, edgecolor='white', \n                            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,\n                bbox=dict(facecolor='gray', alpha=0.4, pad=1))\n    \n    # Group duplicates by connected components (using panel indices)\n    from collections import defaultdict\n    duplicate_groups = []\n    visited = set()\n    \n    # Build adjacency for grouping (based on panel indices i, j)\n    adj = defaultdict(set)\n    for i, j, _, _, _, _, _ in duplicates:\n        adj[i].add(j)\n        adj[j].add(i)\n    \n    # Find connected components\n    for node in adj:\n        if node not in visited:\n            group = set()\n            stack = [node]\n            while stack:\n                curr = stack.pop()\n                if curr not in visited:\n                    visited.add(curr)\n                    group.add(curr)\n                    stack.extend(adj[curr] - visited)\n            if group:\n                duplicate_groups.append(list(group))\n    \n    # Highlight duplicate pairs with tight bounding boxes\n    cmap = plt.get_cmap(\"tab10\")\n    \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\n        xj, yj, wj, hj = bbox_j\n        \n        # Thick colored borders for tight duplicate regions\n        ax.add_patch(plt.Rectangle((xi, yi), wi, hi, edgecolor=color,\n                                   facecolor='none', linewidth=3))\n        ax.add_patch(plt.Rectangle((xj, yj), wj, hj, edgecolor=color,\n                                   facecolor='none', linewidth=3))\n        \n        # Connect centers\n        ci = (xi + wi/2, yi + hi/2)\n        cj = (xj + wj/2, yj + hj/2)\n        ax.plot([ci[0], cj[0]], [ci[1], cj[1]], color=color,\n                linewidth=2, linestyle='--', alpha=0.7)\n        \n        # Label with score\n        label = f\"Pair {k}\\n{transform}\\n{n_inliers} matches\\n{ratio:.1%}\"\n        mid_x, mid_y = (ci[0] + cj[0]) / 2, (ci[1] + cj[1]) / 2\n        ax.text(mid_x, mid_y, label, color='white', fontsize=9,\n                bbox=dict(facecolor='black', alpha=0.8, edgecolor=color, pad=3),\n                ha='center', va='center')\n    \n    # Draw polygon hulls for duplicate groups using tight bounding boxes\n    for group_idx, group in enumerate(duplicate_groups):\n        if len(group) < 2:\n            continue\n        \n        # Collect all tight bounding boxes for this group\n        tight_boxes = []\n        for i, j, _, _, _, bbox_i, bbox_j in duplicates:\n            if i in group:\n                tight_boxes.append(bbox_i)\n            if j in group:\n                tight_boxes.append(bbox_j)\n        \n        # Remove duplicates\n        tight_boxes = list(set(tight_boxes))\n        \n        if len(tight_boxes) < 2:\n            continue\n        \n        # Get convex hull of all tight boxes in this group\n        points = []\n        for x, y, w, h in tight_boxes:\n            points.extend([\n                [x, y],\n                [x + w, y],\n                [x + w, y + h],\n                [x, y + h]\n            ])\n        \n        points = np.array(points)\n        hull = cv2.convexHull(points.astype(np.float32))\n        hull_points = hull.squeeze()\n        \n        # Draw polygon\n        color = cmap(group_idx % 10)\n        polygon = Polygon(hull_points, edgecolor=color, facecolor=color,\n                         alpha=0.15, linewidth=3, linestyle='-')\n        ax.add_patch(polygon)\n        \n        # Calculate average score for this group\n        group_scores = []\n        for i, j, transform, n_inliers, ratio, _, _ in duplicates:\n            if i in group or j in group:\n                group_scores.append(ratio)\n        avg_score = np.mean(group_scores) if group_scores else 0\n        \n        # Add group label\n        centroid = hull_points.mean(axis=0)\n        ax.text(centroid[0], centroid[1], \n                f\"Group {group_idx}\\nPanels: {sorted(group)}\\nAvg: {avg_score:.1%}\",\n                color='white', fontsize=11, fontweight='bold',\n                bbox=dict(facecolor=color, alpha=0.9, edgecolor='white', \n                         linewidth=2, pad=5),\n                ha='center', va='center')\n    \n    title = f\"Detected {len(duplicates)} Copy-Move Forgery Pairs in {len(duplicate_groups)} Groups\\n\"\n    title += \"Dotted white: Original panels | Solid colored: Keypoint match regions | Shaded: Group hulls\"\n    plt.title(title, fontsize=14, fontweight='bold')\n    plt.tight_layout()\n    plt.show()\n    plt.close(fig)\n\n\n# Update the existing functions to use enhanced visualization\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  # Add visualization flag\n):\n    \"\"\"\n    Generate ensemble predictions with optional visualization.\n    \"\"\"\n    if device is None:\n        device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')\n    \n    # Get test image paths\n    test_images = sorted(list(Path(test_dir).glob('*.png')))\n    #test_images += sorted(list(Path('/kaggle/input/recodai-luc-scientific-image-forgery-detection/train_images/forged').glob('*.png')))[500:550]\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(\"BBBBBBBBBBBBBBBBBBBB\", test_images)\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    # Panel detection with SAM3\n    raw_or_panel = []\n    \n    test_df = pd.DataFrame({'img': test_images})\n    test_df['mask'] = None\n    test_df['group'] = -1\n\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    threshold = 0.05\n    \n    for idx, row in tqdm(test_df.iterrows(), desc='SAM3 panel segmentation', total=len(test_df)):\n        image = Image.open(row['img']).convert('RGB')\n\n        all_masks = []\n        all_boxes = []\n        all_scores = []\n\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            print(\"N BOXES FOUND\", len(boxes))\n            \n            # Filter masks by score threshold\n            high_confidence_idx = scores > threshold\n            filtered_masks = masks[high_confidence_idx]\n            filtered_boxes = boxes[high_confidence_idx]\n            filtered_scores = scores[high_confidence_idx]\n\n        #    print(\"YYYYYYYYYYYYYYYYYY\", len(filtered_boxes))\n\n            # # 1. Determine how many masks to keep (handle cases with < 100 masks)\n            # k = min(50, len(filtered_scores))\n            \n            # # 2. Get the indices of the top K scores\n            # top_scores, top_indices = torch.topk(filtered_scores, k=k, sorted=True)\n\n            # print(\"XXXXXXXXXXXXXXXXXXXXX\", len(top_scores))\n            \n            # # 3. Index the tensors using the top indices\n            # filtered_masks = filtered_masks[top_indices]\n            # filtered_boxes = filtered_boxes[top_indices]\n            # filtered_scores = top_scores\n\n          #  print(\"WWWWWWWWWWWWWWWWWWWWW\", len(filtered_boxes))\n\n            # If you found them, then dont go through the other prompts.\n            if filtered_boxes.shape[0] > 0:\n                all_masks.append(filtered_masks.detach().cpu().numpy())\n                all_boxes.append(filtered_boxes.detach().cpu().numpy())\n                all_scores.append(filtered_scores.detach().cpu().numpy())\n\n                # Clear GPU tensors\n                #del masks, boxes, scores, filtered_masks, filtered_boxes, filtered_scores\n                #torch.cuda.empty_cache()\n                break\n            #else:\n            #    pass\n                # Clear even if no boxes found\n                #del masks, boxes, scores, filtered_masks, filtered_boxes, filtered_scores\n\n        if len(all_boxes) > 0:\n            all_masks_concat = np.concatenate(all_masks, axis=0)\n            all_boxes_concat = np.concatenate(all_boxes, axis=0)\n            all_scores_concat = np.concatenate(all_scores, axis=0)\n\n            # Clear the lists after concatenation\n            #del all_masks, all_boxes, all_scores\n            \n            \n\n            # Apply NMS and get indices of kept boxes\n            # curr_img_preds, kept_indices = nms_boxes(curr_img_preds, iou_threshold=0.1, return_indices=True)\n            \n            # Filter masks, boxes, and scores to only kept ones after NMS\n            # if kept_indices is not None:\n            #    all_masks_concat = all_masks_concat[kept_indices]\n            #    all_boxes_concat = all_boxes_concat[kept_indices]\n            #    all_scores_concat = all_scores_concat[kept_indices]\n            \n            # Visualize de-duped SAM3 masks if requested\n          #  print(\"VVVVVVVVVVVVVVVVVVVVVVVVVV\", len(all_boxes_concat))\n            if visualize:\n                plot_sam3_masks(image, all_masks_concat, all_boxes_concat, \n                              all_scores_concat, \n                              title=f\"SAM3 Masks (After NMS) for {Path(row['img']).name}\")\n\n            # Convert from (x1, y1, x2, y2) format to (x, y, w, h) format\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    \n            # Convert to integer pixels\n            all_boxes_concat = all_boxes_concat.round().astype('int32')\n            # Don't keep large masks in memory\n            #del all_masks_concat, all_scores_concat\n        else:\n            # If you dont find anything, try insert bbox that is entire image.\n            all_boxes_concat = np.array([[0, 0, image.width, image.height]]).astype('int32')\n            #del all_masks, all_boxes, all_scores\n\n        #del inference_state, processor\n\n        raw_or_panel.append({\n            'path': row['img'],\n            'is_panel': True,#len(all_boxes_concat) >= 2,\n            'bbox_preds': all_boxes_concat\n        })\n\n        \n\n    raw_or_panel = pd.DataFrame(raw_or_panel)\n        \n    \n\n    ########################################################################\n    ########################################################################\n    # Handle panel images\n    ########################################################################\n    ########################################################################\n    test_images = raw_or_panel.loc[raw_or_panel['is_panel'], 'path'].tolist()\n\n    if len(test_images) > 0:\n        \n        \n        # ---------------------- PARAMETERS ----------------------\n        # Keypoint detection\n        SIFT_FEATURES = 4500           # Max features per crop\n        SIFT_CONTRAST_THRESH = 0.02    # Lower = more keypoints (default 0.04)\n        SIFT_EDGE_THRESH = 10          # Higher = more keypoints (default 10)\n        \n        # Matching\n        LOWE_RATIO = 0.75              # Lowe's ratio test threshold (0.7-0.8 typical)\n        MIN_MATCH_COUNT = 10            # Minimum good matches to consider duplicate\n        RANSAC_REPROJ_THRESH = 3.0     # RANSAC inlier threshold in pixels\n        \n        # Geometric validation\n        MIN_INLIER_RATIO = 0.8         # Min ratio of inliers to total matches\n        MAX_SCALE_CHANGE = 3.0         # Max allowed scale factor change\n        MIN_SCALE_CHANGE = 0.33        # Min allowed scale factor change\n        \n        # Pre-filtering (cheap checks before expensive keypoint matching)\n        AREA_RATIO_TOLERANCE = 0.15    # Allow 15% area difference\n        MEAN_COLOR_DIFF_THRESH = 40.0  # L2 distance of RGB means\n        HIST_DIFF_THRESH = 0.30        # Normalized histogram distance\n        \n        # Overlap filtering\n        IOU_THRESH = 0.5 # So we skip any boxes that overlap more than 10%.\n        \n        # ---------------------- UTILITY FUNCTIONS ----------------------\n        \n        def compute_iou(box1: Tuple[int, int, int, int], \n                        box2: Tuple[int, int, int, int]) -> float:\n            \"\"\"Compute Intersection over Union of two boxes (x, y, w, h).\"\"\"\n            x1, y1, w1, h1 = box1\n            x2, y2, w2, h2 = box2\n            \n            # Intersection\n            xi1 = max(x1, x2)\n            yi1 = max(y1, y2)\n            xi2 = min(x1 + w1, x2 + w2)\n            yi2 = min(y1 + h1, y2 + h2)\n            \n            inter_area = max(0, xi2 - xi1) * max(0, yi2 - yi1)\n            \n            # Union\n            box1_area = w1 * h1\n            box2_area = w2 * h2\n            \n            # IOU\n            # union_area = box1_area + box2_area - inter_area\n            # return inter_area / (union_area + 1e-10)\n            \n            # Pure intersection\n            min_area = min(box1_area, box2_area)\n            return inter_area / (min_area + 1e-10)\n        \n        \n        def extract_keypoints_and_descriptors(crop_rgb: np.ndarray) -> Tuple[List, np.ndarray]:\n            \"\"\"Extract SIFT keypoints and descriptors from a crop.\"\"\"\n            # Convert to grayscale\n            gray = cv2.cvtColor(crop_rgb, cv2.COLOR_RGB2GRAY)\n\n            # 1. Apply CLAHE First (Fixes Brightness issues & pops text)\n            clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8))\n            gray = clahe.apply(gray)\n\n            # --- Step 2: Text/Arrow Suppression ---\n            # Scientific text is usually high contrast and high frequency.\n            # We use morphological gradient to find edges, then threshold.\n            kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3,3))\n            morph_grad = cv2.morphologyEx(gray, cv2.MORPH_GRADIENT, kernel)\n            \n            # Threshold to find strong edges (text/arrows)\n            # Dynamic thresholding based on image statistics works better than fixed\n            mean_val = np.mean(morph_grad)\n            std_val = np.std(morph_grad)\n            thresh_val = mean_val + 2.5 * std_val \n            _, binary_mask = cv2.threshold(morph_grad, thresh_val, 255, cv2.THRESH_BINARY)\n            \n            # Dilate slightly to cover the edges of the text\n            dilated_mask = cv2.dilate(binary_mask, kernel, iterations=2)\n            \n            # Inpaint or Blur the masked regions. \n            # Blurring is faster and effectively kills SIFT keypoints in those areas.\n            # We blur the WHOLE image, then combine based on mask.\n            blurred_gray = cv2.GaussianBlur(gray, (15, 15), 0)\n            \n            # Where mask is white (text), use blurred image. Otherwise use original CLAHE image.\n            gray = np.where(dilated_mask > 0, blurred_gray, gray)\n            \n            # Create SIFT detector\n            sift = cv2.SIFT_create(\n                nfeatures=SIFT_FEATURES,\n                contrastThreshold=SIFT_CONTRAST_THRESH,\n                edgeThreshold=SIFT_EDGE_THRESH\n            )\n            \n            # Detect and compute\n            keypoints, descriptors = sift.detectAndCompute(gray, None)\n            \n            return keypoints, descriptors\n        \n        \n        def match_descriptors(desc1: np.ndarray, desc2: np.ndarray) -> List[cv2.DMatch]:\n            \"\"\"Match descriptors using FLANN and apply Lowe's ratio test.\"\"\"\n            if desc1 is None or desc2 is None or len(desc1) < 2 or len(desc2) < 2:\n                return []\n            \n            # FLANN matcher\n            FLANN_INDEX_KDTREE = 1\n            index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)\n            search_params = dict(checks=50)\n            flann = cv2.FlannBasedMatcher(index_params, search_params)\n            \n            # Find 2 nearest neighbors\n            matches = flann.knnMatch(desc1, desc2, k=2)\n            \n            # Lowe's ratio test\n            good_matches = []\n            for match_pair in matches:\n                if len(match_pair) == 2:\n                    m, n = match_pair\n                    if m.distance < LOWE_RATIO * n.distance:\n                        good_matches.append(m)\n            \n            return good_matches\n\n\n        def get_keypoint_bounding_box(keypoints: List, matches: List[cv2.DMatch], \n                                      query_or_train: str = 'query',\n                                      padding: int = 10) -> Tuple[int, int, int, int]:\n            \"\"\"\n            Get bounding box around matched keypoints.\n            \n            Args:\n                keypoints: List of cv2.KeyPoint objects\n                matches: List of cv2.DMatch objects\n                query_or_train: 'query' for queryIdx, 'train' for trainIdx\n                padding: Additional padding around the keypoints in pixels\n            \n            Returns:\n                Tuple of (x, y, w, h) for the bounding box\n            \"\"\"\n            if not matches or not keypoints:\n                return (0, 0, 0, 0)\n            \n            # Extract matched keypoint coordinates\n            if query_or_train == 'query':\n                pts = [keypoints[m.queryIdx].pt for m in matches]\n            else:  # 'train'\n                pts = [keypoints[m.trainIdx].pt for m in matches]\n            \n            if not pts:\n                return (0, 0, 0, 0)\n            \n            # Convert to numpy array for easier computation\n            pts = np.array(pts)\n            \n            # Get bounding box coordinates\n            x_min = int(np.floor(pts[:, 0].min()))\n            y_min = int(np.floor(pts[:, 1].min()))\n            x_max = int(np.ceil(pts[:, 0].max()))\n            y_max = int(np.ceil(pts[:, 1].max()))\n            \n            # Add padding\n            x_min = max(0, x_min - padding)\n            y_min = max(0, y_min - padding)\n            x_max = x_max + padding\n            y_max = y_max + padding\n            \n            # Convert to (x, y, w, h) format\n            w = x_max - x_min\n            h = y_max - y_min\n            \n            return (x_min, y_min, w, h)\n        \n        \n        def validate_geometric_transformation_with_bbox(kp1: List, kp2: List, \n                                                        matches: List[cv2.DMatch],\n                                                        box1_offset: Tuple[int, int],\n                                                        box2_offset: Tuple[int, int]) -> Tuple[bool, int, np.ndarray, str, Tuple, Tuple]:\n            \"\"\"\n            Validate matches using RANSAC and return bounding boxes for matched regions.\n            \n            Args:\n                kp1, kp2: Keypoints from both crops\n                matches: List of matches\n                box1_offset: (x, y) offset of crop1 in the full image\n                box2_offset: (x, y) offset of crop2 in the full image\n            \n            Returns:\n                (is_duplicate, num_inliers, H, transform_desc, bbox1_global, bbox2_global)\n                where bboxes are in global image coordinates (x, y, w, h)\n            \"\"\"\n            if len(matches) < MIN_MATCH_COUNT:\n                return False, 0, None, \"\", (0, 0, 0, 0), (0, 0, 0, 0)\n            \n            # Extract matched keypoint locations\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            # Find homography using RANSAC\n            H, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, RANSAC_REPROJ_THRESH)\n            \n            if H is None:\n                return False, 0, None, \"\", (0, 0, 0, 0), (0, 0, 0, 0)\n            \n            # Count inliers and filter matches to only inliers\n            inliers = mask.ravel().tolist()\n            num_inliers = sum(inliers)\n            inlier_ratio = num_inliers / len(matches)\n            \n            if inlier_ratio < 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            # Filter to only inlier matches\n            inlier_matches = [m for m, is_inlier in zip(matches, inliers) if is_inlier]\n            \n            # Get bounding boxes around matched keypoints (in crop coordinates)\n            bbox1_local = get_keypoint_bounding_box(kp1, inlier_matches, 'query', padding=10)\n            bbox2_local = get_keypoint_bounding_box(kp2, inlier_matches, 'train', padding=10)\n            \n            # Convert to global image coordinates\n            x1_off, y1_off = box1_offset\n            x2_off, y2_off = box2_offset\n            \n            x1, y1, w1, h1 = bbox1_local\n            bbox1_global = (x1 + x1_off, y1 + y1_off, w1, h1)\n            \n            x2, y2, w2, h2 = bbox2_local\n            bbox2_global = (x2 + x2_off, y2 + y2_off, w2, h2)\n            \n            # Analyze transformation\n            transform_desc = analyze_homography(H)\n            \n            return True, num_inliers, H, transform_desc, bbox1_global, bbox2_global\n        \n        \n        def analyze_homography(H: np.ndarray) -> str:\n            \"\"\"\n            Analyze a homography matrix to describe the transformation.\n            Returns a string describing rotation, scale, flip, etc.\n            \"\"\"\n            if H is None:\n                return \"unknown\"\n            \n            # Decompose into rotation, scale, translation\n            # Extract upper-left 2x2 for rotation/scale analysis\n            A = H[:2, :2]\n            \n            # SVD decomposition\n            U, S, Vt = np.linalg.svd(A)\n            \n            # Scale factors\n            sx, sy = S[0], S[1]\n            avg_scale = (sx + sy) / 2\n            \n            # Check for scale validity\n            if avg_scale > MAX_SCALE_CHANGE or avg_scale < MIN_SCALE_CHANGE:\n                return f\"invalid_scale({avg_scale:.2f})\"\n            \n            # Rotation angle (approximate)\n            rotation = U @ Vt\n            angle_rad = math.atan2(rotation[1, 0], rotation[0, 0])\n            angle_deg = math.degrees(angle_rad)\n            \n            # Detect flip\n            det = np.linalg.det(A)\n            has_flip = det < 0\n            \n            # Build description\n            parts = []\n            if abs(angle_deg) > 5:\n                parts.append(f\"rot{int(angle_deg)}°\")\n            if abs(avg_scale - 1.0) > 0.15:\n                parts.append(f\"scale{avg_scale:.2f}x\")\n            if has_flip:\n                parts.append(\"flip\")\n            \n            if not parts:\n                parts.append(\"identity\")\n            \n            return \"+\".join(parts)\n        \n        \n        def find_duplicates_keypoint_based_v2(\n            panel_img_rgb: np.ndarray,\n            boxes: List[Tuple[int, int, int, int]],\n            verbose: bool = False\n        ) -> List[Tuple[int, int, str, int, float, Tuple, Tuple]]:\n            \"\"\"\n            Find duplicate crops using keypoint matching.\n            \n            Includes:\n            1. Intra-box duplicates (copy-paste within the same bounding box).\n            2. Inter-box duplicates (copy-paste between different bounding boxes).\n            \n            Returns:\n                List of tuples: (index_i, index_j, transform_description, num_inliers, \n                                inlier_ratio, bbox_i_global, bbox_j_global)\n            \"\"\"\n            duplicates = []\n            \n            # --- Helper: Check for duplicates INSIDE a single box ---\n            def check_intra_box_duplicates(crop: np.ndarray, box_idx: int, \n                                            global_offset: Tuple[int, int]) -> List:\n                \"\"\"\n                Splits a single crop into halves, thirds, quadrants, and grids to find self-duplicates.\n                Returns a list of duplicate tuples if found.\n                \"\"\"\n                h, w = crop.shape[:2]\n                # Safety check for very small boxes\n                if h < 32 or w < 32:\n                    return []\n            \n                gx, gy = global_offset\n                found_local = []\n            \n                # Inner helper to match two sub-regions\n                def match_subregions(img_a, offset_a, img_b, offset_b):\n                    # 1. Re-apply preprocessing (CLAHE) and Extract SIFT\n                    # Ensure your extract_keypoints_and_descriptors function is available in scope\n                    kp_a, desc_a = extract_keypoints_and_descriptors(img_a)\n                    kp_b, desc_b = extract_keypoints_and_descriptors(img_b)\n                    \n                    # 2. Match\n                    # Ensure match_descriptors is available\n                    matches_sub = match_descriptors(desc_a, desc_b)\n                    \n                    # 3. Geometric Validation\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                    \n                    # Ensure validate_geometric_transformation_with_bbox is available\n                    return validate_geometric_transformation_with_bbox(\n                        kp_a, kp_b, matches_sub, global_off_a, global_off_b\n                    )\n            \n                # ---------------------------------------------------------\n                # Strategy 1: Left vs Right (50/50 Split)\n                # ---------------------------------------------------------\n                print('STRATEGY 1')\n                mid_x = w // 2\n                left_half = crop[:, :mid_x]\n                right_half = crop[:, mid_x:]\n                \n                is_dup, n_in, _, trans, box_a, box_b = match_subregions(\n                    left_half, (0, 0), \n                    right_half, (mid_x, 0)\n                )\n\n                area_i = box_a[2]*box_a[3]\n                area_j = box_b[2]*box_b[3]\n                area_ratio = max(area_i/(area_j+1), area_j/(area_i+1))\n                if area_ratio >= 1.5:\n                    is_dup = False\n                \n                if is_dup:\n                    ratio = n_in / (n_in + 1e-5)\n                    found_local += [(box_idx, box_idx, f\"Intra-LR: {trans}\", n_in, ratio, box_a, box_b)]\n            \n                # ---------------------------------------------------------\n                # Strategy 2: Top vs Bottom (50/50 Split)\n                # ---------------------------------------------------------\n                print('STRATEGY 2')\n                mid_y = h // 2\n                top_half = crop[:mid_y, :]\n                bot_half = crop[mid_y:, :]\n                \n                is_dup, n_in, _, trans, box_a, box_b = match_subregions(\n                    top_half, (0, 0), \n                    bot_half, (0, mid_y)\n                )\n\n                area_i = box_a[2]*box_a[3]\n                area_j = box_b[2]*box_b[3]\n                area_ratio = max(area_i/(area_j+1), area_j/(area_i+1))\n                if area_ratio >= 1.5:\n                    is_dup = False\n                \n                if is_dup:\n                    ratio = n_in / (n_in + 1e-5)\n                    found_local +=  [(box_idx, box_idx, f\"Intra-TB: {trans}\", n_in, ratio, box_a, box_b)]\n            \n                # ---------------------------------------------------------\n                # Strategy 3: Quadrants (2x2)\n                # ---------------------------------------------------------\n                print('STRATEGY 3')\n                # Good for corner-to-corner clones\n                quads = [\n                    (crop[:mid_y, :mid_x], (0, 0)),        # 0: TL\n                    (crop[:mid_y, mid_x:], (mid_x, 0)),    # 1: TR\n                    (crop[mid_y:, :mid_x], (0, mid_y)),    # 2: BL\n                    (crop[mid_y:, mid_x:], (mid_x, mid_y)) # 3: BR\n                ]\n                \n                for q_i, q_j in itertools.combinations(range(4), 2):\n                    img_i, off_i = quads[q_i]\n                    img_j, off_j = quads[q_j]\n                    \n                    is_dup, n_in, _, trans, box_a, box_b = match_subregions(img_i, off_i, img_j, off_j)\n\n                    area_i = box_a[2]*box_a[3]\n                    area_j = box_b[2]*box_b[3]\n                    area_ratio = max(area_i/(area_j+1), area_j/(area_i+1))\n                    if area_ratio >= 1.5:\n                        is_dup = False\n                    \n                    if is_dup:\n                        ratio = n_in / (n_in + 1e-5)\n                        found_local.append(\n                            (box_idx, box_idx, f\"Intra-Q{q_i}Q{q_j}: {trans}\", n_in, ratio, box_a, box_b)\n                        )\n            \n                # ---------------------------------------------------------\n                # Strategy 4: Horizontal Strips (1x3)\n                # ---------------------------------------------------------\n                # Catches clones that straddle the vertical center line\n                w_third = w // 3\n                if w_third > 10: # Only if strips are wide enough\n                    print('STRATEGY 4')\n                    h_strips = [\n                        (crop[:, 0:w_third], (0, 0)),           # Left\n                        (crop[:, w_third:2*w_third], (w_third, 0)), # Center\n                        (crop[:, 2*w_third:], (2*w_third, 0))   # Right\n                    ]\n                    \n                    for i, j in itertools.combinations(range(3), 2):\n                        img_i, off_i = h_strips[i]\n                        img_j, off_j = h_strips[j]\n                        \n                        is_dup, n_in, _, trans, box_a, box_b = match_subregions(img_i, off_i, img_j, off_j)\n\n                        area_i = box_a[2]*box_a[3]\n                        area_j = box_b[2]*box_b[3]\n                        area_ratio = max(area_i/(area_j+1), area_j/(area_i+1))\n                        if area_ratio >= 1.5:\n                            is_dup = False\n                        \n                        if is_dup:\n                            ratio = n_in / (n_in + 1e-5)\n                            found_local.append(\n                                (box_idx, box_idx, f\"Intra-HStrip{i}-{j}: {trans}\", n_in, ratio, box_a, box_b)\n                            )\n            \n                # ---------------------------------------------------------\n                # Strategy 5: Vertical Strips (3x1)\n                # ---------------------------------------------------------\n                # Catches clones that straddle the horizontal center line\n                h_third = h // 3\n                if h_third > 10:\n                    print('STRATEGY 5')\n                    \n                    v_strips = [\n                        (crop[0:h_third, :], (0, 0)),           # Top\n                        (crop[h_third:2*h_third, :], (0, h_third)), # Middle\n                        (crop[2*h_third:, :], (0, 2*h_third))   # Bottom\n                    ]\n                    \n                    for i, j in itertools.combinations(range(3), 2):\n                        img_i, off_i = v_strips[i]\n                        img_j, off_j = v_strips[j]\n                        \n                        is_dup, n_in, _, trans, box_a, box_b = match_subregions(img_i, off_i, img_j, off_j)\n\n                        area_i = box_a[2]*box_a[3]\n                        area_j = box_b[2]*box_b[3]\n                        area_ratio = max(area_i/(area_j+1), area_j/(area_i+1))\n                        if area_ratio >= 1.5:\n                            is_dup = False\n                        \n                        if is_dup:\n                            ratio = n_in / (n_in + 1e-5)\n                            found_local.append(\n                                (box_idx, box_idx, f\"Intra-VStrip{i}-{j}: {trans}\", n_in, ratio, box_a, box_b)\n                            )\n                # ---------------------------------------------------------\n                # Strategy 6: Fine-Grained Grid (2x3)\n                # ---------------------------------------------------------\n                # Only run if no duplicates found yet and image is large enough.\n                # This splits image into 6 blocks (2 rows, 3 cols) to catch small local clones.\n                if not found_local and h > 60 and w > 60:\n                    print('STRATEGY 6')\n                    \n                    rows, cols = 2, 3\n                    h_step = h // rows\n                    w_step = w // cols\n                    grid_patches = []\n                    \n                    for r in range(rows):\n                        for c in range(cols):\n                            y1, x1 = r * h_step, c * w_step\n                            patch = crop[y1 : y1 + h_step, x1 : x1 + w_step]\n                            grid_patches.append((patch, (x1, y1)))\n                    \n                    # itertools.combinations of 6 items = 15 checks\n                    for i, j in itertools.combinations(range(len(grid_patches)), 2):\n                        img_i, off_i = grid_patches[i]\n                        img_j, off_j = grid_patches[j]\n                        \n                        is_dup, n_in, _, trans, box_a, box_b = match_subregions(img_i, off_i, img_j, off_j)\n\n                        area_i = box_a[2]*box_a[3]\n                        area_j = box_b[2]*box_b[3]\n                        area_ratio = max(area_i/(area_j+1), area_j/(area_i+1))\n                        if area_ratio >= 1.5:\n                            is_dup = False\n                        \n                        if is_dup:\n                            ratio = n_in / (n_in + 1e-5)\n                            found_local.append(\n                                (box_idx, box_idx, f\"Intra-Grid{i}-{j}: {trans}\", n_in, ratio, box_a, box_b)\n                            )\n\n                print('DINE WIT STEATEGY')\n                \n                # # ---------------------------------------------------------\n                # # Strategy 7: Overlapping Sliding Micro-Windows (The \"Tiny Cell\" Catcher)\n                # # ---------------------------------------------------------\n                # # Logic:\n                # # 1. We create small windows (e.g., 1/4th size of image).\n                # # 2. We overlap them by 50% so a tiny cell on a boundary is not missed.\n                # # 3. We ONLY compare windows that are physically distant from each other \n                # #    (to avoid matching the overlap region itself).\n                \n                # # Only run if no obvious duplicates found yet, to save time\n                # if not found_local and h > 100 and w > 100:\n                    \n                #     # Window size: ~25% of the image dimension (zooms in significantly)\n                #     win_h = int(h * 0.30) \n                #     win_w = int(w * 0.30)\n                    \n                #     # Stride: Slide by half the window size (50% overlap)\n                #     step_h = win_h // 2\n                #     step_w = win_w // 2\n                    \n                #     windows = []\n                    \n                #     # Generate Sliding Windows\n                #     # We use a while loop to ensure we hit the edges\n                #     y = 0\n                #     while y + win_h <= h:\n                #         x = 0\n                #         while x + win_w <= w:\n                #             sub_img = crop[y:y+win_h, x:x+win_w]\n                #             windows.append( (sub_img, (x, y)) )\n                #             x += step_w\n                #         y += step_h\n                        \n                #     # Limit the number of windows to prevent explosion (max ~16-20 windows)\n                #     # If too many, we might need to increase window size slightly\n                #     if len(windows) > 25:\n                #          # Fallback for massive images: just take the central ones or reduce density\n                #          windows = windows[::2] \n            \n                #     # Compare windows\n                #     for i, j in itertools.combinations(range(len(windows)), 2):\n                #         img_i, off_i = windows[i]\n                #         img_j, off_j = windows[j]\n                        \n                #         # CRITICAL: Distance Check\n                #         # If windows overlap significantly, they will match on the shared pixels.\n                #         # We calculate center points to check distance.\n                #         cy_i, cx_i = off_i[1] + win_h//2, off_i[0] + win_w//2\n                #         cy_j, cx_j = off_j[1] + win_h//2, off_j[0] + win_w//2\n                        \n                #         dist = np.sqrt((cy_i - cy_j)**2 + (cx_i - cx_j)**2)\n                        \n                #         # Minimum distance: windows must be at least 1 window-width apart \n                #         # effectively to consider them distinct \"regions\"\n                #         min_dist = min(win_h, win_w) * 2\n                        \n                #         if dist > min_dist:\n                #             is_dup, n_in, _, trans, box_a, box_b = match_subregions(img_i, off_i, img_j, off_j)\n                            \n                #             if is_dup:\n                #                 # Higher standard for tiny regions: usually need more keypoints \n                #                 # to be sure it's not noise\n                #                 if n_in >= 7: \n                #                     ratio = n_in / (n_in + 1e-5)\n                #                     found_local.append(\n                #                         (box_idx, box_idx, f\"Intra-SlideWin{i}-{j}: {trans}\", n_in, ratio, box_a, box_b)\n                #                     )\n                #                     # Optimization: If we find a match in micro-windows, \n                #                     # we can probably stop and return, or continue to find more.\n                #                     print('hiiiiiiiiiiiiii')\n                #                     return found_local \n            \n                return found_local\n        \n            # =========================================================\n            # MAIN LOGIC\n            # =========================================================\n            n = len(boxes)\n            if n == 0:\n                return []\n        \n            # 1. Extract crops and prepare data\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:\n                    crop = np.zeros((h, w, 3), dtype=np.uint8)\n                crops_rgb.append(crop)\n        \n            if verbose:\n                print(\"Extracting keypoints and checking intra-box duplicates...\")\n        \n            keypoints_list = []\n            descriptors_list = []\n\n            print('LEN CROPS RGB:', len(crops_rgb))\n        \n            # 2. Iterate through each box to:\n            #    a) Check for Intra-Box duplicates (Splitting method)\n            #    b) Compute whole-box SIFT features for Inter-Box comparison later\n            for i, crop in enumerate(crops_rgb):\n                # A. Check Intra-Box (Self) Duplicates\n                #box_offset = (boxes[i][0], boxes[i][1])\n                #intra_dups = check_intra_box_duplicates(crop, i, box_offset)\n                \n                #if intra_dups:\n                #    duplicates.extend(intra_dups)\n                #    if verbose:\n                #        for d in intra_dups:\n                #            print(f\"  ✓ INTRA-BOX DUPLICATE in Box {i}: {d[2]}\")\n        \n                # B. Prepare for Inter-Box comparison (Standard SIFT on whole crop)\n                kp, desc = extract_keypoints_and_descriptors(crop)\n                keypoints_list.append(kp)\n                descriptors_list.append(desc)\n        \n            # 3. Inter-Box Comparison (Standard logic: Compare Box A vs Box B)\n            print('WHATS GOING ON', n)\n            if n > 1:\n                if verbose:\n                    print(f\"Comparing {n * (n-1) // 2} inter-box pairs...\")\n                \n                for i, j in itertools.combinations(range(n), 2):\n                    # Skip if boxes overlap significantly\n                    iou = compute_iou(boxes[i], boxes[j])\n                    if iou > IOU_THRESH:\n                        continue\n                    \n                    # Match descriptors\n                    matches = match_descriptors(descriptors_list[i], descriptors_list[j])\n                    \n                    if len(matches) < MIN_MATCH_COUNT:\n                        continue\n                    \n                    # Get box offsets for global coordinate conversion\n                    box_i_offset = (boxes[i][0], boxes[i][1])\n                    box_j_offset = (boxes[j][0], boxes[j][1])\n                    \n                    # Geometric validation with bounding boxes\n                    is_dup, num_inliers, H, transform_desc, bbox_i, bbox_j = \\\n                        validate_geometric_transformation_with_bbox(\n                            keypoints_list[i], keypoints_list[j], matches,\n                            box_i_offset, box_j_offset\n                        )\n\n                    area_i = bbox_i[2]*bbox_i[3]\n                    area_j = bbox_j[2]*bbox_j[3]\n                    area_ratio = max(area_i/(area_j+1), area_j/(area_i+1))\n                    if area_ratio >= 1.5:\n                        is_dup = False\n                    \n                    if is_dup:\n                        inlier_ratio = num_inliers / len(matches)\n                        duplicates.append((i, j, transform_desc, num_inliers, inlier_ratio, bbox_i, bbox_j))\n                        if verbose:\n                            print(f\"  ✓ INTER-BOX DUPLICATE: ({i},{j}) - {transform_desc}\")\n\n            # If you found no duplicates, try once more with the entire image.\n            # A. Check Intra-Box (Self) Duplicates\n            box_offset = (0, 0)\n            intra_dups = check_intra_box_duplicates(panel_img_rgb, 999, box_offset)\n            \n            if intra_dups:\n                duplicates.extend(intra_dups)\n                if verbose:\n                    for d in intra_dups:\n                        print(f\"  ✓ INTRA-BOX DUPLICATE in Box {i}: {d[2]}\")\n    \n            return duplicates\n        \n        \n        # ---------------------- VISUALIZATION ----------------------\n        \n        def visualize_matches(img1_rgb: np.ndarray, img2_rgb: np.ndarray,\n                             kp1: List, kp2: List, matches: List[cv2.DMatch],\n                             title: str = \"Keypoint Matches\"):\n            \"\"\"Visualize matched keypoints between two images.\"\"\"\n            import matplotlib.pyplot as plt\n            \n            # Convert keypoints to correct format for drawMatches\n            img_matches = cv2.drawMatches(\n                img1_rgb, kp1, img2_rgb, kp2, matches[:50],  # Show top 50\n                None, flags=cv2.DrawMatchesFlags_NOT_DRAW_SINGLE_POINTS\n            )\n            \n            plt.figure(figsize=(15, 8))\n            plt.imshow(img_matches)\n            plt.title(f\"{title} (showing {min(50, len(matches))} matches)\")\n            plt.axis('off')\n            plt.tight_layout()\n            plt.show()\n        \n        \n        def plot_panel_with_keypoint_duplicates(\n            panel_img_rgb: np.ndarray,\n            boxes: List[Tuple[int, int, int, int]],\n            duplicates: List[Tuple[int, int, str, int, float]]\n        ):\n            \"\"\"Plot panel with detected duplicate boxes highlighted.\"\"\"\n            import matplotlib.pyplot as plt\n            \n            fig, ax = plt.subplots(1, figsize=(12, 12))\n            ax.imshow(panel_img_rgb)\n            ax.axis(\"off\")\n            \n            # Draw all boxes\n            for idx, (x, y, w, h) in enumerate(boxes):\n                rect = plt.Rectangle((x, y), w, h, edgecolor='white', \n                                    facecolor='none', linewidth=1)\n                ax.add_patch(rect)\n                ax.text(x+3, y+15, f\"{idx}\", color='white', fontsize=11,\n                        bbox=dict(facecolor='black', alpha=0.7, pad=2))\n            \n            # Highlight duplicate pairs\n            cmap = plt.get_cmap(\"tab10\")\n            for k, (i, j, transform, n_inliers, ratio) in enumerate(duplicates):\n                color = cmap(k % 10)\n                xi, yi, wi, hi = boxes[i]\n                xj, yj, wj, hj = boxes[j]\n                \n                # Thick colored borders for duplicates\n                ax.add_patch(plt.Rectangle((xi, yi), wi, hi, edgecolor=color,\n                                           facecolor='none', linewidth=3))\n                ax.add_patch(plt.Rectangle((xj, yj), wj, hj, edgecolor=color,\n                                           facecolor='none', linewidth=3))\n                \n                # Connect centers\n                ci = (xi + wi/2, yi + hi/2)\n                cj = (xj + wj/2, yj + hj/2)\n                ax.plot([ci[0], cj[0]], [ci[1], cj[1]], color=color,\n                        linewidth=2, linestyle='--', alpha=0.7)\n                \n                # Label\n                label = f\"Pair {k}\\n{transform}\\n{n_inliers} matches\\n{ratio:.1%}\"\n                mid_x, mid_y = (ci[0] + cj[0]) / 2, (ci[1] + cj[1]) / 2\n                ax.text(mid_x, mid_y, label, color=color, fontsize=9,\n                        bbox=dict(facecolor='black', alpha=0.8, edgecolor=color, pad=3),\n                        ha='center', va='center')\n            \n            plt.title(f\"Detected {len(duplicates)} Copy-Move Forgery Candidates\", \n                     fontsize=14, fontweight='bold')\n            plt.tight_layout()\n            plt.show()\n            plt.close(fig)\n\n        def process_panel_images(test_images: List[str], \n                                 raw_or_panel,  # Your dataframe\n                                 verbose: bool = False,\n                                 visualize: bool = False) -> List[dict]:\n            \"\"\"\n            Process panel images to detect copy-move forgeries.\n            \n            Args:\n                test_images: List of image paths\n                raw_or_panel: DataFrame with 'path' and 'bbox_preds' columns\n                verbose: Print detailed progress\n                visualize: Show visualization plots\n            \n            Returns:\n                List of dicts with keys: 'path', 'duplicates', 'boxes'\n            \"\"\"\n            results = []\n            \n            for img_path in tqdm(test_images, desc=\"Processing panels\"):\n                if verbose:\n                    print(f\"\\n{'='*60}\")\n                    print(f\"Processing: {img_path}\")\n                    print('='*60)\n                \n                # Load image\n                img = cv2.imread(str(img_path))\n                if img is None:\n                    print(f\"Warning: Could not load {img_path}\")\n                    results.append({\"path\": img_path, \"duplicates\": [], \"boxes\": []})\n                    continue\n                    \n                img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n                \n                # Get bounding boxes from your detection model\n                boxes = raw_or_panel.loc[raw_or_panel['path'] == img_path, 'bbox_preds'].iloc[0]\n                \n                # Your existing preprocessing\n                # boxes = trim_uniform_borders_from_boxes(img_rgb, boxes)\n                # boxes = expand_borders_from_boxes(img_rgb, boxes)\n                \n                # Skip if only one box\n                #if len(boxes) <= 1:\n                #    if verbose:\n                #        print(f\"  Only {len(boxes)} box(es) - skipping\")\n                #    results.append({\"path\": img_path, \"duplicates\": [], \"boxes\": boxes})\n                #    continue\n                \n                if verbose:\n                    print(f\"  Found {len(boxes)} sub-images\")\n\n                print(\"NUM BOXES FOUND:\", len(boxes))\n                \n                # Run keypoint-based duplicate detection\n                duplicates = find_duplicates_keypoint_based_v2(\n                    img_rgb, \n                    boxes, \n                    verbose=verbose\n                )\n\n                print(\"NUM DUPLICATES FOUND:\", len(duplicates))\n                \n                if verbose:\n                    print(f\"  Detected {len(duplicates)} duplicate pairs\")\n                    for i, j, transform, n_inliers, ratio, bbox1, bbox2 in duplicates:\n                        print(f\"    - Boxes {i} & {j}: {transform} \"\n                              f\"({n_inliers} matches, {ratio:.1%} inlier ratio)\")\n                \n                # Remove overlapping duplicates if needed\n                # (you might want to adapt this function for the new duplicate format)\n                if len(duplicates) > 0:\n                    prev_len = None\n                    while prev_len != len(boxes):\n                        prev_len = len(boxes)\n                        boxes, duplicates = remove_overlapping_duplicates_v2(\n                            boxes, duplicates, iou_thresh=0.70\n                        )\n                \n                # Visualization\n                if visualize and len(duplicates) > 0:\n                    plot_panel_with_keypoint_duplicates_enhanced_v2(img_rgb, boxes, duplicates)\n                \n                results.append({\n                    \"path\": img_path,\n                    \"duplicates\": duplicates,\n                    \"boxes\": boxes\n                })\n            \n            return results\n        \n        \n        def remove_overlapping_duplicates_v2(\n            boxes: List[Tuple[int, int, int, int]],\n            duplicates: List[Tuple[int, int, str, int, float, Tuple, Tuple]],\n            iou_thresh: float = 0.70\n        ) -> Tuple[List, List]:\n            \"\"\"\n            Remove duplicate pairs where the tight bounding boxes overlap significantly.\n            Updated to handle the new duplicate format with bounding boxes.\n            \n            Args:\n                boxes: Original detection boxes (kept for compatibility, not modified)\n                duplicates: List of (idx_i, idx_j, transform, n_inliers, ratio, bbox_i, bbox_j)\n                iou_thresh: IoU threshold for filtering overlapping tight boxes\n            \n            Returns:\n                Tuple of (boxes, filtered_duplicates)\n            \"\"\"\n            if not duplicates:\n                return boxes, duplicates\n            \n            # Filter duplicates based on overlap of the tight bounding boxes\n            filtered_dups = []\n            for i, j, transform, n_inliers, ratio, bbox_i, bbox_j in duplicates:\n                # Check IoU between the tight bounding boxes (not original panel boxes)\n                iou = compute_iou(bbox_i, bbox_j)\n                if iou < iou_thresh:\n                    filtered_dups.append((i, j, transform, n_inliers, ratio, bbox_i, bbox_j))\n            \n            return boxes, filtered_dups\n        \n        \n        def generate_summary_report(results: List[dict]) -> dict:\n            \"\"\"Generate a summary report of CMF detection results.\"\"\"\n            total_images = len(results)\n            images_with_cmf = sum(1 for r in results if len(r['duplicates']) > 0)\n            total_cmf_pairs = sum(len(r['duplicates']) for r in results)\n            \n            # Analyze transformations\n            transform_counts = {}\n            inlier_counts = []\n            \n            for result in results:\n                for i, j, transform, n_inliers, ratio in result['duplicates']:\n                    # Count transformation types\n                    if transform not in transform_counts:\n                        transform_counts[transform] = 0\n                    transform_counts[transform] += 1\n                    inlier_counts.append(n_inliers)\n            \n            report = {\n                'total_images': total_images,\n                'images_with_cmf': images_with_cmf,\n                'cmf_rate': images_with_cmf / total_images if total_images > 0 else 0,\n                'total_cmf_pairs': total_cmf_pairs,\n                'avg_cmf_per_image': total_cmf_pairs / total_images if total_images > 0 else 0,\n                'transform_distribution': transform_counts,\n                'avg_inliers': np.mean(inlier_counts) if inlier_counts else 0,\n                'median_inliers': np.median(inlier_counts) if inlier_counts else 0\n            }\n            \n            return report\n        \n        \n        def print_summary_report(report: dict):\n            \"\"\"Print a formatted summary report.\"\"\"\n            print(\"\\n\" + \"=\"*70)\n            print(\"COPY-MOVE FORGERY DETECTION SUMMARY\")\n            print(\"=\"*70)\n            print(f\"Total images processed:        {report['total_images']}\")\n            print(f\"Images with CMF detected:      {report['images_with_cmf']} \"\n                  f\"({report['cmf_rate']:.1%})\")\n            print(f\"Total CMF pairs found:         {report['total_cmf_pairs']}\")\n            print(f\"Average CMF pairs per image:   {report['avg_cmf_per_image']:.2f}\")\n            print(f\"Average inlier matches:        {report['avg_inliers']:.1f}\")\n            print(f\"Median inlier matches:         {report['median_inliers']:.1f}\")\n            \n            if report['transform_distribution']:\n                print(\"\\nTransformation Distribution:\")\n                sorted_transforms = sorted(report['transform_distribution'].items(),\n                                          key=lambda x: x[1], reverse=True)\n                for transform, count in sorted_transforms:\n                    print(f\"  {transform:30s} : {count:3d} instances\")\n            \n            print(\"=\"*70 + \"\\n\")\n\n         # Process all images\n        results = process_panel_images(\n            test_images,\n            raw_or_panel,\n            verbose=visualize,   # Set to False for less output\n            visualize=visualize  # Set to False to skip plots\n        )\n\n\n        submission_df_panel = []\n        for r in results:\n            img_name = r['path'].stem\n            if len(r['duplicates']) == 0:\n                rle_string = 'authentic'\n            else:\n                #print(r['duplicates'])\n                #print(len(r['duplicates']))\n                #print(aggregate_duplicates(r['duplicates']))\n                duplicate_groups = aggregate_duplicates(r['duplicates'])\n                #grouped_boxes = [[r['boxes'][idx] for idx in group] for group in duplicate_groups]\n                grouped_boxes = []\n                for group in duplicate_groups:\n                    curr_group = []\n                    for dup in r['duplicates']:\n                        if dup[0] in set(group):\n                            curr_group.append(dup[5]) # box_i\n                        if dup[1] in set(group):\n                            curr_group.append(dup[6]) # box_j\n                    curr_group = list(set(curr_group))\n                    grouped_boxes.append(curr_group)\n\n                img = cv2.imread(r['path'])\n                rle_string = boxes_to_rle_masks(grouped_boxes, image_height=img.shape[0], image_width=img.shape[1])\n                # print(rle_string)\n\n            submission_df_panel.append({\n                'case_id': img_name,\n                'annotation': rle_string,\n                'boxes': r['boxes']\n            })\n\n        submission_df_panel = pd.DataFrame(submission_df_panel)\n            \n    else:\n        submission_df_panel = pd.DataFrame()\n\n    ########################################################################\n    ########################################################################\n    # Handle raw images\n    ########################################################################\n    ########################################################################\n    test_images = raw_or_panel.loc[~raw_or_panel['is_panel'], 'path'].tolist()\n\n    if len(test_images) > 0:\n        all_predictions = []\n        for img_path in test_images:\n            img_name = Path(img_path).stem\n            all_predictions.append({\n                'case_id': img_name,\n                'annotation': \"authentic\",\n                \"boxes\": None,\n            })\n        \n        # Create submission DataFrame\n        submission_df = pd.DataFrame(all_predictions)\n\n    else:\n        submission_df = pd.DataFrame()\n    \n    \n    # Finally, concatenate the two dataframes.\n    submission_df = pd.concat([\n        submission_df_panel,\n        submission_df\n    ], axis=0)\n    \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-10T15:08:45.403345Z","iopub.execute_input":"2026-01-10T15:08:45.403858Z","iopub.status.idle":"2026-01-10T15:08:45.51313Z","shell.execute_reply.started":"2026-01-10T15:08:45.403834Z","shell.execute_reply":"2026-01-10T15:08:45.512401Z"}},"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-10T15:09:00.112688Z","iopub.execute_input":"2026-01-10T15:09:00.113005Z","iopub.status.idle":"2026-01-10T15:09:16.054203Z","shell.execute_reply.started":"2026-01-10T15:09:00.112986Z","shell.execute_reply":"2026-01-10T15:09:16.053493Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"print('DONE!')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:09:16.055384Z","iopub.execute_input":"2026-01-10T15:09:16.055611Z","iopub.status.idle":"2026-01-10T15:09:16.059801Z","shell.execute_reply.started":"2026-01-10T15:09:16.055593Z","shell.execute_reply":"2026-01-10T15:09:16.059076Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"sub = sub.merge(submission_df, on='case_id', how='left', suffixes=('','_cell'))\nreplace_mask = (sub['annotationdetr'] == 'authentic') & (sub['annotation_cell'] != 'authentic')\nsub.loc[replace_mask, 'annotation'] = sub.loc[replace_mask, 'annotation_cell']","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-10T15:09:16.060669Z","iopub.execute_input":"2026-01-10T15:09:16.061259Z","iopub.status.idle":"2026-01-10T15:09:16.08002Z","shell.execute_reply.started":"2026-01-10T15:09:16.061231Z","shell.execute_reply":"2026-01-10T15:09:16.079097Z"}},"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-10T15:09:16.081634Z","iopub.execute_input":"2026-01-10T15:09:16.081888Z","iopub.status.idle":"2026-01-10T15:09:16.098367Z","shell.execute_reply.started":"2026-01-10T15:09:16.081871Z","shell.execute_reply":"2026-01-10T15:09:16.0977Z"}},"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}]}