{"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":"none","dataSources":[{"sourceId":113558,"databundleVersionId":14878066,"sourceType":"competition"},{"sourceId":287688594,"sourceType":"kernelVersion"},{"sourceId":90860,"sourceType":"modelInstanceVersion","modelInstanceId":76172,"modelId":100857}],"dockerImageVersionId":31192,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# 🔬 Scientific Image Forgery Detection\n\n### Key Learning:\nMany metrics **saturate** when objects are naturally similar (same product type).\nCopy-move detection requires metrics that can find **exact duplicates**\namong objects that are already **categorically similar**.\n\n### What Works (Empirically Verified):\n- **LPIPS** (0.576 mean, 0.986 max) - Correctly identified 6→4 ✓\n- **SIFT** (0.574 mean, 0.969 max) - Good discrimination\n\n### What Saturates (Removed):\n- DCT, Histogram, LBP, Chamfer, CLIP - All ~1.0 for similar objects\n\n### New Strategy:\nFocus on metrics that detect **exact pixel-level duplication**:\n1. **LPIPS** - Perceptual features (KEEP - best performer)\n2. **SIFT** - Keypoint matching (KEEP - good performer)  \n3. **Pixel MSE** - Direct pixel comparison (new)\n4. **Gradient Correlation** - Edge pattern matching (new)\n5. **FFT Magnitude** - Frequency fingerprint (new)\n6. **SSIM on Masked Pixels** - Structural on actual content (fixed)","metadata":{"_uuid":"67977215-d8a0-4d72-bc77-8d597a590c64","_cell_guid":"da7eaadd-7b5a-44f8-bcb9-1601ac528d4b","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"!pip install -q sam_2 --no-index -f \"/kaggle/input/forgery-using-sam2-for-candidate-generation/packages/\"\n!pip uninstall -y tensorflow","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-01-11T20:08:57.865012Z","iopub.execute_input":"2026-01-11T20:08:57.865316Z","iopub.status.idle":"2026-01-11T20:12:49.862186Z","shell.execute_reply.started":"2026-01-11T20:08:57.865283Z","shell.execute_reply":"2026-01-11T20:12:49.860888Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 📦 Imports","metadata":{"_uuid":"1d9b9028-36a3-4f41-ab8c-054f1f458479","_cell_guid":"4d522fa5-10c6-488a-9e00-ffae28cc8096","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"import os\nimport warnings\nfrom pathlib import Path\nfrom typing import Dict, List, Tuple\nfrom dataclasses import dataclass\n\nimport numpy as np\nimport pandas as pd\nimport cv2\nimport matplotlib.pyplot as plt\nfrom PIL import Image\nfrom tqdm.auto import tqdm\n\nimport torch\nimport torch.nn as nn\nfrom torchmetrics.image.lpip import LearnedPerceptualImagePatchSimilarity\n\nfrom sam2.build_sam import build_sam2\nfrom sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator\n\nwarnings.filterwarnings('ignore')","metadata":{"_uuid":"cded3813-2d2a-4fcb-971c-0f89e4304fd7","_cell_guid":"de4647d8-8054-4f23-b0f4-2f39c083b4a2","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-11T20:12:49.865294Z","iopub.execute_input":"2026-01-11T20:12:49.865642Z","iopub.status.idle":"2026-01-11T20:13:05.052488Z","shell.execute_reply.started":"2026-01-11T20:12:49.865607Z","shell.execute_reply":"2026-01-11T20:13:05.051193Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## ⚙️ Configuration","metadata":{"_uuid":"6b3bb1f2-41c9-46e7-9615-eee16c2a6b36","_cell_guid":"c1a6f1d7-8f9e-407c-8748-540631327c78","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"!mkdir torch-home\nos.environ['TORCH_HOME'] = '/kaggle/working/torch-home'\n\n# =============================================================================\n# 📁 PATHS\n# =============================================================================\nIMAGE_PATH = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection/train_images/forged/59969.png\"\nOUTPUT_DIR = \"/kaggle/working/similarity_results\"\n\n# =============================================================================\n# 🎯 SAM2 MODEL\n# =============================================================================\nSAM2_CHECKPOINT = \"/kaggle/input/segment-anything-2/pytorch/sam2-hiera-base-plus/1/sam2_hiera_base_plus.pt\"\nSAM2_CONFIG = \"sam2_hiera_b+.yaml\"\n\n# =============================================================================\n# 🎯 SAM2 PARAMETERS\n# =============================================================================\nSAM2_POINTS_PER_SIDE = 32\nSAM2_POINTS_PER_BATCH = 64\nSAM2_PRED_IOU_THRESH = 0.8\nSAM2_STABILITY_SCORE_THRESH = 0.95\nSAM2_STABILITY_SCORE_OFFSET = 1.0\nSAM2_MASK_THRESHOLD = 0.0\nSAM2_BOX_NMS_THRESH = 0.7\nSAM2_CROP_N_LAYERS = 0\nSAM2_CROP_NMS_THRESH = 0.7\nSAM2_CROP_OVERLAP_RATIO = 512 / 1500\nSAM2_CROP_N_POINTS_DOWNSCALE_FACTOR = 1\nSAM2_MIN_MASK_REGION_AREA = 0\nSAM2_OUTPUT_MODE = \"binary_mask\"\nSAM2_USE_M2M = False\nSAM2_MULTIMASK_OUTPUT = True\n\n# =============================================================================\n# 📐 OBJECT EXTRACTION  \n# =============================================================================\nMIN_OBJECT_AREA_RATIO = 0.01\nMAX_OBJECT_AREA_RATIO = 0.50\nCROP_PADDING = 10\n\n# =============================================================================\n# 🖥️ DEVICE\n# =============================================================================\nDEVICE = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n\n# =============================================================================\n# 📊 DISCRIMINATIVE METRICS ONLY\n# =============================================================================\n# Only metrics that showed good discrimination in testing\nALL_METRICS = [\n    'LPIPS_alex',       # Best performer - perceptual features\n    'LPIPS_vgg',        # Best performer - perceptual features\n    'LPIPS_squeeze',    # Best performer - perceptual features\n    'SIFT',         # Good performer - keypoint matching\n    'Pixel_MSE',    # Direct pixel comparison\n    'Gradient',     # Edge pattern correlation\n    'FFT_Mag',      # Frequency fingerprint\n    'SSIM_Masked',  # SSIM on object pixels only\n]\n\n# Weights heavily favor proven performers\nMETRIC_WEIGHTS = {\n    'LPIPS_alex': 0.30,       # Best empirical performer\n    'SIFT': 0.25,        # Second best\n    'Pixel_MSE': 0.15,   # Direct comparison\n    'Gradient': 0.10,    # Edge patterns\n    'FFT_Mag': 0.10,     # Frequency domain\n    'SSIM_Masked': 0.10, # Structure on masked\n}\n\nPath(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)\nprint(f\"🔧 Device: {DEVICE}\")","metadata":{"_uuid":"94e793f9-8a5b-45ba-a6d9-7b796ec08815","_cell_guid":"57d64b3e-df2c-47be-bbae-783af50762c6","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-11T20:13:05.053610Z","iopub.execute_input":"2026-01-11T20:13:05.054166Z","iopub.status.idle":"2026-01-11T20:13:05.218692Z","shell.execute_reply.started":"2026-01-11T20:13:05.054141Z","shell.execute_reply":"2026-01-11T20:13:05.217492Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 📐 Similarity Functions","metadata":{"_uuid":"6ead6635-b046-41a3-b531-ac73e9c4d78c","_cell_guid":"f8937c8c-f9e4-4df5-bc88-b55515cccdcd","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def gaussian_similarity(distance: float, sigma: float = 1.0) -> float:\n    \"\"\"Gaussian: s = exp(-d²/σ²)\"\"\"\n    if sigma < 1e-10:\n        return 1.0 if distance < 1e-10 else 0.0\n    return float(np.exp(-(distance ** 2) / (sigma ** 2)))\n\n\ndef linear_transform(value: float, old_min: float, old_max: float) -> float:\n    \"\"\"Linear transform to [0, 1]\"\"\"\n    if old_max - old_min < 1e-10:\n        return 0.5\n    return float(max(0.0, min(1.0, (value - old_min) / (old_max - old_min))))","metadata":{"_uuid":"d29fc42d-7f3b-48c8-9992-67a38f20def6","_cell_guid":"9e6b2976-75fc-4d33-90af-2b2963b95da8","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-11T20:13:05.220190Z","iopub.execute_input":"2026-01-11T20:13:05.220530Z","iopub.status.idle":"2026-01-11T20:13:05.227485Z","shell.execute_reply.started":"2026-01-11T20:13:05.220495Z","shell.execute_reply":"2026-01-11T20:13:05.226370Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 🖼️ Image & SAM2","metadata":{"_uuid":"1f368056-610a-4376-a879-163e4e7e30fd","_cell_guid":"deb1445f-ae96-4e71-9104-caa134b42e38","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def load_image(path: str = IMAGE_PATH) -> np.ndarray:\n    return np.array(Image.open(path).convert('RGB'))\n\n\ndef display_image(image: np.ndarray, title: str = \"Image\", figsize=(10, 10)):\n    fig, ax = plt.subplots(figsize=figsize)\n    ax.imshow(image)\n    ax.set_title(title, fontsize=14)\n    ax.axis('off')\n    plt.tight_layout()\n    return fig\n\n\ndef display_masks(image: np.ndarray, anns: List[Dict], title: str = \"SAM2\", \n                  figsize=(14, 14), alpha=0.4):\n    fig, ax = plt.subplots(figsize=figsize)\n    ax.imshow(image)\n    ax.axis('off')\n    \n    if not anns:\n        ax.set_title(f\"{title} (no masks)\")\n        return fig\n    \n    sorted_anns = sorted(anns, key=lambda x: x['area'], reverse=True)\n    h, w = image.shape[:2]\n    overlay = np.zeros((h, w, 4), dtype=np.float32)\n    \n    np.random.seed(42)\n    colors = [np.random.random(3) for _ in range(len(sorted_anns))]\n    \n    for idx, (ann, rgb) in enumerate(zip(sorted_anns, colors)):\n        mask = ann['segmentation'].astype(np.uint8)\n        overlay[mask > 0, :3] = rgb\n        overlay[mask > 0, 3] = alpha\n        \n        contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n        for c in contours:\n            c = c.squeeze()\n            if len(c.shape) == 2 and len(c) > 2:\n                ax.plot(c[:, 0], c[:, 1], color=rgb, linewidth=2)\n                ax.plot([c[-1, 0], c[0, 0]], [c[-1, 1], c[0, 1]], color=rgb, linewidth=2)\n        \n        m = cv2.moments(mask)\n        if m['m00'] > 0:\n            cx, cy = int(m['m10'] / m['m00']), int(m['m01'] / m['m00'])\n            ax.text(cx, cy, str(idx), fontsize=8, ha='center', va='center',\n                   color='white', fontweight='bold',\n                   bbox=dict(boxstyle='round,pad=0.2', facecolor=rgb, alpha=0.8))\n    \n    ax.imshow(overlay)\n    ax.set_title(f\"{title} ({len(anns)} objects)\", fontsize=14)\n    plt.tight_layout()\n    return fig\n\n\ndef setup_sam2(device=DEVICE):\n    sam2 = build_sam2(config_file=SAM2_CONFIG, ckpt_path=SAM2_CHECKPOINT, device=device)\n    gen = SAM2AutomaticMaskGenerator(\n        model=sam2, points_per_side=SAM2_POINTS_PER_SIDE,\n        points_per_batch=SAM2_POINTS_PER_BATCH, pred_iou_thresh=SAM2_PRED_IOU_THRESH,\n        stability_score_thresh=SAM2_STABILITY_SCORE_THRESH,\n        stability_score_offset=SAM2_STABILITY_SCORE_OFFSET,\n        mask_threshold=SAM2_MASK_THRESHOLD, box_nms_thresh=SAM2_BOX_NMS_THRESH,\n        crop_n_layers=SAM2_CROP_N_LAYERS, crop_nms_thresh=SAM2_CROP_NMS_THRESH,\n        crop_overlap_ratio=SAM2_CROP_OVERLAP_RATIO,\n        crop_n_points_downscale_factor=SAM2_CROP_N_POINTS_DOWNSCALE_FACTOR,\n        min_mask_region_area=SAM2_MIN_MASK_REGION_AREA,\n        output_mode=SAM2_OUTPUT_MODE, use_m2m=SAM2_USE_M2M,\n        multimask_output=SAM2_MULTIMASK_OUTPUT,\n    )\n    print(\"✅ SAM2 loaded\")\n    return gen\n\n\ndef segment(image: np.ndarray, gen, min_r=MIN_OBJECT_AREA_RATIO, max_r=MAX_OBJECT_AREA_RATIO):\n    all_masks = gen.generate(image)\n    area = image.shape[0] * image.shape[1]\n    filtered = [m for m in all_masks if area * min_r < m['area'] < area * max_r]\n    filtered = sorted(filtered, key=lambda x: x['area'], reverse=True)\n    print(f\"🎯 {len(filtered)} objects (from {len(all_masks)})\")\n    return all_masks, filtered","metadata":{"_uuid":"ff72cf5f-0935-4398-8657-950aa4cf6ea8","_cell_guid":"036421be-2c52-49af-bc9e-f42d66b15b83","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-11T20:13:05.228860Z","iopub.execute_input":"2026-01-11T20:13:05.229166Z","iopub.status.idle":"2026-01-11T20:13:05.253128Z","shell.execute_reply.started":"2026-01-11T20:13:05.229144Z","shell.execute_reply":"2026-01-11T20:13:05.252052Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 📐 Object Extraction","metadata":{"_uuid":"eb53f1f0-d1ce-440e-8e65-2162fb45ee75","_cell_guid":"006bd4d1-9769-4986-8ff1-cd155007fe77","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"@dataclass\nclass ExtractedObject:\n    id: int\n    crop_rgb: np.ndarray\n    crop_gray: np.ndarray\n    mask: np.ndarray\n    crop_mask: np.ndarray\n    bbox: Tuple[int, int, int, int]\n    area: int\n    centroid: Tuple[float, float]\n    \n    # Normalized crops for comparison (same size)\n    norm_rgb: np.ndarray = None\n    norm_gray: np.ndarray = None\n    norm_mask: np.ndarray = None\n    \n    # Pre-computed features\n    gradient_mag: np.ndarray = None\n    fft_magnitude: np.ndarray = None\n\n\ndef extract_objects(image: np.ndarray, masks: List[Dict], \n                    padding: int = CROP_PADDING,\n                    norm_size: int = 128) -> List[ExtractedObject]:\n    \"\"\"Extract objects with normalized size for fair comparison.\"\"\"\n    objects = []\n    \n    for idx, mask_data in enumerate(masks):\n        mask = mask_data['segmentation'].astype(np.uint8)\n        x, y, w, h = [int(v) for v in mask_data['bbox']]\n        \n        x1, y1 = max(0, x - padding), max(0, y - padding)\n        x2, y2 = min(image.shape[1], x + w + padding), min(image.shape[0], y + h + padding)\n        \n        crop_rgb = image[y1:y2, x1:x2].copy()\n        crop_mask = mask[y1:y2, x1:x2]\n        crop_rgb_masked = crop_rgb.copy()\n        crop_rgb_masked[crop_mask == 0] = 0\n        crop_gray = cv2.cvtColor(crop_rgb_masked, cv2.COLOR_RGB2GRAY)\n        \n        m = cv2.moments(mask)\n        cx = m['m10'] / m['m00'] if m['m00'] > 0 else x + w / 2\n        cy = m['m01'] / m['m00'] if m['m00'] > 0 else y + h / 2\n        \n        # Normalize to fixed size for fair comparison\n        norm_rgb = cv2.resize(crop_rgb_masked, (norm_size, norm_size))\n        norm_gray = cv2.resize(crop_gray, (norm_size, norm_size))\n        norm_mask = cv2.resize(crop_mask, (norm_size, norm_size), interpolation=cv2.INTER_NEAREST)\n        \n        # Pre-compute gradient magnitude\n        gx = cv2.Sobel(norm_gray, cv2.CV_64F, 1, 0, ksize=3)\n        gy = cv2.Sobel(norm_gray, cv2.CV_64F, 0, 1, ksize=3)\n        gradient_mag = np.sqrt(gx**2 + gy**2)\n        gradient_mag[norm_mask == 0] = 0  # Mask out background\n        \n        # Pre-compute FFT magnitude (normalized)\n        fft = np.fft.fft2(norm_gray.astype(np.float64))\n        fft_shift = np.fft.fftshift(fft)\n        fft_magnitude = np.log1p(np.abs(fft_shift))\n        # Normalize\n        fft_magnitude = (fft_magnitude - fft_magnitude.min()) / (fft_magnitude.max() - fft_magnitude.min() + 1e-10)\n        \n        obj = ExtractedObject(\n            id=idx, crop_rgb=crop_rgb_masked, crop_gray=crop_gray,\n            mask=mask, crop_mask=crop_mask, bbox=(x1, y1, x2-x1, y2-y1),\n            area=int(mask_data['area']), centroid=(cx, cy),\n            norm_rgb=norm_rgb, norm_gray=norm_gray, norm_mask=norm_mask,\n            gradient_mag=gradient_mag, fft_magnitude=fft_magnitude\n        )\n        objects.append(obj)\n    \n    print(f\"📐 Extracted {len(objects)} objects (normalized to {norm_size}x{norm_size})\")\n    return objects","metadata":{"_uuid":"5df7e0a2-264c-4ba0-8097-2502a8af50e9","_cell_guid":"bb22137c-b6cd-4439-84e0-05e5caac06bb","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-11T20:13:05.256299Z","iopub.execute_input":"2026-01-11T20:13:05.256603Z","iopub.status.idle":"2026-01-11T20:13:05.279221Z","shell.execute_reply.started":"2026-01-11T20:13:05.256579Z","shell.execute_reply":"2026-01-11T20:13:05.278137Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 📏 Discriminative Metrics\n\nThese metrics are designed to detect **exact duplication** among \nobjects that are already categorically similar.","metadata":{"_uuid":"a65d9f5e-f31f-4ca6-a090-bfc13c5aec10","_cell_guid":"7027d32d-96ef-4a90-91c5-ed1f1272df8f","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def compute_pixel_mse_similarity(obj1: ExtractedObject, obj2: ExtractedObject) -> float:\n    \"\"\"\n    Pixel-level MSE comparison on masked regions.\n    \n    For copy-move forgery, copied regions should have near-zero MSE.\n    Convert MSE to similarity via Gaussian with tight sigma.\n    \"\"\"\n    img1, img2 = obj1.norm_gray.astype(np.float64), obj2.norm_gray.astype(np.float64)\n    mask = (obj1.norm_mask > 0) & (obj2.norm_mask > 0)\n    \n    if mask.sum() < 100:\n        return 0.0\n    \n    # MSE on masked pixels only\n    diff = (img1[mask] - img2[mask]) ** 2\n    mse = np.mean(diff)\n    \n    # Normalize MSE by pixel range (0-255)^2 = 65025\n    # Typical MSE for similar images: 100-1000\n    # Typical MSE for copies: 0-50\n    # Use tight sigma to be discriminative\n    normalized_mse = np.sqrt(mse) / 255.0  # RMSE normalized to [0, 1]\n    \n    # Gaussian similarity with tight sigma\n    # sigma=0.1 means RMSE of ~0.1 (25.5 pixel diff) gives ~0.37 similarity\n    return gaussian_similarity(normalized_mse, sigma=0.15)\n\n\ndef compute_gradient_similarity(obj1: ExtractedObject, obj2: ExtractedObject) -> float:\n    \"\"\"\n    Gradient (edge) pattern correlation.\n    \n    Copy-move preserves edge patterns exactly.\n    Uses normalized cross-correlation on gradient magnitude.\n    \"\"\"\n    g1, g2 = obj1.gradient_mag, obj2.gradient_mag\n    mask = (obj1.norm_mask > 0) & (obj2.norm_mask > 0)\n    \n    if mask.sum() < 100:\n        return 0.0\n    \n    # Extract masked pixels\n    v1, v2 = g1[mask], g2[mask]\n    \n    # Normalize\n    v1 = v1 - v1.mean()\n    v2 = v2 - v2.mean()\n    \n    std1, std2 = v1.std(), v2.std()\n    if std1 < 1e-6 or std2 < 1e-6:\n        return 0.5\n    \n    # Normalized cross-correlation\n    ncc = np.mean(v1 * v2) / (std1 * std2)\n    \n    # NCC is in [-1, 1], transform to [0, 1]\n    return linear_transform(ncc, -1.0, 1.0)\n\n\ndef compute_fft_similarity(obj1: ExtractedObject, obj2: ExtractedObject) -> float:\n    \"\"\"\n    FFT magnitude spectrum similarity.\n    \n    Copied regions have identical frequency content.\n    Compare log-magnitude spectra.\n    \"\"\"\n    f1, f2 = obj1.fft_magnitude, obj2.fft_magnitude\n    \n    # Flatten and compare\n    v1, v2 = f1.flatten(), f2.flatten()\n    \n    # Cosine similarity\n    norm1, norm2 = np.linalg.norm(v1), np.linalg.norm(v2)\n    if norm1 < 1e-10 or norm2 < 1e-10:\n        return 0.0\n    \n    cosine = np.dot(v1, v2) / (norm1 * norm2)\n    \n    # Cosine is in [-1, 1]\n    return linear_transform(cosine, -1.0, 1.0)\n\n\ndef compute_ssim_masked(obj1: ExtractedObject, obj2: ExtractedObject) -> float:\n    \"\"\"\n    SSIM computed only on object pixels (not black background).\n    \n    Standard SSIM fails because black padding dominates.\n    This version computes SSIM on overlapping masked regions.\n    \"\"\"\n    img1, img2 = obj1.norm_gray.astype(np.float64), obj2.norm_gray.astype(np.float64)\n    mask = (obj1.norm_mask > 0) & (obj2.norm_mask > 0)\n    \n    if mask.sum() < 100:\n        return 0.0\n    \n    # SSIM constants\n    C1 = (0.01 * 255) ** 2\n    C2 = (0.03 * 255) ** 2\n    \n    # Compute on masked pixels\n    v1, v2 = img1[mask], img2[mask]\n    \n    mu1, mu2 = v1.mean(), v2.mean()\n    sigma1_sq = v1.var()\n    sigma2_sq = v2.var()\n    sigma12 = np.mean((v1 - mu1) * (v2 - mu2))\n    \n    # SSIM formula\n    numerator = (2 * mu1 * mu2 + C1) * (2 * sigma12 + C2)\n    denominator = (mu1**2 + mu2**2 + C1) * (sigma1_sq + sigma2_sq + C2)\n    \n    ssim = numerator / denominator\n    \n    # SSIM is in [-1, 1], but typically [0, 1] for similar images\n    return float(max(0.0, min(1.0, ssim)))\n\n\ndef compute_sift_similarity(obj1: ExtractedObject, obj2: ExtractedObject,\n                            n_features: int = 500, ratio_thresh: float = 0.75) -> float:\n    \"\"\"SIFT keypoint matching - proven performer.\"\"\"\n    sift = cv2.SIFT_create(nfeatures=n_features)\n    \n    mask1 = obj1.crop_mask.astype(np.uint8) * 255\n    mask2 = obj2.crop_mask.astype(np.uint8) * 255\n    \n    kp1, desc1 = sift.detectAndCompute(obj1.crop_gray, mask1)\n    kp2, desc2 = sift.detectAndCompute(obj2.crop_gray, mask2)\n    \n    if desc1 is None or desc2 is None or len(desc1) < 2 or len(desc2) < 2:\n        return 0.0\n    \n    # FLANN matching\n    flann = cv2.FlannBasedMatcher(dict(algorithm=1, trees=5), dict(checks=50))\n    matches = flann.knnMatch(desc1, desc2, k=2)\n    \n    good = [m for m_n in matches if len(m_n) == 2 \n            for m, n in [m_n] if m.distance < ratio_thresh * n.distance]\n    \n    # Jaccard-style\n    union = max(len(kp1), len(kp2))\n    return float(min(1.0, len(good) / union)) if union > 0 else 0.0","metadata":{"_uuid":"c84abdca-31ee-4492-8ebf-b653801d2c98","_cell_guid":"afd64a4c-44d7-4f6e-8413-b2695fbb3d46","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-11T20:13:05.280351Z","iopub.execute_input":"2026-01-11T20:13:05.280696Z","iopub.status.idle":"2026-01-11T20:13:05.305555Z","shell.execute_reply.started":"2026-01-11T20:13:05.280662Z","shell.execute_reply":"2026-01-11T20:13:05.304681Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 🧠 LPIPS (Best Performer)","metadata":{"_uuid":"6d2b8edc-84b9-4cfc-ab0e-13cd00a6dacc","_cell_guid":"e3ebf59c-fe20-4a37-acac-2fc1f8dac9f8","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def create_lpips(net_type='alex', device=DEVICE):\n    metric = LearnedPerceptualImagePatchSimilarity(net_type=net_type, normalize=True).to(device)\n    print(f\"✅ LPIPS ({net_type}) loaded\")\n    return metric\n\n\ndef to_tensor(img: np.ndarray, device=DEVICE):\n    t = torch.from_numpy(img).permute(2, 0, 1).unsqueeze(0).float()\n    if t.max() > 1.0:\n        t = t / 255.0\n    return t.to(device)\n\n\ndef compute_lpips_similarity(obj1: ExtractedObject, obj2: ExtractedObject,\n                             metric, device=DEVICE) -> float:\n    \"\"\"LPIPS - Best empirical performer for copy-move detection.\"\"\"\n    # Use normalized crops\n    t1 = to_tensor(obj1.norm_rgb, device)\n    t2 = to_tensor(obj2.norm_rgb, device)\n    \n    with torch.no_grad():\n        dist = metric(t1, t2)\n    \n    # LPIPS is a distance [0, ~1], convert to similarity\n    # Tight sigma because we want to detect exact copies\n    return gaussian_similarity(float(dist.item()), sigma=0.4)\n\n\nlpips_metric_alex = create_lpips(net_type='alex')\nlpips_metric_squeeze = create_lpips(net_type='squeeze')\nlpips_metric_vgg = create_lpips(net_type='vgg')","metadata":{"_uuid":"83074eeb-4360-4ab3-9775-8d91644c7de2","_cell_guid":"5ffc9209-001b-4e51-8767-37e908d1928e","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-11T20:17:19.344397Z","iopub.execute_input":"2026-01-11T20:17:19.344788Z","iopub.status.idle":"2026-01-11T20:17:21.682262Z","shell.execute_reply.started":"2026-01-11T20:17:19.344744Z","shell.execute_reply":"2026-01-11T20:17:21.681264Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 📊 Matrix Computation","metadata":{"_uuid":"3c332d9e-55d0-499f-92b3-ee1705d969ad","_cell_guid":"da6e473b-b193-4e36-8a33-0df69b1db33a","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def compute_all(objects: List[ExtractedObject], \n                metrics: List[str] = ALL_METRICS,\n                device=DEVICE) -> Dict[str, np.ndarray]:\n    n = len(objects)\n    results = {m: np.eye(n) for m in metrics}\n    pairs = [(i, j) for i in range(n) for j in range(i + 1, n)]\n    \n    for i, j in tqdm(pairs, desc=\"📊 Computing\"):\n        o1, o2 = objects[i], objects[j]\n        \n        if 'LPIPS_alex' in metrics:\n            s = compute_lpips_similarity(o1, o2, lpips_metric_alex, device)\n            results['LPIPS_alex'][i, j] = results['LPIPS_alex'][j, i] = s\n        if 'LPIPS_vgg' in metrics:\n            s = compute_lpips_similarity(o1, o2, lpips_metric_vgg, device)\n            results['LPIPS_vgg'][i, j] = results['LPIPS_vgg'][j, i] = s\n        if 'LPIPS_squeeze' in metrics:\n            s = compute_lpips_similarity(o1, o2, lpips_metric_squeeze, device)\n            results['LPIPS_squeeze'][i, j] = results['LPIPS_squeeze'][j, i] = s\n        \n        if 'SIFT' in metrics:\n            s = compute_sift_similarity(o1, o2)\n            results['SIFT'][i, j] = results['SIFT'][j, i] = s\n        \n        if 'Pixel_MSE' in metrics:\n            s = compute_pixel_mse_similarity(o1, o2)\n            results['Pixel_MSE'][i, j] = results['Pixel_MSE'][j, i] = s\n        \n        if 'Gradient' in metrics:\n            s = compute_gradient_similarity(o1, o2)\n            results['Gradient'][i, j] = results['Gradient'][j, i] = s\n        \n        if 'FFT_Mag' in metrics:\n            s = compute_fft_similarity(o1, o2)\n            results['FFT_Mag'][i, j] = results['FFT_Mag'][j, i] = s\n        \n        if 'SSIM_Masked' in metrics:\n            s = compute_ssim_masked(o1, o2)\n            results['SSIM_Masked'][i, j] = results['SSIM_Masked'][j, i] = s\n    \n    return results\n\n\ndef compute_combined(matrices: Dict[str, np.ndarray], \n                     weights: Dict[str, float] = METRIC_WEIGHTS) -> np.ndarray:\n    n = next(iter(matrices.values())).shape[0]\n    combined = np.zeros((n, n))\n    w_sum = 0.0\n    \n    for m, mat in matrices.items():\n        if m in weights and m != 'Combined':\n            combined += weights[m] * mat\n            w_sum += weights[m]\n    \n    return combined / w_sum if w_sum > 0 else combined","metadata":{"_uuid":"4a602b77-3e93-49b1-a2a9-cbada5122b87","_cell_guid":"63b99d0f-fbce-498b-9ca9-382e1c23e5ee","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-11T20:17:21.684002Z","iopub.execute_input":"2026-01-11T20:17:21.684329Z","iopub.status.idle":"2026-01-11T20:17:21.698572Z","shell.execute_reply.started":"2026-01-11T20:17:21.684307Z","shell.execute_reply":"2026-01-11T20:17:21.697430Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 🖼️ Visualization","metadata":{"_uuid":"20f1f384-8a9d-4619-ac5a-e91da7ada0c4","_cell_guid":"606dc206-3d23-4131-ae65-b6bb7f4cb39f","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def visualize(objects, matrices, img_shape, save_path=None, \n              figsize_per=(7, 6), cmap='Greens', n_cols=2):\n    n_metrics = len(matrices)\n    n_rows = (n_metrics + n_cols - 1) // n_cols\n    h, w = img_shape[:2]\n    n = len(objects)\n    \n    fig, axes = plt.subplots(n_rows, n_cols, \n                             figsize=(figsize_per[0] * n_cols, figsize_per[1] * n_rows))\n    \n    if n_metrics == 1:\n        axes = np.array([[axes]])\n    elif n_rows == 1:\n        axes = axes.reshape(1, -1)\n    elif n_cols == 1:\n        axes = axes.reshape(-1, 1)\n    \n    for idx, (name, matrix) in enumerate(matrices.items()):\n        ax = axes[idx // n_cols, idx % n_cols]\n        \n        best_scores, best_ids = [], []\n        for i in range(n):\n            scores = matrix[i].copy()\n            scores[i] = -np.inf\n            best_idx = np.argmax(scores)\n            best_scores.append(scores[best_idx])\n            best_ids.append(best_idx)\n        \n        best_scores = np.array(best_scores)\n        \n        score_map = np.zeros((h, w), dtype=np.float32)\n        for obj, s in zip(objects, best_scores):\n            score_map[obj.mask > 0] = s\n        \n        im = ax.imshow(score_map, cmap=cmap, vmin=0, vmax=1)\n        ax.set_facecolor('black')\n        \n        for obj, s, best in zip(objects, best_scores, best_ids):\n            cx, cy = obj.centroid\n            ax.text(cx, cy, f'{obj.id}→{best}', fontsize=8, ha='center', va='center',\n                   color='white', fontweight='bold',\n                   bbox=dict(boxstyle='round,pad=0.2', facecolor='black', alpha=0.7))\n        \n        fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)\n        ax.set_title(f'{name}\\nmean={best_scores.mean():.3f}, max={best_scores.max():.3f}',\n                    fontsize=12, fontweight='bold')\n        ax.axis('off')\n    \n    for idx in range(n_metrics, n_rows * n_cols):\n        axes[idx // n_cols, idx % n_cols].axis('off')\n    \n    plt.suptitle('🔍 Best Match Scores [0,1]', fontsize=14, fontweight='bold', y=1.02)\n    plt.tight_layout()\n    \n    if save_path:\n        plt.savefig(save_path, dpi=150, bbox_inches='tight', facecolor='black')\n    \n    return fig","metadata":{"_uuid":"559af2ff-3d27-4720-8cac-b619b2b791d9","_cell_guid":"ac7e56df-ac44-45d9-8735-08c4dd444ec3","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-11T20:17:21.699591Z","iopub.execute_input":"2026-01-11T20:17:21.699896Z","iopub.status.idle":"2026-01-11T20:17:21.722710Z","shell.execute_reply.started":"2026-01-11T20:17:21.699873Z","shell.execute_reply":"2026-01-11T20:17:21.721700Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 📝 Reporting","metadata":{"_uuid":"c185bc2c-f09a-4bb0-9f2c-2471eb4a0b20","_cell_guid":"e92e06ec-113a-4389-a414-53f44e9086b6","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def create_report(matrices, objects, combined=None, threshold=0.7):\n    n = len(objects)\n    rows = []\n    \n    for i in range(n):\n        for j in range(i + 1, n):\n            row = {'Obj_1': i, 'Obj_2': j}\n            \n            high = 0\n            for m, mat in matrices.items():\n                if m != 'Combined':\n                    s = mat[i, j]\n                    row[m] = round(s, 4)\n                    if s >= threshold:\n                        high += 1\n            \n            row['High_Count'] = high\n            row['Mean'] = round(np.mean([row[m] for m in matrices if m != 'Combined']), 4)\n            if combined is not None:\n                row['Combined'] = round(combined[i, j], 4)\n            \n            rows.append(row)\n    \n    df = pd.DataFrame(rows)\n    return df.sort_values('Combined' if 'Combined' in df else 'Mean', ascending=False)","metadata":{"_uuid":"176d40a8-80cf-4457-9512-09795eff27c1","_cell_guid":"9b5160da-9629-4695-95b6-c51353bd652c","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-11T20:17:21.724572Z","iopub.execute_input":"2026-01-11T20:17:21.725386Z","iopub.status.idle":"2026-01-11T20:17:21.745400Z","shell.execute_reply.started":"2026-01-11T20:17:21.725359Z","shell.execute_reply":"2026-01-11T20:17:21.744371Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## 🚀 Pipeline","metadata":{"_uuid":"d95da26f-0a2c-49dc-99c3-4fb88774d3ab","_cell_guid":"1949de5e-6f55-49fe-a8e4-c4e85bfa34df","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"def run(image_path=IMAGE_PATH, metrics=ALL_METRICS, weights=METRIC_WEIGHTS, device=DEVICE):\n    print(\"=\" * 70)\n    print(\"🔬 OBJECT SIMILARITY v15 - Discriminative Metrics\")\n    print(\"=\" * 70)\n    \n    # Load\n    print(\"\\n📷 Loading...\")\n    image = load_image(image_path)\n    print(f\"   Shape: {image.shape}\")\n    display_image(image, f\"Input ({image.shape[1]}x{image.shape[0]})\")\n    plt.show()\n    \n    # Segment\n    print(\"\\n🎯 Segmenting...\")\n    gen = setup_sam2(device)\n    all_masks, filtered = segment(image, gen)\n    \n    display_masks(image, all_masks, \"All Objects\")\n    plt.show()\n    display_masks(image, filtered, \"Filtered\")\n    plt.show()\n    \n    if len(filtered) < 2:\n        print(\"❌ Need ≥2 objects\")\n        return None, None, None, None\n    \n    # Extract\n    print(\"\\n📐 Extracting...\")\n    objects = extract_objects(image, filtered)\n    \n    # Compute\n    print(\"\\n📊 Computing...\")\n    matrices = compute_all(objects, metrics, device)\n    \n    # Combine\n    print(\"\\n🔗 Combining...\")\n    combined = compute_combined(matrices, weights)\n    matrices['Combined'] = combined\n    \n    # Visualize\n    print(\"\\n🔍 Visualizing...\")\n    visualize(objects, matrices, image.shape, f\"{OUTPUT_DIR}/heatmaps.png\", n_cols=2)\n    plt.show()\n    \n    # Report\n    print(\"\\n📝 Report...\")\n    report = create_report(matrices, objects, combined)\n    report.to_csv(f\"{OUTPUT_DIR}/report.csv\", index=False)\n    \n    print(\"\\n\" + \"=\" * 70)\n    print(\"🚨 TOP SUSPICIOUS PAIRS\")\n    print(\"=\" * 70)\n    cols = ['Obj_1', 'Obj_2', 'Combined', 'High_Count'] + [m for m in metrics if m in report.columns][:3]\n    print(report[cols].head(10).to_string(index=False))\n    \n    return objects, matrices, combined, report","metadata":{"_uuid":"60fd840c-9bef-4409-bc72-e970b8c025c1","_cell_guid":"662bc595-55ad-4ae8-8d83-e4a0bdd2174c","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-11T20:17:21.746499Z","iopub.execute_input":"2026-01-11T20:17:21.746871Z","iopub.status.idle":"2026-01-11T20:17:21.769708Z","shell.execute_reply.started":"2026-01-11T20:17:21.746839Z","shell.execute_reply":"2026-01-11T20:17:21.768720Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## ▶️ Run","metadata":{"_uuid":"250bf7a9-584e-40f5-a816-eb1add44e5bf","_cell_guid":"35c05407-471e-4190-8322-b2ff40d0852e","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false}}},{"cell_type":"code","source":"objects, matrices, combined, report = run()","metadata":{"_uuid":"00b5d7be-89f9-41fa-8004-fa23d9bc02c3","_cell_guid":"c160a969-3186-4cfd-9401-ab9bfcdff416","trusted":true,"collapsed":false,"jupyter":{"outputs_hidden":false},"execution":{"iopub.status.busy":"2026-01-11T20:17:21.770766Z","iopub.execute_input":"2026-01-11T20:17:21.771112Z","iopub.status.idle":"2026-01-11T20:20:49.545634Z","shell.execute_reply.started":"2026-01-11T20:17:21.771081Z","shell.execute_reply":"2026-01-11T20:20:49.544714Z"}},"outputs":[],"execution_count":null}]}