added better memory caching, multithreaded processing, cleanup scripts dir

This commit is contained in:
rpotter6298
2026-03-13 08:08:36 +01:00
parent 8282461a23
commit 7ea85d5426
39 changed files with 1850 additions and 1998 deletions
+1
View File
@@ -11,3 +11,4 @@ models/refuge/
models/v2/refuge/ models/v2/refuge/
**/.archive/ **/.archive/
.archive/ .archive/
scripts/deprecated/
-19
View File
@@ -1,19 +0,0 @@
from .clinical_data import ClinicalData
from .dataset import ClinicalDataset
from .image_tower import ImageTower
from .md_tower import MDTower
from .bridge import Bridge, VoteBridge
# from .hypertower import HyperTower
from .backbones import list_names, BackboneSpec, BACKBONES
from .papila_builders import build_papila_clinical
from .SE_attention import SEBlock, SEGateLogger
from .early_stop import EarlyStopper
__all__ = [
"ClinicalData",
"ClinicalDataset",
"ImageTower",
"MDTower",
"Bridge",
"VoteBridge",
# "HyperTower",
]
+849
View File
@@ -0,0 +1,849 @@
"""REFUGE glaucoma classification with rotation-based TTT."""
from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, Iterable, List, Optional, Sequence, Tuple
import random
import numpy as np
from PIL import Image
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset
from torchvision import models, transforms
from torchvision.transforms import functional as TF
import torch.nn.functional as F
from sklearn.metrics import roc_auc_score
from skimage.transform import warp_polar
from tqdm import tqdm
from classes.geometry_features import (
FEATURE_DIM,
EPS,
compute_geometry_features,
disc_cup_from_mask_image,
)
from classes.refuge_preprocessing import RefugePreprocessing, RefugeSample
from classes.refuge_segmentation import RefugeSegmentation
from classes.unet_segmenter import UNetSegmenter
# ---------------------------------------------------------------------------
# Dataset utilities
# ---------------------------------------------------------------------------
def _default_image_transform(size: int = 256) -> transforms.Compose:
return transforms.Compose(
[
transforms.Resize((size, size)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
]
)
def _augment_image_transform(size: int = 256) -> transforms.Compose:
return transforms.Compose(
[
transforms.Resize((size, size)),
transforms.RandomHorizontalFlip(),
transforms.RandomRotation(10),
transforms.ColorJitter(0.1, 0.1, 0.1, 0.05),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
]
)
def _crop_from_geometry(image: Image.Image, geometry: Dict[str, float], size: int = 256) -> Image.Image:
cx, cy = geometry["centre_x"], geometry["centre_y"]
r = geometry["crop_radius"]
left = max(0.0, cx - r)
upper = max(0.0, cy - r)
right = min(image.width, cx + r)
lower = min(image.height, cy + r)
crop = image.crop((left, upper, right, lower))
return crop.resize((size, size), Image.BILINEAR)
def _geometry_from_mask(mask: np.ndarray, scale: float) -> Dict[str, float]:
mask = np.asarray(mask) > 0
coords = np.argwhere(mask)
if coords.size == 0:
raise RuntimeError("Empty mask; cannot derive geometry")
ys, xs = coords[:, 0], coords[:, 1]
centre_x = float(xs.mean())
centre_y = float(ys.mean())
width = float(xs.max() - xs.min())
height = float(ys.max() - ys.min())
diameter = max(width, height)
radius = diameter / 2.0
crop_radius = radius * scale
return {
"centre_x": centre_x,
"centre_y": centre_y,
"radius": radius,
"crop_radius": crop_radius,
"crop_size": crop_radius * 2.0,
}
def _compute_feature_vector(disc_mask: np.ndarray, cup_mask: np.ndarray) -> np.ndarray:
return compute_geometry_features(disc_mask, cup_mask)
def _compute_polar_image(crop: Image.Image, size: int) -> Image.Image:
arr = np.asarray(crop).astype(np.float32) / 255.0
radius = min(arr.shape[0], arr.shape[1]) / 2.0
polar = warp_polar(
arr,
radius=radius,
scaling="linear",
channel_axis=-1,
)
polar = np.clip(polar, 0.0, 1.0)
polar_img = Image.fromarray((polar * 255).astype(np.uint8))
return polar_img.resize((size, size), Image.BILINEAR)
def _crop_mask_from_geometry(mask: np.ndarray, geometry: Dict[str, float], size: int) -> np.ndarray:
mask_img = Image.fromarray((mask > 0).astype(np.uint8) * 255)
cx, cy = geometry["centre_x"], geometry["centre_y"]
r = geometry["crop_radius"]
left = max(0.0, cx - r)
upper = max(0.0, cy - r)
right = min(mask_img.width, cx + r)
lower = min(mask_img.height, cy + r)
crop = mask_img.crop((left, upper, right, lower)).resize((size, size), Image.NEAREST)
return (np.asarray(crop) > 0).astype(np.uint8)
@dataclass
class RefugeClassificationRecord:
sample: RefugeSample
geometry: Dict[str, float]
disc_mask: Optional[np.ndarray] = None
cup_mask: Optional[np.ndarray] = None
class RefugeClassificationDataset(Dataset):
def __init__(
self,
records: Sequence[RefugeClassificationRecord],
transform: transforms.Compose,
polar_transform: transforms.Compose,
size: int = 256,
) -> None:
self.records = list(records)
self.transform = transform
self.polar_transform = polar_transform
self.size = size
def __len__(self) -> int:
return len(self.records)
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
rec = self.records[idx]
image = Image.open(rec.sample.image_path).convert("RGB")
crop = _crop_from_geometry(image, rec.geometry, size=self.size)
polar_image = _compute_polar_image(crop, size=self.size)
tensor = self.transform(crop)
polar_tensor = self.polar_transform(polar_image)
features = np.zeros((FEATURE_DIM,), dtype=np.float32)
if rec.disc_mask is not None and rec.cup_mask is not None:
disc_crop = _crop_mask_from_geometry(rec.disc_mask, rec.geometry, self.size)
cup_crop = _crop_mask_from_geometry(rec.cup_mask, rec.geometry, self.size)
features = _compute_feature_vector(disc_crop, cup_crop)
feature_tensor = torch.from_numpy(features).float()
label = rec.sample.label
if label is None:
raise ValueError(f"Sample {rec.sample.sample_id} is missing glaucoma label")
return {
"image": tensor,
"polar": polar_tensor,
"features": feature_tensor,
"label": torch.tensor(label, dtype=torch.long),
"sample_id": rec.sample.sample_id,
}
class RefugeTTTDataset(Dataset):
"""Dataset providing unlabeled crops for test-time training."""
def __init__(self, records: Sequence[RefugeClassificationRecord], transform: transforms.Compose, size: int = 256) -> None:
self.records = list(records)
self.transform = transform
self.size = size
def __len__(self) -> int:
return len(self.records)
def __getitem__(self, idx: int) -> torch.Tensor:
rec = self.records[idx]
image = Image.open(rec.sample.image_path).convert("RGB")
crop = _crop_from_geometry(image, rec.geometry, size=self.size)
return self.transform(crop)
class UNetGeometryProvider:
"""Callable wrapper that derives disc geometry using a trained UNetSegmenter."""
def __init__(
self,
segmenter: UNetSegmenter,
threshold: float = 0.5,
tta: bool = False,
) -> None:
self.segmenter = segmenter
self.threshold = threshold
self.tta = tta
self.segmenter.model.eval()
def __call__(self, sample: RefugeSample, scale: float) -> Tuple[Dict[str, float], np.ndarray, np.ndarray]:
image = Image.open(sample.image_path).convert("RGB")
resized = self.segmenter.preprocess_image(image)
tensor = transforms.ToTensor()(resized)
tensor = self.segmenter._normalize_tensor(tensor)
tensor = tensor.unsqueeze(0).to(self.segmenter.device)
with torch.no_grad():
logits = self.segmenter.model(tensor)
if self.tta:
t_h = torch.flip(tensor, dims=[3])
log_h = self.segmenter.model(t_h)
log_h = torch.flip(log_h, dims=[3])
t_v = torch.flip(tensor, dims=[2])
log_v = self.segmenter.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] > self.threshold).astype(np.uint8) * 255
cup_pred = (probs[1] > self.threshold).astype(np.uint8) * 255
disc_img = Image.fromarray(disc_pred, mode="L").resize(image.size, Image.NEAREST)
cup_img = Image.fromarray(cup_pred, mode="L").resize(image.size, Image.NEAREST)
disc_mask = (np.array(disc_img, dtype=np.uint8) > 0).astype(np.uint8)
cup_mask = (np.array(cup_img, dtype=np.uint8) > 0).astype(np.uint8)
cup_mask = (cup_mask > 0) & (disc_mask > 0)
cup_mask = cup_mask.astype(np.uint8)
geom = _geometry_from_mask(disc_mask, scale)
return geom, disc_mask, cup_mask
# ---------------------------------------------------------------------------
# Classification module
# ---------------------------------------------------------------------------
class ArcMarginProduct(nn.Module):
"""Additive angular margin (ArcFace) head."""
def __init__(
self,
in_features: int,
out_features: int,
s: float = 30.0,
m: float = 0.5,
easy_margin: bool = False,
) -> None:
super().__init__()
self.in_features = in_features
self.out_features = out_features
self.s = float(s)
self.m = float(m)
self.easy_margin = easy_margin
self.weight = nn.Parameter(torch.empty(out_features, in_features))
nn.init.xavier_uniform_(self.weight)
self.cos_m = math.cos(m)
self.sin_m = math.sin(m)
self.th = math.cos(math.pi - m)
self.mm = math.sin(math.pi - m) * m
def forward(self, input: torch.Tensor, label: Optional[torch.Tensor] = None) -> torch.Tensor:
cosine = F.linear(F.normalize(input), F.normalize(self.weight))
if label is None:
return cosine * self.s
sine = torch.sqrt(torch.clamp(1.0 - cosine.pow(2), min=0.0))
phi = cosine * self.cos_m - sine * self.sin_m
if self.easy_margin:
phi = torch.where(cosine > 0, phi, cosine)
else:
phi = torch.where(cosine > self.th, phi, cosine - self.mm)
one_hot = torch.zeros_like(cosine)
one_hot.scatter_(1, label.view(-1, 1), 1.0)
logits = (one_hot * phi) + ((1.0 - one_hot) * cosine)
logits *= self.s
return logits
class RefugeClassification:
"""Train and evaluate REFUGE glaucoma classifiers with TTT support."""
def __init__(
self,
preprocessing: RefugePreprocessing,
segmentation: RefugeSegmentation,
backbone: Optional[nn.Module] = None,
geometry_fn: Optional[
Callable[
[RefugeSample, float],
Tuple[Dict[str, float], Optional[np.ndarray], Optional[np.ndarray]],
]
] = None,
cache_dir: Optional[Path] = None,
use_all_labeled: bool = False,
auto_val_ratio: float = 0.1,
use_margin: bool = False,
margin_s: float = 30.0,
margin_m: float = 0.5,
) -> None:
self.preprocessing = preprocessing
self.segmentation = segmentation
if backbone is not None:
self.backbone = backbone
in_features = getattr(self.backbone, "_feature_dim", None)
if in_features is None:
if hasattr(self.backbone, "fc") and hasattr(self.backbone.fc, "in_features"):
in_features = self.backbone.fc.in_features # type: ignore[attr-defined]
self.backbone.fc = nn.Identity() # type: ignore[attr-defined]
else:
raise ValueError(
"Provided backbone must have '_feature_dim' or expose fc.in_features"
)
else:
self.backbone = self._default_backbone()
in_features = getattr(self.backbone, "_feature_dim", None)
if in_features is None:
in_features = self.backbone.fc.in_features # type: ignore[attr-defined]
self.backbone.fc = nn.Identity() # type: ignore[attr-defined]
self.feature_dim = in_features
self.use_polar = True
self.extra_feature_dim = FEATURE_DIM
combined_dim = self.feature_dim * (1 + int(self.use_polar)) + self.extra_feature_dim
self.margin_s = float(margin_s)
self.margin_m = float(margin_m)
self.use_margin = bool(use_margin)
if self.use_margin:
self.classifier_head = ArcMarginProduct(
combined_dim, 2, s=self.margin_s, m=self.margin_m
)
else:
self.classifier_head = nn.Linear(combined_dim, 2)
self.rotation_head = nn.Linear(self.feature_dim, 4)
self.train_dataset: Optional[RefugeClassificationDataset] = None
self.val_dataset: Optional[RefugeClassificationDataset] = None
self.train_loader: Optional[DataLoader] = None
self.val_loader: Optional[DataLoader] = None
self.ttt_transform = _default_image_transform()
self.train_transform = _augment_image_transform()
self.eval_transform = _default_image_transform()
self.polar_transform = _default_image_transform()
self.crop_scale = 2.5
self.crop_size = 256
self.geometry_cache: Dict[
str, Tuple[Dict[str, float], Optional[np.ndarray], Optional[np.ndarray]]
] = {}
self.train_records: List[RefugeClassificationRecord] = []
self.val_records: List[RefugeClassificationRecord] = []
self._geometry_fn = geometry_fn
self.cache_dir = cache_dir
if self.cache_dir is not None:
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.use_all_labeled = use_all_labeled
self.auto_val_ratio = auto_val_ratio
# ------------------------------------------------------------------
@staticmethod
def _default_backbone() -> nn.Module:
weights = models.ResNet50_Weights.IMAGENET1K_V2
model = models.resnet50(weights=weights)
in_features = model.fc.in_features
model.fc = nn.Identity()
setattr(model, "_feature_dim", in_features)
return model
# ------------------------------------------------------------------
def build_datasets(
self,
crop_scale: float = 2.5,
crop_size: int = 256,
batch_size: int = 16,
num_workers: int = 4,
) -> None:
self.crop_scale = crop_scale
self.crop_size = crop_size
self.train_transform = _augment_image_transform(crop_size)
self.eval_transform = _default_image_transform(crop_size)
self.ttt_transform = _default_image_transform(crop_size)
self.polar_transform = _default_image_transform(crop_size)
manifest = list(self.preprocessing.build_manifest())
train_records: List[RefugeClassificationRecord] = []
val_records: List[RefugeClassificationRecord] = []
allowed_splits = {"train", "val"}
candidates = [
sample
for sample in manifest
if sample.label is not None and sample.split in allowed_splits
]
print(
f"[classifier] Building datasets from {len(candidates)} labelled samples (train/val)"
)
skipped: List[str] = []
for sample in tqdm(
candidates,
desc="Preparing records",
unit="sample",
leave=False,
):
try:
geom, disc_mask, cup_mask = self._resolve_geometry(sample, crop_scale)
except RuntimeError:
skipped.append(sample.sample_id)
continue
record = RefugeClassificationRecord(
sample=sample,
geometry=geom,
disc_mask=disc_mask,
cup_mask=cup_mask,
)
if sample.split == "train" or (
self.use_all_labeled and sample.split == "val"
):
train_records.append(record)
else:
val_records.append(record)
if skipped:
print(
f"[classifier] WARNING: {len(skipped)}/{len(candidates)} samples skipped "
f"due to empty segmentation mask: {skipped}"
)
if (not val_records or self.use_all_labeled) and train_records and self.auto_val_ratio > 0.0:
rng = random.Random(42)
label_groups: Dict[int, List[RefugeClassificationRecord]] = {}
for rec in train_records:
label = int(rec.sample.label or 0)
label_groups.setdefault(label, []).append(rec)
new_train: List[RefugeClassificationRecord] = []
new_val: List[RefugeClassificationRecord] = []
for recs in label_groups.values():
rng.shuffle(recs)
if len(recs) <= 1:
new_train.extend(recs)
continue
val_count = max(1, int(round(len(recs) * self.auto_val_ratio)))
if val_count >= len(recs):
val_count = len(recs) - 1
new_val.extend(recs[:val_count])
new_train.extend(recs[val_count:])
if not new_val:
# Fallback: ensure at least one validation sample if possible
if len(new_train) > 1:
new_val.append(new_train.pop())
if new_val:
val_records = new_val
train_records = new_train
self.train_records = train_records
self.val_records = val_records
print(
f"[classifier] Records ready → train: {len(train_records)}, val: {len(val_records)}"
)
self.train_dataset = RefugeClassificationDataset(
train_records,
transform=self.train_transform,
polar_transform=self.polar_transform,
size=crop_size,
)
self.val_dataset = RefugeClassificationDataset(
val_records,
transform=self.eval_transform,
polar_transform=self.polar_transform,
size=crop_size,
)
self.train_loader = DataLoader(
self.train_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=num_workers,
pin_memory=True,
)
self.val_loader = DataLoader(
self.val_dataset,
batch_size=batch_size,
shuffle=False,
num_workers=num_workers,
pin_memory=True,
)
print(
"[classifier] DataLoaders prepared — training batches will start shortly"
)
# ------------------------------------------------------------------
def _resolve_geometry(
self, sample: RefugeSample, scale: float
) -> Tuple[Dict[str, float], Optional[np.ndarray], Optional[np.ndarray]]:
key = self._cache_key(sample.sample_id, scale)
cached = self.geometry_cache.get(key)
if cached is not None:
return cached
cache_path = self._cache_path(sample.sample_id, scale)
if cache_path is not None and cache_path.exists():
data = np.load(cache_path, allow_pickle=False)
geom = {
"centre_x": float(data["centre_x"]),
"centre_y": float(data["centre_y"]),
"radius": float(data["radius"]),
"crop_radius": float(data["crop_radius"]),
"crop_size": float(data["crop_size"]),
}
disc_mask = None
cup_mask = None
if int(data["has_disc"]):
disc_mask = data["disc_mask"].astype(np.uint8)
if int(data["has_cup"]):
cup_mask = data["cup_mask"].astype(np.uint8)
self.geometry_cache[key] = (geom, disc_mask, cup_mask)
return geom, disc_mask, cup_mask
disc_mask: Optional[np.ndarray] = None
cup_mask: Optional[np.ndarray] = None
if sample.mask_path and sample.mask_path.exists():
mask_img = Image.open(sample.mask_path).convert("RGB")
disc_mask, cup_mask = disc_cup_from_mask_image(mask_img)
geom = _geometry_from_mask(disc_mask, scale)
elif self._geometry_fn is not None:
geom, disc_mask, cup_mask = self._geometry_fn(sample, scale)
else:
geom = self.segmentation.infer_disc_geometry(sample, scale=scale)
try:
pred_mask = self.segmentation.predict_mask(sample).numpy()
disc_mask = pred_mask.astype(np.uint8)
except Exception:
disc_mask = None
cup_mask = None
if cache_path is not None:
try:
np.savez_compressed(
cache_path,
centre_x=geom["centre_x"],
centre_y=geom["centre_y"],
radius=geom["radius"],
crop_radius=geom["crop_radius"],
crop_size=geom.get("crop_size", geom["crop_radius"] * 2.0),
disc_mask=disc_mask if disc_mask is not None else np.array([], dtype=np.uint8),
cup_mask=cup_mask if cup_mask is not None else np.array([], dtype=np.uint8),
has_disc=int(disc_mask is not None),
has_cup=int(cup_mask is not None),
)
except Exception:
pass
self.geometry_cache[key] = (geom, disc_mask, cup_mask)
return geom, disc_mask, cup_mask
def set_geometry_fn(
self,
geometry_fn: Optional[
Callable[
[RefugeSample, float],
Tuple[Dict[str, float], Optional[np.ndarray], Optional[np.ndarray]],
]
],
) -> None:
self._geometry_fn = geometry_fn
self.geometry_cache.clear()
def build_records_for_samples(
self,
samples: Sequence[RefugeSample],
crop_scale: Optional[float] = None,
progress_prefix: Optional[str] = None,
) -> List[RefugeClassificationRecord]:
scale = crop_scale if crop_scale is not None else self.crop_scale
records: List[RefugeClassificationRecord] = []
skipped: List[str] = []
iterator: Iterable[RefugeSample]
if progress_prefix is not None:
iterator = tqdm(samples, desc=progress_prefix, unit="sample", leave=False)
else:
iterator = samples
labeled = [s for s in samples if s.label is not None]
for sample in iterator:
if sample.label is None:
continue
try:
geom, disc_mask, cup_mask = self._resolve_geometry(sample, scale)
except RuntimeError:
skipped.append(sample.sample_id)
continue
records.append(
RefugeClassificationRecord(
sample=sample,
geometry=geom,
disc_mask=disc_mask,
cup_mask=cup_mask,
)
)
prefix = f"[{progress_prefix}]" if progress_prefix else "[classifier]"
if skipped:
print(
f"{prefix} WARNING: {len(skipped)}/{len(labeled)} samples skipped "
f"due to empty segmentation mask: {skipped}"
)
else:
print(f"{prefix} All {len(labeled)} samples processed successfully.")
return records
def clear_disk_cache(self) -> None:
"""Delete all cached geometry/mask .npz files in cache_dir."""
if self.cache_dir is None or not self.cache_dir.exists():
return
removed = 0
for f in self.cache_dir.glob("*.npz"):
f.unlink()
removed += 1
self.geometry_cache.clear()
print(f"[classifier] Cleared {removed} cached geometry files from {self.cache_dir}")
def _cache_key(self, sample_id: str, scale: float) -> str:
scale_tag = int(round(scale * 100))
return f"{sample_id}_s{scale_tag}"
def _cache_path(self, sample_id: str, scale: float) -> Optional[Path]:
if self.cache_dir is None:
return None
return self.cache_dir / f"{self._cache_key(sample_id, scale)}.npz"
# ------------------------------------------------------------------
def train(
self,
epochs: int = 30,
lr: float = 1e-4,
weight_decay: float = 1e-4,
device: Optional[str] = None,
rotation_weight: float = 0.5,
checkpoint_dir: Optional[Path] = None,
) -> Dict[str, float]:
if self.train_loader is None or self.val_loader is None:
self.build_datasets()
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.backbone.to(device)
self.classifier_head.to(device)
self.rotation_head.to(device)
params = list(self.backbone.parameters()) + list(self.classifier_head.parameters()) + list(self.rotation_head.parameters())
optimizer = torch.optim.Adam(params, lr=lr, weight_decay=weight_decay)
clf_loss = nn.CrossEntropyLoss()
rot_loss = nn.CrossEntropyLoss()
best_auc = 0.0
history: Dict[str, float] = {}
epoch_iter = tqdm(range(1, epochs + 1), desc="Epochs", unit="epoch")
print(
f"[classifier] Starting training for {epochs} epochs with batch size {self.train_loader.batch_size}"
)
for epoch in epoch_iter:
self.backbone.train()
self.classifier_head.train()
self.rotation_head.train()
running_loss = 0.0
batch_iter = tqdm(
self.train_loader, # type: ignore[arg-type]
desc=f"Train {epoch}/{epochs}",
leave=False,
unit="batch",
)
for batch in batch_iter:
images = batch["image"].to(device)
polars = batch["polar"].to(device)
extra_feats = batch["features"].to(device)
labels = batch["label"].to(device)
optimizer.zero_grad()
feats_img = self.backbone(images)
feats = feats_img
if self.use_polar:
feats_polar = self.backbone(polars)
feats = torch.cat([feats, feats_polar], dim=1)
if self.extra_feature_dim > 0:
feats = torch.cat([feats, extra_feats], dim=1)
if self.use_margin:
logits = self.classifier_head(feats, labels)
else:
logits = self.classifier_head(feats)
loss_cls = clf_loss(logits, labels)
rot_imgs, rot_labels = self._build_rotation_batch(images)
feats_rot = self.backbone(rot_imgs)
logits_rot = self.rotation_head(feats_rot)
loss_rot = rot_loss(logits_rot, rot_labels)
loss = loss_cls + rotation_weight * loss_rot
loss.backward()
optimizer.step()
running_loss += loss.item() * images.size(0)
train_loss = running_loss / len(self.train_loader.dataset) # type: ignore[arg-type]
metrics = self.evaluate(device=device)
history[f"epoch_{epoch}_loss"] = train_loss
history[f"epoch_{epoch}_auc"] = metrics.get("auc", float("nan"))
auc_val = metrics.get("auc", 0.0)
epoch_iter.set_postfix(loss=f"{train_loss:.4f}", auc=f"{auc_val:.4f}")
if auc_val > best_auc:
best_auc = metrics["auc"]
if checkpoint_dir is not None:
checkpoint_dir.mkdir(parents=True, exist_ok=True)
torch.save({
"backbone": self.backbone.state_dict(),
"classifier": self.classifier_head.state_dict(),
"rotation": self.rotation_head.state_dict(),
}, checkpoint_dir / "refuge_classifier_best.pt")
return {"best_auc": best_auc, **history}
# ------------------------------------------------------------------
def evaluate(
self,
split: str = "val",
apply_ttt: bool = False,
device: Optional[str] = None,
) -> Dict[str, float]:
if split != "val":
raise ValueError("Only validation split supported currently")
if self.val_loader is None:
self.build_datasets()
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.backbone.to(device)
self.classifier_head.to(device)
self.rotation_head.to(device)
if apply_ttt:
ttt_ds = RefugeTTTDataset(self.val_records, transform=self.ttt_transform, size=self.crop_size)
ttt_loader = DataLoader(ttt_ds, batch_size=32, shuffle=False)
self.apply_ttt(ttt_loader, device=device)
self.backbone.eval()
self.classifier_head.eval()
preds: List[float] = []
targets: List[int] = []
with torch.no_grad():
val_iter = tqdm(self.val_loader, desc="Validate", leave=False, unit="batch")
for batch in val_iter: # type: ignore[arg-type]
images = batch["image"].to(device)
labels = batch["label"].to(device)
polars = batch["polar"].to(device)
extra_feats = batch["features"].to(device)
feats_img = self.backbone(images)
feats = feats_img
if self.use_polar:
feats_polar = self.backbone(polars)
feats = torch.cat([feats, feats_polar], dim=1)
if self.extra_feature_dim > 0:
feats = torch.cat([feats, extra_feats], dim=1)
if self.use_margin:
logits = self.classifier_head(feats)
else:
logits = self.classifier_head(feats)
probs = torch.softmax(logits, dim=1)[:, 1]
preds.extend(probs.cpu().numpy().tolist())
targets.extend(labels.cpu().numpy().tolist())
auc = 0.0
try:
if len(set(targets)) > 1:
auc = float(roc_auc_score(targets, preds))
except ValueError:
auc = 0.0
return {"auc": auc}
# ------------------------------------------------------------------
def apply_ttt(self, loader: DataLoader, device: Optional[str] = None, steps: int = 1, lr: float = 1e-5) -> None:
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.backbone.to(device)
self.rotation_head.to(device)
self.backbone.train()
self.rotation_head.train()
optimizer = torch.optim.Adam(list(self.backbone.parameters()) + list(self.rotation_head.parameters()), lr=lr)
criterion = nn.CrossEntropyLoss()
for _ in range(steps):
for batch in tqdm(loader, desc="TTT adapt", leave=False, unit="batch"):
if isinstance(batch, dict):
images = batch["image"].to(device)
else:
images = batch.to(device)
optimizer.zero_grad()
rot_imgs, rot_labels = self._build_rotation_batch(images)
feats = self.backbone(rot_imgs)
logits = self.rotation_head(feats)
loss = criterion(logits, rot_labels)
loss.backward()
optimizer.step()
# ------------------------------------------------------------------
def extract_backbone(self) -> nn.Module:
return self.backbone
def save_checkpoint(self, output_dir: Path) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
torch.save({
"backbone": self.backbone.state_dict(),
"classifier": self.classifier_head.state_dict(),
"rotation": self.rotation_head.state_dict(),
}, output_dir / "refuge_classifier.pt")
def load_checkpoint(self, checkpoint_path: Path) -> None:
payload = torch.load(checkpoint_path, map_location="cpu")
self.backbone.load_state_dict(payload["backbone"])
self.classifier_head.load_state_dict(payload["classifier"])
self.rotation_head.load_state_dict(payload["rotation"])
# ------------------------------------------------------------------
def _build_rotation_batch(self, images: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
rotations = [0, 90, 180, 270]
rotated = []
labels = []
for idx, angle in enumerate(rotations):
rot = TF.rotate(images, angle)
rotated.append(rot)
labels.append(torch.full((images.size(0),), idx, dtype=torch.long, device=images.device))
batch = torch.cat(rotated, dim=0)
batch_labels = torch.cat(labels, dim=0)
return batch, batch_labels
+306
View File
@@ -0,0 +1,306 @@
"""Utilities for preparing REFUGE (REFUGE1/REFUGE2) datasets.
Builds a unified manifest across all provided splits (REFUGE1 train/val/test
and REFUGE2 validation/test), exposing image paths, glaucoma labels, disc/cup
masks, and fovea coordinates so downstream segmentation/classification modules
can operate without additional bookkeeping.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
import pandas as pd
@dataclass
class RefugeSample:
"""Lightweight container describing a REFUGE sample."""
sample_id: str
dataset: str
split: str
image_path: Path
label: Optional[int]
device: Optional[str]
mask_path: Optional[Path]
fovea_coord: Optional[Tuple[float, float]]
class RefugePreprocessing:
"""Builds manifests and provides shared helpers for REFUGE workflows.
Responsibilities:
* scan the REFUGE directory structure and build a consistent manifest
(train/val/test, device vendor, ground-truth labels)
* expose convenience loaders for raw RGB frames, OD/OC masks, and
optional fovea landmarks
* compute geometric metadata (disc centres, diameters) so downstream
stages can crop ROIs lazily instead of storing pre-rendered tiles
"""
def __init__(self, root_dir: Path | str) -> None:
self.root_dir = Path(root_dir)
self._manifest = None # populated by build_manifest()
# ------------------------------------------------------------------
# Manifest handling
# ------------------------------------------------------------------
def build_manifest(self, refresh: bool = False) -> Iterable[RefugeSample]:
"""Return an iterable of :class:`RefugeSample` records.
Parameters
----------
refresh:
when True, force a rescan of the filesystem instead of reusing the
cached manifest.
Returns
-------
Iterable[RefugeSample]
A sequence containing one entry per sample in the REFUGE datasets.
Notes
-----
The actual manifest-building logic will live here: parsing the
directory structure, reading any provided CSV/Excel metadata, and
aligning masks/labels. For now, this method raises ``NotImplementedError``
so callers are reminded to hook it up before use.
"""
if self._manifest is not None and not refresh:
return self._manifest
manifest: List[RefugeSample] = []
manifest.extend(self._collect_refuge1_train())
manifest.extend(self._collect_refuge1_val())
manifest.extend(self._collect_refuge1_test())
manifest.extend(self._collect_refuge2_val())
manifest.extend(self._collect_refuge2_test())
self._manifest = manifest
return self._manifest
# ------------------------------------------------------------------
# Accessors for downstream modules
# ------------------------------------------------------------------
def load_image(self, sample: RefugeSample):
"""Return the RGB fundus image for ``sample``.
Implementors should handle color-space consistency (e.g., ensure RGB vs
BGR) and any global normalisation desired across devices.
"""
raise NotImplementedError("Image loading to be implemented")
def load_mask(self, sample: RefugeSample):
"""Return the optic disc/cup mask for ``sample`` if available."""
raise NotImplementedError("Mask loading to be implemented")
def disc_geometry(self, sample: RefugeSample) -> Dict[str, float]:
"""Compute disc centre and diameter from the mask.
The segmentation module will rely on this to crop 2.53× disc-diameter
ROIs at training time.
"""
raise NotImplementedError("Disc geometry helper to be implemented")
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _collect_refuge1_train(self) -> List[RefugeSample]:
base = self.root_dir / "Train" / "REFUGE1-train"
if not base.exists():
return []
fovea_path = base / "Fovea_location.xlsx"
fovea_map = self._read_fovea_table(fovea_path, img_col="ImgName")
samples: List[RefugeSample] = []
image_root = base / "Training400"
mask_root = base / "Disc_Cup_Masks"
for label_name, label_val in ("Glaucoma", 1), ("Non-Glaucoma", 0):
img_dir = image_root / label_name
mask_dir = mask_root / label_name
if not img_dir.exists():
continue
for image_path in sorted(img_dir.glob("*.jpg")):
img_name = image_path.name
mask_path = (mask_dir / image_path.with_suffix(".bmp").name)
fovea = fovea_map.get(img_name)
sample_id = f"refuge1_train_{image_path.stem}"
samples.append(
RefugeSample(
sample_id=sample_id,
dataset="refuge1",
split="train",
image_path=image_path,
label=label_val,
device=None,
mask_path=mask_path if mask_path.exists() else None,
fovea_coord=fovea,
)
)
return samples
def _collect_refuge1_val(self) -> List[RefugeSample]:
base = self.root_dir / "Train" / "REFUGE1-val"
if not base.exists():
return []
fovea_path = base / "Fovea_locations.xlsx"
df = pd.read_excel(fovea_path)
samples: List[RefugeSample] = []
image_root = base / "REFUGE-Validation400"
mask_root = base / "Disc_Cup_Masks"
for _, row in df.iterrows():
img_name = row["ImgName"]
image_path = image_root / img_name
mask_path = mask_root / Path(img_name).with_suffix(".bmp").name
fovea = self._extract_fovea(row, x_key="Fovea_X", y_key="Fovea_Y")
label = int(row.get("Glaucoma Label", 0)) if not pd.isna(row.get("Glaucoma Label", 0)) else None
sample_id = f"refuge1_val_{Path(img_name).stem}"
samples.append(
RefugeSample(
sample_id=sample_id,
dataset="refuge1",
split="val",
image_path=image_path,
label=label,
device=None,
mask_path=mask_path if mask_path.exists() else None,
fovea_coord=fovea,
)
)
return samples
def _collect_refuge1_test(self) -> List[RefugeSample]:
base = self.root_dir / "Train" / "REFUGE1-test"
if not base.exists():
return []
df = pd.read_excel(base / "Glaucoma_label_and_Fovea_location.xlsx")
image_root = base / "Test400"
mask_root = base / "Disc_Cup_Masks"
samples: List[RefugeSample] = []
for _, row in df.iterrows():
img_name = row["ImgName"]
image_path = image_root / img_name
mask_path = mask_root / Path(img_name).with_suffix(".bmp").name
fovea = self._extract_fovea(row, x_key="Fovea_X", y_key="Fovea_Y")
label = int(row.get("Label(Glaucoma=1)", 0)) if not pd.isna(row.get("Label(Glaucoma=1)", 0)) else None
sample_id = f"refuge1_test_{Path(img_name).stem}"
samples.append(
RefugeSample(
sample_id=sample_id,
dataset="refuge1",
split="test",
image_path=image_path,
label=label,
device=None,
mask_path=mask_path if mask_path.exists() else None,
fovea_coord=fovea,
)
)
return samples
def _collect_refuge2_val(self) -> List[RefugeSample]:
base = self.root_dir / "Validation"
if not base.exists():
return []
label_df = pd.read_csv(base / "glaucoma.csv")
fovea_df = pd.read_csv(base / "fovea.csv")
fovea_map = {
row["ImageName"]: (float(row["Fovea_X"]), float(row["Fovea_Y"]))
for _, row in fovea_df.iterrows()
}
samples: List[RefugeSample] = []
image_root = base / "Images"
mask_root = base / "Disc_Masks"
for _, row in label_df.iterrows():
img_name = row["FileName"]
image_path = image_root / img_name
mask_path = mask_root / Path(img_name).with_suffix(".png").name
label = row.get("Glaucoma Risk")
label = int(label) if label == label else None
sample_id = f"refuge2_val_{Path(img_name).stem}"
samples.append(
RefugeSample(
sample_id=sample_id,
dataset="refuge2",
split="val",
image_path=image_path,
label=label,
device=None,
mask_path=mask_path if mask_path.exists() else None,
fovea_coord=fovea_map.get(img_name),
)
)
return samples
def _collect_refuge2_test(self) -> List[RefugeSample]:
base = self.root_dir / "Test"
if not base.exists():
return []
label_df = pd.read_excel(base / "task1.xls", header=None, names=["ImgName", "Glaucoma"])
fovea_df = pd.read_excel(base / "fovea.xlsx")
fovea_map = {
row["ImageName"]: (float(row["Fovea_X"]), float(row["Fovea_Y"]))
for _, row in fovea_df.iterrows()
}
samples: List[RefugeSample] = []
image_root = base / "refuge2-test"
mask_root = base / "Disc_Mask"
for _, row in label_df.iterrows():
img_name = row["ImgName"]
image_path = image_root / img_name
mask_path = mask_root / Path(img_name).with_suffix(".png").name
label = row.get("Glaucoma")
label = int(label) if label == label else None
sample_id = f"refuge2_test_{Path(img_name).stem}"
samples.append(
RefugeSample(
sample_id=sample_id,
dataset="refuge2",
split="test",
image_path=image_path,
label=label,
device=None,
mask_path=mask_path if mask_path.exists() else None,
fovea_coord=fovea_map.get(img_name),
)
)
return samples
@staticmethod
def _read_fovea_table(path: Path, img_col: str) -> Dict[str, Tuple[float, float]]:
if not path.exists():
return {}
df = pd.read_excel(path)
mapping: Dict[str, Tuple[float, float]] = {}
for _, row in df.iterrows():
mapping[row[img_col]] = (
float(row.get("Fovea_X", float("nan"))),
float(row.get("Fovea_Y", float("nan"))),
)
return mapping
@staticmethod
def _extract_fovea(row: pd.Series, x_key: str, y_key: str) -> Optional[Tuple[float, float]]:
x_val = row.get(x_key)
y_val = row.get(y_key)
if pd.isna(x_val) or pd.isna(y_val):
return None
return float(x_val), float(y_val)
+383
View File
@@ -0,0 +1,383 @@
"""REFUGE optic disc / cup segmentation utilities."""
from __future__ import annotations
import math
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
import numpy as np
from PIL import Image
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset
from torchvision import transforms
from classes.refuge_preprocessing import RefugePreprocessing, RefugeSample
# ---------------------------------------------------------------------------
# Dataset helpers
# ---------------------------------------------------------------------------
def _load_rgb(path: Path) -> Image.Image:
img = Image.open(path)
if img.mode != "RGB":
img = img.convert("RGB")
return img
def _load_mask_array(path: Path) -> np.ndarray:
mask_img = Image.open(path).convert("L")
mask = np.array(mask_img, dtype=np.float32)
# REFUGE masks encode disc/cup with different intensities; treat any
# positive value as disc for coarse localisation.
mask = np.where(mask > 0, 1.0, 0.0)
return mask
@dataclass
class RefugeSegmentationSample:
sample: RefugeSample
image_path: Path
mask_path: Path
class RefugeSegmentationDataset(Dataset):
"""Simple segmentation dataset returning tensors."""
def __init__(
self,
samples: Sequence[RefugeSegmentationSample],
image_size: int = 512,
) -> None:
self.samples = list(samples)
self.image_size = image_size
self.to_tensor = transforms.ToTensor()
def __len__(self) -> int:
return len(self.samples)
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
rec = self.samples[idx]
image = _load_rgb(rec.image_path)
mask_arr = _load_mask_array(rec.mask_path)
if self.image_size is not None:
image = image.resize((self.image_size, self.image_size), Image.BILINEAR)
mask_img = Image.fromarray(mask_arr).resize(
(self.image_size, self.image_size), Image.NEAREST
)
mask_arr = np.array(mask_img, dtype=np.float32)
image_tensor = self.to_tensor(image)
mask_tensor = torch.from_numpy(mask_arr).unsqueeze(0) # [1,H,W]
return {
"image": image_tensor,
"mask": mask_tensor,
"sample_id": rec.sample.sample_id,
}
# ---------------------------------------------------------------------------
# Model definition (lightweight U-Net)
# ---------------------------------------------------------------------------
class DoubleConv(nn.Module):
def __init__(self, in_channels: int, out_channels: int):
super().__init__()
self.net = nn.Sequential(
nn.Conv2d(in_channels, out_channels, 3, padding=1, bias=False),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True),
nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False),
nn.BatchNorm2d(out_channels),
nn.ReLU(inplace=True),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.net(x)
class UNet(nn.Module):
def __init__(self, in_channels: int = 3, base_channels: int = 64):
super().__init__()
self.enc1 = DoubleConv(in_channels, base_channels)
self.enc2 = DoubleConv(base_channels, base_channels * 2)
self.enc3 = DoubleConv(base_channels * 2, base_channels * 4)
self.enc4 = DoubleConv(base_channels * 4, base_channels * 8)
self.pool = nn.MaxPool2d(2)
self.bottleneck = DoubleConv(base_channels * 8, base_channels * 16)
self.up4 = nn.ConvTranspose2d(base_channels * 16, base_channels * 8, 2, stride=2)
self.dec4 = DoubleConv(base_channels * 16, base_channels * 8)
self.up3 = nn.ConvTranspose2d(base_channels * 8, base_channels * 4, 2, stride=2)
self.dec3 = DoubleConv(base_channels * 8, base_channels * 4)
self.up2 = nn.ConvTranspose2d(base_channels * 4, base_channels * 2, 2, stride=2)
self.dec2 = DoubleConv(base_channels * 4, base_channels * 2)
self.up1 = nn.ConvTranspose2d(base_channels * 2, base_channels, 2, stride=2)
self.dec1 = DoubleConv(base_channels * 2, base_channels)
self.out = nn.Conv2d(base_channels, 1, 1)
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(d1)
# ---------------------------------------------------------------------------
# Segmentation manager
# ---------------------------------------------------------------------------
class RefugeSegmentation:
"""Train and run coarse-to-fine OD/OC segmentation for REFUGE."""
def __init__(
self,
preprocessing: RefugePreprocessing,
model: Optional[nn.Module] = None,
) -> None:
self.preprocessing = preprocessing
self.model = model or UNet()
self.train_dataset: Optional[RefugeSegmentationDataset] = None
self.val_dataset: Optional[RefugeSegmentationDataset] = None
self.train_loader: Optional[DataLoader] = None
self.val_loader: Optional[DataLoader] = None
# ------------------------------------------------------------------
def build_datasets(
self,
image_size: int = 512,
batch_size: int = 8,
num_workers: int = 4,
) -> None:
manifest = self.preprocessing.build_manifest()
train_samples: List[RefugeSegmentationSample] = []
val_samples: List[RefugeSegmentationSample] = []
for sample in manifest:
if not sample.mask_path or not sample.mask_path.exists():
continue
rec = RefugeSegmentationSample(sample=sample, image_path=sample.image_path, mask_path=sample.mask_path)
if sample.split == "train":
train_samples.append(rec)
elif sample.split in {"val", "validation"}:
val_samples.append(rec)
if not val_samples:
# Fall back to using a subset of training data for validation
split = max(1, int(0.1 * len(train_samples)))
val_samples = train_samples[:split]
train_samples = train_samples[split:]
self.train_dataset = RefugeSegmentationDataset(train_samples, image_size=image_size)
self.val_dataset = RefugeSegmentationDataset(val_samples, image_size=image_size)
self.train_loader = DataLoader(
self.train_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=num_workers,
pin_memory=True,
)
self.val_loader = DataLoader(
self.val_dataset,
batch_size=batch_size,
shuffle=False,
num_workers=num_workers,
pin_memory=True,
)
# ------------------------------------------------------------------
def train(
self,
epochs: int = 40,
lr: float = 1e-3,
weight_decay: float = 1e-5,
device: Optional[str] = None,
checkpoint_dir: Optional[Path] = None,
) -> Dict[str, float]:
if self.train_loader is None or self.val_loader is None:
self.build_datasets()
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.model.to(device)
criterion = nn.BCEWithLogitsLoss()
optimizer = torch.optim.Adam(self.model.parameters(), lr=lr, weight_decay=weight_decay)
best_dice = 0.0
history: Dict[str, float] = {}
for epoch in range(1, epochs + 1):
print(f"[Seg] Processing epoch {epoch}/{epochs}")
self.model.train()
running_loss = 0.0
for batch in self.train_loader: # type: ignore[arg-type]
images = batch["image"].to(device)
masks = batch["mask"].to(device)
optimizer.zero_grad()
logits = self.model(images)
loss = criterion(logits, masks)
loss.backward()
optimizer.step()
running_loss += loss.item() * images.size(0)
train_loss = running_loss / len(self.train_loader.dataset) # type: ignore[arg-type]
val_metrics = self.evaluate(device=device)
history[f"epoch_{epoch}_loss"] = train_loss
history[f"epoch_{epoch}_dice"] = val_metrics.get("dice", float("nan"))
if val_metrics.get("dice", 0.0) > best_dice:
best_dice = val_metrics["dice"]
if checkpoint_dir is not None:
checkpoint_dir.mkdir(parents=True, exist_ok=True)
torch.save(self.model.state_dict(), checkpoint_dir / "refuge_segmentation_best.pt")
return {"best_dice": best_dice, **history}
# ------------------------------------------------------------------
def evaluate(self, split: str = "val", device: Optional[str] = None) -> Dict[str, float]:
if split != "val":
raise ValueError("Only validation split supported currently")
if self.val_loader is None:
self.build_datasets()
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.model.to(device)
self.model.eval()
dices: List[float] = []
criterion = nn.BCEWithLogitsLoss()
losses: List[float] = []
with torch.no_grad():
for batch in self.val_loader: # type: ignore[arg-type]
images = batch["image"].to(device)
masks = batch["mask"].to(device)
logits = self.model(images)
loss = criterion(logits, masks)
losses.append(loss.item() * images.size(0))
probs = torch.sigmoid(logits)
preds = (probs > 0.5).float()
dice = self._dice_coefficient(preds, masks)
dices.extend(dice)
mean_dice = float(np.mean(dices)) if dices else 0.0
mean_loss = float(np.sum(losses) / len(self.val_loader.dataset)) # type: ignore[arg-type]
return {"dice": mean_dice, "loss": mean_loss}
# ------------------------------------------------------------------
def predict_mask(self, sample: RefugeSample, device: Optional[str] = None) -> torch.Tensor:
if self.train_dataset is None:
self.build_datasets()
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.model.to(device)
self.model.eval()
image = _load_rgb(sample.image_path)
original_size = image.size # (width, height)
image_resized = image.resize((self.train_dataset.image_size, self.train_dataset.image_size), Image.BILINEAR) # type: ignore[union-attr]
tensor = transforms.ToTensor()(image_resized).unsqueeze(0).to(device)
with torch.no_grad():
logits = self.model(tensor)
mask_resized = torch.sigmoid(logits)[0, 0]
mask_np = mask_resized.cpu().numpy()
mask_np = (mask_np > 0.5).astype(np.float32)
mask_img = Image.fromarray(mask_np)
mask_img = mask_img.resize(original_size, Image.NEAREST)
return torch.from_numpy(np.array(mask_img, dtype=np.float32))
def infer_disc_geometry(
self,
sample: RefugeSample,
scale: float = 2.5,
) -> Dict[str, float]:
if sample.mask_path and sample.mask_path.exists():
mask = _load_mask_array(sample.mask_path)
else:
mask = self.predict_mask(sample).numpy()
coords = np.argwhere(mask > 0.5)
if coords.size == 0:
raise RuntimeError(f"Unable to locate disc for sample {sample.sample_id}")
ys, xs = coords[:, 0], coords[:, 1]
centre_x = float(xs.mean())
centre_y = float(ys.mean())
width = float(xs.max() - xs.min())
height = float(ys.max() - ys.min())
diameter = max(width, height)
radius = diameter / 2.0
crop_radius = radius * scale
return {
"centre_x": centre_x,
"centre_y": centre_y,
"radius": radius,
"crop_radius": crop_radius,
"crop_size": crop_radius * 2.0,
}
def batch_crops(
self,
samples: Iterable[RefugeSample],
scale: float = 2.5,
output_dir: Optional[Path] = None,
size: int = 256,
) -> Dict[str, Path]:
output_paths: Dict[str, Path] = {}
if output_dir is not None:
output_dir.mkdir(parents=True, exist_ok=True)
for sample in samples:
geom = self.infer_disc_geometry(sample, scale=scale)
image = _load_rgb(sample.image_path)
cx, cy = geom["centre_x"], geom["centre_y"]
r = geom["crop_radius"]
left = max(0.0, cx - r)
upper = max(0.0, cy - r)
right = min(image.width, cx + r)
lower = min(image.height, cy + r)
crop = image.crop((left, upper, right, lower)).resize((size, size), Image.BILINEAR)
if output_dir is not None:
out_path = output_dir / f"{sample.sample_id}_crop.png"
crop.save(out_path)
output_paths[sample.sample_id] = out_path
return output_paths
# ------------------------------------------------------------------
@staticmethod
def _dice_coefficient(preds: torch.Tensor, targets: torch.Tensor) -> List[float]:
eps = 1e-6
dices = []
preds = preds.view(preds.size(0), -1)
targets = targets.view(targets.size(0), -1)
for p, t in zip(preds, targets):
intersection = float((p * t).sum().item())
union = float(p.sum().item() + t.sum().item())
dice = (2.0 * intersection + eps) / (union + eps)
dices.append(dice)
return dices
+2
View File
@@ -206,6 +206,7 @@ def make_loader(
*, *,
image_transform, image_transform,
image_preprocessor=None, image_preprocessor=None,
image_cache=None,
batch_size: int, batch_size: int,
shuffle: bool, shuffle: bool,
num_workers: int, num_workers: int,
@@ -216,6 +217,7 @@ def make_loader(
slots, slots,
image_transform=image_transform, image_transform=image_transform,
image_preprocessor=image_preprocessor, image_preprocessor=image_preprocessor,
image_cache=image_cache,
) )
return DataLoader( return DataLoader(
ds, ds,
+61
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Callable, Optional from typing import Any, Callable, Optional
from pathlib import Path from pathlib import Path
@@ -45,12 +46,14 @@ class SlotDataset(Dataset):
image_transform: Optional[Callable[[Image.Image], torch.Tensor]] = None, image_transform: Optional[Callable[[Image.Image], torch.Tensor]] = None,
matrix_transform: Optional[Callable[[Any], torch.Tensor]] = None, matrix_transform: Optional[Callable[[Any], torch.Tensor]] = None,
image_preprocessor: Optional[Callable[..., Image.Image]] = None, image_preprocessor: Optional[Callable[..., Image.Image]] = None,
image_cache: Optional[dict[str, np.ndarray]] = None,
) -> None: ) -> None:
self.samples = samples self.samples = samples
self.slot_descriptors = slot_descriptors self.slot_descriptors = slot_descriptors
self.image_transform = image_transform or transforms.ToTensor() self.image_transform = image_transform or transforms.ToTensor()
self.matrix_transform = matrix_transform or self._default_matrix_transform self.matrix_transform = matrix_transform or self._default_matrix_transform
self.image_preprocessor = image_preprocessor self.image_preprocessor = image_preprocessor
self.image_cache = image_cache
def __len__(self) -> int: def __len__(self) -> int:
return len(self.samples) return len(self.samples)
@@ -74,14 +77,72 @@ class SlotDataset(Dataset):
raise ValueError("Missing required image slot") raise ValueError("Missing required image slot")
return None return None
path = Path(value) path = Path(value)
cache_key = str(value)
if self.image_cache is not None:
cached = self.image_cache.get(cache_key)
if cached is not None:
return self.image_transform(Image.fromarray(cached, mode="RGB"))
img = Image.open(path).convert("RGB") img = Image.open(path).convert("RGB")
if self.image_preprocessor is not None: if self.image_preprocessor is not None:
try: try:
img = self.image_preprocessor(img, path) img = self.image_preprocessor(img, path)
except TypeError: except TypeError:
img = self.image_preprocessor(img) img = self.image_preprocessor(img)
if self.image_cache is not None:
self.image_cache[cache_key] = np.asarray(img, dtype=np.uint8)
return self.image_transform(img) return self.image_transform(img)
def prebuild_image_cache(self, cache_workers: int = 0) -> None:
"""Pre-populate image_cache for all samples in this dataset."""
if self.image_cache is None:
return
paths = list({
str(record[key])
for record in self.samples
for key, desc in self.slot_descriptors.items()
if desc.kind == "image" and record.get(key) is not None
})
to_warm = [p for p in paths if p not in self.image_cache]
if not to_warm:
return
print(
f"[image_cache] warming {len(to_warm)} images "
f"({len(paths) - len(to_warm)} already cached)",
flush=True,
)
def _warm_one(path_str: str) -> None:
if path_str in self.image_cache:
return
p = Path(path_str)
img = Image.open(p).convert("RGB")
if self.image_preprocessor is not None:
try:
img = self.image_preprocessor(img, p)
except TypeError:
img = self.image_preprocessor(img)
self.image_cache[path_str] = np.asarray(img, dtype=np.uint8)
try:
from tqdm import tqdm
except ImportError:
tqdm = None
if cache_workers <= 1:
it = tqdm(to_warm, desc="Warm image cache", unit="img") if tqdm else to_warm
for path_str in it:
_warm_one(path_str)
else:
with ThreadPoolExecutor(max_workers=cache_workers) as ex:
futures = {ex.submit(_warm_one, p): p for p in to_warm}
it = tqdm(as_completed(futures), total=len(futures), desc="Warm image cache", unit="img") if tqdm else as_completed(futures)
for fut in it:
fut.result()
def _load_matrix(self, value: Any, *, required: bool) -> Optional[torch.Tensor]: def _load_matrix(self, value: Any, *, required: bool) -> Optional[torch.Tensor]:
if value is None: if value is None:
if required: if required:
+28 -5
View File
@@ -222,7 +222,13 @@ class V2HyperTower:
ap.add_argument("--augment", action="store_true") ap.add_argument("--augment", action="store_true")
ap.add_argument("--balanced-sampling", action="store_true", ap.add_argument("--balanced-sampling", action="store_true",
help="Use WeightedRandomSampler during training to equalise class frequency (default: off).") help="Use WeightedRandomSampler during training to equalise class frequency (default: off).")
ap.add_argument("--num-workers", type=int, default=0) ap.add_argument("--num-workers", type=int, default=4)
ap.add_argument("--in-memory-cache", action="store_true", default=True,
help="Cache preprocessed images in RAM (default: on).")
ap.add_argument("--no-in-memory-cache", action="store_false", dest="in_memory_cache",
help="Disable in-memory image cache.")
ap.add_argument("--cache-workers", type=int, default=4,
help="Threads for prebuilding in-memory image cache (default: 4).")
ap.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto") ap.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto")
ap.add_argument("--seed", type=int, default=1234) ap.add_argument("--seed", type=int, default=1234)
ap.add_argument("--run-name", default=None) ap.add_argument("--run-name", default=None)
@@ -452,6 +458,8 @@ class V2HyperTower:
n_classes=num_classes, n_classes=num_classes,
) )
image_cache: dict | None = {} if getattr(args, "in_memory_cache", False) else None
for fold in range(n_folds): for fold in range(n_folds):
seed_everything(args.seed + fold * 100) seed_everything(args.seed + fold * 100)
fold_dir = tm_dir / f"fold{fold}" fold_dir = tm_dir / f"fold{fold}"
@@ -469,6 +477,7 @@ class V2HyperTower:
fold_dir=fold_dir, fold_dir=fold_dir,
tower_mode=tower_mode, tower_mode=tower_mode,
pred_store=pred_store, pred_store=pred_store,
image_cache=image_cache,
) )
fold_results.append(result) fold_results.append(result)
if artifacts.y_true_ensemble is not None: if artifacts.y_true_ensemble is not None:
@@ -632,6 +641,7 @@ class V2HyperTower:
fold_dir: Path, fold_dir: Path,
tower_mode: str, tower_mode: str,
pred_store: "PredictionStore | None" = None, pred_store: "PredictionStore | None" = None,
image_cache: "dict | None" = None,
) -> tuple[FoldResult, FoldArtifacts]: ) -> tuple[FoldResult, FoldArtifacts]:
args = self.args args = self.args
device = self.device device = self.device
@@ -743,7 +753,8 @@ class V2HyperTower:
slots_eye = profile_eye.slot_descriptors() slots_eye = profile_eye.slot_descriptors()
slots_patient = profile_patient.slot_descriptors() slots_patient = profile_patient.slot_descriptors()
loader_kw = dict(batch_size=args.batch_size, num_workers=args.num_workers) loader_kw = dict(batch_size=args.batch_size, num_workers=args.num_workers,
image_cache=image_cache)
# ---- loaders --------------------------------------------------- # ---- loaders ---------------------------------------------------
use_balanced = bool(getattr(args, "balanced_sampling", False)) use_balanced = bool(getattr(args, "balanced_sampling", False))
@@ -831,6 +842,16 @@ class V2HyperTower:
if pred_store is not None: if pred_store is not None:
pred_store.set_split(fold, [str(s["id_1"]) for s in holdout_bilat], "holdout") pred_store.set_split(fold, [str(s["id_1"]) for s in holdout_bilat], "holdout")
# ---- prebuild in-memory image cache (fold 0 only; shared dict fills for later folds) ----
if image_cache is not None:
cache_workers = int(getattr(args, "cache_workers", 4))
_loaders_to_warm = [
train_single_loader, train_bilat_loader, val_loader, holdout_loader,
]
for _ldr in _loaders_to_warm:
if _ldr is not None:
_ldr.dataset.prebuild_image_cache(cache_workers=cache_workers)
opt_single = torch.optim.Adam(single.parameters(), lr=args.lr) if run_single else None opt_single = torch.optim.Adam(single.parameters(), lr=args.lr) if run_single else None
opt_bilateral = torch.optim.Adam(bilateral.parameters(), lr=args.lr) if run_bilat else None opt_bilateral = torch.optim.Adam(bilateral.parameters(), lr=args.lr) if run_bilat else None
@@ -942,6 +963,7 @@ class V2HyperTower:
# ---- epoch loop ------------------------------------------------ # ---- epoch loop ------------------------------------------------
_prev_phase_single = "inactive" # used to detect md_warmup → next phase transition _prev_phase_single = "inactive" # used to detect md_warmup → next phase transition
for epoch in range(total_epochs): for epoch in range(total_epochs):
_epoch_t0 = time.time()
if not run_single: if not run_single:
phase_single, main_epoch_single, single_active = "inactive", 0, False phase_single, main_epoch_single, single_active = "inactive", 0, False
elif epoch < single_warmup_md: elif epoch < single_warmup_md:
@@ -1329,6 +1351,7 @@ class V2HyperTower:
print() # seal the progress bar line print() # seal the progress bar line
if args.log_every > 0 and (epoch + 1) % args.log_every == 0: if args.log_every > 0 and (epoch + 1) % args.log_every == 0:
_epoch_secs = time.time() - _epoch_t0
hld_auc = target_holdout_single_auc if run_single else bi_auc_h hld_auc = target_holdout_single_auc if run_single else bi_auc_h
hld_suffix = f" hld_auc={hld_auc:.4f}" if holdout_loader is not None else "" hld_suffix = f" hld_auc={hld_auc:.4f}" if holdout_loader is not None else ""
@@ -1356,7 +1379,7 @@ class V2HyperTower:
if run_single: if run_single:
if tower_mode == "single": if tower_mode == "single":
msg = ( msg = (
f" ep {epoch+1:>3}/{total_epochs} " f" ep {epoch+1:>3}/{total_epochs} ({_epoch_secs:.1f}s) "
f"[single:{phase_single} {single_phase_epoch}/{single_phase_total}] " f"[single:{phase_single} {single_phase_epoch}/{single_phase_total}] "
f"fused(acc={cl_acc:.4f},auc={cl_auc:.4f}) " f"fused(acc={cl_acc:.4f},auc={cl_auc:.4f}) "
f"img(acc={cl_acc_img:.4f},auc={cl_auc_img:.4f}) " f"img(acc={cl_acc_img:.4f},auc={cl_auc_img:.4f}) "
@@ -1366,7 +1389,7 @@ class V2HyperTower:
) )
else: else:
msg = ( msg = (
f" ep {epoch+1:>3}/{total_epochs} " f" ep {epoch+1:>3}/{total_epochs} ({_epoch_secs:.1f}s) "
f"[single:{phase_single} {single_phase_epoch}/{single_phase_total}] " f"[single:{phase_single} {single_phase_epoch}/{single_phase_total}] "
f"fused(acc={en_acc:.4f},auc={en_auc:.4f}) " f"fused(acc={en_acc:.4f},auc={en_auc:.4f}) "
f"img(acc={en_acc_img:.4f},auc={en_auc_img:.4f}) " f"img(acc={en_acc_img:.4f},auc={en_auc_img:.4f}) "
@@ -1376,7 +1399,7 @@ class V2HyperTower:
) )
else: else:
msg = ( msg = (
f" ep {epoch+1:>3}/{total_epochs} " f" ep {epoch+1:>3}/{total_epochs} ({_epoch_secs:.1f}s) "
f"[bilat:{phase_bilat} {bilat_phase_epoch}/{bilat_phase_total}] " f"[bilat:{phase_bilat} {bilat_phase_epoch}/{bilat_phase_total}] "
f"fused(acc={bi_acc:.4f},auc={bi_auc:.4f}) " f"fused(acc={bi_acc:.4f},auc={bi_auc:.4f}) "
f"img(acc={bi_acc_img:.4f},auc={bi_auc_img:.4f}) " f"img(acc={bi_acc_img:.4f},auc={bi_auc_img:.4f}) "
+216
View File
@@ -0,0 +1,216 @@
name: fundus_imaging
channels:
- conda-forge
- defaults
dependencies:
- _libgcc_mutex=0.1=conda_forge
- _openmp_mutex=4.5=2_gnu
- alsa-lib=1.2.11=hd590300_1
- asttokens=2.4.1=pyhd8ed1ab_0
- attr=2.5.1=h166bdaf_1
- blas=1.0=openblas
- bottleneck=1.3.7=py312ha883a20_0
- brotli=1.0.9=h5eee18b_8
- brotli-bin=1.0.9=h5eee18b_8
- bzip2=1.0.8=hd590300_5
- ca-certificates=2025.9.9=h06a4308_0
- cairo=1.18.0=h3faef2a_0
- comm=0.2.2=pyhd8ed1ab_0
- contourpy=1.2.0=py312hdb19cb5_0
- cycler=0.11.0=pyhd3eb1b0_0
- dbus=1.13.18=hb2f20db_0
- debugpy=1.8.1=py312h30efb56_0
- decorator=5.1.1=pyhd8ed1ab_0
- exceptiongroup=1.2.0=pyhd8ed1ab_2
- executing=2.0.1=pyhd8ed1ab_0
- expat=2.6.2=h6a678d5_0
- font-ttf-dejavu-sans-mono=2.37=hd3eb1b0_0
- font-ttf-inconsolata=2.001=hcb22688_0
- font-ttf-source-code-pro=2.030=hd3eb1b0_0
- font-ttf-ubuntu=0.83=h8b1ccd4_0
- fontconfig=2.14.2=h14ed4e7_0
- fonts-anaconda=1=h8fa9717_0
- fonts-conda-ecosystem=1=hd3eb1b0_0
- fonttools=4.51.0=py312h5eee18b_0
- freetype=2.12.1=h4a9f257_0
- gettext=0.22.5=h59595ed_2
- gettext-tools=0.22.5=h59595ed_2
- glib=2.80.2=hf974151_0
- glib-tools=2.80.2=hb6ce0ca_0
- graphite2=1.3.14=h295c915_1
- gst-plugins-base=1.14.1=h6a678d5_1
- gstreamer=1.14.1=h5eee18b_1
- harfbuzz=8.5.0=hfac3d4d_0
- icu=73.2=h59595ed_0
- imageio=2.37.0=py312h06a4308_0
- importlib-metadata=7.1.0=pyha770c72_0
- importlib_metadata=7.1.0=hd8ed1ab_0
- ipykernel=6.29.3=pyhd33586a_0
- ipython=8.24.0=pyh707e725_0
- ipywidgets=8.1.2=pyhd8ed1ab_1
- jedi=0.19.1=pyhd8ed1ab_0
- joblib=1.4.0=py312h06a4308_0
- jpeg=9e=h5eee18b_1
- jupyter_client=8.6.1=pyhd8ed1ab_0
- jupyter_core=5.7.2=py312h7900ff3_0
- jupyterlab_widgets=3.0.10=py312h06a4308_0
- keyutils=1.6.1=h166bdaf_0
- kiwisolver=1.4.4=py312h6a678d5_0
- krb5=1.20.1=h81ceb04_0
- lame=3.100=h7b6447c_0
- lazy_loader=0.4=py312h06a4308_0
- lcms2=2.12=h3be6417_0
- ld_impl_linux-64=2.40=h55db66e_0
- lerc=3.0=h295c915_0
- libasprintf=0.22.5=h661eb56_2
- libasprintf-devel=0.22.5=h661eb56_2
- libbrotlicommon=1.0.9=h5eee18b_8
- libbrotlidec=1.0.9=h5eee18b_8
- libbrotlienc=1.0.9=h5eee18b_8
- libcap=2.69=h0f662aa_0
- libclang=14.0.6=default_hc6dbbc7_1
- libclang-cpp15=15.0.7=default_h127d8a8_5
- libclang13=14.0.6=default_he11475f_1
- libcups=2.4.2=h2d74bed_1
- libdeflate=1.17=h5eee18b_1
- libedit=3.1.20191231=he28a2e2_2
- libevent=2.1.12=hdbd6064_1
- libexpat=2.6.2=h59595ed_0
- libffi=3.4.2=h7f98852_5
- libflac=1.4.3=h59595ed_0
- libgcc-ng=13.2.0=h77fa898_7
- libgcrypt=1.10.3=hd590300_0
- libgettextpo=0.22.5=h59595ed_2
- libgettextpo-devel=0.22.5=h59595ed_2
- libgfortran=3.0.0=1
- libgfortran-ng=11.2.0=h00389a5_1
- libgfortran5=11.2.0=h1234567_1
- libglib=2.80.2=hf974151_0
- libgomp=13.2.0=h77fa898_7
- libgpg-error=1.49=h4f305b6_0
- libiconv=1.17=hd590300_2
- libjpeg-turbo=2.1.4=h166bdaf_0
- libllvm14=14.0.6=hdb19cb5_3
- libllvm15=15.0.7=hb3ce162_4
- libllvm18=18.1.5=hb77312f_0
- libnsl=2.0.1=hd590300_0
- libogg=1.3.5=h27cfd23_1
- libopenblas=0.3.21=h043d6bf_0
- libopus=1.3.1=h7b6447c_0
- libpng=1.6.43=h2797004_0
- libpq=12.17=hdbd6064_0
- libsndfile=1.2.2=hc60ed4a_1
- libsodium=1.0.18=h36c2ea0_1
- libsqlite=3.45.3=h2797004_0
- libstdcxx-ng=13.2.0=hc0a3c3a_7
- libsystemd0=255=h3516f8a_1
- libtiff=4.5.1=h6a678d5_0
- libuuid=2.38.1=h0b41bf4_0
- libvorbis=1.3.7=h7b6447c_0
- libwebp-base=1.3.2=h5eee18b_0
- libxcb=1.15=h7f8727e_0
- libxcrypt=4.4.36=hd590300_1
- libxkbcommon=1.7.0=h662e7e4_0
- libxml2=2.12.7=hc051c1a_0
- libzlib=1.2.13=hd590300_5
- lz4-c=1.9.4=h6a678d5_1
- matplotlib=3.8.4=py312h06a4308_0
- matplotlib-base=3.8.4=py312h526ad5a_0
- matplotlib-inline=0.1.7=pyhd8ed1ab_0
- mpg123=1.32.6=h59595ed_0
- mysql=5.7.20=hf484d3e_1001
- mysql-common=8.3.0=hf1915f5_4
- mysql-libs=8.3.0=hca2cd23_4
- ncurses=6.5=h59595ed_0
- nest-asyncio=1.6.0=pyhd8ed1ab_0
- networkx=3.4.2=py312h06a4308_0
- nspr=4.35=h6a678d5_0
- nss=3.100=hca3bf56_0
- numexpr=2.8.7=py312he7dcb8a_0
- numpy=1.26.4=py312h2809609_0
- numpy-base=1.26.4=py312he1a6c75_0
- openblas=0.3.4=ha44fe06_0
- openjpeg=2.4.0=h3ad879b_0
- openssl=3.3.0=hd590300_0
- packaging=24.0=pyhd8ed1ab_0
- pandas=2.2.1=py312h526ad5a_0
- parso=0.8.4=pyhd8ed1ab_0
- pcre2=10.43=hcad00b1_0
- pexpect=4.9.0=pyhd8ed1ab_0
- pickleshare=0.7.5=py_1003
- pillow=10.3.0=py312h5eee18b_0
- pixman=0.43.2=h59595ed_0
- platformdirs=4.2.1=pyhd8ed1ab_0
- ply=3.11=py312h06a4308_1
- prompt-toolkit=3.0.42=pyha770c72_0
- psutil=5.9.8=py312h98912ed_0
- ptyprocess=0.7.0=pyhd3deb0d_0
- pulseaudio-client=17.0=hb77b528_0
- pure_eval=0.2.2=pyhd8ed1ab_0
- pybind11-abi=5=hd3eb1b0_0
- pygments=2.18.0=pyhd8ed1ab_0
- pyparsing=3.0.9=py312h06a4308_0
- pyqt=5.15.10=py312h6a678d5_0
- pyqt5-sip=12.13.0=py312h5eee18b_0
- python=3.12.3=hab00c5b_0_cpython
- python-dateutil=2.9.0=pyhd8ed1ab_0
- python-tzdata=2023.3=pyhd3eb1b0_0
- python_abi=3.12=4_cp312
- pytz=2024.1=py312h06a4308_0
- pyzmq=26.0.3=py312h8fd38d8_0
- qt-main=5.15.2=h53bd1ea_10
- readline=8.2=h8228510_1
- scikit-image=0.25.2=py312hc74f9fe_0
- scikit-learn=1.4.2=py312h526ad5a_1
- scipy=1.13.0=py312h2809609_0
- setuptools=69.5.1=pyhd8ed1ab_0
- sip=6.7.12=py312h6a678d5_0
- six=1.16.0=pyh6c4a22f_0
- sqlite=3.45.3=h5eee18b_0
- stack_data=0.6.2=pyhd8ed1ab_0
- threadpoolctl=2.2.0=pyh0d69192_0
- tifffile=2024.12.12=py312h06a4308_0
- tk=8.6.13=noxft_h4845f30_101
- tornado=6.4=py312h98912ed_0
- traitlets=5.14.3=pyhd8ed1ab_0
- typing_extensions=4.11.0=pyha770c72_0
- tzdata=2024a=h0c530f3_0
- unicodedata2=15.1.0=py312h5eee18b_0
- wcwidth=0.2.13=pyhd8ed1ab_0
- widgetsnbextension=4.0.10=py312h06a4308_0
- xcb-util=0.4.0=hd590300_1
- xcb-util-image=0.4.0=h8ee46fc_1
- xcb-util-keysyms=0.4.0=h8ee46fc_1
- xcb-util-renderutil=0.3.9=hd590300_1
- xcb-util-wm=0.4.1=h8ee46fc_1
- xkeyboard-config=2.41=hd590300_0
- xlrd=2.0.1=pyhd3eb1b0_1
- xorg-kbproto=1.0.7=h7f98852_1002
- xorg-libice=1.1.1=hd590300_0
- xorg-libsm=1.2.4=h7391055_0
- xorg-libx11=1.8.9=h8ee46fc_0
- xorg-libxau=1.0.11=hd590300_0
- xorg-libxext=1.3.4=h0b41bf4_2
- xorg-libxrender=0.9.11=hd590300_0
- xorg-renderproto=0.11.1=h7f98852_1002
- xorg-xextproto=7.3.0=h0b41bf4_1003
- xorg-xf86vidmodeproto=2.3.1=h7f98852_1002
- xorg-xproto=7.0.31=h27cfd23_1007
- xz=5.4.6=h5eee18b_1
- zeromq=4.3.5=h6a678d5_0
- zipp=3.17.0=pyhd8ed1ab_0
- zlib=1.2.13=hd590300_5
- zstd=1.5.6=ha6fb4c9_0
- pip:
- et-xmlfile==2.0.0
- opencv-python==4.9.0.80
- openpyxl==3.1.5
- pip==25.2
- pytorch-triton-rocm==3.2.0+rocm6.4.1.git6da9e660
- sympy==1.13.1
- torch==2.6.0+rocm6.4.1.git1ded221d
- torchaudio==2.6.0+rocm6.4.1.gitd8831425
- torchvision==0.21.0+rocm6.4.1.git4040d51f
- tqdm==4.66.4
- wheel==0.45.1
prefix: /home/rpotter/miniconda3/envs/fundus_imaging
-326
View File
@@ -1,326 +0,0 @@
#!/usr/bin/env python3
"""Run classical ML models and plot *combined* ROC curves (multimodel overlays).
Keeps your original workflow for folds/tests exactly the same.
Only changes: collects predictions per test and makes:
One ROC plot per class (OvR), overlaying all models
One binary ROC plot (Healthy vs Glaucoma), overlaying all models
"""
import os
import re
from pathlib import Path
from typing import Iterable, List, Tuple, Dict, Optional
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler, label_binarize
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC
from sklearn.metrics import roc_curve, auc
from classes import build_papila_clinical
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
SPLIT_ROOT = Path("HelpCode/kfold")
TRUST_INDEX_COL = False
# ---------------------------------------------------------------------------
# Feature matrix
# ---------------------------------------------------------------------------
def build_feature_matrix(clinical):
df = clinical.df.copy()
scalars = ["Age", "dioptre_1", "dioptre_2", "astigmatism", "Pachymetry", "Axial_Length", "IOP_corr"]
cats = ["Gender", "Phakic/Pseudophakic"]
X = pd.concat([df[scalars], pd.get_dummies(df[cats].astype("category"), drop_first=False, prefix=cats)], axis=1)
y = df[clinical.label_col].astype(int).values
return X, y, scalars, df # X keeps NaNs; we impute per-fold
# ---------------------------------------------------------------------------
# Models with tuned hyper-parameters (unchanged)
# ---------------------------------------------------------------------------
def make_models() -> Dict[str, Pipeline]:
return {
"LogReg": Pipeline([
("scaler", StandardScaler()),
("clf", LogisticRegression(
C=1,
class_weight="balanced",
max_iter=200,
solver="lbfgs",
multi_class="auto")),
]),
"kNN": Pipeline([
("scaler", StandardScaler()),
("clf", KNeighborsClassifier(
n_neighbors=11, weights="distance")),
]),
"RF": Pipeline([
("clf", RandomForestClassifier(n_estimators=200, max_depth=8,
min_samples_split=4, random_state=42)),
]),
"SVM": Pipeline([
("scaler", StandardScaler()),
("clf", SVC(C=10, kernel="rbf", gamma=0.1, probability=True)),
]),
}
# ---------------------------------------------------------------------------
# Split helpers copied from paper_clinical_baselines_official.py (unchanged)
# ---------------------------------------------------------------------------
_FNAME_RE = re.compile(r"RET\s*(\d+)\s*([Oo][DSs])\.jpg$", re.IGNORECASE)
def _read_sheet_any(p: Path) -> pd.DataFrame:
if p.suffix.lower() == ".xlsx":
return pd.read_excel(p)
if p.suffix.lower() == ".csv":
return pd.read_csv(p)
if p.suffix.lower() == ".txt":
lines = [ln.strip() for ln in p.read_text(encoding="utf-8", errors="ignore").splitlines() if ln.strip()]
return pd.DataFrame({"filename": lines})
raise ValueError(f"Unsupported split file type: {p.suffix}")
def _normcols(cols: List[str]) -> Dict[str, str]:
def norm(s: str) -> str:
return re.sub(r"[^a-z0-9]", "", s.lower())
return {norm(c): c for c in cols}
def _parse_fname_to_pid_eye(fname: str) -> Optional[Tuple[int, str]]:
base = os.path.basename(str(fname))
m = _FNAME_RE.search(base.replace(" ", ""))
if not m:
return None
return int(m.group(1)), m.group(2).upper()
def _rows_from_sheet(sheet: pd.DataFrame, df_master: pd.DataFrame) -> List[int]:
cols = _normcols(list(sheet.columns))
if "filename" in cols:
fn_col = cols["filename"]
lookup: Dict[str, List[int]] = {}
for i, (pid, eye) in enumerate(zip(df_master["Patient ID"].astype(int), df_master["eyeID"].astype(str))):
lookup.setdefault(f"{pid}|{eye.upper()}", []).append(i)
rows: List[int] = []
for fn in sheet[fn_col].astype(str).tolist():
pe = _parse_fname_to_pid_eye(fn)
if pe is None:
continue
pid, eye = pe
rows.extend(lookup.get(f"{pid}|{eye}", []))
return rows
if "patientid" in cols and "eyeid" in cols:
pid_col, eye_col = cols["patientid"], cols["eyeid"]
lookup = {}
for i, (pid, eye) in enumerate(zip(df_master["Patient ID"].astype(int), df_master["eyeID"].astype(str))):
lookup.setdefault(f"{pid}|{eye.upper()}", []).append(i)
rows = []
for pid, eye in zip(sheet[pid_col], sheet[eye_col]):
rows.extend(lookup.get(f"{int(pid)}|{str(eye).upper()}", []))
return rows
if TRUST_INDEX_COL and "index" in cols:
idx = sheet[cols["index"]].astype(int).tolist()
n = len(df_master)
return [i for i in idx if 0 <= i < n]
raise RuntimeError("Split sheet missing usable columns")
def _pair_train_test_files(dir_train: Path, dir_test: Path) -> List[Tuple[Path, Path]]:
def fold_key(p: Path) -> str:
m = re.search(r"(\d+)", p.stem)
return m.group(1) if m else p.stem.lower()
trains = sorted([p for p in dir_train.iterdir() if p.is_file() and p.suffix.lower() in (".xlsx", ".csv", ".txt")], key=fold_key)
tests = sorted([p for p in dir_test.iterdir() if p.is_file() and p.suffix.lower() in (".xlsx", ".csv", ".txt")], key=fold_key)
return [(trains[i], tests[i]) for i in range(min(len(trains), len(tests)))]
def iter_official_folds_xlsx(clinical, split_root: Path, test_name: str) -> Iterable[Tuple[pd.DataFrame, pd.DataFrame]]:
df_master = clinical.df.copy()
test_dir = split_root / test_name
dir_train = test_dir / "Train"
dir_test = test_dir / "Test"
if not dir_train.exists() or not dir_test.exists():
raise FileNotFoundError(f"Expected: {dir_train} and {dir_test}")
for train_file, test_file in _pair_train_test_files(dir_train, dir_test):
sh_tr, sh_te = _read_sheet_any(train_file), _read_sheet_any(test_file)
tr_rows, te_rows = _rows_from_sheet(sh_tr, df_master), _rows_from_sheet(sh_te, df_master)
tr_df, te_df = df_master.iloc[tr_rows].copy(), df_master.iloc[te_rows].copy()
yield tr_df, te_df
# ---------------------------------------------------------------------------
# Utilities (unchanged)
# ---------------------------------------------------------------------------
def _prepare_fold_X(X: pd.DataFrame, scalars: List[str], tr_idx: np.ndarray, te_idx: np.ndarray):
Xtr, Xte = X.iloc[tr_idx].copy(), X.iloc[te_idx].copy()
med = Xtr[scalars].median(numeric_only=True)
Xtr[scalars] = Xtr[scalars].fillna(med)
Xte[scalars] = Xte[scalars].fillna(med)
return Xtr.values.astype(np.float32), Xte.values.astype(np.float32)
# ---------------------------------------------------------------------------
# NEW: combined plotting helpers (multimodel overlays)
# ---------------------------------------------------------------------------
def _plot_multiclass_overlay(y_true: np.ndarray, prob_dict: Dict[str, np.ndarray], out_dir: Path, test_tag: str):
"""One figure per class (OvR), overlaying all models."""
n_classes = next(iter(prob_dict.values())).shape[1]
class_names = [f"Class{k}" for k in range(n_classes)]
y_bin = label_binarize(y_true, classes=list(range(n_classes)))
for k in range(n_classes):
fig, ax = plt.subplots(figsize=(6, 5))
for model_name, proba in prob_dict.items():
fpr, tpr, _ = roc_curve(y_bin[:, k], proba[:, k])
auc_val = auc(fpr, tpr)
ax.plot(fpr, tpr, lw=1.8, label=f"{model_name} (AUC={auc_val:.3f})")
ax.plot([0, 1], [0, 1], "k--", lw=1)
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title(f"{class_names[k]} vs Rest — {test_tag}")
ax.legend(loc="lower right")
ax.grid(True, alpha=0.3, linestyle="--")
fig.tight_layout()
fig.savefig(out_dir / f"{test_tag}_{class_names[k]}.png", dpi=170)
plt.close(fig)
def _plot_binary_overlay(y_true: np.ndarray, prob1d_dict: Dict[str, np.ndarray], out_dir: Path, test_tag: str):
"""One figure (Healthy vs Glaucoma), overlaying all models. Assumes y_true ∈ {0,1}."""
fig, ax = plt.subplots(figsize=(6, 5))
any_curve = False
for model_name, scores in prob1d_dict.items():
if scores.size == 0:
continue
fpr, tpr, _ = roc_curve(y_true, scores, pos_label=1)
auc_val = auc(fpr, tpr)
ax.plot(fpr, tpr, lw=1.8, label=f"{model_name} (AUC={auc_val:.3f})")
any_curve = True
ax.plot([0, 1], [0, 1], "k--", lw=1)
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title(f"Binary Healthy vs Glaucoma — {test_tag}")
if any_curve:
ax.legend(loc="lower right")
ax.grid(True, alpha=0.3, linestyle="--")
fig.tight_layout()
fig.savefig(out_dir / f"{test_tag}_binary.png", dpi=170)
plt.close(fig)
# ---------------------------------------------------------------------------
# Main (same folds/tests flow; only result collation & plotting changed)
# ---------------------------------------------------------------------------
def main():
clinical = build_papila_clinical(
image_dir="Papila/FundusImages",
clinical_dir="Papila/ClinicalData",
label_col="Diagnosis",
cat_cols=["Gender", "Phakic/Pseudophakic"],
)
X, y, scalars, _ = build_feature_matrix(clinical)
models = make_models()
out_dir = Path("analysis_data/roc_baselines")
out_dir.mkdir(parents=True, exist_ok=True)
tests = [
("Test 3", False), ("Test 4", True)
] if (SPLIT_ROOT / "Test 3").exists() else [
("Test 1", False), ("Test 2", True)
]
for test_name, is_binary in tests:
# Collect per-model probabilities following your original per-model loop.
# For multiclass: dict[model] -> (N, C)
# For binary: dict[model] -> (N,) (probability of class 1)
prob_dict_multi: Dict[str, np.ndarray] = {}
prob_dict_bin: Dict[str, np.ndarray] = {}
y_ref_multi: Optional[np.ndarray] = None
y_ref_bin: Optional[np.ndarray] = None
for model_name, model in models.items():
y_all: List[np.ndarray] = []
p_all: List[np.ndarray] = []
for fold_idx, (train_df, test_df) in enumerate(iter_official_folds_xlsx(clinical, SPLIT_ROOT, test_name), 1):
# Keep your exact masking/handling
dup_rows = set(train_df.index).intersection(set(test_df.index))
shared_pids = set(train_df["Patient ID"]).intersection(set(test_df["Patient ID"]))
if test_name in ("Test 1", "Test 2") and shared_pids:
train_df = train_df[~train_df["Patient ID"].isin(shared_pids)].copy()
dup_rows = set(train_df.index).intersection(set(test_df.index))
shared_pids = set(train_df["Patient ID"]).intersection(set(test_df["Patient ID"]))
tr_idx, te_idx = train_df.index.values, test_df.index.values
if is_binary:
# original binary handling: drop Suspects on both sets
mask_tr = np.isin(y[tr_idx], [0, 1])
mask_te = np.isin(y[te_idx], [0, 1])
if not mask_tr.any() or not mask_te.any():
# skip empty fold (keeps behavior safe without changing fold logic)
continue
Xtr, Xte = _prepare_fold_X(X, scalars, tr_idx[mask_tr], te_idx[mask_te])
ytr, yte = y[tr_idx][mask_tr], y[te_idx][mask_te]
else:
Xtr, Xte = _prepare_fold_X(X, scalars, tr_idx, te_idx)
ytr, yte = y[tr_idx], y[te_idx]
# Fit and score (unchanged approach)
model.fit(Xtr, ytr)
if is_binary:
if hasattr(model[-1], "predict_proba"):
prob = model.predict_proba(Xte)[:, 1]
else:
dec = model.decision_function(Xte)
prob = 1.0 / (1.0 + np.exp(-dec)) if np.ptp(dec) > 0 else np.full_like(dec, 0.5)
y_all.append(yte)
p_all.append(prob)
else:
if hasattr(model[-1], "predict_proba"):
prob = model.predict_proba(Xte)
else:
dec = model.decision_function(Xte)
if dec.ndim == 1:
dec = np.stack([-dec, dec], axis=1)
e = np.exp(dec - dec.max(axis=1, keepdims=True))
prob = e / e.sum(axis=1, keepdims=True)
y_all.append(yte)
p_all.append(prob)
if not y_all:
# No valid folds for this model under this test (e.g., all-bad after mask); skip
continue
y_cat = np.concatenate(y_all)
p_cat = np.concatenate(p_all)
if is_binary:
# Store 1D scores per model
prob_dict_bin[model_name] = p_cat
if y_ref_bin is None:
y_ref_bin = y_cat
else:
# Align lengths defensively (should match in normal use)
n = min(len(y_ref_bin), len(y_cat))
y_ref_bin = y_ref_bin[:n]
prob_dict_bin[model_name] = prob_dict_bin[model_name][:n]
else:
# Store (N, C) per model
prob_dict_multi[model_name] = p_cat
if y_ref_multi is None:
y_ref_multi = y_cat
else:
# Align lengths defensively (should match in normal use)
n = min(len(y_ref_multi), len(y_cat))
y_ref_multi = y_ref_multi[:n]
prob_dict_multi[model_name] = prob_dict_multi[model_name][:n, :]
tag = test_name.replace(" ", "")
# Produce overlays
if prob_dict_multi and y_ref_multi is not None:
_plot_multiclass_overlay(y_ref_multi, prob_dict_multi, out_dir, tag)
if prob_dict_bin and y_ref_bin is not None:
_plot_binary_overlay(y_ref_bin, prob_dict_bin, out_dir, tag)
if __name__ == "__main__":
main()
-384
View File
@@ -1,384 +0,0 @@
"""Evaluate REFUGE-trained classifier on Papila images using UNet crops."""
from __future__ import annotations
import argparse
import csv
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Set
import numpy as np
import torch
from torch.utils.data import DataLoader
from tqdm import tqdm
from PIL import Image, ImageDraw
import sys
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.refuge_preprocessing import RefugePreprocessing, RefugeSample
from classes.refuge_segmentation import RefugeSegmentation
from classes.refuge_classification import (
RefugeClassification,
RefugeClassificationDataset,
RefugeClassificationRecord,
UNetGeometryProvider,
_default_image_transform,
_geometry_from_mask,
)
from classes.backbones import BACKBONES, load_backbone_weights
from classes.unet_segmenter import UNetSegmenter
from classes.papila_builders import build_papila_clinical
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Evaluate classifier on Papila with UNet crops")
parser.add_argument("--filtered-metrics", type=Path, required=True, help="CSV of Papila samples with acceptable Dice")
parser.add_argument("--segmenter-manifest", type=Path, required=True, help="Manifest used to train the UNet segmenter")
parser.add_argument("--segmenter-weights", type=Path, required=True, help="Path to trained UNet weights (best.pt)")
parser.add_argument("--classifier-weights", type=Path, required=False, help="Path to classifier checkpoint (refuge_classifier_best.pt)")
parser.add_argument("--refuge-root", type=Path, default=Path("REFUGE"))
parser.add_argument("--image-dir", type=Path, default=Path("Papila/FundusImages"))
parser.add_argument("--clinical-dir", type=Path, default=Path("Papila/ClinicalData"))
parser.add_argument("--label-col", type=str, default="Diagnosis", help="Column name holding Papila labels")
parser.add_argument(
"--positive-labels",
nargs="*",
default=["glaucoma", "glaucoma suspect", "suspect"],
help="Values treated as glaucoma-positive when labels are non-numeric",
)
parser.add_argument("--dice-threshold", type=float, default=0.01, help="Minimum Dice (disc or cup) to keep a sample")
parser.add_argument("--segmenter-threshold", type=float, default=0.5, help="Probability threshold for UNet geometry")
parser.add_argument("--segmenter-normalize", choices=["none", "imagenet", "per_image"], default="per_image")
parser.add_argument("--segmenter-tta", action="store_true", help="Enable TTA (H/V flips) when deriving geometry")
parser.add_argument("--crop-scale", type=float, default=2.5)
parser.add_argument("--crop-size", type=int, default=224)
parser.add_argument("--batch-size", type=int, default=32)
parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
parser.add_argument("--output", type=Path, default=None, help="Optional CSV to store per-sample probabilities")
parser.add_argument(
"--cache-dir",
type=Path,
default=Path("analysis_data/classifier_cache"),
help="Directory to reuse classifier preprocessing cache",
)
parser.add_argument(
"--use-gt-masks",
action="store_true",
help="Use ground truth Papila contours instead of UNet predictions",
)
parser.add_argument(
"--gt-contours-dir",
type=Path,
default=Path("Papila/ExpertsSegmentations/Contours"),
help="Directory containing Papila contour text files",
)
parser.add_argument(
"--backbone",
type=str,
default=None,
help="Optional backbone name (e.g. inception_v3, densenet121). Requires matching classifier weights.",
)
return parser.parse_args()
def load_allowed_ids(path: Path, dice_threshold: float) -> Set[str]:
allowed: Set[str] = set()
with path.open(newline="") as fp:
reader = csv.DictReader(fp)
for row in reader:
sample_id = row.get("sample_id")
if not sample_id or sample_id == "__mean__":
continue
try:
disc = float(row.get("dice_disc", "nan"))
cup = float(row.get("dice_cup", "nan"))
except ValueError:
continue
if disc < dice_threshold and cup < dice_threshold:
continue
allowed.add(sample_id)
return allowed
def build_papila_samples(
image_dir: Path,
clinical_dir: Path,
label_col: str,
positive_labels: Sequence[str],
allowed_ids: Set[str],
) -> List[RefugeSample]:
clinical = build_papila_clinical(
image_dir=str(image_dir),
clinical_dir=str(clinical_dir),
label_col=label_col,
cat_cols=[],
)
positives = {lbl.lower() for lbl in positive_labels}
samples: Dict[str, RefugeSample] = {}
for _, row in clinical.df.iterrows():
image_path = clinical.get_image_path(row)
sample_id = f"papila_{image_path.stem}"
if sample_id not in allowed_ids or sample_id in samples:
continue
value = row.get(label_col)
if value is None or (isinstance(value, float) and np.isnan(value)):
continue
label: Optional[int]
try:
label_int = int(value)
if label_int == 2:
continue
label = 1 if label_int > 0 else 0
except (TypeError, ValueError):
label = 1 if str(value).strip().lower() in positives else 0
samples[sample_id] = RefugeSample(
sample_id=sample_id,
dataset="papila",
split="eval",
image_path=Path(image_path),
label=label,
device=None,
mask_path=None,
fovea_coord=None,
)
return list(samples.values())
def load_contour(path: Path) -> np.ndarray:
coords = np.loadtxt(path)
if coords.ndim == 1:
coords = coords.reshape(-1, 2)
return coords
def contour_to_mask(coords: np.ndarray, size: Sequence[int]) -> np.ndarray:
if coords is None or coords.size == 0:
return np.zeros((size[1], size[0]), dtype=np.uint8)
img = Image.new("L", size, 0)
draw = ImageDraw.Draw(img)
points = [tuple(map(float, pt)) for pt in coords]
draw.polygon(points, outline=1, fill=1)
return np.array(img, dtype=np.uint8)
class PapilaGTGeometryProvider:
def __init__(self, contours_dir: Path) -> None:
self.contours_dir = contours_dir
def _pick(self, base: str, kind: str) -> Optional[Path]:
for exp in ("exp2", "exp1"):
cand = self.contours_dir / f"{base}_{kind}_{exp}.txt"
if cand.exists():
return cand
return None
def __call__(self, sample: RefugeSample, scale: float):
base = Path(sample.image_path).stem
disc_path = self._pick(base, "disc")
cup_path = self._pick(base, "cup")
if disc_path is None or cup_path is None:
raise RuntimeError(f"Missing ground-truth contours for {sample.sample_id}")
image = Image.open(sample.image_path).convert("RGB")
disc_coords = load_contour(disc_path)
cup_coords = load_contour(cup_path)
disc_mask = contour_to_mask(disc_coords, image.size)
cup_mask = contour_to_mask(cup_coords, image.size)
cup_mask = ((cup_mask > 0) & (disc_mask > 0)).astype(np.uint8)
geom = _geometry_from_mask(disc_mask, scale)
return geom, disc_mask.astype(np.uint8), cup_mask.astype(np.uint8)
def build_backbone(name: Optional[str]) -> Optional[torch.nn.Module]:
if not name:
return None
key = name.lower()
if key not in BACKBONES:
raise ValueError(f"Unknown backbone '{name}'. Available: {', '.join(sorted(BACKBONES.keys()))}")
spec = BACKBONES[key]
model = spec.ctor(weights=spec.weights_default)
out_dim, model = spec.strip(model)
setattr(model, "_feature_dim", out_dim)
if key == "refugelike":
load_backbone_weights(key, model)
return model
def evaluate_records(
clf: RefugeClassification,
records: Sequence[RefugeClassificationRecord],
device: str,
batch_size: int,
) -> Dict[str, float]:
dataset = RefugeClassificationDataset(
records,
transform=clf.eval_transform,
polar_transform=clf.polar_transform,
size=clf.crop_size,
)
loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=0)
clf.backbone.to(device).eval()
clf.classifier_head.to(device).eval()
preds: List[float] = []
targets: List[int] = []
with torch.no_grad():
for batch in tqdm(loader, desc="Papila Eval", leave=False, unit="batch"):
images = batch["image"].to(device)
polars = batch["polar"].to(device)
extra_feats = batch["features"].to(device)
labels = batch["label"].cpu().numpy().tolist()
feats_img = clf.backbone(images)
feats = feats_img
if clf.use_polar:
feats_polar = clf.backbone(polars)
feats = torch.cat([feats, feats_polar], dim=1)
if clf.extra_feature_dim > 0:
feats = torch.cat([feats, extra_feats], dim=1)
logits = clf.classifier_head(feats)
probs = torch.softmax(logits, dim=1)[:, 1].cpu().numpy().tolist()
preds.extend(probs)
targets.extend(labels)
metrics: Dict[str, float] = {"count": float(len(targets))}
unique_labels = set(targets)
if len(unique_labels) >= 2:
metrics["auc"] = float(torchmetrics_auc(targets, preds))
else:
metrics["auc"] = float("nan")
preds_bin = [1 if p >= 0.5 else 0 for p in preds]
accuracy = sum(int(p == t) for p, t in zip(preds_bin, targets)) / max(1, len(targets))
metrics["accuracy"] = float(accuracy)
metrics["mean_prob"] = float(np.mean(preds)) if preds else float("nan")
metrics["labels_pos"] = float(sum(targets))
if preds:
metrics["probs_std"] = float(np.std(preds))
return metrics
def torchmetrics_auc(targets: Sequence[int], preds: Sequence[float]) -> float:
try:
from sklearn.metrics import roc_auc_score
except ImportError as exc:
raise RuntimeError("scikit-learn is required to compute AUC") from exc
return float(roc_auc_score(targets, preds))
def main() -> None:
args = parse_args()
device = args.device
allowed_ids = load_allowed_ids(args.filtered_metrics, args.dice_threshold)
if not allowed_ids:
raise SystemExit("No Papila samples passed the Dice threshold.")
papila_samples = build_papila_samples(
args.image_dir,
args.clinical_dir,
args.label_col,
args.positive_labels,
allowed_ids,
)
if not papila_samples:
raise SystemExit("No Papila samples with labels matched the filtered metrics.")
cache_dir = args.cache_dir
if args.use_gt_masks and cache_dir is not None:
cache_dir = cache_dir / "gt"
if args.use_gt_masks:
geometry_provider = PapilaGTGeometryProvider(args.gt_contours_dir)
segmenter = None
else:
segmenter = UNetSegmenter(
manifest_path=args.segmenter_manifest,
device=device,
normalize=args.segmenter_normalize,
)
seg_state = torch.load(args.segmenter_weights, map_location=device)
seg_state_dict = seg_state.get("model", seg_state)
segmenter.model.load_state_dict(seg_state_dict)
segmenter.model.to(device)
geometry_provider = UNetGeometryProvider(
segmenter=segmenter,
threshold=args.segmenter_threshold,
tta=args.segmenter_tta,
)
pre = RefugePreprocessing(args.refuge_root)
dummy_seg = RefugeSegmentation(pre)
backbone = build_backbone(args.backbone)
clf = RefugeClassification(
pre,
dummy_seg,
geometry_fn=geometry_provider,
cache_dir=cache_dir,
backbone=backbone,
)
clf.crop_scale = args.crop_scale
clf.crop_size = args.crop_size
clf.eval_transform = _default_image_transform(args.crop_size)
clf.ttt_transform = clf.eval_transform
if args.classifier_weights is not None:
clf_state = torch.load(args.classifier_weights, map_location=device)
clf.backbone.load_state_dict(clf_state["backbone"])
clf.classifier_head.load_state_dict(clf_state["classifier"])
clf.rotation_head.load_state_dict(clf_state["rotation"])
if "feature_reg" in clf_state and getattr(clf, "feature_reg_head", None) is not None:
clf.feature_reg_head.load_state_dict(clf_state["feature_reg"])
records = clf.build_records_for_samples(
papila_samples,
crop_scale=args.crop_scale,
progress_prefix="papila_eval",
)
if not records:
raise SystemExit("Unable to build any records; check geometry predictions or labels.")
metrics = evaluate_records(clf, records, device=device, batch_size=args.batch_size)
print(f"Samples evaluated: {int(metrics['count'])}")
print(f"AUC: {metrics['auc']:.4f}" if not np.isnan(metrics['auc']) else "AUC: NaN")
print(f"Accuracy @0.5: {metrics['accuracy']:.4f}")
print(f"Mean glaucoma prob: {metrics['mean_prob']:.4f}")
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
with args.output.open("w", newline="") as fp:
writer = csv.writer(fp)
writer.writerow(["sample_id", "prob_glaucoma", "label"])
clf.backbone.eval()
clf.classifier_head.eval()
dataset = RefugeClassificationDataset(
records,
transform=clf.eval_transform,
polar_transform=clf.polar_transform,
size=clf.crop_size,
)
loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=False, num_workers=0)
with torch.no_grad():
for batch in tqdm(loader, desc="Papila Output", leave=False, unit="batch"):
images = batch["image"].to(device)
polars = batch["polar"].to(device)
extra_feats = batch["features"].to(device)
ids = batch["sample_id"]
labels = batch["label"].tolist()
feats_img = clf.backbone(images)
feats = feats_img
if clf.use_polar:
feats_polar = clf.backbone(polars)
feats = torch.cat([feats, feats_polar], dim=1)
if clf.extra_feature_dim > 0:
feats = torch.cat([feats, extra_feats], dim=1)
logits = clf.classifier_head(feats)
probs = torch.softmax(logits, dim=1)[:, 1].cpu().numpy().tolist()
for sid, prob, label in zip(ids, probs, labels):
writer.writerow([sid, prob, label])
print(f"Per-sample probabilities written to {args.output}")
if __name__ == "__main__":
main()
-115
View File
@@ -1,115 +0,0 @@
#!/usr/bin/env python3
"""
Quick utility to recover the best epoch metrics from HyperTower run folders.
Example:
python scripts/extract_best_auc.py analysis_data/img_only_densenet_gt_bin/img_only_densenet_gt_bin_20251028_112733
By default it looks for columns named like `auc_fused` (set via --metric) inside each
`fold{n}_epoch_log.csv`, returning the epoch with the highest value plus the holdout
metrics, if present.
"""
from __future__ import annotations
import argparse
import csv
import json
import math
from pathlib import Path
from typing import Dict, Optional, Tuple
def to_float(value: Optional[str]) -> Optional[float]:
if value is None:
return None
value = value.strip()
if not value:
return None
try:
out = float(value)
except ValueError:
return None
if math.isnan(out):
return None
return out
def best_row(path: Path, metric: str) -> Optional[Dict[str, str]]:
if not path.exists():
return None
best: Optional[Tuple[float, int, Dict[str, str]]] = None
with path.open("r", newline="") as fp:
reader = csv.DictReader(fp)
for row in reader:
val = to_float(row.get(metric))
if val is None:
continue
epoch = int(row.get("epoch", reader.line_num))
if best is None or val > best[0]:
best = (val, epoch, row)
return best[2] if best else None
def summarize_fold(row: Dict[str, str], metric: str) -> Dict[str, float]:
data: Dict[str, float] = {}
for key in (metric, f"holdout_{metric.split('_', 1)[-1]}", "holdout_auc_img", "holdout_auc_fused"):
val = to_float(row.get(key))
if val is not None:
data[key] = val
epoch_val = to_float(row.get("epoch"))
if epoch_val is not None:
data["epoch"] = int(epoch_val)
return data
def main() -> None:
ap = argparse.ArgumentParser(description="Extract best-per-fold metric from HyperTower runs.")
ap.add_argument("run_dir", type=Path, help="Run directory (contains fold*_epoch_log.csv)")
ap.add_argument("--metric", default="auc_fused", help="Metric column to maximise (default: auc_fused)")
ap.add_argument("--json", type=Path, default=None, help="Optional path to dump JSON summary")
args = ap.parse_args()
run_dir: Path = args.run_dir
metric: str = args.metric
if not run_dir.exists():
raise SystemExit(f"Run directory not found: {run_dir}")
fold_summaries: Dict[str, Dict[str, float]] = {}
metric_values = []
for csv_path in sorted(run_dir.glob("fold*_epoch_log.csv")):
best = best_row(csv_path, metric)
fold_name = csv_path.stem.replace("_epoch_log", "")
if best is None:
print(f"{fold_name}: no valid '{metric}' values found")
continue
summary = summarize_fold(best, metric)
fold_summaries[fold_name] = summary
val = summary.get(metric)
if val is not None:
metric_values.append(val)
holdout_val = summary.get(f"holdout_{metric.split('_', 1)[-1]}")
print(f"{fold_name}: epoch={summary.get('epoch')} {metric}={val:.4f}" if val is not None else f"{fold_name}: epoch={summary.get('epoch')}")
if holdout_val is not None:
print(f" holdout_{metric.split('_', 1)[-1]}={holdout_val:.4f}")
if metric_values:
mean_val = sum(metric_values) / len(metric_values)
print(f"\nMean best {metric}: {mean_val:.4f}")
if args.json:
payload = {
"run_dir": str(run_dir),
"metric": metric,
"folds": fold_summaries,
"mean_metric": (sum(metric_values) / len(metric_values)) if metric_values else None,
}
args.json.parent.mkdir(parents=True, exist_ok=True)
args.json.write_text(json.dumps(payload, indent=2))
print(f"Summary written to {args.json}")
if __name__ == "__main__":
main()
-493
View File
@@ -1,493 +0,0 @@
import pandas as pd
from classes import HyperTower, ClinicalData, list_names, build_papila_clinical
from pathlib import Path
import shutil, json, textwrap
from datetime import datetime
import numpy as np
from typing import Dict, List, Tuple
from sklearn.model_selection import GroupKFold
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import roc_auc_score
from sklearn.linear_model import LogisticRegression
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.svm import SVC
from sklearn.preprocessing import label_binarize
def _proba_from_model(model, X):
if hasattr(model[-1], "predict_proba"):
return model.predict_proba(X)
dec = model.decision_function(X)
if dec.ndim == 1: # binary margins -> make 2-col
dec = np.stack([-dec, dec], axis=1)
e = np.exp(dec - dec.max(axis=1, keepdims=True))
return e / e.sum(axis=1, keepdims=True)
def _cv_auc_multiclass_per_class(X, y, groups, model, n_splits=5) -> np.ndarray:
"""
Returns a length-3 array of mean OvR AUCs for Class0/1/2 across GroupKFold.
Uses nan-safe means if a class is absent in a fold's test split.
"""
gkf = GroupKFold(n_splits=n_splits)
per_class_lists = [[], [], []]
for tr, te in gkf.split(X, y, groups):
model.fit(X[tr], y[tr])
proba = _proba_from_model(model, X[te])
y_te = y[te]
y_bin = label_binarize(y_te, classes=[0, 1, 2]) # (n,3)
for k in range(3):
yk = y_bin[:, k]
if yk.min() != yk.max(): # both classes present
per_class_lists[k].append(roc_auc_score(yk, proba[:, k]))
else:
per_class_lists[k].append(np.nan)
return np.array([np.nanmean(per_class_lists[k]) for k in range(3)], dtype=float)
def _cv_auc_binary(X, y, groups, model, n_splits=5) -> float:
mask = np.isin(y, [0, 1])
Xb, yb, gb = X[mask], y[mask], groups[mask]
gkf = GroupKFold(n_splits=n_splits)
aucs = []
for tr, te in gkf.split(Xb, yb, gb):
model.fit(Xb[tr], yb[tr])
if hasattr(model[-1], "predict_proba"):
p = model.predict_proba(Xb[te])[:, 1]
else:
p = model.decision_function(Xb[te])
# logistic squash for safety
if np.ptp(p) > 0:
p = 1.0 / (1.0 + np.exp(-p))
else:
p = np.full_like(p, 0.5, dtype=float)
# only compute if both classes present
if len(np.unique(yb[te])) == 2:
aucs.append(roc_auc_score(yb[te], p))
else:
aucs.append(np.nan)
return float(np.nanmean(aucs))
# -----------------------------------
# 1) Build Clinical Data (paper-faithful)
# -----------------------------------
IMAGE_DIR = "Papila/FundusImages"
CLINICAL_DIR = "Papila/ClinicalData"
LABEL_COL = "Diagnosis"
CAT_COLS = ["Gender", "Phakic/Pseudophakic"]
paper_auc = {
"TEST3_multiclass": { # Class0=Healthy, Class1=Glaucoma, Class2=Suspect
"LogReg": {"Class0": 0.67, "Class1": 0.66, "Class2": 0.67}, # from Fig. 7 (rounded)
"kNN": {"Class0": 0.72, "Class1": 0.70, "Class2": 0.76}, # your read of Fig. 7
"RF": {"Class0": 0.66, "Class1": 0.66, "Class2": 0.67}, # from Fig. 7 (rounded)
"SVM": {"Class0": 0.66, "Class1": 0.65, "Class2": 0.66}, # from Fig. 7 (rounded)
},
"TEST4_binary": { # Healthy vs Glaucoma (Suspects removed)
"LogReg": 0.71, # from text/Fig. 7 range midpoint
"kNN": 0.75, # your read of Fig. 7
"RF": 0.70, # from Fig. 7 (rounded)
"SVM": 0.69, # from Fig. 7 (rounded)
}
}
clinical = build_papila_clinical(
image_dir=IMAGE_DIR,
clinical_dir=CLINICAL_DIR,
label_col=LABEL_COL,
cat_cols=CAT_COLS,
)
# -----------------------------------
# 2) Feature matrix (no MD; IOP_corr already present)
# -----------------------------------
def build_feature_matrix(clinical) -> Tuple[np.ndarray, np.ndarray, np.ndarray, List[str]]:
"""
Returns:
X: features (N x D)
y: labels (Diagnosis: 0 healthy, 1 glaucoma, 2 suspect)
groups: patient IDs for GroupKFold
feat_names: list of feature names in X order
"""
df = clinical.df.copy()
# Scalars used in paper-style baselines (no VF_MD)
scalars = ["Age", "dioptre_1", "dioptre_2", "astigmatism",
"Pachymetry", "Axial_Length", "IOP_corr"]
# Categorical one-hot
cats = ["Gender", "Phakic/Pseudophakic"]
df_cats = pd.get_dummies(df[cats].astype("category"), drop_first=False, prefix=cats)
# Combine
X = pd.concat([df[scalars], df_cats], axis=1)
# Median impute numerics (simple, consistent)
for c in scalars:
med = pd.to_numeric(X[c], errors="coerce").median()
X[c] = pd.to_numeric(X[c], errors="coerce").fillna(med)
y = df[LABEL_COL].astype(int).values
groups = df["Patient ID"].astype(int).values
feat_names = list(X.columns)
return X.values.astype(np.float32), y, groups, feat_names
# ----------------------------
# 3) Model zoo (the four methods used in the paper)
# ----------------------------
def make_models(best_params: dict | None = None, random_state: int = 42) -> dict:
"""
Build paper-like baseline models. If best_params is provided (a dict mapping
model-name -> param dict with pipeline-style keys like 'clf__C'), those
params are applied to the corresponding pipelines.
"""
models = {
"LogReg": Pipeline([
("scaler", StandardScaler()),
("clf", LogisticRegression(
max_iter=100,
solver="lbfgs",
multi_class="auto"
))
]),
"kNN": Pipeline([
("scaler", StandardScaler()),
("clf", KNeighborsClassifier(
n_neighbors=5,
weights="uniform",
metric="minkowski",
p=2
))
]),
"RF": Pipeline([
("clf", RandomForestClassifier(
n_estimators=100,
criterion="gini",
max_depth=None,
min_samples_split=2,
min_samples_leaf=1,
max_features="sqrt",
bootstrap=True,
# random_state left as default; set via best_params if desired
))
]),
"SVM": Pipeline([
("scaler", StandardScaler()),
("clf", SVC(
C=1.0,
kernel="rbf",
gamma="scale",
probability=False
))
]),
}
# Apply overrides if provided
if best_params:
for name, params in best_params.items():
if name in models and params:
models[name].set_params(**params)
return models
# -----------------------------------
# 4) CV AUCs (mean over 5 folds; GroupKFold by patient)
# -----------------------------------
def _cv_auc_multiclass(X, y, groups, model, n_splits=5) -> float:
gkf = GroupKFold(n_splits=n_splits)
aucs = []
for tr, te in gkf.split(X, y, groups):
model.fit(X[tr], y[tr])
if hasattr(model[-1], "predict_proba"):
proba = model.predict_proba(X[te])
else:
dec = model.decision_function(X[te])
if dec.ndim == 1:
dec = np.stack([-dec, dec], axis=1)
e = np.exp(dec - dec.max(axis=1, keepdims=True))
proba = e / e.sum(axis=1, keepdims=True)
aucs.append(roc_auc_score(y[te], proba, multi_class="ovr", average="macro"))
return float(np.mean(aucs))
def _cv_auc_binary(X, y, groups, model, n_splits=5) -> float:
# Keep classes 0 (healthy) and 1 (glaucoma); drop suspects (2)
mask = np.isin(y, [0, 1])
Xb, yb, gb = X[mask], y[mask], groups[mask]
gkf = GroupKFold(n_splits=n_splits)
aucs = []
for tr, te in gkf.split(Xb, yb, gb):
model.fit(Xb[tr], yb[tr])
if hasattr(model[-1], "predict_proba"):
p = model.predict_proba(Xb[te])[:, 1]
else:
p = model.decision_function(Xb[te])
# simple logistic squashing if needed
if np.ptp(p) > 0:
p = 1.0 / (1.0 + np.exp(-p))
else:
p = np.full_like(p, 0.5, dtype=float)
aucs.append(roc_auc_score(yb[te], p))
return float(np.mean(aucs))
# -----------------------------------
# 5) Run both tests (multiclass + binary) and print table
# -----------------------------------
def run_papila_clinical_baselines(clinical, n_splits: int = 5,
random_state: int = 42,
best_params: dict | None = None) -> pd.DataFrame:
X, y, groups, feat_names = build_feature_matrix(clinical)
models = make_models(best_params=best_params, random_state=random_state)
rows = []
for name, model in models.items():
c0, c1, c2 = _cv_auc_multiclass_per_class(X, y, groups, model, n_splits=n_splits)
auc_bin = _cv_auc_binary(X, y, groups, model, n_splits=n_splits)
rows.append({"model": name, "Class0": c0, "Class1": c1, "Class2": c2, "Binary": auc_bin})
df = pd.DataFrame(rows).set_index("model").sort_index()
return df
results = run_papila_clinical_baselines(clinical, n_splits=5)
# print(results.to_string(float_format=lambda x: f"{x:.3f}"))
##############################
from sklearn.model_selection import ParameterGrid
from sklearn.base import clone
from sklearn.preprocessing import label_binarize
from sklearn.utils import check_random_state
# ==============================
# Helper: per-class & binary AUC with GroupKFold
# ==============================
def _proba_from_model(model, X):
if hasattr(model[-1], "predict_proba"):
return model.predict_proba(X)
# decision_function fallback
dec = model.decision_function(X)
if dec.ndim == 1: # binary margin -> 2-col probs
dec = np.stack([-dec, dec], axis=1)
e = np.exp(dec - dec.max(axis=1, keepdims=True))
return e / e.sum(axis=1, keepdims=True)
def _cv_auc_perclass_and_binary(X, y, groups, model, n_splits=5):
"""
Returns:
per_class_auc: length-3 array (Class0, Class1, Class2) averaged over folds
binary_auc: scalar (0 vs 1) averaged over folds
"""
gkf = GroupKFold(n_splits=n_splits)
# Hold fold-wise per-class AUCs (list of arrays of length 3)
perclass_fold_scores = []
binary_fold_scores = []
for tr, te in gkf.split(X, y, groups):
y_te = y[te]
# Multiclass per-class (OvR)
model.fit(X[tr], y[tr])
proba = _proba_from_model(model, X[te])
# One-vs-rest per-class AUCs (skip a class if absent in test fold)
y_bin = label_binarize(y_te, classes=[0, 1, 2]) # shape (n, 3)
perclass_scores = []
for k in range(3):
yk = y_bin[:, k]
# Only compute if both 0 and 1 are present
if yk.min() != yk.max():
perclass_scores.append(roc_auc_score(yk, proba[:, k]))
else:
perclass_scores.append(np.nan)
perclass_fold_scores.append(perclass_scores)
# Binary AUC (0 vs 1; drop class 2)
mask = np.isin(y_te, [0, 1])
if mask.sum() > 0 and len(np.unique(y_te[mask])) == 2:
# we need probabilities/margins for class 1 among (0,1)
# Map proba[:, 1] if the model was trained 3-way; we restrict te samples to 0/1
binary_p = proba[mask, 1]
binary_y = y_te[mask]
binary_fold_scores.append(roc_auc_score(binary_y, binary_p))
else:
binary_fold_scores.append(np.nan)
# Average over folds (ignore NaNs if a class was missing in a fold)
perclass_arr = np.array(perclass_fold_scores, dtype=float) # (n_folds, 3)
per_class_auc = np.nanmean(perclass_arr, axis=0)
binary_auc = float(np.nanmean(np.array(binary_fold_scores, dtype=float)))
return per_class_auc, binary_auc
# ==============================
# Distance-to-paper objective
# ==============================
def _distance_to_paper(model_name: str,
per_class_auc: np.ndarray,
binary_auc: float,
paper_auc: Dict,
w_mc: float = 1.0,
w_bin: float = 1.0) -> float:
mc_targets = paper_auc["TEST3_multiclass"][model_name]
tvec = np.array([mc_targets["Class0"], mc_targets["Class1"], mc_targets["Class2"]], dtype=float)
mc_diff = np.nanmean(np.abs(per_class_auc - tvec)) # mean absolute difference over 3 classes
bin_target = paper_auc["TEST4_binary"][model_name]
bin_diff = abs(binary_auc - bin_target)
return float(w_mc * mc_diff + w_bin * bin_diff)
# ==============================
# Parameter grids (paper-ish, not crazy-large)
# ==============================
def get_param_grids() -> Dict[str, List[dict]]:
return {
"LogReg": [
{
"clf__C": [0.01, 0.1, 1.0, 3.0, 10.0],
"clf__class_weight": [None, "balanced"],
"clf__max_iter": [200, 500],
# lbfgs + l2 is implied
}
],
"kNN": [
{
"clf__n_neighbors": [3, 5, 7, 9, 11],
"clf__weights": ["uniform", "distance"],
"clf__p": [1, 2], # Manhattan vs Euclidean
}
],
"RF": [
{
"clf__n_estimators": [200, 500, 1000],
"clf__max_depth": [None, 5, 10, 20],
"clf__max_features": ["sqrt", "log2", 0.5],
"clf__min_samples_leaf": [1, 2, 5],
"clf__class_weight": [None, "balanced"],
# If you want determinism add: "clf__random_state": [42]
}
],
"SVM": [
{
"clf__C": [0.1, 1.0, 3.0, 10.0],
"clf__gamma": ["scale", "auto", 0.1, 0.01, 0.001],
"clf__kernel": ["rbf"], # fixed to rbf as in paper-like default
}
],
}
# ==============================
# Grid search loop minimizing distance-to-paper
# ==============================
def search_params_to_match_paper(
clinical,
models: Dict[str, Pipeline],
paper_auc: Dict,
n_splits: int = 5,
w_mc: float = 1.0,
w_bin: float = 1.0,
verbose: bool = True,
) -> Tuple[pd.DataFrame, Dict[str, dict]]:
X, y, groups, feat_names = build_feature_matrix(clinical)
grids = get_param_grids()
summary_rows = []
best_params_by_model = {}
for name, base_model in models.items():
if name not in grids:
if verbose:
print(f"[warn] No grid for {name}, skipping.")
continue
best_loss = np.inf
best_params = None
best_mc = None
best_bin = None
for param_set in ParameterGrid(grids[name]):
model = clone(base_model).set_params(**param_set)
per_class_auc, binary_auc = _cv_auc_perclass_and_binary(
X, y, groups, model, n_splits=n_splits
)
loss = _distance_to_paper(
name, per_class_auc, binary_auc, paper_auc, w_mc=w_mc, w_bin=w_bin
)
if verbose:
mc_str = " / ".join(f"{a:.3f}" if np.isfinite(a) else "nan" for a in per_class_auc)
print(f"[{name}] params={param_set} | mc per-class={mc_str} | bin={binary_auc:.3f} | loss={loss:.4f}")
if loss < best_loss:
best_loss = loss
best_params = param_set
best_mc = per_class_auc
best_bin = binary_auc
# store
best_params_by_model[name] = best_params
summary_rows.append({
"model": name,
"best_loss": best_loss,
"best_params": json.dumps(best_params),
"mc_Class0": float(best_mc[0]),
"mc_Class1": float(best_mc[1]),
"mc_Class2": float(best_mc[2]),
"binary_auc": float(best_bin),
"paper_mc_Class0": paper_auc["TEST3_multiclass"][name]["Class0"],
"paper_mc_Class1": paper_auc["TEST3_multiclass"][name]["Class1"],
"paper_mc_Class2": paper_auc["TEST3_multiclass"][name]["Class2"],
"paper_binary": paper_auc["TEST4_binary"][name],
})
df = pd.DataFrame(summary_rows).set_index("model").sort_values("best_loss")
return df, best_params_by_model
# ==============================
# Run the search
# ==============================
models = make_models(random_state=42)
df_match, best_params = search_params_to_match_paper(
clinical=clinical,
models=models,
paper_auc=paper_auc,
n_splits=5,
w_mc=1.0, # weight multiclass distance
w_bin=1.0, # weight binary distance
verbose=True
)
# print("\n=== Best params found (by minimal distance-to-paper) ===")
# print(df_match[["best_loss","best_params","mc_Class0","mc_Class1","mc_Class2","binary_auc",
# "paper_mc_Class0","paper_mc_Class1","paper_mc_Class2","paper_binary"]])
# print("\nBest param dicts:")
for k, v in best_params.items():
print(k, "->", v)
results2 = run_papila_clinical_baselines(clinical, n_splits=5, random_state=42, best_params=best_params)
print(f"Default Settings: {results.round(2)}")
print(f"Best Params Settings: {results2.round(2)}")
print(f" Paper Results: {pd.DataFrame({
model: {**vals, "Binary": paper_auc["TEST4_binary"][model]}
for model, vals in paper_auc["TEST3_multiclass"].items()
}).T[["Class0","Class1","Class2","Binary"]]}")
-119
View File
@@ -1,119 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage:
# bash scripts/run_all_sweep.sh --epochs 25 --n-splits 5 --batch-size 8 [extra args]
#
# Merged sweep: runs the SE attention grid (bridge/tower/both × R=8/16/32),
# skipping tower-only non-normalized variants (tower normalization is a no-op),
# and includes binary eval counterparts for each baseline run. It also submits
# the full gradual-thaw grid (multiclass + binary variants).
ARGS=("$@")
run() {
local SHORT="$1"; shift
echo "=== Running: $SHORT ==="
# Skip if a summary for this shortname already exists
if ls "analysis_data/${SHORT}_"*.md >/dev/null 2>&1; then
echo "… skipping ${SHORT} (summary already present)"
return 0
fi
python3 scripts/run_multifold.py \
--shortname "$SHORT" \
"$@" \
"${ARGS[@]}" || true
}
echo "--- SE Grid (bridge/tower/both × R=8/16/32; tower nonorm skipped) ---"
for R in 8 16 32; do
# Bridge-only
run "se_bridge_R${R}_norm" --se-where bridge --se-reduction ${R} --se-pre-norm --checkpoint-best
run "se_bridge_R${R}_norm_bin" --se-where bridge --se-reduction ${R} --se-pre-norm --checkpoint-best --eval_mode binary
run "se_bridge_R${R}_nonorm" --se-where bridge --se-reduction ${R} --no-se-pre-norm --checkpoint-best
run "se_bridge_R${R}_nonorm_bin" --se-where bridge --se-reduction ${R} --no-se-pre-norm --checkpoint-best --eval_mode binary
# Tower-only
run "se_tower_R${R}_norm" --se-where tower --se-reduction-tower ${R} --se-pre-norm-tower --checkpoint-best
run "se_tower_R${R}_norm_bin" --se-where tower --se-reduction-tower ${R} --se-pre-norm-tower --checkpoint-best --eval_mode binary
# Tower+Bridge
run "se_tower_bridge_R${R}_norm" \
--se-where both --se-reduction ${R} --se-reduction-tower ${R} \
--se-pre-norm --se-pre-norm-tower --checkpoint-best
run "se_tower_bridge_R${R}_norm_bin" \
--se-where both --se-reduction ${R} --se-reduction-tower ${R} \
--se-pre-norm --se-pre-norm-tower --checkpoint-best --eval_mode binary
run "se_tower_bridge_R${R}_nonorm" \
--se-where both --se-reduction ${R} --se-reduction-tower ${R} \
--no-se-pre-norm --no-se-pre-norm-tower --checkpoint-best
run "se_tower_bridge_R${R}_nonorm_bin" \
--se-where both --se-reduction ${R} --se-reduction-tower ${R} \
--no-se-pre-norm --no-se-pre-norm-tower --checkpoint-best --eval_mode binary
done
THAW_COMMON_ARGS=(
--gradual-thaw
--thaw-phase-duration 5
--thaw-ratio 0.33
--thaw-start-epoch 5
--early-stop
--early-patience 5
)
echo "--- Gradual Thaw Grid (multiclass + binary) ---"
# Bridge-only thaw runs (norm and nonorm)
for R in 8 16 32; do
for MODE in norm nonorm; do
if [[ "$MODE" == "norm" ]]; then
FLAGS=(--se-where bridge --se-reduction "$R" --se-pre-norm --checkpoint-best)
else
FLAGS=(--se-where bridge --se-reduction "$R" --no-se-pre-norm --checkpoint-best)
fi
run "thaw_se_bridge_R${R}_${MODE}" "${FLAGS[@]}" "${THAW_COMMON_ARGS[@]}"
run "thawbin_se_bridge_R${R}_${MODE}" "${FLAGS[@]}" "${THAW_COMMON_ARGS[@]}" --eval_mode binary
done
done
# Tower-only thaw runs (norm and nonorm)
for R in 8 16 32; do
for MODE in norm nonorm; do
if [[ "$MODE" == "norm" ]]; then
FLAGS=(--se-where tower --se-reduction-tower "$R" --se-pre-norm-tower --checkpoint-best)
else
FLAGS=(--se-where tower --se-reduction-tower "$R" --no-se-pre-norm-tower --checkpoint-best)
fi
run "thaw_se_tower_R${R}_${MODE}" "${FLAGS[@]}" "${THAW_COMMON_ARGS[@]}"
run "thawbin_se_tower_R${R}_${MODE}" "${FLAGS[@]}" "${THAW_COMMON_ARGS[@]}" --eval_mode binary
done
done
# Tower+bridge thaw runs (norm and nonorm)
for R in 8 16 32; do
for MODE in norm nonorm; do
if [[ "$MODE" == "norm" ]]; then
FLAGS=(
--se-where both
--se-reduction "$R"
--se-reduction-tower "$R"
--se-pre-norm
--se-pre-norm-tower
--checkpoint-best
)
else
FLAGS=(
--se-where both
--se-reduction "$R"
--se-reduction-tower "$R"
--no-se-pre-norm
--no-se-pre-norm-tower
--checkpoint-best
)
fi
run "thaw_se_tower_bridge_R${R}_${MODE}" "${FLAGS[@]}" "${THAW_COMMON_ARGS[@]}"
run "thawbin_se_tower_bridge_R${R}_${MODE}" "${FLAGS[@]}" "${THAW_COMMON_ARGS[@]}" --eval_mode binary
done
done
echo "Merged sweep submitted. Check analysis_data/* and models/* for outputs."
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage:
# bash scripts/run_gradual_thaw_top5.sh --epochs 20 --n-splits 5 --batch-size 8 [extra args]
#
# Runs the full gradual-thaw grid aligned with the SE sweep (bridge/tower/both × R=8/16/32 × norm vs nonorm).
ARGS=("$@")
run() {
local SHORT="$1"; shift
echo "=== Running: $SHORT ==="
if ls "analysis_data/${SHORT}_"*.md >/dev/null 2>&1; then
echo "… skipping ${SHORT} (summary already present)"
return 0
fi
python3 scripts/run_multifold.py \
--shortname "$SHORT" \
--gradual-thaw --thaw-phase-duration 5 --thaw-ratio 0.33 --thaw-start-epoch 5 \
--early-stop --early-patience 5 \
"$@" \
"${ARGS[@]}" || true
}
# Bridge-only thaw runs
for R in 8 16 32; do
for MODE in norm nonorm; do
if [[ "$MODE" == "norm" ]]; then
FLAGS=(--se-where bridge --se-reduction "$R" --se-pre-norm --checkpoint-best)
else
FLAGS=(--se-where bridge --se-reduction "$R" --no-se-pre-norm --checkpoint-best)
fi
run "thaw_se_bridge_R${R}_${MODE}" "${FLAGS[@]}"
run "thawbin_se_bridge_R${R}_${MODE}" "${FLAGS[@]}" --eval_mode binary
done
done
# Tower-only thaw runs
for R in 8 16 32; do
for MODE in norm nonorm; do
if [[ "$MODE" == "norm" ]]; then
FLAGS=(--se-where tower --se-reduction-tower "$R" --se-pre-norm-tower --checkpoint-best)
else
FLAGS=(--se-where tower --se-reduction-tower "$R" --no-se-pre-norm-tower --checkpoint-best)
fi
run "thaw_se_tower_R${R}_${MODE}" "${FLAGS[@]}"
run "thawbin_se_tower_R${R}_${MODE}" "${FLAGS[@]}" --eval_mode binary
done
done
# Tower+bridge thaw runs
for R in 8 16 32; do
for MODE in norm nonorm; do
if [[ "$MODE" == "norm" ]]; then
FLAGS=(
--se-where both
--se-reduction "$R"
--se-reduction-tower "$R"
--se-pre-norm
--se-pre-norm-tower
--checkpoint-best
)
else
FLAGS=(
--se-where both
--se-reduction "$R"
--se-reduction-tower "$R"
--no-se-pre-norm
--no-se-pre-norm-tower
--checkpoint-best
)
fi
run "thaw_se_tower_bridge_R${R}_${MODE}" "${FLAGS[@]}"
run "thawbin_se_tower_bridge_R${R}_${MODE}" "${FLAGS[@]}" --eval_mode binary
done
done
echo "Gradual thaw grid submitted. Check analysis_data/* and models/* for outputs."
-15
View File
@@ -1,15 +0,0 @@
#!/usr/bin/env python3
"""Launch the Tkinter front-end for run_multifold."""
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.frontend import launch_frontend
if __name__ == "__main__":
launch_frontend()
-145
View File
@@ -1,145 +0,0 @@
#!/usr/bin/env python3
import argparse, subprocess, sys, time, json
from pathlib import Path
# Backbones in the paper that torchvision supports
BACKBONES = [
"efficientnet_b0",
"resnet50",
"densenet121",
"vgg16",
"mobilenet_v2",
"inception_v3",
# (Xception omitted; not in torchvision — add via timm later if needed)
]
MODES = [
("multiclass", ["Healthy", "Glaucoma", "Suspect"]),
("binary", ["Healthy", "Glaucoma"]),
]
def run(cmd):
print("\n$ " + " ".join(map(str, cmd)))
res = subprocess.run(cmd, check=True)
return res.returncode
def main():
ap = argparse.ArgumentParser(description="Run all paper CNNs across folds in multiclass + binary, then compile plots.")
ap.add_argument("--epochs", type=int, default=5, help="Epochs per fold (fast sanity first).")
ap.add_argument("--shortname", type=str, default="papergrid", help="Prefix for run IDs.")
ap.add_argument("--n-splits", type=int, default=5, help="Number of folds.")
ap.add_argument("--fusion-mode", type=str, default="fused", choices=["image_only","fused","metadata_only","vote"],
help="Paper CNNs are image-only; leave as image_only unless youre testing others.")
ap.add_argument("--freeze-ratio", type=float, default=0.0, help="0.0 = full fine-tune (as in the paper).")
# You can override data roots if needed
ap.add_argument("--image-dir", default="Papila/FundusImages")
ap.add_argument("--clinical-dir", default="Papila/ClinicalData")
ap.add_argument("--label-col", default="Diagnosis")
ap.add_argument("--cat-cols", nargs="*", default=["Gender", "Phakic/Pseudophakic"])
args = ap.parse_args()
ts = time.strftime("%Y%m%d_%H%M%S")
master_tag = f"{args.shortname}_{ts}"
master_dir = Path("analysis_data") / master_tag
master_dir.mkdir(parents=True, exist_ok=True)
# Keep a log of all subruns for the master report
index = []
for backbone in BACKBONES:
for eval_mode, class_names in MODES:
# build a child shortname per (backbone, mode)
sub_prefix = f"{args.shortname}_{backbone}_{eval_mode}"
cmd = [
sys.executable, "scripts/run_multifold.py",
"--backbone", backbone,
"--freeze-ratio", str(args.freeze_ratio),
"--fusion-mode", args.fusion_mode,
"--epochs", str(args.epochs),
"--n-splits", str(args.n_splits),
"--shortname", sub_prefix,
"--eval_mode", eval_mode,
"--image-dir", args.image_dir,
"--clinical-dir", args.clinical_dir,
"--label-col", args.label_col,
]
# class names by mode (ensures plot legends are correct)
cmd += ["--class-names", *class_names]
plot_head_map = {
"image_only": "image",
"fused" : "fused",
"metadata_only": "metadata",
"vote": "fused",
}
# We always aggregate/plot the image head for paper CNNs
cmd += ["--plot-head", plot_head_map.get(args.fusion_mode)]
# Delegate the whole run to run_multifold.py
run(cmd)
# Discover the child run folder (the newest folder matching the shortname prefix)
# We do this because run_multifold appends its own timestamp.
adir = Path("analysis_data")
children = sorted([p for p in adir.glob(f"{sub_prefix}_*") if p.is_dir()])
if not children:
print(f"[WARN] No analysis_data folder found for {sub_prefix}; skipping index entry.")
continue
run_dir = children[-1]
summary_json = run_dir / "summary.json"
plots_dir = run_dir / "plots"
# Record entry
entry = {
"backbone": backbone,
"eval_mode": eval_mode,
"run_dir": str(run_dir),
"summary_json": str(summary_json) if summary_json.exists() else None,
"plots": {
"mean": str(plots_dir / "roc_image_mean_ovr.png"),
"overlay": str(plots_dir / "roc_image_perfold_overlay.png"),
}
}
# Try to read AUCs
try:
if summary_json.exists():
entry.update(json.loads(summary_json.read_text()))
except Exception:
pass
index.append(entry)
# Write a master JSON + markdown report
(master_dir / "index.json").write_text(json.dumps(index, indent=2), encoding="utf-8")
# Simple markdown table of results with links
lines = [
f"# Multimodel grid — {master_tag}",
"",
f"- Epochs per fold: **{args.epochs}**",
f"- Folds: **{args.n_splits}**",
f"- Fusion mode: **{args.fusion_mode}** (paper CNNs = image-only)",
f"- Freeze ratio: **{args.freeze_ratio}**",
"",
"| Backbone | Mode | Mean AUC (macro/mc or ROC-AUC/bin) | Plots | Run folder |",
"|---|---|---:|---|---|",
]
for e in index:
auc_mean = e.get("macro_ovr_auc_mean", None)
if auc_mean is not None:
auc_str = f"{auc_mean:.3f}"
else:
auc_str = ""
mean_png = e["plots"]["mean"]
overlay_png = e["plots"]["overlay"]
plots_md = f"[mean]({mean_png}) / [overlay]({overlay_png})"
lines.append(
f"| `{e['backbone']}` | `{e['eval_mode']}` | {auc_str} | {plots_md} | `{e['run_dir']}` |"
)
(master_dir / "README.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"\nAll done.\n- Master index: {master_dir/'index.json'}\n- Report: {master_dir/'README.md'}")
print(f"- Individual runs live under analysis_data/<shortname_backbone_mode_*> with plots and summaries.")
if __name__ == "__main__":
main()
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage:
# bash scripts/run_se_sweep.sh --epochs 50 --n-splits 5 --batch-size 8 --eval_mode multiclass [extra args]
#
# This will launch a series of runs covering the grid from the slide:
# - Bridge-only R8/R16/R32 (normalized and non-normalized)
# - Tower-only R8/R16/R32 (normalized and non-normalized)
# - Tower+Bridge R8/R16/R32 (normalized and non-normalized)
ARGS=("$@")
run() {
local SHORT="$1"; shift
echo "=== Running: $SHORT ==="
# Skip if a summary for this shortname already exists
if ls "analysis_data/${SHORT}_"*.md >/dev/null 2>&1; then
echo "… skipping ${SHORT} (summary already present)"
return 0
fi
python3 scripts/run_multifold.py \
--shortname "$SHORT" \
"$@" \
"${ARGS[@]}" || true
}
# Bridge-only (normalized + non-normalized)
for R in 8 16 32; do
run "se_bridge_R${R}_norm" --se-where bridge --se-reduction ${R} --se-pre-norm --checkpoint-best
run "se_bridge_R${R}_nonorm" --se-where bridge --se-reduction ${R} --no-se-pre-norm --checkpoint-best
done
# Tower-only (normalized + non-normalized)
for R in 8 16 32; do
run "se_tower_R${R}_norm" \
--se-where tower --se-reduction-tower ${R} --se-pre-norm-tower \
--checkpoint-best
run "se_tower_R${R}_nonorm" \
--se-where tower --se-reduction-tower ${R} --no-se-pre-norm-tower \
--checkpoint-best
done
# Tower+Bridge (normalized + non-normalized)
for R in 8 16 32; do
# normalized (both pre-norm on)
run "se_tower_bridge_R${R}_norm" \
--se-where both --se-reduction ${R} --se-reduction-tower ${R} \
--se-pre-norm --se-pre-norm-tower --checkpoint-best
# non-normalized (both pre-norm off)
run "se_tower_bridge_R${R}_nonorm" \
--se-where both --se-reduction ${R} --se-reduction-tower ${R} \
--no-se-pre-norm --no-se-pre-norm-tower --checkpoint-best
done
echo "Sweep submitted. Check analysis_data/* and models/* for outputs."
+3 -3
View File
@@ -1287,7 +1287,7 @@
"outputs": [], "outputs": [],
"source": [ "source": [
"# 3a) No crop — original full-size images\n", "# 3a) No crop — original full-size images\n",
"!python scripts/basic_analysis/compare_hypertower_modes.py \\\n", "!python scripts/main/v2/multirun_hypertower.py \\\n",
" --tower-modes single ensemble \\\n", " --tower-modes single ensemble \\\n",
" --eval-modes binary multiclass \\\n", " --eval-modes binary multiclass \\\n",
" --epochs 40 --n-splits 5 \\\n", " --epochs 40 --n-splits 5 \\\n",
@@ -1305,7 +1305,7 @@
"outputs": [], "outputs": [],
"source": [ "source": [
"# 3b) GT crop — expert segmentation masks crop the optic disc region\n", "# 3b) GT crop — expert segmentation masks crop the optic disc region\n",
"!python scripts/basic_analysis/compare_hypertower_modes.py \\\n", "!python scripts/main/v2/multirun_hypertower.py \\\n",
" --tower-modes single ensemble \\\n", " --tower-modes single ensemble \\\n",
" --eval-modes binary multiclass \\\n", " --eval-modes binary multiclass \\\n",
" --epochs 40 --n-splits 5 \\\n", " --epochs 40 --n-splits 5 \\\n",
@@ -1323,7 +1323,7 @@
"outputs": [], "outputs": [],
"source": [ "source": [
"# 3c) UNet crop — trained segmenter crops the optic disc region\n", "# 3c) UNet crop — trained segmenter crops the optic disc region\n",
"!python scripts/basic_analysis/compare_hypertower_modes.py \\\n", "!python scripts/main/v2/multirun_hypertower.py \\\n",
" --tower-modes single ensemble \\\n", " --tower-modes single ensemble \\\n",
" --eval-modes binary multiclass \\\n", " --eval-modes binary multiclass \\\n",
" --epochs 40 --n-splits 5 \\\n", " --epochs 40 --n-splits 5 \\\n",
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Binary runs v2.2 (4 total):
# UNet crop: single | fused head
# GT crop: single | fused head
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$ROOT_DIR"
MANIFEST="manifest.csv"
UNET_WEIGHTS="models/v2/refuge/segmentation/per_image/best.pt"
COMMON=(
--epochs 40
--n-splits 5
--batch-size 8
--backbone refugelike
--eval-mode binary
--single-warmup-tower-epochs 4
--single-warmup-fused-epochs 4
--img-crop-manifest "$MANIFEST"
)
UNET_CROP=(
--img-crop-weights "$UNET_WEIGHTS"
)
GT_CROP=(
--img-crop-gt
)
# ── UNet crop ────────────────────────────────────────────────────────────────
echo "[1/4] UNet crop — binary, single..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" "${UNET_CROP[@]}" \
--tower-mode single \
--run-name v2.2_single_binary_unet_40ep_5fold
echo "[2/4] UNet crop — binary, ensemble + fused head..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" "${UNET_CROP[@]}" \
--tower-mode ensemble \
--fused-head --fusion-epochs 20 \
--run-name v2.2_fused_binary_unet_40ep_5fold
# ── GT crop ──────────────────────────────────────────────────────────────────
echo "[3/4] GT crop — binary, single..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" "${GT_CROP[@]}" \
--tower-mode single \
--run-name v2.2_single_binary_gt_40ep_5fold
echo "[4/4] GT crop — binary, ensemble + fused head..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" "${GT_CROP[@]}" \
--tower-mode ensemble \
--fused-head --fusion-epochs 20 \
--run-name v2.2_fused_binary_gt_40ep_5fold
echo "Binary v2.2 runs complete."
-68
View File
@@ -1,68 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Image-only runs v2.21 (6 total):
# No crop: binary | multiclass
# GT crop: binary | multiclass
# UNet crop: binary | multiclass
#
# Purpose: isolate the effect of ROI cropping at the single-CNN level,
# without any MD tower contribution (bridge-mode=image_only).
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$ROOT_DIR"
MANIFEST="manifest.csv"
UNET_WEIGHTS="models/v2/refuge/segmentation/per_image/best.pt"
COMMON=(
--epochs 40
--n-splits 5
--batch-size 8
--backbone refugelike
--tower-mode single
--bridge-mode image_only
--single-warmup-tower-epochs 4
--single-warmup-fused-epochs 0
--img-crop-manifest "$MANIFEST"
)
# ── No crop ──────────────────────────────────────────────────────────────────
echo "[1/6] No crop — binary, image-only..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" --eval-mode binary \
--run-name v2.21_imgonly_binary_nocrop_40ep_5fold
echo "[2/6] No crop — multiclass, image-only..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" --eval-mode multiclass \
--run-name v2.21_imgonly_multiclass_nocrop_40ep_5fold
# ── GT crop ──────────────────────────────────────────────────────────────────
echo "[3/6] GT crop — binary, image-only..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" --eval-mode binary --img-crop-gt \
--run-name v2.21_imgonly_binary_gt_40ep_5fold
echo "[4/6] GT crop — multiclass, image-only..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" --eval-mode multiclass --img-crop-gt \
--run-name v2.21_imgonly_multiclass_gt_40ep_5fold
# ── UNet crop ────────────────────────────────────────────────────────────────
echo "[5/6] UNet crop — binary, image-only..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" --eval-mode binary \
--img-crop-weights "$UNET_WEIGHTS" \
--run-name v2.21_imgonly_binary_unet_40ep_5fold
echo "[6/6] UNet crop — multiclass, image-only..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" --eval-mode multiclass \
--img-crop-weights "$UNET_WEIGHTS" \
--run-name v2.21_imgonly_multiclass_unet_40ep_5fold
echo "Image-only v2.21 runs complete."
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Multiclass runs v2.2 (4 total):
# UNet crop: single | fused head
# GT crop: single | fused head
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$ROOT_DIR"
MANIFEST="manifest.csv"
UNET_WEIGHTS="models/v2/refuge/segmentation/per_image/best.pt"
COMMON=(
--epochs 40
--n-splits 5
--batch-size 8
--backbone refugelike
--eval-mode multiclass
--single-warmup-tower-epochs 4
--single-warmup-fused-epochs 4
--img-crop-manifest "$MANIFEST"
)
UNET_CROP=(
--img-crop-weights "$UNET_WEIGHTS"
)
GT_CROP=(
--img-crop-gt
)
# ── UNet crop ────────────────────────────────────────────────────────────────
echo "[1/4] UNet crop — multiclass, single..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" "${UNET_CROP[@]}" \
--tower-mode single \
--run-name v2.2_single_multiclass_unet_40ep_5fold
echo "[2/4] UNet crop — multiclass, ensemble + fused head..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" "${UNET_CROP[@]}" \
--tower-mode ensemble \
--fused-head --fusion-epochs 20 \
--run-name v2.2_fused_multiclass_unet_40ep_5fold
# ── GT crop ──────────────────────────────────────────────────────────────────
echo "[3/4] GT crop — multiclass, single..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" "${GT_CROP[@]}" \
--tower-mode single \
--run-name v2.2_single_multiclass_gt_40ep_5fold
echo "[4/4] GT crop — multiclass, ensemble + fused head..."
python3 scripts/main/v2/run_multifold_v2.py \
"${COMMON[@]}" "${GT_CROP[@]}" \
--tower-mode ensemble \
--fused-head --fusion-epochs 20 \
--run-name v2.2_fused_multiclass_gt_40ep_5fold
echo "Multiclass v2.2 runs complete."
@@ -1,20 +0,0 @@
#!/usr/bin/env bash
set -e
echo "=== v2.3 ensemble binary nocrop ==="
python scripts/basic_analysis/compare_hypertower_modes.py \
--tower-modes ensemble --eval-modes binary \
--epochs 40 --n-splits 5 \
--backbone refugelike \
--img-crop-manifest analysis_data/unet_manifest.csv \
--run-name v2.3_ensemble_binary_nocrop
echo "=== v2.3 ensemble multiclass nocrop ==="
python scripts/basic_analysis/compare_hypertower_modes.py \
--tower-modes ensemble --eval-modes multiclass \
--epochs 40 --n-splits 5 \
--backbone refugelike \
--img-crop-manifest analysis_data/unet_manifest.csv \
--run-name v2.3_ensemble_multiclass_nocrop
echo "=== done ==="
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -e
echo "=== v2.3 fused binary nocrop ==="
python scripts/basic_analysis/compare_hypertower_modes.py \
--tower-modes ensemble --eval-modes binary \
--epochs 40 --n-splits 5 \
--backbone refugelike \
--img-crop-manifest analysis_data/unet_manifest.csv \
--warmup-md-epochs 50 \
--fused-head \
--run-name v2.3_fused_binary_nocrop
echo "=== v2.3 fused multiclass nocrop ==="
python scripts/basic_analysis/compare_hypertower_modes.py \
--tower-modes ensemble --eval-modes multiclass \
--epochs 40 --n-splits 5 \
--backbone refugelike \
--img-crop-manifest analysis_data/unet_manifest.csv \
--warmup-md-epochs 50 \
--fused-head \
--run-name v2.3_fused_multiclass_nocrop
echo "=== done ==="