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

This commit is contained in:
rpotter6298
2026-03-12 16:12:59 +01:00
parent 13ad32683f
commit 6d0698d0fb
35 changed files with 7765 additions and 5723 deletions
+2
View File
@@ -9,3 +9,5 @@ cache_data/
# model artifacts
models/refuge/
models/v2/refuge/
**/.archive/
.archive/
-105
View File
@@ -1,105 +0,0 @@
# bridge.py
import torch
import torch.nn as nn
from classes.SE_attention import SEBlock, SEGateLogger
# class SEBlock(nn.Module):
# def __init__(self, dim: int, reduction: int = 16):
# super().__init__()
# hidden = max(1, dim // max(1, reduction))
# self.net = nn.Sequential(
# nn.Linear(dim, hidden, bias=True),
# nn.ReLU(inplace=True),
# nn.Linear(hidden, dim, bias=True),
# nn.Sigmoid(),
# )
# def forward(self, x):
# return self.net(x)
class Bridge(nn.Module):
def __init__(
self,
img_dim,
meta_dim,
num_classes,
fusion_dim=256,
mode="fused",
use_se: bool = True,
se_reduction: int = 16,
se_pre_norm: bool = True,
):
super().__init__()
self.mode = mode
self.use_se = use_se
# self.se_reduction = se_reduction
# self.se_pre_norm = se_pre_norm
#project towers to equal width
self.W_img = nn.Linear(img_dim, fusion_dim)
self.W_md = nn.Linear(meta_dim, fusion_dim)
#(optional) : set layernorm for se so one tower doesn't dominate the other
self.ln_img = nn.LayerNorm(fusion_dim) if se_pre_norm else nn.Identity()
self.ln_md = nn.LayerNorm(fusion_dim) if se_pre_norm else nn.Identity()
#SE gate on the fused vector
self.se = SEBlock(fusion_dim, reduction=se_reduction, residual=True) if use_se else None
self.se_log = SEGateLogger(enabled=use_se, track_channels=False, dim=fusion_dim)
#heads
self.classifier_fused = nn.Sequential(
nn.ReLU(), nn.Dropout(0.5), nn.Linear(fusion_dim, num_classes)
)
self.classifier_img = nn.Linear(img_dim, num_classes)
self.classifier_md = nn.Linear(meta_dim, num_classes)
def reset_se_stats(self):
"""Call at epoch start."""
if getattr(self, "se_log", None):
self.se_log.reset()
def get_se_stats(self, reset: bool = True):
"""Call after eval. Returns dict or None."""
if getattr(self, "se_log", None) and self.se_log.enabled:
return self.se_log.get(reset=reset)
return None
def forward(self, img_feats, md_feats):
out_img = None if self.mode == "metadata_only" else self.classifier_img(img_feats)
out_md = None if self.mode == "image_only" else self.classifier_md(md_feats)
if self.mode == "fused":
hi = self.ln_img(self.W_img(img_feats)) #image features
hm = self.ln_md(self.W_md(md_feats)) #metadata features
fused = hi * hm #elementwise product
#apply SE gates
if self.se is not None:
fused, gates = self.se(fused)
if self.se_log.enabled:
self.se_log.accumulate(gates)
if self.se is not None and self.training and self.se_log.enabled:
if not hasattr(self, "_dbg_seen"):
self._dbg_seen = 0
if self._dbg_seen < 3: # print only a few times
print("[SE] gate mean this batch:", gates.mean().item())
self._dbg_seen += 1
out_f = self.classifier_fused(fused)
return out_f, out_img, out_md
# if ablation modes:
if self.mode == "image_only":
return out_img, out_img, None
if self.mode == "metadata_only":
return out_md, None, out_md
class VoteBridge(nn.Module):
def __init__(self, num_classes):
super().__init__()
self.vote_combiner = nn.Linear(num_classes * 2, num_classes) # two sets of logits
def forward(self, out_img, out_md):
votes = torch.cat([out_img, out_md], dim=1)
return self.vote_combiner(votes)
-264
View File
@@ -1,264 +0,0 @@
# clinical_data.py
from __future__ import annotations
from pathlib import Path
from typing import Iterable, Optional, Dict, List, Tuple
import numpy as np
import pandas as pd
class ClinicalData:
"""
Torch-free container for clinical metadata and file/label bookkeeping.
- Holds one or more dataframes (via add_df) and harmonizes columns
- Canonical IDs: 'Patient ID' must exist (or be specified and will be renamed)
- Canonical eye column: 'eyeID' recoded to 'OS'/'OD' if present; if absent, set to 0
- Feature typing (if cat_cols not provided):
* Categorical if (a) <= max_unique categorical threshold (default 4), or
(b) values cannot be coerced to float; otherwise numeric (scalar)
- Scaling/imputation:
* Numeric: minmax to [0,1], median imputation; + one missing flag per numeric feature
* Categorical: one-hot with '<UNK>' bucket at index 0
- Patient-level K-fold indices stored as dict: folds[k] -> {'train_ids': [...], 'test_ids': [...]}
"""
def __init__(
self,
image_dir: str,
clinical_dir: Optional[str],
label_col: str,
# typing / detection
cat_cols: Optional[Iterable[str]] = None,
max_unique_for_cat: int = 4,
# splitting
n_splits: int = 5,
random_seed: int = 42,
):
self.image_dir = Path(image_dir)
self.clinical_dir = Path(clinical_dir) if clinical_dir else None
self.label_col = label_col
self.max_unique_for_cat = max_unique_for_cat
self.n_splits = n_splits
# Internal state
self.frames: List[pd.DataFrame] = [] # raw frames as added
self.df: pd.DataFrame = pd.DataFrame() # concatenated
self.scalar_cols: List[str] = []
self.cat_cols: List[str] = list(cat_cols) if cat_cols is not None else []
self.scalar_stats: Dict[str, Dict[str, float]] = {}
self.cat_maps: Dict[str, Dict[object, int]] = {}
self.feature_dim: int = 0
self.folds: Dict[int, Dict[str, List[object]]] = {} # fold -> {'train_ids': [], 'test_ids': []}
self.random_seed = int(random_seed)
# ------------------- Public API -------------------
def add_df(
self,
df: pd.DataFrame,
id_column: Optional[str] = None,
eye_column: Optional[str] = None,
exclude_cols: Optional[Iterable[str]] = None,
) -> None:
"""
Add a dataframe and re-run harmonization, typing, stats, and K-fold indices.
QC rules:
- Must have patient ID column; if not provided under that name, specify id_column.
- eyeID, if present, must be binary; recoded to 'OS'/'OD'. If absent, create and set to 0.
"""
df = df.copy()
# --- QC: Patient ID ---
pid_col = self._ensure_patient_id(df, id_column)
# --- QC: eyeID ---
self._canonicalize_eye_column(df, eye_column)
# --- Normalize label presence ---
if self.label_col not in df.columns:
raise ValueError(f"label_col '{self.label_col}' not found in added dataframe")
# append & refresh
self.frames.append(df)
self._refresh_master_df(exclude_cols=exclude_cols)
self._infer_or_validate_feature_types(exclude_cols=exclude_cols)
self._compute_numeric_stats()
self._build_cat_maps()
self._compute_feature_dim()
self._build_kfold_indices()
def get_split_ids(self, fold: int) -> Tuple[List[object], List[object]]:
rec = self.folds.get(fold)
if not rec: raise KeyError(f"Fold {fold} not available. Built folds: {sorted(self.folds.keys())}")
return rec['train_ids'], rec['test_ids']
def get_split_dfs(self, fold: int) -> Tuple[pd.DataFrame, pd.DataFrame]:
train_ids, test_ids = self.get_split_ids(fold)
train_df = self.df[self.df['Patient ID'].isin(train_ids)].reset_index(drop=True)
test_df = self.df[self.df['Patient ID'].isin(test_ids)].reset_index(drop=True)
return train_df, test_df
def vectorize_row(self, row: pd.Series) -> np.ndarray:
"""Return a numpy feature vector (torch-free)."""
feats: List[float] = []
miss: List[float] = []
# numeric
for col in self.scalar_cols:
v = pd.to_numeric(row.get(col), errors='coerce')
if pd.isna(v):
miss.append(1.0)
v = self.scalar_stats[col]['median']
else:
miss.append(0.0)
lo = self.scalar_stats[col]['min']; hi = self.scalar_stats[col]['max']
feats.append((float(v) - lo) / (hi - lo) if hi > lo else 0.0)
# categorical
for col in self.cat_cols:
mapping = self.cat_maps[col]
one = [0.0] * len(mapping)
key = row.get(col)
one[mapping.get(key, 0)] = 1.0 # 0 is <UNK>
feats.extend(one)
# numeric missing flags
feats.extend(miss)
return np.asarray(feats, dtype=np.float32)
def get_image_path(self, row: pd.Series, filename_template: str = "RET{pid:03d}{eye}.jpg") -> Path:
pid = int(row['Patient ID']); eye = row.get('eyeID', 0)
if eye in ("OS", "OD"):
eye_str = eye
else:
eye_str = str(eye)
return self.image_dir / filename_template.format(pid=pid, eye=eye_str)
# ------------------- Internal helpers -------------------
def _ensure_patient_id(self, df: pd.DataFrame, id_column: Optional[str]) -> str:
if 'Patient ID' in df.columns:
return 'Patient ID'
if id_column and id_column in df.columns:
df.rename(columns={id_column: 'Patient ID'}, inplace=True)
return 'Patient ID'
# try auto-detect common variants
candidates = [c for c in df.columns if c.lower().replace(" ", "") in {"patientid","patient","pid"}]
if len(candidates) == 1:
df.rename(columns={candidates[0]: 'Patient ID'}, inplace=True)
return 'Patient ID'
raise ValueError("A 'Patient ID' column is required; provide id_column=... if it has a different name.")
def _canonicalize_eye_column(self, df: pd.DataFrame, eye_column: Optional[str]) -> None:
# Find source
src = None
if 'eyeID' in df.columns: src = 'eyeID'
elif eye_column and eye_column in df.columns: src = eye_column
else:
# try auto detect
for c in df.columns:
if 'eye' in c.lower():
src = c; break
if src is None:
df['eyeID'] = 0
return
# Map to OS/OD
s = df[src]
def norm(v):
if pd.isna(v): return None
x = str(v).strip().upper()
if x in {"OS","L","LEFT","0"}: return "OS"
if x in {"OD","R","RIGHT","1"}: return "OD"
# numbers like 2? fall back by parity
try:
num = int(float(x))
return "OD" if num % 2 == 1 else "OS"
except Exception:
return None
mapped = s.map(norm)
uniq = {u for u in mapped.dropna().unique().tolist()}
if not uniq.issubset({"OS","OD"}):
raise ValueError(f"eyeID must be binary; found values {sorted(uniq)}")
df['eyeID'] = mapped.fillna("OS")
if src != 'eyeID':
# keep original too if you want, but we standardize on 'eyeID'
pass
def _refresh_master_df(self, exclude_cols: Optional[Iterable[str]] = None) -> None:
self.df = pd.concat(self.frames, axis=0, ignore_index=True)
# drop columns explicitly excluded
if exclude_cols:
self.df = self.df.drop(columns=[c for c in exclude_cols if c in self.df.columns])
def _infer_or_validate_feature_types(self, exclude_cols: Optional[Iterable[str]] = None) -> None:
excluded = set(exclude_cols or []) | {self.label_col, 'Patient ID'}
# we keep canonical 'eyeID' as categorical if present
feature_candidates = [c for c in self.df.columns if c not in excluded]
# If user pre-specified cat_cols in __init__, respect them and infer the rest
cats = set(self.cat_cols) if self.cat_cols else set()
scalars = set()
for c in feature_candidates:
if c == 'eyeID':
cats.add('eyeID'); continue
if c in cats: continue
s = self.df[c]
# try numeric coercion
as_num = pd.to_numeric(s, errors='coerce')
num_missing = as_num.isna().mean()
num_unique = s.dropna().nunique()
if as_num.notna().any() and num_missing < 1.0 and num_unique > self.max_unique_for_cat:
scalars.add(c)
else:
# categorical if few uniques OR non-numeric
if num_unique <= self.max_unique_for_cat or as_num.isna().mean() > 0.0:
cats.add(c)
else:
scalars.add(c)
self.cat_cols = sorted(cats)
self.scalar_cols = sorted(scalars)
def _compute_numeric_stats(self) -> None:
self.scalar_stats.clear()
for col in self.scalar_cols:
s = pd.to_numeric(self.df[col], errors='coerce')
vals = s.dropna().astype(float).values
if vals.size == 0:
lo, hi, med = 0.0, 1.0, 0.0
else:
lo, hi = float(np.min(vals)), float(np.max(vals))
med = float(np.median(vals))
if hi <= lo: hi = lo + 1.0
self.scalar_stats[col] = {"min": lo, "max": hi, "median": med}
def _build_cat_maps(self) -> None:
self.cat_maps.clear()
for col in self.cat_cols:
cats = [v for v in self.df[col].dropna().unique().tolist()]
try: cats = sorted(cats)
except Exception: pass
mapping = {"<UNK>": 0}
for i, v in enumerate(cats, start=1): mapping[v] = i
self.cat_maps[col] = mapping
def _compute_feature_dim(self) -> None:
self.feature_dim = len(self.scalar_cols) + sum(len(m) for m in self.cat_maps.values()) + len(self.scalar_cols)
# ------------------- K-fold on unique patients -------------------
def _build_kfold_indices(self) -> None:
# unique patients and a per-patient label for stratification if possible
pats = self.df['Patient ID'].unique().tolist()
# Derive a patient label as the mode of their rows (fallback to first valid)
labels_by_pat = {}
for pid, grp in self.df.groupby('Patient ID'):
lab = grp[self.label_col].dropna()
if len(lab) == 0:
labels_by_pat[pid] = 0
else:
labels_by_pat[pid] = lab.mode().iloc[0]
y_pat = np.array([labels_by_pat[p] for p in pats])
# Try to use StratifiedGroupKFold if available, else fall back to StratifiedKFold on patient labels
try:
from sklearn.model_selection import StratifiedGroupKFold
sgkf = StratifiedGroupKFold(n_splits=self.n_splits, shuffle=True, random_state=self.random_seed)
split_iter = sgkf.split(X=pats, y=y_pat, groups=pats)
except Exception:
from sklearn.model_selection import StratifiedKFold
skf = StratifiedKFold(n_splits=self.n_splits, shuffle=True, random_state=self.random_seed)
split_iter = skf.split(X=np.zeros(len(pats)), y=y_pat)
self.folds.clear()
for i, (train_idx, test_idx) in enumerate(split_iter):
train_ids = [pats[j] for j in train_idx]
test_ids = [pats[j] for j in test_idx]
self.folds[i] = {"train_ids": train_ids, "test_ids": test_ids}
-58
View File
@@ -1,58 +0,0 @@
# dataset.py
from torch.utils.data import Dataset
from PIL import Image
import numpy as np
import torch
class ClinicalDataset(Dataset):
"""Generic dataset wrapping a ClinicalData instance.
Returns (img_tensor, meta_tensor, label)."""
def __init__(
self,
clinical_data,
img_transform,
meta_transform=None,
image_preprocessor=None,
geometry_provider=None,
geometry_dim: int = 0,
):
self.clinical = clinical_data
self.transform_image = img_transform
self.meta_transform = meta_transform or (lambda x: x)
self.image_preprocessor = image_preprocessor
self.geometry_provider = geometry_provider
self.geometry_dim = geometry_dim if geometry_provider is not None else 0
def __len__(self):
return len(self.clinical.df)
def __getitem__(self, idx: int):
row = self.clinical.df.iloc[idx]
# load & transform image
img_path = self.clinical.get_image_path(row)
orig_img = Image.open(img_path).convert("RGB")
img = orig_img
if self.image_preprocessor is not None:
img = self.image_preprocessor(img, img_path)
img_t = self.transform_image(img)
# encode & transform metadata
meta = self.clinical.encode_metadata(row)
meta_t = self.meta_transform(meta)
# label
label = self.clinical.get_label(row)
if self.geometry_dim > 0:
features = None
if self.geometry_provider is not None and hasattr(self.geometry_provider, "geometry_features"):
features = self.geometry_provider.geometry_features(orig_img, img_path)
if features is None:
geom_vec = torch.zeros(self.geometry_dim, dtype=torch.float32)
else:
features = np.asarray(features, dtype=np.float32)
if features.shape[0] != self.geometry_dim:
geom_vec = torch.zeros(self.geometry_dim, dtype=torch.float32)
else:
geom_vec = torch.from_numpy(features)
return img_t, meta_t, geom_vec, label
return img_t, meta_t, label
-89
View File
@@ -1,89 +0,0 @@
import math, copy, torch
class EarlyStopper:
def __init__(self, monitor: str, mode: str = "auto",
patience: int = 5, min_delta: float = 0.0,
save_path: str | None = None, restore_best: bool = True):
"""
monitor: key in your epoch row, e.g. 'eval_loss', 'auc_fused', 'acc_fused'
mode: 'max' (higher is better), 'min', or 'auto' (min for '*loss*', else max)
patience: epochs without improvement before stopping
min_delta: required improvement magnitude
save_path: optional .pth file to save best weights each time it improves
restore_best: if True, load best weights back at the end
"""
self.monitor = monitor
if mode == "auto":
mode = "min" if "loss" in monitor.lower() else "max"
self.mode = mode
self.patience = int(patience)
self.min_delta = float(min_delta)
self.save_path = save_path
self.restore_best = restore_best
self.best = -math.inf if mode == "max" else math.inf
self.bad_epochs = 0
self.best_state = None
self.best_epoch = -1
self.last_improved = False
def _is_better(self, val):
if val is None or (isinstance(val, float) and math.isnan(val)):
return False
if self.mode == "max":
return val > (self.best + self.min_delta)
else:
return val < (self.best - self.min_delta)
def step(self, metrics: dict, trainer, epoch: int) -> bool:
val = metrics.get(self.monitor, None)
improved = self._is_better(val)
self.last_improved = improved
if improved:
self.best = val
self.best_epoch = epoch
self.bad_epochs = 0
# snapshot + optional save
state = {
"img_tower": trainer.img_tower.state_dict(),
"md_tower": trainer.md_tower.state_dict(),
"optimizer": trainer.optimizer.state_dict(),
}
if hasattr(trainer, "bridge"): state["bridge"] = trainer.bridge.state_dict()
if hasattr(trainer, "head_img"): state["head_img"] = trainer.head_img.state_dict()
if hasattr(trainer, "head_md"): state["head_md"] = trainer.head_md.state_dict()
# keep an in-memory copy for restore(); file save is optional
self.best_state = copy.deepcopy(state)
if self.save_path: torch.save(state, self.save_path)
print(f"[early] ↑ new best {self.monitor}={val:.5f} at epoch {epoch+1}")
else:
self.bad_epochs += 1
stop = self.bad_epochs >= self.patience
if stop:
print(f"[early] stopping: no improvement in {self.patience} epochs "
f"(best {self.monitor}={self.best:.5f} @ epoch {self.best_epoch+1})")
return stop
def restore(self, trainer):
if not self.restore_best:
return
# Prefer in-memory best state; otherwise try loading from save_path
st = self.best_state
if st is None and self.save_path:
try:
st = torch.load(self.save_path, map_location="cpu")
except Exception:
st = None
if st is None:
return
trainer.img_tower.load_state_dict(st["img_tower"])
trainer.md_tower.load_state_dict(st["md_tower"])
if "bridge" in st and hasattr(trainer, "bridge"):
trainer.bridge.load_state_dict(st["bridge"])
if "head_img" in st and hasattr(trainer, "head_img"):
trainer.head_img.load_state_dict(st["head_img"])
if "head_md" in st and hasattr(trainer, "head_md"):
trainer.head_md.load_state_dict(st["head_md"])
trainer.optimizer.load_state_dict(st["optimizer"])
-1402
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-125
View File
@@ -1,125 +0,0 @@
# classes/image_tower.py
from __future__ import annotations
import math
from typing import Optional
import torch
from torch import nn
from torchvision import transforms
from classes.backbones import BACKBONES, list_names, load_backbone_weights
from classes.SE_attention import SEBlock
def build_backbone(name: str, freeze_ratio: float = 0.0, augment: bool = True):
"""
Operational builder:
- instantiate with DEFAULT weights
- strip classifier → features
- apply ratio-based freezing over coarse blocks
- return (model, out_dim, transform)
"""
key = (name or "").lower()
if key not in BACKBONES:
raise ValueError(f"Unsupported backbone '{name}'. Valid options: {list_names()}")
spec = BACKBONES[key]
m = spec.ctor(weights=spec.weights_default)
out_dim, m = spec.strip(m)
load_backbone_weights(key, m)
# transforms: use the weights mean/std, but keep your augmentation pipeline
mean = getattr(spec.weights_default, "meta", {}).get("mean", (0.485, 0.456, 0.406))
std = getattr(spec.weights_default, "meta", {}).get("std", (0.229, 0.224, 0.225))
crop = 299 if key == "inception_v3" else 224
if augment:
transform = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(crop),
transforms.RandomHorizontalFlip(),
transforms.RandomVerticalFlip(),
transforms.RandomRotation(15),
transforms.ColorJitter(0.1, 0.1, 0.1, 0.05),
transforms.ToTensor(),
transforms.Normalize(mean=mean, std=std),
])
else:
transform = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(crop),
transforms.ToTensor(),
transforms.Normalize(mean=mean, std=std),
])
# ratio-based freezing: freeze earliest floor(N * freeze_ratio) blocks
fr = max(0.0, min(1.0, float(freeze_ratio)))
blocks = spec.blocks(m)
n = len(blocks)
freeze_n = int(math.floor(n * fr))
for b in blocks[:freeze_n]:
for p in b.parameters():
p.requires_grad = False
return m, out_dim, transform
class ImageTower(nn.Module):
"""
Vision backbone → pooled features.
- backbone: one of list_names() (default 'efficientnet_b0')
- always DEFAULT torchvision weights
- freeze_ratio ∈ [0,1] freezes earliest floor(N*freeze_ratio) blocks
- returns [N, out_dim] features from backbone forward
"""
def __init__(self, backbone: str = "efficientnet_b0", freeze_ratio: float = 0.0,
use_se: bool = False, se_reduction: int = 16, se_pre_norm: bool = True,
augment: bool = True, geometry_dim: int = 0):
super().__init__()
self.backbone, base_dim, self.transform = build_backbone(backbone, freeze_ratio, augment=augment)
self._name = backbone
# Keep ordered blocks for dynamic freezing/thawing
key = (self._name or "").lower()
self._spec = BACKBONES[key]
self._blocks = self._spec.blocks(self.backbone)
# Optional tower-level SE over the final feature vector
self.base_dim = base_dim
self.geometry_dim = max(0, int(geometry_dim))
self.out_dim = self.base_dim + self.geometry_dim
self.tower_ln = nn.LayerNorm(self.base_dim) if se_pre_norm else nn.Identity()
self.tower_se = SEBlock(self.base_dim, reduction=se_reduction, residual=True) if use_se else None
def forward(self, x: torch.Tensor, geometry: Optional[torch.Tensor] = None) -> torch.Tensor:
y = self.backbone(x)
# sanity: pooled features, not logits
assert y.dim() == 2 and y.size(1) == self.base_dim, \
f"Expected features [N,{self.base_dim}], got {tuple(y.shape)}"
if self.tower_se is not None:
y, _ = self.tower_se(self.tower_ln(y))
if self.geometry_dim > 0:
if geometry is None or geometry.numel() == 0:
geom = torch.zeros(y.size(0), self.geometry_dim, device=y.device, dtype=y.dtype)
else:
if geometry.dim() == 1:
geom = geometry.unsqueeze(0)
else:
geom = geometry
geom = geom.to(device=y.device, dtype=y.dtype)
if geom.size(0) != y.size(0):
raise ValueError(f"Geometry batch size mismatch: {geom.size(0)} vs {y.size(0)}")
if geom.size(1) != self.geometry_dim:
raise ValueError(f"Expected geometry dim {self.geometry_dim}, got {geom.size(1)}")
y = torch.cat([y, geom], dim=1)
return y
def set_freeze_ratio(self, ratio: float):
"""Dynamically freeze earliest floor(N*ratio) backbone blocks.
ratio in [0,1]."""
r = max(0.0, min(1.0, float(ratio)))
n = len(self._blocks)
freeze_n = int(math.floor(n * r))
# Unfreeze all first
for b in self._blocks:
for p in b.parameters():
p.requires_grad = True
# Freeze earliest blocks
for b in self._blocks[:freeze_n]:
for p in b.parameters():
p.requires_grad = False
-54
View File
@@ -1,54 +0,0 @@
# md_tower.py
import torch
import torch.nn as nn
from classes import ClinicalData
from classes.SE_attention import SEBlock
class MDTower(nn.Module):
"""MLP over ClinicalData.vectorize_row outputs (convert to torch inside tower)."""
def __init__(self, clinical_data: ClinicalData, hidden_dim: int = 128, dropout: float = 0.1,
use_se: bool = False, se_reduction: int = 16, se_pre_norm: bool = True):
super().__init__()
self.feature_dim = clinical_data.feature_dim
self.out_dim = hidden_dim
# two-block MLP so we can optionally freeze/thaw per block
self.block0 = nn.Sequential(
nn.Linear(self.feature_dim, hidden_dim),
nn.LayerNorm(hidden_dim),
nn.ReLU(inplace=True),
nn.Dropout(dropout),
)
self.block1 = nn.Sequential(
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(inplace=True),
)
self.net = nn.Sequential(self.block0, self.block1)
self.tower_ln = nn.LayerNorm(hidden_dim) if se_pre_norm else nn.Identity()
self.tower_se = SEBlock(hidden_dim, reduction=se_reduction, residual=True) if use_se else None
def forward(self, meta_np_or_torch) -> torch.Tensor:
if isinstance(meta_np_or_torch, torch.Tensor):
x = meta_np_or_torch
else:
x = torch.as_tensor(meta_np_or_torch, dtype=torch.float32)
h = self.net(x)
if self.tower_se is not None:
h, _ = self.tower_se(self.tower_ln(h))
return h
def set_freeze_ratio(self, ratio: float):
"""Optionally freeze earliest blocks of the MLP.
With two blocks, ratio≥0.5 freezes block0; ratio≥1.0 freezes both."""
r = max(0.0, min(1.0, float(ratio)))
# Unfreeze all
for p in self.block0.parameters():
p.requires_grad = True
for p in self.block1.parameters():
p.requires_grad = True
# Freeze earliest blocks based on ratio threshold
if r >= 0.5:
for p in self.block0.parameters():
p.requires_grad = False
if r >= 1.0:
for p in self.block1.parameters():
p.requires_grad = False
-99
View File
@@ -1,99 +0,0 @@
# papila_builders.py
from typing import List, Dict
import numpy as np
import pandas as pd
from classes import ClinicalData # adjust import path if needed
# ---- Pachymetry → IOP correction (per PAPILA Table 3) ----
_PACHY_TABLE: Dict[int, int] = {
475:+5, 485:+4, 495:+4, 505:+3, 515:+2, 525:+1, 535:+1,
545: 0, 555:-1, 565:-1, 575:-2, 585:-3, 595:-4, 605:-4, 615:-5,
}
_PACHY_KEYS = np.array(sorted(_PACHY_TABLE.keys()))
def _nearest_pachy_key(x: float) -> int:
idx = int(np.argmin(np.abs(_PACHY_KEYS - float(x))))
return int(_PACHY_KEYS[idx])
def _pick_iop(row: pd.Series) -> float:
"""Prefer Pneumatic, else Perkins; may return NaN."""
raw = row["Pneumatic"] if not pd.isna(row.get("Pneumatic", np.nan)) else row.get("Perkins", np.nan)
return float(raw) if not pd.isna(raw) else np.nan
def _correct_iop(raw_iop: float, pachy: float) -> float:
"""Return corrected IOP using nearest pachymetry bin; if pachy missing, return raw."""
if pd.isna(raw_iop):
return np.nan
if pd.isna(pachy):
return float(raw_iop)
key = _nearest_pachy_key(float(pachy))
return float(raw_iop) + float(_PACHY_TABLE[key])
def _apply_iop_and_drop_md(df: pd.DataFrame) -> pd.DataFrame:
"""Add IOP_raw/IOP_corr and drop VF_MD if present (in-place safe)."""
# IOP_raw
df["IOP_raw"] = df.apply(_pick_iop, axis=1)
# IOP_corr
pachy = df.get("Pachymetry", pd.Series(np.nan, index=df.index))
df["IOP_corr"] = [
_correct_iop(r, p) for r, p in zip(df["IOP_raw"].values, pachy.values)
]
# Drop VF_MD if present
if "VF_MD" in df.columns:
df.drop(columns=["VF_MD"], inplace=True)
return df
def build_papila_clinical(
image_dir: str,
clinical_dir: str,
label_col: str,
cat_cols: List[str],
n_splits: int = 5,
random_seed: int = 42,
) -> ClinicalData:
"""
Build ClinicalData exactly like the user's original build_clinical:
- add_df(OD), set eyeID='OD'
- add_df(OS), set eyeID='OS'
- normalize 'Patient ID' on frames
THEN:
- compute IOP_raw / IOP_corr on each frame
- drop VF_MD
- refresh master df + kfold indices
"""
clinical = ClinicalData(
image_dir=image_dir,
clinical_dir=clinical_dir,
label_col=label_col,
cat_cols=cat_cols,
n_splits=n_splits,
random_seed=random_seed,
)
# --- Load exactly like original build_clinical ---
clinical.add_df(pd.read_excel(f"{clinical_dir}/patient_data_od.xlsx", header=1), id_column="ID")
clinical.frames[0]["eyeID"] = "OD"
clinical.add_df(pd.read_excel(f"{clinical_dir}/patient_data_os.xlsx", header=1), id_column="ID")
clinical.frames[1]["eyeID"] = "OS"
# Normalize 'Patient ID' on the per-eye frames (string → int)
for frame in clinical.frames:
frame["Patient ID"] = frame["Patient ID"].astype(str).str.extract(r"(\d+)")[0].astype(int)
# Build initial master as in original
clinical._refresh_master_df()
# --- Post-processing ON THE FRAMES (so everything stays consistent) ---
for i in range(len(clinical.frames)):
clinical.frames[i] = _apply_iop_and_drop_md(clinical.frames[i])
# Refresh master again so IOP_raw/IOP_corr & MD removal propagate
clinical._refresh_master_df()
clinical._build_kfold_indices()
return clinical
-849
View File
@@ -1,849 +0,0 @@
"""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
@@ -1,306 +0,0 @@
"""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
@@ -1,383 +0,0 @@
"""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
-149
View File
@@ -1,149 +0,0 @@
# tower_watcher.py
import matplotlib.pyplot as plt
# tower_watcher.py
import matplotlib.pyplot as plt
class TowerWatcher:
"""
Live monitor:
- Cumulative batch-level: loss & accuracy per batch across all epochs.
- Epoch batch-level: loss & accuracy per batch within the current epoch (resets each epoch).
- TP/FP/TN/FN bar charts per tower, one chart each, new group each epoch.
"""
def __init__(self):
plt.ion()
# 2 line plots (cum loss, cum acc), 2 line plots (epoch loss, epoch acc), 3 bar plots
self.fig, self.axs = plt.subplots(7, 1, figsize=(10, 28))
self.reset()
def reset(self):
# Cumulative batch-level
self.global_batches = []
self.loss_cum = {"fusion": [], "image": [], "meta": []}
self.acc_cum = {"fusion": [], "image": [], "meta": []}
# Epoch batch-level
self.epoch_batches = []
self.loss_epoch_batch = {"fusion": [], "image": [], "meta": []}
self.acc_epoch_batch = {"fusion": [], "image": [], "meta": []}
# Epoch markers for cum plots
self.epoch_markers = []
# Stats per epoch for bars
self.epoch_stats = {"fusion": [], "image": [], "meta": []}
# Track current epoch
self.current_epoch = -1
def on_epoch_start(self, epoch):
# mark epoch boundary in cumulative
x = self.global_batches[-1] + 1 if self.global_batches else 0
self.epoch_markers.append(x)
# reset epoch batch-level data
self.epoch_batches = []
for d in [self.loss_epoch_batch, self.acc_epoch_batch]:
for k in d:
d[k].clear()
self.current_epoch = epoch
def on_batch_end(self, idx, stats: dict):
# Cumulative
self.global_batches.append(len(self.global_batches) + 1)
for key, lk, ak in [
("fusion", "loss_f", "acc_f"),
("image", "loss_i", "acc_i"),
("meta", "loss_m", "acc_m"),
]:
self.loss_cum[key].append(stats.get(lk, 0))
self.acc_cum[key].append(stats.get(ak, 0))
# Epoch-level
self.epoch_batches.append(len(self.epoch_batches) + 1)
for key, lk, ak in [
("fusion", "loss_f", "acc_f"),
("image", "loss_i", "acc_i"),
("meta", "loss_m", "acc_m"),
]:
self.loss_epoch_batch[key].append(stats.get(lk, 0))
self.acc_epoch_batch[key].append(stats.get(ak, 0))
# redraw
self._draw_batch_plots()
def on_epoch_end(self, epoch, stats: dict):
# record per-epoch TP/FP/TN/FN
for key in ["fusion", "image", "meta"]:
self.epoch_stats[key].append(
{
"tp": stats.get("tp", 0),
"fp": stats.get("fp", 0),
"tn": stats.get("tn", 0),
"fn": stats.get("fn", 0),
}
)
self._draw_epoch_bars()
def _draw_batch_plots(self):
# Cumulative Loss
ax = self.axs[0]
ax.clear()
ax.plot(self.global_batches, self.loss_cum["fusion"], label="Fusion")
ax.plot(self.global_batches, self.loss_cum["image"], label="Image Tower")
ax.plot(self.global_batches, self.loss_cum["meta"], label="MD Tower")
for x in self.epoch_markers:
ax.axvline(x=x, color="gray", linestyle="--")
ax.set_ylabel("Cumulative Loss")
ax.legend()
# Epoch Loss
ax = self.axs[1]
ax.clear()
ax.plot(self.epoch_batches, self.loss_epoch_batch["fusion"], label="Fusion")
ax.plot(self.epoch_batches, self.loss_epoch_batch["image"], label="Image Tower")
ax.plot(self.epoch_batches, self.loss_epoch_batch["meta"], label="MD Tower")
ax.set_ylabel(f"Epoch {self.current_epoch+1} Loss")
ax.set_xlabel("Batch (Epoch)")
ax.legend()
# Cumulative Accuracy
ax = self.axs[2]
ax.clear()
ax.plot(self.global_batches, self.acc_cum["fusion"], label="Fusion")
ax.plot(self.global_batches, self.acc_cum["image"], label="Image Tower")
ax.plot(self.global_batches, self.acc_cum["meta"], label="MD Tower")
for x in self.epoch_markers:
ax.axvline(x=x, color="gray", linestyle="--")
ax.set_ylabel("Cumulative Accuracy")
ax.legend()
# Epoch Accuracy
ax = self.axs[3]
ax.clear()
ax.plot(self.epoch_batches, self.acc_epoch_batch["fusion"], label="Fusion")
ax.plot(self.epoch_batches, self.acc_epoch_batch["image"], label="Image Tower")
ax.plot(self.epoch_batches, self.acc_epoch_batch["meta"], label="MD Tower")
ax.set_ylabel(f"Epoch {self.current_epoch+1} Accuracy")
ax.set_xlabel("Batch (Epoch)")
ax.legend()
plt.pause(0.01)
def _draw_epoch_bars(self):
# Bar charts per tower
for i, key in enumerate(["fusion", "image", "meta"]):
ax = self.axs[4 + i]
ax.clear()
data = self.epoch_stats[key]
epochs = list(range(1, len(data) + 1))
tp = [d["tp"] for d in data]
fp = [d["fp"] for d in data]
tn = [d["tn"] for d in data]
fn = [d["fn"] for d in data]
width = 0.2
ax.bar([e - width for e in epochs], tp, width, label="TP")
ax.bar(epochs, fp, width, label="FP")
ax.bar([e + width for e in epochs], tn, width, label="TN")
ax.bar([e + 2 * width for e in epochs], fn, width, label="FN")
ax.set_title(f"{key.title()} Tower Stats")
ax.set_xlabel("Epoch")
ax.set_ylabel("Count")
ax.legend()
plt.pause(0.01)
+1 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import torch
import torch.nn as nn
from classes.SE_attention import SEBlock, SEGateLogger
from classes.v2.SE_attention import SEBlock, SEGateLogger
class Bridge(nn.Module):
+24 -3
View File
@@ -10,9 +10,30 @@ import torch
from PIL import Image, ImageDraw
from torchvision import transforms
from classes.geometry_features import compute_geometry_features, disc_cup_from_mask_image
from classes.refuge_classification import _geometry_from_mask
from classes.unet_segmenter import UNetSegmenter
from classes.v2.geometry_features import compute_geometry_features, disc_cup_from_mask_image
from classes.v2.unet_segmenter import UNetSegmenter
def _geometry_from_mask(mask: np.ndarray, scale: float) -> Dict:
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,
}
class UNetImageCropper:
+24
View File
@@ -219,6 +219,15 @@ def _set_single_phase(model: SingleEyeHT, phase: str) -> None:
# Ablation modes have no fusion bridge; fused_warmup is meaningless — treat as tower_warmup
if bridge_mode in ("image_only", "metadata_only") and phase == "fused_warmup":
phase = "tower_warmup"
if phase == "md_warmup":
_set_requires_grad(model.img_tower, False)
_set_requires_grad(model.md_tower, True)
_set_requires_grad(model.bridge.classifier_img, False)
_set_requires_grad(model.bridge.classifier_md, True)
_set_requires_grad(model.bridge.W_img, False)
_set_requires_grad(model.bridge.W_md, False)
_set_requires_grad(model.bridge.classifier_fused, False)
return
if phase == "tower_warmup":
_set_requires_grad(model.img_tower, bridge_mode != "metadata_only")
_set_requires_grad(model.md_tower, bridge_mode != "image_only")
@@ -282,12 +291,27 @@ def train_single_epoch(
x = batch.get("image_1")
m = batch.get("matrix_1")
y = batch.get("label_1")
if phase == "md_warmup":
if not torch.is_tensor(m):
continue
m = m.to(device)
y = _to_label_tensor(y, device)
md_feats = model.md_tower(m)
logits = model.bridge.classifier_md(md_feats)
loss = F.cross_entropy(logits, y)
opt.zero_grad(); loss.backward(); opt.step()
bs = y.shape[0]
total_loss += float(loss.item()) * bs
total_correct += int((logits.argmax(1) == y).sum())
total_n += bs
continue
if not torch.is_tensor(x) or not torch.is_tensor(m):
continue
x = x.to(device)
m = m.to(device)
y = _to_label_tensor(y, device)
bridge_mode = model.bridge.mode
img_feats = None if bridge_mode == "metadata_only" else model.img_tower(x)
md_feats = None if bridge_mode == "image_only" else model.md_tower(m)
+14 -3
View File
@@ -33,10 +33,21 @@ def _nearest_pachy_key(x: float) -> int:
return int(_PACHY_KEYS[idx])
# Ratio derived from patients with both Pneumatic and Perkins readings (n=41, OD+OS combined).
# Pneumatic / Perkins mean ratio = 1.158; applied to Perkins-only rows to put them on the
# Pneumatic scale before IOP_corr is computed.
_PERKINS_TO_PNEUMATIC_RATIO: float = 1.158
def _pick_iop(row: pd.Series) -> float:
"""Prefer Pneumatic, else Perkins; may return NaN."""
raw = row["Pneumatic"] if not pd.isna(row.get("Pneumatic", np.nan)) else row.get("Perkins", np.nan)
return float(raw) if not pd.isna(raw) else np.nan
"""Prefer Pneumatic; scale Perkins to Pneumatic scale if Pneumatic is absent."""
pneumatic = row.get("Pneumatic", np.nan)
if not pd.isna(pneumatic):
return float(pneumatic)
perkins = row.get("Perkins", np.nan)
if not pd.isna(perkins):
return float(perkins) * _PERKINS_TO_PNEUMATIC_RATIO
return np.nan
def _correct_iop(raw_iop: float, pachy: float) -> float:
+2 -2
View File
@@ -7,8 +7,8 @@ import torch
from torch import nn
from torchvision import transforms
from classes.backbones import BACKBONES, list_names, load_backbone_weights
from classes.SE_attention import SEBlock
from classes.v2.backbones import BACKBONES, list_names, load_backbone_weights
from classes.v2.SE_attention import SEBlock
from classes.v2.data_bundle import DataBundle
+1 -1
View File
@@ -7,7 +7,7 @@ from PIL import Image
from torchvision import transforms
from classes.backbones import BACKBONES
from classes.v2.backbones import BACKBONES
IMAGENET_MEAN: Tuple[float, float, float] = (0.485, 0.456, 0.406)
+60 -14
View File
@@ -206,6 +206,9 @@ class V2HyperTower:
help="Single-eye model tower warmup (overrides --warmup-tower-epochs).")
ap.add_argument("--single-warmup-fused-epochs", type=int, default=None,
help="Single-eye model fused warmup (overrides --warmup-fused-epochs).")
ap.add_argument("--warmup-md-epochs", type=int, default=0,
help="MD-only warmup epochs before tower warmup. Trains only md_tower + "
"classifier_md (no CNN forward pass, so 50-100 epochs is cheap).")
ap.add_argument("--bilat-warmup-tower-epochs", type=int, default=None,
help="Bilateral model tower warmup (overrides --warmup-tower-epochs).")
ap.add_argument("--bilat-warmup-fused-epochs", type=int, default=None,
@@ -424,7 +427,8 @@ class V2HyperTower:
if getattr(args, "single_warmup_fused_epochs", None) is not None
else int(_global_warmup_fused) if _global_warmup_fused is not None else 2
)
_total_epochs = _warmup_tower + _warmup_fused + int(args.epochs) + fusion_epochs
_warmup_md = int(getattr(args, "warmup_md_epochs", 0))
_total_epochs = _warmup_md + _warmup_tower + _warmup_fused + int(args.epochs) + fusion_epochs
# sample IDs depend on mode: single uses eye IDs, others use patient IDs
if tower_mode in ("single", "classic"):
@@ -572,6 +576,7 @@ class V2HyperTower:
"run_id": run_name,
"backbone": args.backbone,
"epochs": args.epochs,
"warmup_md_epochs": getattr(args, "warmup_md_epochs", 0),
"warmup_tower_epochs": args.warmup_tower_epochs,
"warmup_fused_epochs": args.warmup_fused_epochs,
"single_warmup_tower_epochs": args.single_warmup_tower_epochs,
@@ -659,6 +664,7 @@ class V2HyperTower:
if getattr(args, "bilat_warmup_fused_epochs", None) is not None
else int(global_warmup_fused) if global_warmup_fused is not None else 3
)
single_warmup_md = int(getattr(args, "warmup_md_epochs", 0)) if run_single else 0
if not run_single:
single_warmup_tower = 0
single_warmup_fused = 0
@@ -666,7 +672,7 @@ class V2HyperTower:
bilat_warmup_tower = 0
bilat_warmup_fused = 0
main_epochs = int(args.epochs)
total_single_epochs = (single_warmup_tower + single_warmup_fused + main_epochs) if run_single else 0
total_single_epochs = (single_warmup_md + single_warmup_tower + single_warmup_fused + main_epochs) if run_single else 0
total_bilat_epochs = (bilat_warmup_tower + bilat_warmup_fused + main_epochs) if run_bilat else 0
total_epochs = max(total_single_epochs, total_bilat_epochs)
@@ -744,6 +750,7 @@ class V2HyperTower:
train_single_loader = None
train_eval_loader = None # non-shuffled, no sampler — for per-epoch train logging
train_bilat_loader = None
md_only_loader = None # image-free loader for md_warmup phase
if run_single:
single_sampler = build_balanced_sampler(eye_train) if use_balanced else None
train_single_loader = make_loader(
@@ -761,6 +768,20 @@ class V2HyperTower:
shuffle=False,
**loader_kw,
)
if single_warmup_md > 0:
# MD-only loader: drop image_1 so PIL never opens files during md_warmup.
# Always use balanced sampling for md_warmup — MD features alone are weaker
# than images and collapse to majority class without class balancing.
slots_md_only = {k: v for k, v in slots_eye.items() if k != "image_1"}
md_warmup_sampler = single_sampler if single_sampler is not None else build_balanced_sampler(eye_train)
md_only_loader = make_loader(
eye_train, slots_md_only,
image_transform=None,
image_preprocessor=None,
shuffle=True,
sampler=md_warmup_sampler,
**loader_kw,
)
if run_bilat:
bilat_sampler = build_balanced_sampler(bilat_train) if use_balanced else None
train_bilat_loader = make_loader(
@@ -907,7 +928,7 @@ class V2HyperTower:
print(
f" [fold {fold+1}] single_train_n={len(eye_train)} (eye-level) "
f"val_n={len(bilat_val)} "
f"single_warmup={single_warmup_tower}+{single_warmup_fused} total={total_single_epochs}",
f"single_warmup=md{single_warmup_md}+twr{single_warmup_tower}+fus{single_warmup_fused} total={total_single_epochs}",
flush=True,
)
else:
@@ -919,17 +940,20 @@ class V2HyperTower:
)
# ---- epoch loop ------------------------------------------------
_prev_phase_single = "inactive" # used to detect md_warmup → next phase transition
for epoch in range(total_epochs):
if not run_single:
phase_single, main_epoch_single, single_active = "inactive", 0, False
elif epoch < single_warmup_tower:
elif epoch < single_warmup_md:
phase_single, main_epoch_single, single_active = "md_warmup", 0, True
elif epoch < (single_warmup_md + single_warmup_tower):
phase_single, main_epoch_single, single_active = "tower_warmup", 0, True
elif epoch < (single_warmup_tower + single_warmup_fused):
elif epoch < (single_warmup_md + single_warmup_tower + single_warmup_fused):
phase_single, main_epoch_single, single_active = "fused_warmup", 0, True
elif epoch < total_single_epochs:
phase_single, main_epoch_single, single_active = (
"main",
epoch - single_warmup_tower - single_warmup_fused + 1,
epoch - single_warmup_md - single_warmup_tower - single_warmup_fused + 1,
True,
)
else:
@@ -951,8 +975,9 @@ class V2HyperTower:
phase_bilat, main_epoch_bilat, bilat_active = "done", main_epochs, False
if run_single and single_active:
_active_loader = md_only_loader if phase_single == "md_warmup" else train_single_loader
sl_loss, sl_acc = train_single_epoch(
single, train_single_loader, opt_single, device,
single, _active_loader, opt_single, device,
phase=phase_single, bcd_prob=float(args.bcd_prob),
)
else:
@@ -966,7 +991,9 @@ class V2HyperTower:
else:
bl_loss, bl_acc = nan, nan
if run_single and tower_mode == "single":
_skip_val_eval = (phase_single == "md_warmup")
if run_single and tower_mode == "single" and not _skip_val_eval:
y_cl, p_cl, p_cl_img, p_cl_md = collect_probs_single_components(
single, val_loader, device, aggregate_patient=False
)
@@ -980,7 +1007,7 @@ class V2HyperTower:
en_acc = en_auc = nan
en_n = 0
en_acc_img = en_acc_md = en_auc_img = en_auc_md = nan
elif run_single and tower_mode == "ensemble":
elif run_single and tower_mode == "ensemble" and not _skip_val_eval:
(y_en,
_p_en_f_od, _p_en_i_od, _p_en_m_od,
_p_en_f_os, _p_en_i_os, _p_en_m_os,
@@ -1010,7 +1037,7 @@ class V2HyperTower:
cl_acc_img = cl_acc_md = en_acc_img = en_acc_md = nan
cl_auc_img = cl_auc_md = en_auc_img = en_auc_md = nan
if run_bilat:
if run_bilat and not _skip_val_eval:
y_bi, p_bi, p_bi_img, p_bi_md = collect_probs_bilateral_components(
bilateral, val_loader, device
)
@@ -1034,7 +1061,7 @@ class V2HyperTower:
p_cl_h = p_cl_h_img = p_cl_h_md = _z2
p_en_h = p_en_h_img = p_en_h_md = _z2
if holdout_loader is not None:
if holdout_loader is not None and not _skip_val_eval:
if run_single and tower_mode == "single":
y_cl_h, p_cl_h, p_cl_h_img, p_cl_h_md = collect_probs_single_components(
single, holdout_loader, device, aggregate_patient=False
@@ -1092,7 +1119,7 @@ class V2HyperTower:
tr_fe_corr = tr_fe_err = tr_n = 0
y_tr = np.array([], dtype=np.int64)
p_tr_f = p_tr_i = p_tr_m = np.zeros((0, num_classes), dtype=np.float32)
if run_single and train_eval_loader is not None:
if run_single and train_eval_loader is not None and not _skip_val_eval:
y_tr, p_tr_f, p_tr_i, p_tr_m, tr_ids = collect_probs_eye_level(
single, train_eval_loader, device, return_ids=True
)
@@ -1284,16 +1311,33 @@ class V2HyperTower:
**cm_row,
}, optional_cols=epoch_fields)
# ---- md_warmup progress bar (replaces per-epoch print) --------
if phase_single == "md_warmup":
_bar_w = 30
_filled = int(_bar_w * (epoch + 1) / single_warmup_md)
_bar = "#" * _filled + "-" * (_bar_w - _filled)
_bar_msg = (
f" [fold {fold+1}] md_warmup [{_bar}] "
f"{epoch + 1}/{single_warmup_md} loss={sl_loss:.4f}"
)
print(f"\r{_bar_msg}", end="", flush=True)
fold_logger.info(_bar_msg)
_prev_phase_single = phase_single
continue # skip normal log block entirely
if _prev_phase_single == "md_warmup":
print() # seal the progress bar line
if args.log_every > 0 and (epoch + 1) % args.log_every == 0:
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 ""
# Human-readable phase progress for console logs.
if phase_single == "tower_warmup":
single_phase_epoch = epoch + 1
single_phase_epoch = epoch - single_warmup_md + 1
single_phase_total = single_warmup_tower
elif phase_single == "fused_warmup":
single_phase_epoch = epoch - single_warmup_tower + 1
single_phase_epoch = epoch - single_warmup_md - single_warmup_tower + 1
single_phase_total = single_warmup_fused
else:
single_phase_epoch = main_epoch_single
@@ -1343,6 +1387,8 @@ class V2HyperTower:
print(msg, flush=True)
fold_logger.info(msg)
_prev_phase_single = phase_single
fold_logger.close()
if args.save_checkpoints:
File diff suppressed because one or more lines are too long
@@ -45,6 +45,13 @@ def main():
if not first_run:
cli.append("--persist-img-crop-cache")
args = base_parser.parse_args(cli)
# Skip if this mode is already fully complete.
if args.run_name:
tm_dir = Path(args.output_root) / args.run_name / eval_mode / tower_mode
if (tm_dir / "summary.json").exists():
print(f"[compare] {eval_mode}:{tower_mode} already complete — skipping.")
first_run = False # treat as done so cache is preserved for later runs
continue
V2HyperTower(args).run()
first_run = False
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -13,7 +13,7 @@ REPO_ROOT = Path(__file__).resolve().parents[3]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from classes.unet_segmenter import UNetSegmenter
from classes.v2.unet_segmenter import UNetSegmenter
def parse_args() -> argparse.Namespace:
+68
View File
@@ -0,0 +1,68 @@
#!/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."
+20
View File
@@ -0,0 +1,20 @@
#!/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
@@ -0,0 +1,24 @@
#!/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 ==="
@@ -611,84 +611,70 @@ def run_fusion_event_analysis(
pred_i = pi.argmax(axis=1)
pred_m = pm.argmax(axis=1)
corrections = (pred_f == y_true) & (pred_i != y_true) & (pred_m != y_true)
errors = (pred_f != y_true) & (pred_i == y_true) & (pred_m == y_true)
n_corr = corrections.sum()
n_err = errors.sum()
both_wrong = ((pred_i != y_true) & (pred_m != y_true)).sum()
both_correct = ((pred_i == y_true) & (pred_m == y_true)).sum()
# confidence of the predicted class for each head
conf_f = np.take_along_axis(pf, pred_f[:, None], axis=1).squeeze(1)
conf_i = np.take_along_axis(pi, pred_i[:, None], axis=1).squeeze(1)
conf_m = np.take_along_axis(pm, pred_m[:, None], axis=1).squeeze(1)
# how much did fusion shift confidence vs the average of the two towers?
conf_delta = conf_f - 0.5 * (conf_i + conf_m)
print(f" N={N} corrections={n_corr} errors={n_err} ratio={n_corr}/{n_err}", flush=True)
print(f" correction rate: {n_corr}/{both_wrong} = {n_corr/max(both_wrong,1):.2%} of both-wrong cases", flush=True)
print(f" error rate: {n_err}/{both_correct} = {n_err/max(both_correct,1):.2%} of both-correct cases", flush=True)
f_ok = pred_f == y_true
i_ok = pred_i == y_true
m_ok = pred_m == y_true
# ---- cache intermediate hm/hi vectors for all patients ----
model.eval()
hm1_list, hm2_list, hi1_list, hi2_list = [], [], [], []
with torch.no_grad():
for batch in loader:
x1 = batch.get("image_1"); m1 = batch.get("matrix_1")
x2 = batch.get("image_2"); m2 = batch.get("matrix_2")
if not (torch.is_tensor(x1) and torch.is_tensor(m1)):
continue
hi1 = model.bridge.ln_img(model.bridge.W_img(model.img_tower(x1.to(device))))
hi2 = model.bridge.ln_img(model.bridge.W_img(model.img_tower(x2.to(device))))
hm1 = model.bridge.ln_md(model.bridge.W_md(model.md_tower(m1.to(device))))
hm2 = model.bridge.ln_md(model.bridge.W_md(model.md_tower(m2.to(device))))
hi1_list.append(hi1.cpu()); hi2_list.append(hi2.cpu())
hm1_list.append(hm1.cpu()); hm2_list.append(hm2.cpu())
# 6 non-trivial bridge-effect event types
full_correction = f_ok & ~i_ok & ~m_ok # both towers wrong → fused right
img_assist = f_ok & ~i_ok & m_ok # img wrong, md right → fused right (md carried it)
md_assist = f_ok & i_ok & ~m_ok # md wrong, img right → fused right (img carried it)
full_error = ~f_ok & i_ok & m_ok # both towers right → fused wrong
img_drag = ~f_ok & ~i_ok & m_ok # img wrong, md right → fused wrong (img dragged it down)
md_drag = ~f_ok & i_ok & ~m_ok # md wrong, img right → fused wrong (md dragged it down)
concordant_ok = f_ok & i_ok & m_ok
concordant_bad = ~f_ok & ~i_ok & ~m_ok
hi1 = torch.cat(hi1_list) # [N, fusion_dim]
hi2 = torch.cat(hi2_list)
hm1 = torch.cat(hm1_list) # [N, fusion_dim]
hm2 = torch.cat(hm2_list)
event_labels = [
"full correction\n(both wrong→fused right)",
"img assist\n(img wrong, md right→right)",
"md assist\n(md wrong, img right→right)",
"full error\n(both right→fused wrong)",
"img drag\n(img wrong, md right→wrong)",
"md drag\n(md wrong, img right→wrong)",
]
event_masks = [full_correction, img_assist, md_assist, full_error, img_drag, md_drag]
event_colors = ["#2ca02c", "#98df8a", "#b5d46e", "#d62728", "#ff9896", "#ffbf9b"]
event_keys = ["full_correction", "img_assist", "md_assist",
"full_error", "img_drag", "md_drag"]
counts = [int(m.sum()) for m in event_masks]
hm1_mean = hm1.mean(dim=0, keepdim=True)
hm2_mean = hm2.mean(dim=0, keepdim=True)
print(f" N={N}", flush=True)
for label, count in zip(event_labels, counts):
print(f" {label.replace(chr(10), ' '):55s}: {count}", flush=True)
n_corr, n_err = counts[0], counts[3]
ratio_str = f"{n_corr}/{n_err}" if n_err > 0 else f"{n_corr}/0"
print(f" full correction/error ratio: {ratio_str}", flush=True)
print(f" conf_delta mean={conf_delta.mean():+.4f} median={np.median(conf_delta):+.4f}",
flush=True)
# ---- for each patient: compare logit[true_class] with real hm vs mean hm ----
gains = []
with torch.no_grad():
for idx in range(N):
true_cls = int(y_true[idx])
# patient-level average of OD/OS fused vectors (SE skipped: hard to replicate outside forward)
fused_real = (hi1[idx:idx+1] * hm1[idx:idx+1] + hi2[idx:idx+1] * hm2[idx:idx+1]) * 0.5
fused_mean = (hi1[idx:idx+1] * hm1_mean + hi2[idx:idx+1] * hm2_mean) * 0.5
logit_real = model.bridge.classifier_fused(fused_real.to(device))
logit_mean = model.bridge.classifier_fused(fused_mean.to(device))
gain = (logit_real[0, true_cls] - logit_mean[0, true_cls]).item()
gains.append(gain)
gains = np.array(gains)
if n_corr > 0:
corr_gains = gains[corrections]
helped = (corr_gains > 0).sum()
print(f"\n Fusion corrections — MD gate gain vs mean gate:", flush=True)
print(f" mean gain = {corr_gains.mean():+.4f} median = {np.median(corr_gains):+.4f}", flush=True)
print(f" real MD helped {helped}/{n_corr} correction patients ({helped/n_corr:.0%})", flush=True)
if n_err > 0:
err_gains = gains[errors]
print(f"\n Fusion errors — MD gate gain vs mean gate:", flush=True)
print(f" mean gain = {err_gains.mean():+.4f} median = {np.median(err_gains):+.4f}", flush=True)
# ---- save CSV ----
# ---- CSV ----
import csv
event_type = np.where(concordant_ok, "concordant_correct",
np.where(concordant_bad, "concordant_wrong", "other")).astype(object)
for mask, key in zip(event_masks, event_keys):
event_type[mask] = key
rows = []
for idx in range(N):
rows.append({
"patient_idx": idx,
"y_true": int(y_true[idx]),
"pred_fused": int(pred_f[idx]),
"pred_img": int(pred_i[idx]),
"pred_md": int(pred_m[idx]),
"conf_fused": float(pf[idx].max()),
"conf_img": float(pi[idx].max()),
"conf_md": float(pm[idx].max()),
"is_correction": bool(corrections[idx]),
"is_error": bool(errors[idx]),
"md_gate_gain": float(gains[idx]),
"patient_idx": idx,
"y_true": int(y_true[idx]),
"pred_fused": int(pred_f[idx]),
"pred_img": int(pred_i[idx]),
"pred_md": int(pred_m[idx]),
"conf_fused": float(conf_f[idx]),
"conf_img": float(conf_i[idx]),
"conf_md": float(conf_m[idx]),
"conf_delta": float(conf_delta[idx]),
"event_type": event_type[idx],
})
csv_path = out_dir / "fusion_events.csv"
with csv_path.open("w", newline="") as f:
@@ -697,25 +683,61 @@ def run_fusion_event_analysis(
writer.writerows(rows)
print(f" Saved → {csv_path}", flush=True)
# ---- chart ----
fig, axes = plt.subplots(1, 2, figsize=(11, 4))
# ---- plot ----
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
categories = ["corrections\n(both wrong→fused right)", "errors\n(both right→fused wrong)"]
counts = [int(n_corr), int(n_err)]
axes[0].bar(categories, counts, color=["#e05c5c", "#5c9ee0"], width=0.5)
# Panel 1: stacked bar — positive events vs negative events
pos_counts = counts[:3]
neg_counts = counts[3:]
pos_colors = event_colors[:3]
neg_colors = event_colors[3:]
for bar_x, bar_counts, bar_colors in ((0, pos_counts, pos_colors),
(1, neg_counts, neg_colors)):
bot = 0
for c, col in zip(bar_counts, bar_colors):
axes[0].bar(bar_x, c, bottom=bot, color=col, width=0.5)
if c > 0:
axes[0].text(bar_x, bot + c / 2, str(c), ha="center", va="center",
fontsize=9, fontweight="bold")
bot += c
axes[0].set_xticks([0, 1])
axes[0].set_xticklabels(["Positive\nevents", "Negative\nevents"])
axes[0].set_ylabel("Count")
axes[0].set_title(f"Fusion Events (N={N})")
for i, v in enumerate(counts):
axes[0].text(i, v + 0.1, str(v), ha="center", fontsize=11)
patches = [mpatches.Patch(color=c, label=l.replace("\n", " "))
for c, l in zip(event_colors, event_labels)]
axes[0].legend(handles=patches, fontsize=6, loc="upper right")
if n_corr > 0:
axes[1].hist(gains[corrections], bins=10, alpha=0.7, color="#e05c5c", label=f"corrections (n={n_corr})")
if n_err > 0:
axes[1].hist(gains[errors], bins=10, alpha=0.7, color="#5c9ee0", label=f"errors (n={n_err})")
axes[1].axvline(0, color="black", linewidth=0.8)
axes[1].set_xlabel("MD gate gain vs mean gate\n(logit[true class]: real mean)")
axes[1].set_title("Does real MD help the fused prediction?")
axes[1].legend(fontsize=9)
# Panel 2: conf_delta boxplot per event type (only non-empty)
box_data = [conf_delta[m] for m in event_masks if m.sum() > 0]
box_labels = [l.split("\n")[0] for m, l in zip(event_masks, event_labels) if m.sum() > 0]
box_cols = [c for m, c in zip(event_masks, event_colors) if m.sum() > 0]
if box_data:
bp = axes[1].boxplot(box_data, patch_artist=True, widths=0.5)
for patch, color in zip(bp["boxes"], box_cols):
patch.set_facecolor(color)
axes[1].set_xticks(range(1, len(box_labels) + 1))
axes[1].set_xticklabels(box_labels, rotation=35, ha="right", fontsize=7)
axes[1].axhline(0, color="black", linewidth=0.8, linestyle="--")
axes[1].set_ylabel("conf_delta\n(fused avg(img, md))")
axes[1].set_title("Confidence delta by event type")
# Panel 3: img vs md confidence space, coloured by event type
for mask, color, label in zip(event_masks, event_colors, event_labels):
if mask.sum() > 0:
axes[2].scatter(conf_i[mask], conf_m[mask], c=color,
label=label.split("\n")[0], alpha=0.85, s=45, edgecolors="none")
if concordant_ok.sum() > 0:
axes[2].scatter(conf_i[concordant_ok], conf_m[concordant_ok],
c="lightgrey", alpha=0.4, s=20, edgecolors="none", label="concordant correct")
if concordant_bad.sum() > 0:
axes[2].scatter(conf_i[concordant_bad], conf_m[concordant_bad],
c="darkgrey", alpha=0.4, s=20, edgecolors="none", label="concordant wrong")
axes[2].plot([0, 1], [0, 1], "k--", linewidth=0.5, alpha=0.4)
axes[2].set_xlabel("conf_img")
axes[2].set_ylabel("conf_md")
axes[2].set_title("Tower confidence space\ncoloured by fusion event")
axes[2].legend(fontsize=6, loc="lower right")
fig.tight_layout()
fig.savefig(out_dir / "fusion_events.png", dpi=150)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,652 @@
"""
Plot predicted-probability strip charts for V2 runs.
Three styles available via --style:
strips (default)
X = true class, Y = P(Glaucoma), color = true class × fold shade.
Works for binary and multiclass.
confidence
X = predicted class (major) subdivided by true class (minor sub-column).
Y = model confidence in its own prediction (P of the predicted class).
Color = true class × fold shade.
Makes high-confidence mistakes immediately visible.
triangle (multiclass only)
Ternary / simplex plot. Each corner = 100% probability for one class.
Every sample is a dot placed at its softmax probability vector
(p_H, p_G, p_S) using barycentric coordinates. The centroid is maximum
uncertainty (1/3, 1/3, 1/3). Correctly classified samples cluster near
their true-class corner; mistakes drift toward the wrong corner.
Colour families (light dark = fold 0 fold N-1):
Blues = Healthy / Normal eyes
Reds = Glaucoma eyes
Greens = Suspect eyes
Usage
-----
python scripts/output_analysis/visualizations/plot_prob_strips.py \
--run-dir analysis_data/v2.3_single_multiclass_nocrop/multiclass/single \
--style triangle --head fused
"""
from __future__ import annotations
import argparse
import re
from pathlib import Path
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
import numpy as np
import pandas as pd
# ── colour families ────────────────────────────────────────────────────────────
_CLASS_FAMILIES = {
0: ("#aac4ff", "#0a3d91"), # blues (healthy / normal)
1: ("#ffaaaa", "#8b0000"), # reds (glaucoma)
2: ("#aaffcc", "#1a6b3c"), # greens (suspect)
}
_CLASS_LABELS = {
0: "Healthy",
1: "Glaucoma",
2: "Suspect",
}
_CLASS_LABELS_SHORT = {
0: "H",
1: "G",
2: "S",
}
def _lerp_hex(c1: str, c2: str, t: float) -> tuple:
def h(c): return tuple(int(c.lstrip("#")[i*2:i*2+2], 16) / 255 for i in range(3))
r1, g1, b1 = h(c1)
r2, g2, b2 = h(c2)
return (r1 + t*(r2-r1), g1 + t*(g2-g1), b1 + t*(b2-b1))
def _fold_colours(n_folds: int) -> dict[int, dict[int, tuple]]:
out: dict[int, dict[int, tuple]] = {}
for cls, (light, dark) in _CLASS_FAMILIES.items():
out[cls] = {}
for f in range(n_folds):
t = f / max(n_folds - 1, 1)
out[cls][f] = _lerp_hex(light, dark, t)
return out
def _load_folds(run_dir: Path, head: str) -> list[pd.DataFrame]:
fold_dirs = sorted(
[p for p in run_dir.glob("fold*") if p.is_dir() and re.search(r"\d+", p.name)],
key=lambda p: int(re.search(r"\d+", p.name).group()),
)
if not fold_dirs:
raise FileNotFoundError(f"No fold* directories found under {run_dir}")
frames = []
for fd in fold_dirs:
csv = fd / "predictions_classic.csv"
if not csv.exists():
print(f" [warn] {csv} not found, skipping")
continue
df = pd.read_csv(csv)
df["_fold"] = int(re.search(r"\d+", fd.name).group())
frames.append(df)
return frames
def _detect_num_classes(df: pd.DataFrame, head: str) -> int:
return len([c for c in df.columns if c.startswith(f"prob_{head}_c")])
def _draw_grid_legend(ax_leg: plt.Axes, n_folds: int, classes: list[int],
colours: dict, title: str = "True class") -> None:
"""Rows = folds, columns = classes grid of coloured dots."""
from matplotlib.patches import FancyBboxPatch
ax_leg.axis("off")
col_xs = np.linspace(0.55, 0.88, len(classes)) if len(classes) > 1 else [0.72]
row_ys = np.linspace(0.88, 0.05, n_folds + 1)
header_y, dot_ys = row_ys[0], row_ys[1:]
for ci, cls in enumerate(classes):
ax_leg.text(col_xs[ci], header_y, _CLASS_LABELS_SHORT.get(cls, f"C{cls}"),
ha="center", va="bottom", fontsize=8, fontweight="bold",
transform=ax_leg.transAxes)
for fi in range(n_folds):
y = dot_ys[fi]
ax_leg.text(0.05, y, f"Fold {fi}", ha="left", va="center", fontsize=9,
transform=ax_leg.transAxes)
for ci, cls in enumerate(classes):
ax_leg.scatter([col_xs[ci]], [y], color=colours[cls][fi], s=70, zorder=3,
transform=ax_leg.transAxes, clip_on=False)
ax_leg.add_patch(FancyBboxPatch((0, 0), 1, 1, boxstyle="round,pad=0.02",
linewidth=0.8, edgecolor="#aaaaaa",
facecolor="#f9f9f9", zorder=0,
transform=ax_leg.transAxes))
ax_leg.set_title(title, fontsize=9, pad=4)
# ── strips style ───────────────────────────────────────────────────────────────
def plot_strip(run_dir: Path, head: str = "fused", out_dir: Path | None = None,
jitter_strength: float = 0.08) -> Path:
"""X = true class, Y = P(Glaucoma)."""
frames = _load_folds(run_dir, head)
all_true = sorted({int(v) for df in frames for v in df["y_true"]})
n_folds = len(frames)
colours = _fold_colours(n_folds)
rng = np.random.default_rng(seed=0)
prob_col = f"prob_{head}_c1"
x_pos = {cls: i for i, cls in enumerate(all_true)}
leg_width = 0.8 + 0.55 * len(all_true)
fig = plt.figure(figsize=(max(6, 2.2 * len(all_true) + 1), 7))
gs = fig.add_gridspec(1, 2, width_ratios=[max(6, 2.2 * len(all_true)), leg_width],
wspace=0.08)
ax = fig.add_subplot(gs[0])
ax_leg = fig.add_subplot(gs[1])
fig.suptitle(f"{run_dir.parent.parent.name} — P(Glaucoma) [{head}]",
fontsize=11, y=1.01)
for fold_idx, df in enumerate(frames):
for cls in all_true:
sub = df[df["y_true"] == cls]
if sub.empty:
continue
probs = sub[prob_col].values
jitter = rng.uniform(-jitter_strength, jitter_strength, size=len(probs))
ax.scatter(x_pos[cls] + jitter, probs, color=colours[cls][fold_idx],
s=45, alpha=0.88, linewidths=0, zorder=3)
ax.set_xticks(list(x_pos.values()))
ax.set_xticklabels([_CLASS_LABELS.get(c, f"C{c}") for c in all_true], fontsize=12)
ax.set_xlim(-0.5, len(all_true) - 0.5)
ax.set_ylim(-0.05, 1.05)
ax.set_ylabel("Predicted P(Glaucoma)", fontsize=12)
ax.set_xlabel("True class", fontsize=12)
ax.axhline(0.5, color="grey", linestyle="--", linewidth=0.8, alpha=0.5)
ax.grid(axis="y", linestyle=":", alpha=0.4)
_draw_grid_legend(ax_leg, n_folds, all_true, colours, title="Legend")
fig.tight_layout()
return _save(fig, out_dir or run_dir / "plots", f"prob_strips_{head}.png")
# ── confidence style ───────────────────────────────────────────────────────────
def plot_confidence(run_dir: Path, head: str = "fused", out_dir: Path | None = None,
jitter_strength: float = 0.06) -> Path:
"""
X = predicted class (major) × true class (minor sub-column).
Y = model confidence = P(predicted class).
Color = true class × fold shade.
"""
frames = _load_folds(run_dir, head)
num_cls = _detect_num_classes(frames[0], head)
all_cls = list(range(num_cls))
all_true = sorted({int(v) for df in frames for v in df["y_true"]})
n_folds = len(frames)
colours = _fold_colours(n_folds)
rng = np.random.default_rng(seed=0)
# Sub-column spacing within each predicted-class group
# E.g. for 3 classes: sub-offsets at -0.25, 0, +0.25
n_sub = len(all_true)
sub_spacing = 0.22
sub_offsets = np.linspace(-(n_sub - 1) * sub_spacing / 2,
(n_sub - 1) * sub_spacing / 2,
n_sub)
sub_off = {cls: sub_offsets[i] for i, cls in enumerate(all_true)}
# Major x positions for each predicted class, spaced so sub-columns don't bleed
group_gap = sub_spacing * n_sub + 0.35
major_x = {pc: i * group_gap for i, pc in enumerate(all_cls)}
leg_width = 0.8 + 0.55 * n_sub
fig_w = max(7, group_gap * len(all_cls) * 1.8 + 1)
fig = plt.figure(figsize=(fig_w, 7))
gs = fig.add_gridspec(1, 2, width_ratios=[fig_w - leg_width, leg_width],
wspace=0.08)
ax = fig.add_subplot(gs[0])
ax_leg = fig.add_subplot(gs[1])
fig.suptitle(f"{run_dir.parent.parent.name} — Prediction confidence [{head}]",
fontsize=11, y=1.01)
for fold_idx, df in enumerate(frames):
prob_cols = [f"prob_{head}_c{c}" for c in all_cls]
pred_col = f"pred_{head}"
for true_cls in all_true:
sub = df[df["y_true"] == true_cls].copy()
if sub.empty:
continue
for pred_cls in all_cls:
rows = sub[sub[pred_col] == pred_cls]
if rows.empty:
continue
# confidence = probability assigned to the predicted class
conf = rows[f"prob_{head}_c{pred_cls}"].values
x_base = major_x[pred_cls] + sub_off[true_cls]
jitter = rng.uniform(-jitter_strength * 0.5,
jitter_strength * 0.5, size=len(conf))
ax.scatter(x_base + jitter, conf, color=colours[true_cls][fold_idx],
s=45, alpha=0.88, linewidths=0, zorder=3)
# X-axis: major ticks with predicted-class labels, minor sub-column markers
ax.set_xlim(-group_gap * 0.5, group_gap * len(all_cls) - group_gap * 0.5)
ax.set_xticks([major_x[pc] for pc in all_cls])
ax.set_xticklabels([_CLASS_LABELS.get(pc, f"C{pc}") for pc in all_cls], fontsize=12)
# Light vertical separators between predicted-class groups
for i in range(1, len(all_cls)):
sep_x = (major_x[all_cls[i-1]] + major_x[all_cls[i]]) / 2
ax.axvline(sep_x, color="#cccccc", linewidth=1.0, zorder=1)
# Sub-column labels (H/G/S) just below the x-axis
for pred_cls in all_cls:
for true_cls in all_true:
lbl = _CLASS_LABELS_SHORT.get(true_cls, f"C{true_cls}")
ax.text(major_x[pred_cls] + sub_off[true_cls], -0.085, lbl,
ha="center", va="top", fontsize=7, color="#555555",
transform=ax.get_xaxis_transform())
ax.set_ylim(-0.05, 1.05)
ax.set_ylabel("Confidence P(predicted class)", fontsize=12)
ax.set_xlabel("Predicted class", fontsize=12, labelpad=18)
ax.axhline(0.5, color="grey", linestyle="--", linewidth=0.8, alpha=0.5)
ax.grid(axis="y", linestyle=":", alpha=0.4)
_draw_grid_legend(ax_leg, n_folds, all_true, colours, title="True class")
fig.tight_layout()
return _save(fig, out_dir or run_dir / "plots", f"prob_confidence_{head}.png")
# ── triangle / ternary style ───────────────────────────────────────────────────
# Equilateral triangle vertices in Cartesian space:
# H (Healthy) = bottom-left (0, 0)
# G (Glaucoma) = bottom-right (1, 0)
# S (Suspect) = apex (0.5, sqrt(3)/2)
_TRI_VERTICES = np.array([
[0.0, 0.0], # class 0 Healthy
[1.0, 0.0], # class 1 Glaucoma
[0.5, np.sqrt(3) / 2], # class 2 Suspect
])
def _bary_to_cart(probs: np.ndarray) -> np.ndarray:
"""
Convert Nx3 barycentric coordinates (softmax probs) to Nx2 Cartesian.
probs rows must sum to 1.
"""
return probs @ _TRI_VERTICES
def _draw_triangle_grid(ax: plt.Axes, levels: tuple = (0.25, 0.5, 0.75)) -> None:
"""Draw the triangle border and iso-probability grid lines."""
from matplotlib.patches import Polygon
from matplotlib.lines import Line2D
# Outer triangle
tri = Polygon(_TRI_VERTICES, fill=False, edgecolor="#333333", linewidth=1.5, zorder=2)
ax.add_patch(tri)
# Grid lines: for each class, lines where p_class = level,
# parallel to the opposite edge.
for level in levels:
for cls in range(3):
# Points on the two edges adjacent to this vertex at distance `level`
v0 = _TRI_VERTICES[cls]
v1 = _TRI_VERTICES[(cls + 1) % 3]
v2 = _TRI_VERTICES[(cls + 2) % 3]
# A line at p_cls = level divides the triangle:
# p1 = level * v0 + (1-level) * v1
# p2 = level * v0 + (1-level) * v2
p1 = level * v0 + (1 - level) * v1
p2 = level * v0 + (1 - level) * v2
ax.plot([p1[0], p2[0]], [p1[1], p2[1]],
color="#cccccc", linewidth=0.6, zorder=1, linestyle="--")
def plot_triangle(run_dir: Path, head: str = "fused", out_dir: Path | None = None) -> Path:
"""
Ternary simplex plot of softmax probabilities for a 3-class run.
Each dot = one eye; position = (p_H, p_G, p_S) in barycentric coords.
Color = true class × fold shade.
"""
frames = _load_folds(run_dir, head)
num_cls = _detect_num_classes(frames[0], head)
if num_cls != 3:
raise ValueError(f"Triangle plot requires 3 classes; got {num_cls}. "
"Use --style strips for binary.")
all_true = sorted({int(v) for df in frames for v in df["y_true"]})
n_folds = len(frames)
colours = _fold_colours(n_folds)
leg_width = 0.8 + 0.55 * len(all_true)
fig = plt.figure(figsize=(8, 7))
gs = fig.add_gridspec(1, 2, width_ratios=[8 - leg_width, leg_width], wspace=0.05)
ax = fig.add_subplot(gs[0], aspect="equal")
ax_leg = fig.add_subplot(gs[1])
fig.suptitle(f"{run_dir.parent.parent.name} — Simplex [{head}]",
fontsize=11, y=1.01)
_draw_triangle_grid(ax)
# Vertex labels — placed just outside each corner
offsets = [(-0.07, -0.06), (0.07, -0.06), (0.0, 0.06)]
for cls_idx in range(3):
lbl = _CLASS_LABELS.get(cls_idx, f"C{cls_idx}")
vx, vy = _TRI_VERTICES[cls_idx]
dx, dy = offsets[cls_idx]
ax.text(vx + dx, vy + dy, lbl, ha="center", va="center",
fontsize=12, fontweight="bold")
# Centroid marker
cx, cy = _TRI_VERTICES.mean(axis=0)
ax.scatter([cx], [cy], color="#aaaaaa", s=30, marker="+", zorder=2, linewidths=1)
ax.text(cx + 0.02, cy - 0.04, "1/3 each", fontsize=7, color="#999999", ha="left")
# Data dots
rng = np.random.default_rng(seed=0)
for fold_idx, df in enumerate(frames):
prob_cols = [f"prob_{head}_c{c}" for c in range(3)]
probs_all = df[prob_cols].values # Nx3
y_true = df["y_true"].values.astype(int)
for true_cls in all_true:
mask = y_true == true_cls
if not mask.any():
continue
probs = probs_all[mask] # Kx3
xy = _bary_to_cart(probs) # Kx2
noise = rng.normal(0, 0.004, xy.shape)
ax.scatter(xy[:, 0] + noise[:, 0],
xy[:, 1] + noise[:, 1],
color=colours[true_cls][fold_idx],
s=30, alpha=0.80, linewidths=0, zorder=3)
ax.set_xlim(-0.18, 1.18)
ax.set_ylim(-0.12, 1.02)
ax.axis("off")
_draw_grid_legend(ax_leg, n_folds, all_true, colours, title="True class")
fig.tight_layout()
return _save(fig, out_dir or run_dir / "plots", f"prob_triangle_{head}.png")
# ── triangle3d style ──────────────────────────────────────────────────────────
# Triangle vertices for the 3D "bread-slice" view.
# Triangles stand upright in the x-z plane; y is the depth (layer) axis.
# H (Healthy) = bottom-left (0, 0) — back corner of the base
# G (Glaucoma) = top-centre (0.5, √3/2) — apex (visually "at the top")
# S (Suspect) = bottom-right (1, 0) — front corner toward the viewer
_TRI3D_VERTS = np.array([
[0.0, 0.0], # class 0 Healthy (bottom-left / back)
[0.5, np.sqrt(3) / 2], # class 1 Glaucoma (top)
[1.0, 0.0], # class 2 Suspect (bottom-right / front)
])
def _bary_to_cart_3d(probs: np.ndarray) -> np.ndarray:
"""Convert Nx3 softmax probs to Nx2 Cartesian using the 3D vertex layout."""
return probs @ _TRI3D_VERTS
def plot_triangle_3d(
run_dir: Path,
head: str = "fused",
out_dir: Path | None = None,
elev: float = 20,
azim: float = -45,
y_spacing: float = 0.4,
) -> Path:
"""
3-D ternary "bread-slice" plot.
Each true class gets its own vertical triangle slice standing in the x-z
plane, stacked along the y (depth) axis (camera at ~4:30 / SE position):
Healthy layer is at y=0 (front-left from 4:30 view)
Glaucoma layer is at y=1
Suspect layer is at y=2 (back-right from 4:30 view)
Within every slice the simplex corners are:
G (Glaucoma) at the top apex
H (Healthy) at the bottom-left (back corner of the base)
S (Suspect) at the bottom-right (front corner toward viewer)
View with elev/azim to see the slices as near-vertical planes with slight
perspective depth.
"""
from mpl_toolkits.mplot3d import Axes3D # noqa: F401
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
from matplotlib.patches import Patch
frames = _load_folds(run_dir, head)
num_cls = _detect_num_classes(frames[0], head)
if num_cls != 3:
raise ValueError("triangle3d requires 3 classes.")
all_true = sorted({int(v) for df in frames for v in df["y_true"]})
n_folds = len(frames)
colours = _fold_colours(n_folds)
rng = np.random.default_rng(seed=0)
# Layer y positions: H front-left (y=0), S back-right (y=2) from 4:30 view
y_pos = {cls: i * y_spacing for i, cls in enumerate(all_true)}
# Triangle wireframe in (x, z) — loop closed
wire_x = np.append(_TRI3D_VERTS[:, 0], _TRI3D_VERTS[0, 0])
wire_z = np.append(_TRI3D_VERTS[:, 1], _TRI3D_VERTS[0, 1])
fig = plt.figure(figsize=(9, 7))
ax = fig.add_subplot(111, projection="3d")
ax.view_init(elev=elev, azim=azim)
fig.suptitle(run_dir.parent.parent.name, fontsize=11)
# ── pre-compute all xz points per class (needed for KDE) ─────────────────
from scipy.stats import gaussian_kde
import matplotlib.colors as mcolors
_xz_by_cls: dict[int, np.ndarray] = {}
for df in frames:
prob_cols_all = [f"prob_{head}_c{c}" for c in range(3)]
p_all = df[prob_cols_all].values
yt_all = df["y_true"].values.astype(int)
for cls in all_true:
m = yt_all == cls
if m.any():
xz_pts = _bary_to_cart_3d(p_all[m])
_xz_by_cls[cls] = (
np.vstack([_xz_by_cls[cls], xz_pts])
if cls in _xz_by_cls else xz_pts
)
# KDE grid setup — evaluate on a 40×40 grid masked to the triangle interior
_G = 40
_x_lin = np.linspace(0.0, 1.0, _G)
_z_lin = np.linspace(0.0, np.sqrt(3) / 2, _G)
_dx = _x_lin[1] - _x_lin[0]
_dz = _z_lin[1] - _z_lin[0]
_XX, _ZZ = np.meshgrid(_x_lin, _z_lin) # (_G, _G)
# Vectorised inside-triangle test (barycentric)
vH, vG, vS = _TRI3D_VERTS
_denom = (vG[1] - vS[1]) * (vH[0] - vS[0]) + (vS[0] - vG[0]) * (vH[1] - vS[1])
def _inside(px, pz):
la = ((vG[1] - vS[1]) * (px - vS[0]) + (vS[0] - vG[0]) * (pz - vS[1])) / _denom
lb = ((vS[1] - vH[1]) * (px - vS[0]) + (vH[0] - vS[0]) * (pz - vS[1])) / _denom
return (la >= 0) & (lb >= 0) & ((1 - la - lb) >= 0)
_inside_mask = _inside(_XX.ravel(), _ZZ.ravel()).reshape(_G, _G)
_grid_pts = np.vstack([_XX.ravel(), _ZZ.ravel()]) # 2×(_G²)
# ── draw triangle wireframe + density heatmap at each y layer ─────────────
for cls in all_true:
y = y_pos[cls]
base_col = colours[cls][n_folds // 2]
rgba_base = np.array(mcolors.to_rgba(base_col))
# Wireframe
ax.plot(wire_x, np.full_like(wire_x, y), wire_z,
color=base_col, linewidth=1.2, alpha=0.65, zorder=1)
# ── density heatmap ────────────────────────────────────────────────
xz_pts = _xz_by_cls.get(cls)
if xz_pts is not None and len(xz_pts) >= 2:
kde = gaussian_kde(xz_pts.T, bw_method="silverman")
density = kde(_grid_pts).reshape(_G, _G)
density[~_inside_mask] = 0.0
inside_vals = density[_inside_mask]
d_max = inside_vals.max()
if d_max > 0:
# Normalise by 95th-percentile density so a tight peak at one
# corner doesn't wash out the rest of the triangle. Values
# above the cap are clipped to the max alpha.
d_ref = np.percentile(inside_vals[inside_vals > 0], 95)
if d_ref == 0:
d_ref = d_max
alpha_grid = np.clip(density / d_ref, 0, 1) * 0.50
# Build one quad per inside cell, coloured by density alpha
quads, face_cols = [], []
for i in range(_G):
for j in range(_G):
if not _inside_mask[i, j]:
continue
xi, zj = _x_lin[j], _z_lin[i]
x0, x1 = xi - _dx / 2, xi + _dx / 2
z0, z1 = zj - _dz / 2, zj + _dz / 2
quads.append([(x0, y, z0), (x1, y, z0),
(x1, y, z1), (x0, y, z1)])
fc = rgba_base.copy()
fc[3] = float(alpha_grid[i, j])
face_cols.append(fc)
heat = Poly3DCollection(quads, facecolors=face_cols,
edgecolors="none", zorder=0)
ax.add_collection3d(heat)
# Iso-prob grid lines
for level in (0.25, 0.5, 0.75):
for vi in range(3):
v0 = _TRI3D_VERTS[vi]
v1 = _TRI3D_VERTS[(vi + 1) % 3]
v2 = _TRI3D_VERTS[(vi + 2) % 3]
p1 = level * v0 + (1 - level) * v1
p2 = level * v0 + (1 - level) * v2
ax.plot([p1[0], p2[0]], [y, y], [p1[1], p2[1]],
color="#cccccc", linewidth=0.4, linestyle="--",
alpha=0.45, zorder=1)
# ── vertex labels just outside the front face of the merged volume ────────
# Place labels at the H-layer plane (y=0, front-left) but pushed slightly
# in front of the y-axis so they don't collide with the wireframe.
lbl_info = [
(0, -0.13, -0.08), # H: bottom-left
(1, 0.00, 0.10), # G: top
(2, 0.13, -0.08), # S: bottom-right
]
front_y = min(y_pos.values()) - 0.08
for ci, dx, dz in lbl_info:
vx, vz = _TRI3D_VERTS[ci]
ax.text(vx + dx, front_y, vz + dz,
_CLASS_LABELS.get(ci, f"C{ci}"),
fontsize=10, fontweight="bold", ha="center", va="center",
zorder=10)
# ── data dots ─────────────────────────────────────────────────────────────
for fold_idx, df in enumerate(frames):
prob_cols = [f"prob_{head}_c{c}" for c in range(3)]
probs_all = df[prob_cols].values
y_true = df["y_true"].values.astype(int)
for true_cls in all_true:
mask = y_true == true_cls
if not mask.any():
continue
probs = probs_all[mask]
xz = _bary_to_cart_3d(probs) # Nx2 (x, z)
noise = rng.normal(0, 0.004, xz.shape)
y_vals = np.full(mask.sum(), y_pos[true_cls])
ax.scatter(xz[:, 0] + noise[:, 0],
y_vals,
xz[:, 1] + noise[:, 1],
color=colours[true_cls][fold_idx],
s=22, alpha=0.82, linewidths=0,
depthshade=False, zorder=5)
ax.set_xlim(-0.15, 1.15)
ax.set_ylim(-0.3, max(y_pos.values()) + 0.3)
ax.set_zlim(-0.1, np.sqrt(3) / 2 + 0.1)
ax.set_xlabel("")
ax.set_ylabel("")
ax.set_zlabel("")
ax.set_xticks([])
ax.set_yticks([])
ax.set_zticks([])
ax.xaxis.pane.fill = False
ax.yaxis.pane.fill = False
ax.zaxis.pane.fill = False
ax.grid(False)
# ── legend: same grid style as 2D plots, placed as an inset axes ─────────
ax_leg = fig.add_axes([0.68, 0.65, 0.28, 0.30])
_draw_grid_legend(ax_leg, n_folds, all_true, colours, title="True class")
fig.tight_layout()
return _save(fig, out_dir or run_dir / "plots", f"prob_triangle3d_{head}.png")
# ── shared save helper ─────────────────────────────────────────────────────────
def _save(fig: plt.Figure, out_dir: Path, filename: str) -> Path:
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
out_path = out_dir / filename
fig.savefig(out_path, dpi=150, bbox_inches="tight")
plt.close(fig)
print(f"Saved: {out_path}")
return out_path
# ── CLI ────────────────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--run-dir", required=True)
ap.add_argument("--head", default="fused", choices=["fused", "img", "md"])
ap.add_argument("--style", default="strips",
choices=["strips", "confidence", "triangle", "triangle3d"],
help="strips: X=true class, Y=P(Glaucoma). "
"confidence: X=predicted class × true sub-column, Y=confidence. "
"triangle: ternary simplex plot (multiclass only).")
ap.add_argument("--out", default=None)
args = ap.parse_args()
rd = Path(args.run_dir)
od = Path(args.out) if args.out else None
if args.style == "confidence":
plot_confidence(rd, head=args.head, out_dir=od)
elif args.style == "triangle":
plot_triangle(rd, head=args.head, out_dir=od)
elif args.style == "triangle3d":
plot_triangle_3d(rd, head=args.head, out_dir=od)
else:
plot_strip(rd, head=args.head, out_dir=od)
if __name__ == "__main__":
main()