{"metadata":{"kernelspec":{"name":"python3","display_name":"Python 3","language":"python"},"language_info":{"name":"python","version":"3.12.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"cell-01-markdown","cell_type":"markdown","source":"# Nuclei Segmentation & Phenotypic Profiling for Drug Discovery\n\n## What This Project Does\n\nThis notebook trains a **U-Net (ResNet34 encoder)** to segment cell nuclei in fluorescence microscopy images from the 2018 Data Science Bowl. Accurate nucleus segmentation is the first step in high-content imaging (HCI) pipelines used in drug discovery: once nuclei are identified, per-cell morphological features (area, eccentricity, solidity, intensity) can be extracted at scale — enabling phenotypic profiling that reveals how compounds alter cell biology.\n\n**Pipeline overview:**\n1. Load microscopy images + binary masks\n2. Train U-Net with combined Dice + BCE loss\n3. Post-process with Watershed to separate touching nuclei (instance segmentation)\n4. Extract per-nucleus morphological features → phenotypic profile DataFrame\n5. Evaluate: Dice, IoU, Precision, Recall\n\n---\n\n## Kaggle Setup Instructions\n\n1. **Attach the dataset**: Go to *Add Data* → search **`data-science-bowl-2018`** → attach the Kaggle competition dataset.\n2. **Enable GPU**: *Settings* → *Accelerator* → select **GPU T4 x2**.\n3. The training data will be available at:\n   ```\n   /kaggle/input/data-science-bowl-2018/stage1_train/\n   ```\n   Each sub-folder is one image sample and contains:\n   - `images/<id>.png` — the microscopy image\n   - `masks/<nucleus_id>.png` — one PNG per nucleus (merged into one binary mask)\n\n4. Outputs (checkpoints, figures, metrics) are saved to `/kaggle/working/`.\n\n> **Local smoke test**: Cell 15 runs the full pipeline on tiny synthetic data so every code path can be verified without the Kaggle dataset or a GPU.","metadata":{}},{"id":"cell-02-installs","cell_type":"code","source":"# Cell 2 — pip installs (run on Kaggle; packages already present locally)\n!pip install -q segmentation-models-pytorch albumentations","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T16:59:12.733132Z","iopub.execute_input":"2026-07-08T16:59:12.733425Z","iopub.status.idle":"2026-07-08T16:59:16.201215Z","shell.execute_reply.started":"2026-07-08T16:59:12.733400Z","shell.execute_reply":"2026-07-08T16:59:16.200121Z"}},"outputs":[],"execution_count":null},{"id":"cell-03-imports","cell_type":"code","source":"# Cell 3 — Imports + GPU check\nimport os\nimport json\nimport random\nimport time\nfrom pathlib import Path\n\nimport cv2\nimport numpy as np\nimport pandas as pd\n%matplotlib inline\nimport matplotlib\nimport matplotlib.pyplot as plt\n\n\nimport scipy.ndimage as ndi\nfrom skimage import measure, segmentation, morphology\n\nimport torch\nimport torch.nn as nn\nimport torch.nn.functional as F\nfrom torch.utils.data import Dataset, DataLoader\nimport torchvision\n\nimport albumentations as A\nfrom albumentations.pytorch import ToTensorV2\n\nimport segmentation_models_pytorch as smp\n\n# ── GPU check ─────────────────────────────────────────────────────────────────\ncuda_available = torch.cuda.is_available()\ndevice = torch.device('cuda' if cuda_available else 'cpu')\nprint(f'CUDA available: {cuda_available}')\nprint(f'Device: {device}')\nif cuda_available:\n    print(f'GPU: {torch.cuda.get_device_name(0)}')\n    print(f'GPU count: {torch.cuda.device_count()}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T16:59:16.203201Z","iopub.execute_input":"2026-07-08T16:59:16.203574Z","iopub.status.idle":"2026-07-08T16:59:16.213837Z","shell.execute_reply.started":"2026-07-08T16:59:16.203545Z","shell.execute_reply":"2026-07-08T16:59:16.213212Z"}},"outputs":[],"execution_count":null},{"id":"cell-04-config","cell_type":"code","source":"# Cell 4 — Config / Hyperparameters\nfrom dataclasses import dataclass\n\n@dataclass\nclass Config:\n    # ── Data ──────────────────────────────────────────────────────────────────\n    DATA_DIR: Path = Path('/kaggle/input/competitions/data-science-bowl-2018/stage1_train')\n    ZIP_PATH: Path = Path('/kaggle/input/competitions/data-science-bowl-2018/stage1_train.zip')\n\n    # ── Model / training ──────────────────────────────────────────────────────\n    IMG_SIZE: int = 256\n    BATCH_SIZE: int = 16\n    NUM_EPOCHS: int = 100\n    LR: float = 1e-4\n    SEED: int = 42\n\n    # ── Split ratios ──────────────────────────────────────────────────────────\n    TRAIN_SPLIT: float = 0.70\n    VAL_SPLIT:   float = 0.15\n    TEST_SPLIT:  float = 0.15\n\n    # ── Output paths ──────────────────────────────────────────────────────────\n    CHECKPOINT_DIR: Path = Path('/kaggle/working/checkpoints')\n    FIGURES_DIR:    Path = Path('/kaggle/working/figures')\n    METRICS_PATH:   Path = Path('/kaggle/working/metrics.json')\n\nCFG = Config()\n\nCFG.CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)\nCFG.FIGURES_DIR.mkdir(parents=True, exist_ok=True)\n\nrandom.seed(CFG.SEED)\nnp.random.seed(CFG.SEED)\ntorch.manual_seed(CFG.SEED)\n\nprint('Config loaded:')\nprint(f'  DATA_DIR  = {CFG.DATA_DIR}')\nprint(f'  ZIP_PATH  = {CFG.ZIP_PATH}')\nprint(f'  IMG_SIZE={CFG.IMG_SIZE}, BATCH_SIZE={CFG.BATCH_SIZE}, NUM_EPOCHS={CFG.NUM_EPOCHS}, LR={CFG.LR}')\nprint(f'  Split train/val/test = {CFG.TRAIN_SPLIT}/{CFG.VAL_SPLIT}/{CFG.TEST_SPLIT}')\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T16:59:16.215085Z","iopub.execute_input":"2026-07-08T16:59:16.215362Z","iopub.status.idle":"2026-07-08T16:59:16.238525Z","shell.execute_reply.started":"2026-07-08T16:59:16.215305Z","shell.execute_reply":"2026-07-08T16:59:16.237819Z"}},"outputs":[],"execution_count":null},{"id":"cell-05-synthetic","cell_type":"code","source":"# Cell 5 — Load real dataset from zip and visualise samples\nimport zipfile\nfrom collections import defaultdict\n\ndef load_dataset_from_zip(zip_path: Path):\n    \"\"\"Read all (image, merged_mask) pairs directly from the zip — no extraction needed.\"\"\"\n    samples = []\n\n    with zipfile.ZipFile(zip_path, 'r') as zf:\n        all_names = zf.namelist()\n\n        sample_images = defaultdict(list)\n        sample_masks  = defaultdict(list)\n\n        for name in all_names:\n            parts = name.split('/')\n            if len(parts) >= 3 and parts[-1].endswith('.png'):\n                sample_id = parts[0]\n                if parts[-2] == 'images':\n                    sample_images[sample_id].append(name)\n                elif parts[-2] == 'masks':\n                    sample_masks[sample_id].append(name)\n\n        for sample_id in sorted(sample_images):\n            img_bytes = zf.read(sample_images[sample_id][0])\n            img = cv2.imdecode(np.frombuffer(img_bytes, np.uint8), cv2.IMREAD_GRAYSCALE)\n            if img is None:\n                continue\n\n            merged_mask = np.zeros_like(img, dtype=np.uint8)\n            for mask_name in sorted(sample_masks[sample_id]):\n                m_bytes = zf.read(mask_name)\n                m = cv2.imdecode(np.frombuffer(m_bytes, np.uint8), cv2.IMREAD_GRAYSCALE)\n                if m is not None:\n                    merged_mask = np.maximum(merged_mask, m)\n\n            samples.append((img, merged_mask))\n\n    return samples\n\n\n# ── Load ──────────────────────────────────────────────────────────────────────\nprint(f'Loading dataset from: {CFG.ZIP_PATH}')\nall_samples = load_dataset_from_zip(CFG.ZIP_PATH)\nprint(f'Total samples loaded : {len(all_samples)}')\nprint(f'  Image shape : {all_samples[0][0].shape}, dtype: {all_samples[0][0].dtype}')\nprint(f'  Mask  shape : {all_samples[0][1].shape}, dtype: {all_samples[0][1].dtype}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T16:59:16.240046Z","iopub.execute_input":"2026-07-08T16:59:16.240423Z","iopub.status.idle":"2026-07-08T16:59:37.779392Z","shell.execute_reply.started":"2026-07-08T16:59:16.240401Z","shell.execute_reply":"2026-07-08T16:59:37.778655Z"}},"outputs":[],"execution_count":null},{"id":"87b85bb6-f3c5-4b9a-91f3-c8d50db3de30","cell_type":"code","source":"# ── Visualise 6 random samples ────────────────────────────────────────────────\nimport random as _rnd\n_picks = _rnd.sample(range(len(all_samples)), min(6, len(all_samples)))\n\nfig, axes = plt.subplots(len(_picks), 2, figsize=(8, 3 * len(_picks)))\nfor row, idx in enumerate(_picks):\n    img, mask = all_samples[idx]\n    axes[row, 0].imshow(img, cmap='gray')\n    axes[row, 0].set_title(f'Sample {idx} — Image')\n    axes[row, 0].axis('off')\n    axes[row, 1].imshow(mask, cmap='gray')\n    axes[row, 1].set_title(f'Sample {idx} — Mask')\n    axes[row, 1].axis('off')\n\nfig.suptitle('Real Dataset Samples', fontsize=13)\nfig.tight_layout()\nplt.show()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T16:59:37.780442Z","iopub.execute_input":"2026-07-08T16:59:37.780870Z","iopub.status.idle":"2026-07-08T16:59:38.854118Z","shell.execute_reply.started":"2026-07-08T16:59:37.780834Z","shell.execute_reply":"2026-07-08T16:59:38.853272Z"}},"outputs":[],"execution_count":null},{"id":"cell-06-dataset","cell_type":"code","source":"# Cell 6 — Dataset class + DataLoaders\n\nclass NucleiDataset(Dataset):\n\n    def __init__(self, samples, img_size=256, transform=None):\n        self.samples   = samples\n        self.img_size  = img_size\n        self.transform = transform\n\n    def __len__(self):\n        return len(self.samples)\n\n    def __getitem__(self, idx):\n        image, mask = self.samples[idx]\n\n        image = cv2.resize(image, (self.img_size, self.img_size), interpolation=cv2.INTER_LINEAR)\n        mask  = cv2.resize(mask,  (self.img_size, self.img_size), interpolation=cv2.INTER_NEAREST)\n\n        image = image.astype(np.float32) / 255.0\n        mask  = (mask > 127).astype(np.float32)\n\n        image_3ch = np.stack([image, image, image], axis=-1)  # HxWx3\n\n        if self.transform is not None:\n            augmented = self.transform(image=image_3ch, mask=mask)\n            image_3ch = augmented['image']\n            mask      = augmented['mask']\n        else:\n            image_3ch = torch.from_numpy(image_3ch.transpose(2, 0, 1))\n            mask      = torch.from_numpy(mask)\n\n        mask = mask.unsqueeze(0)\n        return image_3ch, mask\n\n\ndef get_dataloaders(samples, img_size=None, batch_size=None,\n                    train_transform=None, val_transform=None):\n\n    _img_size   = img_size   or CFG.IMG_SIZE\n    _batch_size = batch_size or CFG.BATCH_SIZE\n\n    n = len(samples)\n    idx = list(range(n))\n    random.seed(CFG.SEED)\n    random.shuffle(idx)\n\n    n_train = max(1, int(n * CFG.TRAIN_SPLIT))\n    n_val   = max(1, int(n * CFG.VAL_SPLIT))\n\n    train_s = [samples[i] for i in idx[:n_train]]\n    val_s   = [samples[i] for i in idx[n_train:n_train + n_val]]\n    test_s  = [samples[i] for i in idx[n_train + n_val:]]\n\n    train_ds = NucleiDataset(train_s, img_size=_img_size, transform=train_transform)\n    val_ds   = NucleiDataset(val_s,   img_size=_img_size, transform=val_transform)\n    test_ds  = NucleiDataset(test_s,  img_size=_img_size, transform=val_transform)\n\n    train_loader = DataLoader(train_ds, batch_size=_batch_size, shuffle=True,  num_workers=0, pin_memory=False)\n    val_loader   = DataLoader(val_ds,   batch_size=_batch_size, shuffle=False, num_workers=0, pin_memory=False)\n    test_loader  = DataLoader(test_ds,  batch_size=_batch_size, shuffle=False, num_workers=0, pin_memory=False)\n\n    print(f'DataLoaders ready: train={len(train_ds)}, val={len(val_ds)}, test={len(test_ds)}')\n    return train_loader, val_loader, test_loader\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T16:59:38.855176Z","iopub.execute_input":"2026-07-08T16:59:38.855484Z","iopub.status.idle":"2026-07-08T16:59:38.866280Z","shell.execute_reply.started":"2026-07-08T16:59:38.855462Z","shell.execute_reply":"2026-07-08T16:59:38.865577Z"}},"outputs":[],"execution_count":null},{"id":"cell-07-augmentations","cell_type":"code","source":"# Cell 7 — Augmentations (albumentations)\n\ndef get_transforms(img_size: int = None):\n    \"\"\"Return (train_transform, val_transform).\"\"\"\n    _size = img_size or CFG.IMG_SIZE\n\n    train_transform = A.Compose([\n        A.HorizontalFlip(p=0.5),\n        A.VerticalFlip(p=0.5),\n        A.RandomRotate90(p=0.5),\n        A.ElasticTransform(alpha=120, sigma=120 * 0.05,\n                           p=0.3),\n        A.RandomBrightnessContrast(brightness_limit=0.2,\n                                   contrast_limit=0.2, p=0.4),\n        A.GaussianBlur(blur_limit=(3, 5), p=0.2),\n        A.Normalize(mean=(0.485, 0.456, 0.406),\n                    std=(0.229, 0.224, 0.225)),\n        ToTensorV2(),\n    ])\n\n    val_transform = A.Compose([\n        A.Normalize(mean=(0.485, 0.456, 0.406),\n                    std=(0.229, 0.224, 0.225)),\n        ToTensorV2(),\n    ])\n\n    return train_transform, val_transform\n\n\ntrain_tfm, val_tfm = get_transforms()\nprint('Augmentations defined.')\nprint(f'  Train pipeline: {[type(t).__name__ for t in train_tfm.transforms]}')\nprint(f'  Val   pipeline: {[type(t).__name__ for t in val_tfm.transforms]}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T16:59:38.867252Z","iopub.execute_input":"2026-07-08T16:59:38.867635Z","iopub.status.idle":"2026-07-08T16:59:38.888853Z","shell.execute_reply.started":"2026-07-08T16:59:38.867614Z","shell.execute_reply":"2026-07-08T16:59:38.888030Z"}},"outputs":[],"execution_count":null},{"id":"cell-08-model","cell_type":"code","source":"# Cell 8 — Model: U-Net with ResNet34 encoder\nimport segmentation_models_pytorch as smp\n\nmodel = smp.Unet(\n    encoder_name='resnet50',\n    encoder_weights='imagenet',\n    in_channels=3,\n    classes=1,\n    activation=None,       # raw logits — sigmoid applied in loss/inference\n)\nmodel = model.to(device)\n\nn_params = sum(p.numel() for p in model.parameters())\nn_trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)\nprint(f'Model: U-Net (ResNet34 encoder)')\nprint(f'  Total parameters   : {n_params:,}')\nprint(f'  Trainable parameters: {n_trainable:,}')\nprint(f'  Device: {next(model.parameters()).device}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T16:59:38.889656Z","iopub.execute_input":"2026-07-08T16:59:38.889994Z","iopub.status.idle":"2026-07-08T16:59:42.060019Z","shell.execute_reply.started":"2026-07-08T16:59:38.889956Z","shell.execute_reply":"2026-07-08T16:59:42.059394Z"}},"outputs":[],"execution_count":null},{"id":"cell-09-loss","cell_type":"code","source":"# Cell 9 — Loss function + metric helpers\n\nclass DiceBCELoss(nn.Module):\n    \"\"\"Combined Dice + Binary Cross-Entropy loss for binary segmentation.\"\"\"\n\n    def __init__(self, smooth: float = 1.0):\n        super().__init__()\n        self.smooth = smooth\n\n    def forward(self, pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:\n        \"\"\"\n        Args:\n            pred   : raw logits, shape (B, 1, H, W)\n            target : binary float mask, shape (B, 1, H, W)\n        \"\"\"\n        bce = F.binary_cross_entropy_with_logits(pred, target)\n\n        pred_sig = torch.sigmoid(pred)\n        intersection = (pred_sig * target).sum(dim=(2, 3))\n        dice = 1.0 - (\n            (2.0 * intersection + self.smooth) /\n            (pred_sig.sum(dim=(2, 3)) + target.sum(dim=(2, 3)) + self.smooth)\n        )\n        return bce + dice.mean()\n\n\ndef dice_score(\n    pred: torch.Tensor,\n    target: torch.Tensor,\n    threshold: float = 0.5,\n    smooth: float = 1.0,\n) -> float:\n    \"\"\"Compute Dice coefficient (scalar) after sigmoid + threshold.\"\"\"\n    with torch.no_grad():\n        pred_bin = (torch.sigmoid(pred) > threshold).float()\n        intersection = (pred_bin * target).sum(dim=(2, 3))\n        score = (\n            (2.0 * intersection + smooth) /\n            (pred_bin.sum(dim=(2, 3)) + target.sum(dim=(2, 3)) + smooth)\n        )\n    return score.mean().item()\n\n\ndef iou_score(\n    pred: torch.Tensor,\n    target: torch.Tensor,\n    threshold: float = 0.5,\n    smooth: float = 1.0,\n) -> float:\n    \"\"\"Compute mean IoU (Jaccard) over a batch.\"\"\"\n    with torch.no_grad():\n        pred_bin = (torch.sigmoid(pred) > threshold).float()\n        intersection = (pred_bin * target).sum(dim=(2, 3))\n        union = pred_bin.sum(dim=(2, 3)) + target.sum(dim=(2, 3)) - intersection\n        score = (intersection + smooth) / (union + smooth)\n    return score.mean().item()\n\n\ncriterion = DiceBCELoss()\nprint('Loss function and metric helpers defined.')\n\n# ── Quick unit test ───────────────────────────────────────────────────────────\n_pred   = torch.randn(2, 1, 32, 32)\n_target = (torch.rand(2, 1, 32, 32) > 0.5).float()\n_loss   = criterion(_pred, _target)\n_dice   = dice_score(_pred, _target)\n_iou    = iou_score(_pred, _target)\nprint(f'  Unit test — loss={_loss.item():.4f}, dice={_dice:.4f}, iou={_iou:.4f}')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T16:59:42.061127Z","iopub.execute_input":"2026-07-08T16:59:42.061469Z","iopub.status.idle":"2026-07-08T16:59:42.074118Z","shell.execute_reply.started":"2026-07-08T16:59:42.061431Z","shell.execute_reply":"2026-07-08T16:59:42.073249Z"}},"outputs":[],"execution_count":null},{"id":"cell-10-training","cell_type":"code","source":"# Cell 10 — Training Loop\n\ndef train_model(\n    model,\n    train_loader,\n    val_loader,\n    num_epochs: int = CFG.NUM_EPOCHS,\n    lr: float = CFG.LR,\n    checkpoint_dir: Path = CFG.CHECKPOINT_DIR,\n    figures_dir: Path = CFG.FIGURES_DIR,\n    verbose: bool = True,\n):\n    \"\"\"Full training loop with validation, checkpointing, and curve saving.\n\n    Returns:\n        history dict with keys: train_loss, val_loss, val_dice, val_iou\n    \"\"\"\n    Path(checkpoint_dir).mkdir(parents=True, exist_ok=True)\n    Path(figures_dir).mkdir(parents=True, exist_ok=True)\n\n    optimizer = torch.optim.Adam(model.parameters(), lr=lr)\n    scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(\n        optimizer, mode='max', factor=0.5, patience=5\n    )\n\n    best_dice   = -1.0\n    history     = {'train_loss': [], 'val_loss': [], 'val_dice': [], 'val_iou': []}\n\n    for epoch in range(1, num_epochs + 1):\n        t0 = time.time()\n\n        # ── Train ─────────────────────────────────────────────────────────────\n        model.train()\n        train_loss_sum = 0.0\n        for images, masks in train_loader:\n            images = images.to(device, dtype=torch.float32)\n            masks  = masks.to(device,  dtype=torch.float32)\n\n            optimizer.zero_grad()\n            preds = model(images)\n            loss  = criterion(preds, masks)\n            loss.backward()\n            optimizer.step()\n            train_loss_sum += loss.item() * images.size(0)\n\n        avg_train_loss = train_loss_sum / max(1, len(train_loader.dataset))\n\n        # ── Validate ──────────────────────────────────────────────────────────\n        model.eval()\n        val_loss_sum = 0.0\n        val_dice_sum = 0.0\n        val_iou_sum  = 0.0\n        n_val_batches = 0\n\n        with torch.no_grad():\n            for images, masks in val_loader:\n                images = images.to(device, dtype=torch.float32)\n                masks  = masks.to(device,  dtype=torch.float32)\n\n                preds = model(images)\n                val_loss_sum += criterion(preds, masks).item()\n                val_dice_sum += dice_score(preds, masks)\n                val_iou_sum  += iou_score(preds, masks)\n                n_val_batches += 1\n\n        avg_val_loss = val_loss_sum  / max(1, n_val_batches)\n        avg_val_dice = val_dice_sum  / max(1, n_val_batches)\n        avg_val_iou  = val_iou_sum   / max(1, n_val_batches)\n\n        history['train_loss'].append(avg_train_loss)\n        history['val_loss'].append(avg_val_loss)\n        history['val_dice'].append(avg_val_dice)\n        history['val_iou'].append(avg_val_iou)\n\n        scheduler.step(avg_val_dice)\n\n        # ── Checkpoint ────────────────────────────────────────────────────────\n        if avg_val_dice > best_dice:\n            best_dice = avg_val_dice\n            ckpt_path = Path(checkpoint_dir) / 'best_model.pth'\n            torch.save({\n                'epoch': epoch,\n                'model_state_dict': model.state_dict(),\n                'optimizer_state_dict': optimizer.state_dict(),\n                'val_dice': best_dice,\n            }, ckpt_path)\n\n        elapsed = time.time() - t0\n        if verbose:\n            print(f'Epoch {epoch:3d}/{num_epochs} | '\n                  f'train_loss={avg_train_loss:.4f} | '\n                  f'val_loss={avg_val_loss:.4f} | '\n                  f'val_dice={avg_val_dice:.4f} | '\n                  f'val_iou={avg_val_iou:.4f} | '\n                  f'{elapsed:.1f}s')\n\n    # ── Save loss / dice curves ────────────────────────────────────────────────\n    fig, axes = plt.subplots(1, 2, figsize=(12, 4))\n    epochs_x = range(1, len(history['train_loss']) + 1)\n\n    axes[0].plot(epochs_x, history['train_loss'], label='Train Loss')\n    axes[0].plot(epochs_x, history['val_loss'],   label='Val Loss')\n    axes[0].set_title('Loss')\n    axes[0].set_xlabel('Epoch')\n    axes[0].legend()\n\n    axes[1].plot(epochs_x, history['val_dice'], label='Val Dice')\n    axes[1].plot(epochs_x, history['val_iou'],  label='Val IoU')\n    axes[1].set_title('Metrics')\n    axes[1].set_xlabel('Epoch')\n    axes[1].legend()\n\n    fig.tight_layout()\n    curve_path = Path(figures_dir) / 'loss_curve.png'\n    fig.savefig(curve_path, dpi=100)\n    plt.close(fig)\n    print(f'Curves saved to {curve_path}')\n    print(f'Best val Dice: {best_dice:.4f}')\n\n    return history\n\nprint('train_model() defined (uncomment call above for Kaggle full run).')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T16:59:42.077021Z","iopub.execute_input":"2026-07-08T16:59:42.077224Z","iopub.status.idle":"2026-07-08T16:59:42.218747Z","shell.execute_reply.started":"2026-07-08T16:59:42.077204Z","shell.execute_reply":"2026-07-08T16:59:42.218063Z"}},"outputs":[],"execution_count":null},{"id":"d479eb39-4d28-4f1b-bc51-e58cee3b5279","cell_type":"code","source":"train_tfm, val_tfm = get_transforms()\n\ntrain_loader, val_loader, test_loader = get_dataloaders(\n    all_samples,\n    train_transform=train_tfm,\n    val_transform=val_tfm,\n)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T16:59:42.219630Z","iopub.execute_input":"2026-07-08T16:59:42.219911Z","iopub.status.idle":"2026-07-08T16:59:42.240815Z","shell.execute_reply.started":"2026-07-08T16:59:42.219880Z","shell.execute_reply":"2026-07-08T16:59:42.240219Z"}},"outputs":[],"execution_count":null},{"id":"1536331a-fbde-493b-8629-50507582360f","cell_type":"code","source":"history = train_model(\n    model, train_loader, val_loader,\n    num_epochs=CFG.NUM_EPOCHS, lr=CFG.LR,\n    checkpoint_dir=CFG.CHECKPOINT_DIR,\n    figures_dir=CFG.FIGURES_DIR,\n)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T16:59:42.241580Z","iopub.execute_input":"2026-07-08T16:59:42.241847Z","iopub.status.idle":"2026-07-08T17:23:08.980264Z","shell.execute_reply.started":"2026-07-08T16:59:42.241816Z","shell.execute_reply":"2026-07-08T17:23:08.979505Z"}},"outputs":[],"execution_count":null},{"id":"cell-11-inference","cell_type":"code","source":"def predict(model, dataloader, device, threshold: float = 0.3):\n    model.eval()\n    results = []\n\n    # ImageNet mean/std used during normalization\n    mean = np.array([0.485, 0.456, 0.406])\n    std  = np.array([0.229, 0.224, 0.225])\n\n    with torch.no_grad():\n        for images, masks in dataloader:\n            images_dev = images.to(device, dtype=torch.float32)\n            logits = model(images_dev)\n            probs  = torch.sigmoid(logits)\n            preds  = (probs > threshold).float()\n\n            images_np = images.cpu().numpy()  # B x C x H x W\n            masks_np  = masks.cpu().numpy()\n            preds_np  = preds.cpu().numpy()\n\n            for b in range(images_np.shape[0]):\n                # ── Denormalize: reverse ImageNet normalization ────────────────\n                img_chw = images_np[b]                        # 3 x H x W\n                img_hwc = img_chw.transpose(1, 2, 0)         # H x W x 3\n                img_hwc = (img_hwc * std + mean)              # reverse normalize\n                img_hwc = np.clip(img_hwc * 255, 0, 255).astype(np.uint8)\n                img_gray = cv2.cvtColor(img_hwc, cv2.COLOR_RGB2GRAY)  # back to grayscale\n\n                true_disp = (masks_np[b, 0] * 255).clip(0, 255).astype(np.uint8)\n                pred_disp = (preds_np[b, 0] * 255).clip(0, 255).astype(np.uint8)\n\n                results.append((img_gray, true_disp, pred_disp))\n\n    print(f'predict(): {len(results)} samples processed.')\n    return results\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T17:59:55.744739Z","iopub.execute_input":"2026-07-08T17:59:55.745279Z","iopub.status.idle":"2026-07-08T17:59:55.753391Z","shell.execute_reply.started":"2026-07-08T17:59:55.745249Z","shell.execute_reply":"2026-07-08T17:59:55.752698Z"}},"outputs":[],"execution_count":null},{"id":"cell-12-watershed","cell_type":"code","source":"# Cell 12 — Post-processing: Watershed for instance segmentation\n\ndef apply_watershed(binary_mask: np.ndarray) -> np.ndarray:\n    \"\"\"Split touching nuclei using distance-transform + watershed.\n\n    Args:\n        binary_mask: uint8 HxW, values 0 or 255\n\n    Returns:\n        labeled_mask: int32 HxW, each nucleus has a unique integer label (0=background)\n    \"\"\"\n    # Binarise defensively\n    binary = (binary_mask > 127).astype(np.uint8)\n\n    if binary.sum() == 0:\n        return np.zeros_like(binary, dtype=np.int32)\n\n    # ── Distance transform ────────────────────────────────────────────────────\n    dist = ndi.distance_transform_edt(binary)\n\n    # ── Local maxima as markers ───────────────────────────────────────────────\n    # Use a threshold on the distance map to find nucleus centres\n    local_max = dist > (dist.max() * 0.4)\n    markers, _ = ndi.label(local_max)\n\n    # ── Watershed on the negative distance map ────────────────────────────────\n    labeled = segmentation.watershed(-dist, markers, mask=binary)\n\n    return labeled.astype(np.int32)\n\n\n# ── Quick unit test ───────────────────────────────────────────────────────────\n_dummy_mask = np.zeros((64, 64), dtype=np.uint8)\n_dummy_mask[10:25, 10:25] = 255   # nucleus 1\n_dummy_mask[30:50, 30:50] = 255   # nucleus 2\n_labeled = apply_watershed(_dummy_mask)\n_n_labels = _labeled.max()\nprint(f'apply_watershed() unit test: found {_n_labels} instance(s) (expected 2)')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T17:23:08.991643Z","iopub.execute_input":"2026-07-08T17:23:08.992434Z","iopub.status.idle":"2026-07-08T17:23:09.013929Z","shell.execute_reply.started":"2026-07-08T17:23:08.992310Z","shell.execute_reply":"2026-07-08T17:23:09.013114Z"}},"outputs":[],"execution_count":null},{"id":"cell-13-features","cell_type":"code","source":"# Cell 13 — Feature Extraction (phenotypic profiling)\n\ndef extract_features(image: np.ndarray, instance_mask: np.ndarray) -> pd.DataFrame:\n    \"\"\"Extract per-nucleus morphological features using skimage regionprops.\n\n    Args:\n        image        : uint8 HxW grayscale image\n        instance_mask: int32 HxW labeled mask (0=background)\n\n    Returns:\n        DataFrame with one row per nucleus plus image-level summary columns.\n        Columns: label, area, eccentricity, solidity, mean_intensity,\n                 perimeter, nucleus_count, mean_area, median_area, mean_eccentricity\n    \"\"\"\n    props = measure.regionprops(instance_mask, intensity_image=image)\n\n    if len(props) == 0:\n        return pd.DataFrame(columns=[\n            'label', 'area', 'eccentricity', 'solidity',\n            'mean_intensity', 'perimeter',\n            'nucleus_count', 'mean_area', 'median_area', 'mean_eccentricity',\n        ])\n\n    rows = []\n    for p in props:\n        # intensity_mean is the canonical name in skimage >= 0.26\n        # fall back to mean_intensity for older versions\n        mean_int = getattr(p, 'intensity_mean', None)\n        if mean_int is None:\n            mean_int = p.mean_intensity  # skimage < 0.26 fallback\n        rows.append({\n            'label'         : p.label,\n            'area'          : p.area,\n            'eccentricity'  : p.eccentricity,\n            'solidity'      : p.solidity,\n            'mean_intensity': float(mean_int),\n            'perimeter'     : p.perimeter,\n        })\n\n    df = pd.DataFrame(rows)\n\n    # ── Image-level summary columns ───────────────────────────────────────────\n    df['nucleus_count']     = len(df)\n    df['mean_area']         = df['area'].mean()\n    df['median_area']       = df['area'].median()\n    df['mean_eccentricity'] = df['eccentricity'].mean()\n\n    return df\n\n\n# ── Quick unit test ───────────────────────────────────────────────────────────\n_img_u8   = np.random.randint(100, 200, (64, 64), dtype=np.uint8)\n_feats_df = extract_features(_img_u8, _labeled)\nprint(f'extract_features() unit test: {len(_feats_df)} nucleus rows')\nprint(_feats_df.head(3).to_string())","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T17:23:09.014853Z","iopub.execute_input":"2026-07-08T17:23:09.015166Z","iopub.status.idle":"2026-07-08T17:23:09.042445Z","shell.execute_reply.started":"2026-07-08T17:23:09.015141Z","shell.execute_reply":"2026-07-08T17:23:09.041716Z"}},"outputs":[],"execution_count":null},{"id":"cell-14-evaluation","cell_type":"code","source":"# Cell 14 — Evaluation + Visualization\n\ndef evaluate_and_visualize(\n    predictions,\n    figures_dir: Path = CFG.FIGURES_DIR,\n    max_plots: int = 4,\n):\n    \"\"\"Compute segmentation metrics and save side-by-side visualizations.\n\n    Args:\n        predictions: list of (image_uint8, true_mask_uint8, pred_mask_uint8)\n        figures_dir: directory to save PNG figures\n        max_plots  : how many samples to visualize\n\n    Returns:\n        metrics dict: dice, iou, precision, recall\n    \"\"\"\n    Path(figures_dir).mkdir(parents=True, exist_ok=True)\n\n    dice_list, iou_list, prec_list, rec_list = [], [], [], []\n\n    for img, true_mask, pred_mask in predictions:\n        t = (true_mask > 127).astype(np.float32)\n        p = (pred_mask > 127).astype(np.float32)\n\n        tp = float((p * t).sum())\n        fp = float((p * (1 - t)).sum())\n        fn = float(((1 - p) * t).sum())\n\n        denom_dice = 2 * tp + fp + fn\n        denom_iou  = tp + fp + fn\n\n        dice_list.append((2 * tp + 1) / (denom_dice + 1))\n        iou_list.append( (tp + 1)     / (denom_iou  + 1))\n        prec_list.append((tp + 1)     / (tp + fp + 1))\n        rec_list.append( (tp + 1)     / (tp + fn + 1))\n\n    metrics = {\n        'dice'     : float(np.mean(dice_list)),\n        'iou'      : float(np.mean(iou_list)),\n        'precision': float(np.mean(prec_list)),\n        'recall'   : float(np.mean(rec_list)),\n    }\n\n    # ── Visualizations ────────────────────────────────────────────────────────\n    n_show = min(max_plots, len(predictions))\n    for i in range(n_show):\n        img, true_mask, pred_mask = predictions[i]\n        inst_mask = apply_watershed(pred_mask)\n\n        # Build coloured instance overlay\n        overlay = np.stack([img, img, img], axis=-1).astype(np.uint8)\n        for lbl in range(1, inst_mask.max() + 1):\n            colour = np.array([\n                (lbl * 73) % 255,\n                (lbl * 137) % 255,\n                (lbl * 211) % 255,\n            ], dtype=np.uint8)\n            overlay[inst_mask == lbl] = colour\n\n        fig, axes = plt.subplots(1, 4, figsize=(16, 4))\n        axes[0].imshow(img,       cmap='gray')\n        axes[0].set_title('Original')\n        axes[1].imshow(true_mask, cmap='gray')\n        axes[1].set_title('Ground Truth')\n        axes[2].imshow(pred_mask, cmap='gray')\n        axes[2].set_title('Predicted')\n        axes[3].imshow(overlay)\n        axes[3].set_title('Instance Overlay')\n\n        for ax in axes:\n            ax.axis('off')\n\n        fig.suptitle(\n            f'Sample {i+1} | Dice={dice_list[i]:.3f}, IoU={iou_list[i]:.3f}',\n            fontsize=11,\n        )\n        fig.tight_layout()\n        save_path = Path(figures_dir) / f'visualization_{i+1}.png'\n        fig.savefig(save_path, dpi=100)\n        plt.close(fig)\n\n    print(f'Saved {n_show} visualization(s) to {figures_dir}')\n    print(f'Metrics: Dice={metrics[\"dice\"]:.4f}, IoU={metrics[\"iou\"]:.4f}, '\n          f'Precision={metrics[\"precision\"]:.4f}, Recall={metrics[\"recall\"]:.4f}')\n\n    return metrics\n\n\nprint('evaluate_and_visualize() defined.')","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T18:03:02.324074Z","iopub.execute_input":"2026-07-08T18:03:02.324766Z","iopub.status.idle":"2026-07-08T18:03:02.337405Z","shell.execute_reply.started":"2026-07-08T18:03:02.324735Z","shell.execute_reply":"2026-07-08T18:03:02.336549Z"}},"outputs":[],"execution_count":null},{"id":"cell-16-kaggle-run","cell_type":"code","source":"# Cell 16 — Predict, Evaluate, Save Results\n\npredictions = predict(model, test_loader, device)\n\nmetrics = evaluate_and_visualize(predictions, CFG.FIGURES_DIR)\n\n# Feature extraction — phenotypic profiling\nall_features = []\nfor img, true_mask, pred_mask in predictions:\n    inst_mask = apply_watershed(pred_mask)\n    feats = extract_features(img, inst_mask)\n    all_features.append(feats)\n\npd.concat(all_features).to_csv('/kaggle/working/nuclei_features.csv', index=False)\n\nwith open(CFG.METRICS_PATH, 'w') as f:\n    json.dump(metrics, f, indent=2)\n\nprint(f\"Final Dice: {metrics['dice']:.4f}, IoU: {metrics['iou']:.4f}\")\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2026-07-08T18:03:06.704264Z","iopub.execute_input":"2026-07-08T18:03:06.705029Z","iopub.status.idle":"2026-07-08T18:03:12.949773Z","shell.execute_reply.started":"2026-07-08T18:03:06.705000Z","shell.execute_reply":"2026-07-08T18:03:12.949083Z"}},"outputs":[],"execution_count":null}]}