{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"gpu","dataSources":[{"sourceId":113558,"databundleVersionId":14878066,"sourceType":"competition"},{"sourceId":291333284,"sourceType":"kernelVersion"},{"sourceId":292066567,"sourceType":"kernelVersion"},{"sourceId":90860,"sourceType":"modelInstanceVersion","modelInstanceId":76172,"modelId":100857}],"dockerImageVersionId":31192,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# Scientific Image Forgery Detection: SAM + LPIPS Self-Similarity\n\n**Competition:** [ReCoDAI-LUC Scientific Image Forgery Detection](https://www.kaggle.com/competitions/recodai-luc-scientific-image-forgery-detection)\n\n## Approach\nCopy-move forgery detection via object-level similarity matching:\n1. **Segment** - Extract candidate objects using SAM (SAM2 or SAM1)\n2. **Filter** - Keep meaningful objects by size (% of image) and aspect ratio\n3. **Match** - Compute pairwise LPIPS perceptual similarity\n4. **Threshold** - Flag pairs with LPIPS < threshold as forgeries\n5. **Submit** - Generate RLE-encoded masks in competition format\n\n## Submission Format\n- Columns: `case_id`, `annotation`\n- RLE: **Column-major** order, Python list format `[start, len, start, len, ...]`\n- No forgery: `authentic` (unquoted keyword)\n- Forgery: `\"[start, len, ...]\"` (quoted list repr)","metadata":{"_uuid":"53f26b4c-13a9-4b26-93f2-16437085fe6c","_cell_guid":"18a6c74b-5646-40a9-9f95-c67ee7db44d5","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# === OFFLINE SETUP (run first) ===\nimport os\nos.environ['TORCH_HOME'] = '/kaggle/input/forgery-sam2-object-similarity-validation/torch-home'\n\n# Install SAM2 from local packages\n!pip install -q sam_2 \"torch<2.9.0\" \"torchvision<0.24.0\" --no-index -f \"/kaggle/input/forgery-using-sam2-for-candidate-generation/packages/\" #\n!pip uninstall -y tensorflow","metadata":{"_uuid":"d45be6cf-7c5b-41ee-8bf7-6a81ffefa216","_cell_guid":"c63d26b0-37f0-4d58-ba10-abc899db0e5a","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-15T20:09:40.593299Z","iopub.execute_input":"2026-01-15T20:09:40.594093Z","iopub.status.idle":"2026-01-15T20:09:45.733140Z","shell.execute_reply.started":"2026-01-15T20:09:40.594066Z","shell.execute_reply":"2026-01-15T20:09:45.732070Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import warnings\nfrom pathlib import Path\nfrom typing import Optional\n\nimport numpy as np\nimport pandas as pd\nimport torch\nfrom PIL import Image\nfrom tqdm.auto import tqdm\nimport matplotlib.pyplot as plt\n\nwarnings.filterwarnings(\"ignore\")","metadata":{"_uuid":"b733c2ee-81aa-4c94-8232-db596260e5dc","_cell_guid":"bbbd5ad9-50ee-4668-b745-7866e246a869","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-15T20:09:45.734898Z","iopub.execute_input":"2026-01-15T20:09:45.735204Z","iopub.status.idle":"2026-01-15T20:09:45.740762Z","shell.execute_reply.started":"2026-01-15T20:09:45.735171Z","shell.execute_reply":"2026-01-15T20:09:45.739986Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Configuration\nAll parameters as global constants. Functions/classes use these as default arguments.","metadata":{"_uuid":"1005bcd2-5998-406b-9d99-a406bde0f69f","_cell_guid":"c0b2c56a-efeb-468d-b669-886253585f78","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"# === PATHS ===\nDATA_DIR = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection\"\nOUTPUT_DIR = \"/kaggle/working\"\n\n# SAM2 checkpoint (Kaggle model format)\nSAM_CHECKPOINT = \"/kaggle/input/segment-anything-2/pytorch/sam2-hiera-base-plus/1/sam2_hiera_base_plus.pt\"\nSAM_MODEL_CONFIG = \"sam2_hiera_b+.yaml\"\n\n# === OBJECT FILTERING (percentage of image area) ===\nMIN_OBJECT_PCT = 0.001        # 0.1% of image area minimum\nMAX_OBJECT_PCT = 0.25         # 25% of image area maximum\nMIN_ASPECT_RATIO = 0.1        # Skip extreme aspect ratios\nMAX_ASPECT_RATIO = 10.0\n\n# === SIMILARITY MATCHING ===\nLPIPS_THRESHOLD = 0.02        # LPIPS < 0.02 ≈ similarity > 0.98\nCROP_SIZE = 128               # Normalized crop size for LPIPS\nBATCH_SIZE = 16               # Batch size for LPIPS computation\nLPIPS_NET = \"alex\"            # Backbone: 'alex' (fast), 'vgg', 'squeeze'\n\n# === RUNTIME ===\nDEVICE = \"cuda\" if torch.cuda.is_available() else \"cpu\"\nVISUALIZE = True\nMAX_VIZ_IMAGES = 5\n\nprint(f\"Using device: {DEVICE}\")","metadata":{"_uuid":"fdda132c-3223-4685-9bd6-df4388aebc62","_cell_guid":"c3b8b767-a783-4bb2-a17f-c93a99c872a3","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-15T20:09:45.741601Z","iopub.execute_input":"2026-01-15T20:09:45.741884Z","iopub.status.idle":"2026-01-15T20:09:45.755620Z","shell.execute_reply.started":"2026-01-15T20:09:45.741867Z","shell.execute_reply":"2026-01-15T20:09:45.755044Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## RLE Encoding/Decoding\n\n**Competition format:**\n- Column-major order (Fortran-style): `mask.T.flatten()`\n- 1-indexed positions\n- Python list format: `[start1, length1, start2, length2, ...]`","metadata":{"_uuid":"f578379d-c8c6-4083-ac71-702ffecba59f","_cell_guid":"d77fec1f-34e1-402e-bbab-68484d1054da","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def rle_encode(mask: np.ndarray, fg_val: int = 1) -> list:\n    \"\"\"\n    Encode binary mask to RLE list in competition format.\n    \n    Format: Column-major (Fortran) order, 1-indexed.\n    \n    Args:\n        mask: Binary mask (H, W) with values 0/1\n        fg_val: Foreground value to encode\n        \n    Returns:\n        List of integers [start1, len1, start2, len2, ...] or empty list\n    \"\"\"\n    if mask is None:\n        return []\n        \n    dots = np.where(mask.T.flatten() == fg_val)[0]\n    if len(dots) == 0:\n        return []\n    \n    run_lengths = []\n    prev = -2\n    \n    for b in dots:\n        if b > prev + 1:\n            run_lengths.extend((b + 1, 0))  # 1-indexed start\n        run_lengths[-1] += 1\n        prev = b\n    \n    return run_lengths\n\n\ndef rle_decode(rle_list: list, shape: tuple) -> np.ndarray:\n    \"\"\"Decode RLE list to binary mask.\"\"\"\n    h, w = shape\n    mask_flat = np.zeros(h * w, dtype=np.uint8)\n    \n    if not rle_list:\n        return mask_flat.reshape(shape)\n    \n    for i in range(0, len(rle_list), 2):\n        start = rle_list[i] - 1\n        length = rle_list[i + 1]\n        mask_flat[start:start + length] = 1\n    \n    return mask_flat.reshape((w, h)).T\n\n\ndef format_annotation(rle_list: list) -> str:\n    \"\"\"Format RLE for submission: 'authentic' or quoted list repr.\"\"\"\n    if not rle_list:\n        return 'authentic'\n    return f'\"{repr(rle_list)}\"'","metadata":{"_uuid":"d4c0f9b8-60b5-465d-af03-1e20d07a28e3","_cell_guid":"f80241fc-7e95-41e4-a5be-4cde8c64ed6d","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-15T20:09:45.757238Z","iopub.execute_input":"2026-01-15T20:09:45.757509Z","iopub.status.idle":"2026-01-15T20:09:45.771169Z","shell.execute_reply.started":"2026-01-15T20:09:45.757493Z","shell.execute_reply":"2026-01-15T20:09:45.770386Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## SAM Segmentation Wrapper\n\nSupports both SAM2 and SAM1 with automatic GPU compatibility detection.\n- SAM2 requires compute capability ≥ 7.0 (V100, T4, RTX)\n- SAM1 works on older GPUs (P100, K80)\n- Falls back to CPU if GPU incompatible","metadata":{"_uuid":"5f8697fd-6a5b-4761-a0cb-c9398d433741","_cell_guid":"5bcdbb66-aa9c-477f-8f92-2a7fc78dc4b8","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"class SAMSegmenter:\n    \"\"\"\n    SAM-based object segmentation (SAM2 or SAM1).\n    \n    Handles GPU compatibility:\n    - SAM2: requires CC ≥ 7.0, falls back to CPU if needed\n    - SAM1: wider GPU support, use if SAM2 unavailable\n    \"\"\"\n    \n    # SAM parameters as class attributes\n    POINTS_PER_SIDE = 32\n    PRED_IOU_THRESH = 0.86\n    STABILITY_SCORE_THRESH = 0.92\n    MIN_MASK_REGION_AREA = 100\n    \n    def __init__(\n        self,\n        checkpoint_path: str = SAM_CHECKPOINT,\n        device: str = DEVICE,\n        points_per_side: int = None,\n        pred_iou_thresh: float = None,\n        stability_score_thresh: float = None,\n        min_mask_region_area: int = None,\n    ):\n        self.checkpoint_path = checkpoint_path\n        self.requested_device = device\n        self.device = device\n        self.points_per_side = points_per_side or self.POINTS_PER_SIDE\n        self.pred_iou_thresh = pred_iou_thresh or self.PRED_IOU_THRESH\n        self.stability_score_thresh = stability_score_thresh or self.STABILITY_SCORE_THRESH\n        self.min_mask_region_area = min_mask_region_area or self.MIN_MASK_REGION_AREA\n        self.mask_generator = self._try_load_sam2()\n    \n    def _try_load_sam2(self) -> Optional[object]:\n        \"\"\"Attempt to load SAM2.\"\"\"\n        if not os.path.exists(self.checkpoint_path):\n            return None\n        \n        try:\n            from sam2.build_sam import build_sam2\n            from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator\n            \n            sam2_model = build_sam2(SAM_MODEL_CONFIG, self.checkpoint_path, device=self.device)\n            mask_generator = SAM2AutomaticMaskGenerator(\n                model=sam2_model,\n                points_per_side=self.points_per_side,\n                pred_iou_thresh=self.pred_iou_thresh,\n                stability_score_thresh=self.stability_score_thresh,\n                min_mask_region_area=self.min_mask_region_area,\n            )\n            print(f\"✓ SAM2 loaded on {self.device.upper()}\")\n            return mask_generator\n        except Exception as e:\n            err = str(e)\n            if \"no kernel image\" in err or \"cudaErrorNoKernelImageForDevice\" in err:\n                print(f\"⚠ SAM2 incompatible with GPU kernel:\\n{e}\")\n            else:\n                print(f\"⚠ SAM2 failed on {device}: {type(e).__name__}\")\n            return None\n    \n    @staticmethod\n    def filter_objects(\n        candidates: list[tuple],\n        image_area: int,\n        min_pct: float = MIN_OBJECT_PCT,\n        max_pct: float = MAX_OBJECT_PCT,\n        min_aspect: float = MIN_ASPECT_RATIO,\n        max_aspect: float = MAX_ASPECT_RATIO,\n    ) -> list[dict]:\n        \"\"\"Filter objects by percentage of image area and aspect ratio.\"\"\"\n        min_area = int(image_area * min_pct)\n        max_area = int(image_area * max_pct)\n        \n        objects = []\n        for mask, bbox, area in candidates:\n            if area < min_area or area > max_area:\n                continue\n            \n            # Convert bbox to int (SAM2 returns floats)\n            x, y, w, h = [int(v) for v in bbox]\n            if h == 0 or w == 0:\n                continue\n            aspect = w / h\n            if aspect < min_aspect or aspect > max_aspect:\n                continue\n            \n            objects.append({\n                \"mask\": mask.astype(np.uint8),\n                \"bbox\": [x, y, w, h],  # Store as int\n                \"area\": area\n            })\n        \n        return objects\n    \n    def extract(\n        self,\n        image: np.ndarray,\n        min_pct: float = MIN_OBJECT_PCT,\n        max_pct: float = MAX_OBJECT_PCT,\n        min_aspect: float = MIN_ASPECT_RATIO,\n        max_aspect: float = MAX_ASPECT_RATIO,\n    ) -> list[dict]:\n        \"\"\"\n        Extract filtered objects from image.\n        \n        Returns:\n            List of dicts: {mask, bbox, area}\n        \"\"\"\n        h, w = image.shape[:2]\n        image_area = h * w\n        \n        # Run in inference mode and with mixed precision (float16) to drastically reduce memory usage\n        with torch.inference_mode(), torch.autocast(\"cuda\", dtype=torch.float16):\n            try:\n                masks = self.mask_generator.generate(image)\n            except Exception:  # OutOfMemoryError\n                print(f\"image with dim {image.shape} is too large <- '{img_path}'\")\n                torch.cuda.empty_cache()\n                return None\n\n        candidates = [(m[\"segmentation\"], m[\"bbox\"], m[\"area\"]) for m in masks]\n        return self.filter_objects(\n            candidates, image_area,\n            min_pct=min_pct, max_pct=max_pct,\n            min_aspect=min_aspect, max_aspect=max_aspect,\n        )","metadata":{"_uuid":"ae929dc9-13a2-47ef-8058-04226e369841","_cell_guid":"9f34245e-73d4-4353-aa73-2c1dd685aa81","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-15T20:09:45.772153Z","iopub.execute_input":"2026-01-15T20:09:45.772508Z","iopub.status.idle":"2026-01-15T20:09:45.790390Z","shell.execute_reply.started":"2026-01-15T20:09:45.772485Z","shell.execute_reply":"2026-01-15T20:09:45.789723Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## LPIPS Similarity Matcher\n\nWraps torchmetrics LPIPS for pairwise object similarity.\nLower LPIPS = more perceptually similar.","metadata":{"_uuid":"5487b66e-ab3a-4394-a3d2-06821f991cbc","_cell_guid":"4e1fc4d3-da1b-4b31-8801-f5fb5a78bcec","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"class LPIPSMatcher:\n    \"\"\"LPIPS-based perceptual similarity matcher using torchmetrics.\"\"\"\n    \n    def __init__(\n        self,\n        net_type: str = LPIPS_NET,\n        crop_size: int = CROP_SIZE,\n        batch_size: int = BATCH_SIZE,\n        device: str = DEVICE,\n    ):\n        self.net_type = net_type\n        self.crop_size = crop_size\n        self.batch_size = batch_size\n        self.device = device\n        self.model = self._load_model()\n    \n    def _load_model(self):\n        \"\"\"Load torchmetrics LPIPS model.\"\"\"\n        from torchmetrics.image.lpip import LearnedPerceptualImagePatchSimilarity\n        \n        model = LearnedPerceptualImagePatchSimilarity(\n            net_type=self.net_type,\n            reduction='none',\n            normalize=False,\n        ).to(self.device)\n        model.eval()\n        print(f\"✓ LPIPS loaded ({self.net_type})\")\n        return model\n    \n    def extract_crop(\n        self,\n        image: np.ndarray,\n        mask: np.ndarray,\n        bbox: list,\n    ) -> torch.Tensor:\n        \"\"\"Extract normalized object crop with background masking.\"\"\"\n        # Convert bbox to int (SAM2 returns floats)\n        x, y, w, h = [int(v) for v in bbox]\n        \n        # Ensure valid crop dimensions\n        if w <= 0 or h <= 0:\n            # Return gray placeholder\n            tensor = torch.full((1, 3, self.crop_size, self.crop_size), 0.0)\n            return tensor\n        \n        crop = image[y:y+h, x:x+w].astype(np.float32)\n        mask_crop = mask[y:y+h, x:x+w]\n        \n        # Mask background to gray\n        mask_3d = np.stack([mask_crop] * 3, axis=-1)\n        crop = crop * mask_3d + 128.0 * (1 - mask_3d)\n        \n        # Resize with aspect ratio preservation\n        crop_pil = Image.fromarray(crop.astype(np.uint8))\n        aspect = w / h if h > 0 else 1.0\n        if aspect > 1:\n            new_w, new_h = self.crop_size, max(1, int(self.crop_size / aspect))\n        else:\n            new_w, new_h = max(1, int(self.crop_size * aspect)), self.crop_size\n        crop_pil = crop_pil.resize((new_w, new_h), Image.BILINEAR)\n        \n        # Pad to square\n        padded = Image.new(\"RGB\", (self.crop_size, self.crop_size), (128, 128, 128))\n        offset = ((self.crop_size - new_w) // 2, (self.crop_size - new_h) // 2)\n        padded.paste(crop_pil, offset)\n        \n        # Normalize to [-1, 1]\n        tensor = torch.from_numpy(np.array(padded)).float()\n        tensor = tensor.permute(2, 0, 1) / 127.5 - 1.0\n        return tensor.unsqueeze(0)\n    \n    @torch.no_grad()\n    def compute_distance_matrix(\n        self,\n        image: np.ndarray,\n        objects: list[dict],\n    ) -> np.ndarray:\n        \"\"\"Compute pairwise LPIPS distances between all objects.\"\"\"\n        n = len(objects)\n        if n < 2:\n            return np.ones((n, n))\n        \n        crops = torch.cat([\n            self.extract_crop(image, obj[\"mask\"], obj[\"bbox\"])\n            for obj in objects\n        ], dim=0).to(self.device)\n        \n        dist_matrix = np.zeros((n, n))\n        pairs = [(i, j) for i in range(n) for j in range(i + 1, n)]\n        \n        for batch_start in range(0, len(pairs), self.batch_size):\n            batch_pairs = pairs[batch_start:batch_start + self.batch_size]\n            idx_i = [p[0] for p in batch_pairs]\n            idx_j = [p[1] for p in batch_pairs]\n            \n            dists = self.model(crops[idx_i], crops[idx_j]).cpu().numpy().flatten()\n            \n            for k, (i, j) in enumerate(batch_pairs):\n                dist_matrix[i, j] = dists[k]\n                dist_matrix[j, i] = dists[k]\n        \n        return dist_matrix\n    \n    def find_matches(\n        self,\n        dist_matrix: np.ndarray,\n        threshold: float = LPIPS_THRESHOLD,\n    ) -> tuple[list[tuple], set[int]]:\n        \"\"\"Find object pairs below distance threshold.\"\"\"\n        n = dist_matrix.shape[0]\n        matched_pairs = []\n        matched_indices = set()\n        \n        for i in range(n):\n            for j in range(i + 1, n):\n                if dist_matrix[i, j] < threshold:\n                    matched_pairs.append((i, j, dist_matrix[i, j]))\n                    matched_indices.update([i, j])\n        \n        return matched_pairs, matched_indices","metadata":{"_uuid":"7f729a7c-97f7-44af-a506-a7b237f46992","_cell_guid":"43c3fa5d-4421-42b0-9a7f-48a96d985f10","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-15T20:09:45.791134Z","iopub.execute_input":"2026-01-15T20:09:45.791397Z","iopub.status.idle":"2026-01-15T20:09:45.809882Z","shell.execute_reply.started":"2026-01-15T20:09:45.791373Z","shell.execute_reply":"2026-01-15T20:09:45.808924Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Detection Pipeline","metadata":{"_uuid":"16413590-902b-46bf-ad11-d804169052ef","_cell_guid":"7eba96b3-a531-4317-8de3-aa4c9d308f50","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def detect_forgery(\n    image: np.ndarray,\n    segmenter: SAMSegmenter,\n    matcher: LPIPSMatcher,\n    threshold: float = LPIPS_THRESHOLD,\n    min_pct: float = MIN_OBJECT_PCT,\n    max_pct: float = MAX_OBJECT_PCT,\n) -> tuple[np.ndarray, list[tuple], list[dict]]:\n    \"\"\"\n    Detect copy-move forgery in a single image.\n    \n    Returns:\n        forgery_mask, matched_pairs, objects\n    \"\"\"\n    h, w = image.shape[:2]\n    \n    objects = segmenter.extract(image, min_pct=min_pct, max_pct=max_pct)\n    if not objects or len(objects) < 2:\n        return np.zeros((h, w), dtype=np.uint8), [], objects\n    \n    dist_matrix = matcher.compute_distance_matrix(image, objects)\n    matched_pairs, matched_indices = matcher.find_matches(dist_matrix, threshold)\n    \n    forgery_mask = np.zeros((h, w), dtype=np.uint8)\n    for idx in matched_indices:\n        forgery_mask = np.maximum(forgery_mask, objects[idx][\"mask\"])\n    \n    return forgery_mask, matched_pairs, objects\n\n\ndef visualize_detection(\n    image: np.ndarray,\n    forgery_mask: np.ndarray,\n    objects: list[dict],\n    matched_pairs: list[tuple],\n    title: str = \"\",\n) -> plt.Figure:\n    \"\"\"Visualize detection results.\"\"\"\n    fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n    \n    axes[0].imshow(image)\n    axes[0].set_title(f\"Original ({len(objects)} objects)\")\n    axes[0].axis(\"off\")\n    \n    obj_viz = image.copy().astype(np.float32)\n    colors = plt.cm.tab20(np.linspace(0, 1, 20))\n    for i, obj in enumerate(objects):\n        c = (np.array(colors[i % 20][:3]) * 255)\n        m = np.stack([obj[\"mask\"]] * 3, axis=-1)\n        obj_viz = obj_viz * (1 - m * 0.4) + m * 0.4 * c\n    axes[1].imshow(obj_viz.astype(np.uint8))\n    axes[1].set_title(\"Detected Objects\")\n    axes[1].axis(\"off\")\n    \n    forg_viz = image.copy().astype(np.float32)\n    if forgery_mask is not None and forgery_mask.sum() > 0:\n        m = np.stack([forgery_mask] * 3, axis=-1)\n        forg_viz = forg_viz * (1 - m * 0.5) + m * 0.5 * np.array([255, 0, 0])\n    axes[2].imshow(forg_viz.astype(np.uint8))\n    axes[2].set_title(f\"Forgery ({len(matched_pairs)} matches)\")\n    axes[2].axis(\"off\")\n    \n    if title:\n        fig.suptitle(title)\n    plt.tight_layout()\n    return fig","metadata":{"_uuid":"32b5bab1-2073-4ed6-8741-f49e17c413ce","_cell_guid":"320202c0-354c-4d30-96a0-eae91ab714d5","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-15T20:09:45.810761Z","iopub.execute_input":"2026-01-15T20:09:45.811089Z","iopub.status.idle":"2026-01-15T20:09:45.828511Z","shell.execute_reply.started":"2026-01-15T20:09:45.811068Z","shell.execute_reply":"2026-01-15T20:09:45.827768Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Submission","metadata":{"_uuid":"edc101ad-cc3f-459b-a9cd-4cfee7814606","_cell_guid":"e25e5828-a2f9-438a-9750-275844de919c","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def write_submission_csv(\n    submissions: list[dict],\n    output_path: str = os.path.join(OUTPUT_DIR, \"submission.csv\"),\n) -> None:\n    \"\"\"Write submission CSV in competition format.\"\"\"\n    lines = [\"case_id,annotation\\n\"]\n    for row in submissions:\n        lines.append(f\"{row['case_id']},{row['annotation']}\\n\")\n    \n    with open(output_path, 'w') as f:\n        f.writelines(lines)\n    print(f\"✓ Submission saved: {output_path}\")\n\n\ndef create_submission(\n    case_ids: list[str],\n    masks: list[np.ndarray],\n    output_path: str = os.path.join(OUTPUT_DIR, \"submission.csv\"),\n) -> pd.DataFrame:\n    \"\"\"Create submission from case IDs and masks.\"\"\"\n    submissions = []\n    for case_id, mask in zip(case_ids, masks):\n        rle = rle_encode(mask)\n        annotation = format_annotation(rle)\n        submissions.append({'case_id': case_id, 'annotation': annotation})\n    \n    write_submission_csv(submissions, output_path)\n    return pd.DataFrame(submissions)","metadata":{"_uuid":"b0e23577-03ce-4fbe-84b6-c4371091daed","_cell_guid":"7dc241c7-21e6-471e-97cf-df0da236f579","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-15T20:09:45.829245Z","iopub.execute_input":"2026-01-15T20:09:45.829450Z","iopub.status.idle":"2026-01-15T20:09:45.846277Z","shell.execute_reply.started":"2026-01-15T20:09:45.829436Z","shell.execute_reply":"2026-01-15T20:09:45.845579Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Main","metadata":{"_uuid":"dd927755-3096-4e60-b533-5a2f344c9437","_cell_guid":"e4c9cee9-f35e-4291-a461-a33b13a66b21","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def main(\n    data_dir: str = DATA_DIR,\n    output_dir: str = OUTPUT_DIR,\n    sam_checkpoint: str = SAM_CHECKPOINT,\n    visualize: bool = VISUALIZE,\n    max_viz: int = MAX_VIZ_IMAGES,\n    max_images: int = None,\n    threshold: float = LPIPS_THRESHOLD,\n    min_pct: float = MIN_OBJECT_PCT,\n    max_pct: float = MAX_OBJECT_PCT,\n):\n    \"\"\"Run full pipeline on test images.\"\"\"\n    test_dir = Path(data_dir) / \"test_images\"\n    if not test_dir.exists():\n        test_dir = Path(data_dir) / \"test\"\n    if not test_dir.exists():\n        test_dir = Path(data_dir)\n    \n    Path(output_dir).mkdir(parents=True, exist_ok=True)\n    \n    test_images = sorted(test_dir.glob(\"*.png\"), key=lambda p: p.stem)\n    if max_images:\n        test_images = test_images[:max_images]\n    print(f\"Found {len(test_images)} test images\")\n    \n    print(\"\\n=== Loading Models ===\")\n    segmenter = SAMSegmenter(checkpoint_path=sam_checkpoint)\n    matcher = LPIPSMatcher()\n    \n    print(\"\\n=== Processing Images ===\")\n    case_ids, masks = [], []\n    stats = {\"total\": 0, \"with_matches\": 0, \"total_matches\": 0}\n    \n    for idx, img_path in enumerate(tqdm(test_images, desc=\"Detecting\")):\n        image = np.array(Image.open(img_path).convert(\"RGB\"))\n        case_id = img_path.stem\n\n        try:\n            mask, pairs, objects = detect_forgery(\n                image, segmenter, matcher,\n                threshold=threshold, min_pct=min_pct, max_pct=max_pct,\n            )\n        except Exception as ex:\n            print(ex)\n            mask, pairs, objects = None, [], []\n        \n        case_ids.append(case_id)\n        masks.append(mask)\n        \n        stats[\"total\"] += 1\n        if pairs:\n            stats[\"with_matches\"] += 1\n            stats[\"total_matches\"] += len(pairs)\n        \n        if visualize and idx < max_viz:\n            fig = visualize_detection(image, mask, objects, pairs, title=case_id)\n            fig.savefig(Path(output_dir) / f\"viz_{case_id}.png\", dpi=100, bbox_inches=\"tight\")\n            plt.close(fig)\n    \n    print(\"\\n=== Creating Submission ===\")\n    submission = create_submission(case_ids, masks, f\"{output_dir}/submission.csv\")\n    \n    print(\"\\n\" + \"=\" * 50)\n    print(\"SUMMARY\")\n    print(\"=\" * 50)\n    print(f\"Images processed:     {stats['total']}\")\n    print(f\"Images with matches:  {stats['with_matches']}\")\n    print(f\"Total matched pairs:  {stats['total_matches']}\")\n    print(f\"Authentic images:     {stats['total'] - stats['with_matches']}\")\n    \n    return submission","metadata":{"_uuid":"7650b811-8d2d-4aa0-a19d-377061f8970f","_cell_guid":"d39f887e-37d3-4e29-9bb3-4fc04e8219f6","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-15T20:09:45.847841Z","iopub.execute_input":"2026-01-15T20:09:45.848126Z","iopub.status.idle":"2026-01-15T20:09:45.861966Z","shell.execute_reply.started":"2026-01-15T20:09:45.848110Z","shell.execute_reply":"2026-01-15T20:09:45.861057Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"main(visualize=True)","metadata":{"_uuid":"2ee5314c-cee1-459c-8dd7-e8594aa3edef","_cell_guid":"4b34f10f-09e6-496c-b080-a7c0a8557377","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-15T20:09:45.863749Z","iopub.execute_input":"2026-01-15T20:09:45.864025Z","iopub.status.idle":"2026-01-15T20:09:56.656363Z","shell.execute_reply.started":"2026-01-15T20:09:45.864008Z","shell.execute_reply":"2026-01-15T20:09:56.655731Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"!head submission.csv","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-15T20:09:56.657109Z","iopub.execute_input":"2026-01-15T20:09:56.657439Z","iopub.status.idle":"2026-01-15T20:09:56.833637Z","shell.execute_reply.started":"2026-01-15T20:09:56.657419Z","shell.execute_reply":"2026-01-15T20:09:56.832801Z"}},"outputs":[],"execution_count":null}]}