{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","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":14174843,"sourceType":"competition"}],"dockerImageVersionId":31154,"isInternetEnabled":false,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"markdown","source":"# CMFD — Offline Baseline (Pure PyTorch U‑Net, authentic/forged subfolders)\n\nKaggle **Internet Off**\n\nHighlights\n\n-🔌 No pip installs — Only use libraries available in the standard Kaggle environment (torch, torchvision, numpy, pandas, PIL, matplotlib).\n\n-🧠 A pure U-Net implementation using PyTorch.\n\n-🗂️ Automatically supports subfolders train_images/authentic/ and train_images/forged/.\n\n-🧩 If multiple masks are present, combine them using logical OR.\n\n-🧾 RLE encode/decode, compatible with sample_submission.csv format.\n\n-⚖️ Loss function: BCEWithLogits + Dice loss.\n\n","metadata":{}},{"cell_type":"code","source":"\n# 1) Import and settings\nimport os, random, glob\nfrom pathlib import Path\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\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 sklearn.model_selection import StratifiedKFold\n\nSEED = 42\nrandom.seed(SEED)\nnp.random.seed(SEED)\ntorch.manual_seed(SEED)\ntorch.cuda.manual_seed_all(SEED)\n\nDATA_DIR = Path('/kaggle/input/recodai-luc-scientific-image-forgery-detection')\nTRAIN_IMG_DIR = DATA_DIR / 'train_images'\nTEST_IMG_DIR  = DATA_DIR / 'test_images'\nTRAIN_MASK_DIR = DATA_DIR / 'train_masks'\nSAMPLE_SUB_PATH = DATA_DIR / 'sample_submission.csv'\nOUTPUT_DIR = Path('/kaggle/working/'); OUTPUT_DIR.mkdir(exist_ok=True)\n\n# Дэд фолдерууд\nTRAIN_IMG_DIR_AUTH = TRAIN_IMG_DIR / 'authentic'\nTRAIN_IMG_DIR_FORG = TRAIN_IMG_DIR / 'forged'\n\n# Hyperparams\nIMG_SIZE = 256\nBATCH_SIZE = 8\nEPOCHS = 20\nLR = 1e-3\nDEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'\nprint('Device:', DEVICE)\n\n# Туслах\nIMG_EXTS = {'.png', '.jpg', '.jpeg', '.tif', '.bmp'}\n\ndef is_image(p: Path):\n    return p.is_file() and p.suffix.lower() in IMG_EXTS\n\n# Train index (authentic/forged)\ndef build_train_index():\n    rows = []\n    for split_name, root in [('authentic', TRAIN_IMG_DIR_AUTH), ('forged', TRAIN_IMG_DIR_FORG)]:\n        if root.exists():\n            for p in sorted(root.rglob('*')):\n                if is_image(p):\n                    rows.append({'case_id': p.stem, 'split': split_name, 'path': str(p)})\n    df = pd.DataFrame(rows)\n    if len(df)==0:\n        print('⚠️ train_images/authentic & forged хоосон эсвэл олдсонгүй')\n        return pd.DataFrame(columns=['case_id','split','path'])\n    df = df.drop_duplicates(subset=['case_id'], keep='last').reset_index(drop=True)\n    return df\n\nTRAIN_INDEX = build_train_index()\nID2PATH = {row.case_id: Path(row.path) for _, row in TRAIN_INDEX.iterrows()}\nprint(f\"Train index: {len(TRAIN_INDEX)} images (auth={(TRAIN_INDEX.split=='authentic').sum()}, forg={(TRAIN_INDEX.split=='forged').sum()})\")\n\nprint('Sanity:')\nprint('train_images:', TRAIN_IMG_DIR.exists())\nprint('train_masks :', TRAIN_MASK_DIR.exists())\nprint('test_images :', TEST_IMG_DIR.exists())\nprint('sample_submission:', SAMPLE_SUB_PATH.exists())\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-27T03:18:01.182610Z","iopub.execute_input":"2025-10-27T03:18:01.182904Z","iopub.status.idle":"2025-10-27T03:18:12.549228Z","shell.execute_reply.started":"2025-10-27T03:18:01.182873Z","shell.execute_reply":"2025-10-27T03:18:12.548593Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# 2) Utils — RLE, IO, mask\n\ndef rle_encode(mask: np.ndarray) -> str:\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[1::2] - runs[::2]\n    return ' '.join(str(x) for x in runs)\n\n\ndef rle_decode(rle: str, shape) -> np.ndarray:\n    if isinstance(rle, float) and np.isnan(rle):\n        return np.zeros(shape, dtype=np.uint8)\n    s = rle.strip().split()\n    starts, lengths = [np.asarray(x, dtype=int) for x in (s[0::2], s[1::2])]\n    starts -= 1\n    ends = starts + lengths\n    img = np.zeros(shape[0]*shape[1], dtype=np.uint8)\n    for lo, hi in zip(starts, ends):\n        img[lo:hi] = 1\n    return img.reshape(shape, order='F')\n\n\ndef load_image(path: Path) -> np.ndarray:\n    img = Image.open(path).convert('RGB')\n    return np.array(img)\n\n\ndef load_all_masks_for_case(case_id: str, mask_dir: Path, image_shape=None) -> np.ndarray:\n    patterns = [\n        str(mask_dir / f'{case_id}*.png'),\n        str(mask_dir / f'{case_id}*.jpg'),\n        str(mask_dir / f'{case_id}*.jpeg'),\n        str(mask_dir / f'{case_id}*.tif'),\n        str(mask_dir / f'{case_id}*.bmp')\n    ]\n    files = []\n    for pat in patterns:\n        files.extend(glob.glob(pat))\n    if len(files)==0:\n        return None\n    mask_sum = None\n    for fp in files:\n        m = Image.open(fp).convert('L')\n        m = (np.array(m) > 0).astype(np.uint8)\n        if image_shape is not None and m.shape != image_shape[:2]:\n            m = np.array(Image.fromarray(m).resize((image_shape[1], image_shape[0]), resample=Image.NEAREST))\n        mask_sum = m if mask_sum is None else np.maximum(mask_sum, m)\n    return mask_sum\n\n\ndef has_mask(case_id: str) -> int:\n    m = load_all_masks_for_case(case_id, TRAIN_MASK_DIR)\n    return 1 if m is not None and m.sum() > 0 else 0\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-27T03:18:15.197801Z","iopub.execute_input":"2025-10-27T03:18:15.198659Z","iopub.status.idle":"2025-10-27T03:18:15.208284Z","shell.execute_reply.started":"2025-10-27T03:18:15.198632Z","shell.execute_reply":"2025-10-27T03:18:15.207463Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# 3) Simple transform (numpy/PIL/torch) \nclass SimpleTransform:\n    def __init__(self, img_size=512, train=True):\n        self.img_size = img_size\n        self.train = train\n\n    def _resize_pad(self, img, mask=None):\n        # Longest side fit + pad to square (IMG_SIZE)\n        h, w = img.shape[:2]\n        scale = self.img_size / max(h, w)\n        nh, nw = int(round(h*scale)), int(round(w*scale))\n        img_r = np.array(Image.fromarray(img).resize((nw, nh), resample=Image.BILINEAR))\n        pad_h = self.img_size - nh\n        pad_w = self.img_size - nw\n        top = pad_h//2; bottom = pad_h - top\n        left = pad_w//2; right = pad_w - left\n        img_p = np.pad(img_r, ((top,bottom),(left,right),(0,0)), mode='reflect')\n        if mask is None:\n            return img_p, None\n        mask_r = np.array(Image.fromarray(mask).resize((nw, nh), resample=Image.NEAREST))\n        mask_p = np.pad(mask_r, ((top,bottom),(left,right)), mode='reflect')\n        return img_p, mask_p\n\n    def _hflip(self, img, mask):\n        if random.random() < 0.5:\n            img = np.ascontiguousarray(img[:, ::-1])\n            if mask is not None:\n                mask = np.ascontiguousarray(mask[:, ::-1])\n        return img, mask\n\n    def _vflip(self, img, mask):\n        if random.random() < 0.5:\n            img = np.ascontiguousarray(img[::-1, :])\n            if mask is not None:\n                mask = np.ascontiguousarray(mask[::-1, :])\n        return img, mask\n\n    def __call__(self, image: np.ndarray, mask: np.ndarray=None):\n        img, msk = image, mask\n        img, msk = self._resize_pad(img, msk)\n        if self.train:\n            img, msk = self._hflip(img, msk)\n            img, msk = self._vflip(img, msk)\n        # Normalize to ImageNet stats\n        mean = np.array([0.485,0.456,0.406], dtype=np.float32)\n        std  = np.array([0.229,0.224,0.225], dtype=np.float32)\n        img = img.astype(np.float32)/255.0\n        img = (img - mean)/std\n        img = np.transpose(img, (2,0,1))  # CHW\n        img_t = torch.from_numpy(img)\n        if msk is None:\n            return {'image': img_t}\n        msk = (msk>0).astype(np.float32)\n        msk_t = torch.from_numpy(msk)[None, ...]  # 1HW\n        return {'image': img_t, 'mask': msk_t}\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-27T03:18:18.916838Z","iopub.execute_input":"2025-10-27T03:18:18.917513Z","iopub.status.idle":"2025-10-27T03:18:18.927019Z","shell.execute_reply.started":"2025-10-27T03:18:18.917492Z","shell.execute_reply":"2025-10-27T03:18:18.926274Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# 4) Dataset\n\nclass CMFDDataset(Dataset):\n    def __init__(self, case_ids, img_dir: Path, mask_dir: Path=None, transform=None, id2path: dict=None):\n        self.case_ids = case_ids\n        self.img_dir = img_dir\n        self.mask_dir = mask_dir\n        self.transform = transform\n        self.id2path = id2path or {}\n\n    def __len__(self):\n        return len(self.case_ids)\n\n    def __getitem__(self, idx):\n        cid = self.case_ids[idx]\n        # path resolve (subfolders supported)\n        if cid in self.id2path:\n            img_path = self.id2path[cid]\n        else:\n            # fallback to root\n            img_path = None\n            for ext in IMG_EXTS:\n                p = self.img_dir / f\"{cid}{ext}\"\n                if p.exists():\n                    img_path = p; break\n            if img_path is None:\n                raise FileNotFoundError(f\"Image file not found for {cid}\")\n        img = load_image(img_path)\n\n        msk = None\n        if self.mask_dir is not None:\n            msk = load_all_masks_for_case(cid, self.mask_dir, img.shape)\n            if msk is None:\n                msk = np.zeros(img.shape[:2], dtype=np.uint8)\n\n        if self.transform is not None:\n            data = self.transform(img, msk)\n            img_t = data['image']\n            msk_t = data.get('mask', None)\n        else:\n            img_t = torch.from_numpy(np.transpose(img.astype(np.float32)/255.0, (2,0,1)))\n            msk_t = None if msk is None else torch.from_numpy(msk.astype(np.float32))[None, ...]\n\n        return {'image': img_t, 'mask': msk_t, 'id': cid}\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-27T03:18:21.733255Z","iopub.execute_input":"2025-10-27T03:18:21.733528Z","iopub.status.idle":"2025-10-27T03:18:21.740670Z","shell.execute_reply.started":"2025-10-27T03:18:21.733507Z","shell.execute_reply":"2025-10-27T03:18:21.740046Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# 5) Model - Pytorch, U-Net\n\nclass DoubleConv(nn.Module):\n    def __init__(self, in_c, out_c):\n        super().__init__()\n        self.net = nn.Sequential(\n            nn.Conv2d(in_c, out_c, 3, padding=1, bias=False),\n            nn.BatchNorm2d(out_c),\n            nn.ReLU(inplace=True),\n            nn.Conv2d(out_c, out_c, 3, padding=1, bias=False),\n            nn.BatchNorm2d(out_c),\n            nn.ReLU(inplace=True),\n        )\n    def forward(self, x):\n        return self.net(x)\n\nclass UNet(nn.Module):\n    def __init__(self, in_ch=3, out_ch=1, base_ch=32):\n        super().__init__()\n        self.enc1 = DoubleConv(in_ch, base_ch)\n        self.enc2 = DoubleConv(base_ch, base_ch*2)\n        self.enc3 = DoubleConv(base_ch*2, base_ch*4)\n        self.enc4 = DoubleConv(base_ch*4, base_ch*8)\n        self.pool = nn.MaxPool2d(2)\n\n        self.bottleneck = DoubleConv(base_ch*8, base_ch*16)\n\n        self.up4 = nn.ConvTranspose2d(base_ch*16, base_ch*8, 2, stride=2)\n        self.dec4 = DoubleConv(base_ch*16, base_ch*8)\n        self.up3 = nn.ConvTranspose2d(base_ch*8, base_ch*4, 2, stride=2)\n        self.dec3 = DoubleConv(base_ch*8, base_ch*4)\n        self.up2 = nn.ConvTranspose2d(base_ch*4, base_ch*2, 2, stride=2)\n        self.dec2 = DoubleConv(base_ch*4, base_ch*2)\n        self.up1 = nn.ConvTranspose2d(base_ch*2, base_ch, 2, stride=2)\n        self.dec1 = DoubleConv(base_ch*2, base_ch)\n\n        self.outc = nn.Conv2d(base_ch, out_ch, 1)\n\n    def forward(self, x):\n        e1 = self.enc1(x)\n        e2 = self.enc2(self.pool(e1))\n        e3 = self.enc3(self.pool(e2))\n        e4 = self.enc4(self.pool(e3))\n        b  = self.bottleneck(self.pool(e4))\n\n        d4 = self.up4(b)\n        d4 = torch.cat([d4, e4], dim=1)\n        d4 = self.dec4(d4)\n        d3 = self.up3(d4)\n        d3 = torch.cat([d3, e3], dim=1)\n        d3 = self.dec3(d3)\n        d2 = self.up2(d3)\n        d2 = torch.cat([d2, e2], dim=1)\n        d2 = self.dec2(d2)\n        d1 = self.up1(d2)\n        d1 = torch.cat([d1, e1], dim=1)\n        d1 = self.dec1(d1)\n        out = self.outc(d1)\n        return out\n\nclass SoftDiceLoss(nn.Module):\n    def __init__(self, smooth=1.0):\n        super().__init__()\n        self.smooth = smooth\n    def forward(self, logits, targets):\n        probs = torch.sigmoid(logits)\n        num = 2.0 * (probs*targets).sum(dim=(2,3)) + self.smooth\n        den = (probs*probs).sum(dim=(2,3)) + (targets*targets).sum(dim=(2,3)) + self.smooth\n        dice = num / den\n        return 1.0 - dice.mean()\n\nclass BCEDiceLoss(nn.Module):\n    def __init__(self):\n        super().__init__()\n        self.bce = nn.BCEWithLogitsLoss()\n        self.dice = SoftDiceLoss()\n    def forward(self, logits, targets):\n        return 0.5*self.bce(logits, targets) + 0.5*self.dice(logits, targets)\n\n\ndef dice_coeff(preds: torch.Tensor, targets: torch.Tensor, thr=0.5, eps=1e-7):\n    probs = torch.sigmoid(preds)\n    preds = (probs > thr).float()\n    inter = (preds*targets).sum(dim=(2,3))\n    union = preds.sum(dim=(2,3)) + targets.sum(dim=(2,3))\n    dice = (2*inter + eps) / (union + eps)\n    return dice.mean().item()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-27T03:18:24.557433Z","iopub.execute_input":"2025-10-27T03:18:24.557706Z","iopub.status.idle":"2025-10-27T03:18:24.570958Z","shell.execute_reply.started":"2025-10-27T03:18:24.557686Z","shell.execute_reply":"2025-10-27T03:18:24.570243Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# 6) Training (K-Fold)\n\ndef fit_one_epoch(model, loader, optimizer, loss_fn):\n    model.train()\n    loss_sum = 0.0\n    dice_sum = 0.0\n    for batch in loader:\n        imgs = batch['image'].to(DEVICE)\n        msks = batch['mask'].to(DEVICE)\n        optimizer.zero_grad(set_to_none=True)\n        logits = model(imgs)\n        loss = loss_fn(logits, msks)\n        loss.backward()\n        optimizer.step()\n        loss_sum += loss.item()*imgs.size(0)\n        dice_sum += dice_coeff(logits.detach(), msks)\n    n = len(loader.dataset)\n    return loss_sum/n, dice_sum/len(loader)\n\n\ndef validate_one_epoch(model, loader, loss_fn):\n    model.eval()\n    loss_sum = 0.0\n    dice_sum = 0.0\n    with torch.no_grad():\n        for batch in loader:\n            imgs = batch['image'].to(DEVICE)\n            msks = batch['mask'].to(DEVICE)\n            logits = model(imgs)\n            loss = loss_fn(logits, msks)\n            loss_sum += loss.item()*imgs.size(0)\n            dice_sum += dice_coeff(logits, msks)\n    n = len(loader.dataset)\n    return loss_sum/n, dice_sum/len(loader)\n\n# Case ids & labels\nall_ids = TRAIN_INDEX['case_id'].tolist()\nlabels = np.array([has_mask(cid) for cid in all_ids])\nprint(f\"Нийт train images: {len(all_ids)} | Forged(1): {labels.sum()} | Authentic(0): {(labels==0).sum()}\")\n\nfolds = 3\nskf = StratifiedKFold(n_splits=folds, shuffle=True, random_state=SEED)\n\nhistory = []\nBEST_MODELS = []\n\nfor fold, (tr_idx, va_idx) in enumerate(skf.split(all_ids, labels), start=1):\n    print(f\"\\n========== Fold {fold}/{folds} ==========\")\n    tr_ids = [all_ids[i] for i in tr_idx]\n    va_ids = [all_ids[i] for i in va_idx]\n\n    tr_ds = CMFDDataset(tr_ids, TRAIN_IMG_DIR, TRAIN_MASK_DIR, transform=SimpleTransform(IMG_SIZE, train=True), id2path=ID2PATH)\n    va_ds = CMFDDataset(va_ids, TRAIN_IMG_DIR, TRAIN_MASK_DIR, transform=SimpleTransform(IMG_SIZE, train=False), id2path=ID2PATH)\n\n    tr_dl = DataLoader(tr_ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=2, pin_memory=True)\n    va_dl = DataLoader(va_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=2, pin_memory=True)\n\n    model = UNet(in_ch=3, out_ch=1, base_ch=32).to(DEVICE)\n    optimizer = torch.optim.AdamW(model.parameters(), lr=LR)\n    loss_fn = BCEDiceLoss()\n\n    best_dice = -1.0\n    best_path = OUTPUT_DIR / f\"model_fold{fold}.pt\"\n    patience, patience_cnt = 5, 0\n\n    for epoch in range(1, EPOCHS+1):\n        tl, td = fit_one_epoch(model, tr_dl, optimizer, loss_fn)\n        vl, vd = validate_one_epoch(model, va_dl, loss_fn)\n        print(f\"Epoch {epoch:02d} | train_loss={tl:.4f} dice={td:.4f} | valid_loss={vl:.4f} dice={vd:.4f}\")\n        history.append({\"fold\": fold, \"epoch\": epoch, \"tr_loss\": tl, \"tr_dice\": td, \"va_loss\": vl, \"va_dice\": vd})\n        if vd > best_dice:\n            best_dice = vd\n            torch.save({\"state_dict\": model.state_dict(), \"dice\": best_dice}, best_path)\n            patience_cnt = 0\n        else:\n            patience_cnt += 1\n            if patience_cnt >= patience:\n                print(\"Early stopping!\")\n                break\n    BEST_MODELS.append(str(best_path))\n\npd.DataFrame(history).to_csv(OUTPUT_DIR/\"training_history.csv\", index=False)\nprint(\"\\nBest model paths:\")\nfor p in BEST_MODELS:\n    print(p)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-10-27T03:18:27.755931Z","iopub.execute_input":"2025-10-27T03:18:27.756210Z","iopub.status.idle":"2025-10-27T03:21:11.669332Z","shell.execute_reply.started":"2025-10-27T03:18:27.756190Z","shell.execute_reply":"2025-10-27T03:21:11.668223Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# 6.b) DEBUG — маскууд олдож байна уу?\nif len(TRAIN_INDEX)>0:\n    sample_ids = TRAIN_INDEX['case_id'].sample(min(10, len(TRAIN_INDEX)), random_state=SEED).tolist()\n    missing = []\n    for cid in sample_ids:\n        m = load_all_masks_for_case(cid, TRAIN_MASK_DIR)\n        if m is None:\n            missing.append(cid)\n    print(f\"Жишээ {len(sample_ids)} case-оос маск олдоогүй нь: {len(missing)}\")\n    if missing:\n        print(\"Маск олдоогүй case_id-уудын жишээ:\", missing[:10])\n\nmask_files = glob.glob(str(TRAIN_MASK_DIR / '*'))[:10]\nprint('train_masks дахь файлуудын жишээ:', [Path(x).name for x in mask_files])\n","metadata":{},"outputs":[],"execution_count":null},{"cell_type":"code","source":"\n# 7) Inference & submission (Ensemble: fold checkpoints avg)\n\ndef post_process(mask: np.ndarray, min_size: int = 64):\n    # remove small connected areas (simple, no skimage)\n    # 4-connectivity\n    H, W = mask.shape\n    visited = np.zeros_like(mask, dtype=np.uint8)\n    out = np.zeros_like(mask, dtype=np.uint8)\n    dirs = [(1,0),(-1,0),(0,1),(0,-1)]\n    from collections import deque\n    for y in range(H):\n        for x in range(W):\n            if mask[y,x] and not visited[y,x]:\n                q = deque([(y,x)])\n                visited[y,x] = 1\n                comp = [(y,x)]\n                while q:\n                    cy,cx = q.popleft()\n                    for dy,dx in dirs:\n                        ny,nx = cy+dy, cx+dx\n                        if 0<=ny<H and 0<=nx<W and mask[ny,nx] and not visited[ny,nx]:\n                            visited[ny,nx]=1\n                            q.append((ny,nx))\n                            comp.append((ny,nx))\n                if len(comp) >= min_size:\n                    for (yy,xx) in comp:\n                        out[yy,xx] = 1\n    return out\n\n# test ids (no subfolders)\n\ndef get_case_ids(img_dir: Path) -> list:\n    ids = []\n    for p in sorted(img_dir.iterdir()):\n        if p.is_file() and p.suffix.lower() in IMG_EXTS:\n            ids.append(p.stem)\n    return ids\n\ncase_ids_test = get_case_ids(TEST_IMG_DIR)\n\nclass TestDataset(Dataset):\n    def __init__(self, case_ids, img_dir: Path, transform):\n        self.case_ids = case_ids\n        self.img_dir = img_dir\n        self.transform = transform\n    def __len__(self):\n        return len(self.case_ids)\n    def __getitem__(self, idx):\n        cid = self.case_ids[idx]\n        img_path = None\n        for ext in IMG_EXTS:\n            p = self.img_dir / f\"{cid}{ext}\"\n            if p.exists():\n                img_path = p; break\n        if img_path is None:\n            raise FileNotFoundError(cid)\n        img = load_image(img_path)\n        t = self.transform(img, None)\n        return {'image': t['image'], 'id': cid}\n\n# Load models\nmodels = []\nfor fn in os.listdir(OUTPUT_DIR):\n    if fn.startswith('model_fold') and fn.endswith('.pt'):\n        ckpt_path = OUTPUT_DIR / fn\n        model = UNet(in_ch=3, out_ch=1, base_ch=32).to(DEVICE)\n        state = torch.load(ckpt_path, map_location=DEVICE)\n        model.load_state_dict(state['state_dict'])\n        model.eval()\n        models.append(model)\n\n# Inference\n\ntest_ds = TestDataset(case_ids_test, TEST_IMG_DIR, transform=SimpleTransform(IMG_SIZE, train=False))\n\ntest_dl = DataLoader(test_ds, batch_size=BATCH_SIZE, shuffle=False, num_workers=2, pin_memory=True)\n\nall_preds = {}\nwith torch.no_grad():\n    for batch in test_dl:\n        imgs = batch['image'].to(DEVICE)\n        ids = batch['id']\n        if len(models)==0:\n            # safety: randomly output zeros\n            logits_sum = torch.zeros((imgs.size(0),1,imgs.size(2),imgs.size(3)), device=DEVICE)\n        else:\n            logits_sum = None\n            for mdl in models:\n                lo = mdl(imgs)\n                logits_sum = lo if logits_sum is None else (logits_sum + lo)\n            logits_sum = logits_sum / max(1,len(models))\n        probs = torch.sigmoid(logits_sum).cpu().numpy()\n        for i, cid in enumerate(ids):\n            m = (probs[i,0] > 0.5).astype(np.uint8)\n            m = post_process(m, min_size=64)\n            all_preds[cid] = m\n\n# Submission\nsub = pd.read_csv(SAMPLE_SUB_PATH)\nannotations = []\nfor _, row in sub.iterrows():\n    cid = str(row['case_id'])\n    if cid in all_preds and all_preds[cid].sum() > 0:\n        annotations.append(rle_encode(all_preds[cid]))\n    else:\n        annotations.append('authentic')\nsub['annotation'] = annotations\nSUB_PATH = OUTPUT_DIR / 'submission.csv'\nsub.to_csv(SUB_PATH, index=False)\nprint('Saved:', SUB_PATH)\n","metadata":{},"outputs":[],"execution_count":null}]}