{"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":14456136,"sourceType":"competition"}],"dockerImageVersionId":31192,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"from pathlib import Path\nimport os\nimport random\nimport pandas as pd\nimport numpy as np\nimport cv2\nfrom tqdm.auto import tqdm\nfrom PIL import Image\nfrom io import BytesIO\nimport json\nimport matplotlib.pyplot as plt","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2025-11-28T00:10:30.936059Z","iopub.execute_input":"2025-11-28T00:10:30.936442Z","iopub.status.idle":"2025-11-28T00:10:30.942838Z","shell.execute_reply.started":"2025-11-28T00:10:30.936419Z","shell.execute_reply":"2025-11-28T00:10:30.941324Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Preliminaries","metadata":{}},{"cell_type":"code","source":"DATA_PATH = Path(\"/kaggle/input/recodai-luc-scientific-image-forgery-detection\")\nAUTH_IMAGES_PATH = DATA_PATH / \"train_images/authentic\"\nFORGED_IMAGES_PATH = DATA_PATH / \"train_images/forged\"\nMASKS_PATH = DATA_PATH / \"train_masks\"\n\nIMAGE_EXTS = {\".png\", \".jpg\", \".jpeg\"}\nMASK_EXTS = {\".png\", \".jpg\"}\n\n# Function to extract all image paths in a directory\ndef get_image_paths(directory):\n    img_paths = []\n    \n    if not directory.is_dir():\n        print(f\"{directory} is not a directory or it does not exist\")\n        return img_paths\n\n    for ext in [\"*.jpg\", \"*.jpeg\", \"*.png\"]:\n        img_paths.extend(directory.rglob(ext))\n\n    return img_paths\n\n# Function to extract all mask paths in a directory\ndef get_mask_paths(directory):\n    mask_paths = []\n    \n    if not directory.is_dir():\n        print(f\"{directory} is not a directory or it does not exist\")\n        return mask_paths\n\n    mask_paths.extend(directory.rglob(\"*.npy\"))\n\n    return mask_paths\n\ndef get_mask(image_path):\n    mask_path = DATA_PATH / f\"train_masks/{image_path.stem}.npy\"\n    mask = np.load(mask_path).any(axis=0) # Join masks into 1\n    return mask.astype(np.uint8)\n\ndef rle_encode(mask: np.ndarray) -> str:\n    pixels = mask.astype(np.uint8).flatten(order='F')\n\n    pixels = np.concatenate([[0], pixels, [0]])\n\n    changes = np.where(pixels[1:] != pixels[:-1])[0] + 1\n\n    starts = changes[0::2]\n    ends   = changes[1::2]\n    lengths = ends - starts\n\n    if len(starts) == 0:\n        return \"[]\"\n        \n    rle_numbers = np.column_stack((starts, lengths)).ravel()\n\n    return \"[\" + \", \".join(map(str, rle_numbers)) + \"]\"\n\ndef rle_decode(mask_rle, shape):\n    \"\"\"\n    Decodes run-length string into binary mask.\n    \n    mask_rle: run-length as string\n    shape: (height, width)\n    Returns: binary mask (H, W)\n    \"\"\"\n    height, width = shape\n    \n    if mask_rle == \"authentic\":\n        return np.zeros(shape, dtype=np.uint8)\n\n    mask_rle = json.loads(mask_rle)\n    mask_rle = np.asarray(mask_rle, dtype=int)\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.reshape(shape, order='F')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-28T00:10:30.952383Z","iopub.execute_input":"2025-11-28T00:10:30.952701Z","iopub.status.idle":"2025-11-28T00:10:30.968732Z","shell.execute_reply.started":"2025-11-28T00:10:30.952679Z","shell.execute_reply":"2025-11-28T00:10:30.967331Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"auth_rows = []\nfor image_path in tqdm(get_image_paths(AUTH_IMAGES_PATH), desc=\"Filling data frame with authentic images\"):\n\n    with Image.open(image_path) as image:\n        width, height = image.size\n    \n    rle = \"authentic\"\n    \n    auth_rows.append({\n        \"image_path\": str(image_path),\n        \"width\": width,\n        \"height\": height,\n        \"rle\": rle,\n    })\n    \nauth_df = pd.DataFrame(auth_rows)\n\nforged_rows = []\nfor image_path in tqdm(get_image_paths(FORGED_IMAGES_PATH), desc=\"Filling data frame with forged images\"):\n    \n    with Image.open(image_path) as image:\n        width, height = image.size\n    \n    mask = get_mask(image_path)\n    rle = rle_encode(mask)\n    \n    forged_rows.append({\n        \"image_path\": str(image_path),\n        \"width\": width,\n        \"height\": height,\n        \"rle\": rle,\n    })\n\nforged_df = pd.DataFrame(forged_rows)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-28T00:10:30.970683Z","iopub.execute_input":"2025-11-28T00:10:30.971055Z","iopub.status.idle":"2025-11-28T00:10:59.762971Z","shell.execute_reply.started":"2025-11-28T00:10:30.971026Z","shell.execute_reply":"2025-11-28T00:10:59.761331Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"auth_df.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-28T00:10:59.764035Z","iopub.execute_input":"2025-11-28T00:10:59.764321Z","iopub.status.idle":"2025-11-28T00:10:59.774605Z","shell.execute_reply.started":"2025-11-28T00:10:59.764295Z","shell.execute_reply":"2025-11-28T00:10:59.773568Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"forged_df.head()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-28T00:10:59.776716Z","iopub.execute_input":"2025-11-28T00:10:59.777016Z","iopub.status.idle":"2025-11-28T00:10:59.801347Z","shell.execute_reply.started":"2025-11-28T00:10:59.776995Z","shell.execute_reply":"2025-11-28T00:10:59.800049Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Noise and Error Information from Images","metadata":{}},{"cell_type":"markdown","source":"## Spatial Rich Model (SRM)","metadata":{}},{"cell_type":"markdown","source":"We want to compute the result of passing certain filter (like in a CNN) that discards low frequencies and keeps edges + noise. We can use multiple different filters that extract different information from the image and stack these extra channels together.","metadata":{}},{"cell_type":"code","source":"HP_KERNEL = np.array([[0, -1,  0],\n                      [-1, 4, -1],\n                      [0, -1,  0]], dtype=np.float32)\n\ndef compute_highpass(rgb):\n    # rgb: H,W,3 uint8\n    rgb_f = rgb.astype(np.float32)\n    hp = np.zeros(rgb_f.shape[:2], dtype=np.float32)  # grayscale\n    for c in range(3):\n        hp += cv2.filter2D(rgb_f[..., c], -1, HP_KERNEL)\n    hp /= 3.0  # average channels\n    hp_norm = hp - hp.min()\n    hp_norm /= (hp_norm.max() + 1e-6)\n    return hp_norm","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-28T00:10:59.802693Z","iopub.execute_input":"2025-11-28T00:10:59.803084Z","iopub.status.idle":"2025-11-28T00:10:59.822681Z","shell.execute_reply.started":"2025-11-28T00:10:59.803059Z","shell.execute_reply":"2025-11-28T00:10:59.821475Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Error-Level Analysis (ELA)","metadata":{}},{"cell_type":"markdown","source":"We compute the difference between an image before and after compressing it to JPEG. We do this because edited regions often have different compression history, therefore different error pattern.","metadata":{}},{"cell_type":"code","source":"def compute_ela(rgb, quality=90):\n    # rgb: H,W,3 uint8\n    pil_img = Image.fromarray(rgb)\n    buffer = BytesIO()\n    pil_img.save(buffer, format=\"JPEG\", quality=quality)\n    buffer.seek(0)\n    rec_img = np.array(Image.open(buffer)).astype(np.float32)\n\n    diff = np.abs(rgb.astype(np.float32) - rec_img)\n    diff_gray = diff.mean(axis=2)  # H,W\n    diff_gray -= diff_gray.min()\n    diff_gray /= (diff_gray.max() + 1e-6)\n    return diff_gray","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-28T00:10:59.823828Z","iopub.execute_input":"2025-11-28T00:10:59.824768Z","iopub.status.idle":"2025-11-28T00:10:59.845761Z","shell.execute_reply.started":"2025-11-28T00:10:59.824725Z","shell.execute_reply":"2025-11-28T00:10:59.844729Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"## Noise Residuals","metadata":{}},{"cell_type":"markdown","source":"We want to compute the noise residuals of our images because the noise field of a forged area often doesn’t match the rest (e.g. re-saved region, different sensor).","metadata":{}},{"cell_type":"code","source":"def compute_noise_residual(rgb):\n    rgb_f = rgb.astype(np.float32)\n    blurred = cv2.GaussianBlur(rgb_f, (5,5), 0)\n    resid = rgb_f - blurred\n    resid_gray = resid.mean(axis=2)\n    resid_gray -= resid_gray.min()\n    resid_gray /= (resid_gray.max() + 1e-6)\n    return resid_gray","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-28T00:10:59.846842Z","iopub.execute_input":"2025-11-28T00:10:59.847107Z","iopub.status.idle":"2025-11-28T00:10:59.876616Z","shell.execute_reply.started":"2025-11-28T00:10:59.847085Z","shell.execute_reply":"2025-11-28T00:10:59.875366Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"# Visualize","metadata":{}},{"cell_type":"code","source":"def overlay_mask(rgb, mask, alpha=0.4, color=(255, 0, 0)):\n    \"\"\"\n    rgb: H,W,3 uint8\n    mask: H,W uint8 {0,1}\n    color: BGR tuple\n    \"\"\"\n    overlay = rgb.copy()\n    overlay[mask == 1] = color\n    out = cv2.addWeighted(overlay, alpha, rgb, 1-alpha, 0)\n    return out","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-28T00:10:59.877692Z","iopub.execute_input":"2025-11-28T00:10:59.877967Z","iopub.status.idle":"2025-11-28T00:10:59.900005Z","shell.execute_reply.started":"2025-11-28T00:10:59.877945Z","shell.execute_reply":"2025-11-28T00:10:59.898617Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def mask_contour(mask):\n    # simple contour\n    contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)\n    return contours\n\ndef draw_mask_contour(gray_img, mask):\n    # gray_img: H,W float [0,1]\n    vis = (gray_img * 255).astype(np.uint8)\n    vis_color = cv2.cvtColor(vis, cv2.COLOR_GRAY2BGR)\n    contours = mask_contour(mask)\n    cv2.drawContours(vis_color, contours, -1, (0,0,255), 1)\n    return vis_color","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-28T00:10:59.902702Z","iopub.execute_input":"2025-11-28T00:10:59.902999Z","iopub.status.idle":"2025-11-28T00:10:59.923286Z","shell.execute_reply.started":"2025-11-28T00:10:59.902977Z","shell.execute_reply":"2025-11-28T00:10:59.921747Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def visualize_for_image(img_path, rle, ela_q=90):\n    # Load RGB\n    image = cv2.imread(img_path, cv2.IMREAD_COLOR)\n    image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)  # convert to RGB\n    H, W, _ = image.shape\n\n    # Decode mask\n    mask = rle_decode(rle, (H, W)).astype(np.uint8)\n\n    # Compute maps\n    ela = compute_ela(image, quality=ela_q)\n    hp  = compute_highpass(image)\n    resid = compute_noise_residual(image)\n\n    # Overlays\n    rgb_overlay = overlay_mask(image, mask, alpha=0.5, color=(255,0,0))\n    ela_cont = draw_mask_contour(ela, mask)\n    hp_cont = draw_mask_contour(hp, mask)\n    resid_cont = draw_mask_contour(resid, mask)\n\n    # Plot\n    fig, axes = plt.subplots(2, 3, figsize=(15, 10))\n\n    axes[0,0].imshow(image)\n    axes[0,0].set_title(\"Original RGB\")\n    axes[0,0].axis(\"off\")\n\n    axes[0,1].imshow(rgb_overlay)\n    axes[0,1].set_title(\"RGB + mask overlay\")\n    axes[0,1].axis(\"off\")\n\n    axes[0,2].imshow(mask, cmap=\"gray\")\n    axes[0,2].set_title(\"Ground truth mask\")\n    axes[0,2].axis(\"off\")\n\n    axes[1,0].imshow(ela, cmap=\"gray\")\n    axes[1,0].set_title(\"ELA\")\n    axes[1,0].axis(\"off\")\n\n    axes[1,1].imshow(hp, cmap=\"gray\")\n    axes[1,1].set_title(\"High-pass\")\n    axes[1,1].axis(\"off\")\n\n    axes[1,2].imshow(resid, cmap=\"gray\")\n    axes[1,2].set_title(\"Noise residual\")\n    axes[1,2].axis(\"off\")\n\n    plt.tight_layout()\n    plt.show()\n\n    # Optionally show with contours:\n    fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n    axes[0].imshow(ela_cont)\n    axes[0].set_title(\"ELA + mask contour\")\n    axes[0].axis(\"off\")\n    axes[1].imshow(hp_cont)\n    axes[1].set_title(\"High-pass + contour\")\n    axes[1].axis(\"off\")\n    axes[2].imshow(resid_cont)\n    axes[2].set_title(\"Residual + contour\")\n    axes[2].axis(\"off\")\n    plt.tight_layout()\n    plt.show()","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-28T00:10:59.924563Z","iopub.execute_input":"2025-11-28T00:10:59.924907Z","iopub.status.idle":"2025-11-28T00:10:59.941460Z","shell.execute_reply.started":"2025-11-28T00:10:59.924877Z","shell.execute_reply":"2025-11-28T00:10:59.939931Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"forged_sample = forged_df.sample(4)\nauth_sample = auth_df.sample(2)\n\n# Visualize forged\nfor _, row in forged_sample.iterrows():\n    img_path = row.image_path\n    print(\"FORGED:\")\n    visualize_for_image(img_path, row[\"rle\"])\n    \n# Visualize some authentics to see \"false signals\"\nfor _, row in auth_sample.iterrows():\n    img_path = row.image_path\n    print(\"AUTHENTIC:\")\n    visualize_for_image(img_path, row[\"rle\"]) ","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-28T00:16:42.436518Z","iopub.execute_input":"2025-11-28T00:16:42.436845Z","iopub.status.idle":"2025-11-28T00:16:52.452943Z","shell.execute_reply.started":"2025-11-28T00:16:42.436821Z","shell.execute_reply":"2025-11-28T00:16:52.451774Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}