{"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":"nvidiaTeslaT4","dataSources":[{"sourceId":113558,"databundleVersionId":14456136,"isSourceIdPinned":false,"sourceType":"competition"},{"sourceId":278230946,"sourceType":"kernelVersion"},{"sourceId":4534,"sourceType":"modelInstanceVersion","isSourceIdPinned":false,"modelInstanceId":3326,"modelId":986},{"sourceId":648498,"sourceType":"modelInstanceVersion","isSourceIdPinned":false,"modelInstanceId":489174,"modelId":504592}],"dockerImageVersionId":31193,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"import sys\n\n# Added '/wheels' to the end of the path\n!pip install segmentation-models-pytorch --no-index --find-links=/kaggle/input/forgery-detection-dependency-downloader/wheels","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-11-20T12:16:31.799955Z","iopub.execute_input":"2025-11-20T12:16:31.800913Z","iopub.status.idle":"2025-11-20T12:19:35.249657Z","shell.execute_reply.started":"2025-11-20T12:16:31.800883Z","shell.execute_reply":"2025-11-20T12:19:35.248678Z"},"_kg_hide-output":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import os\nos.environ[\"PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION\"] = \"python\"\n\nimport glob\nimport cv2\nimport numpy as np\nimport pandas as pd\nimport random\nimport gc\nimport json\nfrom tqdm import tqdm\nimport matplotlib.pyplot as plt\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nfrom torch.optim.lr_scheduler import CosineAnnealingWarmRestarts\nfrom torch.cuda.amp import autocast, GradScaler\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\nimport segmentation_models_pytorch as smp\nfrom transformers import AutoModel\n\n# ====================================================\n# 1. CONFIGURATION\n# ====================================================\nclass CFG:\n    seed = 42\n    debug = False\n    \n    # Paths\n    train_img_dir = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection/train_images\"\n    train_mask_dir = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection/train_masks\"\n    test_img_dir = \"/kaggle/input/recodai-luc-scientific-image-forgery-detection/test_images\"\n    save_path = \"best_hybrid_model.pth\"\n    submission_path = \"submission.csv\"\n    \n    # DINO Path (Offline)\n    dino_model = \"/kaggle/input/dinov2/pytorch/base/1\"\n    \n    img_size = 518 \n    epochs = 25\n    batch_size = 2\n    accum_iter = 8\n    lr = 5e-5\n    weight_decay = 1e-4\n    num_workers = 4\n    device = 'cuda' if torch.cuda.is_available() else 'cpu'\n\ndef set_seed(seed=42):\n    random.seed(seed)\n    os.environ['PYTHONHASHSEED'] = str(seed)\n    np.random.seed(seed)\n    torch.manual_seed(seed)\n    torch.cuda.manual_seed(seed)\n    torch.backends.cudnn.deterministic = True\n\nset_seed(CFG.seed)\n\n# ====================================================\n# 2. MODEL ARCHITECTURE\n# ====================================================\n\nclass BayarConv2d(nn.Module):\n    def __init__(self, in_channels, out_channels, kernel_size=5, padding=2):\n        super(BayarConv2d, self).__init__()\n        self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, padding=padding, bias=False)\n        self.register_buffer('mask', torch.ones(out_channels, in_channels, kernel_size, kernel_size))\n        self.mask[:, :, kernel_size//2, kernel_size//2] = 0\n        nn.init.xavier_uniform_(self.conv.weight)\n        self.conv.weight.data *= 0.1 \n\n    def forward(self, x):\n        self.conv.weight.data *= self.mask\n        weight_sum = self.conv.weight.data.sum(dim=(2, 3), keepdim=True)\n        self.conv.weight.data /= (weight_sum + 1e-7)\n        self.conv.weight.data[:, :, 2, 2] = -1.0 \n        return self.conv(x)\n\nclass DinoV2Encoder(nn.Module):\n    def __init__(self, model_name=CFG.dino_model):\n        super().__init__()\n        # Load from local path\n        self.dino = AutoModel.from_pretrained(model_name)\n        self.embed_dim = self.dino.config.hidden_size \n        \n        self.adapter_s1 = nn.Sequential(nn.Conv2d(self.embed_dim, 64, 1), nn.Upsample(scale_factor=4, mode='bilinear', align_corners=False))\n        self.adapter_s2 = nn.Sequential(nn.Conv2d(self.embed_dim, 128, 1), nn.Upsample(scale_factor=2, mode='bilinear', align_corners=False))\n        self.adapter_s3 = nn.Conv2d(self.embed_dim, 256, 1)\n        self.adapter_s4 = nn.Sequential(nn.Conv2d(self.embed_dim, 512, 1), nn.MaxPool2d(2))\n\n    def forward(self, x):\n        outputs = self.dino(pixel_values=x)\n        \n        patch_tokens = outputs.last_hidden_state[:, 1:, :]\n        B, N, C = patch_tokens.shape\n        H = W = int(N ** 0.5)\n        x_reshaped = patch_tokens.permute(0, 2, 1).reshape(B, C, H, W)\n        \n        return [\n            self.adapter_s1(x_reshaped),\n            self.adapter_s2(x_reshaped),\n            self.adapter_s3(x_reshaped),\n            self.adapter_s4(x_reshaped)\n        ]\nclass HybridDualStreamModel(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.rgb_encoder = DinoV2Encoder(CFG.dino_model)\n        self.bayar_conv = BayarConv2d(3, 3)\n        \n        # Offline weights=None\n        self.noise_encoder = smp.encoders.get_encoder(\n            \"resnet18\", in_channels=3, depth=5, weights=None \n        )\n        \n        # Base channels from dual streams\n        base_channels = [128, 256, 512, 1024]\n        \n        self.projectors = nn.ModuleList([\n            nn.Conv2d(c, c // 2, kernel_size=1) for c in base_channels\n        ])\n        \n        # Define decoder channels including the input image (3 channels)\n        decoder_encoder_channels = [3] + [c // 2 for c in base_channels]\n        \n        self.decoder = smp.decoders.unet.decoder.UnetDecoder(\n            encoder_channels=decoder_encoder_channels,\n            decoder_channels=(256, 128, 64, 32),\n            n_blocks=4,\n            attention_type='scse'\n        )\n        \n        self.segmentation_head = smp.base.SegmentationHead(\n            in_channels=32, out_channels=1, activation=None\n        )\n\n    def forward(self, x):\n        rgb_pyramid = self.rgb_encoder(x)\n        noise_input = self.bayar_conv(x)\n        noise_all = self.noise_encoder(noise_input)\n        noise_pyramid = [noise_all[2], noise_all[3], noise_all[4], noise_all[5]]\n        \n        fused_pyramid = []\n        for i in range(len(rgb_pyramid)):\n            if noise_pyramid[i].shape[2:] != rgb_pyramid[i].shape[2:]:\n                noise_pyramid[i] = F.interpolate(\n                    noise_pyramid[i], size=rgb_pyramid[i].shape[2:], mode='bilinear', align_corners=False\n                )\n            cat = torch.cat([rgb_pyramid[i], noise_pyramid[i]], dim=1)\n            fused_pyramid.append(self.projectors[i](cat))\n        \n        # Create the full feature list\n        full_features = [x] + fused_pyramid\n        \n        decoder_out = self.decoder(full_features)\n        \n        return self.segmentation_head(decoder_out)\n\n# ====================================================\n# 3. DATASET & TRAINING LOOP\n# ====================================================\n\ndef get_transforms(data):\n    if data == 'train':\n        return A.Compose([\n            A.Resize(CFG.img_size, CFG.img_size),\n            A.HorizontalFlip(p=0.5),\n            A.VerticalFlip(p=0.5),\n            A.RandomRotate90(p=0.5),\n            A.ImageCompression(p=0.5), \n            A.GaussNoise(p=0.5),\n            A.GaussianBlur(blur_limit=(3, 7), p=0.2),\n            A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n            ToTensorV2(),\n        ])\n    elif data == 'valid':\n        return A.Compose([\n            A.Resize(CFG.img_size, CFG.img_size),\n            A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n            ToTensorV2(),\n        ])\n\nclass ScientificForgeryDataset(Dataset):\n    def __init__(self, df, transform=None):\n        self.df = df\n        self.transform = transform\n    def __len__(self): return len(self.df)\n    def __getitem__(self, idx):\n        row = self.df.iloc[idx]\n        image = cv2.imread(row['image_path'])\n        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)\n        if row['label'] == 1:\n            mask = np.load(row['mask_path'])\n            if mask.ndim == 3: mask = np.max(mask, axis=0)\n            mask = (mask > 0).astype(np.float32)\n        else:\n            h, w = image.shape[:2]\n            mask = np.zeros((h, w), dtype=np.float32)\n        if self.transform:\n            aug = self.transform(image=image, mask=mask)\n            image, mask = aug['image'], aug['mask']\n        return image, mask.unsqueeze(0)\n\ndef train_one_epoch(model, optimizer, scheduler, dataloader, device, epoch):\n    model.train()\n    scaler = torch.amp.GradScaler('cuda')\n    running_loss = 0.0\n    loss_focal = smp.losses.FocalLoss(mode='binary')\n    loss_dice = smp.losses.DiceLoss(mode='binary')\n    \n    pbar = tqdm(dataloader, desc=f\"Train Epoch {epoch}\")\n    for step, (images, masks) in enumerate(pbar):\n        images = images.to(device, dtype=torch.float)\n        masks = masks.to(device, dtype=torch.float)\n        \n        with torch.amp.autocast('cuda'):\n            y_pred = model(images)\n            loss = 0.6 * loss_focal(y_pred, masks) + 0.4 * loss_dice(y_pred, masks)\n            loss = loss / CFG.accum_iter\n        \n        scaler.scale(loss).backward()\n        \n        if (step + 1) % CFG.accum_iter == 0:\n            scaler.step(optimizer)\n            scaler.update()\n            optimizer.zero_grad()\n            scheduler.step()\n            \n        running_loss += (loss.item() * CFG.accum_iter)\n        pbar.set_postfix(loss=f\"{loss.item() * CFG.accum_iter:.4f}\")\n        \n    return running_loss / len(dataloader)\n\n@torch.no_grad()\ndef valid_one_epoch(model, dataloader, device):\n    model.eval()\n    loss_dice = smp.losses.DiceLoss(mode='binary')\n    val_scores = []\n    for images, masks in tqdm(dataloader, desc=\"Valid\"):\n        images = images.to(device, dtype=torch.float)\n        masks = masks.to(device, dtype=torch.float)\n        with torch.amp.autocast('cuda'):\n            y_pred = model(images)\n            y_pred = y_pred.sigmoid()\n            score = 1 - loss_dice(y_pred, masks).item()\n            val_scores.append(score)\n    return np.mean(val_scores)\n\ndef run_training():\n    # 1. Clear Memory First\n    gc.collect()\n    torch.cuda.empty_cache()\n    \n    # 2. Prepare Data\n    auth_files = glob.glob(f\"{CFG.train_img_dir}/authentic/*.png\")\n    forg_files = glob.glob(f\"{CFG.train_img_dir}/forged/*.png\")\n    \n    data = []\n    for p in auth_files:\n        data.append({\"image_path\": p, \"mask_path\": None, \"label\": 0})\n    \n    for p in forg_files:\n        mid = os.path.basename(p).split('.')[0]\n        mpath = f\"{CFG.train_mask_dir}/{mid}.npy\"\n        if os.path.exists(mpath):\n            data.append({\"image_path\": p, \"mask_path\": mpath, \"label\": 1})\n            \n    df = pd.DataFrame(data)\n    \n    from sklearn.model_selection import train_test_split\n    train_df, valid_df = train_test_split(df, test_size=0.15, random_state=CFG.seed, stratify=df['label'])\n    \n    train_ds = ScientificForgeryDataset(train_df, get_transforms('train'))\n    valid_ds = ScientificForgeryDataset(valid_df, get_transforms('valid'))\n    \n    train_loader = DataLoader(train_ds, batch_size=CFG.batch_size, shuffle=True, \n                              num_workers=CFG.num_workers, pin_memory=True, drop_last=True)\n    valid_loader = DataLoader(valid_ds, batch_size=CFG.batch_size, shuffle=False, \n                              num_workers=CFG.num_workers, pin_memory=True)\n    \n    # 3. Model Setup\n    model = HybridDualStreamModel().to(CFG.device)\n    \n    optimizer = torch.optim.AdamW(model.parameters(), lr=CFG.lr, weight_decay=CFG.weight_decay)\n    \n    # Adjust scheduler steps for new accumulation\n    num_steps = int(len(train_loader) * CFG.epochs / CFG.accum_iter)\n    scheduler = CosineAnnealingWarmRestarts(optimizer, T_0=max(10, num_steps//5), T_mult=1, eta_min=1e-6)\n    \n    best_score = 0.0\n    \n    print(f\"Starting training with Batch Size: {CFG.batch_size}, Accumulation: {CFG.accum_iter}\")\n    \n    for epoch in range(CFG.epochs):\n        loss = train_one_epoch(model, optimizer, scheduler, train_loader, CFG.device, epoch)\n        \n        # Clear cache after training epoch to free memory for validation\n        torch.cuda.empty_cache()\n        \n        score = valid_one_epoch(model, valid_loader, CFG.device)\n        print(f\"Epoch {epoch}: Train Loss {loss:.4f}, Val Dice {score:.4f}\")\n        \n        if score > best_score:\n            best_score = score\n            torch.save(model.state_dict(), CFG.save_path)\n            print(f\">>> Saved Model (Dice: {best_score:.4f})\")\n            \n    # Cleanup\n    del model, train_loader, valid_loader\n    gc.collect()\n    torch.cuda.empty_cache()\n\n# ====================================================\n# 4. INFERENCE & SUBMISSION\n# ====================================================\n\ndef postprocess_prediction(pred):\n    pred = cv2.GaussianBlur(pred, (3, 3), 0)\n    mask = (pred > 0.5).astype(np.uint8)\n    mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))\n    return mask\n\ndef rle_encode(mask):\n    pixels = mask.flatten()\n    dots = np.where(pixels == 1)[0]\n    if len(dots) == 0: return \"authentic\"\n    run_lengths = []\n    prev = -2\n    for b in dots:\n        if b > prev + 1: run_lengths.extend((b + 1, 0))\n        run_lengths[-1] += 1\n        prev = b\n    return json.dumps([int(x) for x in run_lengths])\n\n@torch.no_grad()\ndef run_inference():\n    if not os.path.exists(CFG.save_path): \n        print(\"No model found, skipping inference.\")\n        return\n    \n    model = HybridDualStreamModel().to(CFG.device)\n    model.load_state_dict(torch.load(CFG.save_path))\n    model.eval()\n    \n    test_files = sorted(glob.glob(f\"{CFG.test_img_dir}/*.png\"))\n    results = []\n    transform = get_transforms('valid')\n    \n    for path in tqdm(test_files, desc=\"Inference\"):\n        img = cv2.imread(path)\n        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)\n        h, w = img.shape[:2]\n        \n        aug = transform(image=img)\n        tensor = aug['image'].unsqueeze(0).to(CFG.device)\n        \n        # TTA\n        pred = torch.sigmoid(model(tensor))\n        pred_h = torch.sigmoid(model(torch.flip(tensor, [3])))\n        pred_v = torch.sigmoid(model(torch.flip(tensor, [2])))\n        avg = (pred + torch.flip(pred_h, [3]) + torch.flip(pred_v, [2])) / 3.0\n        \n        avg = F.interpolate(avg, size=(h, w), mode='bilinear', align_corners=False)\n        mask = postprocess_prediction(avg[0, 0].cpu().numpy())\n        \n        if mask.sum() < 400:\n            rle = \"authentic\"\n        else:\n            rle = rle_encode(mask)\n            \n        results.append({\"case_id\": os.path.basename(path).split('.')[0], \"annotation\": rle})\n        \n    pd.DataFrame(results).to_csv(CFG.submission_path, index=False)\n    print(f\"Submission generated at {CFG.submission_path}\")\n\nif __name__ == \"__main__\":\n    run_training()\n    run_inference()","metadata":{"trusted":true},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}