reworked iop_corr, added explainability tools and plotting tools, cleanup codebase

This commit is contained in:
rpotter6298
2026-03-12 16:12:59 +01:00
parent 13ad32683f
commit 6d0698d0fb
35 changed files with 7765 additions and 5723 deletions
+894
View File
@@ -0,0 +1,894 @@
"""U-Net based optic disc/cup segmenter for REFUGE + Papila."""
from __future__ import annotations
import math
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Optional, Set, Tuple
import numpy as np
import pandas as pd
from PIL import Image, ImageDraw, ImageOps
from PIL.Image import Resampling
from skimage import measure
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset
from torchvision import transforms
from tqdm import tqdm
@dataclass
class ManifestEntry:
sample_id: str
dataset: str
image_path: Path
annotation_disc: Path
annotation_cup: Path
annotation_type_disc: str
annotation_type_cup: str
split: str # train / holdout / etc.
class UNet(nn.Module):
def __init__(
self, in_channels: int = 3, base_channels: int = 32, out_channels: int = 2
):
super().__init__()
self.enc1 = self._block(in_channels, base_channels)
self.enc2 = self._block(base_channels, base_channels * 2)
self.enc3 = self._block(base_channels * 2, base_channels * 4)
self.enc4 = self._block(base_channels * 4, base_channels * 8)
self.pool = nn.MaxPool2d(2)
self.bottleneck = self._block(base_channels * 8, base_channels * 16)
self.up4 = nn.ConvTranspose2d(
base_channels * 16, base_channels * 8, 2, stride=2
)
self.dec4 = self._block(base_channels * 16, base_channels * 8)
self.up3 = nn.ConvTranspose2d(base_channels * 8, base_channels * 4, 2, stride=2)
self.dec3 = self._block(base_channels * 8, base_channels * 4)
self.up2 = nn.ConvTranspose2d(base_channels * 4, base_channels * 2, 2, stride=2)
self.dec2 = self._block(base_channels * 4, base_channels * 2)
self.up1 = nn.ConvTranspose2d(base_channels * 2, base_channels, 2, stride=2)
self.dec1 = self._block(base_channels * 2, base_channels)
self.out_conv = nn.Conv2d(base_channels, out_channels, kernel_size=1)
@staticmethod
def _block(in_ch: int, out_ch: int) -> nn.Module:
return nn.Sequential(
nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True),
nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
e1 = self.enc1(x)
e2 = self.enc2(self.pool(e1))
e3 = self.enc3(self.pool(e2))
e4 = self.enc4(self.pool(e3))
b = self.bottleneck(self.pool(e4))
d4 = self.up4(b)
d4 = torch.cat([d4, e4], dim=1)
d4 = self.dec4(d4)
d3 = self.up3(d4)
d3 = torch.cat([d3, e3], dim=1)
d3 = self.dec3(d3)
d2 = self.up2(d3)
d2 = torch.cat([d2, e2], dim=1)
d2 = self.dec2(d2)
d1 = self.up1(d2)
d1 = torch.cat([d1, e1], dim=1)
d1 = self.dec1(d1)
return self.out_conv(d1)
class SegmentationDataset(Dataset):
def __init__(
self,
entries: List[ManifestEntry],
segmenter: "UNetSegmenter",
augment: bool,
) -> None:
self.entries = entries
self.segmenter = segmenter
self.augment = augment
def __len__(self) -> int:
return len(self.entries)
def __getitem__(self, idx: int):
entry = self.entries[idx]
image = self.segmenter.load_preprocessed_image(entry)
disc_mask, cup_mask = self.segmenter.load_masks(entry)
if self.augment:
image = self.segmenter.jitter_image(image)
image, disc_mask, cup_mask = self.segmenter.augment_geometric(
image, disc_mask, cup_mask
)
image_tensor = transforms.ToTensor()(image)
image_tensor = self.segmenter._normalize_tensor(image_tensor)
mask = np.stack([disc_mask, cup_mask], axis=0).astype(np.float32)
mask_tensor = torch.from_numpy(mask)
return image_tensor, mask_tensor
class UNetSegmenter:
def __init__(
self,
manifest_path: Path,
device: Optional[str] = None,
cup_weight: float = 1.0,
disc_weight: float = 1.0,
target_size: int = 512,
val_ratio: float = 0.1,
train_datasets: Optional[Iterable[str]] = None,
val_datasets: Optional[Iterable[str]] = None,
holdout_datasets: Optional[Iterable[str]] = None,
normalize: str = "none",
use_stronger_aug: bool = False,
mask_cache_dir: Optional[Path] = None,
image_cache_dir: Optional[Path] = None,
in_memory_cache: bool = False,
loader_workers: int = 0,
) -> None:
self.manifest_path = manifest_path
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.cup_weight = cup_weight
self.disc_weight = disc_weight
self.target_size = target_size
self.val_ratio = val_ratio
self.normalize = (normalize or "none").lower()
self.use_stronger_aug = bool(use_stronger_aug)
self.mask_cache_dir = Path(mask_cache_dir).resolve() if mask_cache_dir else None
if self.mask_cache_dir:
self.mask_cache_dir.mkdir(parents=True, exist_ok=True)
self.image_cache_dir = Path(image_cache_dir).resolve() if image_cache_dir else None
if self.image_cache_dir:
self.image_cache_dir.mkdir(parents=True, exist_ok=True)
self.in_memory_cache = bool(in_memory_cache)
self._mem_image_cache: dict[str, np.ndarray] = {}
self._mem_mask_cache: dict[str, Tuple[np.ndarray, np.ndarray]] = {}
self.loader_workers = max(0, int(loader_workers))
self.train_dataset_filter = self._normalize_filter(train_datasets)
self.val_dataset_filter = self._normalize_filter(val_datasets)
self.holdout_dataset_filter = self._normalize_filter(holdout_datasets)
self.model = UNet().to(self.device)
self._manifest: List[ManifestEntry] = []
self.train_entries: List[ManifestEntry] = []
self.val_entries: List[ManifestEntry] = []
self.holdout_entries: List[ManifestEntry] = []
self.read_manifest()
def prebuild_in_memory_cache(
self,
*,
cache_workers: int = 0,
include_train: bool = True,
include_val: bool = True,
include_holdout: bool = False,
) -> None:
if not self.in_memory_cache:
return
selected: List[ManifestEntry] = []
if include_train:
selected.extend(self.train_entries)
if include_val:
selected.extend(self.val_entries)
if include_holdout:
selected.extend(self.holdout_entries)
if not selected:
return
# Deduplicate by cache key.
dedup = {}
for entry in selected:
dedup[self._entry_cache_key(entry)] = entry
entries = list(dedup.values())
workers = max(0, int(cache_workers))
print(
f"[UNetSegmenter] prebuilding in-memory cache for {len(entries)} samples "
f"(cache_workers={workers})",
flush=True,
)
def _warm_one(entry: ManifestEntry) -> None:
self.load_preprocessed_image(entry)
self.load_masks(entry)
if workers <= 1:
for entry in tqdm(entries, desc="Warm cache", unit="sample"):
_warm_one(entry)
else:
with ThreadPoolExecutor(max_workers=workers) as ex:
futures = [ex.submit(_warm_one, entry) for entry in entries]
for fut in tqdm(as_completed(futures), total=len(futures), desc="Warm cache", unit="sample"):
fut.result()
# ------------------------------------------------------------------
def read_manifest(self) -> None:
df = pd.read_csv(self.manifest_path)
entries: List[ManifestEntry] = []
for _, row in df.iterrows():
entry = ManifestEntry(
sample_id=row["sample_id"],
dataset=row["dataset"],
image_path=Path(row["image_path"]),
annotation_disc=Path(row["annotation_disc"]),
annotation_cup=Path(row["annotation_cup"]),
annotation_type_disc=row["annotation_type_disc"],
annotation_type_cup=row["annotation_type_cup"],
split=row["split"],
)
entries.append(entry)
self._manifest = entries
self.holdout_entries = [e for e in entries if e.split == "holdout"]
if self.holdout_dataset_filter is not None:
self.holdout_entries = [
e for e in self.holdout_entries if e.dataset in self.holdout_dataset_filter
]
trainable = [e for e in entries if e.split != "holdout"]
if self.train_dataset_filter is not None:
trainable = [
e for e in trainable if e.dataset in self.train_dataset_filter
]
if not trainable:
self.val_entries = []
self.train_entries = []
return
val_pool = trainable
if self.val_dataset_filter is not None:
filtered = [e for e in trainable if e.dataset in self.val_dataset_filter]
if filtered:
val_pool = filtered
if len(trainable) == 1:
val_count = 0
else:
val_count = max(1, int(len(trainable) * self.val_ratio))
val_count = min(val_count, len(val_pool), len(trainable) - 1)
selected_val: List[ManifestEntry] = []
if val_count > 0:
selected_val = list(val_pool[:val_count])
self.val_entries = selected_val
selected_ids = {id(item) for item in selected_val}
self.train_entries = [e for e in trainable if id(e) not in selected_ids]
if not self.train_entries and trainable:
# Fallback when filtering removed all train entries (e.g. val_count forced entire set)
self.train_entries = trainable
self.val_entries = []
# ------------------------------------------------------------------
def preprocess_image(self, image: Image.Image) -> Image.Image:
return image.resize((self.target_size, self.target_size), Resampling.BILINEAR)
def jitter_image(self, image: Image.Image) -> Image.Image:
# Photometric jitter only; geometric ops are applied jointly (image+mask)
return transforms.ColorJitter(0.1, 0.1, 0.1, 0.05)(image)
def augment_geometric(
self,
image: Image.Image,
disc_mask: np.ndarray,
cup_mask: np.ndarray,
) -> tuple[Image.Image, np.ndarray, np.ndarray]:
if not self.use_stronger_aug:
return image, disc_mask, cup_mask
img = image
disc_pil = Image.fromarray((disc_mask > 0).astype(np.uint8) * 255)
cup_pil = Image.fromarray((cup_mask > 0).astype(np.uint8) * 255)
# Random horizontal flip
if np.random.rand() < 0.5:
img = ImageOps.mirror(img)
disc_pil = ImageOps.mirror(disc_pil)
cup_pil = ImageOps.mirror(cup_pil)
# Random vertical flip
if np.random.rand() < 0.5:
img = ImageOps.flip(img)
disc_pil = ImageOps.flip(disc_pil)
cup_pil = ImageOps.flip(cup_pil)
# Random rotation (multiples of 90° to keep masks aligned)
rotations = np.random.choice([0, 90, 180, 270])
if rotations:
img = img.rotate(rotations, expand=False)
disc_pil = disc_pil.rotate(rotations, expand=False)
cup_pil = cup_pil.rotate(rotations, expand=False)
disc_mask = (np.array(disc_pil) > 0).astype(np.float32)
cup_mask = (np.array(cup_pil) > 0).astype(np.float32)
return img, disc_mask, cup_mask
@staticmethod
def _slugify(text: str) -> str:
return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in text)
def _entry_cache_key(self, entry: ManifestEntry) -> str:
return self._slugify(f"{entry.dataset}_{entry.sample_id}_sz{self.target_size}")
def _mask_cache_path(self, entry: ManifestEntry) -> Optional[Path]:
if self.mask_cache_dir is None:
return None
slug = self._slugify(f"{entry.dataset}_{entry.sample_id}")
fname = f"{slug}_sz{self.target_size}.npz"
return self.mask_cache_dir / fname
def _image_cache_path(self, entry: ManifestEntry) -> Optional[Path]:
if self.image_cache_dir is None:
return None
slug = self._slugify(f"{entry.dataset}_{entry.sample_id}")
fname = f"{slug}_img_sz{self.target_size}.npz"
return self.image_cache_dir / fname
def _load_image_cache(self, cache_path: Path) -> Optional[Image.Image]:
try:
data = np.load(str(cache_path), allow_pickle=False)
arr = data["image"].astype(np.uint8, copy=False)
if arr.ndim != 3 or arr.shape[2] != 3:
return None
return Image.fromarray(arr, mode="RGB")
except Exception:
with suppress(OSError, FileNotFoundError):
cache_path.unlink()
return None
def _save_image_cache(self, cache_path: Optional[Path], image: Image.Image) -> None:
if cache_path is None:
return
cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp.npz")
try:
arr = np.asarray(image, dtype=np.uint8)
np.savez_compressed(tmp_path, image=arr)
os.replace(tmp_path, cache_path)
except Exception:
with suppress(OSError, FileNotFoundError):
tmp_path.unlink()
def load_preprocessed_image(self, entry: ManifestEntry) -> Image.Image:
key = self._entry_cache_key(entry)
if self.in_memory_cache:
cached = self._mem_image_cache.get(key)
if cached is not None:
return Image.fromarray(cached, mode="RGB")
cache_path = self._image_cache_path(entry)
if cache_path and cache_path.exists():
cached = self._load_image_cache(cache_path)
if cached is not None:
if self.in_memory_cache:
self._mem_image_cache[key] = np.asarray(cached, dtype=np.uint8)
return cached
image = Image.open(entry.image_path).convert("RGB")
image = self.preprocess_image(image)
if self.in_memory_cache:
self._mem_image_cache[key] = np.asarray(image, dtype=np.uint8)
self._save_image_cache(cache_path, image)
return image
def _load_mask_cache(self, cache_path: Path) -> Optional[Tuple[np.ndarray, np.ndarray]]:
try:
data = np.load(str(cache_path), allow_pickle=False)
disc = data["disc"].astype(np.float32)
cup = data["cup"].astype(np.float32)
return disc, cup
except Exception:
with suppress(OSError, FileNotFoundError):
cache_path.unlink()
return None
def _save_mask_cache(
self,
cache_path: Optional[Path],
disc_mask: np.ndarray,
cup_mask: np.ndarray,
) -> None:
if cache_path is None:
return
cache_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp.npz")
try:
np.savez_compressed(
tmp_path,
disc=disc_mask.astype(np.uint8),
cup=cup_mask.astype(np.uint8),
)
os.replace(tmp_path, cache_path)
except Exception:
with suppress(OSError, FileNotFoundError):
tmp_path.unlink()
def _normalize_tensor(self, tensor: torch.Tensor) -> torch.Tensor:
if self.normalize == "per_image":
mean = tensor.mean(dim=(1, 2), keepdim=True)
std = tensor.std(dim=(1, 2), keepdim=True).clamp(min=1e-6)
return (tensor - mean) / std
if self.normalize == "imagenet":
mean = torch.tensor([0.485, 0.456, 0.406]).view(-1, 1, 1)
std = torch.tensor([0.229, 0.224, 0.225]).view(-1, 1, 1)
return (tensor - mean) / std
return tensor
# ------------------------------------------------------------------
def extract_masks_from_image(
self,
mask_path: Path,
disc_color: Optional[tuple[int, int, int]] = None,
cup_color: Optional[tuple[int, int, int]] = None,
) -> Tuple[np.ndarray, Optional[np.ndarray], Tuple[int, int]]:
raw = Image.open(mask_path)
arr = np.array(raw)
if arr.ndim == 2:
h, w = arr.shape
flat = arr.reshape(-1).astype(np.int64, copy=False)
edges = np.concatenate([arr[0, :], arr[-1, :], arr[:, 0], arr[:, -1]], axis=0).astype(np.int64, copy=False)
edge_counts = np.bincount(edges, minlength=256)
bg_val = int(np.argmax(edge_counts))
counts = np.bincount(flat, minlength=256)
counts[bg_val] = 0
vals = np.where(counts > 0)[0]
if vals.size < 1:
raise ValueError(f"Mask {mask_path} does not contain discernible labels")
# Disc = ALL non-background pixels (full optic disc: rim + cup combined).
# Previously this was rim-only, which caused the cup structural prior
# (cup & disc) to produce empty cup masks since cup and rim don't overlap.
disc_mask = (arr != bg_val).astype(np.uint8)
# Cup = the darkest non-background value (0 in REFUGE = inner cup region).
# Using min-value rather than frequency avoids swapping when cup area > rim area.
cup_val = int(np.min(vals)) if vals.size > 1 else None
cup_mask = (arr == cup_val).astype(np.uint8) if cup_val is not None else np.zeros_like(disc_mask, dtype=np.uint8)
return disc_mask, cup_mask if cup_mask.any() else None, (w, h)
image = raw.convert("RGB")
arr = np.array(image)
h, w, c = arr.shape
if disc_color is None or cup_color is None:
# Fast color discovery via NumPy (avoid Python-level per-pixel tuple counting).
edges = np.concatenate(
[arr[0, :, :], arr[-1, :, :], arr[:, 0, :], arr[:, -1, :]], axis=0
)
edge_colors, edge_counts = np.unique(edges.reshape(-1, c), axis=0, return_counts=True)
bg_color_np = edge_colors[int(np.argmax(edge_counts))]
colors_np, counts_np = np.unique(arr.reshape(-1, c), axis=0, return_counts=True)
keep = np.any(colors_np != bg_color_np.reshape(1, -1), axis=1)
colors_np = colors_np[keep]
counts_np = counts_np[keep]
if colors_np.shape[0] < 1:
raise ValueError(f"Mask {mask_path} does not contain discernible labels")
order = np.argsort(-counts_np)
colors_np = colors_np[order]
disc_color = tuple(int(v) for v in colors_np[0].tolist())
cup_color = (
tuple(int(v) for v in colors_np[1].tolist())
if colors_np.shape[0] > 1
else None
)
disc_mask = np.zeros((h, w), dtype=np.uint8)
cup_mask = np.zeros((h, w), dtype=np.uint8)
if disc_color is not None:
disc_mask[np.all(arr == disc_color, axis=-1)] = 1
if cup_color is not None:
cup_mask[np.all(arr == cup_color, axis=-1)] = 1
return disc_mask, cup_mask if cup_mask.any() else None, (w, h)
def load_contour_from_file(self, contour_path: Path) -> np.ndarray:
# Fast path: contour files are typically CSV or whitespace-delimited x,y pairs.
try:
arr = np.loadtxt(str(contour_path), delimiter=",", comments="#", dtype=np.float32)
except Exception:
try:
arr = np.loadtxt(str(contour_path), comments="#", dtype=np.float32)
except Exception:
return np.zeros((0, 2), dtype=np.float32)
if arr.size == 0:
return np.zeros((0, 2), dtype=np.float32)
if arr.ndim == 1:
if arr.shape[0] < 2:
return np.zeros((0, 2), dtype=np.float32)
arr = arr.reshape(1, -1)
if arr.shape[1] < 2:
return np.zeros((0, 2), dtype=np.float32)
return arr[:, :2].astype(np.float32, copy=False)
def coords_to_mask(
self,
coords: Optional[np.ndarray],
size: Tuple[int, int],
) -> np.ndarray:
if coords is None or len(coords) == 0:
return np.zeros((self.target_size, self.target_size), dtype=np.float32)
width, height = map(int, size)
target_shape = (height, width)
arr = np.asarray(coords)
if arr.size == 0:
return np.zeros((self.target_size, self.target_size), dtype=np.float32)
if arr.ndim == 2 and arr.shape[-1] != 2:
mask = (arr > 0).astype(np.uint8)
return self._resize_mask(mask)
if arr.ndim > 2:
arr = arr.reshape(-1, arr.shape[-1])
arr = arr.astype(float, copy=False)
if arr.shape[-1] != 2:
raise ValueError(f"Expected coordinate pairs, got shape {arr.shape}")
points = [tuple(map(float, pt)) for pt in arr]
if len(points) < 3:
return np.zeros(target_shape, dtype=np.float32)
img = Image.new("L", size, 0)
draw = ImageDraw.Draw(img)
draw.polygon(points, outline=1, fill=1)
mask = np.array(img, dtype=np.uint8)
return self._resize_mask(mask)
def _resize_mask(self, mask: np.ndarray) -> np.ndarray:
img = Image.fromarray((mask > 0).astype(np.uint8) * 255)
img = img.resize((self.target_size, self.target_size), Resampling.NEAREST)
return (np.array(img, dtype=np.uint8) > 0).astype(np.float32)
def load_masks(self, entry: ManifestEntry) -> Tuple[np.ndarray, np.ndarray]:
key = self._entry_cache_key(entry)
if self.in_memory_cache:
cached = self._mem_mask_cache.get(key)
if cached is not None:
disc_u8, cup_u8 = cached
return disc_u8.astype(np.float32), cup_u8.astype(np.float32)
cache_path = self._mask_cache_path(entry)
if cache_path and cache_path.exists():
cached = self._load_mask_cache(cache_path)
if cached is not None:
if self.in_memory_cache:
disc, cup = cached
self._mem_mask_cache[key] = (
disc.astype(np.uint8),
cup.astype(np.uint8),
)
return cached
image = Image.open(entry.image_path)
size = image.size
disc_coords = cup_coords = None
if entry.annotation_type_disc == "mask":
disc_coords, cup_coords_from_disc, size = self.extract_masks_from_image(
entry.annotation_disc
)
if cup_coords_from_disc is not None:
cup_coords = cup_coords_from_disc
else:
disc_coords = self.load_contour_from_file(entry.annotation_disc)
if entry.annotation_type_cup == "mask":
_, cup_coords_from_cup, size_cup = self.extract_masks_from_image(
entry.annotation_cup
)
if cup_coords_from_cup is not None:
cup_coords = cup_coords_from_cup
if disc_coords is None:
disc_coords, _, size = self.extract_masks_from_image(
entry.annotation_cup
)
else:
size = size_cup
else:
cup_coords = self.load_contour_from_file(entry.annotation_cup)
disc_mask = self.coords_to_mask(disc_coords, size).astype(np.float32)
cup_mask = self.coords_to_mask(cup_coords, size).astype(np.float32)
if self.in_memory_cache:
self._mem_mask_cache[key] = (
disc_mask.astype(np.uint8),
cup_mask.astype(np.uint8),
)
self._save_mask_cache(cache_path, disc_mask, cup_mask)
return disc_mask, cup_mask
# ------------------------------------------------------------------
def build_loaders(self, batch_size: int = 4, num_workers: int = 0) -> Tuple[DataLoader, DataLoader]:
train_ds = SegmentationDataset(self.train_entries, self, augment=True)
val_ds = SegmentationDataset(self.val_entries, self, augment=False)
train_loader = DataLoader(
train_ds, batch_size=batch_size, shuffle=True, num_workers=num_workers, pin_memory=True
)
val_loader = DataLoader(
val_ds, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True
)
return train_loader, val_loader
# ------------------------------------------------------------------
def dice_score(self, preds: torch.Tensor, targets: torch.Tensor) -> torch.Tensor:
preds = (preds > 0.5).float()
intersection = (preds * targets).sum(dim=(2, 3))
union = preds.sum(dim=(2, 3)) + targets.sum(dim=(2, 3))
dice = (2 * intersection + 1e-6) / (union + 1e-6)
return dice.mean(dim=0)
def train(
self,
epochs: int = 40,
batch_size: int = 4,
lr: float = 1e-3,
weight_decay: float = 1e-5,
checkpoint_dir: Path = Path("models/unet_segmenter"),
) -> None:
print(
f"[UNetSegmenter] training on device={self.device} "
f"(epochs={epochs}, batch_size={batch_size}, workers={self.loader_workers})"
)
train_loader, val_loader = self.build_loaders(batch_size=batch_size, num_workers=self.loader_workers)
optimizer = torch.optim.Adam(
self.model.parameters(), lr=lr, weight_decay=weight_decay
)
criterion = nn.BCEWithLogitsLoss()
best_dice = -math.inf
checkpoint_dir.mkdir(parents=True, exist_ok=True)
best_path = checkpoint_dir / "best.pt"
epoch_bar = tqdm(range(1, epochs + 1), desc="Epochs", unit="epoch")
for epoch in epoch_bar:
self.model.train()
batch_bar = tqdm(
train_loader,
desc=f"Train {epoch}/{epochs}",
leave=False,
unit="batch",
total=len(train_loader),
)
train_loss_total = 0.0
train_samples = 0
for images, masks in batch_bar:
images = images.to(self.device)
masks = masks.to(self.device)
optimizer.zero_grad()
logits = self.model(images)
loss_disc = criterion(logits[:, 0:1], masks[:, 0:1])
loss_cup = criterion(logits[:, 1:2], masks[:, 1:2])
loss = self.disc_weight * loss_disc + self.cup_weight * loss_cup
loss.backward()
optimizer.step()
batch_size = images.size(0)
train_loss_total += loss.item() * batch_size
train_samples += batch_size
train_loss = (
train_loss_total / train_samples if train_samples else float("nan")
)
self.model.eval()
dices = []
val_bar = tqdm(
val_loader,
desc="Validate",
leave=False,
unit="batch",
total=len(val_loader),
)
with torch.no_grad():
for images, masks in val_bar:
images = images.to(self.device)
masks = masks.to(self.device)
logits = self.model(images)
probs = torch.sigmoid(logits)
dice = self.dice_score(probs, masks)
dices.append(dice.cpu())
if dices:
mean_dice = torch.stack(dices).mean(dim=0)
disc_dice = mean_dice[0].item()
cup_dice = mean_dice[1].item()
weight_sum = self.disc_weight + self.cup_weight
score = (
(self.disc_weight * disc_dice + self.cup_weight * cup_dice)
/ weight_sum
if weight_sum
else 0.0
)
epoch_bar.set_postfix(
loss=f"{train_loss:.4f}",
dice_disc=f"{disc_dice:.3f}",
dice_cup=f"{cup_dice:.3f}",
dice_w=f"{score:.3f}",
)
else:
disc_dice = cup_dice = 0.0
score = 0.0
epoch_bar.set_postfix(loss=f"{train_loss:.4f}")
if score > best_dice:
best_dice = score
torch.save({"model": self.model.state_dict()}, best_path)
if best_path.exists():
state = torch.load(best_path, map_location=self.device)
self.model.load_state_dict(state["model"])
# ------------------------------------------------------------------
def evaluate_holdout(
self, output_dir: Path = Path("analysis_data/segmenter_eval")
) -> pd.DataFrame:
return self.evaluate_dataset(split_filter={"holdout"}, output_dir=output_dir)
@staticmethod
def overlay_masks(
image: Image.Image, disc: np.ndarray, cup: np.ndarray
) -> Image.Image:
overlay = image.copy()
disc_img = Image.fromarray((disc * 255).astype(np.uint8))
cup_img = Image.fromarray((cup * 255).astype(np.uint8))
disc_color = Image.new("RGBA", image.size, (255, 0, 0, 0))
cup_color = Image.new("RGBA", image.size, (0, 255, 0, 0))
disc_color.paste((255, 0, 0, 100), mask=disc_img)
cup_color.paste((0, 255, 0, 100), mask=cup_img)
overlay = overlay.convert("RGBA")
overlay = Image.alpha_composite(overlay, disc_color)
overlay = Image.alpha_composite(overlay, cup_color)
return overlay.convert("RGB")
# ------------------------------------------------------------------
@staticmethod
def _normalize_filter(values: Optional[Iterable[str]]) -> Optional[Set[str]]:
if values is None:
return None
if isinstance(values, str):
return {values}
return {str(item) for item in values}
@staticmethod
def _dice_from_masks(pred: np.ndarray, target: np.ndarray) -> float:
pred = (pred > 0).astype(np.float32)
target = (target > 0).astype(np.float32)
intersection = float((pred * target).sum())
denom = float(pred.sum() + target.sum())
return (2.0 * intersection + 1e-6) / (denom + 1e-6)
def get_entries(
self,
dataset_filter: Optional[Iterable[str]] = None,
split_filter: Optional[Iterable[str]] = None,
) -> List[ManifestEntry]:
dataset_set = self._normalize_filter(dataset_filter)
split_set = self._normalize_filter(split_filter)
entries = self._manifest
if dataset_set is not None:
entries = [e for e in entries if e.dataset in dataset_set]
if split_set is not None:
entries = [e for e in entries if e.split in split_set]
return list(entries)
def evaluate_dataset(
self,
dataset_filter: Optional[Iterable[str]] = None,
split_filter: Optional[Iterable[str]] = None,
output_dir: Path = Path("analysis_data/segmenter_eval"),
save_overlays: bool = True,
metrics_path: Optional[Path] = None,
threshold: float = 0.5,
tta: bool = False,
) -> pd.DataFrame:
entries = self.get_entries(
dataset_filter=dataset_filter, split_filter=split_filter
)
if not entries:
return pd.DataFrame(
columns=[
"sample_id",
"dataset",
"split",
"dice_disc",
"dice_cup",
]
)
output_dir.mkdir(parents=True, exist_ok=True)
if metrics_path is None:
suffix_parts = []
if dataset_filter is not None:
suffix_parts.append("-".join(sorted(self._normalize_filter(dataset_filter))))
if split_filter is not None:
suffix_parts.append("-".join(sorted(self._normalize_filter(split_filter))))
suffix = "_".join(part for part in suffix_parts if part)
csv_name = f"metrics{'_' + suffix if suffix else ''}.csv"
metrics_path = output_dir / csv_name
records = []
self.model.eval()
progress = tqdm(
entries,
desc="Evaluate",
unit="sample",
leave=False,
)
for entry in progress:
orig_image = Image.open(entry.image_path).convert("RGB")
image = self.preprocess_image(orig_image)
tensor = transforms.ToTensor()(image)
tensor = self._normalize_tensor(tensor)
tensor = tensor.unsqueeze(0).to(self.device)
with torch.no_grad():
logits = self.model(tensor)
if tta:
t_h = torch.flip(tensor, dims=[3])
log_h = self.model(t_h)
log_h = torch.flip(log_h, dims=[3])
t_v = torch.flip(tensor, dims=[2])
log_v = self.model(t_v)
log_v = torch.flip(log_v, dims=[2])
logits = (logits + log_h + log_v) / 3.0
probs = torch.sigmoid(logits)[0].cpu().numpy()
disc_pred = (probs[0] > threshold).astype(np.uint8)
cup_pred = (probs[1] > threshold).astype(np.uint8)
# Structural prior: cup within disc
cup_pred = (cup_pred > 0) & (disc_pred > 0)
cup_pred = cup_pred.astype(np.uint8)
disc_gt, cup_gt = self.load_masks(entry)
disc_gt = disc_gt.astype(np.uint8)
cup_gt = cup_gt.astype(np.uint8)
dice_disc = self._dice_from_masks(disc_pred, disc_gt)
dice_cup = self._dice_from_masks(cup_pred, cup_gt)
records.append(
{
"sample_id": entry.sample_id,
"dataset": entry.dataset,
"split": entry.split,
"dice_disc": dice_disc,
"dice_cup": dice_cup,
}
)
progress.set_postfix(
dice_disc=f"{dice_disc:.3f}", dice_cup=f"{dice_cup:.3f}"
)
if save_overlays:
overlay_gt = self.overlay_masks(image, disc_gt, cup_gt)
overlay_pred = self.overlay_masks(image, disc_pred, cup_pred)
combined = Image.new("RGB", (image.width * 2, image.height))
combined.paste(overlay_gt, (0, 0))
combined.paste(overlay_pred, (image.width, 0))
combined.save(output_dir / f"{entry.sample_id}_eval.png")
metrics_df = pd.DataFrame(records)
summary = metrics_df[["dice_disc", "dice_cup"]].mean()
summary_row = {
"sample_id": "__mean__",
"dataset": "summary",
"split": "summary",
"dice_disc": summary["dice_disc"],
"dice_cup": summary["dice_cup"],
}
metrics_with_summary = pd.concat(
[metrics_df, pd.DataFrame([summary_row])], ignore_index=True
)
metrics_with_summary.to_csv(metrics_path, index=False)
return metrics_with_summary