{"metadata":{"kernelspec":{"language":"python","display_name":"Python 3","name":"python3"},"language_info":{"name":"python","version":"3.12.12","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"kaggle":{"accelerator":"nvidiaTeslaT4","dataSources":[{"sourceId":113558,"databundleVersionId":14878066,"sourceType":"competition"}],"dockerImageVersionId":31236,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# ============================================================\n# Recod.ai / LUC Scientific Image Forgery Detection\n# Robust Kaggle Baseline Pipeline\n# ============================================================\n\nimport os\nimport numpy as np\nimport cv2\nimport matplotlib.pyplot as plt\nfrom sklearn.model_selection import train_test_split\nfrom sklearn.metrics import f1_score\n\n# -----------------------------\n# PATHS (Kaggle environment)\n# -----------------------------\nBASE_PATH = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection\"\nIMAGE_DIR = os.path.join(BASE_PATH, \"train_images\")\nMASK_DIR  = os.path.join(BASE_PATH, \"train_masks\")\n\n# -----------------------------\n# LOAD IMAGE\n# -----------------------------\ndef load_image(path):\n    img = cv2.imread(path)\n    img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n    return img\n\n# -----------------------------------------------------------\n# ROBUST MASK LOADER\n# Handles any shape (C, H, W)\n# Returns:\n#   merged_mask: (H, W) → for training\n#   full_mask:   (C, H, W) → for visualization\n# -----------------------------------------------------------\ndef load_mask(path):\n    mask = np.load(path)\n\n    if mask.ndim == 2:\n        # Edge case: single channel stored as (H, W)\n        full_mask = mask[np.newaxis, ...]\n    else:\n        full_mask = mask\n\n    merged_mask = np.max(full_mask, axis=0).astype(np.uint8)\n\n    return merged_mask, full_mask\n\n# -----------------------------------------------------------\n# DYNAMIC VISUALIZATION (UP TO 5 CHANNELS)\n# -----------------------------------------------------------\ndef visualize_image_with_masks(image, full_mask, alpha=0.5):\n    \"\"\"\n    image: (H, W, 3)\n    full_mask: (C, H, W)\n    \"\"\"\n    overlay = image.copy()\n\n    colors = [\n        (0, 255, 0),    # Green\n        (255, 0, 0),    # Red\n        (0, 0, 255),    # Blue\n        (255, 255, 0),  # Yellow\n        (255, 0, 255)   # Magenta\n    ]\n\n    num_channels = min(full_mask.shape[0], len(colors))\n\n    for c in range(num_channels):\n        channel_mask = full_mask[c] > 0\n        color = np.array(colors[c])\n\n        overlay[channel_mask] = (\n            (1 - alpha) * overlay[channel_mask] + alpha * color\n        ).astype(np.uint8)\n\n    plt.figure(figsize=(6, 6))\n    plt.imshow(overlay)\n    plt.axis(\"off\")\n    plt.title(f\"Overlay of {num_channels} mask channels\")\n    plt.show()\n\n# -----------------------------------------------------------\n# RLE ENCODE\n# -----------------------------------------------------------\ndef rle_encode(mask):\n    pixels = mask.flatten(order=\"F\")\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# -----------------------------------------------------------\n# RLE DECODE\n# -----------------------------------------------------------\ndef rle_decode(rle, shape):\n    if rle is None or rle == \"\":\n        return np.zeros(shape, dtype=np.uint8)\n\n    s = list(map(int, rle.split()))\n    starts, lengths = s[::2], s[1::2]\n    starts = np.array(starts) - 1\n\n    mask = np.zeros(shape[0] * shape[1], dtype=np.uint8)\n    for start, length in zip(starts, lengths):\n        mask[start:start + length] = 1\n\n    return mask.reshape(shape, order=\"F\")\n\n# -----------------------------------------------------------\n# LOAD DATA (AUTHENTIC + FORGED)\n# -----------------------------------------------------------\nimages = []\nmerged_masks = []\nfull_masks = []\nlabels = []  # 0 = authentic, 1 = forged\n\nfor class_name in [\"authentic\", \"forged\"]:\n    class_dir = os.path.join(IMAGE_DIR, class_name)\n    for fname in os.listdir(class_dir):\n        img_path = os.path.join(class_dir, fname)\n        image = load_image(img_path)\n\n        if class_name == \"forged\":\n            mask_path = os.path.join(MASK_DIR, fname.replace(\".png\", \".npy\"))\n            merged_mask, full_mask = load_mask(mask_path)\n        else:\n            h, w, _ = image.shape\n            merged_mask = np.zeros((h, w), dtype=np.uint8)\n            full_mask = np.zeros((1, h, w), dtype=np.uint8)\n\n        images.append(image)\n        merged_masks.append(merged_mask)\n        full_masks.append(full_mask)\n        labels.append(1 if class_name == \"forged\" else 0)\n\nprint(f\"Total samples loaded: {len(images)}\")\nprint(f\"Authentic: {labels.count(0)}, Forged: {labels.count(1)}\")\n\n# -----------------------------------------------------------\n# STRATIFIED TRAIN-TEST SPLIT (80/20)\n# -----------------------------------------------------------\n(\n    X_train,\n    X_test,\n    y_train_masks,\n    y_test_masks,\n    y_train_labels,\n    y_test_labels\n) = train_test_split(\n    images,\n    merged_masks,\n    labels,\n    test_size=0.2,\n    stratify=labels,\n    random_state=42\n)\n\nprint(\"Train set:\", len(X_train))\nprint(\"Test set :\", len(X_test))\n\n# -----------------------------------------------------------\n# VISUAL CHECK (First forged sample)\n# -----------------------------------------------------------\nfor i, label in enumerate(labels):\n    if label == 1:\n        visualize_image_with_masks(images[i], full_masks[i])\n        break\n\n# -----------------------------------------------------------\n# BASELINE: Predict ALL as AUTHENTIC\n# -----------------------------------------------------------\ndef baseline_predict(shape):\n    return np.zeros(shape, dtype=np.uint8)\n\n# -----------------------------------------------------------\n# LOCAL F1 SCORE\n# -----------------------------------------------------------\ny_true = []\ny_pred = []\n\nfor gt_mask in y_test_masks:\n    pred_mask = baseline_predict(gt_mask.shape)\n\n    y_true.extend(gt_mask.flatten())\n    y_pred.extend(pred_mask.flatten())\n\nf1 = f1_score(y_true, y_pred)\nprint(f\"Baseline F1 Score (All Authentic): {f1:.6f}\")\n\n# -----------------------------------------------------------\n# RLE PIPELINE CHECK\n# -----------------------------------------------------------\nsample_mask = y_test_masks[0]\nencoded = rle_encode(sample_mask)\ndecoded = rle_decode(encoded, sample_mask.shape)\n\nprint(\"RLE Encode/Decode correct:\", np.all(sample_mask == decoded))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T15:41:57.631461Z","iopub.execute_input":"2025-12-29T15:41:57.632505Z","execution_failed":"2025-12-29T15:46:04.574Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nimport numpy as np\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nfrom torch.utils.data import Dataset, DataLoader\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nfrom torchvision import models\nfrom sklearn.metrics import f1_score\nfrom tqdm import tqdm\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T15:34:54.155663Z","iopub.execute_input":"2025-12-29T15:34:54.156040Z","iopub.status.idle":"2025-12-29T15:35:02.768493Z","shell.execute_reply.started":"2025-12-29T15:34:54.156008Z","shell.execute_reply":"2025-12-29T15:35:02.767664Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class ForgeryDataset(Dataset):\n    def __init__(self, images, masks, augment=False):\n        self.images = images\n        self.masks = masks\n        self.augment = augment\n\n        self.transform = A.Compose([\n            A.HorizontalFlip(p=0.5),\n            A.VerticalFlip(p=0.5),\n            A.RandomRotate90(p=0.5),\n            A.RandomBrightnessContrast(p=0.5),\n            A.GaussNoise(p=0.5),\n            A.Normalize(mean=(0.5,), std=(0.5,)),\n            ToTensorV2()\n        ]) if augment else A.Compose([\n            A.Normalize(mean=(0.5,), std=(0.5,)),\n            ToTensorV2()\n        ])\n\n    def __len__(self):\n        return len(self.images)\n\n    def __getitem__(self, idx):\n        image = self.images[idx]\n        mask = self.masks[idx]\n\n        augmented = self.transform(image=image, mask=mask)\n        return augmented[\"image\"], augmented[\"mask\"].unsqueeze(0)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T15:35:02.769889Z","iopub.execute_input":"2025-12-29T15:35:02.770717Z","iopub.status.idle":"2025-12-29T15:35:02.776401Z","shell.execute_reply.started":"2025-12-29T15:35:02.770688Z","shell.execute_reply":"2025-12-29T15:35:02.775851Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class SRMConv(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.conv = nn.Conv2d(3, 3, kernel_size=5, padding=2, bias=False)\n\n        srm = np.array([\n            [[0, 0, 0, 0, 0],\n             [0, -1, 2, -1, 0],\n             [0, 2, -4, 2, 0],\n             [0, -1, 2, -1, 0],\n             [0, 0, 0, 0, 0]]\n        ])\n\n        weight = np.repeat(srm, 3, axis=0)\n        self.conv.weight.data = torch.tensor(weight, dtype=torch.float32)\n\n        for param in self.parameters():\n            param.requires_grad = False\n\n    def forward(self, x):\n        return self.conv(x)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T15:35:02.777199Z","iopub.execute_input":"2025-12-29T15:35:02.777422Z","iopub.status.idle":"2025-12-29T15:35:02.791655Z","shell.execute_reply.started":"2025-12-29T15:35:02.777400Z","shell.execute_reply":"2025-12-29T15:35:02.790943Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class TwoStreamUNet(nn.Module):\n    def __init__(self):\n        super().__init__()\n\n        # Semantic Stream\n        resnet = models.resnet34(weights=\"IMAGENET1K_V1\")\n        self.encoder = nn.Sequential(*list(resnet.children())[:-2])\n\n        # Noise Stream\n        self.noise = SRMConv()\n\n        # Fusion\n        self.fuse = nn.Conv2d(512 + 3, 512, kernel_size=1)\n\n        # Decoder\n        self.decoder = nn.Sequential(\n            nn.ConvTranspose2d(512, 256, 2, 2),\n            nn.ReLU(),\n            nn.ConvTranspose2d(256, 128, 2, 2),\n            nn.ReLU(),\n            nn.ConvTranspose2d(128, 64, 2, 2),\n            nn.ReLU(),\n            nn.ConvTranspose2d(64, 32, 2, 2),\n            nn.ReLU(),\n            nn.Conv2d(32, 1, kernel_size=1)\n        )\n\n    def forward(self, x):\n        semantic = self.encoder(x)\n        noise = self.noise(x)\n        noise = nn.functional.interpolate(noise, size=semantic.shape[2:])\n\n        fused = torch.cat([semantic, noise], dim=1)\n        fused = self.fuse(fused)\n\n        return self.decoder(fused)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T15:35:02.793188Z","iopub.execute_input":"2025-12-29T15:35:02.793409Z","iopub.status.idle":"2025-12-29T15:35:02.806281Z","shell.execute_reply.started":"2025-12-29T15:35:02.793389Z","shell.execute_reply":"2025-12-29T15:35:02.805529Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class DiceLoss(nn.Module):\n    def forward(self, logits, targets):\n        probs = torch.sigmoid(logits)\n        smooth = 1e-6\n\n        intersection = (probs * targets).sum()\n        union = probs.sum() + targets.sum()\n\n        return 1 - (2 * intersection + smooth) / (union + smooth)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T15:35:02.807160Z","iopub.execute_input":"2025-12-29T15:35:02.807568Z","iopub.status.idle":"2025-12-29T15:35:02.820772Z","shell.execute_reply.started":"2025-12-29T15:35:02.807536Z","shell.execute_reply":"2025-12-29T15:35:02.819981Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def image_level_f1(model, loader, device):\n    model.eval()\n    y_true, y_pred = [], []\n\n    with torch.no_grad():\n        for images, masks in loader:\n            images = images.to(device)\n            masks = masks.to(device)\n\n            preds = torch.sigmoid(model(images)) > 0.5\n\n            for p, m in zip(preds, masks):\n                y_pred.append(int(p.any()))\n                y_true.append(int(m.any()))\n\n    return f1_score(y_true, y_pred)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T15:35:02.821596Z","iopub.execute_input":"2025-12-29T15:35:02.821833Z","iopub.status.idle":"2025-12-29T15:35:02.832646Z","shell.execute_reply.started":"2025-12-29T15:35:02.821811Z","shell.execute_reply":"2025-12-29T15:35:02.832085Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"def train_model(model, train_loader, val_loader, device, epochs=25):\n    optimizer = optim.Adam(model.parameters(), lr=1e-4)\n    scheduler = optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience=3)\n    criterion_bce = nn.BCEWithLogitsLoss()\n    criterion_dice = DiceLoss()\n\n    best_f1 = 0\n    patience, patience_counter = 6, 0\n\n    for epoch in range(epochs):\n        model.train()\n        epoch_loss = 0\n\n        for images, masks in tqdm(train_loader):\n            images, masks = images.to(device), masks.to(device)\n\n            optimizer.zero_grad()\n            outputs = model(images)\n\n            loss = criterion_bce(outputs, masks) + criterion_dice(outputs, masks)\n            loss.backward()\n            optimizer.step()\n\n            epoch_loss += loss.item()\n\n        val_f1 = image_level_f1(model, val_loader, device)\n        scheduler.step(1 - val_f1)\n\n        print(f\"Epoch {epoch+1}: Loss={epoch_loss:.4f}, Val F1={val_f1:.4f}\")\n\n        if val_f1 > best_f1:\n            best_f1 = val_f1\n            torch.save(model.state_dict(), \"best_model.pth\")\n            patience_counter = 0\n        else:\n            patience_counter += 1\n\n        if patience_counter >= patience:\n            print(\"Early stopping triggered.\")\n            break\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T15:35:02.833262Z","iopub.execute_input":"2025-12-29T15:35:02.833504Z","iopub.status.idle":"2025-12-29T15:35:02.842211Z","shell.execute_reply.started":"2025-12-29T15:35:02.833459Z","shell.execute_reply":"2025-12-29T15:35:02.841541Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"A.PadIfNeeded(\n    min_height=256,\n    min_width=256,\n    border_mode=cv2.BORDER_CONSTANT,\n    value=0\n)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T16:07:27.958298Z","iopub.execute_input":"2025-12-29T16:07:27.959011Z","iopub.status.idle":"2025-12-29T16:07:27.966559Z","shell.execute_reply.started":"2025-12-29T16:07:27.958978Z","shell.execute_reply":"2025-12-29T16:07:27.965876Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_tfms = A.Compose([\n    A.LongestMaxSize(max_size=256),\n    A.PadIfNeeded(\n        min_height=256,\n        min_width=256,\n        border_mode=cv2.BORDER_CONSTANT,\n        value=0\n    ),\n\n    A.HorizontalFlip(p=0.5),\n    A.VerticalFlip(p=0.5),\n    A.RandomRotate90(p=0.5),\n    A.RandomBrightnessContrast(p=0.5),\n    A.GaussNoise(p=0.5),\n\n    A.Normalize(mean=(0.5, 0.5, 0.5),\n                std=(0.5, 0.5, 0.5)),\n    ToTensorV2()\n])\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T16:07:40.300306Z","iopub.execute_input":"2025-12-29T16:07:40.300920Z","iopub.status.idle":"2025-12-29T16:07:40.309984Z","shell.execute_reply.started":"2025-12-29T16:07:40.300888Z","shell.execute_reply":"2025-12-29T16:07:40.309214Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"val_tfms = A.Compose([\n    A.LongestMaxSize(max_size=256),\n    A.PadIfNeeded(\n        min_height=256,\n        min_width=256,\n        border_mode=cv2.BORDER_CONSTANT,\n        value=0\n    ),\n\n    A.Normalize(mean=(0.5, 0.5, 0.5),\n                std=(0.5, 0.5, 0.5)),\n    ToTensorV2()\n])\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T16:07:52.763678Z","iopub.execute_input":"2025-12-29T16:07:52.764405Z","iopub.status.idle":"2025-12-29T16:07:52.771081Z","shell.execute_reply.started":"2025-12-29T16:07:52.764374Z","shell.execute_reply":"2025-12-29T16:07:52.770336Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"train_tfms = A.Compose([\n    # --- SIZE NORMALIZATION (ASPECT RATIO SAFE) ---\n    A.LongestMaxSize(max_size=256),\n    A.PadIfNeeded(\n        min_height=256,\n        min_width=256,\n        border_mode=cv2.BORDER_CONSTANT\n    ),\n\n    # --- AUGMENTATIONS ---\n    A.HorizontalFlip(p=0.5),\n    A.VerticalFlip(p=0.5),\n    A.RandomRotate90(p=0.5),\n    A.RandomBrightnessContrast(p=0.5),\n    A.GaussNoise(p=0.5),\n\n    # --- NORMALIZATION ---\n    A.Normalize(\n        mean=(0.5, 0.5, 0.5),\n        std=(0.5, 0.5, 0.5)\n    ),\n\n    ToTensorV2()\n])\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T16:11:47.719971Z","iopub.execute_input":"2025-12-29T16:11:47.720843Z","iopub.status.idle":"2025-12-29T16:11:47.728961Z","shell.execute_reply.started":"2025-12-29T16:11:47.720810Z","shell.execute_reply":"2025-12-29T16:11:47.728378Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"val_tfms = A.Compose([\n    # --- SIZE NORMALIZATION (ASPECT RATIO SAFE) ---\n    A.LongestMaxSize(max_size=256),\n    A.PadIfNeeded(\n        min_height=256,\n        min_width=256,\n        border_mode=cv2.BORDER_CONSTANT\n    ),\n\n    # --- NORMALIZATION ONLY ---\n    A.Normalize(\n        mean=(0.5, 0.5, 0.5),\n        std=(0.5, 0.5, 0.5)\n    ),\n\n    ToTensorV2()\n])\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T16:12:02.418186Z","iopub.execute_input":"2025-12-29T16:12:02.418512Z","iopub.status.idle":"2025-12-29T16:12:02.424849Z","shell.execute_reply.started":"2025-12-29T16:12:02.418463Z","shell.execute_reply":"2025-12-29T16:12:02.424159Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class SRMConv(nn.Module):\n    \"\"\"\n    Channel-wise SRM convolution using grouped Conv2d.\n    Each RGB channel is filtered independently.\n    \"\"\"\n    def __init__(self):\n        super().__init__()\n\n        # groups=3 → channel-wise convolution\n        self.conv = nn.Conv2d(\n            in_channels=3,\n            out_channels=3,\n            kernel_size=5,\n            padding=2,\n            bias=False,\n            groups=3\n        )\n\n        # Standard 5x5 SRM high-pass filter\n        srm_kernel = np.array([\n            [0,  0,  0,  0,  0],\n            [0, -1,  2, -1,  0],\n            [0,  2, -4,  2,  0],\n            [0, -1,  2, -1,  0],\n            [0,  0,  0,  0,  0]\n        ], dtype=np.float32)\n\n        # Expand to (out_channels, in_channels/groups, H, W)\n        # → (3, 1, 5, 5)\n        weight = np.zeros((3, 1, 5, 5), dtype=np.float32)\n        for c in range(3):\n            weight[c, 0] = srm_kernel\n\n        self.conv.weight.data = torch.from_numpy(weight)\n\n        # Freeze SRM filters (standard practice)\n        for param in self.parameters():\n            param.requires_grad = False\n\n    def forward(self, x):\n        return self.conv(x)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T16:34:27.575934Z","iopub.execute_input":"2025-12-29T16:34:27.576267Z","iopub.status.idle":"2025-12-29T16:34:27.583715Z","shell.execute_reply.started":"2025-12-29T16:34:27.576239Z","shell.execute_reply":"2025-12-29T16:34:27.582714Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class TwoStreamUNet(nn.Module):\n    def __init__(self):\n        super().__init__()\n\n        # -----------------------------\n        # Semantic Stream (ResNet)\n        # -----------------------------\n        resnet = models.resnet34(weights=\"IMAGENET1K_V1\")\n        self.encoder = nn.Sequential(*list(resnet.children())[:-2])  # (B, 512, H/32, W/32)\n\n        # -----------------------------\n        # Noise Stream (Fixed SRM)\n        # -----------------------------\n        self.noise = SRMConv()  # outputs (B, 3, H, W)\n\n        # -----------------------------\n        # Feature Fusion\n        # -----------------------------\n        self.fuse = nn.Conv2d(\n            in_channels=512 + 3,\n            out_channels=512,\n            kernel_size=1\n        )\n\n        # -----------------------------\n        # Decoder (Lightweight UNet-style)\n        # -----------------------------\n        self.decoder = nn.Sequential(\n            nn.ConvTranspose2d(512, 256, kernel_size=2, stride=2),\n            nn.ReLU(inplace=True),\n\n            nn.ConvTranspose2d(256, 128, kernel_size=2, stride=2),\n            nn.ReLU(inplace=True),\n\n            nn.ConvTranspose2d(128, 64, kernel_size=2, stride=2),\n            nn.ReLU(inplace=True),\n\n            nn.ConvTranspose2d(64, 32, kernel_size=2, stride=2),\n            nn.ReLU(inplace=True),\n\n            nn.Conv2d(32, 1, kernel_size=1)\n        )\n\n    def forward(self, x):\n        # Semantic features\n        sem_feat = self.encoder(x)\n\n        # Noise features\n        noise_feat = self.noise(x)\n        noise_feat = nn.functional.interpolate(\n            noise_feat,\n            size=sem_feat.shape[2:],\n            mode=\"bilinear\",\n            align_corners=False\n        )\n\n        # Fuse\n        fused = torch.cat([sem_feat, noise_feat], dim=1)\n        fused = self.fuse(fused)\n\n        # Decode\n        out = self.decoder(fused)\n        return out\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T16:34:27.585652Z","iopub.execute_input":"2025-12-29T16:34:27.585927Z","iopub.status.idle":"2025-12-29T16:34:27.600758Z","shell.execute_reply.started":"2025-12-29T16:34:27.585898Z","shell.execute_reply":"2025-12-29T16:34:27.600001Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"class TwoStreamUNet(nn.Module):\n    def __init__(self):\n        super().__init__()\n\n        # --------------------------------------------------\n        # Semantic Stream (ResNet34)\n        # Input:  (B, 3, 256, 256)\n        # Output: (B, 512, 8, 8)\n        # --------------------------------------------------\n        resnet = models.resnet34(weights=\"IMAGENET1K_V1\")\n        self.encoder = nn.Sequential(*list(resnet.children())[:-2])\n\n        # --------------------------------------------------\n        # Noise Stream (SRMConv)\n        # Input:  (B, 3, 256, 256)\n        # Output: (B, 3, 256, 256)\n        # --------------------------------------------------\n        self.noise = SRMConv()\n\n        # --------------------------------------------------\n        # Feature Fusion\n        # (B, 512 + 3, 8, 8) → (B, 512, 8, 8)\n        # --------------------------------------------------\n        self.fuse = nn.Conv2d(512 + 3, 512, kernel_size=1)\n\n        # --------------------------------------------------\n        # Decoder (5-stage upsampling)\n        # --------------------------------------------------\n        self.up1 = nn.ConvTranspose2d(512, 256, kernel_size=2, stride=2)\n        # 8×8 → 16×16\n\n        self.up2 = nn.ConvTranspose2d(256, 128, kernel_size=2, stride=2)\n        # 16×16 → 32×32\n\n        self.up3 = nn.ConvTranspose2d(128, 64, kernel_size=2, stride=2)\n        # 32×32 → 64×64\n\n        self.up4 = nn.ConvTranspose2d(64, 32, kernel_size=2, stride=2)\n        # 64×64 → 128×128\n\n        self.up5 = nn.ConvTranspose2d(32, 32, kernel_size=2, stride=2)\n        # 128×128 → 256×256\n\n        # --------------------------------------------------\n        # Final Prediction Layer\n        # Output: (B, 1, 256, 256)\n        # No sigmoid (handled by BCEWithLogitsLoss)\n        # --------------------------------------------------\n        self.out_conv = nn.Conv2d(32, 1, kernel_size=1)\n\n        self.relu = nn.ReLU(inplace=True)\n\n    def forward(self, x):\n        # Semantic features\n        sem = self.encoder(x)  # (B, 512, 8, 8)\n\n        # Noise features\n        noise = self.noise(x)  # (B, 3, 256, 256)\n        noise = nn.functional.interpolate(\n            noise,\n            size=sem.shape[2:],  # → (8, 8)\n            mode=\"bilinear\",\n            align_corners=False\n        )\n\n        # Fuse streams\n        fused = torch.cat([sem, noise], dim=1)  # (B, 515, 8, 8)\n        fused = self.fuse(fused)                # (B, 512, 8, 8)\n\n        # Decoder\n        x = self.relu(self.up1(fused))  # 8×8   → 16×16\n        x = self.relu(self.up2(x))      # 16×16 → 32×32\n        x = self.relu(self.up3(x))      # 32×32 → 64×64\n        x = self.relu(self.up4(x))      # 64×64 → 128×128\n        x = self.relu(self.up5(x))      # 128×128 → 256×256\n\n        out = self.out_conv(x)          # (B, 1, 256, 256)\n        return out\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T16:34:27.601736Z","iopub.execute_input":"2025-12-29T16:34:27.602028Z","iopub.status.idle":"2025-12-29T16:34:27.618446Z","shell.execute_reply.started":"2025-12-29T16:34:27.601998Z","shell.execute_reply":"2025-12-29T16:34:27.617800Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# Execution Block — Start Training\n# ============================================================\n\n# Device setup\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(\"Using device:\", device)\n\n# DataLoaders\nBATCH_SIZE = 8      # reduce to 4 if GPU OOM\nNUM_WORKERS = 2\n\ntrain_loader = DataLoader(\n    train_ds,\n    batch_size=BATCH_SIZE,\n    shuffle=True,\n    num_workers=NUM_WORKERS,\n    pin_memory=True\n)\n\nval_loader = DataLoader(\n    val_ds,\n    batch_size=BATCH_SIZE,\n    shuffle=False,\n    num_workers=NUM_WORKERS,\n    pin_memory=True\n)\n\n# Initialize model (fixed 5-stage decoder)\nmodel = TwoStreamUNet().to(device)\nprint(\"TwoStreamUNet initialized and moved to device.\")\n\n# Launch training loop (tqdm + Val Image-F1 shown each epoch)\ntrain_model(\n    model=model,\n    train_loader=train_loader,\n    val_loader=val_loader,\n    device=device,\n    epochs=25\n)\n\nprint(\"Training completed. Best model saved as best_model.pth\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T16:38:08.563081Z","iopub.execute_input":"2025-12-29T16:38:08.563797Z","iopub.status.idle":"2025-12-29T17:42:35.276861Z","shell.execute_reply.started":"2025-12-29T16:38:08.563765Z","shell.execute_reply":"2025-12-29T17:42:35.275911Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"# ============================================================\n# Inference + Submission Script\n# Recod.ai / LUC Scientific Image Forgery Detection\n# ============================================================\n\nimport os\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torch.nn.functional as F\nfrom tqdm import tqdm\n\n# -----------------------------\n# Paths\n# -----------------------------\nBASE = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection\"\nTEST_DIR = os.path.join(BASE, \"test_images\")\nMODEL_PATH = \"best_model.pth\"\n\n# -----------------------------\n# Device\n# -----------------------------\ndevice = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\nprint(\"Using device:\", device)\n\n# -----------------------------\n# Load Model\n# -----------------------------\nmodel = TwoStreamUNet().to(device)\nmodel.load_state_dict(torch.load(MODEL_PATH, map_location=device))\nmodel.eval()\n\nprint(\"Loaded best_model.pth successfully\")\n\n# -----------------------------\n# RLE Encode (same as training)\n# -----------------------------\ndef rle_encode(mask):\n    \"\"\"\n    mask: 2D binary mask (H, W)\n    returns: RLE string\n    \"\"\"\n    pixels = mask.flatten(order=\"F\")\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# -----------------------------\n# CLAHE (same as Dataset)\n# -----------------------------\nclahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))\n\ndef apply_clahe_rgb(img_rgb):\n    lab = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2LAB)\n    l, a, b = cv2.split(lab)\n    l = clahe.apply(l)\n    lab = cv2.merge((l, a, b))\n    return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)\n\n# -----------------------------\n# Inference Loop\n# -----------------------------\nresults = []\n\ntest_images = sorted(os.listdir(TEST_DIR))\n\nfor fname in tqdm(test_images, desc=\"Running inference\"):\n    img_path = os.path.join(TEST_DIR, fname)\n\n    # --- Load original image ---\n    img_bgr = cv2.imread(img_path)\n    img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)\n    img_rgb = apply_clahe_rgb(img_rgb)\n\n    orig_h, orig_w, _ = img_rgb.shape\n\n    # --- Resize to model input (256x256) ---\n    img_resized = cv2.resize(img_rgb, (256, 256), interpolation=cv2.INTER_LINEAR)\n\n    # --- Normalize (same as validation) ---\n    img_tensor = img_resized.astype(np.float32) / 255.0\n    img_tensor = (img_tensor - 0.5) / 0.5\n    img_tensor = torch.from_numpy(img_tensor).permute(2, 0, 1).unsqueeze(0).to(device)\n\n    # --- Forward pass ---\n    with torch.no_grad():\n        logits = model(img_tensor)\n        probs = torch.sigmoid(logits)[0, 0].cpu().numpy()  # (256, 256)\n\n    # --- Resize mask back to original size ---\n    probs_orig = cv2.resize(\n        probs,\n        (orig_w, orig_h),\n        interpolation=cv2.INTER_LINEAR\n    )\n\n    # --- Thresholding logic ---\n    max_prob = probs_orig.max()\n    bin_mask = (probs_orig > 0.5).astype(np.uint8)\n    forged_pixels = bin_mask.sum()\n\n    if max_prob < 0.5 or forged_pixels < 100:\n        annotation = \"authentic\"\n    else:\n        annotation = rle_encode(bin_mask)\n\n    case_id = os.path.splitext(fname)[0]\n    results.append({\n        \"case_id\": case_id,\n        \"annotation\": annotation\n    })\n\n# -----------------------------\n# Save submission.csv\n# -----------------------------\nsubmission = pd.DataFrame(results)\nsubmission.to_csv(\"submission.csv\", index=False)\n\nprint(\"submission.csv created successfully!\")\nprint(submission.head())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-12-29T17:43:09.227372Z","iopub.execute_input":"2025-12-29T17:43:09.227995Z","iopub.status.idle":"2025-12-29T17:43:10.053853Z","shell.execute_reply.started":"2025-12-29T17:43:09.227959Z","shell.execute_reply":"2025-12-29T17:43:10.053206Z"}},"outputs":[],"execution_count":null}]}