{"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":"gpu","dataSources":[{"sourceId":117682,"databundleVersionId":15062069,"sourceType":"competition"}],"dockerImageVersionId":31259,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":true}},"nbformat_minor":4,"nbformat":4,"cells":[{"cell_type":"code","source":"# This Python 3 environment comes with many helpful analytics libraries installed\n# It is defined by the kaggle/python Docker image: https://github.com/kaggle/docker-python\n# For example, here's several helpful packages to load\n\n# import numpy as np # linear algebra\nimport pandas as pd # data processing, CSV file I/O (e.g. pd.read_csv)\n\n# Input data files are available in the read-only \"../input/\" directory\n# For example, running this (by clicking run or pressing Shift+Enter) will list all files under the input directory\n\n# import os\n# for dirname, _, filenames in os.walk('/kaggle/input'):\n#     for filename in filenames:\n#         print(os.path.join(dirname, filename))\n\n# You can write up to 20GB to the current directory (/kaggle/working/) that gets preserved as output when you create a version using \"Save & Run All\" \n# You can also write temporary files to /kaggle/temp/, but they won't be saved outside of the current session","metadata":{"_uuid":"8f2839f25d086af736a60e9eeb907d3b93b6e0e5","_cell_guid":"b1076dfc-b9ad-4769-8c92-a6c4dae69d19","trusted":true,"execution":{"iopub.status.busy":"2026-02-03T12:13:10.673740Z","iopub.execute_input":"2026-02-03T12:13:10.674048Z","iopub.status.idle":"2026-02-03T12:13:11.834505Z","shell.execute_reply.started":"2026-02-03T12:13:10.674023Z","shell.execute_reply":"2026-02-03T12:13:11.833851Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"THIS is draft ..... And a test ...","metadata":{}},{"cell_type":"code","source":"!pip install imagecodecs","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-03T12:13:11.835471Z","iopub.execute_input":"2026-02-03T12:13:11.835886Z","iopub.status.idle":"2026-02-03T12:13:17.377271Z","shell.execute_reply.started":"2026-02-03T12:13:11.835848Z","shell.execute_reply":"2026-02-03T12:13:17.376468Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"import imagecodecs","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-03T12:13:17.379820Z","iopub.execute_input":"2026-02-03T12:13:17.380053Z","iopub.status.idle":"2026-02-03T12:13:17.389718Z","shell.execute_reply.started":"2026-02-03T12:13:17.380026Z","shell.execute_reply":"2026-02-03T12:13:17.388799Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"from tifffile import TiffFile","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-03T12:13:17.390773Z","iopub.execute_input":"2026-02-03T12:13:17.391132Z","iopub.status.idle":"2026-02-03T12:13:17.654617Z","shell.execute_reply.started":"2026-02-03T12:13:17.391084Z","shell.execute_reply":"2026-02-03T12:13:17.653931Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"The \"Sandwich\" Concept\n\nIf you are trying to find ink on slice 100 and Z_CONTEXT = 2, the model doesn't just look at slice 100. It looks at a 5-layer \"sandwich\":\n\nSlice 98\n\nSlice 99\n\nSlice 100 (Target)\n\nSlice 101\n\nSlice 102\n\n2. Why is it used? (2.5D vs. 2D)\n\n2D Approach (Z_CONTEXT = 0): The model sees only 1 slice. It is hard to distinguish ink from charred papyrus because they look very similar on a single plane.\n\n2.5D Approach (Z_CONTEXT > 0): By providing layers above and below, the model can see the 3D structure of the ink. Real ink has a specific \"height\" and \"depth\" profile within the papyrus fibers that a single slice can't capture.","metadata":{}},{"cell_type":"code","source":"import os\nimport random\nimport time\nimport zipfile\nimport numpy as np\nimport torch\nimport torch.nn as nn\nimport torch.optim as optim\nimport matplotlib.pyplot as plt\nfrom torch.utils.data import DataLoader, Dataset\nfrom pathlib import Path\nfrom PIL import Image, ImageSequence\nfrom tqdm import tqdm\n\n# ==========================================\n# 1. CONFIGURATION\n# ==========================================\n# Set to True to use a small subset (e.g. 2 volumes) for debugging\nPOC_MODE = False  \n\n# Training constraints\nMAX_BATCHES_PER_EPOCH = 500  # Set to None for full dataset\nMAX_TRAIN_TIME_HOURS = 4     # Limit training time to 3 hours max\nMAX_TRAIN_SECONDS = MAX_TRAIN_TIME_HOURS * 3600\n\nDATA_PATH = Path(\"/kaggle/input/vesuvius-challenge-surface-detection\")\nCHECKPOINT_DIR = Path(\"checkpoints\")\nCHECKPOINT_DIR.mkdir(exist_ok=True)\n\n# Hyperparameters\nPATCH_SIZE = 256\nZ_CONTEXT = 2\nBATCH_SIZE = 16\nLEARNING_RATE = 2e-4\nSAMPLES_PER_ID = 128  # Patches per volume\n\n# ==========================================\n# 2. DATA UTILITIES\n# ==========================================\n\ndef read_volume(path: Path):\n    \"\"\"\n    Read 3D volume using PIL to handle LZW compression safely.\n    Returns a numpy array of shape (D, H, W).\n    \"\"\"\n    try:\n        with Image.open(str(path)) as img:\n            # Efficiently load all frames\n            frames = [np.array(frame) for frame in ImageSequence.Iterator(img)]\n            vol = np.stack(frames, axis=0)\n    except Exception as e:\n        print(f\"Error reading {path}: {e}\")\n        # Return dummy if failed to prevent crash\n        return np.zeros((1, PATCH_SIZE, PATCH_SIZE), dtype=np.uint8)\n    \n    if vol.ndim == 2:\n        vol = vol[None, ...]\n    return vol\n\ndef normalize_patch(patch: np.ndarray) -> np.ndarray:\n    \"\"\"Standardize patch statistics.\"\"\"\n    patch = patch.astype(np.float32)\n    mean = patch.mean()\n    std = patch.std()\n    return (patch - mean) / (std + 1e-6)\n\nclass VesuviusSurfaceDataset(Dataset):\n    def __init__(self, ids, images_dir, labels_dir, samples_per_id, patch_size=PATCH_SIZE, z_context=Z_CONTEXT):\n        self.patch_size = patch_size\n        self.z_context = z_context\n        self.rng = np.random.default_rng(42)\n        self.volumes = {}\n        self.labels = {}\n        self.coords = []\n\n        print(f\"🔍 Indexing {len(ids)} volumes...\")\n        for vid in tqdm(ids, desc=\"Scanning Metadata\"):\n            img_p = images_dir / f\"{vid}.tif\"\n            lbl_p = labels_dir / f\"{vid}.tif\"\n            \n            if not img_p.exists() or not lbl_p.exists(): continue\n            \n            # Open mainly to get dimensions\n            try:\n                with Image.open(img_p) as img:\n                    z_max = getattr(img, \"n_frames\", 1)\n                    w, h = img.size\n            except: continue\n            \n            # Skip if too small\n            if h < patch_size or w < patch_size: continue\n\n            z_min = max(z_context, 5)\n            z_max_r = min(z_max - z_context - 1, z_max - 5)\n            \n            if z_max_r <= z_min: continue\n            \n            # Sample coordinates\n            zs = self.rng.integers(z_min, z_max_r, size=samples_per_id)\n            \n            # Fix: Ensure high >= 1 for rng.integers. \n            # If h == patch_size, we want integers(0, 1) which returns 0.\n            y_high = max(1, h - patch_size)\n            x_high = max(1, w - patch_size)\n            \n            ys = self.rng.integers(0, y_high, size=samples_per_id)\n            xs = self.rng.integers(0, x_high, size=samples_per_id)\n            \n            for z, y, x in zip(zs, ys, xs):\n                self.coords.append({\n                    'vid': vid, 'z': int(z), 'y': int(y), 'x': int(x),\n                    'img_path': img_p, 'lbl_path': lbl_p\n                })\n\n    def __len__(self): return len(self.coords)\n\n    def __getitem__(self, idx):\n        item = self.coords[idx]\n        vid = item['vid']\n        \n        # Simple caching strategy: Keep mostly recent volumes\n        # Logic: If vid not in cache, clear cache and load new. \n        # This keeps RAM usage low (~2GB) instead of exploding.\n        if vid not in self.volumes:\n            if len(self.volumes) > 2:\n                self.volumes.clear()\n                self.labels.clear()\n            self.volumes[vid] = read_volume(item['img_path'])\n            self.labels[vid] = read_volume(item['lbl_path'])\n\n        z, y, x = item['z'], item['y'], item['x']\n        \n        # Extract 2.5D Stack\n        z0, z1 = z - self.z_context, z + self.z_context + 1\n        img_patch = self.volumes[vid][z0:z1, y:y+self.patch_size, x:x+self.patch_size]\n        img_patch = normalize_patch(img_patch)\n        \n        # Extract Label Slice (Center Z)\n        lbl_vol = self.labels[vid]\n        # Handle case where label might be 2D or 3D\n        z_lbl = z if lbl_vol.shape[0] > 1 else 0\n        lbl_patch = lbl_vol[z_lbl, y:y+self.patch_size, x:x+self.patch_size]\n        \n        return torch.from_numpy(img_patch).float(), torch.from_numpy(lbl_patch).float().unsqueeze(0)\n\n# ==========================================\n# 3. MODEL ARCHITECTURE (Pix2Pix)\n# ==========================================\n\nclass Pix2PixGenerator(nn.Module):\n    def __init__(self, in_ch=5, out_ch=1):\n        super().__init__()\n        def block(in_c, out_c, down=True):\n            layers = [nn.Conv2d(in_c, out_c, 4, 2, 1) if down else nn.ConvTranspose2d(in_c, out_c, 4, 2, 1)]\n            layers.append(nn.BatchNorm2d(out_c))\n            layers.append(nn.LeakyReLU(0.2) if down else nn.ReLU())\n            return nn.Sequential(*layers)\n        \n        self.e1 = nn.Sequential(nn.Conv2d(in_ch, 64, 4, 2, 1), nn.LeakyReLU(0.2))\n        self.e2 = block(64, 128); self.e3 = block(128, 256)\n        self.d1 = block(256, 128, False); self.d2 = block(256, 64, False)\n        # Output raw logits (no sigmoid here, handled in loss/vis)\n        self.final = nn.ConvTranspose2d(128, out_ch, 4, 2, 1)\n\n    def forward(self, x):\n        s1 = self.e1(x); s2 = self.e2(s1); s3 = self.e3(s2)\n        u1 = self.d1(s3); u2 = self.d2(torch.cat([u1, s2], 1))\n        return self.final(torch.cat([u2, s1], 1))\n\n# ==========================================\n# 4. VISUALIZATION\n# ==========================================\n\ndef visualize_results(model, dataset, device, epoch):\n    model.eval()\n    if len(dataset) == 0: return\n    \n    # Pick random sample\n    idx = random.randint(0, len(dataset)-1)\n    img, target = dataset[idx]\n    \n    with torch.no_grad():\n        logits = model(img.unsqueeze(0).to(device))\n        pred = torch.sigmoid(logits).cpu().squeeze().numpy()\n    \n    # Prepare images for plotting\n    img_mid = img[Z_CONTEXT].numpy()\n    target_np = target.squeeze().numpy()\n    \n    # Construct RGB Ground Truth Image:\n    # 0 (BG) -> Black, 1 (Surface) -> White, 2 (Ignore) -> Grey\n    gt_viz = np.zeros((*target_np.shape, 3))\n    gt_viz[target_np == 1] = [1, 1, 1]   # White\n    gt_viz[target_np == 2] = [0.5, 0.5, 0.5] # Grey\n    \n    fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n    axes[0].imshow(img_mid, cmap='gray')\n    axes[0].set_title(\"Input (Mid Slice)\")\n    \n    axes[1].imshow(gt_viz)\n    axes[1].set_title(\"GT (Grey=Ignore)\")\n    \n    axes[2].imshow(pred, cmap='magma', vmin=0, vmax=1)\n    axes[2].set_title(f\"Prediction (Max: {pred.max():.2f})\")\n    \n    for ax in axes: ax.axis('off')\n    plt.tight_layout()\n    plt.savefig(f\"epoch_{epoch}_visual.png\")\n    plt.close()\n\n# ==========================================\n# 5. TRAINING & INFERENCE LOGIC\n# ==========================================\n\ndef hybrid_loss(logits, target):\n    \"\"\"\n    BCE + Dice Loss. Masks out pixels with value 2 (Ignore).\n    \"\"\"\n    # Create masks\n    valid_mask = (target != 2).float()\n    target_binary = (target == 1).float()\n    \n    # Masked BCEWithLogits\n    bce_loss = nn.functional.binary_cross_entropy_with_logits(logits, target_binary, reduction='none')\n    masked_bce = (bce_loss * valid_mask).sum() / (valid_mask.sum() + 1e-6)\n    \n    # Soft Dice\n    pred = torch.sigmoid(logits)\n    intersection = (pred * target_binary * valid_mask).sum()\n    dice_den = (pred * valid_mask).sum() + (target_binary * valid_mask).sum()\n    dice_loss = 1 - (2. * intersection + 1e-6) / (dice_den + 1e-6)\n    \n    return 0.5 * masked_bce + 0.5 * dice_loss\n\ndef run_inference(model, device):\n    \"\"\"\n    Inference loop:\n    1. Loads test images.\n    2. Tiles them to handle large sizes.\n    3. Saves multi-page TIFFs matching input depth.\n    4. Zips them for submission.\n    \"\"\"\n    model.eval()\n    test_dir = DATA_PATH / 'test_images'\n    if not test_dir.exists():\n        print(\"Test directory not found. Skipping inference.\")\n        return\n\n    test_ids = [f.stem for f in test_dir.glob('*.tif')]\n    submission_files = []\n\n    print(f\"🏁 Starting Inference on {len(test_ids)} volumes...\")\n\n    for vid in test_ids:\n        vol = read_volume(test_dir / f\"{vid}.tif\")\n        z_max, h, w = vol.shape\n        volume_pages = []\n        \n        # Iterate through every slice to maintain 1-to-1 page mapping\n        for z in tqdm(range(z_max), desc=f\"Infer {vid}\", leave=False):\n            z0, z1 = z - Z_CONTEXT, z + Z_CONTEXT + 1\n            \n            # Edge case handling: output blank page if context is OOB\n            if z0 < 0 or z1 > z_max:\n                volume_pages.append(Image.fromarray(np.zeros((h, w), dtype=np.uint8)))\n                continue\n                \n            page_pred = np.zeros((h, w), dtype=np.uint8)\n            \n            # Tile processing\n            for y in range(0, h, PATCH_SIZE):\n                for x in range(0, w, PATCH_SIZE):\n                    y1, x1 = min(y + PATCH_SIZE, h), min(x + PATCH_SIZE, w)\n                    y0, x0 = y1 - PATCH_SIZE, x1 - PATCH_SIZE\n                    \n                    patch = vol[z0:z1, y0:y1, x0:x1]\n                    patch = normalize_patch(patch)\n                    \n                    patch_t = torch.from_numpy(patch).float().unsqueeze(0).to(device)\n                    with torch.no_grad():\n                        logits = model(patch_t)\n                        pred = torch.sigmoid(logits).cpu().squeeze().numpy()\n                    \n                    # Thresholding 0.5 -> Binary Output (0 or 1)\n                    page_pred[y0:y1, x0:x1] = (pred > 0.5).astype(np.uint8)\n            \n            volume_pages.append(Image.fromarray(page_pred))\n\n        # Save multi-page TIFF\n        out_name = f\"{vid}.tif\"\n        if volume_pages:\n            volume_pages[0].save(out_name, save_all=True, append_images=volume_pages[1:], compression=\"tiff_deflate\")\n            submission_files.append(out_name)\n\n    # Zip it up\n    with zipfile.ZipFile('submission.zip', 'w') as zipf:\n        for f in submission_files:\n            zipf.write(f)\n            os.remove(f) # Clean up\n    print(\"📦 Submission.zip created successfully.\")\n\nif __name__ == \"__main__\":\n    device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n    print(f\"🚀 Device: {device}\")\n    \n    # 1. Prepare Data\n    train_ids = [f.stem for f in (DATA_PATH/'train_images').glob('*.tif')]\n    \n    # DEBUG SWITCH: POC_MODE limits dataset size for testing\n    if POC_MODE: \n        print(\"⚠️ DEBUG MODE ACTIVE: Using only 2 volumes.\")\n        train_ids = train_ids[:2]\n    \n    if not train_ids:\n        print(\"❌ No training data found.\")\n    else:\n        ds = VesuviusSurfaceDataset(\n            train_ids, \n            DATA_PATH/'train_images', \n            DATA_PATH/'train_labels', \n            SAMPLES_PER_ID\n        )\n        \n        loader = DataLoader(\n            ds, \n            batch_size=BATCH_SIZE, \n            shuffle=True, \n            num_workers=2, \n            pin_memory=True, \n            persistent_workers=True\n        )\n        \n        # 2. Setup Model\n        model = Pix2PixGenerator(in_ch=2*Z_CONTEXT+1).to(device)\n        optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE)\n        scaler = torch.amp.GradScaler('cuda')\n        \n        # 3. Training Loop (Time-Limited)\n        start_time = time.time()\n        print(f\"⏳ Training limit: {MAX_TRAIN_TIME_HOURS} hours...\")\n        \n        epoch = 1\n        best_loss = float('inf')\n        \n        while (time.time() - start_time) < MAX_TRAIN_SECONDS:\n            model.train()\n            epoch_loss = 0\n            \n            # Limit the number of batches per epoch if MAX_BATCHES_PER_EPOCH is set\n            total_batches = len(loader)\n            if MAX_BATCHES_PER_EPOCH is not None:\n                total_batches = min(total_batches, MAX_BATCHES_PER_EPOCH)\n                \n            pbar = tqdm(loader, desc=f\"Epoch {epoch}\", total=total_batches)\n            \n            for batch_idx, (imgs, targets) in enumerate(pbar):\n                # Break if we hit the batch limit or the time limit\n                if (MAX_BATCHES_PER_EPOCH is not None and batch_idx >= MAX_BATCHES_PER_EPOCH):\n                    break\n                if (time.time() - start_time) > MAX_TRAIN_SECONDS:\n                    print(\"🛑 Time limit reached during epoch.\")\n                    break\n                \n                imgs = imgs.to(device, non_blocking=True)\n                targets = targets.to(device, non_blocking=True)\n                \n                optimizer.zero_grad()\n                \n                with torch.amp.autocast('cuda'):\n                    logits = model(imgs)\n                    loss = hybrid_loss(logits, targets)\n                \n                scaler.scale(loss).backward()\n                scaler.step(optimizer)\n                scaler.update()\n                \n                epoch_loss += loss.item()\n                pbar.set_postfix(loss=f\"{loss.item():.4f}\")\n            \n            # Calculate average loss based on actual batches processed\n            batches_processed = batch_idx + 1 if 'batch_idx' in locals() else 1\n            avg_loss = epoch_loss / batches_processed\n            \n            # Save & Visualize\n            visualize_results(model, ds, device, epoch)\n            print(f\"📸 Saved visualization: epoch_{epoch}_visual.png\")\n            \n            state = {\n                'epoch': epoch,\n                'state_dict': model.state_dict(),\n                'optimizer': optimizer.state_dict(),\n                'loss': avg_loss\n            }\n            torch.save(state, CHECKPOINT_DIR / \"latest.pth\")\n            \n            if avg_loss < best_loss:\n                best_loss = avg_loss\n                torch.save(state, CHECKPOINT_DIR / \"best_model.pth\")\n                \n            print(f\"✅ Epoch {epoch} finished. Loss: {avg_loss:.4f}\")\n            \n            if POC_MODE and epoch >= 2:\n                print(\"🛑 POC Mode enabled: Stopping training after 2 epochs.\")\n                break\n                \n            epoch += 1\n\n        # 4. Inference\n        if (CHECKPOINT_DIR / \"best_model.pth\").exists():\n            print(\"🔄 Loading best model for inference...\")\n            checkpoint = torch.load(CHECKPOINT_DIR / \"best_model.pth\")\n            model.load_state_dict(checkpoint['state_dict'])\n            \n        run_inference(model, device)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-03T14:59:11.592145Z","iopub.execute_input":"2026-02-03T14:59:11.593018Z","iopub.status.idle":"2026-02-03T14:59:40.061850Z","shell.execute_reply.started":"2026-02-03T14:59:11.592988Z","shell.execute_reply":"2026-02-03T14:59:40.061003Z"}},"outputs":[],"execution_count":null},{"cell_type":"markdown","source":"In the selected code in Canvas, the loss is calculated as: loss = binary_cross_entropy_with_logits(...) + dice_loss(...)\n\nSeeing a negative loss can be confusing, but here is what it means in this context:\n\n1. The Source: Dice Loss\n\nThe standard Dice Loss formula used in your code is: Loss=1−Dice Coefficient Where the Dice Coefficient ranges from 0 to 1. Therefore, the Dice Loss should typically range from 0 to 1.\n\nHowever, if you ever see a negative total loss, it usually indicates one of two things in a PyTorch environment:\n\nNumerical Instability: If the inputs to the loss function (the model's outputs) contain very large values (logits), the sigmoid inside the dice_loss or the binary_cross_entropy_with_logits can sometimes produce infinitesimal values or NaNs, which can occasionally be misinterpreted by the display as a negative value during high-speed training.\n\nCustom Loss Modifications: While not present in your current script, some implementations use Loss=−log(Dice), which would naturally be negative as the model improves.\n\nWhich \"Decreasing\" is Correct?\n\nIn machine learning, \"decreasing\" always means moving towards a smaller algebraic value (more negative).\n\nIf your loss starts at -1 and goes to -9, it is decreasing (improving). If your loss starts at -1 and goes to -0.1, it is increasing (getting worse).","metadata":{}},{"cell_type":"code","source":"import zipfile\nimport io\nimport random\nimport numpy as np\nimport matplotlib.pyplot as plt\nfrom PIL import Image, ImageSequence\nfrom pathlib import Path\n\ndef validate_and_visualize_zip(zip_path=\"submission.zip\", test_images_dir=\"/kaggle/input/vesuvius-challenge-surface-detection/test_images\", num_samples=5):\n    \"\"\"\n    Validates a multi-page TIFF submission.\n    Expects a 1-to-1 mapping between source pages and mask pages.\n    Includes safety checks to prevent EOFError during seeking.\n    \"\"\"\n    test_path = Path(test_images_dir)\n    \n    if not Path(zip_path).exists():\n        print(f\"❌ Error: {zip_path} not found.\")\n        return\n\n    with zipfile.ZipFile(zip_path, 'r') as z:\n        all_files = [f for f in z.namelist() if f.endswith('.tif')]\n        if not all_files:\n            print(\"❌ Error: No .tif files found in zip.\")\n            return\n            \n        print(f\"📦 Zip contains {len(all_files)} files.\")\n        \n        # We will pick one file and look at multiple random pages within it\n        target_file = random.choice(all_files)\n        image_id = target_file.replace('.tif', '')\n        source_file = test_path / f\"{image_id}.tif\"\n        \n        print(f\"🔍 Auditing file: {target_file}\")\n        \n        # 1. Load the full Mask Stack\n        with z.open(target_file) as f:\n            mask_data = io.BytesIO(f.read())\n            mask_img = Image.open(mask_data)\n            mask_pages = getattr(mask_img, \"n_frames\", 1)\n            \n        # 2. Load the full Source Stack\n        if source_file.exists():\n            src_img = Image.open(source_file)\n            src_pages = getattr(src_img, \"n_frames\", 1)\n        else:\n            print(f\"⚠️ Source {source_file} not found. Visualization will be limited.\")\n            return\n\n        print(f\"📊 Page Count: Source={src_pages}, Mask={mask_pages}\")\n        \n        # Determine the maximum safe index to prevent EOFError\n        max_safe_idx = min(src_pages, mask_pages)\n        \n        if src_pages != mask_pages:\n            print(f\"⚠️ WARNING: Page count mismatch! ({src_pages} vs {mask_pages})\")\n            print(f\"⚠️ Seeking will be limited to the first {max_safe_idx} pages.\")\n\n        # 3. Pick random pages to visualize\n        # We use max_safe_idx to ensure we never 'seek' out of bounds\n        potential_indices = sorted(list(set([\n            0, 1, 2, # First three\n            max_safe_idx // 4, max_safe_idx // 2, (3 * max_safe_idx) // 4, # Middle sections\n            max_safe_idx - 3, max_safe_idx - 2, max_safe_idx - 1 # Last three\n        ])))\n        \n        # Filter indices that are actually within range\n        valid_indices = [i for i in potential_indices if i >= 0 and i < max_safe_idx]\n        \n        display_indices = random.sample(valid_indices, min(num_samples, len(valid_indices)))\n        display_indices.sort()\n\n        for idx in display_indices:\n            try:\n                # Get Mask Page\n                mask_img.seek(idx)\n                mask_array = np.array(mask_img)\n                \n                # Get Source Page\n                src_img.seek(idx)\n                source_array = np.array(src_img)\n                \n                # Check for black frames (first/last 3)\n                is_edge = idx < 3 or idx >= (max_safe_idx - 3)\n                status = \"EXPECTED BLACK\" if is_edge else \"DATA AREA\"\n\n                fig, axes = plt.subplots(1, 2, figsize=(12, 5))\n                \n                # Plot Source\n                axes[0].imshow(source_array, cmap='gray')\n                axes[0].set_title(f\"SOURCE - Page {idx+1}\\n({status})\")\n                axes[0].axis('off')\n                \n                # Plot Prediction\n                im = axes[1].imshow(mask_array, cmap='viridis', vmin=0, vmax=2)\n                axes[1].set_title(f\"PREDICTION - Page {idx+1}\\nValues: {np.unique(mask_array)}\")\n                axes[1].axis('off')\n                \n                cbar = plt.colorbar(im, ax=axes[1], ticks=[0, 1, 2], fraction=0.046, pad=0.04)\n                cbar.ax.set_yticklabels(['0: BG', '1: Ink', '2: Ignore'])\n                \n                plt.tight_layout()\n                plt.show()\n\n                # Logical validation check\n                if is_edge and np.any(mask_array > 0):\n                    print(f\"🚨 Note: Page {idx+1} is an edge frame but contains non-zero labels.\")\n                    \n            except EOFError:\n                print(f\"❌ Critical Error: Could not seek to page {idx} despite range checks.\")\n                break\n\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-03T12:13:48.095317Z","iopub.execute_input":"2026-02-03T12:13:48.095808Z","iopub.status.idle":"2026-02-03T12:13:48.112370Z","shell.execute_reply.started":"2026-02-03T12:13:48.095754Z","shell.execute_reply":"2026-02-03T12:13:48.111291Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"validate_and_visualize_zip(\"submission.zip\", num_samples=6)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-02-03T12:13:48.114385Z","iopub.execute_input":"2026-02-03T12:13:48.115192Z","iopub.status.idle":"2026-02-03T12:13:50.424841Z","shell.execute_reply.started":"2026-02-03T12:13:48.115122Z","shell.execute_reply":"2026-02-03T12:13:50.423963Z"}},"outputs":[],"execution_count":null},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]}