commit 9894a23f09e3eca282487ec406d2c38b01f292b7 Author: rpotter6298 Date: Tue Feb 24 10:39:48 2026 +0100 moved_repo_first_update diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0eac0b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.claude/ +analysis_data/ +**/__pycache__/ +Papila/* +REFUGE/* +old_**/ +models/* +!models/refuge +!models/v2 \ No newline at end of file diff --git a/classes/SE_attention.py b/classes/SE_attention.py new file mode 100755 index 0000000..178dc30 --- /dev/null +++ b/classes/SE_attention.py @@ -0,0 +1,123 @@ +# se_block.py +import torch +import torch.nn as nn + +class SEGateLogger: + """ + Lightweight stats over SE gates. + Use: logger.accumulate(gates) each batch; logger.get() at epoch end. + """ + def __init__(self, enabled: bool = True, track_channels: bool = False, dim: int | None = None): + self.enabled = enabled + self.track_channels = track_channels + self.dim = dim + self.reset() + + def reset(self): + self._n = 0 + self._sum = 0.0 + self._sum2 = 0.0 + self._lt02 = 0 + self._gt08 = 0 + # optional per-channel + self._ch_sum = None + self._ch_count = 0 + if self.track_channels and self.dim is not None: + self._ch_sum = torch.zeros(self.dim, dtype=torch.float32) + + @torch.no_grad() + def accumulate(self, gates: torch.Tensor): + if not self.enabled: + return + # gates expected shape [N, C]; if a map/sequence gate is passed, reduce to [N, C] + if gates.dim() == 4: # [N,C,H,W] gates (uncommon) + g = gates.mean(dim=(2,3)) + elif gates.dim() == 3: # [N,T,C] gates (sequence) + g = gates.mean(dim=1) + elif gates.dim() == 2: # [N,C] + g = gates + else: + g = gates.view(gates.size(0), -1) + + g = g.detach() + self._n += g.numel() + self._sum += g.sum().item() + self._sum2 += (g*g).sum().item() + self._lt02 += (g < 0.2).sum().item() + self._gt08 += (g > 0.8).sum().item() + + if self._ch_sum is not None: + self._ch_sum += g.sum(dim=0).cpu() + self._ch_count += g.size(0) + + def get(self, reset: bool = True): + if self._n == 0: + return None + mean = self._sum / self._n + var = max(0.0, self._sum2 / self._n - mean * mean) + out = { + "mean": mean, + "std": var ** 0.5, + "pct_lt_0.2": self._lt02 / self._n, + "pct_gt_0.8": self._gt08 / self._n, + } + if self._ch_sum is not None and self._ch_count > 0: + out["channel_mean"] = (self._ch_sum / float(self._ch_count)).tolist() + if reset: + self.reset() + return out + +class SEBlock(nn.Module): + """ + SE-style channel gating that works for vectors and maps. + + Input: + - [N, C] (vector) -> squeeze = identity + - [N, C, H, W] (image map) -> squeeze over H,W + - [N, T, C] (sequence) -> squeeze over T + + Gate modes: + - residual (default): gate = 1 + tanh(MLP(s)) in (0, 2) [identity at init] + - plain: gate = sigmoid(MLP(s)) in (0, 1) + """ + def __init__(self, dim: int, reduction: int = 16, residual: bool = True, identity_init: bool = True): + super().__init__() + hid = max(1, dim // max(1, reduction)) + self.fc1 = nn.Linear(dim, hid, bias=True) + self.act = nn.ReLU(inplace=True) + self.fc2 = nn.Linear(hid, dim, bias=True) + self.residual = residual + + if residual and identity_init: + # make MLP output ~0 at start → gate ≈ 1.0 + nn.init.zeros_(self.fc2.weight) + nn.init.zeros_(self.fc2.bias) + + def _squeeze(self, x: torch.Tensor) -> torch.Tensor: + if x.dim() == 2: # [N,C] + return x + if x.dim() == 4: # [N,C,H,W] + return x.mean(dim=(2,3)) + if x.dim() == 3: # [N,T,C] + return x.mean(dim=1) + # fallback: flatten non-batch dims into channels + return x.view(x.size(0), -1) + + def _broadcast(self, gate: torch.Tensor, like: torch.Tensor) -> torch.Tensor: + if like.dim() == 2: + return gate + if like.dim() == 3: + return gate.unsqueeze(1) # [N,1,C] + if like.dim() == 4: + return gate.unsqueeze(-1).unsqueeze(-1) # [N,C,1,1] + return gate.view_as(like) + + def forward(self, x: torch.Tensor): + s = self._squeeze(x) # [N,C] + u = self.fc2(self.act(self.fc1(s))) # [N,C] + if self.residual: + gate = 1.0 + torch.tanh(u) # (0, 2) with identity at 1.0 + else: + gate = torch.sigmoid(u) # (0, 1) + y = x * self._broadcast(gate, x) + return y, gate # return both the reweighted tensor and the gate for logging diff --git a/classes/__init__.py b/classes/__init__.py new file mode 100755 index 0000000..81c93d7 --- /dev/null +++ b/classes/__init__.py @@ -0,0 +1,19 @@ +from .clinical_data import ClinicalData +from .dataset import ClinicalDataset +from .image_tower import ImageTower +from .md_tower import MDTower +from .bridge import Bridge, VoteBridge +# from .hypertower import HyperTower +from .backbones import list_names, BackboneSpec, BACKBONES +from .papila_builders import build_papila_clinical +from .SE_attention import SEBlock, SEGateLogger +from .early_stop import EarlyStopper +__all__ = [ + "ClinicalData", + "ClinicalDataset", + "ImageTower", + "MDTower", + "Bridge", + "VoteBridge", +# "HyperTower", +] diff --git a/classes/backbones.py b/classes/backbones.py new file mode 100755 index 0000000..5a92109 --- /dev/null +++ b/classes/backbones.py @@ -0,0 +1,178 @@ +# classes/backbones.py +from __future__ import annotations +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Dict, List + +import torch +from torch import nn +from torchvision import models + +@dataclass(frozen=True) +class BackboneSpec: + ctor: Callable # torchvision constructor + weights_default: object # torchvision Weights enum DEFAULT member + strip: Callable[[nn.Module], tuple] # fn(model)->(out_dim, model_no_head) + blocks: Callable[[nn.Module], List[nn.Module]] # fn(model)->ordered blocks for freezing + +REFUGELIKE_BACKBONE_PATH = Path("models/refuge/classifier/refugelike_backbone.pt") +REFUGE_DENSENET_PATH = Path("models/refuge/classifier/refuge_densenet_backbone.pt") +REFUGE_EFFICIENT_B0_PATH = Path("models/refuge/classifier/refuge_efficient_b0_backbone.pt") +REFUGE_EFFICIENT_B7_PATH = Path("models/refuge/classifier/refuge_efficient_b7_backbone.pt") + +# --- strip fns --- +def _strip_efficientnet_b0(m: models.EfficientNet): + from torch import nn as _nn + out_dim = m.classifier[1].in_features + m.classifier = _nn.Identity() + return out_dim, m + +def _strip_resnet(m: models.ResNet): + out_dim = m.fc.in_features + m.fc = nn.Identity() + return out_dim, m + +def _strip_densenet(m: models.DenseNet): + out_dim = m.classifier.in_features + m.classifier = nn.Identity() + return out_dim, m + +def _strip_vgg(m: models.VGG): + out_dim = m.classifier[0].in_features # 25088 for VGG16 at 224×224 + m.classifier = nn.Identity() + return out_dim, m + +def _strip_mobilenet_v2(m: models.MobileNetV2): + out_dim = m.classifier[1].in_features + m.classifier = nn.Identity() + return out_dim, m + +def _strip_inception_v3(m: models.Inception3): + out_dim = m.fc.in_features + m.fc = nn.Identity() + if hasattr(m, "AuxLogits"): + m.aux_logits = False + return out_dim, m + +# --- block splitters for ratio-based freezing --- +def _blocks_efficientnet_b0(m: models.EfficientNet): + return list(m.features) + +def _blocks_resnet(m: models.ResNet): + stem = nn.Sequential(m.conv1, m.bn1, m.relu, m.maxpool) + return [stem, m.layer1, m.layer2, m.layer3, m.layer4] + +def _blocks_densenet(m: models.DenseNet): + f = m.features + stem = nn.Sequential(f.conv0, f.norm0, f.relu0, f.pool0) + return [stem, f.denseblock1, f.transition1, f.denseblock2, f.transition2, + f.denseblock3, f.transition3, f.denseblock4, f.norm5] + +def _blocks_vgg(m: models.VGG): + stages, cur = [], [] + for mod in m.features: + cur.append(mod) + if isinstance(mod, nn.MaxPool2d): + stages.append(nn.Sequential(*cur)); cur = [] + if cur: stages.append(nn.Sequential(*cur)) + return stages + +def _blocks_mobilenet_v2(m: models.MobileNetV2): + return list(m.features) + +def _blocks_inception_v3(m: models.Inception3): + blocks = [] + for name, child in m.named_children(): + if name in ("fc", "AuxLogits"): + continue + blocks.append(child) + return blocks + +# --- registry (covers paper models available in torchvision) --- +BACKBONES: Dict[str, BackboneSpec] = { + "efficientnet_b0": BackboneSpec( + ctor=models.efficientnet_b0, + weights_default=models.EfficientNet_B0_Weights.DEFAULT, + strip=_strip_efficientnet_b0, + blocks=_blocks_efficientnet_b0, + ), + "resnet50": BackboneSpec( + ctor=models.resnet50, + weights_default=models.ResNet50_Weights.DEFAULT, + strip=_strip_resnet, + blocks=_blocks_resnet, + ), + "densenet121": BackboneSpec( + ctor=models.densenet121, + weights_default=models.DenseNet121_Weights.DEFAULT, + strip=_strip_densenet, + blocks=_blocks_densenet, + ), + "vgg16": BackboneSpec( + ctor=models.vgg16, + weights_default=models.VGG16_Weights.DEFAULT, + strip=_strip_vgg, + blocks=_blocks_vgg, + ), + "mobilenet_v2": BackboneSpec( + ctor=models.mobilenet_v2, + weights_default=models.MobileNet_V2_Weights.DEFAULT, + strip=_strip_mobilenet_v2, + blocks=_blocks_mobilenet_v2, + ), + "inception_v3": BackboneSpec( + ctor=models.inception_v3, + weights_default=models.Inception_V3_Weights.DEFAULT, + strip=_strip_inception_v3, + blocks=_blocks_inception_v3, + ), + "refugelike": BackboneSpec( + ctor=models.resnet50, + weights_default=None, + strip=_strip_resnet, + blocks=_blocks_resnet, + ), + "refuge_densenet": BackboneSpec( + ctor=models.densenet121, + weights_default=None, + strip=_strip_densenet, + blocks=_blocks_densenet, + ), + "refuge_efficient_b0": BackboneSpec( + ctor=models.efficientnet_b0, + weights_default=None, + strip=_strip_efficientnet_b0, + blocks=_blocks_efficientnet_b0, + ), + "refuge_efficient_b7": BackboneSpec( + ctor=models.efficientnet_b7, + weights_default=None, + strip=_strip_efficientnet_b0, + blocks=_blocks_efficientnet_b0, + ), + # Xception isn’t in torchvision +} + +def list_names() -> List[str]: + return list(BACKBONES.keys()) + + +def load_backbone_weights(key: str, model: nn.Module) -> None: + if key == "refugelike": + path = REFUGELIKE_BACKBONE_PATH + elif key == "refuge_densenet": + path = REFUGE_DENSENET_PATH + elif key == "refuge_efficient_b0": + path = REFUGE_EFFICIENT_B0_PATH + elif key == "refuge_efficient_b7": + path = REFUGE_EFFICIENT_B7_PATH + else: + return + + if not path.exists(): + raise FileNotFoundError( + "Custom REFUGE backbone not found at " + f"{path}. Export it via refuge_build.py --export-backbone first." + ) + state = torch.load(path, map_location="cpu") + model.load_state_dict(state, strict=False) diff --git a/classes/bridge.py b/classes/bridge.py new file mode 100755 index 0000000..ec87cf3 --- /dev/null +++ b/classes/bridge.py @@ -0,0 +1,105 @@ +# 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) diff --git a/classes/clinical_data.py b/classes/clinical_data.py new file mode 100755 index 0000000..748ccd4 --- /dev/null +++ b/classes/clinical_data.py @@ -0,0 +1,264 @@ +# 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: min–max to [0,1], median imputation; + one missing flag per numeric feature + * Categorical: one-hot with '' 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 + 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 = {"": 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} diff --git a/classes/dataset.py b/classes/dataset.py new file mode 100755 index 0000000..68b9d87 --- /dev/null +++ b/classes/dataset.py @@ -0,0 +1,58 @@ +# 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 diff --git a/classes/early_stop.py b/classes/early_stop.py new file mode 100755 index 0000000..bc88320 --- /dev/null +++ b/classes/early_stop.py @@ -0,0 +1,89 @@ +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"]) diff --git a/classes/frontend.py b/classes/frontend.py new file mode 100755 index 0000000..5a60f89 --- /dev/null +++ b/classes/frontend.py @@ -0,0 +1,1402 @@ +#!/usr/bin/env python3 +"""Tkinter front-end for scripts/run_multifold.py.""" + +from __future__ import annotations + +import argparse +import contextlib +import csv +import io +import json +import os +import sys +import signal +import subprocess +import threading +import time +import shutil +import re +from pathlib import Path +from typing import Dict, Optional +from types import SimpleNamespace + +import numpy as np +import torch +import torch.nn.functional as F +from sklearn.metrics import roc_auc_score, roc_curve, auc +import matplotlib.pyplot as plt + +import tkinter as tk +from tkinter import filedialog, messagebox + +from classes.hypertower import HyperTower +from classes.backbones import list_names as list_backbones +from classes import build_papila_clinical + + +BACKBONES = [ + "efficientnet_b0", + "resnet50", + "densenet121", + "refugelike", + "refuge_densenet", + "refuge_efficient_b0", + "refuge_efficient_b7", +] + +FUSION_MODES = ["fused", "image_only", "metadata_only", "vote"] +EVAL_MODES = ["multiclass", "binary"] + + +class Multifold: + """Core multifold runner extracted from scripts/run_multifold.""" + + def __init__(self, args: argparse.Namespace) -> None: + self.args = args + + @staticmethod + def build_parser() -> argparse.ArgumentParser: + ap = argparse.ArgumentParser(description="Run k-fold CV and emit per-fold logs, npy, and ROC plots.") + ap.add_argument("--image-dir", default="Papila/FundusImages") + ap.add_argument("--clinical-dir", default="Papila/ClinicalData") + ap.add_argument("--label-col", default="Diagnosis") + ap.add_argument("--cat-cols", nargs="*", default=["Gender", "Phakic/Pseudophakic"]) + + ap.add_argument("--backbone", type=str, default="efficientnet_b0", choices=list_backbones()) + ap.add_argument("--freeze-ratio", type=float, default=0.0) + ap.add_argument("--fusion-mode", default="fused", choices=["fused","image_only","metadata_only","vote"]) + ap.add_argument("--epochs", type=int, default=5) + ap.add_argument("--batch-size", type=int, default=8) + ap.add_argument("--lr", type=float, default=1e-4) + ap.add_argument("--num-classes", type=int, default=3) + ap.add_argument("--n-splits", type=int, default=5) + ap.add_argument("--fold-seed", type=int, default=42, + help="Random seed for patient-level splits") + ap.add_argument("--holdout-per-class", type=int, default=0, + help="Reserve this many samples per class for a monitoring holdout (0 disables)") + ap.add_argument("--holdout-seed", type=int, default=123, + help="Random seed used when sampling the holdout subset") + ap.add_argument("--img-crop-manifest", type=Path, default=None, + help="Optional manifest for UNet cropper (enables disc-centric crops)") + ap.add_argument("--img-crop-weights", type=Path, default=None, + help="UNet checkpoint weights for cropping") + ap.add_argument("--img-crop-normalize", choices=["none", "imagenet", "per_image"], default="per_image") + ap.add_argument("--img-crop-threshold", type=float, default=0.5) + ap.add_argument("--img-crop-scale", type=float, default=2.5) + ap.add_argument("--img-crop-size", type=int, default=224) + ap.add_argument("--img-crop-cache", type=Path, default=Path("analysis_data/hypertower_crops")) + ap.add_argument("--img-crop-tta", action="store_true") + ap.add_argument("--img-crop-gt", action="store_true", + help="Use ground-truth masks/contours from manifest for cropping instead of UNet") + ap.add_argument("--no-img-augment", dest="img_augment", action="store_false", + help="Disable random image augmentations for the image tower") + ap.set_defaults(img_augment=True) + ap.add_argument("--img-geometry-features", action="store_true", + help="Append disc/cup geometry features to the image tower (requires cropping)") + + ap.add_argument("--shortname", default="multi") + ap.add_argument("--plot-head", default="fused", choices=["image","fused","metadata"], + help="Which head to plot/aggregate.") + ap.add_argument("--class-names", nargs="*", default=None) + ap.add_argument("--no-se", dest="use_se", action="store_false", + help="Disable SE attention in the bridge (default: enabled)") + ap.set_defaults(use_se=True) + ap.add_argument("--se-reduction", type=int, default=16, choices=[8,16,32], + help="SE bottleneck: fusion_dim // reduction (default 16)") + ap.add_argument("--se-pre-norm", dest="se_pre_norm", action="store_true", + help="Enable LayerNorm on branches before the multiply (default)") + ap.add_argument("--no-se-pre-norm", dest="se_pre_norm", action="store_false", + help="Disable LayerNorm on branches before the multiply") + ap.set_defaults(se_pre_norm=True) + ap.add_argument("--se-where", choices=["bridge","tower","both","none"], default="bridge", + help="Where to apply SE: bridge (default), tower, both, or none") + ap.add_argument("--se-reduction-tower", type=int, default=16, choices=[8,16,32], + help="SE bottleneck for tower vectors (default 16)") + ap.add_argument("--se-pre-norm-tower", dest="se_pre_norm_tower", action="store_true", + help="Enable LayerNorm on tower vectors before SE (default)") + ap.add_argument("--no-se-pre-norm-tower", dest="se_pre_norm_tower", action="store_false", + help="Disable LayerNorm on tower vectors before SE (default: enabled)") + ap.set_defaults(se_pre_norm_tower=True) + ap.add_argument("--eval_mode", choices=["multiclass","binary"], default="multiclass", + help="Multiclass (3 classes) or binary (Healthy vs Glaucoma; drops Suspect).") + ap.add_argument("--warmup-tower-epochs", type=int, default=2) + ap.add_argument("--warmup-fused-epochs", type=int, default=3) + ap.add_argument("--gradual-thaw", action="store_true", + help="Enable gradual backbone thawing schedule (image/metadata towers)") + ap.add_argument("--thaw-phase-duration", type=int, default=5, + help="Epochs per thaw phase (default 5)") + ap.add_argument("--thaw-ratio", type=float, default=0.33, + help="Fraction of blocks to unfreeze each phase (default 0.33)") + ap.add_argument("--thaw-target", choices=["image","metadata","both"], default="image", + help="Which tower(s) to apply gradual thaw to (default image)") + ap.add_argument("--thaw-start-epoch", type=int, default=-1, + help="Epoch to start thawing (default: warmup_tower_epochs)") + ap.add_argument("--initial-freeze", action="store_true", + help="Before thaw start, force backbone(s) fully frozen (default off)") + + ap.add_argument("--early-stop", action="store_true", + help="Enable early stopping") + ap.add_argument("--early-metric", default=None, + help="Metric key to monitor (e.g., eval_loss, auc_fused, acc_fused).") + ap.add_argument("--early-mode", choices=["auto","min","max"], default="auto") + ap.add_argument("--early-monitor-holdout", action="store_true", + help="Monitor the holdout metric for early stopping/checkpointing (requires holdout set).") + ap.add_argument("--early-patience", type=int, default=7) + ap.add_argument("--early-min-delta", type=float, default=0.0) + ap.add_argument("--checkpoint-best", action="store_true", + help="Save best weights to disk during training") + ap.add_argument("--focal-gamma", type=float, default=0.0, + help="Focal loss exponent (0 disables focal loss)") + ap.add_argument("--balanced-sampler", action="store_true", + help="Use a class-balanced bootstrapped sampler for the training loader") + ap.add_argument("--run-id", default=None, help=argparse.SUPPRESS) + return ap + + @staticmethod + def _serialize_arg(value): + if isinstance(value, Path): + return str(value) + if isinstance(value, (list, tuple)): + return [Multifold._serialize_arg(v) for v in value] + return value + + def _export_run_settings(self, run_dir: Path) -> None: + data = {key: self._serialize_arg(value) for key, value in vars(self.args).items()} + try: + with open(run_dir / "cli_args.json", "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + except Exception as exc: # pragma: no cover + print(f"[run_multifold] Failed to write cli_args.json: {exc}") + + def run(self, callback=None) -> None: + if callback: + sink = _StreamCallback(callback) + with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink): + self._run_impl() + else: + self._run_impl() + + # ---- Helper functions copied from run_multifold ---------------- + @staticmethod + def eval_collect_logits(ht: HyperTower): + device = ht.device + ht.img_tower.eval(); ht.md_tower.eval() + if getattr(ht, "mode", "fused") != "vote": + ht.bridge.eval() + + y_all = [] + pf, pi, pm = [], [], [] + for batch in ht.test_loader: + if len(batch) == 4: + imgs, metas, geometry, labels = batch + else: + imgs, metas, labels = batch + geometry = None + imgs = imgs.to(device) + metas = metas.to(device) + labels = labels.to(device) + if geometry is not None and geometry.numel() > 0: + geometry = geometry.to(device) + else: + geometry = None + if ht.mode == "vote": + img_feats = ht.img_tower(imgs, geometry) + md_feats = ht.md_tower(metas) + out_img = ht.head_img(img_feats) + out_md = ht.head_md(md_feats) + out_fused = ht.vote(out_img, out_md) + else: + img_feats = ht.img_tower(imgs, geometry) + md_feats = ht.md_tower(metas) + outputs = ht.bridge(img_feats, md_feats) + if isinstance(outputs, tuple): + out_fused, out_img, out_md = outputs + else: + out_fused, out_img, out_md = outputs, None, None + + y_all.append(labels.detach().cpu().numpy()) + pf.append(F.softmax(out_fused, dim=1).detach().cpu().numpy()) + if out_img is not None: + pi.append(F.softmax(out_img, dim=1).detach().cpu().numpy()) + if out_md is not None: + pm.append(F.softmax(out_md, dim=1).detach().cpu().numpy()) + + y_true = np.concatenate(y_all, axis=0) + pf = np.concatenate(pf, axis=0) if pf else None + pi = np.concatenate(pi, axis=0) if pi else None + pm = np.concatenate(pm, axis=0) if pm else None + return y_true, pf, pi, pm + + @staticmethod + def auc_for(y, p): + y = np.asarray(y) + if p is None: + return float("nan") + if p.ndim == 1 or p.shape[1] == 1: + return roc_auc_score(y, p.ravel()) + if p.shape[1] == 2: + return roc_auc_score(y, p[:, 1]) + return roc_auc_score(y, p, multi_class="ovr", average="macro") + + @staticmethod + def per_class_roc(y, p): + if p is None: + return {} + K = p.shape[1] + out = {} + for k in range(K): + y_bin = (y == k).astype(np.uint8) + fpr, tpr, _ = roc_curve(y_bin, p[:, k]) + out[k] = (fpr, tpr, auc(fpr, tpr) if len(fpr) > 1 else np.nan) + return out + + @staticmethod + def plot_mean_sd(per_fold_curves, out_png, class_names=None, title="Mean OVR ROC (±1 SD)"): + if not per_fold_curves: + return + fpr_grid = np.linspace(0, 1, 501) + fig = plt.figure(figsize=(10, 8)); ax = fig.add_subplot(111) + ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1) + + keys = sorted({k for d in per_fold_curves for k in d.keys()}) + if class_names is not None and len(class_names) == len(keys): + name_map = {k: class_names[i] for i, k in enumerate(keys)} + else: + name_map = {k: f"class {k}" for k in keys} + + for k in keys: + tprs, aucs = [], [] + for d in per_fold_curves: + if k not in d: + continue + fpr, tpr, a = d[k] + tprs.append(np.interp(fpr_grid, fpr, tpr)) + aucs.append(a) + if not tprs: + continue + tprs = np.vstack(tprs) + mean = tprs.mean(axis=0); std = tprs.std(axis=0) + auc_mean = np.nanmean(aucs); auc_std = np.nanstd(aucs) + label = f"{name_map[k]} (AUC {auc_mean:.3f}±{auc_std:.3f})" + ax.plot(fpr_grid, mean, linewidth=2, label=label) + ax.fill_between(fpr_grid, np.maximum(mean - std, 0), np.minimum(mean + std, 1), alpha=0.15) + + ax.set_xlabel("False Positive Rate"); ax.set_ylabel("True Positive Rate") + ax.set_title(title); ax.legend(loc="lower right"); fig.tight_layout() + fig.savefig(out_png, dpi=160); plt.close(fig) + + @staticmethod + def plot_overlays(per_fold_curves, out_png, title="Per-fold OVR ROC overlays"): + if not per_fold_curves: + return + fig = plt.figure(figsize=(10, 8)); ax = fig.add_subplot(111) + ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1) + for d in per_fold_curves: + for _, (fpr, tpr, _) in d.items(): + ax.plot(fpr, tpr, alpha=0.25, linewidth=1) + ax.set_xlabel("False Positive Rate"); ax.set_ylabel("True Positive Rate") + ax.set_title(title); fig.tight_layout(); fig.savefig(out_png, dpi=160); plt.close(fig) + + @staticmethod + def plot_per_class_overlays(per_fold_curves, out_dir: Path, class_names=None, head_name: str = "fused"): + if not per_fold_curves: + return + keys = sorted({k for d in per_fold_curves for k in d.keys()}) + if class_names is not None and len(class_names) == len(keys): + name_map = {k: class_names[i] for i, k in enumerate(keys)} + else: + name_map = {k: f"class_{k}" for k in keys} + + out_dir.mkdir(parents=True, exist_ok=True) + for k in keys: + per_fold = [] + for fold_idx, d in enumerate(per_fold_curves, start=1): + if k not in d: + continue + fpr, tpr, auc_val = d[k] + per_fold.append((fold_idx, fpr, tpr, auc_val)) + if not per_fold: + continue + + fig = plt.figure(figsize=(10, 8)); ax = fig.add_subplot(111) + ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1, color="grey") + for fold_idx, fpr, tpr, auc_val in per_fold: + label = f"Fold {fold_idx} (AUC {auc_val:.3f})" + ax.plot(fpr, tpr, linewidth=1.5, label=label) + ax.set_xlabel("False Positive Rate"); ax.set_ylabel("True Positive Rate") + ax.set_title(f"{head_name} head — {name_map[k]} ROC per fold") + ax.legend(loc="lower right", frameon=True) + fig.tight_layout() + safe_name = name_map[k].replace(" ", "_") + fig.savefig(out_dir / f"roc_{head_name}_{safe_name}_perfold.png", dpi=160) + plt.close(fig) + + @staticmethod + def move_if_exists(src: Path, dest: Path): + if src.exists(): + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(src), str(dest)) + + @staticmethod + def move_dir_overwrite(src: Path, dest: Path): + """Move directory, replacing destination if it already exists.""" + if not src.exists(): + return + if dest.exists(): + shutil.rmtree(dest) + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(src), str(dest)) + + @staticmethod + def load_holdout_roc_curves(dir_path: Path, head: str): + """ + Load the holdout ROC JSON for a given head from a directory like + foldX_roc_curves_holdout_best, returning the per-class curve map + expected by plotting helpers. + """ + if not dir_path.exists() or not dir_path.is_dir(): + return {} + head_name = {"image": "image", "metadata": "metadata"}.get(head, "fused") + best_path = None + best_epoch = -1 + for path in dir_path.glob(f"epoch*_holdout_{head_name}.json"): + m = re.match(r"epoch(\\d+)_", path.stem) + if not m: + continue + try: + epoch_idx = int(m.group(1)) + except Exception: + continue + if epoch_idx > best_epoch: + best_epoch = epoch_idx + best_path = path + if best_path is None: + return {} + try: + data = json.loads(best_path.read_text()) + except Exception: + return {} + per_class = data.get("per_class") or {} + curves = {} + for k, vals in per_class.items(): + if not isinstance(vals, dict): + continue + fpr = vals.get("fpr"); tpr = vals.get("tpr"); auc_val = vals.get("auc") + if fpr is None or tpr is None or auc_val is None: + continue + try: + idx = int(k) + except Exception: + continue + curves[idx] = (np.array(fpr, dtype=float), np.array(tpr, dtype=float), float(auc_val)) + return curves + + def _run_impl(self) -> None: + args = self.args + args.num_classes = 2 if args.eval_mode == "binary" else 3 + + if getattr(args, "run_id", None): + run_id = str(args.run_id) + else: + ts = time.strftime("%Y%m%d_%H%M%S") + run_id = f"{args.shortname}_{ts}" if args.shortname else ts + run_dir = Path("analysis_data") / args.shortname / run_id + (run_dir / "plots").mkdir(parents=True, exist_ok=True) + base_models_dir = Path("models") / args.shortname / run_id + base_models_dir.mkdir(parents=True, exist_ok=True) + self._export_run_settings(run_dir) + + clinical = build_papila_clinical( + args.image_dir, + args.clinical_dir, + args.label_col, + args.cat_cols, + n_splits=args.n_splits, + random_seed=args.fold_seed, + ) + + holdout_df = None + if args.holdout_per_class > 0: + df_full = clinical.df.copy() + if args.eval_mode == "binary": + df_full = df_full[df_full[args.label_col].isin([0, 1])].reset_index(drop=True) + rng = np.random.default_rng(args.holdout_seed) + holdout_indices = [] + for label, group in df_full.groupby(args.label_col): + n = min(args.holdout_per_class, len(group)) + if n <= 0: + continue + selected = rng.choice(group.index.to_numpy(), size=n, replace=False) + holdout_indices.extend(selected.tolist()) + if holdout_indices: + holdout_indices = sorted(set(holdout_indices)) + holdout_df = df_full.loc[holdout_indices].reset_index(drop=True) + train_df = df_full.drop(index=holdout_indices).reset_index(drop=True) + clinical.frames = [train_df.copy()] + clinical.df = train_df.copy() + clinical._infer_or_validate_feature_types() + clinical._compute_numeric_stats() + clinical._build_cat_maps() + clinical._compute_feature_dim() + clinical._build_kfold_indices() + holdout_path = run_dir / "holdout.csv" + holdout_df.to_csv(holdout_path, index=False) + print(f"[run_multifold] Reserved holdout set of {len(holdout_df)} samples (saved to {holdout_path})") + + fold_macro_aucs = [] + per_fold_ovr_curves_for_plot_head = [] + holdout_per_fold_ovr_curves_for_plot_head = [] + + def _default_monitor(): + if args.early_metric: + mode = getattr(args, "early_mode", "auto") + if mode == "auto": + mode = "min" if "loss" in args.early_metric.lower() else "max" + return args.early_metric, mode + if args.fusion_mode == "image_only": + return "auc_img", "max" + if args.fusion_mode == "metadata_only": + return "auc_md", "max" + return "auc_fused", "max" + + monitor_name, monitor_mode = _default_monitor() + fold_summaries = [] + best_metric_values = [] + + for fold in range(args.n_splits): + print(f"\n=== Fold {fold+1}/{args.n_splits} ===") + if args.early_metric: + early_metric = args.early_metric + else: + if args.fusion_mode == "image_only": + early_metric = "auc_img" + elif args.fusion_mode == "metadata_only": + early_metric = "auc_md" + else: + early_metric = "auc_fused" + + ht_args = SimpleNamespace( + image_dir=args.image_dir, + clinical_dir=args.clinical_dir, + label_col=args.label_col, + cat_cols=args.cat_cols, + batch_size=args.batch_size, + epochs=args.epochs, + lr=args.lr, + num_classes=args.num_classes, + img_augment=args.img_augment, + focal_gamma=args.focal_gamma, + eval_mode=args.eval_mode, + fold=fold, + run_dir=str(run_dir), + models_dir=str((base_models_dir / f"fold{fold}").resolve()), + backbone=args.backbone, + freeze_ratio=args.freeze_ratio, + fusion_mode=args.fusion_mode, + use_se=args.use_se, + se_reduction=args.se_reduction, + se_pre_norm=args.se_pre_norm, + se_where=args.se_where, + se_reduction_tower=args.se_reduction_tower, + se_pre_norm_tower=args.se_pre_norm_tower, + warmup_tower_epochs=args.warmup_tower_epochs, + warmup_fused_epochs=args.warmup_fused_epochs, + gradual_thaw=args.gradual_thaw, + thaw_phase_duration=args.thaw_phase_duration, + thaw_ratio=args.thaw_ratio, + thaw_target=args.thaw_target, + thaw_start_epoch=args.thaw_start_epoch, + initial_freeze=args.initial_freeze, + bcd_prob=0.5, + bcd_p0=0.20, + bcd_min=0.05, + bcd_max=0.30, + bcd_k=0.4, + bcd_metric="auc", + bcd_alpha_batch=0.2, + bcd_alpha_tower=0.3, + bcd_explore_floor=0.15, + aux_img=0.05, + aux_md=0.05, + aux_detach=True, + ema_alpha=0.9, + entropy_ema=0.7, + early_stop=args.early_stop, + early_metric=early_metric, + early_mode=args.early_mode, + early_patience=args.early_patience, + early_min_delta=args.early_min_delta, + checkpoint_best=args.checkpoint_best, + holdout_df=holdout_df, + img_crop_manifest=args.img_crop_manifest, + img_crop_weights=args.img_crop_weights, + img_crop_normalize=args.img_crop_normalize, + img_crop_threshold=args.img_crop_threshold, + img_crop_scale=args.img_crop_scale, + img_crop_size=args.img_crop_size, + img_crop_cache=args.img_crop_cache, + img_crop_tta=args.img_crop_tta, + img_crop_gt=args.img_crop_gt, + ) + + (base_models_dir / f"fold{fold}").mkdir(parents=True, exist_ok=True) + ht = HyperTower(clinical, ht_args) + ht.train() + + epoch_log_path = run_dir / "epoch_log.csv" + best_row_data = None + best_value = None + if epoch_log_path.exists(): + try: + with epoch_log_path.open("r", newline="", encoding="utf-8") as fp: + reader = csv.DictReader(fp) + for row in reader: + val_raw = row.get(monitor_name) + try: + val = float(val_raw) + except (TypeError, ValueError): + continue + if best_value is None: + best_value = val + best_row_data = dict(row) + else: + if monitor_mode == "max": + if val > best_value: + best_value = val + best_row_data = dict(row) + else: + if val < best_value: + best_value = val + best_row_data = dict(row) + except OSError as exc: # pragma: no cover + print(f"[run_multifold] Warning: failed to read {epoch_log_path} ({exc}); skipping best-metric parse.") + + def _coerce_types(row: dict | None) -> dict | None: + if row is None: + return None + out = {} + for key, value in row.items(): + if value is None or value == "": + out[key] = None + continue + try: + out[key] = float(value) + if key == "epoch": + out[key] = int(float(value)) + except ValueError: + out[key] = value + return out + + best_row_converted = _coerce_types(best_row_data) + if isinstance(best_row_converted, dict) and "epoch" in best_row_converted: + try: + best_epoch = int(best_row_converted["epoch"]) + except Exception: + best_epoch = None + else: + best_epoch = None + + if best_value is not None: + best_metric_values.append(best_value) + + fold_summaries.append({ + "fold": fold, + "best_metric_value": best_value, + "best_epoch": best_epoch, + "monitor": monitor_name, + "warmup_tower_epochs": int(getattr(ht, "warmup_tower_epochs", getattr(args, "warmup_tower_epochs", 2))), + "warmup_fused_epochs": int(getattr(ht, "warmup_fused_epochs", getattr(args, "warmup_fused_epochs", 3))), + "main_epochs": int(getattr(ht, "epochs", args.epochs)), + "total_epochs": int(getattr(ht, "total_epochs", args.epochs)), + "stats": best_row_converted, + }) + + self.move_if_exists(run_dir / "train.log", run_dir / f"fold{fold}_train.log") + self.move_if_exists(epoch_log_path, run_dir / f"fold{fold}_epoch_log.csv") + roc_src = run_dir / "roc_curves" + if roc_src.exists() and roc_src.is_dir(): + self.move_dir_overwrite(roc_src, run_dir / f"fold{fold}_roc_curves") + roc_best_src = run_dir / "roc_curves_best" + if roc_best_src.exists() and roc_best_src.is_dir(): + self.move_dir_overwrite(roc_best_src, run_dir / f"fold{fold}_roc_curves_best") + roc_holdout_src = run_dir / "roc_curves_holdout_best" + if roc_holdout_src.exists() and roc_holdout_src.is_dir(): + self.move_dir_overwrite(roc_holdout_src, run_dir / f"fold{fold}_roc_curves_holdout_best") + holdout_curves = self.load_holdout_roc_curves(run_dir / f"fold{fold}_roc_curves_holdout_best", args.plot_head) + if holdout_curves: + holdout_per_fold_ovr_curves_for_plot_head.append(holdout_curves) + + # Reload the recorded best checkpoint so downstream metrics/plots use the same epoch as the summary. + best_snapshot = Path(ht_args.models_dir) / "model_best.pt" + if best_snapshot.exists(): + try: + try: + state = torch.load(best_snapshot, map_location=ht.device, weights_only=False) + except TypeError: + state = torch.load(best_snapshot, map_location=ht.device) + ht._restore_from_state(state) + except Exception as exc: # pragma: no cover + print(f"[run_multifold] Warning: failed to reload best checkpoint for fold {fold}: {exc}") + + train_df, test_df = clinical.get_split_dfs(fold) + y_true, p_fused, p_img, p_md = self.eval_collect_logits(ht) + if args.eval_mode == "binary": + keep = np.isin(y_true, [0, 1]) + if keep.sum() == 0: + raise RuntimeError("No binary samples left after filtering.") + y_true = y_true[keep] + if p_fused is not None: p_fused = p_fused[keep] + if p_img is not None: p_img = p_img[keep] + if p_md is not None: p_md = p_md[keep] + + def _slice2(p): + if p is None: + return None + if p.ndim == 2 and p.shape[1] >= 2: + return p[:, :2] + return p + + p_fused = _slice2(p_fused) + p_img = _slice2(p_img) + p_md = _slice2(p_md) + + np.save(run_dir / f"fold{fold}_y_true.npy", y_true) + if p_img is not None: np.save(run_dir / f"fold{fold}_probs_img.npy", p_img) + if p_fused is not None: np.save(run_dir / f"fold{fold}_probs_fused.npy", p_fused) + if p_md is not None: np.save(run_dir / f"fold{fold}_probs_md.npy", p_md) + + if args.plot_head == "image": + p_plot = p_img + elif args.plot_head == "metadata": + p_plot = p_md + else: + p_plot = p_fused + + fold_auc = self.auc_for(y_true, p_plot) if p_plot is not None else float("nan") + fold_macro_aucs.append(fold_auc) + + per_fold_ovr_curves_for_plot_head.append(self.per_class_roc(y_true, p_plot)) + + auc_mean = float(np.nanmean(fold_macro_aucs)) if fold_macro_aucs else float("nan") + auc_std = float(np.nanstd(fold_macro_aucs)) if fold_macro_aucs else float("nan") + + class_names = args.class_names if args.class_names else ( + ["Healthy", "Glaucoma"] if args.eval_mode == "binary" else ["Healthy","Glaucoma","Suspect"] + ) + + self.plot_per_class_overlays( + per_fold_ovr_curves_for_plot_head, + out_dir=run_dir / "plots", + class_names=class_names, + head_name=args.plot_head, + ) + self.plot_mean_sd( + per_fold_ovr_curves_for_plot_head, + out_png=run_dir / "plots" / f"roc_{args.plot_head}_mean_ovr.png", + class_names=class_names, + title=f"Mean OVR ROC (±1 SD) — {args.plot_head} head" + ) + + if holdout_per_fold_ovr_curves_for_plot_head: + holdout_plots_dir = run_dir / "plots" / "holdout" + holdout_plots_dir.mkdir(parents=True, exist_ok=True) + self.plot_per_class_overlays( + holdout_per_fold_ovr_curves_for_plot_head, + out_dir=holdout_plots_dir, + class_names=class_names, + head_name=f"{args.plot_head}_holdout", + ) + self.plot_mean_sd( + holdout_per_fold_ovr_curves_for_plot_head, + out_png=holdout_plots_dir / f"roc_{args.plot_head}_holdout_mean_ovr.png", + class_names=class_names, + title=f"Holdout Mean OVR ROC (±1 SD) — {args.plot_head} head", + ) + + if best_metric_values: + best_metric_mean = float(np.mean(best_metric_values)) + best_metric_std = float(np.std(best_metric_values, ddof=0)) + else: + best_metric_mean = None + best_metric_std = None + + warmup_tower_used = ( + fold_summaries[0].get("warmup_tower_epochs") + if fold_summaries else getattr(args, "warmup_tower_epochs", None) + ) + warmup_fused_used = ( + fold_summaries[0].get("warmup_fused_epochs") + if fold_summaries else getattr(args, "warmup_fused_epochs", None) + ) + total_epochs_used = ( + fold_summaries[0].get("total_epochs") + if fold_summaries else args.epochs + ) + + summary = { + "run_id": run_id, + "backbone": args.backbone, + "freeze_ratio": args.freeze_ratio, + "fusion_mode": args.fusion_mode, + "epochs": args.epochs, + "warmup_tower_epochs": warmup_tower_used, + "warmup_fused_epochs": warmup_fused_used, + "total_epochs": total_epochs_used, + "batch_size": args.batch_size, + "lr": args.lr, + "num_classes": args.num_classes, + "eval_mode": args.eval_mode, + "n_splits": args.n_splits, + "focal_gamma": args.focal_gamma, + "balanced_sampler": bool(args.balanced_sampler), + "se": { + "enabled": bool(args.use_se), + "reduction": int(args.se_reduction), + "pre_norm": bool(args.se_pre_norm), + }, + "best_metric": monitor_name, + "best_metric_mode": monitor_mode, + "best_metric_mean": best_metric_mean, + "best_metric_std": best_metric_std, + "fold_metrics": fold_summaries, + } + + with open(run_dir / "summary.json", "w") as jf: + json.dump(summary, jf, indent=2) + print(f"Summary written to {run_dir / 'summary.json'}") + + +class _StreamCallback(io.TextIOBase): + def __init__(self, callback): + self.callback = callback + + def write(self, s): + if self.callback and s: + self.callback(s) + return len(s) + + def flush(self): + pass + + +class MultifoldRunner: + """Wrapper used by the GUI to execute Multifold runs.""" + + def run(self, cli_args: list[str], callback=None) -> None: + parser = Multifold.build_parser() + if callback: + sink = _StreamCallback(callback) + with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink): + args = parser.parse_args(cli_args) + else: + args = parser.parse_args(cli_args) + multifold = Multifold(args) + multifold.run(callback) + + +class MultifoldFrontend(tk.Tk): + """Simple Tkinter GUI for configuring and launching run_multifold.py.""" + + def __init__(self) -> None: + super().__init__() + self.title("run_multifold.py") + self.vars: Dict[str, tk.Variable] = {} + self._configure_scale() + self.geometry(self.window_geometry) + self._build_form() + self._build_output() + self.status_var = tk.StringVar(value="Idle") + status_frame = tk.Frame(self) + status_frame.pack(fill="x", padx=6, pady=(0, 6)) + tk.Label(status_frame, text="Status:", font=self.base_font).pack(side="left") + tk.Label(status_frame, textvariable=self.status_var, anchor="w", font=self.base_font).pack(side="left", fill="x") + self.force_cpu = False + self.after(0, self.on_device_change) + self.repo_root = Path(__file__).resolve().parents[1] + self.current_process: Optional[subprocess.Popen] = None + self.current_thread: Optional[threading.Thread] = None + self.current_run_dir: Optional[Path] = None + + # ---- UI helpers ------------------------------------------------- + def _configure_scale(self) -> None: + try: + screen_w = self.winfo_screenwidth() + screen_h = self.winfo_screenheight() + except Exception: + screen_w, screen_h = 1920, 1080 + + scale = screen_w / 1920.0 + scale = max(0.8, min(scale, 1.6)) + base_size = max(10, int(10 * scale)) + self.base_font = ("TkDefaultFont", base_size) + self.bold_font = ("TkDefaultFont", max(base_size, 11), "bold") + self.entry_font = ("TkDefaultFont", max(9, base_size)) + self.button_font = ("TkDefaultFont", max(9, base_size - 1)) + self.mono_font = ("Courier", max(9, base_size)) + self.text_height = max(18, int(24 * scale)) + width = int(720 * scale) + height = int(860 * scale) + self.window_geometry = f"{width}x{height}" + + try: + self.tk.call("tk", "scaling", scale) + except Exception: + pass + + def _entry(self, parent: tk.Widget, label: str, default: str = "") -> tk.Entry: + frame = tk.Frame(parent) + frame.pack(fill="x", padx=4, pady=2) + tk.Label(frame, text=label, width=22, anchor="w", font=self.base_font).pack(side="left") + var = self.vars.get(label) + if not isinstance(var, tk.StringVar): + var = tk.StringVar(value=default) + entry = tk.Entry(frame, textvariable=var, font=self.entry_font) + entry.pack(side="left", fill="x", expand=True) + self.vars[label] = var + return entry + + def _browse_entry(self, parent: tk.Widget, label: str, default: str = "", is_dir: bool = True) -> None: + entry = self._entry(parent, label, default) + + def choose() -> None: + path = filedialog.askdirectory() if is_dir else filedialog.askopenfilename() + if path: + entry.delete(0, tk.END) + entry.insert(0, path) + + tk.Button(entry.master, text="Browse", command=choose, font=self.button_font).pack(side="left", padx=4) + + def _checkbox(self, parent: tk.Widget, label: str, default: bool = False) -> None: + var = self.vars.get(label) + if not isinstance(var, tk.BooleanVar): + var = tk.BooleanVar(value=default) + tk.Checkbutton(parent, text=label, variable=var, font=self.base_font).pack(anchor="w", padx=6) + self.vars[label] = var + + def _option_menu(self, parent: tk.Widget, label: str, options: list[str], default: str) -> None: + frame = tk.Frame(parent) + frame.pack(fill="x", padx=4, pady=2) + tk.Label(frame, text=label, width=22, anchor="w", font=self.base_font).pack(side="left") + var = self.vars.get(label) + if not isinstance(var, tk.StringVar): + var = tk.StringVar(value=default) + menu = tk.OptionMenu(frame, var, *options) + menu.configure(font=self.base_font) + menu["menu"].configure(font=self.base_font) + menu.pack(side="left", fill="x", expand=True) + self.vars[label] = var + + def _on_eval_mode_change(self, *_args) -> None: + mode_var = self.vars.get("Evaluation mode") + num_var = self.vars.get("Number of classes") + if isinstance(mode_var, tk.StringVar) and isinstance(num_var, tk.StringVar): + num_var.set("2" if mode_var.get() == "binary" else "3") + + def _build_popup_section(self, title: str, builder, parent: Optional[tk.Widget] = None) -> None: + container = parent if parent is not None else self + frame = tk.Frame(container) + frame.pack(fill="x", padx=6, pady=2) + tk.Button(frame, text=title, command=lambda: self._open_popup(title, builder), font=self.button_font).pack(anchor="w") + + def _open_popup(self, title: str, builder) -> None: + win = tk.Toplevel(self) + win.title(title) + win.transient(self) + content = tk.Frame(win, padx=8, pady=8) + content.pack(fill="both", expand=True) + builder(content) + tk.Button(content, text="Close", command=win.destroy, font=self.button_font).pack(pady=(8, 0)) + + def _build_crop_settings(self, parent: tk.Widget) -> None: + self._browse_entry(parent, "Cropping mask manifest", "manifest.csv", is_dir=False) + self._browse_entry(parent, "Cropping weights (optional)", "", is_dir=False) + self._option_menu(parent, "Crop normalization", ["none", "imagenet", "per_image"], "per_image") + self._entry(parent, "Crop threshold", "0.5") + self._entry(parent, "Crop scale", "2.5") + self._entry(parent, "Crop size", "224") + self._entry(parent, "Crop cache directory", "analysis_data/hypertower_crops") + self._checkbox(parent, "Use ground truth masks", True) + self._checkbox(parent, "Use crop TTA", False) + self._checkbox(parent, "Append geometry features", False) + + def _build_se_settings(self, parent: tk.Widget) -> None: + self._checkbox(parent, "Enable SE", True) + self._entry(parent, "SE reduction (bridge)", "16") + self._checkbox(parent, "SE pre-norm (bridge)", True) + self._option_menu(parent, "SE location", ["bridge", "tower", "both", "none"], "bridge") + self._entry(parent, "SE reduction (tower)", "16") + self._checkbox(parent, "SE pre-norm (tower)", True) + + def _build_warmup_settings(self, parent: tk.Widget) -> None: + self._entry(parent, "Tower warmup epochs", "2") + self._entry(parent, "Fused warmup epochs", "3") + self._checkbox(parent, "Enable gradual thaw", False) + self._entry(parent, "Thaw phase duration", "5") + self._entry(parent, "Thaw ratio", "0.33") + self._option_menu(parent, "Thaw target", ["image", "metadata", "both"], "image") + self._entry(parent, "Thaw start epoch", "-1") + self._checkbox(parent, "Initial freeze before thaw", False) + + def _build_early_stop_settings(self, parent: tk.Widget) -> None: + self._entry(parent, "Early metric", "") + self._option_menu(parent, "Early mode", ["auto", "min", "max"], "auto") + self._entry(parent, "Early patience", "7") + self._entry(parent, "Early min delta", "0.0") + self._checkbox(parent, "Monitor holdout for early stop", False) + self._checkbox(parent, "Save best checkpoint", False) + + # ---- Layout ----------------------------------------------------- + def _build_form(self) -> None: + form = tk.Frame(self) + form.pack(fill="both", expand=False) + + self._entry(form, "Run name", "gui_run") + self._browse_entry(form, "Fundus image directory", "Papila/FundusImages") + self._browse_entry(form, "Clinical data directory", "Papila/ClinicalData") + self._entry(form, "Diagnosis column", "Diagnosis") + self._entry(form, "Categorical columns", "Gender,Phakic/Pseudophakic") + + self._option_menu(form, "Backbone architecture", BACKBONES, BACKBONES[0]) + self._option_menu(form, "Fusion mode", FUSION_MODES, "image_only") + self._option_menu(form, "Evaluation mode", EVAL_MODES, "binary") + + self._entry(form, "Epochs", "40") + self._entry(form, "Batch size", "8") + self._entry(form, "Learning rate", "5e-5") + self._entry(form, "Number of classes", "2") + self._entry(form, "Number of folds", "5") + self._entry(form, "Fold seed", "42") + self._entry(form, "Holdout per class", "8") + self._entry(form, "Holdout seed", "123") + + eval_var = self.vars.get("Evaluation mode") + if isinstance(eval_var, tk.StringVar): + eval_var.trace_add("write", self._on_eval_mode_change) + self._on_eval_mode_change() + + tk.Label(form, text="Options", font=self.bold_font).pack(anchor="w", padx=6, pady=(8, 0)) + self._checkbox(form, "Disable image augmentation", False) + self._checkbox(form, "Use balanced sampler", False) + self._checkbox(form, "Enable early stopping", False) + self._checkbox(form, "Use image cropping", True) + self._checkbox(form, "Use focal loss", False) + self._entry(form, "Focal gamma", "0.0") + + dummy = tk.Frame(self) + self._build_crop_settings(dummy) + self._build_se_settings(dummy) + self._build_warmup_settings(dummy) + self._build_early_stop_settings(dummy) + dummy.destroy() + + self._build_popup_section("Cropping Settings", self._build_crop_settings, parent=form) + self._build_popup_section("Squeeze-and-Excitation Settings", self._build_se_settings, parent=form) + self._build_popup_section("Warmup / Thaw Settings", self._build_warmup_settings, parent=form) + self._build_popup_section("Early Stop Settings", self._build_early_stop_settings, parent=form) + + device_frame = tk.Frame(form) + device_frame.pack(fill="x", pady=4, padx=6) + tk.Label(device_frame, text="Compute Device", font=self.bold_font).pack(anchor="w") + self.device_var = tk.StringVar(value="gpu") + tk.Radiobutton(device_frame, text="GPU", variable=self.device_var, value="gpu", + command=self.on_device_change, font=self.base_font).pack(anchor="w") + tk.Radiobutton(device_frame, text="CPU", variable=self.device_var, value="cpu", + command=self.on_device_change, font=self.base_font).pack(anchor="w") + + btn_frame = tk.Frame(form) + btn_frame.pack(fill="x", pady=8) + self.run_button = tk.Button(btn_frame, text="Run", command=self.run_command, font=self.button_font) + self.run_button.pack(side="left", padx=4) + self.stop_button = tk.Button(btn_frame, text="Stop", command=self.stop_command, font=self.button_font, state="disabled") + self.stop_button.pack(side="left", padx=4) + tk.Button(btn_frame, text="Preview Command", command=self.preview_command, font=self.button_font).pack(side="left", padx=4) + tk.Button(btn_frame, text="Export Settings", command=self.export_settings, font=self.button_font).pack(side="right", padx=4) + tk.Button(btn_frame, text="Import Settings", command=self.import_settings, font=self.button_font).pack(side="right", padx=4) + + def _build_output(self) -> None: + tk.Label(self, text="Command / Output", font=self.bold_font).pack(anchor="w", padx=6) + self.output = tk.Text(self, height=self.text_height, font=self.mono_font) + self.output.pack(fill="both", expand=True, padx=6, pady=(0, 6)) + self.output.configure(state="disabled") + + # ---- Run management helpers ------------------------------------ + def _compute_run_id_and_dir(self) -> tuple[str, Path]: + shortname = "" + short_var = self.vars.get("Run name") + if isinstance(short_var, tk.StringVar): + shortname = short_var.get().strip() + timestamp = time.strftime("%Y%m%d_%H%M%S") + run_id = f"{shortname}_{timestamp}" if shortname else timestamp + base = Path("analysis_data") + if shortname: + base = base / shortname + run_dir = base / run_id + return run_id, run_dir + + def _auto_export_settings(self, run_dir: Path) -> None: + data = {} + for key, var in self.vars.items(): + try: + data[key] = var.get() + except Exception: + pass + try: + run_dir.mkdir(parents=True, exist_ok=True) + with open(run_dir / "gui_settings.json", "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + self.append_output(f"[GUI] Settings saved to {run_dir / 'gui_settings.json'}\n") + except Exception as exc: # pragma: no cover + self.append_output(f"[GUI] Failed to save GUI settings: {exc}\n") + + def _compose_command(self, include_run_id: bool = False) -> tuple[list[str], Optional[str], Optional[Path]]: + cli_args = self.build_cli_args() + run_id = None + run_dir = None + if include_run_id: + run_id, run_dir = self._compute_run_id_and_dir() + cli_args = cli_args + ["--run-id", run_id] + cmd = [sys.executable, "-u", "scripts/run_multifold.py", *cli_args] + return cmd, run_id, run_dir + + def _on_process_finished(self, exit_code: Optional[int], error: Optional[Exception]) -> None: + self.current_process = None + self.current_thread = None + self.current_run_dir = None + self.stop_button.config(state="disabled") + self.run_button.config(state="normal") + if error is not None: + self.append_output(f"\n[GUI] Error: {error}\n") + self.set_status("Error") + return + if exit_code is None: + self.append_output("\nProcess finished.\n") + self.set_status("Finished") + return + self.append_output(f"\nProcess finished with exit code {exit_code}\n") + self.set_status("Finished (exit 0)" if exit_code == 0 else f"Finished (exit {exit_code})") + + def _force_terminate_if_running(self) -> None: + proc = self.current_process + if proc is None or proc.poll() is not None: + return + self.append_output("[GUI] Process still running after interrupt; terminating...\n") + try: + proc.terminate() + except Exception as exc: # pragma: no cover + self.append_output(f"[GUI] Failed to terminate process: {exc}\n") + self.after(4000, self._kill_process) + + def _kill_process(self) -> None: + proc = self.current_process + if proc is None or proc.poll() is not None: + return + self.append_output("[GUI] Forcing process kill.\n") + try: + proc.kill() + except Exception as exc: # pragma: no cover + self.append_output(f"[GUI] Failed to kill process: {exc}\n") + + def stop_command(self) -> None: + proc = self.current_process + if proc is None or proc.poll() is not None: + self.stop_button.config(state="disabled") + return + self.append_output("\n[GUI] Sending interrupt signal...\n") + self.set_status("Stopping...") + try: + if os.name == "nt": + ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", signal.SIGINT) + proc.send_signal(ctrl_break) + else: + proc.send_signal(signal.SIGINT) + except Exception as exc: # pragma: no cover + self.append_output(f"[GUI] Failed to send interrupt: {exc}\n") + self.stop_button.config(state="disabled") + self.after(4000, self._force_terminate_if_running) + + # ---- Output helpers --------------------------------------------- + def append_output(self, text: str) -> None: + self.after(0, self._append_output, text) + + def _append_output(self, text: str) -> None: + self.output.configure(state="normal") + self.output.insert(tk.END, text) + self.output.see(tk.END) + self.output.configure(state="disabled") + + def set_status(self, text: str) -> None: + self.after(0, self.status_var.set, text) + + def preview_command(self) -> None: + cmd_list, run_id, run_dir = self._compose_command(include_run_id=True) + cmd = " ".join(cmd_list) + self.output.configure(state="normal") + self.output.delete("1.0", tk.END) + self.output.insert(tk.END, cmd + "\n") + if run_dir is not None: + self.output.insert(tk.END, f"# output directory: {run_dir}\n") + if run_id is not None: + self.output.insert(tk.END, f"# run id: {run_id}\n") + self.output.configure(state="disabled") + + # ---- Command execution ------------------------------------------ + def run_command(self) -> None: + if self.current_process and self.current_process.poll() is None: + messagebox.showwarning("Run in progress", "A run is already in progress.") + return + + cmd_list, run_id, run_dir = self._compose_command(include_run_id=True) + self.output.configure(state="normal") + self.output.delete("1.0", tk.END) + self.output.insert(tk.END, "Running: " + " ".join(cmd_list) + "\n\n") + if run_dir is not None: + self.output.insert(tk.END, f"# output directory: {run_dir}\n\n") + self.output.configure(state="disabled") + self.set_status("Running") + self.run_button.config(state="disabled") + self.stop_button.config(state="normal") + + env = os.environ.copy() + if self.force_cpu: + env["CUDA_VISIBLE_DEVICES"] = "-1" + + self.current_run_dir = run_dir + if run_dir is not None: + self._auto_export_settings(run_dir) + + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0 + + def worker() -> None: + exit_code: Optional[int] = None + error: Optional[Exception] = None + try: + proc = subprocess.Popen( + cmd_list, + cwd=self.repo_root, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + universal_newlines=True, + env=env, + creationflags=creationflags, + ) + self.current_process = proc + assert proc.stdout is not None + for line in proc.stdout: + if not line: + break + self.append_output(line) + proc.stdout.close() + exit_code = proc.wait() + except Exception as exc: # pragma: no cover + error = exc + finally: + self.after(0, lambda: self._on_process_finished(exit_code, error)) + + self.current_thread = threading.Thread(target=worker, daemon=True) + self.current_thread.start() + + # ---- Command builder -------------------------------------------- + def build_cli_args(self) -> list[str]: + args_list: list[str] = [] + + def add(flag: str, var_name: str, allow_empty: bool = False) -> None: + var = self.vars.get(var_name) + if isinstance(var, tk.StringVar): + value = var.get().strip() + if value or allow_empty: + args_list.extend([flag, value]) + + add("--shortname", "Run name") + add("--image-dir", "Fundus image directory") + add("--clinical-dir", "Clinical data directory") + add("--label-col", "Diagnosis column") + + cats = self.vars["Categorical columns"].get().strip() + if cats: + args_list.extend(["--cat-cols", *[c.strip() for c in cats.split(",") if c.strip()]]) + + add("--backbone", "Backbone architecture") + add("--fusion-mode", "Fusion mode") + add("--epochs", "Epochs") + add("--batch-size", "Batch size") + add("--lr", "Learning rate") + add("--num-classes", "Number of classes") + add("--n-splits", "Number of folds") + add("--fold-seed", "Fold seed") + add("--holdout-per-class", "Holdout per class") + add("--holdout-seed", "Holdout seed") + + eval_mode = self.vars["Evaluation mode"].get() + if eval_mode: + args_list.extend(["--eval_mode", eval_mode]) + + use_crop_var = self.vars.get("Use image cropping") + if isinstance(use_crop_var, tk.BooleanVar) and use_crop_var.get(): + add("--img-crop-manifest", "Cropping mask manifest") + weights = self.vars["Cropping weights (optional)"].get().strip() + if weights: + args_list.extend(["--img-crop-weights", weights]) + norm_var = self.vars.get("Crop normalization") + if isinstance(norm_var, tk.StringVar): + norm = norm_var.get().strip() + if norm: + args_list.extend(["--img-crop-normalize", norm]) + add("--img-crop-threshold", "Crop threshold") + add("--img-crop-cache", "Crop cache directory") + add("--img-crop-scale", "Crop scale") + add("--img-crop-size", "Crop size") + + if self.vars["Use ground truth masks"].get(): + args_list.append("--img-crop-gt") + if self.vars["Use crop TTA"].get(): + args_list.append("--img-crop-tta") + if self.vars["Append geometry features"].get(): + args_list.append("--img-geometry-features") + + if self.vars["Disable image augmentation"].get(): + args_list.append("--no-img-augment") + if self.vars["Use balanced sampler"].get(): + args_list.append("--balanced-sampler") + if self.vars["Enable early stopping"].get(): + args_list.append("--early-stop") + + if self.vars.get("Use focal loss") and self.vars["Use focal loss"].get(): + focal_gamma = self.vars["Focal gamma"].get().strip() + if focal_gamma: + args_list.extend(["--focal-gamma", focal_gamma]) + + if not self.vars["Enable SE"].get(): + args_list.append("--no-se") + else: + add("--se-reduction", "SE reduction (bridge)") + if not self.vars["SE pre-norm (bridge)"].get(): + args_list.append("--no-se-pre-norm") + se_where = self.vars["SE location"].get().strip() + if se_where: + args_list.extend(["--se-where", se_where]) + add("--se-reduction-tower", "SE reduction (tower)") + if not self.vars["SE pre-norm (tower)"].get(): + args_list.append("--no-se-pre-norm-tower") + + add("--warmup-tower-epochs", "Tower warmup epochs") + add("--warmup-fused-epochs", "Fused warmup epochs") + if self.vars["Enable gradual thaw"].get(): + args_list.append("--gradual-thaw") + add("--thaw-phase-duration", "Thaw phase duration") + add("--thaw-ratio", "Thaw ratio") + thaw_target = self.vars["Thaw target"].get().strip() + if thaw_target: + args_list.extend(["--thaw-target", thaw_target]) + add("--thaw-start-epoch", "Thaw start epoch") + if self.vars["Initial freeze before thaw"].get(): + args_list.append("--initial-freeze") + + early_metric = self.vars["Early metric"].get().strip() + if early_metric: + args_list.extend(["--early-metric", early_metric]) + early_mode = self.vars["Early mode"].get().strip() + if early_mode: + args_list.extend(["--early-mode", early_mode]) + add("--early-patience", "Early patience") + add("--early-min-delta", "Early min delta") + if self.vars["Monitor holdout for early stop"].get(): + args_list.append("--early-monitor-holdout") + if self.vars["Save best checkpoint"].get(): + args_list.append("--checkpoint-best") + + return args_list + + def build_command(self) -> list[str]: + return [sys.executable, "-u", "scripts/run_multifold.py", *self.build_cli_args()] + + def on_device_change(self) -> None: + choice = getattr(self, "device_var", None) + if choice is None: + return + choice = self.device_var.get() + if choice == "cpu": + self.force_cpu = True + self.append_output("CPU selected. Forcing CPU usage.\n") + self.set_status("CPU selected") + else: + if not torch.cuda.is_available(): + self.append_output("GPU selected but CUDA is not available. Falling back to CPU.\n") + self.device_var.set("cpu") + self.force_cpu = True + self.set_status("GPU unavailable; CPU selected") + return + try: + x = torch.rand((2048,), device="cuda") + y = torch.rand((2048,), device="cuda") + _ = (x * y).sum().item() + self.append_output("GPU support confirmed.\n") + self.force_cpu = False + self.set_status("GPU selected") + except Exception as exc: + self.append_output(f"GPU self-test failed ({exc}). Falling back to CPU.\n") + self.device_var.set("cpu") + self.force_cpu = True + self.set_status("GPU test failed; CPU selected") + + # ---- Settings import / export ---------------------------------- + def export_settings(self) -> None: + path = filedialog.asksaveasfilename( + title="Export Settings", + defaultextension=".json", + filetypes=[("JSON", "*.json"), ("All files", "*.*")], + ) + if not path: + return + data = {} + for key, var in self.vars.items(): + try: + data[key] = var.get() + except Exception: + pass + try: + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, indent=2) + self.set_status(f"Settings exported to {path}") + except Exception as exc: # pragma: no cover + messagebox.showerror("Export failed", str(exc)) + self.set_status("Export failed") + + def import_settings(self) -> None: + path = filedialog.askopenfilename( + title="Import Settings", + filetypes=[("JSON", "*.json"), ("All files", "*.*")], + ) + if not path: + return + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except Exception as exc: + messagebox.showerror("Import failed", str(exc)) + self.set_status("Import failed") + return + + for key, value in data.items(): + var = self.vars.get(key) + if var is None: + continue + try: + if isinstance(var, tk.BooleanVar): + var.set(bool(value)) + else: + var.set(str(value)) + except Exception: + continue + self.set_status(f"Settings imported from {path}") + self.on_device_change() + + +def launch_frontend() -> None: + app = MultifoldFrontend() + app.mainloop() + + +if __name__ == "__main__": + launch_frontend() diff --git a/classes/geometry_features.py b/classes/geometry_features.py new file mode 100755 index 0000000..6539cae --- /dev/null +++ b/classes/geometry_features.py @@ -0,0 +1,87 @@ +"""Shared helpers for deriving disc/cup geometry features.""" + +from __future__ import annotations + +from collections import Counter +from typing import Tuple + +import numpy as np +from PIL import Image + +EPS = 1e-6 +FEATURE_DIM = 5 + + +def disc_cup_from_mask_image(mask_img: Image.Image) -> Tuple[np.ndarray, np.ndarray]: + """Return binary disc/cup masks from a REFUGE-style annotation image.""" + arr = np.asarray(mask_img) + if arr.ndim == 3: + h, w, c = arr.shape + border = np.concatenate( + [arr[0, :, :], arr[-1, :, :], arr[:, 0, :], arr[:, -1, :]], + axis=0, + ) + border_counts = Counter(map(tuple, border)) + bg_color = border_counts.most_common(1)[0][0] + flat = arr.reshape(-1, c) + colors = Counter(map(tuple, flat)) + colors.pop(bg_color, None) + disc = (~np.all(arr == bg_color, axis=-1)).astype(np.uint8) + if colors: + cup_color = min(colors.keys(), key=lambda col: sum(col)) + cup = np.all(arr == cup_color, axis=-1).astype(np.uint8) + else: + cup = np.zeros((h, w), dtype=np.uint8) + else: + border = np.concatenate([arr[0, :], arr[-1, :], arr[:, 0], arr[:, -1]]) + counts = Counter(border.tolist()) + bg_value = counts.most_common(1)[0][0] + disc = (arr != bg_value).astype(np.uint8) + fg = arr[arr != bg_value] + if fg.size > 0: + cup_value = int(np.min(fg)) + cup = (arr == cup_value).astype(np.uint8) + else: + cup = np.zeros_like(arr, dtype=np.uint8) + cup = (cup > 0) & (disc > 0) + return disc.astype(np.uint8), cup.astype(np.uint8) + + +def compute_geometry_features(disc_mask: np.ndarray, cup_mask: np.ndarray) -> np.ndarray: + """Compute cup/disc geometry descriptors (area, rim, diameter ratios, centre shift).""" + disc = (disc_mask > 0).astype(np.float32) + cup = (cup_mask > 0).astype(np.float32) + + disc_area = disc.sum() + cup_area = cup.sum() + area_ratio = cup_area / (disc_area + EPS) + rim_ratio = (disc_area - cup_area) / (disc_area + EPS) + + disc_rows = np.any(disc > 0, axis=1) + cup_rows = np.any(cup > 0, axis=1) + disc_cols = np.any(disc > 0, axis=0) + cup_cols = np.any(cup > 0, axis=0) + + disc_height = float(disc_rows.sum()) + cup_height = float(cup_rows.sum()) + disc_width = float(disc_cols.sum()) + cup_width = float(cup_cols.sum()) + + vertical_ratio = cup_height / (disc_height + EPS) + horizontal_ratio = cup_width / (disc_width + EPS) + + def _centre(mask: np.ndarray) -> Tuple[float, float]: + coords = np.argwhere(mask > 0) + if coords.size == 0: + return 0.5, 0.5 + ys, xs = coords[:, 0], coords[:, 1] + return float(xs.mean()) / mask.shape[1], float(ys.mean()) / mask.shape[0] + + disc_cx, disc_cy = _centre(disc) + cup_cx, cup_cy = _centre(cup) + centre_shift = float(np.hypot(cup_cx - disc_cx, cup_cy - disc_cy)) + + return np.array( + [area_ratio, rim_ratio, vertical_ratio, horizontal_ratio, centre_shift], + dtype=np.float32, + ) diff --git a/classes/hypertower.py b/classes/hypertower.py new file mode 100755 index 0000000..17c4511 --- /dev/null +++ b/classes/hypertower.py @@ -0,0 +1,1696 @@ +from __future__ import annotations +import os +import csv +import math +import argparse +import json +from pathlib import Path +import shutil +import logging +import pandas as pd +from PIL import Image, ImageDraw +import torch +from torch import nn +from torch.utils.data import DataLoader +from torch.utils.data.sampler import WeightedRandomSampler +from classes import ClinicalData, ClinicalDataset, ImageTower, MDTower, Bridge, VoteBridge, EarlyStopper +from classes.unet_segmenter import UNetSegmenter +from classes.geometry_features import ( + FEATURE_DIM, + compute_geometry_features, + disc_cup_from_mask_image, +) +from classes.refuge_classification import _geometry_from_mask +from torchvision import transforms + +# from clinical_data import ClinicalData +# from dataset import ClinicalDataset +# from image_tower import ImageTower +# from md_tower import MDTower +# from bridge import Bridge, VoteBridge +from random import random +from sklearn.metrics import roc_auc_score +import torch.nn.functional as F +import numpy as np +from sklearn.metrics import roc_curve, auc +from sklearn.preprocessing import label_binarize +from typing import Optional, Tuple + + +LOG_FIELDS = [ +"epoch", +"eval_loss", +"acc_fused", "acc_img", "acc_md", +"auc_fused", "auc_img", "auc_md", +"top2_fused", "top2_img", "top2_md", +"margin_fused", "margin_img", "margin_md", +"agree_fused_img", "agree_fused_md", +"pct_fused", "pct_img", "pct_md", +"phase", +"holdout_loss", +"holdout_acc_fused", +"holdout_acc_img", +"holdout_acc_md", +"holdout_auc_fused", +"holdout_auc_img", +"holdout_auc_md", +] + + +def focal_loss( + logits: torch.Tensor, + targets: torch.Tensor, + gamma: float = 0.0, + weight: Optional[torch.Tensor] = None, + reduction: str = "mean", +) -> torch.Tensor: + """ + Standard focal loss wrapper. When gamma=0 it reduces to cross entropy. + weight should be per-class weights (same semantics as CrossEntropyLoss). + """ + if gamma <= 0: + return F.cross_entropy(logits, targets, weight=weight, reduction=reduction) + + log_probs = F.log_softmax(logits, dim=1) + probs = log_probs.exp() + + targets = targets.long().view(-1, 1) + logpt = log_probs.gather(1, targets) + pt = probs.gather(1, targets) + + focal_factor = (1.0 - pt).clamp_min(0.0) ** gamma + loss = -focal_factor * logpt + + if weight is not None: + class_weight = weight.gather(0, targets.view(-1)) + loss = loss * class_weight.view(-1, 1) + + loss = loss.view(-1) + if reduction == "sum": + return loss.sum() + if reduction == "mean": + return loss.mean() + return loss + + +class UNetImageCropper: + def __init__( + self, + manifest_path: Path, + weights_path: Path, + normalize: str = "per_image", + threshold: float = 0.5, + tta: bool = False, + scale: float = 2.5, + target_size: int = 224, + cache_dir: Optional[Path] = None, + ) -> None: + self.segmenter = UNetSegmenter( + manifest_path=manifest_path, + normalize=normalize, + ) + state = torch.load(weights_path, map_location=self.segmenter.device) + state_dict = state.get("model", state) + self.segmenter.model.load_state_dict(state_dict) + self.segmenter.model.to(self.segmenter.device) + self.segmenter.model.eval() + + self.threshold = threshold + self.tta = tta + self.scale = scale + self.target_size = target_size + self.cache_dir = Path(cache_dir) if cache_dir is not None else None + if self.cache_dir is not None: + self.cache_dir.mkdir(parents=True, exist_ok=True) + + self.to_tensor = transforms.ToTensor() + + def _cache_path(self, image_path: Path) -> Optional[Path]: + if self.cache_dir is None: + return None + stem = image_path.stem + return self.cache_dir / f"{stem}_s{int(self.scale * 100)}.npz" + + def _infer_masks(self, image: Image.Image) -> Optional[Tuple[np.ndarray, np.ndarray]]: + resized = self.segmenter.preprocess_image(image) + tensor = self.to_tensor(resized).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) + disc_mask = np.array(disc_img, dtype=np.uint8) + cup_img = Image.fromarray(cup_pred, mode="L").resize(image.size, Image.NEAREST) + 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) + disc_mask = (disc_mask > 0).astype(np.uint8) + return disc_mask, cup_mask + + def _compute_crop_info(self, image: Image.Image, image_path: Path) -> Optional[dict]: + image_path = Path(image_path).resolve() + cache_path = self._cache_path(image_path) + cached_bounds = None + if cache_path is not None and cache_path.exists(): + data = np.load(cache_path, allow_pickle=False) + try: + cached_bounds = { + "left": float(data["left"]), + "upper": float(data["upper"]), + "right": float(data["right"]), + "lower": float(data["lower"]), + } + if "features" in data.files: + cached_bounds["features"] = data["features"].astype(np.float32) + return cached_bounds + except KeyError: + cached_bounds = None + + masks = self._infer_masks(image) + if masks is None: + return cached_bounds + disc_mask, cup_mask = masks + try: + geom = _geometry_from_mask(disc_mask, self.scale) + except Exception: + return cached_bounds + cx = geom["centre_x"] + cy = geom["centre_y"] + r = geom["crop_radius"] + left = max(0.0, cx - r) + upper = max(0.0, cy - r) + right = min(float(image.width), cx + r) + lower = min(float(image.height), cy + r) + features = compute_geometry_features(disc_mask, cup_mask) + + info = { + "left": left, + "upper": upper, + "right": right, + "lower": lower, + "features": features, + } + if cache_path is not None: + np.savez( + cache_path, + left=left, + upper=upper, + right=right, + lower=lower, + width=float(image.width), + height=float(image.height), + scale=self.scale, + target_size=self.target_size, + features=features, + ) + return info + + def __call__(self, image: Image.Image, image_path: Path) -> Image.Image: + info = self._compute_crop_info(image, image_path) + if info is None: + return image + left = info["left"] + upper = info["upper"] + right = info["right"] + lower = info["lower"] + if right <= left or lower <= upper: + return image + crop = image.crop((left, upper, right, lower)) + return crop.resize((self.target_size, self.target_size), Image.BILINEAR) + + def geometry_features(self, image: Image.Image, image_path: Path) -> Optional[np.ndarray]: + info = self._compute_crop_info(image, image_path) + if info is None: + return None + features = info.get("features") + if features is None: + return None + return np.asarray(features, dtype=np.float32) + + +class ManifestImageCropper: + def __init__( + self, + manifest_path: Path, + scale: float = 2.5, + target_size: int = 224, + cache_dir: Optional[Path] = None, + ) -> None: + self.scale = scale + self.target_size = target_size + self.cache_dir = Path(cache_dir) if cache_dir is not None else None + if self.cache_dir is not None: + self.cache_dir.mkdir(parents=True, exist_ok=True) + + df = pd.read_csv(manifest_path) + self.entries: Dict[str, dict] = {} + for _, row in df.iterrows(): + img_path = Path(row["image_path"]).resolve() + self.entries[str(img_path)] = { + "annotation_disc": row.get("annotation_disc"), + "annotation_cup": row.get("annotation_cup"), + "annotation_type_disc": row.get("annotation_type_disc"), + "annotation_type_cup": row.get("annotation_type_cup"), + } + + def _cache_path(self, image_path: Path) -> Optional[Path]: + if self.cache_dir is None: + return None + return self.cache_dir / f"{image_path.stem}_s{int(self.scale * 100)}.npz" + + @staticmethod + def _load_contour(path: Path) -> np.ndarray: + coords = np.loadtxt(path) + if coords.ndim == 1: + coords = coords.reshape(-1, 2) + return coords + + @staticmethod + def _contour_to_mask(coords: np.ndarray, size: tuple[int, int]) -> np.ndarray: + if coords is None or coords.size == 0: + return np.zeros((size[1], size[0]), dtype=np.uint8) + img = Image.new("L", size, 0) + draw = ImageDraw.Draw(img) + points = [tuple(map(float, pt)) for pt in coords] + draw.polygon(points, outline=1, fill=1) + return np.array(img, dtype=np.uint8) + + def _load_masks(self, entry: dict, image: Image.Image) -> Optional[Tuple[np.ndarray, np.ndarray]]: + disc_path = entry.get("annotation_disc") + cup_path = entry.get("annotation_cup") + disc_type = (entry.get("annotation_type_disc") or "").lower() + cup_type = (entry.get("annotation_type_cup") or "").lower() + + disc_mask: Optional[np.ndarray] = None + cup_mask: Optional[np.ndarray] = None + + if disc_path and not pd.isna(disc_path): + disc_path = Path(disc_path) + try: + if disc_type == "mask": + mask_img = Image.open(disc_path) + mask_img = mask_img.resize(image.size, Image.NEAREST) + disc_mask, cup_from_mask = disc_cup_from_mask_image(mask_img) + if cup_from_mask.sum() > 0: + cup_mask = cup_from_mask + elif disc_type == "contour": + coords = self._load_contour(disc_path) + disc_mask = self._contour_to_mask(coords, image.size) + except Exception: + disc_mask = None + + if cup_mask is None and cup_path and not pd.isna(cup_path): + cup_path = Path(cup_path) + try: + if cup_type == "mask": + mask_img = Image.open(cup_path) + mask_img = mask_img.resize(image.size, Image.NEAREST) + _, cup_mask = disc_cup_from_mask_image(mask_img) + elif cup_type == "contour": + coords = self._load_contour(cup_path) + cup_mask = self._contour_to_mask(coords, image.size) + except Exception: + cup_mask = None + + if disc_mask is None: + return None + disc_mask = (disc_mask > 0).astype(np.uint8) + if cup_mask is None: + cup_mask = np.zeros_like(disc_mask, dtype=np.uint8) + cup_mask = ((cup_mask > 0) & (disc_mask > 0)).astype(np.uint8) + return disc_mask, cup_mask + + def _compute_crop_info(self, image: Image.Image, image_path: Path) -> Optional[dict]: + image_path = Path(image_path).resolve() + entry = self.entries.get(str(image_path)) + if entry is None: + return None + cache_path = self._cache_path(image_path) + cached_bounds = None + if cache_path is not None and cache_path.exists(): + data = np.load(cache_path, allow_pickle=False) + try: + cached_bounds = { + "left": float(data["left"]), + "upper": float(data["upper"]), + "right": float(data["right"]), + "lower": float(data["lower"]), + } + if "features" in data.files: + cached_bounds["features"] = data["features"].astype(np.float32) + return cached_bounds + except KeyError: + cached_bounds = None + + masks = self._load_masks(entry, image) + if masks is None: + return cached_bounds + disc_mask, cup_mask = masks + try: + geom = _geometry_from_mask(disc_mask, self.scale) + except Exception: + return cached_bounds + cx = geom["centre_x"] + cy = geom["centre_y"] + r = geom["crop_radius"] + left = max(0.0, cx - r) + upper = max(0.0, cy - r) + right = min(float(image.width), cx + r) + lower = min(float(image.height), cy + r) + features = compute_geometry_features(disc_mask, cup_mask) + + info = { + "left": left, + "upper": upper, + "right": right, + "lower": lower, + "features": features, + } + if cache_path is not None: + np.savez( + cache_path, + left=left, + upper=upper, + right=right, + lower=lower, + width=float(image.width), + height=float(image.height), + scale=self.scale, + target_size=self.target_size, + features=features, + ) + return info + + def __call__(self, image: Image.Image, image_path: Path) -> Image.Image: + info = self._compute_crop_info(image, image_path) + if info is None: + return image + left = info["left"] + upper = info["upper"] + right = info["right"] + lower = info["lower"] + + if right <= left or lower <= upper: + return image + crop = image.crop((left, upper, right, lower)) + return crop.resize((self.target_size, self.target_size), Image.BILINEAR) + + def geometry_features(self, image: Image.Image, image_path: Path) -> Optional[np.ndarray]: + info = self._compute_crop_info(image, image_path) + if info is None: + return None + features = info.get("features") + if features is None: + return None + return np.asarray(features, dtype=np.float32) + +def _num(x): + """float(x) or None if NaN/None/invalid.""" + try: + v = float(x) + except Exception: + return None + return None if math.isnan(v) else v + +class _ClinicalView: + """Minimal shim so ClinicalDataset can iterate an epoch-specific DataFrame + while still delegating encoding/paths/labels to the ClinicalData object.""" + def __init__(self, base: ClinicalData, df): + self.base = base + self.df = df + + @property + def image_dir(self): + return self.base.image_dir + + @property + def clinical_dir(self): + return self.base.clinical_dir + + @property + def id_cols(self): + return ("Patient ID", "eyeID") + + @property + def label_col(self): + return self.base.label_col + + @property + def filename_template(self): + # prefer whatever the dataset defined; otherwise fall back to RET{pid}{eye}.jpg style + return getattr(self.base, "filename_template", "RET{pid:03d}{eye}.jpg") + + @property + def dim(self): + return self.base.feature_dim + + def encode_metadata(self, row): + # ClinicalData returns numpy; convert to torch here to keep towers torch-only + import torch as _torch + vec = self.base.vectorize_row(row) + return _torch.as_tensor(vec, dtype=_torch.float32) + + def get_image_path(self, row): + return self.base.get_image_path(row) + + def get_label(self, row): + return int(row[self.base.label_col]) + + +class HyperTower: + + + def __init__(self, clinical: ClinicalData, args): + # Expects a fully built ClinicalData (add_df handled upstream) + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + print(f"Using device: {self.device}") + + self.clinical = clinical + self.warmup_tower_epochs = getattr(args, "warmup_tower_epochs", 2) + self.warmup_fused_epochs = getattr(args, "warmup_fused_epochs", 3) + self.args = args # cache for checkpointing + self._early = None + self.aux_img = getattr(args, "aux_img", 0.05) + self.aux_md = getattr(args, "aux_md", 0.05) + self.aux_detach = getattr(args, "aux_detach", True) + # SE placement configuration + se_where = getattr(args, "se_where", "bridge") # 'bridge'|'tower'|'both'|'none' + use_se_tower = se_where in ("tower", "both") + # Optional disc-centric cropping + self.image_preprocessor = None + crop_manifest = getattr(args, "img_crop_manifest", None) + crop_weights = getattr(args, "img_crop_weights", None) + use_gt = getattr(args, "img_crop_gt", False) + if crop_manifest: + crop_cache = getattr(args, "img_crop_cache", Path("analysis_data/hypertower_crops")) + crop_cache = Path(crop_cache) + if use_gt: + self.image_preprocessor = ManifestImageCropper( + manifest_path=Path(crop_manifest), + scale=getattr(args, "img_crop_scale", 2.5), + target_size=getattr(args, "img_crop_size", 224), + cache_dir=crop_cache, + ) + print(f"[HyperTower] GT disc cropper enabled → cache at {crop_cache}") + elif crop_weights: + self.image_preprocessor = UNetImageCropper( + manifest_path=Path(crop_manifest), + weights_path=Path(crop_weights), + normalize=getattr(args, "img_crop_normalize", "per_image"), + threshold=getattr(args, "img_crop_threshold", 0.5), + tta=getattr(args, "img_crop_tta", False), + scale=getattr(args, "img_crop_scale", 2.5), + target_size=getattr(args, "img_crop_size", 224), + cache_dir=crop_cache, + ) + print(f"[HyperTower] UNet disc cropper enabled → cache at {crop_cache}") + else: + print("[HyperTower] img_crop_manifest provided but no weights/gt flag; skipping cropping") + + self.use_geometry_features = bool(getattr(args, "img_geometry_features", False)) + if self.use_geometry_features and self.image_preprocessor is None: + raise ValueError("img_geometry_features requires --img-crop-manifest with either --img-crop-weights or --img-crop-gt.") + self.geometry_dim = FEATURE_DIM if self.use_geometry_features else 0 + + # Towers derive their dimensions from ClinicalData + self.img_tower = ImageTower( + backbone=getattr(args, "backbone", "efficientnet_b0"), + freeze_ratio=getattr(args, "freeze_ratio", 0.0), + use_se=use_se_tower, + se_reduction=getattr(args, "se_reduction_tower", getattr(args, "se_reduction", 16)), + se_pre_norm=getattr(args, "se_pre_norm_tower", getattr(args, "se_pre_norm", True)), + augment=getattr(args, "img_augment", True), + geometry_dim=self.geometry_dim, + ).to(self.device) + self.md_tower = MDTower( + self.clinical, + use_se=use_se_tower, + se_reduction=getattr(args, "se_reduction_tower", getattr(args, "se_reduction", 16)), + se_pre_norm=getattr(args, "se_pre_norm_tower", getattr(args, "se_pre_norm", True)), + ).to(self.device) + self.mode = args.fusion_mode # cache + # Training hyperparams / objects + self.batch_size = args.batch_size + self.epochs = args.epochs + self.lr = args.lr + self.fold = args.fold + self.criterion = nn.CrossEntropyLoss() + self._ce_weight = self.criterion.weight.detach().clone() if self.criterion.weight is not None else None + if self._ce_weight is not None: + self._ce_weight = self._ce_weight.to(self.device) + self._ce_reduction = self.criterion.reduction + self.focal_gamma = float(getattr(args, "focal_gamma", 0.0) or 0.0) + # Run and model directories come from the script + self.run_dir = Path(getattr(args, "run_dir", ".")).resolve() + self.run_dir.mkdir(parents=True, exist_ok=True) + # Per-run log locations to avoid collisions across concurrent workers + self.epoch_log_path = self.run_dir / "epoch_log.csv" + self.train_log_path = self.run_dir / "train.log" + self.models_dir = Path(getattr(args, "models_dir", "models")).resolve() + self.models_dir.mkdir(parents=True, exist_ok=True) + + self.holdout_df = getattr(args, "holdout_df", None) + self.holdout_loader = None + if isinstance(self.holdout_df, pd.DataFrame) and not self.holdout_df.empty: + if getattr(args, "eval_mode", "multiclass") == "binary": + label_col = getattr(self.clinical, "label_col", None) + if label_col and label_col in self.holdout_df.columns: + self.holdout_df = self.holdout_df[self.holdout_df[label_col].isin([0, 1])].reset_index(drop=True) + self.holdout_loader = self._make_loader_for_df(self.holdout_df, is_train=False) + print(f"[HyperTower] Holdout loader prepared with {len(self.holdout_df)} samples") + + if self.mode == "vote": + # Per-tower classification heads (logits) + vote combiner + self.head_img = nn.Linear(self.img_tower.out_dim, args.num_classes).to(self.device) + self.head_md = nn.Linear(self.md_tower.out_dim, args.num_classes).to(self.device) + self.vote = VoteBridge(num_classes=args.num_classes).to(self.device) + + # Optimizer: towers + heads + vote + self.optimizer = torch.optim.Adam( + list(self.img_tower.parameters()) + + list(self.md_tower.parameters()) + + list(self.head_img.parameters()) + + list(self.head_md.parameters()) + + list(self.vote.parameters()), + lr=self.lr, + ) + else: + # Existing feature-fusion bridge + self.bridge = Bridge( + img_dim=self.img_tower.out_dim, + meta_dim=self.md_tower.out_dim, + num_classes=args.num_classes, + fusion_dim=256, + mode=args.fusion_mode, + use_se=(se_where in ("bridge","both")) and getattr(args, "use_se", True), + se_reduction=(getattr(args, "se_reduction", 16) if getattr(args, "use_se", True) else 0), + se_pre_norm=getattr(args, "se_pre_norm", True) + ).to(self.device) + + self.optimizer = torch.optim.Adam( + list(self.img_tower.parameters()) + + list(self.md_tower.parameters()) + + list(self.bridge.parameters()), + lr=self.lr, + ) + + + # EMA-forgiveness + BCD knobs; logging stays simple for now + self.ema_alpha = args.ema_alpha + self.bcd_prob = args.bcd_prob + self.ema_fused_loss = None + + # File logger (per-run); simple line format, no duplication to root + self.logger = logging.getLogger("hypertower") + self.logger.setLevel(logging.INFO) + # Replace existing handlers to avoid duplicate lines across folds + self.logger.handlers = [] + fh = logging.FileHandler(str(self.train_log_path)) + fh.setFormatter(logging.Formatter("%(asctime)s - %(message)s")) + self.logger.addHandler(fh) + self.logger.propagate = False + + # Dynamic BCD configuration (epoch baselines + batch nudges) + self.bcd_cfg = { + "metric": getattr(args, "bcd_metric", "auc"), + "p0": getattr(args, "bcd_p0", 0.20), + "k": getattr(args, "bcd_k", 0.4), + "pmin": getattr(args, "bcd_min", 0.05), + "pmax": getattr(args, "bcd_max", 0.30), + "alpha_batch": getattr(args, "bcd_alpha_batch", 0.2), + "alpha_tower": getattr(args, "bcd_alpha_tower", 0.3), + "explore_floor": getattr(args, "bcd_explore_floor", 0.15), + "entropy_ema": getattr(args, "entropy_ema", 0.7), + } + + # Track last epoch's eval metrics for baseline deficits; initialize safely + self.last_eval = { + "acc_fused": 0.0, "acc_img": 0.0, "acc_md": 0.0, + "auc_fused": 0.0, "auc_img": 0.0, "auc_md": 0.0, + } + # Running EMA of per-head uncertainty (entropy in [0,1]) + self.entropy_ema = {"fused": 0.5, "img": 0.5, "md": 0.5} + + # DataLoaders are (re)built each epoch from ClinicalData's splits + self.train_loader = None + self.test_loader = None + self._last_step_mix = None + + if not getattr(self.clinical, "folds", None): + raise RuntimeError("ClinicalData has no built folds. Did you call add_df(...) upstream?") + + @staticmethod + def _confusion(preds, labels): + # Quick TP/TN/FP/FN for binary debug (kept as-is) + tp = ((preds == 1) & (labels == 1)).sum().item() + tn = ((preds == 0) & (labels == 0)).sum().item() + fp = ((preds == 1) & (labels == 0)).sum().item() + fn = ((preds == 0) & (labels == 1)).sum().item() + return {"tp": tp, "tn": tn, "fp": fp, "fn": fn} + + def _unpack_batch(self, batch): + if len(batch) == 4: + imgs, metas, geometry, labels = batch + else: + imgs, metas, labels = batch + if self.geometry_dim > 0: + geometry = torch.zeros(imgs.size(0), self.geometry_dim, dtype=torch.float32) + else: + geometry = None + return imgs, metas, geometry, labels + + def _classification_loss(self, logits: torch.Tensor, labels: torch.Tensor) -> torch.Tensor: + """ + Shared classification loss (CrossEntropy or focal depending on gamma). + """ + weight = self._ce_weight + if weight is not None and weight.device != logits.device: + weight = weight.to(logits.device) + return focal_loss( + logits, + labels, + gamma=self.focal_gamma, + weight=weight, + reduction=self._ce_reduction, + ) + + def _step_image_only(self, imgs, metas, geometry, labels): + if self.mode == "vote": + img_feats = self.img_tower(imgs, geometry) + out_img = self.head_img(img_feats) + loss_i = self._classification_loss(out_img, labels) + preds_i = out_img.argmax(dim=1) + acc_i = (preds_i == labels).float().mean().item() + cm = self._confusion(preds_i, labels) + + self.optimizer.zero_grad() + loss_i.backward() + self.optimizer.step() + return None, loss_i.item(), None, None, acc_i, None, {"img": cm} + else:# Optimize the image head alone (used when BCD chooses image-only) + img_feats = self.img_tower(imgs, geometry) + out_img = self.bridge.classifier_img(img_feats) + loss_i = self._classification_loss(out_img, labels) + preds_i = out_img.argmax(dim=1) + acc_i = (preds_i == labels).float().mean().item() + cm = self._confusion(preds_i, labels) + self.optimizer.zero_grad() + loss_i.backward() + self.optimizer.step() + return None, loss_i.item(), None, None, acc_i, None, {"img": cm} + + def _step_meta_only(self, imgs, metas, geometry, labels): + # Optimize the metadata head alone (used when BCD chooses meta-only) + if self.mode == "vote": + meta_feats = self.md_tower(metas) + out_md = self.head_md(meta_feats) + loss_m = self._classification_loss(out_md, labels) + preds_m = out_md.argmax(dim=1) + acc_m = (preds_m == labels).float().mean().item() + cm = self._confusion(preds_m, labels) + + self.optimizer.zero_grad() + loss_m.backward() + self.optimizer.step() + return None, None, loss_m.item(), None, None, acc_m, {"meta": cm} + else: + meta_feats = self.md_tower(metas) + out_md = self.bridge.classifier_md(meta_feats) + loss_m = self._classification_loss(out_md, labels) + preds_m = out_md.argmax(dim=1) + acc_m = (preds_m == labels).float().mean().item() + cm = self._confusion(preds_m, labels) + self.optimizer.zero_grad() + loss_m.backward() + self.optimizer.step() + return None, None, loss_m.item(), None, None, acc_m, {"meta": cm} + + def _step_fused(self, imgs, metas, geometry, labels): + if self.mode == "vote": + img_feats = self.img_tower(imgs, geometry) + md_feats = self.md_tower(metas) + + out_img = self.head_img(img_feats) + out_md = self.head_md(md_feats) + out_fused = self.vote(out_img, out_md) + + preds_f = out_fused.argmax(dim=1) + acc_f = (preds_f == labels).float().mean().item() + loss_f = self._classification_loss(out_fused, labels) + loss_f_val = float(loss_f.detach().item()) + + # Optional: keep your EMA + small aux tower losses just like before + if self.ema_fused_loss is None: + self.ema_fused_loss = loss_f_val + L_total = (1.0 - self.ema_alpha) * loss_f + self.ema_alpha * loss_f.new_tensor(self.ema_fused_loss) + + aux_terms = 0.0 + if self.aux_img > 0: + aux_terms = aux_terms + self.aux_img * self._classification_loss( + out_img.detach() if self.aux_detach else out_img, + labels, + ) + if self.aux_md > 0: + aux_terms = aux_terms + self.aux_md * self._classification_loss( + out_md.detach() if self.aux_detach else out_md, + labels, + ) + L_total = L_total + aux_terms + + self.optimizer.zero_grad() + L_total.backward() + self.optimizer.step() + + self.ema_fused_loss = self.ema_alpha * self.ema_fused_loss + (1.0 - self.ema_alpha) * loss_f_val + + # Confusion tables for logging + cm = { + "fused": self._confusion(preds_f, labels), + "img": self._confusion(out_img.argmax(dim=1), labels), + "meta": self._confusion(out_md.argmax(dim=1), labels), + } + acc_i = (out_img.argmax(dim=1) == labels).float().mean().item() + acc_m = (out_md.argmax(dim=1) == labels).float().mean().item() + + with torch.no_grad(): + loss_img_val = self._classification_loss(out_img, labels).item() + loss_md_val = self._classification_loss(out_md, labels).item() + return loss_f_val, loss_img_val, loss_md_val, acc_f, acc_i, acc_m, cm + else: + img_feats = self.img_tower(imgs, geometry) + meta_feats = self.md_tower(metas) + out_fused, out_img, out_md = self.bridge(img_feats, meta_feats) + + preds_f = out_fused.argmax(dim=1) + acc_f = (preds_f == labels).float().mean().item() + loss_f = self._classification_loss(out_fused, labels) + loss_f_val = float(loss_f.detach().item()) + + cm = {"fused": self._confusion(preds_f, labels)} + loss_i = acc_i = loss_m = acc_m = None + + if out_img is not None: + with torch.no_grad(): + preds_i = out_img.argmax(dim=1) + acc_i = (preds_i == labels).float().mean().item() + loss_i = self._classification_loss(out_img, labels).item() + cm["img"] = self._confusion(preds_i, labels) + if out_md is not None: + with torch.no_grad(): + preds_m = out_md.argmax(dim=1) + acc_m = (preds_m == labels).float().mean().item() + loss_m = self._classification_loss(out_md, labels).item() + cm["meta"] = self._confusion(preds_m, labels) + + # EMA-blended fused loss + if self.ema_fused_loss is None: + self.ema_fused_loss = loss_f_val + L_total = (1.0 - self.ema_alpha) * loss_f + self.ema_alpha * loss_f.new_tensor(self.ema_fused_loss) + + # Auxiliary tower losses (small weights). Use detached features to calibrate heads only. + aux_terms = 0.0 + if self.aux_img > 0 and out_img is not None: + logits_img_for_aux = self.bridge.classifier_img(img_feats.detach()) if self.aux_detach else out_img + aux_terms = aux_terms + self.aux_img * self._classification_loss(logits_img_for_aux, labels) + if self.aux_md > 0 and out_md is not None: + logits_md_for_aux = self.bridge.classifier_md(meta_feats.detach()) if self.aux_detach else out_md + aux_terms = aux_terms + self.aux_md * self._classification_loss(logits_md_for_aux, labels) + + L_total = L_total + aux_terms + + self.optimizer.zero_grad() + L_total.backward() + self.optimizer.step() + + # update EMA after the step + self.ema_fused_loss = self.ema_alpha * self.ema_fused_loss + (1.0 - self.ema_alpha) * loss_f_val + + return loss_f_val, loss_i, loss_m, acc_f, acc_i, acc_m, cm + + + def _make_loader_for_df(self, df, is_train: bool = False): + # Rebuild a DataLoader for the current epoch's split + view = _ClinicalView(self.clinical, df) + geometry_provider = self.image_preprocessor if self.geometry_dim > 0 else None + ds = ClinicalDataset( + view, + self.img_tower.transform, + image_preprocessor=self.image_preprocessor, + geometry_provider=geometry_provider, + geometry_dim=self.geometry_dim, + ) + + # Optional: class-balanced bootstrapped sampling for TRAIN only + if is_train and getattr(self.args, "balanced_sampler", False): + import numpy as _np + y = _np.asarray(df[self.clinical.label_col].values) + # inverse-frequency weights per class + uniq, counts = _np.unique(y, return_counts=True) + inv = {c: (1.0 / cnt if cnt > 0 else 0.0) for c, cnt in zip(uniq, counts)} + w = _np.array([inv[c] for c in y], dtype=_np.float32) + sampler = WeightedRandomSampler(weights=w, num_samples=len(y), replacement=True) + return DataLoader(ds, batch_size=self.batch_size, sampler=sampler, shuffle=False) + + return DataLoader(ds, batch_size=self.batch_size, shuffle=not is_train) + + # ---- Dynamic BCD helpers ---- + def _metric_value(self, name: str) -> float: + # Pull either AUC or ACC from last_eval, falling back if NaN/zero + if self.bcd_cfg["metric"] == "auc": + v = self.last_eval.get(f"auc_{name}", 0.0) + if v == v: # not NaN + return float(v) + # fallback to accuracy + return float(self.last_eval.get(f"acc_{name}", 0.0)) + return float(self.last_eval.get(f"acc_{name}", 0.0)) + + def _epoch_bcd_baseline(self): + # Compute epoch-level baselines: p_bcd_epoch and tower weights w_i, w_m + p0 = self.bcd_cfg["p0"]; k = self.bcd_cfg["k"] + pmin = self.bcd_cfg["pmin"]; pmax = self.bcd_cfg["pmax"] + eps = 1e-6 + Af = self._metric_value("fused"); Ai = self._metric_value("img"); Am = self._metric_value("md") + di = max(0.0, Af - Ai); dm = max(0.0, Af - Am) + p_bcd_epoch = max(pmin, min(pmax, p0 + k * (di + dm) / 2.0)) + wi = (di + eps) / (di + dm + 2 * eps) + wm = 1.0 - wi + return p_bcd_epoch, wi, wm, {"di": di, "dm": dm, "Af": Af, "Ai": Ai, "Am": Am} + + @staticmethod + def _entropy_from_logits(logits: torch.Tensor, num_classes: int) -> float: + # Returns entropy normalized to [0,1] using log(K) denominator + with torch.no_grad(): + probs = F.softmax(logits, dim=1) + ent = -(probs * (probs.clamp_min(1e-12)).log()).sum(dim=1) + ent = ent / np.log(num_classes) + return float(ent.mean().item()) + + def _batch_uncertainty(self, imgs: torch.Tensor, metas: torch.Tensor, geometry: Optional[torch.Tensor] = None) -> dict: + self.img_tower.eval(); self.md_tower.eval() + if self.mode == "vote": + self.head_img.eval(); self.head_md.eval(); self.vote.eval() + with torch.no_grad(): + if geometry is None and self.geometry_dim > 0: + geometry = torch.zeros(imgs.size(0), self.geometry_dim, device=imgs.device, dtype=imgs.dtype) + img_feats = self.img_tower(imgs, geometry) + md_feats = self.md_tower(metas) + out_img = self.head_img(img_feats) + out_md = self.head_md(md_feats) + out_fused = self.vote(out_img, out_md) + K = out_fused.shape[1] + e_f = self._entropy_from_logits(out_fused, K) + e_i = self._entropy_from_logits(out_img, K) + e_m = self._entropy_from_logits(out_md, K) + # restore train() + self.img_tower.train(); self.md_tower.train() + self.head_img.train(); self.head_md.train(); self.vote.train() + else: + self.bridge.eval() + with torch.no_grad(): + if geometry is None and self.geometry_dim > 0: + geometry = torch.zeros(imgs.size(0), self.geometry_dim, device=imgs.device, dtype=imgs.dtype) + img_feats = self.img_tower(imgs, geometry) + meta_feats = self.md_tower(metas) + out_fused, out_img, out_md = self.bridge(img_feats, meta_feats) + K = out_fused.shape[1] + e_f = self._entropy_from_logits(out_fused, K) + e_i = self._entropy_from_logits(out_img, K) if out_img is not None else 0.5 + e_m = self._entropy_from_logits(out_md, K) if out_md is not None else 0.5 + self.img_tower.train(); self.md_tower.train(); self.bridge.train() + + a = self.bcd_cfg["entropy_ema"] + self.entropy_ema["fused"] = a * self.entropy_ema["fused"] + (1 - a) * e_f + self.entropy_ema["img"] = a * self.entropy_ema["img"] + (1 - a) * e_i + self.entropy_ema["md"] = a * self.entropy_ema["md"] + (1 - a) * e_m + return {"fused": self.entropy_ema["fused"], "img": self.entropy_ema["img"], "md": self.entropy_ema["md"]} + + def _write_epoch_log(self, row: dict, path: str | Path | None = None): + """ + Write an epoch row. Initializes the CSV with a stable header on first call. + Later calls auto-fill missing columns with None so nothing gets dropped. + """ + # known optional columns we might add later + OPTIONAL_COLS = [ + "pct_fused", "pct_img", "pct_md", "phase", + "se_mean", "se_std", "se_pct_lt_0.2", "se_pct_gt_0.8", + "holdout_loss", "holdout_acc_fused", "holdout_acc_img", "holdout_acc_md", + "holdout_auc_fused", "holdout_auc_img", "holdout_auc_md", + ] + + # On first call: create file, lock fieldnames + if not hasattr(self, "_epoch_log_writer"): + # union of current row keys + optional columns so header includes them even if None now + fieldnames = list(dict.fromkeys([*row.keys(), *OPTIONAL_COLS])) + self._epoch_log_path = Path(path) if path is not None else self.epoch_log_path + self._epoch_log_path.parent.mkdir(parents=True, exist_ok=True) + self._epoch_log_fp = open(self._epoch_log_path, "w", newline="") + self._epoch_log_writer = csv.DictWriter(self._epoch_log_fp, fieldnames=fieldnames) + self._epoch_log_writer.writeheader() + self._epoch_log_fields = fieldnames # remember for future rows + + # Ensure all header fields exist in this row + for k in self._epoch_log_fields: + row.setdefault(k, None) + + # Write and flush + self._epoch_log_writer.writerow({k: row.get(k) for k in self._epoch_log_fields}) + self._epoch_log_fp.flush() + + def _snapshot(self): + state = { + "img_tower": self.img_tower.state_dict(), + "md_tower": self.md_tower.state_dict(), + "optimizer": self.optimizer.state_dict(), + "args": vars(self.args), + } + if hasattr(self, "bridge"): state["bridge"] = self.bridge.state_dict() + if hasattr(self, "head_img"): state["head_img"] = self.head_img.state_dict() + if hasattr(self, "head_md"): state["head_md"] = self.head_md.state_dict() + return state + + + def train(self): + best_path = None + if getattr(self.args, "checkpoint_best", False): + best_path = str((self.models_dir / "best.pth").resolve()) + + # Always track the best model even if we don't stop early + monitor = getattr(self.args, "early_metric", None) + if not monitor: + # Prefer AUC-based monitoring by default; fall back to loss only if specified + if self.mode == "image_only": + monitor = "auc_img" + elif self.mode == "metadata_only": + monitor = "auc_md" + else: # fused or vote + monitor = "auc_fused" + mode = getattr(self.args, "early_mode", "auto") + # Optional switch: monitor holdout metrics instead of validation + use_holdout_monitor = bool(getattr(self.args, "early_monitor_holdout", False)) + if use_holdout_monitor and self.holdout_loader is None: + print("[early] Requested holdout monitoring but no holdout set is configured; falling back to validation metrics.") + use_holdout_monitor = False + if use_holdout_monitor: + # If the monitor isn't already a holdout metric, prepend it + if not monitor.startswith("holdout_"): + monitor = f"holdout_{monitor}" + # Force sensible default if no monitor was provided + if monitor == "holdout_auc_fused" and mode == "auto": + mode = "max" + if mode == "auto": + mode = ("min" if ("loss" in monitor.lower()) else "max") + self._best_info = { + "monitor": monitor, + "mode": mode, + "min_delta": float(getattr(self.args, "early_min_delta", 0.0)), + "value": (-math.inf if mode == "max" else math.inf), + "epoch": -1, + "state": None, + "save_path": best_path, + } + # keep args in sync so EarlyStopper uses the same monitor/mode + self.args.early_metric = monitor + self.args.early_mode = mode + # Separate tracker for best holdout performance (if a holdout set is provided) + self._holdout_best = None + if self.holdout_loader is not None: + self._holdout_best = { + "monitor": "holdout_auc_fused", + "mode": "max", + "min_delta": 0.0, + "value": -math.inf, + "epoch": -1, + "state": None, + "save_path": str((self.models_dir / "model_holdout_best.pt").resolve()), + } + + self._early = None + if getattr(self.args, "early_stop", False): + self._early = EarlyStopper( + monitor=monitor, + mode=mode, + patience=self.args.early_patience, + min_delta=self.args.early_min_delta, + save_path=best_path, + restore_best=True, + ) + # Gradual thaw config + gradual = bool(getattr(self.args, "gradual_thaw", False)) + thaw_ratio = float(getattr(self.args, "thaw_ratio", 0.33)) + thaw_phase = int(getattr(self.args, "thaw_phase_duration", 5)) + thaw_target = str(getattr(self.args, "thaw_target", "image")) + thaw_start_epoch = int(getattr(self.args, "thaw_start_epoch", -1)) + thaw_initial_freeze = bool(getattr(self.args, "thaw_initial_freeze", False)) + if thaw_start_epoch < 0: + thaw_start_epoch = int(getattr(self, "warmup_tower_epochs", 0)) + + for epoch in range(self.epochs): + # Apply thaw schedule at epoch start: start fully frozen and unfreeze by ratios + if gradual and thaw_phase > 0: + try: + if epoch < thaw_start_epoch: + if thaw_initial_freeze: + freeze_r = 1.0 + if thaw_target in ("image", "both") and hasattr(self, "img_tower"): + self.img_tower.set_freeze_ratio(freeze_r) + if thaw_target in ("metadata", "both") and hasattr(self, "md_tower"): + self.md_tower.set_freeze_ratio(freeze_r) + # else: leave current requires_grad as constructed (no forced freeze) + else: + phase_idx = (epoch - thaw_start_epoch) // thaw_phase + freeze_r = max(0.0, 1.0 - phase_idx * thaw_ratio) + if thaw_target in ("image", "both") and hasattr(self, "img_tower"): + self.img_tower.set_freeze_ratio(freeze_r) + if thaw_target in ("metadata", "both") and hasattr(self, "md_tower"): + self.md_tower.set_freeze_ratio(freeze_r) + except Exception: + pass + # build splits/loaders per-epoch + train_df, test_df = self.clinical.get_split_dfs(self.fold) + if hasattr(self.bridge, "reset_se_stats"): + self.bridge.reset_se_stats() + if getattr(self, "args", None) is None: + self.args = argparse.Namespace() # if not stashed already + # Expect user to pass --eval_mode and --num-classes==2 for binary + if getattr(self.args, "eval_mode", "multiclass") == "binary": + # Drop Suspect (assumed label==2 in PAPILA spreadsheets) + train_df = train_df[train_df[self.clinical.label_col].isin([0,1])].copy() + test_df = test_df [test_df [self.clinical.label_col].isin([0,1])].copy() + self.train_loader = self._make_loader_for_df(train_df, is_train=True) + self.test_loader = self._make_loader_for_df(test_df, is_train=False) + + self.img_tower.train(); self.md_tower.train(); self.bridge.train() + + total_losses = {"fused":0.0,"image_only":0.0,"metadata_only":0.0} + total_accs = {"fused":0.0,"image_only":0.0,"metadata_only":0.0} + counts = {"fused":0, "image_only":0, "metadata_only":0} + step_counts = {"fused":0, "image_only":0, "metadata_only":0} + + # phase selection + in_tower_warmup = epoch < self.warmup_tower_epochs + in_fused_warmup = (self.warmup_tower_epochs <= epoch < (self.warmup_tower_epochs + self.warmup_fused_epochs)) + + # epoch baselines (needed for splits and logging) + p_bcd_epoch, wi_epoch, wm_epoch, diag = self._epoch_bcd_baseline() + + if in_tower_warmup: + # tower-only warmup: alternate or use epoch split; default to 0.5 if empty + wi = wi_epoch if (diag["Ai"] or diag["Am"]) else 0.5 + for batch_idx, batch in enumerate(self.train_loader): + imgs, metas, geometry, labels = self._unpack_batch(batch) + imgs = imgs.to(self.device) + metas = metas.to(self.device) + labels = labels.to(self.device) + geometry = geometry.to(self.device) if geometry is not None else None + if random() < wi: + step_type = "image" + loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_image_only(imgs, metas, geometry, labels) + step_counts["image_only"] += 1 + else: + step_type = "meta" + loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_meta_only(imgs, metas, geometry, labels) + step_counts["metadata_only"] += 1 + + self.logger.info(f"epoch={epoch} batch={batch_idx} phase=tower_warmup step={step_type} " + f"loss_f={loss_f} loss_i={loss_i} loss_m={loss_m} acc_f={acc_f} acc_i={acc_i} acc_m={acc_m} cm={cm}") + + if loss_i is not None: + total_losses["image_only"] += loss_i; total_accs["image_only"] += acc_i; counts["image_only"] += 1 + if loss_m is not None: + total_losses["metadata_only"] += loss_m; total_accs["metadata_only"] += acc_m; counts["metadata_only"] += 1 + + elif in_fused_warmup: + # fused-only warmup + for batch_idx, batch in enumerate(self.train_loader): + imgs, metas, geometry, labels = self._unpack_batch(batch) + imgs = imgs.to(self.device) + metas = metas.to(self.device) + labels = labels.to(self.device) + geometry = geometry.to(self.device) if geometry is not None else None + step_type = "fused" + loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_fused(imgs, metas, geometry, labels) + step_counts["fused"] += 1 + + self.logger.info(f"epoch={epoch} batch={batch_idx} phase=fused_warmup step={step_type} " + f"loss_f={loss_f} acc_f={acc_f} cm={cm}") + + total_losses["fused"] += loss_f; total_accs["fused"] += acc_f; counts["fused"] += 1 + + else: + # dynamic BCD (fusion-centric) + self.logger.info( + f"epoch={epoch} bcd_epoch={p_bcd_epoch:.3f} wi_epoch={wi_epoch:.3f} wm_epoch={wm_epoch:.3f} " + f"deficits={{img:{diag['di']:.3f}, md:{diag['dm']:.3f}}} metrics={{Af:{diag['Af']:.3f}, Ai:{diag['Ai']:.3f}, Am:{diag['Am']:.3f}}}" + ) + for batch_idx, batch in enumerate(self.train_loader): + imgs, metas, geometry, labels = self._unpack_batch(batch) + imgs = imgs.to(self.device) + metas = metas.to(self.device) + labels = labels.to(self.device) + geometry = geometry.to(self.device) if geometry is not None else None + ents = self._batch_uncertainty(imgs, metas, geometry) + + # flip rule: higher fused entropy => fewer tower-only batches => more fused training + alpha_b = self.bcd_cfg["alpha_batch"]; pmin = self.bcd_cfg["pmin"]; pmax = self.bcd_cfg["pmax"] + p_bcd_batch = (1 - alpha_b) * p_bcd_epoch + alpha_b * (1.0 - ents["fused"]) + p_bcd_batch = max(pmin, min(pmax, p_bcd_batch)) + + # image vs meta split + alpha_t = self.bcd_cfg["alpha_tower"]; floor = self.bcd_cfg["explore_floor"]; eps = 1e-6 + s_img = ents["img"] / (ents["img"] + ents["md"] + eps) + p_img_batch = (1 - alpha_t) * wi_epoch + alpha_t * s_img + p_img_batch = max(floor, min(1.0 - floor, p_img_batch)) + + u = random(); v = random() + if u < p_bcd_batch: + if v < p_img_batch: + step_type = "image" + loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_image_only(imgs, metas, geometry, labels) + step_counts["image_only"] += 1 + else: + step_type = "meta" + loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_meta_only(imgs, metas, geometry, labels) + step_counts["metadata_only"] += 1 + else: + step_type = "fused" + loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_fused(imgs, metas, geometry, labels) + step_counts["fused"] += 1 + + self.logger.info( + f"epoch={epoch} batch={batch_idx} phase=dynamic step={step_type} " + f"p_bcd_batch={p_bcd_batch:.3f} p_img_batch={p_img_batch:.3f} " + f"ents={{f:{ents['fused']:.3f}, i:{ents['img']:.3f}, m:{ents['md']:.3f}}} " + f"loss_f={loss_f} loss_i={loss_i} loss_m={loss_m} acc_f={acc_f} acc_i={acc_i} acc_m={acc_m} cm={cm}" + ) + + if loss_f is not None: + total_losses["fused"] += loss_f; total_accs["fused"] += acc_f; counts["fused"] += 1 + if loss_i is not None: + total_losses["image_only"] += loss_i; total_accs["image_only"] += acc_i; counts["image_only"] += 1 + if loss_m is not None: + total_losses["metadata_only"] += loss_m; total_accs["metadata_only"] += acc_m; counts["metadata_only"] += 1 + + # epoch prints + avg_loss = total_losses["fused"] / counts["fused"] if counts["fused"] else 0.0 + avg_fused = total_accs["fused"] / counts["fused"] if counts["fused"] else 0.0 + avg_img = total_accs["image_only"] / counts["image_only"] if counts["image_only"] else 0.0 + avg_md = total_accs["metadata_only"] / counts["metadata_only"] if counts["metadata_only"] else 0.0 + + # realized mix for logging + tot_steps = sum(step_counts.values()) or 1 + self._last_step_mix = { + "pct_fused": step_counts["fused"] / tot_steps, + "pct_img": step_counts["image_only"] / tot_steps, + "pct_md": step_counts["metadata_only"] / tot_steps, + "phase": "tower_warmup" if in_tower_warmup else ("fused_warmup" if in_fused_warmup else "dynamic") + } + + print( + f"[Epoch {epoch+1}/{self.epochs}] Train Loss: {avg_loss:.4f} | " + f"Fused Acc: {100*avg_fused:.2f}% | Img Acc: {100*avg_img:.2f}% | Md Acc: {100*avg_md:.2f}%" + ) + row, stop_now = self.evaluate(epoch) + if stop_now: + print(f"[early] stopping on '{self.args.early_metric}' " + f"with best={self._early.best:.5f} at epoch {epoch+1 - self._early.bad_epochs}") + break + + torch.save(self._snapshot(), str(self.models_dir / "model_last.pt")) + # Ensure holdout-best checkpoint is written + if getattr(self, "_holdout_best", None): + hb = self._holdout_best + if hb.get("state") is not None and hb.get("save_path"): + try: + torch.save(hb["state"], hb["save_path"]) + except Exception: + pass + + def _copy_best_roc(epoch_idx: int, tag: str): + if epoch_idx is None or epoch_idx < 0: + return + src_dir = self.run_dir / "roc_curves" + if not src_dir.exists(): + return + prefix = f"epoch{epoch_idx + 1}_" + dest_dir = self.run_dir / f"roc_curves_{tag}" + dest_dir.mkdir(parents=True, exist_ok=True) + for path in src_dir.glob(f"{prefix}*.json"): + try: + shutil.copy2(path, dest_dir / path.name) + except Exception: + pass + if getattr(self, "_early", None): + print(f"[early] restoring best model at epoch {self._early.best_epoch+1}: {self._early.best:.5f}") + self._early.restore(self) + elif getattr(self, "_best_info", None) and self._best_info.get("state") is not None: + be = self._best_info + print(f"[best] restoring best model at epoch {be['epoch']+1}: {be['value']:.5f} (monitor={be['monitor']})") + self._restore_from_state(be["state"]) + torch.save(self._snapshot(), str(self.models_dir / "model_best.pt")) + # Snapshot ROC curves for both main-best and holdout-best epochs + if getattr(self, "_early", None) and getattr(self._early, "best_epoch", None) is not None: + _copy_best_roc(self._early.best_epoch, "best") + elif getattr(self, "_best_info", None): + _copy_best_roc(self._best_info.get("epoch", -1), "best") + if getattr(self, "_holdout_best", None): + _copy_best_roc(self._holdout_best.get("epoch", -1), "holdout_best") + + + + def _evaluate_split(self, loader, epoch: int, split: str): + """ + Shared evaluation helper that computes core metrics (loss/accuracy/AUC/etc.) + for a given dataloader. Returns a dict with scalar metrics or None if the + loader is empty. + """ + if loader is None: + return None + + total_loss = 0.0 + total_counts = {"fused": 0, "image_only": 0, "metadata_only": 0} + total_accs = {"fused": 0.0, "image_only": 0.0, "metadata_only": 0.0} + all_y = [] + probs_f_list, probs_i_list, probs_m_list = [], [], [] + agree_f_img = 0 + agree_f_md = 0 + n_img_comp = 0 + n_md_comp = 0 + + with torch.no_grad(): + for batch in loader: + imgs, metas, geometry, labels = self._unpack_batch(batch) + imgs = imgs.to(self.device) + metas = metas.to(self.device) + labels = labels.to(self.device) + geometry = geometry.to(self.device) if geometry is not None else None + + if self.mode == "vote": + img_feats = self.img_tower(imgs, geometry) + md_feats = self.md_tower(metas) + out_img = self.head_img(img_feats) + out_md = self.head_md(md_feats) + out_fused = self.vote(out_img, out_md) + else: + img_feats = self.img_tower(imgs, geometry) + md_feats = self.md_tower(metas) + outputs = self.bridge(img_feats, md_feats) + if isinstance(outputs, tuple): + out_fused, out_img, out_md = outputs + else: + out_fused, out_img, out_md = outputs, None, None + + all_y.append(labels.cpu().numpy()) + probs_f_list.append(F.softmax(out_fused, dim=1).cpu().numpy()) + + loss = self._classification_loss(out_fused, labels) + total_loss += loss.item() + + preds_f = out_fused.argmax(dim=1) + total_accs["fused"] += (preds_f == labels).float().mean().item() + total_counts["fused"] += 1 + + if out_img is not None: + preds_i = out_img.argmax(dim=1) + total_accs["image_only"] += (preds_i == labels).float().mean().item() + total_counts["image_only"] += 1 + probs_i_list.append(F.softmax(out_img, dim=1).cpu().numpy()) + + agree_f_img += (preds_f == preds_i).sum().item() + n_img_comp += preds_f.numel() + + if out_md is not None: + preds_m = out_md.argmax(dim=1) + total_accs["metadata_only"] += (preds_m == labels).float().mean().item() + total_counts["metadata_only"] += 1 + probs_m_list.append(F.softmax(out_md, dim=1).cpu().numpy()) + + agree_f_md += (preds_f == preds_m).sum().item() + n_md_comp += preds_f.numel() + + if total_counts["fused"] == 0: + return None + + avg_loss = total_loss / total_counts["fused"] + avg_fused = total_accs["fused"] / total_counts["fused"] + avg_img = (total_accs["image_only"] / total_counts["image_only"]) if total_counts["image_only"] else 0.0 + avg_md = (total_accs["metadata_only"] / total_counts["metadata_only"]) if total_counts["metadata_only"] else 0.0 + + y_true = np.concatenate(all_y, axis=0) if all_y else np.array([]) + y_prob_f = np.concatenate(probs_f_list, axis=0) if probs_f_list else None + y_prob_i = np.concatenate(probs_i_list, axis=0) if probs_i_list else None + y_prob_m = np.concatenate(probs_m_list, axis=0) if probs_m_list else None + + def compute_multiclass_roc(y_true_np, prob_np): + if prob_np is None or prob_np.size == 0: + return None + C = int(prob_np.shape[1]) + classes = list(range(C)) + y_bin = label_binarize(y_true_np, classes=classes) + per_class = {} + aucs = [] + for c in range(C): + try: + fpr, tpr, _ = roc_curve(y_bin[:, c], prob_np[:, c]) + auc_val = float(auc(fpr, tpr)) if len(fpr) > 1 else float("nan") + per_class[c] = {"fpr": fpr.tolist(), "tpr": tpr.tolist(), "auc": auc_val} + aucs.append(auc_val) + except Exception: + per_class[c] = {"fpr": [0.0, 1.0], "tpr": [0.0, 1.0], "auc": float("nan")} + + try: + fpr_micro, tpr_micro, _ = roc_curve(y_bin.ravel(), prob_np[:, :C].ravel()) + auc_micro = float(auc(fpr_micro, tpr_micro)) + micro = {"fpr": fpr_micro.tolist(), "tpr": tpr_micro.tolist(), "auc": auc_micro} + except Exception: + micro = None + + auc_vals = [v["auc"] for v in per_class.values() if v["auc"] == v["auc"]] + macro_auc = float(np.mean(auc_vals)) if auc_vals else float("nan") + return {"per_class": per_class, "micro": micro, "macro_auc": macro_auc} + + def macro_ovr_auc(y, p): + try: + y = np.asarray(y) + if p is None: + return float("nan") + p = np.asarray(p) + if p.ndim == 2 and p.shape[1] == 2: + return roc_auc_score(y, p[:, 1]) + if p.ndim == 1 or p.shape[1] == 1: + return roc_auc_score(y, p.ravel()) + return roc_auc_score(y, p, multi_class="ovr", average="macro") + except Exception: + return float("nan") + + def top2_acc(y, p): + if p is None: + return float("nan") + if p.ndim != 2: + return float("nan") + k = 2 if p.shape[1] >= 2 else 1 + topk = np.argpartition(-p, kth=k - 1, axis=1)[:, :k] + return np.mean((topk == y[:, None]).any(axis=1).astype(np.float32)) + + def avg_margin(p): + if p is None or p.ndim != 2: + return float("nan") + s = np.sort(p, axis=1)[:, ::-1] + if s.shape[1] == 1: + return float("nan") + return float(np.mean(s[:, 0] - s[:, 1])) + + roc_f = compute_multiclass_roc(y_true, y_prob_f) + roc_i = compute_multiclass_roc(y_true, y_prob_i) + roc_m = compute_multiclass_roc(y_true, y_prob_m) + + roc_dir = self.run_dir / "roc_curves" + roc_dir.mkdir(parents=True, exist_ok=True) + + def dump_roc(blob, head: str): + if blob is None: + return + suffix = head if split == "val" else f"{split}_{head}" + path = roc_dir / f"epoch{epoch + 1}_{suffix}.json" + with open(path, "w") as f: + json.dump(blob, f) + + dump_roc(roc_f, "fused") + dump_roc(roc_i, "image") + dump_roc(roc_m, "metadata") + + auc_f = macro_ovr_auc(y_true, y_prob_f) if y_prob_f is not None else float("nan") + auc_i = macro_ovr_auc(y_true, y_prob_i) if y_prob_i is not None else float("nan") + auc_m = macro_ovr_auc(y_true, y_prob_m) if y_prob_m is not None else float("nan") + + top2_f = top2_acc(y_true, y_prob_f) + top2_i = top2_acc(y_true, y_prob_i) + top2_m = top2_acc(y_true, y_prob_m) + mar_f = avg_margin(y_prob_f) + mar_i = avg_margin(y_prob_i) + mar_m = avg_margin(y_prob_m) + + agree_rate_f_img = (agree_f_img / n_img_comp) if n_img_comp else float("nan") + agree_rate_f_md = (agree_f_md / n_md_comp) if n_md_comp else float("nan") + + return { + "loss": float(avg_loss), + "acc_fused": float(avg_fused), + "acc_img": float(avg_img), + "acc_md": float(avg_md), + "auc_fused": float(auc_f) if auc_f == auc_f else float("nan"), + "auc_img": float(auc_i) if auc_i == auc_i else float("nan"), + "auc_md": float(auc_m) if auc_m == auc_m else float("nan"), + "top2_fused": float(top2_f) if top2_f == top2_f else float("nan"), + "top2_img": float(top2_i) if top2_i == top2_i else float("nan"), + "top2_md": float(top2_m) if top2_m == top2_m else float("nan"), + "margin_fused": float(mar_f) if mar_f == mar_f else float("nan"), + "margin_img": float(mar_i) if mar_i == mar_i else float("nan"), + "margin_md": float(mar_m) if mar_m == mar_m else float("nan"), + "agree_fused_img": float(agree_rate_f_img) if agree_rate_f_img == agree_rate_f_img else float("nan"), + "agree_fused_md": float(agree_rate_f_md) if agree_rate_f_md == agree_rate_f_md else float("nan"), + } + + + def evaluate(self, epoch: int): + # Evaluation additionally collects per-head probabilities to report macro AUC + self.img_tower.eval() + self.md_tower.eval() + if self.mode == "vote": + self.head_img.eval(); self.head_md.eval(); self.vote.eval() + else: + self.bridge.eval() + + metrics_val = self._evaluate_split(self.test_loader, epoch, "val") + metrics_holdout = self._evaluate_split(self.holdout_loader, epoch, "holdout") if self.holdout_loader else None + + if metrics_val is None: + raise RuntimeError("Validation loader produced no batches; cannot compute metrics.") + + def ffmt(x): + return "NA" if (x != x) else f"{x:.3f}" + + print( + f"[Epoch {epoch+1}/{self.epochs}] Eval Loss: {metrics_val['loss']:.4f} | " + f"Fused Acc: {100*metrics_val['acc_fused']:.2f}% | " + f"Img Acc: {100*metrics_val['acc_img']:.2f}% | " + f"Md Acc: {100*metrics_val['acc_md']:.2f}% | " + f"Fused AUC: {ffmt(metrics_val['auc_fused'])} | " + f"Img AUC: {ffmt(metrics_val['auc_img'])} | " + f"Md AUC: {ffmt(metrics_val['auc_md'])}" + ) + + if metrics_holdout: + print( + f"[Epoch {epoch+1}/{self.epochs}] Holdout Loss: {metrics_holdout['loss']:.4f} | " + f"Fused Acc: {100*metrics_holdout['acc_fused']:.2f}% | " + f"Img Acc: {100*metrics_holdout['acc_img']:.2f}% | " + f"Md Acc: {100*metrics_holdout['acc_md']:.2f}% | " + f"Fused AUC: {ffmt(metrics_holdout['auc_fused'])} | " + f"Img AUC: {ffmt(metrics_holdout['auc_img'])} | " + f"Md AUC: {ffmt(metrics_holdout['auc_md'])}" + ) + + self.last_eval = { + "acc_fused": float(metrics_val["acc_fused"]), + "acc_img": float(metrics_val["acc_img"]), + "acc_md": float(metrics_val["acc_md"]), + "auc_fused": float(metrics_val["auc_fused"]) if metrics_val["auc_fused"] == metrics_val["auc_fused"] else 0.0, + "auc_img": float(metrics_val["auc_img"]) if metrics_val["auc_img"] == metrics_val["auc_img"] else 0.0, + "auc_md": float(metrics_val["auc_md"]) if metrics_val["auc_md"] == metrics_val["auc_md"] else 0.0, + } + + row = { + "epoch": int(epoch + 1), + "eval_loss": _num(metrics_val["loss"]), + "acc_fused": _num(metrics_val["acc_fused"]), + "acc_img": _num(metrics_val["acc_img"]), + "acc_md": _num(metrics_val["acc_md"]), + "auc_fused": _num(metrics_val["auc_fused"]), + "auc_img": _num(metrics_val["auc_img"]), + "auc_md": _num(metrics_val["auc_md"]), + "top2_fused": _num(metrics_val["top2_fused"]), + "top2_img": _num(metrics_val["top2_img"]), + "top2_md": _num(metrics_val["top2_md"]), + "margin_fused": _num(metrics_val["margin_fused"]), + "margin_img": _num(metrics_val["margin_img"]), + "margin_md": _num(metrics_val["margin_md"]), + "agree_fused_img": _num(metrics_val["agree_fused_img"]), + "agree_fused_md": _num(metrics_val["agree_fused_md"]), + } + + if metrics_holdout: + row.update({ + "holdout_loss": _num(metrics_holdout["loss"]), + "holdout_acc_fused": _num(metrics_holdout["acc_fused"]), + "holdout_acc_img": _num(metrics_holdout["acc_img"]), + "holdout_acc_md": _num(metrics_holdout["acc_md"]), + "holdout_auc_fused": _num(metrics_holdout["auc_fused"]), + "holdout_auc_img": _num(metrics_holdout["auc_img"]), + "holdout_auc_md": _num(metrics_holdout["auc_md"]), + }) + else: + row.setdefault("holdout_loss", None) + row.setdefault("holdout_acc_fused", None) + row.setdefault("holdout_acc_img", None) + row.setdefault("holdout_acc_md", None) + row.setdefault("holdout_auc_fused", None) + row.setdefault("holdout_auc_img", None) + row.setdefault("holdout_auc_md", None) + + # realized step mix from training (if present) + mix = getattr(self, "_last_step_mix", None) + if mix: + row.update({ + "pct_fused": _num(mix.get("pct_fused")), + "pct_img": _num(mix.get("pct_img")), + "pct_md": _num(mix.get("pct_md")), + "phase": mix.get("phase"), + }) + + # SE gate stats (if the bridge exposes them) + # fetch without clearing + bridge_module = getattr(self, "bridge", None) + get_se_stats = getattr(bridge_module, "get_se_stats", None) if bridge_module is not None else None + if callable(get_se_stats): + se_stats = get_se_stats(reset=False) + if se_stats: + row.update({ + "se_mean": _num(se_stats.get("mean")), + "se_std": _num(se_stats.get("std")), + "se_pct_lt_0.2": _num(se_stats.get("pct_lt_0.2")), + "se_pct_gt_0.8": _num(se_stats.get("pct_gt_0.8")), + }) + # debug print BEFORE reset so you can see the true count + try: + print(f"SE gate samples seen: {getattr(getattr(bridge_module, 'se_log', None), '_n', 0)}") + except Exception: + pass + # now clear for the next epoch + try: + get_se_stats(reset=True) + except Exception: + pass + row.setdefault(self.args.early_metric, None) + # early-stop decision (no break here) + should_stop = False + if getattr(self, "_early", None): + should_stop = self._early.step(row, trainer=self, epoch=epoch) + row.update({ + "early_best_so_far" : float(self._early.best), + "early_bad_epochs" : int(self._early.bad_epochs), + "early_improved" : int(self._early.last_improved), + "early_monitor" : self.args.early_metric, + }) + # concise console status each epoch + mon = self.args.early_metric + cur = row.get(mon, None) + if cur is not None and cur == cur: # not NaN + msg = ( + f"[early] epoch {epoch+1}: {mon}={cur:.5f} | best={self._early.best:.5f} | " + f"bad_epochs={self._early.bad_epochs}/{self._early.patience} | improved={'yes' if self._early.last_improved else 'no'}" + ) + print(msg) + try: + self.logger.info(msg) + except Exception: + pass + + # Holdout best-tracker (if a holdout set is present) + if getattr(self, "_holdout_best", None): + mon_h = self._holdout_best["monitor"] + mode_h = self._holdout_best["mode"] + min_delta_h = self._holdout_best["min_delta"] + val_h = row.get(mon_h, None) + improved_h = False + if val_h is not None and val_h == val_h: + best_val_h = self._holdout_best["value"] + if mode_h == "max": + improved_h = (val_h > best_val_h + min_delta_h) + else: + improved_h = (val_h < best_val_h - min_delta_h) + if improved_h: + self._holdout_best["value"] = float(val_h) + self._holdout_best["epoch"] = int(epoch) + st_h = self._snapshot() + self._holdout_best["state"] = st_h + if self._holdout_best.get("save_path"): + try: + torch.save(st_h, self._holdout_best["save_path"]) + except Exception: + pass + print(f"[holdout_best] ↑ new best {mon_h}={val_h:.5f} at epoch {epoch+1}") + row.update({ + "holdout_best_monitor": mon_h, + "holdout_best_so_far": (None if self._holdout_best["value"] in (math.inf, -math.inf) else float(self._holdout_best["value"])), + "holdout_best_epoch": (None if self._holdout_best["epoch"] < 0 else int(self._holdout_best["epoch"] + 1)), + }) + + # Best-tracker (always-on): save best model snapshot and optional checkpoint + if getattr(self, "_best_info", None): + # If early stopper is active, mirror its best info and avoid duplicate prints + if getattr(self, "_early", None): + row.update({ + "best_monitor": self._best_info["monitor"], + "best_so_far": float(self._early.best) if self._early.best == self._early.best else None, + "best_epoch": (int(self._early.best_epoch) + 1) if (self._early.best_epoch is not None) else None, + }) + # skip independent tracking/prints to avoid duplication + self._write_epoch_log(row) + return row, should_stop + mon = self._best_info["monitor"] + mode = self._best_info["mode"] + min_delta = self._best_info["min_delta"] + val = row.get(mon, None) + improved = False + if val is not None and val == val: # not NaN + best_val = self._best_info["value"] + if mode == "max": + improved = (val > best_val + min_delta) + else: + improved = (val < best_val - min_delta) + if improved: + self._best_info["value"] = float(val) + self._best_info["epoch"] = int(epoch) + st = self._snapshot() + self._best_info["state"] = st + if self._best_info.get("save_path"): + try: + torch.save(st, self._best_info["save_path"]) + except Exception: + pass + print(f"[best] ↑ new best {mon}={val:.5f} at epoch {epoch+1}") + # Add best-so-far to row + row.update({ + "best_monitor": mon, + "best_so_far": (None if self._best_info["value"] in (math.inf, -math.inf) else float(self._best_info["value"])), + "best_epoch": (None if self._best_info["epoch"] < 0 else int(self._best_info["epoch"] + 1)), + }) + + # finally: write once, after all updates + self._write_epoch_log(row) + return row, should_stop + + def _restore_from_state(self, st: dict): + if not st: + return + self.img_tower.load_state_dict(st["img_tower"]) + self.md_tower.load_state_dict(st["md_tower"]) + if "bridge" in st and hasattr(self, "bridge"): + self.bridge.load_state_dict(st["bridge"]) + if "head_img" in st and hasattr(self, "head_img"): + self.head_img.load_state_dict(st["head_img"]) + if "head_md" in st and hasattr(self, "head_md"): + self.head_md.load_state_dict(st["head_md"]) + if "optimizer" in st: + self.optimizer.load_state_dict(st["optimizer"]) diff --git a/classes/image_tower.py b/classes/image_tower.py new file mode 100755 index 0000000..96032c7 --- /dev/null +++ b/classes/image_tower.py @@ -0,0 +1,125 @@ +# 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 diff --git a/classes/md_tower.py b/classes/md_tower.py new file mode 100755 index 0000000..61e5225 --- /dev/null +++ b/classes/md_tower.py @@ -0,0 +1,54 @@ +# 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 diff --git a/classes/papila_builders.py b/classes/papila_builders.py new file mode 100755 index 0000000..9a5dc1a --- /dev/null +++ b/classes/papila_builders.py @@ -0,0 +1,99 @@ +# 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 diff --git a/classes/refuge_classification.py b/classes/refuge_classification.py new file mode 100755 index 0000000..efbac25 --- /dev/null +++ b/classes/refuge_classification.py @@ -0,0 +1,819 @@ +"""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)" + ) + + 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: + 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 (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] = [] + iterator: Iterable[RefugeSample] + if progress_prefix is not None: + iterator = tqdm(samples, desc=progress_prefix, unit="sample", leave=False) + else: + iterator = samples + for sample in iterator: + if sample.label is None: + continue + try: + geom, disc_mask, cup_mask = self._resolve_geometry(sample, scale) + except RuntimeError: + continue + records.append( + RefugeClassificationRecord( + sample=sample, + geometry=geom, + disc_mask=disc_mask, + cup_mask=cup_mask, + ) + ) + return records + + 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 diff --git a/classes/refuge_preprocessing.py b/classes/refuge_preprocessing.py new file mode 100755 index 0000000..acab6d0 --- /dev/null +++ b/classes/refuge_preprocessing.py @@ -0,0 +1,306 @@ +"""Utilities for preparing REFUGE (REFUGE1/REFUGE2) datasets. + +Builds a unified manifest across all provided splits (REFUGE1 train/val/test +and REFUGE2 validation/test), exposing image paths, glaucoma labels, disc/cup +masks, and fovea coordinates so downstream segmentation/classification modules +can operate without additional bookkeeping. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + +import pandas as pd + + +@dataclass +class RefugeSample: + """Lightweight container describing a REFUGE sample.""" + + sample_id: str + dataset: str + split: str + image_path: Path + label: Optional[int] + device: Optional[str] + mask_path: Optional[Path] + fovea_coord: Optional[Tuple[float, float]] + + +class RefugePreprocessing: + """Builds manifests and provides shared helpers for REFUGE workflows. + + Responsibilities: + * scan the REFUGE directory structure and build a consistent manifest + (train/val/test, device vendor, ground-truth labels) + * expose convenience loaders for raw RGB frames, OD/OC masks, and + optional fovea landmarks + * compute geometric metadata (disc centres, diameters) so downstream + stages can crop ROIs lazily instead of storing pre-rendered tiles + """ + + def __init__(self, root_dir: Path | str) -> None: + self.root_dir = Path(root_dir) + self._manifest = None # populated by build_manifest() + + # ------------------------------------------------------------------ + # Manifest handling + # ------------------------------------------------------------------ + def build_manifest(self, refresh: bool = False) -> Iterable[RefugeSample]: + """Return an iterable of :class:`RefugeSample` records. + + Parameters + ---------- + refresh: + when True, force a rescan of the filesystem instead of reusing the + cached manifest. + + Returns + ------- + Iterable[RefugeSample] + A sequence containing one entry per sample in the REFUGE datasets. + + Notes + ----- + The actual manifest-building logic will live here: parsing the + directory structure, reading any provided CSV/Excel metadata, and + aligning masks/labels. For now, this method raises ``NotImplementedError`` + so callers are reminded to hook it up before use. + """ + + if self._manifest is not None and not refresh: + return self._manifest + + manifest: List[RefugeSample] = [] + + manifest.extend(self._collect_refuge1_train()) + manifest.extend(self._collect_refuge1_val()) + manifest.extend(self._collect_refuge1_test()) + manifest.extend(self._collect_refuge2_val()) + manifest.extend(self._collect_refuge2_test()) + + self._manifest = manifest + return self._manifest + + # ------------------------------------------------------------------ + # Accessors for downstream modules + # ------------------------------------------------------------------ + def load_image(self, sample: RefugeSample): + """Return the RGB fundus image for ``sample``. + + Implementors should handle color-space consistency (e.g., ensure RGB vs + BGR) and any global normalisation desired across devices. + """ + + raise NotImplementedError("Image loading to be implemented") + + def load_mask(self, sample: RefugeSample): + """Return the optic disc/cup mask for ``sample`` if available.""" + + raise NotImplementedError("Mask loading to be implemented") + + def disc_geometry(self, sample: RefugeSample) -> Dict[str, float]: + """Compute disc centre and diameter from the mask. + + The segmentation module will rely on this to crop 2.5–3× 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) diff --git a/classes/refuge_segmentation.py b/classes/refuge_segmentation.py new file mode 100755 index 0000000..7d300c9 --- /dev/null +++ b/classes/refuge_segmentation.py @@ -0,0 +1,383 @@ +"""REFUGE optic disc / cup segmentation utilities.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +import numpy as np +from PIL import Image +import torch +from torch import nn +from torch.utils.data import DataLoader, Dataset +from torchvision import transforms + +from classes.refuge_preprocessing import RefugePreprocessing, RefugeSample + + +# --------------------------------------------------------------------------- +# Dataset helpers +# --------------------------------------------------------------------------- + + +def _load_rgb(path: Path) -> Image.Image: + img = Image.open(path) + if img.mode != "RGB": + img = img.convert("RGB") + return img + + +def _load_mask_array(path: Path) -> np.ndarray: + mask_img = Image.open(path).convert("L") + mask = np.array(mask_img, dtype=np.float32) + # REFUGE masks encode disc/cup with different intensities; treat any + # positive value as disc for coarse localisation. + mask = np.where(mask > 0, 1.0, 0.0) + return mask + + +@dataclass +class RefugeSegmentationSample: + sample: RefugeSample + image_path: Path + mask_path: Path + + +class RefugeSegmentationDataset(Dataset): + """Simple segmentation dataset returning tensors.""" + + def __init__( + self, + samples: Sequence[RefugeSegmentationSample], + image_size: int = 512, + ) -> None: + self.samples = list(samples) + self.image_size = image_size + self.to_tensor = transforms.ToTensor() + + def __len__(self) -> int: + return len(self.samples) + + def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]: + rec = self.samples[idx] + image = _load_rgb(rec.image_path) + mask_arr = _load_mask_array(rec.mask_path) + + if self.image_size is not None: + image = image.resize((self.image_size, self.image_size), Image.BILINEAR) + mask_img = Image.fromarray(mask_arr).resize( + (self.image_size, self.image_size), Image.NEAREST + ) + mask_arr = np.array(mask_img, dtype=np.float32) + + image_tensor = self.to_tensor(image) + mask_tensor = torch.from_numpy(mask_arr).unsqueeze(0) # [1,H,W] + return { + "image": image_tensor, + "mask": mask_tensor, + "sample_id": rec.sample.sample_id, + } + + +# --------------------------------------------------------------------------- +# Model definition (lightweight U-Net) +# --------------------------------------------------------------------------- + + +class DoubleConv(nn.Module): + def __init__(self, in_channels: int, out_channels: int): + super().__init__() + self.net = nn.Sequential( + nn.Conv2d(in_channels, out_channels, 3, padding=1, bias=False), + nn.BatchNorm2d(out_channels), + nn.ReLU(inplace=True), + nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False), + nn.BatchNorm2d(out_channels), + nn.ReLU(inplace=True), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.net(x) + + +class UNet(nn.Module): + def __init__(self, in_channels: int = 3, base_channels: int = 64): + super().__init__() + self.enc1 = DoubleConv(in_channels, base_channels) + self.enc2 = DoubleConv(base_channels, base_channels * 2) + self.enc3 = DoubleConv(base_channels * 2, base_channels * 4) + self.enc4 = DoubleConv(base_channels * 4, base_channels * 8) + + self.pool = nn.MaxPool2d(2) + self.bottleneck = DoubleConv(base_channels * 8, base_channels * 16) + + self.up4 = nn.ConvTranspose2d(base_channels * 16, base_channels * 8, 2, stride=2) + self.dec4 = DoubleConv(base_channels * 16, base_channels * 8) + self.up3 = nn.ConvTranspose2d(base_channels * 8, base_channels * 4, 2, stride=2) + self.dec3 = DoubleConv(base_channels * 8, base_channels * 4) + self.up2 = nn.ConvTranspose2d(base_channels * 4, base_channels * 2, 2, stride=2) + self.dec2 = DoubleConv(base_channels * 4, base_channels * 2) + self.up1 = nn.ConvTranspose2d(base_channels * 2, base_channels, 2, stride=2) + self.dec1 = DoubleConv(base_channels * 2, base_channels) + + self.out = nn.Conv2d(base_channels, 1, 1) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + e1 = self.enc1(x) + e2 = self.enc2(self.pool(e1)) + e3 = self.enc3(self.pool(e2)) + e4 = self.enc4(self.pool(e3)) + b = self.bottleneck(self.pool(e4)) + + d4 = self.up4(b) + d4 = torch.cat([d4, e4], dim=1) + d4 = self.dec4(d4) + d3 = self.up3(d4) + d3 = torch.cat([d3, e3], dim=1) + d3 = self.dec3(d3) + d2 = self.up2(d3) + d2 = torch.cat([d2, e2], dim=1) + d2 = self.dec2(d2) + d1 = self.up1(d2) + d1 = torch.cat([d1, e1], dim=1) + d1 = self.dec1(d1) + return self.out(d1) + + +# --------------------------------------------------------------------------- +# Segmentation manager +# --------------------------------------------------------------------------- + + +class RefugeSegmentation: + """Train and run coarse-to-fine OD/OC segmentation for REFUGE.""" + + def __init__( + self, + preprocessing: RefugePreprocessing, + model: Optional[nn.Module] = None, + ) -> None: + self.preprocessing = preprocessing + self.model = model or UNet() + self.train_dataset: Optional[RefugeSegmentationDataset] = None + self.val_dataset: Optional[RefugeSegmentationDataset] = None + self.train_loader: Optional[DataLoader] = None + self.val_loader: Optional[DataLoader] = None + + # ------------------------------------------------------------------ + def build_datasets( + self, + image_size: int = 512, + batch_size: int = 8, + num_workers: int = 4, + ) -> None: + manifest = self.preprocessing.build_manifest() + + train_samples: List[RefugeSegmentationSample] = [] + val_samples: List[RefugeSegmentationSample] = [] + + for sample in manifest: + if not sample.mask_path or not sample.mask_path.exists(): + continue + rec = RefugeSegmentationSample(sample=sample, image_path=sample.image_path, mask_path=sample.mask_path) + if sample.split == "train": + train_samples.append(rec) + elif sample.split in {"val", "validation"}: + val_samples.append(rec) + + if not val_samples: + # Fall back to using a subset of training data for validation + split = max(1, int(0.1 * len(train_samples))) + val_samples = train_samples[:split] + train_samples = train_samples[split:] + + self.train_dataset = RefugeSegmentationDataset(train_samples, image_size=image_size) + self.val_dataset = RefugeSegmentationDataset(val_samples, image_size=image_size) + self.train_loader = DataLoader( + self.train_dataset, + batch_size=batch_size, + shuffle=True, + num_workers=num_workers, + pin_memory=True, + ) + self.val_loader = DataLoader( + self.val_dataset, + batch_size=batch_size, + shuffle=False, + num_workers=num_workers, + pin_memory=True, + ) + + # ------------------------------------------------------------------ + def train( + self, + epochs: int = 40, + lr: float = 1e-3, + weight_decay: float = 1e-5, + device: Optional[str] = None, + checkpoint_dir: Optional[Path] = None, + ) -> Dict[str, float]: + if self.train_loader is None or self.val_loader is None: + self.build_datasets() + + device = device or ("cuda" if torch.cuda.is_available() else "cpu") + self.model.to(device) + criterion = nn.BCEWithLogitsLoss() + optimizer = torch.optim.Adam(self.model.parameters(), lr=lr, weight_decay=weight_decay) + + best_dice = 0.0 + history: Dict[str, float] = {} + + for epoch in range(1, epochs + 1): + print(f"[Seg] Processing epoch {epoch}/{epochs}") + self.model.train() + running_loss = 0.0 + for batch in self.train_loader: # type: ignore[arg-type] + images = batch["image"].to(device) + masks = batch["mask"].to(device) + optimizer.zero_grad() + logits = self.model(images) + loss = criterion(logits, masks) + loss.backward() + optimizer.step() + running_loss += loss.item() * images.size(0) + + train_loss = running_loss / len(self.train_loader.dataset) # type: ignore[arg-type] + val_metrics = self.evaluate(device=device) + history[f"epoch_{epoch}_loss"] = train_loss + history[f"epoch_{epoch}_dice"] = val_metrics.get("dice", float("nan")) + + if val_metrics.get("dice", 0.0) > best_dice: + best_dice = val_metrics["dice"] + if checkpoint_dir is not None: + checkpoint_dir.mkdir(parents=True, exist_ok=True) + torch.save(self.model.state_dict(), checkpoint_dir / "refuge_segmentation_best.pt") + + return {"best_dice": best_dice, **history} + + # ------------------------------------------------------------------ + def evaluate(self, split: str = "val", device: Optional[str] = None) -> Dict[str, float]: + if split != "val": + raise ValueError("Only validation split supported currently") + if self.val_loader is None: + self.build_datasets() + + device = device or ("cuda" if torch.cuda.is_available() else "cpu") + self.model.to(device) + self.model.eval() + + dices: List[float] = [] + criterion = nn.BCEWithLogitsLoss() + losses: List[float] = [] + + with torch.no_grad(): + for batch in self.val_loader: # type: ignore[arg-type] + images = batch["image"].to(device) + masks = batch["mask"].to(device) + logits = self.model(images) + loss = criterion(logits, masks) + losses.append(loss.item() * images.size(0)) + probs = torch.sigmoid(logits) + preds = (probs > 0.5).float() + dice = self._dice_coefficient(preds, masks) + dices.extend(dice) + + mean_dice = float(np.mean(dices)) if dices else 0.0 + mean_loss = float(np.sum(losses) / len(self.val_loader.dataset)) # type: ignore[arg-type] + return {"dice": mean_dice, "loss": mean_loss} + + # ------------------------------------------------------------------ + def predict_mask(self, sample: RefugeSample, device: Optional[str] = None) -> torch.Tensor: + if self.train_dataset is None: + self.build_datasets() + device = device or ("cuda" if torch.cuda.is_available() else "cpu") + self.model.to(device) + self.model.eval() + + image = _load_rgb(sample.image_path) + original_size = image.size # (width, height) + image_resized = image.resize((self.train_dataset.image_size, self.train_dataset.image_size), Image.BILINEAR) # type: ignore[union-attr] + tensor = transforms.ToTensor()(image_resized).unsqueeze(0).to(device) + + with torch.no_grad(): + logits = self.model(tensor) + mask_resized = torch.sigmoid(logits)[0, 0] + + mask_np = mask_resized.cpu().numpy() + mask_np = (mask_np > 0.5).astype(np.float32) + mask_img = Image.fromarray(mask_np) + mask_img = mask_img.resize(original_size, Image.NEAREST) + return torch.from_numpy(np.array(mask_img, dtype=np.float32)) + + def infer_disc_geometry( + self, + sample: RefugeSample, + scale: float = 2.5, + ) -> Dict[str, float]: + if sample.mask_path and sample.mask_path.exists(): + mask = _load_mask_array(sample.mask_path) + else: + mask = self.predict_mask(sample).numpy() + + coords = np.argwhere(mask > 0.5) + if coords.size == 0: + raise RuntimeError(f"Unable to locate disc for sample {sample.sample_id}") + + ys, xs = coords[:, 0], coords[:, 1] + centre_x = float(xs.mean()) + centre_y = float(ys.mean()) + width = float(xs.max() - xs.min()) + height = float(ys.max() - ys.min()) + diameter = max(width, height) + radius = diameter / 2.0 + crop_radius = radius * scale + return { + "centre_x": centre_x, + "centre_y": centre_y, + "radius": radius, + "crop_radius": crop_radius, + "crop_size": crop_radius * 2.0, + } + + def batch_crops( + self, + samples: Iterable[RefugeSample], + scale: float = 2.5, + output_dir: Optional[Path] = None, + size: int = 256, + ) -> Dict[str, Path]: + output_paths: Dict[str, Path] = {} + if output_dir is not None: + output_dir.mkdir(parents=True, exist_ok=True) + + for sample in samples: + geom = self.infer_disc_geometry(sample, scale=scale) + image = _load_rgb(sample.image_path) + cx, cy = geom["centre_x"], geom["centre_y"] + r = geom["crop_radius"] + left = max(0.0, cx - r) + upper = max(0.0, cy - r) + right = min(image.width, cx + r) + lower = min(image.height, cy + r) + crop = image.crop((left, upper, right, lower)).resize((size, size), Image.BILINEAR) + if output_dir is not None: + out_path = output_dir / f"{sample.sample_id}_crop.png" + crop.save(out_path) + output_paths[sample.sample_id] = out_path + return output_paths + + # ------------------------------------------------------------------ + @staticmethod + def _dice_coefficient(preds: torch.Tensor, targets: torch.Tensor) -> List[float]: + eps = 1e-6 + dices = [] + preds = preds.view(preds.size(0), -1) + targets = targets.view(targets.size(0), -1) + for p, t in zip(preds, targets): + intersection = float((p * t).sum().item()) + union = float(p.sum().item() + t.sum().item()) + dice = (2.0 * intersection + eps) / (union + eps) + dices.append(dice) + return dices diff --git a/classes/tower_watcher.py b/classes/tower_watcher.py new file mode 100755 index 0000000..09e5954 --- /dev/null +++ b/classes/tower_watcher.py @@ -0,0 +1,149 @@ +# 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) diff --git a/classes/unet_segmenter.py b/classes/unet_segmenter.py new file mode 100755 index 0000000..82c850e --- /dev/null +++ b/classes/unet_segmenter.py @@ -0,0 +1,891 @@ +"""U-Net based optic disc/cup segmenter for REFUGE + Papila.""" + +from __future__ import annotations + +import math +import os +from concurrent.futures import ThreadPoolExecutor, as_completed +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable, List, Optional, Set, Tuple + +import numpy as np +import pandas as pd +from PIL import Image, ImageDraw, ImageOps +from PIL.Image import Resampling +from skimage import measure +import torch +from torch import nn +from torch.utils.data import DataLoader, Dataset +from torchvision import transforms +from tqdm import tqdm + + +@dataclass +class ManifestEntry: + sample_id: str + dataset: str + image_path: Path + annotation_disc: Path + annotation_cup: Path + annotation_type_disc: str + annotation_type_cup: str + split: str # train / holdout / etc. + + +class UNet(nn.Module): + def __init__( + self, in_channels: int = 3, base_channels: int = 32, out_channels: int = 2 + ): + super().__init__() + self.enc1 = self._block(in_channels, base_channels) + self.enc2 = self._block(base_channels, base_channels * 2) + self.enc3 = self._block(base_channels * 2, base_channels * 4) + self.enc4 = self._block(base_channels * 4, base_channels * 8) + + self.pool = nn.MaxPool2d(2) + self.bottleneck = self._block(base_channels * 8, base_channels * 16) + + self.up4 = nn.ConvTranspose2d( + base_channels * 16, base_channels * 8, 2, stride=2 + ) + self.dec4 = self._block(base_channels * 16, base_channels * 8) + self.up3 = nn.ConvTranspose2d(base_channels * 8, base_channels * 4, 2, stride=2) + self.dec3 = self._block(base_channels * 8, base_channels * 4) + self.up2 = nn.ConvTranspose2d(base_channels * 4, base_channels * 2, 2, stride=2) + self.dec2 = self._block(base_channels * 4, base_channels * 2) + self.up1 = nn.ConvTranspose2d(base_channels * 2, base_channels, 2, stride=2) + self.dec1 = self._block(base_channels * 2, base_channels) + + self.out_conv = nn.Conv2d(base_channels, out_channels, kernel_size=1) + + @staticmethod + def _block(in_ch: int, out_ch: int) -> nn.Module: + return nn.Sequential( + nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1, bias=False), + nn.BatchNorm2d(out_ch), + nn.ReLU(inplace=True), + nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1, bias=False), + nn.BatchNorm2d(out_ch), + nn.ReLU(inplace=True), + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + e1 = self.enc1(x) + e2 = self.enc2(self.pool(e1)) + e3 = self.enc3(self.pool(e2)) + e4 = self.enc4(self.pool(e3)) + b = self.bottleneck(self.pool(e4)) + + d4 = self.up4(b) + d4 = torch.cat([d4, e4], dim=1) + d4 = self.dec4(d4) + d3 = self.up3(d4) + d3 = torch.cat([d3, e3], dim=1) + d3 = self.dec3(d3) + d2 = self.up2(d3) + d2 = torch.cat([d2, e2], dim=1) + d2 = self.dec2(d2) + d1 = self.up1(d2) + d1 = torch.cat([d1, e1], dim=1) + d1 = self.dec1(d1) + return self.out_conv(d1) + + +class SegmentationDataset(Dataset): + def __init__( + self, + entries: List[ManifestEntry], + segmenter: "UNetSegmenter", + augment: bool, + ) -> None: + self.entries = entries + self.segmenter = segmenter + self.augment = augment + + def __len__(self) -> int: + return len(self.entries) + + def __getitem__(self, idx: int): + entry = self.entries[idx] + image = self.segmenter.load_preprocessed_image(entry) + disc_mask, cup_mask = self.segmenter.load_masks(entry) + + if self.augment: + image = self.segmenter.jitter_image(image) + image, disc_mask, cup_mask = self.segmenter.augment_geometric( + image, disc_mask, cup_mask + ) + image_tensor = transforms.ToTensor()(image) + image_tensor = self.segmenter._normalize_tensor(image_tensor) + + mask = np.stack([disc_mask, cup_mask], axis=0).astype(np.float32) + mask_tensor = torch.from_numpy(mask) + return image_tensor, mask_tensor + + +class UNetSegmenter: + def __init__( + self, + manifest_path: Path, + device: Optional[str] = None, + cup_weight: float = 1.0, + disc_weight: float = 1.0, + target_size: int = 512, + val_ratio: float = 0.1, + train_datasets: Optional[Iterable[str]] = None, + val_datasets: Optional[Iterable[str]] = None, + holdout_datasets: Optional[Iterable[str]] = None, + normalize: str = "none", + use_stronger_aug: bool = False, + mask_cache_dir: Optional[Path] = None, + image_cache_dir: Optional[Path] = None, + in_memory_cache: bool = False, + loader_workers: int = 0, + ) -> None: + self.manifest_path = manifest_path + self.device = device or ("cuda" if torch.cuda.is_available() else "cpu") + self.cup_weight = cup_weight + self.disc_weight = disc_weight + self.target_size = target_size + self.val_ratio = val_ratio + self.normalize = (normalize or "none").lower() + self.use_stronger_aug = bool(use_stronger_aug) + self.mask_cache_dir = Path(mask_cache_dir).resolve() if mask_cache_dir else None + if self.mask_cache_dir: + self.mask_cache_dir.mkdir(parents=True, exist_ok=True) + self.image_cache_dir = Path(image_cache_dir).resolve() if image_cache_dir else None + if self.image_cache_dir: + self.image_cache_dir.mkdir(parents=True, exist_ok=True) + self.in_memory_cache = bool(in_memory_cache) + self._mem_image_cache: dict[str, np.ndarray] = {} + self._mem_mask_cache: dict[str, Tuple[np.ndarray, np.ndarray]] = {} + self.loader_workers = max(0, int(loader_workers)) + + self.train_dataset_filter = self._normalize_filter(train_datasets) + self.val_dataset_filter = self._normalize_filter(val_datasets) + self.holdout_dataset_filter = self._normalize_filter(holdout_datasets) + + self.model = UNet().to(self.device) + self._manifest: List[ManifestEntry] = [] + self.train_entries: List[ManifestEntry] = [] + self.val_entries: List[ManifestEntry] = [] + self.holdout_entries: List[ManifestEntry] = [] + self.read_manifest() + + def prebuild_in_memory_cache( + self, + *, + cache_workers: int = 0, + include_train: bool = True, + include_val: bool = True, + include_holdout: bool = False, + ) -> None: + if not self.in_memory_cache: + return + selected: List[ManifestEntry] = [] + if include_train: + selected.extend(self.train_entries) + if include_val: + selected.extend(self.val_entries) + if include_holdout: + selected.extend(self.holdout_entries) + if not selected: + return + + # Deduplicate by cache key. + dedup = {} + for entry in selected: + dedup[self._entry_cache_key(entry)] = entry + entries = list(dedup.values()) + workers = max(0, int(cache_workers)) + print( + f"[UNetSegmenter] prebuilding in-memory cache for {len(entries)} samples " + f"(cache_workers={workers})", + flush=True, + ) + + def _warm_one(entry: ManifestEntry) -> None: + self.load_preprocessed_image(entry) + self.load_masks(entry) + + if workers <= 1: + for entry in tqdm(entries, desc="Warm cache", unit="sample"): + _warm_one(entry) + else: + with ThreadPoolExecutor(max_workers=workers) as ex: + futures = [ex.submit(_warm_one, entry) for entry in entries] + for fut in tqdm(as_completed(futures), total=len(futures), desc="Warm cache", unit="sample"): + fut.result() + + # ------------------------------------------------------------------ + def read_manifest(self) -> None: + df = pd.read_csv(self.manifest_path) + entries: List[ManifestEntry] = [] + for _, row in df.iterrows(): + entry = ManifestEntry( + sample_id=row["sample_id"], + dataset=row["dataset"], + image_path=Path(row["image_path"]), + annotation_disc=Path(row["annotation_disc"]), + annotation_cup=Path(row["annotation_cup"]), + annotation_type_disc=row["annotation_type_disc"], + annotation_type_cup=row["annotation_type_cup"], + split=row["split"], + ) + entries.append(entry) + self._manifest = entries + self.holdout_entries = [e for e in entries if e.split == "holdout"] + if self.holdout_dataset_filter is not None: + self.holdout_entries = [ + e for e in self.holdout_entries if e.dataset in self.holdout_dataset_filter + ] + + trainable = [e for e in entries if e.split != "holdout"] + if self.train_dataset_filter is not None: + trainable = [ + e for e in trainable if e.dataset in self.train_dataset_filter + ] + + if not trainable: + self.val_entries = [] + self.train_entries = [] + return + + val_pool = trainable + if self.val_dataset_filter is not None: + filtered = [e for e in trainable if e.dataset in self.val_dataset_filter] + if filtered: + val_pool = filtered + + if len(trainable) == 1: + val_count = 0 + else: + val_count = max(1, int(len(trainable) * self.val_ratio)) + val_count = min(val_count, len(val_pool), len(trainable) - 1) + + selected_val: List[ManifestEntry] = [] + if val_count > 0: + selected_val = list(val_pool[:val_count]) + self.val_entries = selected_val + selected_ids = {id(item) for item in selected_val} + self.train_entries = [e for e in trainable if id(e) not in selected_ids] + + if not self.train_entries and trainable: + # Fallback when filtering removed all train entries (e.g. val_count forced entire set) + self.train_entries = trainable + self.val_entries = [] + + # ------------------------------------------------------------------ + def preprocess_image(self, image: Image.Image) -> Image.Image: + return image.resize((self.target_size, self.target_size), Resampling.BILINEAR) + + def jitter_image(self, image: Image.Image) -> Image.Image: + # Photometric jitter only; geometric ops are applied jointly (image+mask) + return transforms.ColorJitter(0.1, 0.1, 0.1, 0.05)(image) + + def augment_geometric( + self, + image: Image.Image, + disc_mask: np.ndarray, + cup_mask: np.ndarray, + ) -> tuple[Image.Image, np.ndarray, np.ndarray]: + if not self.use_stronger_aug: + return image, disc_mask, cup_mask + + img = image + disc_pil = Image.fromarray((disc_mask > 0).astype(np.uint8) * 255) + cup_pil = Image.fromarray((cup_mask > 0).astype(np.uint8) * 255) + + # Random horizontal flip + if np.random.rand() < 0.5: + img = ImageOps.mirror(img) + disc_pil = ImageOps.mirror(disc_pil) + cup_pil = ImageOps.mirror(cup_pil) + # Random vertical flip + if np.random.rand() < 0.5: + img = ImageOps.flip(img) + disc_pil = ImageOps.flip(disc_pil) + cup_pil = ImageOps.flip(cup_pil) + # Random rotation (multiples of 90° to keep masks aligned) + rotations = np.random.choice([0, 90, 180, 270]) + if rotations: + img = img.rotate(rotations, expand=False) + disc_pil = disc_pil.rotate(rotations, expand=False) + cup_pil = cup_pil.rotate(rotations, expand=False) + + disc_mask = (np.array(disc_pil) > 0).astype(np.float32) + cup_mask = (np.array(cup_pil) > 0).astype(np.float32) + return img, disc_mask, cup_mask + + @staticmethod + def _slugify(text: str) -> str: + return "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in text) + + def _entry_cache_key(self, entry: ManifestEntry) -> str: + return self._slugify(f"{entry.dataset}_{entry.sample_id}_sz{self.target_size}") + + def _mask_cache_path(self, entry: ManifestEntry) -> Optional[Path]: + if self.mask_cache_dir is None: + return None + slug = self._slugify(f"{entry.dataset}_{entry.sample_id}") + fname = f"{slug}_sz{self.target_size}.npz" + return self.mask_cache_dir / fname + + def _image_cache_path(self, entry: ManifestEntry) -> Optional[Path]: + if self.image_cache_dir is None: + return None + slug = self._slugify(f"{entry.dataset}_{entry.sample_id}") + fname = f"{slug}_img_sz{self.target_size}.npz" + return self.image_cache_dir / fname + + def _load_image_cache(self, cache_path: Path) -> Optional[Image.Image]: + try: + data = np.load(str(cache_path), allow_pickle=False) + arr = data["image"].astype(np.uint8, copy=False) + if arr.ndim != 3 or arr.shape[2] != 3: + return None + return Image.fromarray(arr, mode="RGB") + except Exception: + with suppress(OSError, FileNotFoundError): + cache_path.unlink() + return None + + def _save_image_cache(self, cache_path: Optional[Path], image: Image.Image) -> None: + if cache_path is None: + return + cache_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp.npz") + try: + arr = np.asarray(image, dtype=np.uint8) + np.savez_compressed(tmp_path, image=arr) + os.replace(tmp_path, cache_path) + except Exception: + with suppress(OSError, FileNotFoundError): + tmp_path.unlink() + + def load_preprocessed_image(self, entry: ManifestEntry) -> Image.Image: + key = self._entry_cache_key(entry) + if self.in_memory_cache: + cached = self._mem_image_cache.get(key) + if cached is not None: + return Image.fromarray(cached, mode="RGB") + cache_path = self._image_cache_path(entry) + if cache_path and cache_path.exists(): + cached = self._load_image_cache(cache_path) + if cached is not None: + if self.in_memory_cache: + self._mem_image_cache[key] = np.asarray(cached, dtype=np.uint8) + return cached + image = Image.open(entry.image_path).convert("RGB") + image = self.preprocess_image(image) + if self.in_memory_cache: + self._mem_image_cache[key] = np.asarray(image, dtype=np.uint8) + self._save_image_cache(cache_path, image) + return image + + def _load_mask_cache(self, cache_path: Path) -> Optional[Tuple[np.ndarray, np.ndarray]]: + try: + data = np.load(str(cache_path), allow_pickle=False) + disc = data["disc"].astype(np.float32) + cup = data["cup"].astype(np.float32) + return disc, cup + except Exception: + with suppress(OSError, FileNotFoundError): + cache_path.unlink() + return None + + def _save_mask_cache( + self, + cache_path: Optional[Path], + disc_mask: np.ndarray, + cup_mask: np.ndarray, + ) -> None: + if cache_path is None: + return + cache_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = cache_path.with_suffix(cache_path.suffix + ".tmp.npz") + try: + np.savez_compressed( + tmp_path, + disc=disc_mask.astype(np.uint8), + cup=cup_mask.astype(np.uint8), + ) + os.replace(tmp_path, cache_path) + except Exception: + with suppress(OSError, FileNotFoundError): + tmp_path.unlink() + + def _normalize_tensor(self, tensor: torch.Tensor) -> torch.Tensor: + if self.normalize == "per_image": + mean = tensor.mean(dim=(1, 2), keepdim=True) + std = tensor.std(dim=(1, 2), keepdim=True).clamp(min=1e-6) + return (tensor - mean) / std + if self.normalize == "imagenet": + mean = torch.tensor([0.485, 0.456, 0.406]).view(-1, 1, 1) + std = torch.tensor([0.229, 0.224, 0.225]).view(-1, 1, 1) + return (tensor - mean) / std + return tensor + + # ------------------------------------------------------------------ + def extract_masks_from_image( + self, + mask_path: Path, + disc_color: Optional[tuple[int, int, int]] = None, + cup_color: Optional[tuple[int, int, int]] = None, + ) -> Tuple[np.ndarray, Optional[np.ndarray], Tuple[int, int]]: + raw = Image.open(mask_path) + arr = np.array(raw) + if arr.ndim == 2: + h, w = arr.shape + flat = arr.reshape(-1).astype(np.int64, copy=False) + edges = np.concatenate([arr[0, :], arr[-1, :], arr[:, 0], arr[:, -1]], axis=0).astype(np.int64, copy=False) + edge_counts = np.bincount(edges, minlength=256) + bg_val = int(np.argmax(edge_counts)) + counts = np.bincount(flat, minlength=256) + counts[bg_val] = 0 + vals = np.where(counts > 0)[0] + if vals.size < 1: + raise ValueError(f"Mask {mask_path} does not contain discernible labels") + vals = vals[np.argsort(-counts[vals])] + disc_val = int(vals[0]) + cup_val = int(vals[1]) if vals.size > 1 else None + disc_mask = (arr == disc_val).astype(np.uint8) + cup_mask = (arr == cup_val).astype(np.uint8) if cup_val is not None else np.zeros_like(disc_mask, dtype=np.uint8) + return disc_mask, cup_mask if cup_mask.any() else None, (w, h) + + image = raw.convert("RGB") + arr = np.array(image) + h, w, c = arr.shape + + if disc_color is None or cup_color is None: + # Fast color discovery via NumPy (avoid Python-level per-pixel tuple counting). + edges = np.concatenate( + [arr[0, :, :], arr[-1, :, :], arr[:, 0, :], arr[:, -1, :]], axis=0 + ) + edge_colors, edge_counts = np.unique(edges.reshape(-1, c), axis=0, return_counts=True) + bg_color_np = edge_colors[int(np.argmax(edge_counts))] + + colors_np, counts_np = np.unique(arr.reshape(-1, c), axis=0, return_counts=True) + keep = np.any(colors_np != bg_color_np.reshape(1, -1), axis=1) + colors_np = colors_np[keep] + counts_np = counts_np[keep] + if colors_np.shape[0] < 1: + raise ValueError(f"Mask {mask_path} does not contain discernible labels") + order = np.argsort(-counts_np) + colors_np = colors_np[order] + disc_color = tuple(int(v) for v in colors_np[0].tolist()) + cup_color = ( + tuple(int(v) for v in colors_np[1].tolist()) + if colors_np.shape[0] > 1 + else None + ) + + disc_mask = np.zeros((h, w), dtype=np.uint8) + cup_mask = np.zeros((h, w), dtype=np.uint8) + + if disc_color is not None: + disc_mask[np.all(arr == disc_color, axis=-1)] = 1 + if cup_color is not None: + cup_mask[np.all(arr == cup_color, axis=-1)] = 1 + + return disc_mask, cup_mask if cup_mask.any() else None, (w, h) + + def load_contour_from_file(self, contour_path: Path) -> np.ndarray: + # Fast path: contour files are typically CSV or whitespace-delimited x,y pairs. + try: + arr = np.loadtxt(str(contour_path), delimiter=",", comments="#", dtype=np.float32) + except Exception: + try: + arr = np.loadtxt(str(contour_path), comments="#", dtype=np.float32) + except Exception: + return np.zeros((0, 2), dtype=np.float32) + if arr.size == 0: + return np.zeros((0, 2), dtype=np.float32) + if arr.ndim == 1: + if arr.shape[0] < 2: + return np.zeros((0, 2), dtype=np.float32) + arr = arr.reshape(1, -1) + if arr.shape[1] < 2: + return np.zeros((0, 2), dtype=np.float32) + return arr[:, :2].astype(np.float32, copy=False) + + def coords_to_mask( + self, + coords: Optional[np.ndarray], + size: Tuple[int, int], + ) -> np.ndarray: + if coords is None or len(coords) == 0: + return np.zeros((self.target_size, self.target_size), dtype=np.float32) + + width, height = map(int, size) + target_shape = (height, width) + arr = np.asarray(coords) + if arr.size == 0: + return np.zeros((self.target_size, self.target_size), dtype=np.float32) + + if arr.ndim == 2 and arr.shape[-1] != 2: + mask = (arr > 0).astype(np.uint8) + return self._resize_mask(mask) + + if arr.ndim > 2: + arr = arr.reshape(-1, arr.shape[-1]) + arr = arr.astype(float, copy=False) + if arr.shape[-1] != 2: + raise ValueError(f"Expected coordinate pairs, got shape {arr.shape}") + + points = [tuple(map(float, pt)) for pt in arr] + if len(points) < 3: + return np.zeros(target_shape, dtype=np.float32) + + img = Image.new("L", size, 0) + draw = ImageDraw.Draw(img) + draw.polygon(points, outline=1, fill=1) + mask = np.array(img, dtype=np.uint8) + return self._resize_mask(mask) + + def _resize_mask(self, mask: np.ndarray) -> np.ndarray: + img = Image.fromarray((mask > 0).astype(np.uint8) * 255) + img = img.resize((self.target_size, self.target_size), Resampling.NEAREST) + return (np.array(img, dtype=np.uint8) > 0).astype(np.float32) + + def load_masks(self, entry: ManifestEntry) -> Tuple[np.ndarray, np.ndarray]: + key = self._entry_cache_key(entry) + if self.in_memory_cache: + cached = self._mem_mask_cache.get(key) + if cached is not None: + disc_u8, cup_u8 = cached + return disc_u8.astype(np.float32), cup_u8.astype(np.float32) + cache_path = self._mask_cache_path(entry) + if cache_path and cache_path.exists(): + cached = self._load_mask_cache(cache_path) + if cached is not None: + if self.in_memory_cache: + disc, cup = cached + self._mem_mask_cache[key] = ( + disc.astype(np.uint8), + cup.astype(np.uint8), + ) + return cached + + image = Image.open(entry.image_path) + size = image.size + + disc_coords = cup_coords = None + if entry.annotation_type_disc == "mask": + disc_coords, cup_coords_from_disc, size = self.extract_masks_from_image( + entry.annotation_disc + ) + if cup_coords_from_disc is not None: + cup_coords = cup_coords_from_disc + else: + disc_coords = self.load_contour_from_file(entry.annotation_disc) + + if entry.annotation_type_cup == "mask": + _, cup_coords_from_cup, size_cup = self.extract_masks_from_image( + entry.annotation_cup + ) + if cup_coords_from_cup is not None: + cup_coords = cup_coords_from_cup + if disc_coords is None: + disc_coords, _, size = self.extract_masks_from_image( + entry.annotation_cup + ) + else: + size = size_cup + else: + cup_coords = self.load_contour_from_file(entry.annotation_cup) + + disc_mask = self.coords_to_mask(disc_coords, size).astype(np.float32) + cup_mask = self.coords_to_mask(cup_coords, size).astype(np.float32) + if self.in_memory_cache: + self._mem_mask_cache[key] = ( + disc_mask.astype(np.uint8), + cup_mask.astype(np.uint8), + ) + self._save_mask_cache(cache_path, disc_mask, cup_mask) + return disc_mask, cup_mask + + # ------------------------------------------------------------------ + def build_loaders(self, batch_size: int = 4, num_workers: int = 0) -> Tuple[DataLoader, DataLoader]: + train_ds = SegmentationDataset(self.train_entries, self, augment=True) + val_ds = SegmentationDataset(self.val_entries, self, augment=False) + train_loader = DataLoader( + train_ds, batch_size=batch_size, shuffle=True, num_workers=num_workers, pin_memory=True + ) + val_loader = DataLoader( + val_ds, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True + ) + return train_loader, val_loader + + # ------------------------------------------------------------------ + def dice_score(self, preds: torch.Tensor, targets: torch.Tensor) -> torch.Tensor: + preds = (preds > 0.5).float() + intersection = (preds * targets).sum(dim=(2, 3)) + union = preds.sum(dim=(2, 3)) + targets.sum(dim=(2, 3)) + dice = (2 * intersection + 1e-6) / (union + 1e-6) + return dice.mean(dim=0) + + def train( + self, + epochs: int = 40, + batch_size: int = 4, + lr: float = 1e-3, + weight_decay: float = 1e-5, + checkpoint_dir: Path = Path("models/unet_segmenter"), + ) -> None: + print( + f"[UNetSegmenter] training on device={self.device} " + f"(epochs={epochs}, batch_size={batch_size}, workers={self.loader_workers})" + ) + train_loader, val_loader = self.build_loaders(batch_size=batch_size, num_workers=self.loader_workers) + optimizer = torch.optim.Adam( + self.model.parameters(), lr=lr, weight_decay=weight_decay + ) + criterion = nn.BCEWithLogitsLoss() + best_dice = -math.inf + checkpoint_dir.mkdir(parents=True, exist_ok=True) + best_path = checkpoint_dir / "best.pt" + + epoch_bar = tqdm(range(1, epochs + 1), desc="Epochs", unit="epoch") + + for epoch in epoch_bar: + self.model.train() + batch_bar = tqdm( + train_loader, + desc=f"Train {epoch}/{epochs}", + leave=False, + unit="batch", + total=len(train_loader), + ) + train_loss_total = 0.0 + train_samples = 0 + for images, masks in batch_bar: + images = images.to(self.device) + masks = masks.to(self.device) + optimizer.zero_grad() + logits = self.model(images) + loss_disc = criterion(logits[:, 0:1], masks[:, 0:1]) + loss_cup = criterion(logits[:, 1:2], masks[:, 1:2]) + loss = self.disc_weight * loss_disc + self.cup_weight * loss_cup + loss.backward() + optimizer.step() + batch_size = images.size(0) + train_loss_total += loss.item() * batch_size + train_samples += batch_size + + train_loss = ( + train_loss_total / train_samples if train_samples else float("nan") + ) + + self.model.eval() + dices = [] + val_bar = tqdm( + val_loader, + desc="Validate", + leave=False, + unit="batch", + total=len(val_loader), + ) + with torch.no_grad(): + for images, masks in val_bar: + images = images.to(self.device) + masks = masks.to(self.device) + logits = self.model(images) + probs = torch.sigmoid(logits) + dice = self.dice_score(probs, masks) + dices.append(dice.cpu()) + if dices: + mean_dice = torch.stack(dices).mean(dim=0) + disc_dice = mean_dice[0].item() + cup_dice = mean_dice[1].item() + weight_sum = self.disc_weight + self.cup_weight + score = ( + (self.disc_weight * disc_dice + self.cup_weight * cup_dice) + / weight_sum + if weight_sum + else 0.0 + ) + epoch_bar.set_postfix( + loss=f"{train_loss:.4f}", + dice_disc=f"{disc_dice:.3f}", + dice_cup=f"{cup_dice:.3f}", + dice_w=f"{score:.3f}", + ) + else: + disc_dice = cup_dice = 0.0 + score = 0.0 + epoch_bar.set_postfix(loss=f"{train_loss:.4f}") + + if score > best_dice: + best_dice = score + torch.save({"model": self.model.state_dict()}, best_path) + + if best_path.exists(): + state = torch.load(best_path, map_location=self.device) + self.model.load_state_dict(state["model"]) + + # ------------------------------------------------------------------ + def evaluate_holdout( + self, output_dir: Path = Path("analysis_data/segmenter_eval") + ) -> pd.DataFrame: + return self.evaluate_dataset(split_filter={"holdout"}, output_dir=output_dir) + + @staticmethod + def overlay_masks( + image: Image.Image, disc: np.ndarray, cup: np.ndarray + ) -> Image.Image: + overlay = image.copy() + disc_img = Image.fromarray((disc * 255).astype(np.uint8)) + cup_img = Image.fromarray((cup * 255).astype(np.uint8)) + disc_color = Image.new("RGBA", image.size, (255, 0, 0, 0)) + cup_color = Image.new("RGBA", image.size, (0, 255, 0, 0)) + disc_color.paste((255, 0, 0, 100), mask=disc_img) + cup_color.paste((0, 255, 0, 100), mask=cup_img) + overlay = overlay.convert("RGBA") + overlay = Image.alpha_composite(overlay, disc_color) + overlay = Image.alpha_composite(overlay, cup_color) + return overlay.convert("RGB") + + # ------------------------------------------------------------------ + @staticmethod + def _normalize_filter(values: Optional[Iterable[str]]) -> Optional[Set[str]]: + if values is None: + return None + if isinstance(values, str): + return {values} + return {str(item) for item in values} + + @staticmethod + def _dice_from_masks(pred: np.ndarray, target: np.ndarray) -> float: + pred = (pred > 0).astype(np.float32) + target = (target > 0).astype(np.float32) + intersection = float((pred * target).sum()) + denom = float(pred.sum() + target.sum()) + return (2.0 * intersection + 1e-6) / (denom + 1e-6) + + def get_entries( + self, + dataset_filter: Optional[Iterable[str]] = None, + split_filter: Optional[Iterable[str]] = None, + ) -> List[ManifestEntry]: + dataset_set = self._normalize_filter(dataset_filter) + split_set = self._normalize_filter(split_filter) + entries = self._manifest + if dataset_set is not None: + entries = [e for e in entries if e.dataset in dataset_set] + if split_set is not None: + entries = [e for e in entries if e.split in split_set] + return list(entries) + + def evaluate_dataset( + self, + dataset_filter: Optional[Iterable[str]] = None, + split_filter: Optional[Iterable[str]] = None, + output_dir: Path = Path("analysis_data/segmenter_eval"), + save_overlays: bool = True, + metrics_path: Optional[Path] = None, + threshold: float = 0.5, + tta: bool = False, + ) -> pd.DataFrame: + entries = self.get_entries( + dataset_filter=dataset_filter, split_filter=split_filter + ) + if not entries: + return pd.DataFrame( + columns=[ + "sample_id", + "dataset", + "split", + "dice_disc", + "dice_cup", + ] + ) + + output_dir.mkdir(parents=True, exist_ok=True) + if metrics_path is None: + suffix_parts = [] + if dataset_filter is not None: + suffix_parts.append("-".join(sorted(self._normalize_filter(dataset_filter)))) + if split_filter is not None: + suffix_parts.append("-".join(sorted(self._normalize_filter(split_filter)))) + suffix = "_".join(part for part in suffix_parts if part) + csv_name = f"metrics{'_' + suffix if suffix else ''}.csv" + metrics_path = output_dir / csv_name + + records = [] + self.model.eval() + progress = tqdm( + entries, + desc="Evaluate", + unit="sample", + leave=False, + ) + for entry in progress: + orig_image = Image.open(entry.image_path).convert("RGB") + image = self.preprocess_image(orig_image) + tensor = transforms.ToTensor()(image) + tensor = self._normalize_tensor(tensor) + tensor = tensor.unsqueeze(0).to(self.device) + with torch.no_grad(): + logits = self.model(tensor) + if tta: + t_h = torch.flip(tensor, dims=[3]) + log_h = self.model(t_h) + log_h = torch.flip(log_h, dims=[3]) + t_v = torch.flip(tensor, dims=[2]) + log_v = self.model(t_v) + log_v = torch.flip(log_v, dims=[2]) + logits = (logits + log_h + log_v) / 3.0 + probs = torch.sigmoid(logits)[0].cpu().numpy() + + disc_pred = (probs[0] > threshold).astype(np.uint8) + cup_pred = (probs[1] > threshold).astype(np.uint8) + # Structural prior: cup within disc + cup_pred = (cup_pred > 0) & (disc_pred > 0) + cup_pred = cup_pred.astype(np.uint8) + + disc_gt, cup_gt = self.load_masks(entry) + disc_gt = disc_gt.astype(np.uint8) + cup_gt = cup_gt.astype(np.uint8) + + dice_disc = self._dice_from_masks(disc_pred, disc_gt) + dice_cup = self._dice_from_masks(cup_pred, cup_gt) + + records.append( + { + "sample_id": entry.sample_id, + "dataset": entry.dataset, + "split": entry.split, + "dice_disc": dice_disc, + "dice_cup": dice_cup, + } + ) + + progress.set_postfix( + dice_disc=f"{dice_disc:.3f}", dice_cup=f"{dice_cup:.3f}" + ) + + if save_overlays: + overlay_gt = self.overlay_masks(image, disc_gt, cup_gt) + overlay_pred = self.overlay_masks(image, disc_pred, cup_pred) + combined = Image.new("RGB", (image.width * 2, image.height)) + combined.paste(overlay_gt, (0, 0)) + combined.paste(overlay_pred, (image.width, 0)) + combined.save(output_dir / f"{entry.sample_id}_eval.png") + + metrics_df = pd.DataFrame(records) + summary = metrics_df[["dice_disc", "dice_cup"]].mean() + summary_row = { + "sample_id": "__mean__", + "dataset": "summary", + "split": "summary", + "dice_disc": summary["dice_disc"], + "dice_cup": summary["dice_cup"], + } + metrics_with_summary = pd.concat( + [metrics_df, pd.DataFrame([summary_row])], ignore_index=True + ) + metrics_with_summary.to_csv(metrics_path, index=False) + return metrics_with_summary diff --git a/classes/v2/__init__.py b/classes/v2/__init__.py new file mode 100644 index 0000000..72d8c04 --- /dev/null +++ b/classes/v2/__init__.py @@ -0,0 +1,102 @@ +from .network_manager import ( + FoldResult, + LoaderBundle, + NetworkManager, + PatientSplit, +) +from .split_manager import ( + PatientFirstSplitManager, + SplitPlan, + build_patient_split_plans, +) +from .profiles import ( + DatasetProfile, + SimpleDatasetProfile, + SlotDescriptor, + PapilaProfile, + build_papila_profile, +) +from .loader_factory import SlotLoaderFactory +from .slot_dataset import SlotDataset, slot_collate +from .papila_data import PapilaData +from .papila_builders import build_papila_data +from .data_bundle import DataBundle +from .dataset import ClinicalDataset +from .config_builder import ( + ConfigAssembly, + assemble_config, + load_config, + resolve_imports, +) +from .filters import RegexFilter, ColumnFilter, apply_regex_filters, apply_column_filters +from .transforms import ( + ImageTransformConfig, + backbone_transform_config, + build_backbone_transform, + build_imagenet_transform, + ResizeTransform, + CenterCropTransform, + ROICropTransform, + JitterBundleTransform, + UnetMaskProvider, + TRANSFORM_REGISTRY, + build_transform_chain, +) +from .model_builder import V2ModelBundle, build_model_bundle +from .towers import ImageTower, MDTower, SiameseImageTower, build_backbone +from .bridges import Bridge, VoteBridge +from .v2_hypertower import V2HyperTower, V2ModeComparisonOps, V2ModeComparator +from .hypertower_logger import HypertowerLogger + +__all__ = [ + "NetworkManager", + "PatientSplit", + "LoaderBundle", + "FoldResult", + "PatientFirstSplitManager", + "SplitPlan", + "build_patient_split_plans", + "DatasetProfile", + "SimpleDatasetProfile", + "SlotDescriptor", + "PapilaProfile", + "build_papila_profile", + "PapilaData", + "build_papila_data", + "DataBundle", + "ClinicalDataset", + "SlotLoaderFactory", + "SlotDataset", + "slot_collate", + "ConfigAssembly", + "assemble_config", + "load_config", + "resolve_imports", + "RegexFilter", + "ColumnFilter", + "apply_regex_filters", + "apply_column_filters", + "ImageTransformConfig", + "backbone_transform_config", + "build_backbone_transform", + "build_imagenet_transform", + "ResizeTransform", + "CenterCropTransform", + "ROICropTransform", + "JitterBundleTransform", + "UnetMaskProvider", + "TRANSFORM_REGISTRY", + "build_transform_chain", + "V2ModelBundle", + "build_model_bundle", + "ImageTower", + "MDTower", + "SiameseImageTower", + "build_backbone", + "Bridge", + "VoteBridge", + "V2HyperTower", + "V2ModeComparisonOps", + "V2ModeComparator", + "HypertowerLogger", +] diff --git a/classes/v2/bridges.py b/classes/v2/bridges.py new file mode 100644 index 0000000..80bcc5c --- /dev/null +++ b/classes/v2/bridges.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import torch +import torch.nn as nn + +from classes.SE_attention import SEBlock, SEGateLogger + + +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 + + # project towers to equal width + self.W_img = nn.Linear(img_dim, fusion_dim) + self.W_md = nn.Linear(meta_dim, fusion_dim) + + # optional: layernorm before SE + 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) diff --git a/classes/v2/config_builder.py b/classes/v2/config_builder.py new file mode 100644 index 0000000..dca28e4 --- /dev/null +++ b/classes/v2/config_builder.py @@ -0,0 +1,276 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +import json + +from classes.v2.papila_data import PapilaData + + +@dataclass +class ImportSpec: + id: str + class_name: str + params: Dict[str, Any] + + +@dataclass +class DataSourceSpec: + node_id: str + label: str + output_type: str + source: Optional[Dict[str, Any]] + source_ref: Optional[Dict[str, Any]] + + +@dataclass +class TransformSpec: + node_id: str + label: str + transform_type: str + params: Dict[str, Any] + + +@dataclass +class LoaderSpec: + node_id: str + label: str + input_type: str + input_index: str + input_key: str + output_key: str + transforms: List[TransformSpec] + data_source: Optional[DataSourceSpec] + + +@dataclass +class TowerSpec: + node_id: str + label: str + tower_type: str + params: Dict[str, Any] + + +@dataclass +class BridgeSpec: + node_id: str + label: str + method: str + params: Dict[str, Any] + + +@dataclass +class ClassifierSpec: + node_id: str + label: str + + +@dataclass +class ConfigAssembly: + raw: Dict[str, Any] + imports: Dict[str, ImportSpec] + data_sources: Dict[str, DataSourceSpec] + transforms: Dict[str, TransformSpec] + loaders: Dict[str, LoaderSpec] + towers: Dict[str, TowerSpec] + bridges: Dict[str, BridgeSpec] + classifiers: Dict[str, ClassifierSpec] + + +def load_config(path: Path) -> Dict[str, Any]: + payload = json.loads(Path(path).read_text()) + if not isinstance(payload, dict): + raise ValueError("Config JSON must be an object.") + return payload + + +def assemble_config(path: Path) -> ConfigAssembly: + config = load_config(path) + meta = config.get("meta", {}) + imports = _build_imports(meta.get("imports", [])) + nodes = {node["id"]: node for node in config.get("nodes", [])} + edges = config.get("edges", []) + + data_sources: Dict[str, DataSourceSpec] = {} + transforms: Dict[str, TransformSpec] = {} + loaders: Dict[str, LoaderSpec] = {} + towers: Dict[str, TowerSpec] = {} + bridges: Dict[str, BridgeSpec] = {} + classifiers: Dict[str, ClassifierSpec] = {} + + for node in nodes.values(): + ntype = node.get("type") + if ntype == "data": + data_sources[node["id"]] = DataSourceSpec( + node_id=node["id"], + label=node.get("label", ""), + output_type=node.get("outputType", ""), + source=node.get("source"), + source_ref=node.get("sourceRef"), + ) + elif ntype == "transform": + transforms[node["id"]] = TransformSpec( + node_id=node["id"], + label=node.get("label", ""), + transform_type=node.get("transformType", ""), + params=_extract_transform_params(node), + ) + elif ntype == "loader": + loaders[node["id"]] = LoaderSpec( + node_id=node["id"], + label=node.get("label", ""), + input_type=node.get("inputType", ""), + input_index=node.get("inputIndex", ""), + input_key=node.get("inputKey", ""), + output_key=node.get("outputKey", ""), + transforms=[], + data_source=None, + ) + elif ntype in ("image_tower", "metadata_tower"): + towers[node["id"]] = TowerSpec( + node_id=node["id"], + label=node.get("label", ""), + tower_type=node.get("towerType", "image" if ntype == "image_tower" else "metadata"), + params=_extract_tower_params(node), + ) + elif ntype == "bridge": + bridges[node["id"]] = BridgeSpec( + node_id=node["id"], + label=node.get("label", ""), + method=node.get("bridgeMethod", "fusion"), + params=_extract_bridge_params(node), + ) + elif ntype == "classifier": + classifiers[node["id"]] = ClassifierSpec( + node_id=node["id"], + label=node.get("label", ""), + ) + + # attach transforms + data sources to loaders by walking upstream + for loader_id, loader in loaders.items(): + chain = _upstream_chain(loader_id, nodes, edges) + for node_id in reversed(chain): + if node_id in transforms: + loader.transforms.append(transforms[node_id]) + if node_id in data_sources: + loader.data_source = data_sources[node_id] + + return ConfigAssembly( + raw=config, + imports=imports, + data_sources=data_sources, + transforms=transforms, + loaders=loaders, + towers=towers, + bridges=bridges, + classifiers=classifiers, + ) + + +def resolve_imports(assembly: ConfigAssembly) -> Dict[str, Any]: + resolved: Dict[str, Any] = {} + for import_id, spec in assembly.imports.items(): + if spec.class_name == "PapilaData": + params = spec.params + resolved[import_id] = PapilaData.from_dirs( + image_dir=params.get("image_dir", "Papila/FundusImages"), + clinical_dir=params.get("clinical_dir", "Papila/ClinicalData"), + label_col=params.get("label_col", "Diagnosis"), + cat_cols=params.get("cat_cols", ["Gender", "Phakic/Pseudophakic"]), + ) + else: + raise ValueError(f"Unsupported import class {spec.class_name!r}") + return resolved + + +def _build_imports(entries: Iterable[Dict[str, Any]]) -> Dict[str, ImportSpec]: + specs: Dict[str, ImportSpec] = {} + for entry in entries or []: + import_id = entry.get("id") + if not import_id: + continue + specs[import_id] = ImportSpec( + id=import_id, + class_name=entry.get("className", ""), + params=entry.get("params", {}) or {}, + ) + return specs + + +def _extract_transform_params(node: Dict[str, Any]) -> Dict[str, Any]: + return { + "transformType": node.get("transformType"), + "roiMaskSource": node.get("roiMaskSource"), + "roiScale": node.get("roiScale"), + "roiTargetSize": node.get("roiTargetSize"), + "roiFallback": node.get("roiFallback"), + "centerCropSize": node.get("centerCropSize"), + "jitterHFlip": node.get("jitterHFlip"), + "jitterVFlip": node.get("jitterVFlip"), + "jitterRotation": node.get("jitterRotation"), + "jitterColorEnabled": node.get("jitterColorEnabled"), + "jitterColor": node.get("jitterColor"), + "resizeSize": node.get("resizeSize"), + } + + +def _extract_tower_params(node: Dict[str, Any]) -> Dict[str, Any]: + if node.get("towerType") == "metadata": + return { + "hidden_dim": node.get("mdHiddenDim"), + "dropout": node.get("mdDropout"), + "use_se": node.get("mdUseSe"), + "se_reduction": node.get("mdSeReduction"), + "se_pre_norm": node.get("mdSePreNorm"), + "freeze_ratio": node.get("mdFreezeRatio"), + } + return { + "backbone": node.get("imageBackbone"), + "freeze_ratio": node.get("imageFreezeRatio"), + "augment": node.get("imageAugment"), + "geometry_dim": node.get("imageGeometryDim"), + "use_se": node.get("imageUseSe"), + "se_reduction": node.get("imageSeReduction"), + "se_pre_norm": node.get("imageSePreNorm"), + } + + +def _extract_bridge_params(node: Dict[str, Any]) -> Dict[str, Any]: + return { + "fusion_dim": node.get("bridgeFusionDim"), + "use_se": node.get("bridgeUseSe"), + "se_reduction": node.get("bridgeSeReduction"), + "se_pre_norm": node.get("bridgeSePreNorm"), + } + + +def _edge_from(edge: Dict[str, Any]) -> Optional[str]: + return edge.get("from") or edge.get("source") + + +def _edge_to(edge: Dict[str, Any]) -> Optional[str]: + return edge.get("to") or edge.get("target") + + +def _upstream_chain(start_id: str, nodes: Dict[str, Dict[str, Any]], edges: List[Dict[str, Any]]) -> List[str]: + chain: List[str] = [] + visited = set() + current = start_id + while True: + if current in visited: + break + visited.add(current) + incoming = [edge for edge in edges if _edge_to(edge) == current] + if not incoming: + break + # prefer first incoming edge for now + current = _edge_from(incoming[0]) + if not current: + break + chain.append(current) + node = nodes.get(current) + if node and node.get("type") == "data": + break + return chain diff --git a/classes/v2/data_bundle.py b/classes/v2/data_bundle.py new file mode 100644 index 0000000..f9c9767 --- /dev/null +++ b/classes/v2/data_bundle.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Callable, Dict, Iterable, List, Optional, Tuple + +import numpy as np +import pandas as pd + + +class DataBundle: + """ + Generic, torch-free container for metadata and file/label bookkeeping. + + Keeps feature typing, vectorization, and patient-level splits generic. + Dataset-specific preprocessing (e.g., eye canonicalization) should live + in the dataset builder (e.g., papila_builders in v2). + """ + + def __init__( + self, + *, + image_dir: str, + clinical_dir: Optional[str] = None, + label_col: str, + patient_col: str = "Patient ID", + cat_cols: Optional[Iterable[str]] = None, + max_unique_for_cat: int = 4, + n_splits: int = 5, + random_seed: int = 42, + filename_template: str = "RET{pid:03d}{eye}.jpg", + image_path_fn: Optional[Callable[[pd.Series], Path]] = None, + ) -> None: + self.image_dir = Path(image_dir) + self.label_col = label_col + self.patient_col = patient_col + self.max_unique_for_cat = max_unique_for_cat + self.n_splits = n_splits + self.filename_template = filename_template + self.image_path_fn = image_path_fn + self.clinical_dir = Path(clinical_dir) if clinical_dir else None + + # Internal state + self.frames: List[pd.DataFrame] = [] + self.df: pd.DataFrame = pd.DataFrame() + 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]]] = {} + self.random_seed = int(random_seed) + + # ------------------- Public API ------------------- + def add_df( + self, + df: pd.DataFrame, + *, + id_column: Optional[str] = None, + exclude_cols: Optional[Iterable[str]] = None, + ) -> None: + """ + Add a dataframe and re-run typing, stats, and K-fold indices. + QC rules: + - Must have patient ID column; if not provided under that name, specify id_column. + """ + df = df.copy() + self._ensure_patient_id(df, id_column) + if self.label_col not in df.columns: + raise ValueError(f"label_col '{self.label_col}' not found in added dataframe") + + 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[self.patient_col].isin(train_ids)].reset_index(drop=True) + test_df = self.df[self.df[self.patient_col].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 + feats.extend(one) + # numeric missing flags + feats.extend(miss) + return np.asarray(feats, dtype=np.float32) + + def get_image_path(self, row: pd.Series) -> Path: + if self.image_path_fn is not None: + return Path(self.image_path_fn(row)) + pid = int(row[self.patient_col]) + eye = row.get("eyeID", "") + if eye in ("OS", "OD"): + eye_str = eye + else: + eye_str = str(eye) + return self.image_dir / self.filename_template.format(pid=pid, eye=eye_str) + + def encode_metadata(self, row: pd.Series) -> np.ndarray: + return self.vectorize_row(row) + + def get_label(self, row: pd.Series) -> int: + return int(row[self.label_col]) + + # ------------------- Internal helpers ------------------- + def _ensure_patient_id(self, df: pd.DataFrame, id_column: Optional[str]) -> None: + if self.patient_col in df.columns: + return + if id_column and id_column in df.columns: + df.rename(columns={id_column: self.patient_col}, inplace=True) + return + candidates = [ + c + for c in df.columns + if c.lower().replace(" ", "") in {"patientid", "patient", "pid"} + ] + if len(candidates) == 1: + df.rename(columns={candidates[0]: self.patient_col}, inplace=True) + return + raise ValueError( + f"A '{self.patient_col}' column is required; provide id_column=... if it has a different name." + ) + + def _refresh_master_df(self, exclude_cols: Optional[Iterable[str]] = None) -> None: + self.df = pd.concat(self.frames, axis=0, ignore_index=True) + 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, self.patient_col} + feature_candidates = [c for c in self.df.columns if c not in excluded] + cats = set(self.cat_cols) if self.cat_cols else set() + scalars = set() + for c in feature_candidates: + if c in cats: + continue + s = self.df[c] + 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: + 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 = {"": 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: + pats = self.df[self.patient_col].unique().tolist() + labels_by_pat: Dict[object, object] = {} + for pid, grp in self.df.groupby(self.patient_col): + 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: + 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} diff --git a/classes/v2/dataset.py b/classes/v2/dataset.py new file mode 100644 index 0000000..1aaf859 --- /dev/null +++ b/classes/v2/dataset.py @@ -0,0 +1,57 @@ +from torch.utils.data import Dataset +from PIL import Image +import numpy as np +import torch + + +class ClinicalDataset(Dataset): + """Generic dataset wrapping a DataBundle-like 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 diff --git a/classes/v2/filters.py b/classes/v2/filters.py new file mode 100644 index 0000000..96b6922 --- /dev/null +++ b/classes/v2/filters.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, List, Sequence, Tuple, Union +import re + +import pandas as pd + + +@dataclass +class RegexFilter: + pattern: str + flags: int = 0 + + def apply_paths(self, paths: Sequence[str]) -> Tuple[List[str], List[str]]: + if not self.pattern: + return list(paths), [] + try: + regex = re.compile(self.pattern, self.flags) + except re.error as err: + return list(paths), [f'Invalid regex "{self.pattern}": {err}'] + filtered = [p for p in paths if regex.search(p)] + return filtered, [] + + +@dataclass +class ColumnFilter: + column: str + operator: str + value: str + case_insensitive: bool = True + + def apply_df(self, df: pd.DataFrame) -> Tuple[pd.DataFrame, List[str]]: + warnings: List[str] = [] + if not self.column: + return df, ["Column filter missing column name."] + columns = list(df.columns) + col_index = _resolve_column_index(columns, self.column, warnings) + if col_index is None: + return df, warnings + col_name = columns[col_index] + if self.value is None or self.value == "": + return df, [f'Column filter "{self.column}" missing value.'] + series = df[col_name] + mask = series.apply( + lambda cell: compare_cell( + cell, self.value, self.operator, case_insensitive=self.case_insensitive + ) + ) + return df[mask], warnings + + +FilterSpec = Union[RegexFilter, ColumnFilter] + + +def apply_regex_filters(paths: Sequence[str], filters: Iterable[RegexFilter]) -> Tuple[List[str], List[str]]: + filtered = list(paths) + warnings: List[str] = [] + for filt in filters: + filtered, warn = filt.apply_paths(filtered) + warnings.extend(warn) + return filtered, warnings + + +def apply_column_filters(df: pd.DataFrame, filters: Iterable[ColumnFilter]) -> Tuple[pd.DataFrame, List[str]]: + filtered = df + warnings: List[str] = [] + for filt in filters: + filtered, warn = filt.apply_df(filtered) + warnings.extend(warn) + return filtered, warnings + + +def compare_cell(cell, raw_value: str, operator: str, case_insensitive: bool = True) -> bool: + cell_str = "" if cell is None else str(cell).strip() + value_str = "" if raw_value is None else str(raw_value).strip() + if case_insensitive: + cell_str = cell_str.lower() + value_str = value_str.lower() + if operator == "=": + return cell_str == value_str + if operator == "!=": + return cell_str != value_str + cell_num = _to_float(cell_str) + value_num = _to_float(value_str) + if cell_num is None or value_num is None: + return False + if operator == ">": + return cell_num > value_num + if operator == ">=": + return cell_num >= value_num + if operator == "<": + return cell_num < value_num + if operator == "<=": + return cell_num <= value_num + return False + + +def _resolve_column_index(columns: Sequence[str], column: str, warnings: List[str]) -> int | None: + try: + return columns.index(column) + except ValueError: + lower = column.lower() + matches = [idx for idx, col in enumerate(columns) if str(col).lower() == lower] + if matches: + if len(matches) > 1: + warnings.append( + f'Column "{column}" matched multiple headers; using "{columns[matches[0]]}".' + ) + return matches[0] + warnings.append(f'Column "{column}" not found.') + return None + + +def _to_float(value: str) -> float | None: + try: + return float(value) + except (TypeError, ValueError): + return None diff --git a/classes/v2/hypertower_logger.py b/classes/v2/hypertower_logger.py new file mode 100644 index 0000000..59938db --- /dev/null +++ b/classes/v2/hypertower_logger.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import csv +import json +import logging +from pathlib import Path +from typing import Optional + + +DEFAULT_OPTIONAL_EPOCH_COLS = [ + "pct_fused", + "pct_img", + "pct_md", + "phase", + "se_mean", + "se_std", + "se_pct_lt_0.2", + "se_pct_gt_0.8", + "holdout_loss", + "holdout_acc_fused", + "holdout_acc_img", + "holdout_acc_md", + "holdout_auc_fused", + "holdout_auc_img", + "holdout_auc_md", + "best_monitor", + "best_so_far", + "best_epoch", + "early_best_so_far", + "early_bad_epochs", + "early_improved", + "early_monitor", + "holdout_best_monitor", + "holdout_best_so_far", + "holdout_best_epoch", +] + + +class HypertowerLogger: + """ + Shared logging utility for V2 tower workflows. + - train.log line logging + - epoch_log.csv row logging with stable header + - lightweight JSON/array artifact helpers + """ + + def __init__( + self, + *, + run_dir: Path, + train_log_path: Optional[Path] = None, + epoch_log_path: Optional[Path] = None, + logger_name: Optional[str] = None, + ) -> None: + self.run_dir = Path(run_dir).resolve() + self.run_dir.mkdir(parents=True, exist_ok=True) + self.train_log_path = Path(train_log_path) if train_log_path else (self.run_dir / "train.log") + self.epoch_log_path = Path(epoch_log_path) if epoch_log_path else (self.run_dir / "epoch_log.csv") + + self._logger_name = logger_name or f"hypertower.{id(self)}" + self.logger = logging.getLogger(self._logger_name) + self.logger.setLevel(logging.INFO) + self.logger.handlers = [] + fh = logging.FileHandler(str(self.train_log_path)) + fh.setFormatter(logging.Formatter("%(asctime)s - %(message)s")) + self.logger.addHandler(fh) + self.logger.propagate = False + + self._epoch_log_fp = None + self._epoch_log_writer = None + self._epoch_log_fields: list[str] | None = None + + def info(self, msg: str) -> None: + self.logger.info(msg) + + def warning(self, msg: str) -> None: + self.logger.warning(msg) + + def error(self, msg: str) -> None: + self.logger.error(msg) + + def write_epoch_row( + self, + row: dict, + *, + path: str | Path | None = None, + optional_cols: Optional[list[str]] = None, + ) -> None: + optional = optional_cols if optional_cols is not None else DEFAULT_OPTIONAL_EPOCH_COLS + if self._epoch_log_writer is None: + fieldnames = list(dict.fromkeys([*row.keys(), *optional])) + target_path = Path(path) if path is not None else self.epoch_log_path + target_path.parent.mkdir(parents=True, exist_ok=True) + self._epoch_log_fp = open(target_path, "w", newline="", encoding="utf-8") + self._epoch_log_writer = csv.DictWriter(self._epoch_log_fp, fieldnames=fieldnames) + self._epoch_log_writer.writeheader() + self._epoch_log_fields = fieldnames + + assert self._epoch_log_fields is not None + assert self._epoch_log_writer is not None + assert self._epoch_log_fp is not None + for key in self._epoch_log_fields: + row.setdefault(key, None) + self._epoch_log_writer.writerow({k: row.get(k) for k in self._epoch_log_fields}) + self._epoch_log_fp.flush() + + def write_json(self, path: str | Path, payload: dict) -> None: + target = Path(path) + if not target.is_absolute(): + target = self.run_dir / target + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + def close(self) -> None: + if self._epoch_log_fp is not None: + try: + self._epoch_log_fp.close() + except Exception: + pass + self._epoch_log_fp = None + self._epoch_log_writer = None + self._epoch_log_fields = None + for handler in list(self.logger.handlers): + try: + handler.close() + except Exception: + pass + self.logger.removeHandler(handler) diff --git a/classes/v2/loader_factory.py b/classes/v2/loader_factory.py new file mode 100644 index 0000000..3486d3d --- /dev/null +++ b/classes/v2/loader_factory.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Optional + +from torch.utils.data import DataLoader + +from .network_manager import LoaderBundle, PatientSplit +from .slot_dataset import SlotDataset, slot_collate +from .profiles.base import SlotDescriptor, SimpleDatasetProfile + + +def _default_slot_descriptors(patient_col: str, label_col: str) -> dict[str, SlotDescriptor]: + return { + "id_1": SlotDescriptor( + key="id_1", + kind="id", + description=f"Patient identifier column ({patient_col})", + required=True, + shape_hint="scalar", + ), + "label_1": SlotDescriptor( + key="label_1", + kind="label", + description=f"Label column ({label_col})", + required=True, + shape_hint="scalar", + ), + "image_1": SlotDescriptor( + key="image_1", + kind="image", + description="Primary image slot", + required=False, + shape_hint="HWC or CHW", + ), + "matrix_1": SlotDescriptor( + key="matrix_1", + kind="matrix", + description="Primary matrix slot", + required=False, + shape_hint="[feature_dim]", + ), + } + + +def _row_to_sample( + row: Any, + *, + clinical: Any, + patient_col: str, + label_col: str, +) -> dict[str, Any]: + return { + "id_1": row[patient_col], + "label_1": row[label_col], + "image_1": clinical.get_image_path(row) if hasattr(clinical, "get_image_path") else None, + "matrix_1": clinical.vectorize_row(row) if hasattr(clinical, "vectorize_row") else None, + } + + +@dataclass +class SlotLoaderFactory: + """ + Generic loader factory that emits dict batches keyed by slot names. + """ + + image_transform: Optional[Callable] = None + matrix_transform: Optional[Callable] = None + num_workers: int = 0 + + def build( + self, + *, + clinical: Any, + split: PatientSplit, + args: Any, + fold: int, + profile: Optional[Any] = None, + ) -> LoaderBundle: + batch_size = int(getattr(args, "batch_size", 8)) + slot_desc = self._resolve_slot_descriptors(clinical=clinical, profile=profile) + + train_samples = self._build_samples(split.train, clinical, profile, slot_desc) + val_samples = self._build_samples(split.val, clinical, profile, slot_desc) + holdout_samples = ( + self._build_samples(split.holdout, clinical, profile, slot_desc) + if split.holdout is not None + else None + ) + + train_loader = DataLoader( + SlotDataset( + train_samples, + slot_desc, + image_transform=self.image_transform, + matrix_transform=self.matrix_transform, + ), + batch_size=batch_size, + shuffle=True, + num_workers=self.num_workers, + collate_fn=slot_collate, + ) + val_loader = DataLoader( + SlotDataset( + val_samples, + slot_desc, + image_transform=self.image_transform, + matrix_transform=self.matrix_transform, + ), + batch_size=batch_size, + shuffle=False, + num_workers=self.num_workers, + collate_fn=slot_collate, + ) + holdout_loader = None + if holdout_samples is not None: + holdout_loader = DataLoader( + SlotDataset( + holdout_samples, + slot_desc, + image_transform=self.image_transform, + matrix_transform=self.matrix_transform, + ), + batch_size=batch_size, + shuffle=False, + num_workers=self.num_workers, + collate_fn=slot_collate, + ) + return LoaderBundle(train=train_loader, val=val_loader, holdout=holdout_loader) + + @staticmethod + def _resolve_slot_descriptors( + *, + clinical: Any, + profile: Optional[Any], + ) -> dict[str, SlotDescriptor]: + if profile is not None and hasattr(profile, "slot_descriptors"): + return profile.slot_descriptors() + patient_col = getattr(clinical, "patient_col", "Patient ID") + label_col = getattr(clinical, "label_col", "Diagnosis") + return _default_slot_descriptors(patient_col, label_col) + + @staticmethod + def _build_samples( + df, + clinical: Any, + profile: Optional[Any], + slot_desc: dict[str, SlotDescriptor], + ) -> list[dict[str, Any]]: + if df is None or df.empty: + return [] + if profile is not None and hasattr(profile, "build_samples"): + return profile.build_samples(df=df, clinical=clinical) + + patient_col = getattr(profile, "patient_col", None) if profile is not None else None + label_col = getattr(profile, "label_col", None) if profile is not None else None + pcol = patient_col or "Patient ID" + lcol = label_col or getattr(clinical, "label_col", "Diagnosis") + samples = [] + for _, row in df.iterrows(): + sample = _row_to_sample(row, clinical=clinical, patient_col=pcol, label_col=lcol) + for key in slot_desc.keys(): + sample.setdefault(key, None) + samples.append(sample) + return samples diff --git a/classes/v2/model_builder.py b/classes/v2/model_builder.py new file mode 100644 index 0000000..180fbb7 --- /dev/null +++ b/classes/v2/model_builder.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Optional + +import torch +from torch import nn + +from classes.v2.bridges import Bridge, VoteBridge +from classes.v2.towers import ImageTower, MDTower + +from .config_builder import ConfigAssembly +from .transforms import build_transform_chain + + +@dataclass +class V2ModelBundle: + image_tower: Optional[ImageTower] + metadata_tower: Optional[MDTower] + bridge: Optional[nn.Module] + classifier: Optional[nn.Module] + image_transform: Optional[Callable] + matrix_transform: Optional[Callable] + + +def build_model_bundle( + assembly: ConfigAssembly, + clinical: Any, + *, + device: Optional[torch.device] = None, + strict: bool = True, +) -> V2ModelBundle: + """ + Build torch modules and input transforms from a V2 config assembly. + """ + image_tower_spec = _pick_tower(assembly, "image") + md_tower_spec = _pick_tower(assembly, "metadata") + bridge_spec = _pick_bridge(assembly) + image_loader = _pick_loader(assembly, input_type="image") + + clinical_core = getattr(clinical, "clinical", clinical) + num_classes = _infer_num_classes(clinical) + + img_tower = None + if image_tower_spec is not None: + img_tower = ImageTower( + backbone=image_tower_spec.params.get("backbone", "efficientnet_b0"), + freeze_ratio=float(image_tower_spec.params.get("freeze_ratio", 0.0) or 0.0), + use_se=bool(image_tower_spec.params.get("use_se", False)), + se_reduction=int(image_tower_spec.params.get("se_reduction", 16) or 16), + se_pre_norm=bool(image_tower_spec.params.get("se_pre_norm", True)), + augment=bool(image_tower_spec.params.get("augment", True)), + geometry_dim=int(image_tower_spec.params.get("geometry_dim", 0) or 0), + ) + if device is not None: + img_tower = img_tower.to(device) + + md_tower = None + if md_tower_spec is not None: + md_tower = MDTower( + clinical_core, + hidden_dim=int(md_tower_spec.params.get("hidden_dim", 128) or 128), + dropout=float(md_tower_spec.params.get("dropout", 0.1) or 0.1), + use_se=bool(md_tower_spec.params.get("use_se", False)), + se_reduction=int(md_tower_spec.params.get("se_reduction", 16) or 16), + se_pre_norm=bool(md_tower_spec.params.get("se_pre_norm", True)), + ) + if device is not None: + md_tower = md_tower.to(device) + + bridge = None + if bridge_spec is not None and img_tower is not None and md_tower is not None: + if bridge_spec.method == "consensus": + bridge = VoteBridge(num_classes=num_classes) + else: + bridge = Bridge( + img_dim=img_tower.out_dim, + meta_dim=md_tower.out_dim, + num_classes=num_classes, + fusion_dim=int(bridge_spec.params.get("fusion_dim", 256) or 256), + mode="fused", + use_se=bool(bridge_spec.params.get("use_se", True)), + se_reduction=int(bridge_spec.params.get("se_reduction", 16) or 16), + se_pre_norm=bool(bridge_spec.params.get("se_pre_norm", True)), + ) + if device is not None: + bridge = bridge.to(device) + + classifier = None + if assembly.classifiers: + classifier = nn.Identity() + if device is not None: + classifier = classifier.to(device) + + image_transform = None + if image_loader is not None and image_tower_spec is not None: + image_transform = build_transform_chain( + image_loader.transforms, + backbone_name=image_tower_spec.params.get("backbone", "efficientnet_b0"), + augment=bool(image_tower_spec.params.get("augment", True)), + strict=strict, + ) + + return V2ModelBundle( + image_tower=img_tower, + metadata_tower=md_tower, + bridge=bridge, + classifier=classifier, + image_transform=image_transform, + matrix_transform=None, + ) + + +def _pick_tower(assembly: ConfigAssembly, tower_type: str): + matches = [tower for tower in assembly.towers.values() if tower.tower_type == tower_type] + if not matches: + return None + if len(matches) > 1: + raise ValueError(f"Multiple {tower_type} towers found; only one is supported for now.") + return matches[0] + + +def _pick_bridge(assembly: ConfigAssembly): + if not assembly.bridges: + return None + if len(assembly.bridges) > 1: + raise ValueError("Multiple bridges found; only one is supported for now.") + return next(iter(assembly.bridges.values())) + + +def _pick_loader(assembly: ConfigAssembly, input_type: str): + matches = [loader for loader in assembly.loaders.values() if loader.input_type == input_type] + if not matches: + return None + if len(matches) > 1: + raise ValueError(f"Multiple loaders with input_type={input_type!r} found.") + return matches[0] + + +def _infer_num_classes(clinical: Any) -> int: + df = getattr(clinical, "df", None) + label_col = getattr(clinical, "label_col", None) + if df is None and hasattr(clinical, "clinical"): + df = clinical.clinical.df + label_col = clinical.clinical.label_col + if df is None or label_col is None or label_col not in df.columns: + return 2 + return int(df[label_col].dropna().nunique()) diff --git a/classes/v2/network_manager.py b/classes/v2/network_manager.py new file mode 100644 index 0000000..4f78ba6 --- /dev/null +++ b/classes/v2/network_manager.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional, Protocol + +import pandas as pd + + +@dataclass +class PatientSplit: + """Patient-disjoint split definition for a fold.""" + + train: pd.DataFrame + val: pd.DataFrame + holdout: Optional[pd.DataFrame] = None + + +@dataclass +class LoaderBundle: + """All loaders needed by a training run.""" + + train: Any + val: Any + holdout: Optional[Any] = None + + +@dataclass +class FoldResult: + """Normalized fold output from trainer implementations.""" + + fold: int + metrics: dict[str, Any] + artifacts: dict[str, Any] + + +class SplitManager(Protocol): + def build_plans( + self, + *, + clinical: Any, + args: Any, + profile: Optional[Any] = None, + ) -> list[PatientSplit]: + ... + + +class GraphFactory(Protocol): + def build( + self, + *, + clinical: Any, + args: Any, + fold: int, + profile: Optional[Any] = None, + ) -> Any: + ... + + +class LoaderFactory(Protocol): + def build( + self, + *, + clinical: Any, + split: PatientSplit, + args: Any, + fold: int, + profile: Optional[Any] = None, + ) -> LoaderBundle: + ... + + +class Trainer(Protocol): + def fit( + self, + *, + graph: Any, + loaders: LoaderBundle, + args: Any, + fold: int, + profile: Optional[Any] = None, + ) -> FoldResult: + ... + + +class NetworkManager: + """ + V2 orchestration entrypoint. + + This class is intentionally small and modular: + - split policy is delegated to a SplitManager + - graph assembly is delegated to a GraphFactory + - dataloaders are delegated to a LoaderFactory + - train/eval/checkpoint lifecycle is delegated to a Trainer + """ + + def __init__( + self, + *, + clinical: Any, + args: Any, + split_manager: SplitManager, + graph_factory: GraphFactory, + loader_factory: LoaderFactory, + trainer: Trainer, + profile: Optional[Any] = None, + ) -> None: + self.clinical = clinical + self.args = args + self.split_manager = split_manager + self.graph_factory = graph_factory + self.loader_factory = loader_factory + self.trainer = trainer + self.profile = profile + self._split_plans: Optional[list[PatientSplit]] = None + + def run_fold(self, fold: int) -> FoldResult: + plans = self._get_split_plans() + if fold < 0 or fold >= len(plans): + raise IndexError(f"Requested fold {fold} but only {len(plans)} fold plans are available") + split = plans[fold] + self._validate_patient_disjointness(split) + self._validate_labels(split) + + graph = self.graph_factory.build( + clinical=self.clinical, + args=self.args, + fold=fold, + profile=self.profile, + ) + loaders = self.loader_factory.build( + clinical=self.clinical, + split=split, + args=self.args, + fold=fold, + profile=self.profile, + ) + return self.trainer.fit( + graph=graph, + loaders=loaders, + args=self.args, + fold=fold, + profile=self.profile, + ) + + def run_all_folds(self, n_splits: Optional[int] = None) -> list[FoldResult]: + plans = self._get_split_plans() + max_folds = len(plans) + if n_splits is None: + n = max_folds + else: + n = int(n_splits) + if n < 1: + raise ValueError("n_splits must be >= 1") + if n > max_folds: + raise ValueError(f"Requested {n} folds but only {max_folds} fold plans are available") + return [self.run_fold(fold) for fold in range(n)] + + def _get_split_plans(self) -> list[PatientSplit]: + if self._split_plans is None: + self._split_plans = self.split_manager.build_plans( + clinical=self.clinical, + args=self.args, + profile=self.profile, + ) + if not self._split_plans: + raise ValueError("SplitManager returned no fold plans") + return self._split_plans + + def _validate_patient_disjointness(self, split: PatientSplit) -> None: + train_ids = self._patient_ids(split.train) + val_ids = self._patient_ids(split.val) + holdout_ids = self._patient_ids(split.holdout) if split.holdout is not None else set() + + if train_ids & val_ids: + overlap = sorted(train_ids & val_ids)[:10] + raise ValueError(f"Patient leakage between train/val: {overlap}") + if train_ids & holdout_ids: + overlap = sorted(train_ids & holdout_ids)[:10] + raise ValueError(f"Patient leakage between train/holdout: {overlap}") + if val_ids & holdout_ids: + overlap = sorted(val_ids & holdout_ids)[:10] + raise ValueError(f"Patient leakage between val/holdout: {overlap}") + + def _validate_labels(self, split: PatientSplit) -> None: + label_col = getattr(self.clinical, "label_col", None) + if not label_col: + return + for name, df in (("train", split.train), ("val", split.val), ("holdout", split.holdout)): + if df is None: + continue + if label_col not in df.columns: + raise ValueError(f"{name} split is missing label column {label_col!r}") + + @staticmethod + def _patient_ids(df: Optional[pd.DataFrame]) -> set[Any]: + if df is None or df.empty: + return set() + if "Patient ID" not in df.columns: + raise ValueError("Split dataframes must include 'Patient ID'") + return set(df["Patient ID"].tolist()) diff --git a/classes/v2/papila_builders.py b/classes/v2/papila_builders.py new file mode 100644 index 0000000..bcc2ce6 --- /dev/null +++ b/classes/v2/papila_builders.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import Dict, List + +import numpy as np +import pandas as pd + +from classes.v2.data_bundle import DataBundle + +# ---- 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).""" + df["IOP_raw"] = df.apply(_pick_iop, axis=1) + 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) + ] + if "VF_MD" in df.columns: + df.drop(columns=["VF_MD"], inplace=True) + return df + + +def _canonicalize_eye_column(df: pd.DataFrame) -> None: + if "eyeID" in df.columns: + src = "eyeID" + else: + src = None + for c in df.columns: + if "eye" in c.lower(): + src = c + break + if src is None: + df["eyeID"] = "OS" + return + + 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" + 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") + + +def build_papila_data( + *, + image_dir: str, + clinical_dir: str, + label_col: str, + cat_cols: List[str], + n_splits: int = 5, + random_seed: int = 42, +) -> DataBundle: + """ + Build a DataBundle for PAPILA with dataset-specific preprocessing: + - load OD/OS Excel sheets + - normalize Patient ID + - canonicalize eyeID + - compute IOP_raw / IOP_corr, drop VF_MD + - build feature typing & folds + """ + bundle = DataBundle( + image_dir=image_dir, + clinical_dir=clinical_dir, + label_col=label_col, + patient_col="Patient ID", + cat_cols=cat_cols, + n_splits=n_splits, + random_seed=random_seed, + filename_template="RET{pid:03d}{eye}.jpg", + ) + + od = pd.read_excel(f"{clinical_dir}/patient_data_od.xlsx", header=1) + od["eyeID"] = "OD" + os = pd.read_excel(f"{clinical_dir}/patient_data_os.xlsx", header=1) + os["eyeID"] = "OS" + + for frame in (od, os): + if "Patient ID" not in frame.columns and "ID" in frame.columns: + frame.rename(columns={"ID": "Patient ID"}, inplace=True) + frame["Patient ID"] = frame["Patient ID"].astype(str).str.extract(r"(\d+)")[0].astype(int) + _canonicalize_eye_column(frame) + + bundle.add_df(od, id_column="ID") + bundle.add_df(os, id_column="ID") + + for i in range(len(bundle.frames)): + bundle.frames[i] = _apply_iop_and_drop_md(bundle.frames[i]) + + bundle._refresh_master_df() + bundle._infer_or_validate_feature_types() + bundle._compute_numeric_stats() + bundle._build_cat_maps() + bundle._compute_feature_dim() + bundle._build_kfold_indices() + + return bundle diff --git a/classes/v2/papila_data.py b/classes/v2/papila_data.py new file mode 100644 index 0000000..e47fe27 --- /dev/null +++ b/classes/v2/papila_data.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Optional + +import pandas as pd + +from classes.v2.data_bundle import DataBundle +from classes.v2.papila_builders import build_papila_data + + +@dataclass +class PapilaData: + """ + V2-friendly wrapper around the DataBundle pipeline. + + Keeps all formatting/normalization behavior from build_papila_clinical, + but exposes a minimal surface area for the V2 engine. + """ + + clinical: DataBundle + patient_col: str = "Patient ID" + + @property + def df(self) -> pd.DataFrame: + return self.clinical.df + + @property + def label_col(self) -> str: + return self.clinical.label_col + + @property + def feature_dim(self) -> int: + return self.clinical.feature_dim + + def get_image_path(self, row: pd.Series): + return self.clinical.get_image_path(row) + + def vectorize_row(self, row: pd.Series): + return self.clinical.vectorize_row(row) + + @classmethod + def from_dirs( + cls, + *, + image_dir: str, + clinical_dir: str, + label_col: str, + cat_cols: Iterable[str], + n_splits: int = 5, + random_seed: int = 42, + ) -> "PapilaData": + clinical = build_papila_data( + image_dir=image_dir, + clinical_dir=clinical_dir, + label_col=label_col, + cat_cols=list(cat_cols), + n_splits=n_splits, + random_seed=random_seed, + ) + return cls(clinical=clinical) diff --git a/classes/v2/profiles/__init__.py b/classes/v2/profiles/__init__.py new file mode 100644 index 0000000..b903aa0 --- /dev/null +++ b/classes/v2/profiles/__init__.py @@ -0,0 +1,10 @@ +from .base import DatasetProfile, SimpleDatasetProfile, SlotDescriptor +from .papila import PapilaProfile, build_papila_profile + +__all__ = [ + "DatasetProfile", + "SimpleDatasetProfile", + "SlotDescriptor", + "PapilaProfile", + "build_papila_profile", +] diff --git a/classes/v2/profiles/base.py b/classes/v2/profiles/base.py new file mode 100644 index 0000000..2c692b4 --- /dev/null +++ b/classes/v2/profiles/base.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, Any + +import pandas as pd + + +@dataclass(frozen=True) +class SlotDescriptor: + """ + Metadata for a generic batch slot key (e.g., image_1, matrix_1). + """ + + key: str + kind: str + description: str + required: bool = True + shape_hint: str | None = None + + +class DatasetProfile(Protocol): + """ + Dataset-specific wiring that stays outside the generic V2 engine. + """ + + name: str + patient_col: str + label_col: str + + def slot_descriptors(self) -> dict[str, SlotDescriptor]: + ... + + def semantic_aliases(self) -> dict[str, str]: + ... + + def build_samples(self, *, df: pd.DataFrame, clinical: Any) -> list[dict[str, Any]]: + ... + + +@dataclass(frozen=True) +class SimpleDatasetProfile: + name: str + patient_col: str + label_col: str + slots: dict[str, SlotDescriptor] + aliases: dict[str, str] + + def slot_descriptors(self) -> dict[str, SlotDescriptor]: + return dict(self.slots) + + def semantic_aliases(self) -> dict[str, str]: + return dict(self.aliases) diff --git a/classes/v2/profiles/papila.py b/classes/v2/profiles/papila.py new file mode 100644 index 0000000..4a33062 --- /dev/null +++ b/classes/v2/profiles/papila.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import pandas as pd + +from dataclasses import dataclass + +from .base import SimpleDatasetProfile, SlotDescriptor + + +@dataclass(frozen=True) +class PapilaProfile(SimpleDatasetProfile): + sample_mode: str = "patient" # "patient" | "eye" + + def build_samples(self, *, df: pd.DataFrame, clinical) -> list[dict[str, object]]: + samples: list[dict[str, object]] = [] + patient_col = self.patient_col + label_col = self.label_col + + mode = (self.sample_mode or "patient").lower() + if mode not in {"patient", "eye"}: + raise ValueError(f"Unsupported sample_mode '{self.sample_mode}'. Expected 'patient' or 'eye'.") + + if mode == "eye": + for _, row in df.iterrows(): + pid = row[patient_col] + label = row[label_col] + image_1 = clinical.get_image_path(row) if hasattr(clinical, "get_image_path") else None + matrix_1 = clinical.vectorize_row(row) if hasattr(clinical, "vectorize_row") else None + samples.append( + { + "id_1": pid, + "label_1": label, + "image_1": image_1, + "matrix_1": matrix_1, + } + ) + return samples + + for pid, grp in df.groupby(patient_col): + label_series = grp[label_col] + if label_series.empty: + continue + mode_vals = label_series.mode() + label = mode_vals.iloc[0] if not mode_vals.empty else label_series.iloc[0] + + def _row_for_eye(eye: str): + if "eyeID" not in grp.columns: + return None + match = grp[grp["eyeID"].astype(str).str.upper() == eye] + if match.empty: + return None + return match.iloc[0] + + row_od = _row_for_eye("OD") + row_os = _row_for_eye("OS") + row_any = grp.iloc[0] + + image_1 = clinical.get_image_path(row_od) if row_od is not None else None + image_2 = clinical.get_image_path(row_os) if row_os is not None else None + matrix_1 = clinical.vectorize_row(row_od) if row_od is not None else None + matrix_2 = clinical.vectorize_row(row_os) if row_os is not None else None + + if image_1 is None and hasattr(clinical, "get_image_path"): + image_1 = clinical.get_image_path(row_any) + if matrix_1 is None and hasattr(clinical, "vectorize_row"): + matrix_1 = clinical.vectorize_row(row_any) + + samples.append( + { + "id_1": pid, + "label_1": label, + "image_1": image_1, + "image_2": image_2, + "matrix_1": matrix_1, + "matrix_2": matrix_2, + } + ) + return samples + + +def build_papila_profile( + *, + patient_col: str = "Patient ID", + label_col: str = "Diagnosis", + sample_mode: str = "patient", +) -> PapilaProfile: + """ + PAPILA-specific semantic map for generic V2 slot keys. + + The engine remains slot-based (image_1/image_2/matrix_1/...). + PAPILA meaning is captured here so run config stays dataset-local. + """ + + slots = { + "id_1": SlotDescriptor( + key="id_1", + kind="id", + description=f"Patient identifier column ({patient_col})", + required=True, + shape_hint="scalar", + ), + "label_1": SlotDescriptor( + key="label_1", + kind="label", + description=f"Diagnosis label column ({label_col})", + required=True, + shape_hint="scalar", + ), + "image_1": SlotDescriptor( + key="image_1", + kind="image", + description="Fundus image slot 1 (PAPILA: OD / right eye)", + required=False, + shape_hint="HWC or CHW", + ), + "image_2": SlotDescriptor( + key="image_2", + kind="image", + description="Fundus image slot 2 (PAPILA: OS / left eye)", + required=False, + shape_hint="HWC or CHW", + ), + "matrix_1": SlotDescriptor( + key="matrix_1", + kind="matrix", + description="Clinical metadata feature vector", + required=False, + shape_hint="[feature_dim]", + ), + "matrix_2": SlotDescriptor( + key="matrix_2", + kind="matrix", + description="Optional auxiliary tabular vector (reserved for experiments)", + required=False, + shape_hint="[feature_dim_2]", + ), + } + + aliases = { + "id_1": "patient_id", + "label_1": "diagnosis", + "image_1": "od_fundus", + "image_2": "os_fundus", + "matrix_1": "clinical_metadata", + "matrix_2": "aux_metadata", + } + + return PapilaProfile( + name="papila", + patient_col=patient_col, + label_col=label_col, + slots=slots, + aliases=aliases, + sample_mode=sample_mode, + ) diff --git a/classes/v2/slot_dataset.py b/classes/v2/slot_dataset.py new file mode 100644 index 0000000..9335113 --- /dev/null +++ b/classes/v2/slot_dataset.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import Any, Callable, Optional + +from pathlib import Path +from PIL import Image +import numpy as np +import torch +from torch.utils.data import Dataset +from torchvision import transforms + +from .profiles.base import SlotDescriptor + + +def slot_collate(batch: list[dict[str, Any]]) -> dict[str, Any]: + if not batch: + return {} + keys = batch[0].keys() + out: dict[str, Any] = {} + for key in keys: + vals = [item.get(key) for item in batch] + if all(isinstance(v, torch.Tensor) for v in vals): + try: + out[key] = torch.stack(vals, dim=0) + except Exception: + out[key] = vals + else: + out[key] = vals + return out + + +class SlotDataset(Dataset): + """ + Dataset that yields dicts of slot-keyed values. + + Sample records are expected to be dicts with keys matching slot descriptors. + Image slots accept filesystem paths; matrix slots accept array-like values. + """ + + def __init__( + self, + samples: list[dict[str, Any]], + slot_descriptors: dict[str, SlotDescriptor], + *, + image_transform: Optional[Callable[[Image.Image], torch.Tensor]] = None, + matrix_transform: Optional[Callable[[Any], torch.Tensor]] = None, + image_preprocessor: Optional[Callable[..., Image.Image]] = None, + ) -> None: + self.samples = samples + self.slot_descriptors = slot_descriptors + self.image_transform = image_transform or transforms.ToTensor() + self.matrix_transform = matrix_transform or self._default_matrix_transform + self.image_preprocessor = image_preprocessor + + def __len__(self) -> int: + return len(self.samples) + + def __getitem__(self, idx: int) -> dict[str, Any]: + record = self.samples[idx] + out: dict[str, Any] = {} + for key, desc in self.slot_descriptors.items(): + val = record.get(key) + if desc.kind == "image": + out[key] = self._load_image(val, required=desc.required) + elif desc.kind == "matrix": + out[key] = self._load_matrix(val, required=desc.required) + else: + out[key] = val + return out + + def _load_image(self, value: Any, *, required: bool) -> Optional[torch.Tensor]: + if value is None: + if required: + raise ValueError("Missing required image slot") + return None + path = Path(value) + img = Image.open(path).convert("RGB") + if self.image_preprocessor is not None: + try: + img = self.image_preprocessor(img, path) + except TypeError: + img = self.image_preprocessor(img) + return self.image_transform(img) + + def _load_matrix(self, value: Any, *, required: bool) -> Optional[torch.Tensor]: + if value is None: + if required: + raise ValueError("Missing required matrix slot") + return None + return self.matrix_transform(value) + + @staticmethod + def _default_matrix_transform(value: Any) -> torch.Tensor: + if isinstance(value, torch.Tensor): + return value.float() + if isinstance(value, np.ndarray): + return torch.from_numpy(value.astype(np.float32, copy=False)) + return torch.as_tensor(value, dtype=torch.float32) diff --git a/classes/v2/split_manager.py b/classes/v2/split_manager.py new file mode 100644 index 0000000..ffca989 --- /dev/null +++ b/classes/v2/split_manager.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Iterable, Optional + +import numpy as np +import pandas as pd +from sklearn.model_selection import KFold, StratifiedKFold + +from .network_manager import PatientSplit + + +@dataclass(frozen=True) +class SplitPlan: + train_patient_ids: set[Any] + val_patient_ids: set[Any] + holdout_patient_ids: set[Any] + + +def build_patient_split_plans( + patient_ids: Iterable[Any], + patient_labels: Iterable[Any], + *, + n_splits: int, + seed: int, + holdout_per_class: int = 0, + holdout_seed: int = 123, +) -> list[SplitPlan]: + """ + Core vector-based splitter. + + Inputs are one row per patient: + - patient_ids: unique patient IDs + - patient_labels: one label per patient + """ + ids = np.asarray(list(patient_ids)) + labels = np.asarray(list(patient_labels)) + if ids.ndim != 1 or labels.ndim != 1: + raise ValueError("patient_ids and patient_labels must be 1D arrays") + if ids.size != labels.size: + raise ValueError(f"Length mismatch: ids={ids.size}, labels={labels.size}") + if ids.size == 0: + raise ValueError("No patients available for splitting") + if len(set(ids.tolist())) != ids.size: + raise ValueError("patient_ids must be unique (one label per patient)") + if n_splits < 2: + raise ValueError("n_splits must be >= 2") + + holdout_ids: set[Any] = set() + if holdout_per_class > 0: + rng = np.random.default_rng(holdout_seed) + for label in np.unique(labels): + idx = np.where(labels == label)[0] + if idx.size == 0: + continue + n = min(holdout_per_class, idx.size) + chosen = rng.choice(idx, size=n, replace=False) + holdout_ids.update(ids[chosen].tolist()) + + keep_mask = ~np.isin(ids, list(holdout_ids)) + cv_ids = ids[keep_mask] + cv_labels = labels[keep_mask] + if cv_ids.size < n_splits: + raise ValueError( + f"Not enough patients ({cv_ids.size}) for n_splits={n_splits} after holdout removal" + ) + + use_stratified = _can_stratify(cv_labels, n_splits) + if use_stratified: + splitter = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed) + splits = list(splitter.split(cv_ids, cv_labels)) + else: + splitter = KFold(n_splits=n_splits, shuffle=True, random_state=seed) + splits = list(splitter.split(cv_ids)) + + plans: list[SplitPlan] = [] + for train_idx, val_idx in splits: + plans.append( + SplitPlan( + train_patient_ids=set(cv_ids[train_idx].tolist()), + val_patient_ids=set(cv_ids[val_idx].tolist()), + holdout_patient_ids=set(holdout_ids), + ) + ) + return plans + + +class PatientFirstSplitManager: + """ + Patient-level splitter for V2. + + Behavior: + - Optional binary filtering happens first (labels in {0,1} only). + - Optional holdout is sampled at the patient level (never per-eye rows). + - K-fold split is built on remaining patients. + - Returned dataframes contain all rows for each selected patient. + """ + + def __init__( + self, + *, + patient_col: str = "Patient ID", + label_col: Optional[str] = None, + ) -> None: + self.patient_col = patient_col + self.label_col = label_col + + def build_plans( + self, + *, + clinical: Any, + args: Any, + profile: Optional[Any] = None, + ) -> list[PatientSplit]: + profile_label_col = getattr(profile, "label_col", None) if profile is not None else None + profile_patient_col = getattr(profile, "patient_col", None) if profile is not None else None + patient_col = profile_patient_col or self.patient_col + label_col = self.label_col or profile_label_col or getattr(clinical, "label_col", None) + if label_col is None: + raise ValueError("Could not resolve label column from SplitManager or clinical.label_col") + + if not hasattr(clinical, "df"): + raise ValueError("Clinical object must expose a dataframe at .df") + df_full = clinical.df.copy() + self._validate_columns(df_full, label_col, patient_col=patient_col) + + eval_mode = str(getattr(args, "eval_mode", "multiclass")).lower() + if eval_mode == "binary": + df_full = df_full[df_full[label_col].isin([0, 1])].reset_index(drop=True) + + holdout_per_class = int(getattr(args, "holdout_per_class", 0) or 0) + holdout_seed = int(getattr(args, "holdout_seed", 123)) + n_splits = int(getattr(args, "n_splits", 5)) + fold_seed = int(getattr(args, "fold_seed", 42)) + + patient_table = self._patient_label_table(df_full, label_col, patient_col=patient_col) + plans = build_patient_split_plans( + patient_ids=patient_table[patient_col].to_numpy(), + patient_labels=patient_table["_label"].to_numpy(), + n_splits=n_splits, + seed=fold_seed, + holdout_per_class=holdout_per_class, + holdout_seed=holdout_seed, + ) + + out: list[PatientSplit] = [] + for plan in plans: + train_df = ( + df_full[df_full[patient_col].isin(plan.train_patient_ids)] + .reset_index(drop=True) + ) + val_df = ( + df_full[df_full[patient_col].isin(plan.val_patient_ids)] + .reset_index(drop=True) + ) + holdout_df = None + if plan.holdout_patient_ids: + holdout_df = ( + df_full[df_full[patient_col].isin(plan.holdout_patient_ids)] + .reset_index(drop=True) + ) + out.append(PatientSplit(train=train_df, val=val_df, holdout=holdout_df)) + return out + + def _validate_columns(self, df: pd.DataFrame, label_col: str, patient_col: Optional[str] = None) -> None: + pcol = patient_col or self.patient_col + if pcol not in df.columns: + raise ValueError(f"Missing required patient column: {pcol!r}") + if label_col not in df.columns: + raise ValueError(f"Missing required label column: {label_col!r}") + + def _patient_label_table( + self, + df: pd.DataFrame, + label_col: str, + patient_col: Optional[str] = None, + ) -> pd.DataFrame: + pcol = patient_col or self.patient_col + grouped = ( + df.groupby(pcol, as_index=False)[label_col] + .agg(lambda x: x.mode().iloc[0] if not x.mode().empty else x.iloc[0]) + .rename(columns={label_col: "_label"}) + .sort_values(pcol) + .reset_index(drop=True) + ) + if grouped.empty: + raise ValueError("No patients available for splitting") + return grouped + + +def _can_stratify(labels: np.ndarray, n_splits: int) -> bool: + if labels.size == 0: + return False + unique, counts = np.unique(labels, return_counts=True) + if len(unique) < 2: + return False + return bool(np.all(counts >= n_splits)) diff --git a/classes/v2/towers.py b/classes/v2/towers.py new file mode 100644 index 0000000..fa6e04c --- /dev/null +++ b/classes/v2/towers.py @@ -0,0 +1,279 @@ +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 +from classes.v2.data_bundle import DataBundle + + +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.""" + 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 + + +class SiameseImageTower(nn.Module): + """ + Shared-weight bilateral image tower. + + Runs OD and OS images through a single shared backbone, then returns + cat([f_mean, f_delta]) where: + f_mean = (f_od + f_os) / 2 -- shared bilateral representation + f_delta = f_od - f_os -- asymmetry, signed OD-relative + + out_dim = 2 * backbone_out_dim + + When x_os is None (single-eye fallback): + f_mean = f_od + f_delta = zeros + so the module degrades gracefully when only one eye is available. + + The shared backbone means both eyes contribute to every gradient update, + effectively doubling the training signal for the visual pathway without + doubling parameters. + """ + + 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, + ): + super().__init__() + self._tower = ImageTower( + backbone=backbone, + freeze_ratio=freeze_ratio, + use_se=use_se, + se_reduction=se_reduction, + se_pre_norm=se_pre_norm, + augment=augment, + geometry_dim=0, + ) + self.out_dim = self._tower.out_dim * 2 + self.transform = self._tower.transform + + def forward( + self, + x_od: torch.Tensor, + x_os: Optional[torch.Tensor] = None, + ) -> torch.Tensor: + f_od = self._tower(x_od) + if x_os is None: + f_mean = f_od + f_delta = torch.zeros_like(f_od) + else: + f_os = self._tower(x_os) + f_mean = (f_od + f_os) * 0.5 + f_delta = f_od - f_os + return torch.cat([f_mean, f_delta], dim=1) + + def set_freeze_ratio(self, ratio: float) -> None: + """Delegates to the shared inner tower.""" + self._tower.set_freeze_ratio(ratio) + + +class MDTower(nn.Module): + """MLP over DataBundle.vectorize_row outputs (convert to torch inside tower).""" + + def __init__( + self, + clinical_data: DataBundle, + 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.""" + 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 diff --git a/classes/v2/transforms.py b/classes/v2/transforms.py new file mode 100644 index 0000000..17d6020 --- /dev/null +++ b/classes/v2/transforms.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Iterable, Optional, Tuple, Union +import numpy as np +from PIL import Image + +from torchvision import transforms + +from classes.backbones import BACKBONES + + +IMAGENET_MEAN: Tuple[float, float, float] = (0.485, 0.456, 0.406) +IMAGENET_STD: Tuple[float, float, float] = (0.229, 0.224, 0.225) + + +@dataclass +class ImageTransformConfig: + """ + Mirrors the hypertower v1 preprocessing: + - Resize(256) + - CenterCrop(crop) + - Optional augmentations (H/V flip, rotation, color jitter) + - ToTensor + Normalize(mean/std) + """ + + crop_size: int = 224 + resize_size: int = 256 + mean: Tuple[float, float, float] = IMAGENET_MEAN + std: Tuple[float, float, float] = IMAGENET_STD + augment: bool = True + rotation_deg: int = 15 + color_jitter: Tuple[float, float, float, float] = (0.1, 0.1, 0.1, 0.05) + hflip: bool = True + vflip: bool = True + + def build(self) -> transforms.Compose: + ops = [ + transforms.Resize(self.resize_size), + transforms.CenterCrop(self.crop_size), + ] + if self.augment: + if self.hflip: + ops.append(transforms.RandomHorizontalFlip()) + if self.vflip: + ops.append(transforms.RandomVerticalFlip()) + if self.rotation_deg: + ops.append(transforms.RandomRotation(self.rotation_deg)) + if self.color_jitter: + ops.append(transforms.ColorJitter(*self.color_jitter)) + ops.extend( + [ + transforms.ToTensor(), + transforms.Normalize(mean=self.mean, std=self.std), + ] + ) + return transforms.Compose(ops) + + +def backbone_transform_config(backbone_name: str, augment: bool = True) -> ImageTransformConfig: + """ + Build a transform config that matches v1 ImageTower/backbone preprocessing. + Uses DEFAULT weights mean/std and InceptionV3 crop size when relevant. + """ + key = (backbone_name or "").lower() + if key not in BACKBONES: + raise ValueError(f"Unsupported backbone '{backbone_name}'.") + spec = BACKBONES[key] + mean = getattr(spec.weights_default, "meta", {}).get("mean", IMAGENET_MEAN) + std = getattr(spec.weights_default, "meta", {}).get("std", IMAGENET_STD) + crop = 299 if key == "inception_v3" else 224 + return ImageTransformConfig(crop_size=crop, mean=mean, std=std, augment=augment) + + +def build_backbone_transform(backbone_name: str, augment: bool = True) -> transforms.Compose: + return backbone_transform_config(backbone_name, augment=augment).build() + + +def build_imagenet_transform(augment: bool = True, crop_size: int = 224) -> transforms.Compose: + return ImageTransformConfig(crop_size=crop_size, augment=augment).build() + + +@dataclass +class ResizeTransform: + size: Union[int, Tuple[int, int]] = 256 + interpolation: int = Image.BILINEAR + + def __post_init__(self) -> None: + self._op = transforms.Resize(self.size, interpolation=self.interpolation) + + def __call__(self, image: Image.Image) -> Image.Image: + return self._op(image) + + +@dataclass +class CenterCropTransform: + size: Union[int, Tuple[int, int]] = 224 + + def __post_init__(self) -> None: + self._op = transforms.CenterCrop(self.size) + + def __call__(self, image: Image.Image) -> Image.Image: + return self._op(image) + + +class UnetMaskProvider: + """ + Placeholder for a UNet-powered mask provider. + This will be replaced once a UNet tower is wired in. + """ + + def __call__(self, image: Image.Image, image_path: Optional[str] = None): + raise NotImplementedError("UNet mask provider is not wired yet.") + + +@dataclass +class ROICropTransform: + """ + Crop an image using a binary mask (GT or UNet). + Expects a mask of the same spatial size as the image; nonzero pixels are ROI. + """ + + mask_source: str = "gt" # "gt" | "unet" + mask_provider: Optional[Callable[[Image.Image, Optional[str]], np.ndarray]] = None + scale: float = 2.5 + target_size: Optional[Tuple[int, int]] = (224, 224) + fallback_to_original: bool = True + + def __post_init__(self) -> None: + if self.mask_source not in {"gt", "unet"}: + raise ValueError(f"mask_source must be 'gt' or 'unet', got '{self.mask_source}'.") + + def __call__( + self, + image: Image.Image, + mask: Optional[Union[np.ndarray, Image.Image]] = None, + image_path: Optional[str] = None, + ) -> Image.Image: + resolved_mask = mask + if resolved_mask is None and self.mask_provider is not None: + resolved_mask = self.mask_provider(image, image_path) + if resolved_mask is None: + if self.fallback_to_original: + return image + raise ValueError("ROI crop requested but no mask provided.") + + mask_arr = ( + np.asarray(resolved_mask) + if not isinstance(resolved_mask, Image.Image) + else np.array(resolved_mask) + ) + if mask_arr.ndim == 3: + mask_arr = mask_arr[..., 0] + mask_arr = mask_arr > 0 + if not np.any(mask_arr): + return image if self.fallback_to_original else image + + ys, xs = np.where(mask_arr) + y_min, y_max = ys.min(), ys.max() + x_min, x_max = xs.min(), xs.max() + cx = (x_min + x_max) / 2.0 + cy = (y_min + y_max) / 2.0 + width = (x_max - x_min + 1) + height = (y_max - y_min + 1) + size = max(width, height) * float(self.scale) + + left = int(round(cx - size / 2)) + right = int(round(cx + size / 2)) + upper = int(round(cy - size / 2)) + lower = int(round(cy + size / 2)) + + left = max(0, left) + upper = max(0, upper) + right = min(image.width, right) + lower = min(image.height, lower) + crop = image.crop((left, upper, right, lower)) + if self.target_size is not None: + crop = crop.resize(self.target_size, Image.BILINEAR) + return crop + + +@dataclass +class JitterBundleTransform: + """ + Augmentations bundle: flips, rotation, color jitter. + """ + + hflip: bool = True + vflip: bool = True + rotation_deg: int = 15 + color_jitter: Optional[Tuple[float, float, float, float]] = (0.1, 0.1, 0.1, 0.05) + + def __post_init__(self) -> None: + ops = [] + if self.hflip: + ops.append(transforms.RandomHorizontalFlip()) + if self.vflip: + ops.append(transforms.RandomVerticalFlip()) + if self.rotation_deg: + ops.append(transforms.RandomRotation(self.rotation_deg)) + if self.color_jitter: + ops.append(transforms.ColorJitter(*self.color_jitter)) + self._op = transforms.Compose(ops) if ops else None + + def __call__(self, image: Image.Image) -> Image.Image: + if self._op is None: + return image + return self._op(image) + + +TRANSFORM_REGISTRY = { + "resize": ResizeTransform, + "roi_crop": ROICropTransform, + "center_crop": CenterCropTransform, + "jitter_bundle": JitterBundleTransform, +} + + +def _parse_color_jitter(value: Optional[Union[str, Iterable[float]]]) -> Optional[Tuple[float, float, float, float]]: + if value is None: + return None + if isinstance(value, str): + parts = [p.strip() for p in value.split(",") if p.strip()] + if not parts: + return None + try: + nums = [float(p) for p in parts] + except ValueError: + return None + if len(nums) == 1: + return (nums[0], nums[0], nums[0], nums[0]) + if len(nums) >= 4: + return (nums[0], nums[1], nums[2], nums[3]) + return tuple(nums + [nums[-1]] * (4 - len(nums))) # pad to length 4 + try: + vals = list(value) + except TypeError: + return None + if not vals: + return None + vals = [float(v) for v in vals] + if len(vals) == 1: + return (vals[0], vals[0], vals[0], vals[0]) + if len(vals) >= 4: + return (vals[0], vals[1], vals[2], vals[3]) + return tuple(vals + [vals[-1]] * (4 - len(vals))) + + +def build_transform_chain( + transform_specs: Iterable[object], + *, + backbone_name: str, + augment: bool = True, + mask_provider: Optional[Callable[[Image.Image, Optional[str]], np.ndarray]] = None, + strict: bool = True, +) -> transforms.Compose: + """ + Build an image transform pipeline from a list of transform specs plus the + standard ToTensor + Normalize steps. This mirrors the V1 preprocessing + but uses the explicit transform nodes from config. + """ + ops: list[Callable[[Image.Image], Image.Image]] = [] + for spec in transform_specs: + transform_type = getattr(spec, "transform_type", None) + params = getattr(spec, "params", None) + if transform_type is None and isinstance(spec, dict): + transform_type = spec.get("transformType") or spec.get("transform_type") + params = spec + params = params or {} + + if transform_type == "resize": + size = params.get("resizeSize", 256) + ops.append(ResizeTransform(size=size)) + elif transform_type == "center_crop": + size = params.get("centerCropSize", 224) + ops.append(CenterCropTransform(size=size)) + elif transform_type == "jitter_bundle": + if not augment: + continue + jitter = JitterBundleTransform( + hflip=bool(params.get("jitterHFlip", True)), + vflip=bool(params.get("jitterVFlip", True)), + rotation_deg=int(params.get("jitterRotation", 15) or 0), + color_jitter=_parse_color_jitter(params.get("jitterColor")) + if params.get("jitterColorEnabled", True) + else None, + ) + ops.append(jitter) + elif transform_type == "roi_crop": + roi = ROICropTransform( + mask_source=params.get("roiMaskSource", "gt"), + mask_provider=mask_provider, + scale=float(params.get("roiScale", 2.5)), + target_size=(int(params.get("roiTargetSize", 224)), int(params.get("roiTargetSize", 224))) + if params.get("roiTargetSize") is not None + else None, + fallback_to_original=bool(params.get("roiFallback", True)), + ) + if roi.mask_provider is None and roi.mask_source == "unet": + if strict: + raise ValueError("ROI crop requires a mask provider for 'unet' source.") + ops.append(roi) + else: + if strict: + raise ValueError(f"Unsupported transform type: {transform_type!r}") + + # Always end with tensor + normalize, using backbone defaults + cfg = backbone_transform_config(backbone_name, augment=augment) + ops.extend( + [ + transforms.ToTensor(), + transforms.Normalize(mean=cfg.mean, std=cfg.std), + ] + ) + return transforms.Compose(ops) diff --git a/classes/v2/v2_hypertower.py b/classes/v2/v2_hypertower.py new file mode 100644 index 0000000..92e5340 --- /dev/null +++ b/classes/v2/v2_hypertower.py @@ -0,0 +1,3622 @@ +from __future__ import annotations +import os +import math +import argparse +import json +import time +import copy +import csv +from pathlib import Path +import shutil +import random as pyrandom +from dataclasses import dataclass +import pandas as pd +from PIL import Image, ImageDraw +import torch +from torch import nn +from torch.utils.data import DataLoader +from torch.utils.data.sampler import WeightedRandomSampler +from classes.v2.data_bundle import DataBundle +from classes.v2.dataset import ClinicalDataset +from classes.early_stop import EarlyStopper +from classes.unet_segmenter import UNetSegmenter +from classes.geometry_features import ( + FEATURE_DIM, + compute_geometry_features, + disc_cup_from_mask_image, +) +from classes.refuge_classification import _geometry_from_mask +from classes.v2.bridges import Bridge, VoteBridge +from classes.v2.hypertower_logger import HypertowerLogger +from classes.v2.towers import ImageTower, MDTower +from torchvision import transforms + +# from clinical_data import ClinicalData +# from dataset import ClinicalDataset +# from image_tower import ImageTower +# from md_tower import MDTower +# from bridge import Bridge, VoteBridge +from random import random +from sklearn.metrics import roc_auc_score, cohen_kappa_score, f1_score, matthews_corrcoef, recall_score +import torch.nn.functional as F +import numpy as np +from sklearn.metrics import roc_curve, auc +from sklearn.preprocessing import label_binarize +from typing import Optional, Tuple +from types import SimpleNamespace +from classes.backbones import BACKBONES +from classes.v2.papila_builders import build_papila_data +from classes.v2.split_manager import PatientFirstSplitManager + + +LOG_FIELDS = [ +"epoch", +"eval_loss", +"acc_fused", "acc_img", "acc_md", +"auc_fused", "auc_img", "auc_md", +"top2_fused", "top2_img", "top2_md", +"margin_fused", "margin_img", "margin_md", +"agree_fused_img", "agree_fused_md", +"pct_fused", "pct_img", "pct_md", +"phase", +"holdout_loss", +"holdout_acc_fused", +"holdout_acc_img", +"holdout_acc_md", +"holdout_auc_fused", +"holdout_auc_img", +"holdout_auc_md", +] + + +def focal_loss( + logits: torch.Tensor, + targets: torch.Tensor, + gamma: float = 0.0, + weight: Optional[torch.Tensor] = None, + reduction: str = "mean", +) -> torch.Tensor: + """ + Standard focal loss wrapper. When gamma=0 it reduces to cross entropy. + weight should be per-class weights (same semantics as CrossEntropyLoss). + """ + if gamma <= 0: + return F.cross_entropy(logits, targets, weight=weight, reduction=reduction) + + log_probs = F.log_softmax(logits, dim=1) + probs = log_probs.exp() + + targets = targets.long().view(-1, 1) + logpt = log_probs.gather(1, targets) + pt = probs.gather(1, targets) + + focal_factor = (1.0 - pt).clamp_min(0.0) ** gamma + loss = -focal_factor * logpt + + if weight is not None: + class_weight = weight.gather(0, targets.view(-1)) + loss = loss * class_weight.view(-1, 1) + + loss = loss.view(-1) + if reduction == "sum": + return loss.sum() + if reduction == "mean": + return loss.mean() + return loss + + +class UNetImageCropper: + def __init__( + self, + manifest_path: Path, + weights_path: Path, + normalize: str = "per_image", + threshold: float = 0.5, + tta: bool = False, + scale: float = 2.5, + target_size: int = 224, + cache_dir: Optional[Path] = None, + ) -> None: + self.segmenter = UNetSegmenter( + manifest_path=manifest_path, + normalize=normalize, + ) + state = torch.load(weights_path, map_location=self.segmenter.device) + state_dict = state.get("model", state) + self.segmenter.model.load_state_dict(state_dict) + self.segmenter.model.to(self.segmenter.device) + self.segmenter.model.eval() + + self.threshold = threshold + self.tta = tta + self.scale = scale + self.target_size = target_size + self.cache_dir = Path(cache_dir) if cache_dir is not None else None + if self.cache_dir is not None: + self.cache_dir.mkdir(parents=True, exist_ok=True) + + self.to_tensor = transforms.ToTensor() + + def _cache_path(self, image_path: Path) -> Optional[Path]: + if self.cache_dir is None: + return None + stem = image_path.stem + return self.cache_dir / f"{stem}_s{int(self.scale * 100)}.npz" + + def _infer_masks(self, image: Image.Image) -> Optional[Tuple[np.ndarray, np.ndarray]]: + resized = self.segmenter.preprocess_image(image) + tensor = self.to_tensor(resized).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) + disc_mask = np.array(disc_img, dtype=np.uint8) + cup_img = Image.fromarray(cup_pred, mode="L").resize(image.size, Image.NEAREST) + 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) + disc_mask = (disc_mask > 0).astype(np.uint8) + return disc_mask, cup_mask + + def _compute_crop_info(self, image: Image.Image, image_path: Path) -> Optional[dict]: + image_path = Path(image_path).resolve() + cache_path = self._cache_path(image_path) + cached_bounds = None + if cache_path is not None and cache_path.exists(): + data = np.load(cache_path, allow_pickle=False) + try: + cached_bounds = { + "left": float(data["left"]), + "upper": float(data["upper"]), + "right": float(data["right"]), + "lower": float(data["lower"]), + } + if "features" in data.files: + cached_bounds["features"] = data["features"].astype(np.float32) + return cached_bounds + except KeyError: + cached_bounds = None + + masks = self._infer_masks(image) + if masks is None: + return cached_bounds + disc_mask, cup_mask = masks + try: + geom = _geometry_from_mask(disc_mask, self.scale) + except Exception: + return cached_bounds + cx = geom["centre_x"] + cy = geom["centre_y"] + r = geom["crop_radius"] + left = max(0.0, cx - r) + upper = max(0.0, cy - r) + right = min(float(image.width), cx + r) + lower = min(float(image.height), cy + r) + features = compute_geometry_features(disc_mask, cup_mask) + + info = { + "left": left, + "upper": upper, + "right": right, + "lower": lower, + "features": features, + } + if cache_path is not None: + np.savez( + cache_path, + left=left, + upper=upper, + right=right, + lower=lower, + width=float(image.width), + height=float(image.height), + scale=self.scale, + target_size=self.target_size, + features=features, + ) + return info + + def __call__(self, image: Image.Image, image_path: Path) -> Image.Image: + info = self._compute_crop_info(image, image_path) + if info is None: + return image + left = info["left"] + upper = info["upper"] + right = info["right"] + lower = info["lower"] + if right <= left or lower <= upper: + return image + crop = image.crop((left, upper, right, lower)) + return crop.resize((self.target_size, self.target_size), Image.BILINEAR) + + def geometry_features(self, image: Image.Image, image_path: Path) -> Optional[np.ndarray]: + info = self._compute_crop_info(image, image_path) + if info is None: + return None + features = info.get("features") + if features is None: + return None + return np.asarray(features, dtype=np.float32) + + +class ManifestImageCropper: + def __init__( + self, + manifest_path: Path, + scale: float = 2.5, + target_size: int = 224, + cache_dir: Optional[Path] = None, + ) -> None: + self.scale = scale + self.target_size = target_size + self.cache_dir = Path(cache_dir) if cache_dir is not None else None + if self.cache_dir is not None: + self.cache_dir.mkdir(parents=True, exist_ok=True) + + df = pd.read_csv(manifest_path) + self.entries: Dict[str, dict] = {} + for _, row in df.iterrows(): + img_path = Path(row["image_path"]).resolve() + self.entries[str(img_path)] = { + "annotation_disc": row.get("annotation_disc"), + "annotation_cup": row.get("annotation_cup"), + "annotation_type_disc": row.get("annotation_type_disc"), + "annotation_type_cup": row.get("annotation_type_cup"), + } + + def _cache_path(self, image_path: Path) -> Optional[Path]: + if self.cache_dir is None: + return None + return self.cache_dir / f"{image_path.stem}_s{int(self.scale * 100)}.npz" + + @staticmethod + def _load_contour(path: Path) -> np.ndarray: + coords = np.loadtxt(path) + if coords.ndim == 1: + coords = coords.reshape(-1, 2) + return coords + + @staticmethod + def _contour_to_mask(coords: np.ndarray, size: tuple[int, int]) -> np.ndarray: + if coords is None or coords.size == 0: + return np.zeros((size[1], size[0]), dtype=np.uint8) + img = Image.new("L", size, 0) + draw = ImageDraw.Draw(img) + points = [tuple(map(float, pt)) for pt in coords] + draw.polygon(points, outline=1, fill=1) + return np.array(img, dtype=np.uint8) + + def _load_masks(self, entry: dict, image: Image.Image) -> Optional[Tuple[np.ndarray, np.ndarray]]: + disc_path = entry.get("annotation_disc") + cup_path = entry.get("annotation_cup") + disc_type = (entry.get("annotation_type_disc") or "").lower() + cup_type = (entry.get("annotation_type_cup") or "").lower() + + disc_mask: Optional[np.ndarray] = None + cup_mask: Optional[np.ndarray] = None + + if disc_path and not pd.isna(disc_path): + disc_path = Path(disc_path) + try: + if disc_type == "mask": + mask_img = Image.open(disc_path) + mask_img = mask_img.resize(image.size, Image.NEAREST) + disc_mask, cup_from_mask = disc_cup_from_mask_image(mask_img) + if cup_from_mask.sum() > 0: + cup_mask = cup_from_mask + elif disc_type == "contour": + coords = self._load_contour(disc_path) + disc_mask = self._contour_to_mask(coords, image.size) + except Exception: + disc_mask = None + + if cup_mask is None and cup_path and not pd.isna(cup_path): + cup_path = Path(cup_path) + try: + if cup_type == "mask": + mask_img = Image.open(cup_path) + mask_img = mask_img.resize(image.size, Image.NEAREST) + _, cup_mask = disc_cup_from_mask_image(mask_img) + elif cup_type == "contour": + coords = self._load_contour(cup_path) + cup_mask = self._contour_to_mask(coords, image.size) + except Exception: + cup_mask = None + + if disc_mask is None: + return None + disc_mask = (disc_mask > 0).astype(np.uint8) + if cup_mask is None: + cup_mask = np.zeros_like(disc_mask, dtype=np.uint8) + cup_mask = ((cup_mask > 0) & (disc_mask > 0)).astype(np.uint8) + return disc_mask, cup_mask + + def _compute_crop_info(self, image: Image.Image, image_path: Path) -> Optional[dict]: + image_path = Path(image_path).resolve() + entry = self.entries.get(str(image_path)) + if entry is None: + return None + cache_path = self._cache_path(image_path) + cached_bounds = None + if cache_path is not None and cache_path.exists(): + data = np.load(cache_path, allow_pickle=False) + try: + cached_bounds = { + "left": float(data["left"]), + "upper": float(data["upper"]), + "right": float(data["right"]), + "lower": float(data["lower"]), + } + if "features" in data.files: + cached_bounds["features"] = data["features"].astype(np.float32) + return cached_bounds + except KeyError: + cached_bounds = None + + masks = self._load_masks(entry, image) + if masks is None: + return cached_bounds + disc_mask, cup_mask = masks + try: + geom = _geometry_from_mask(disc_mask, self.scale) + except Exception: + return cached_bounds + cx = geom["centre_x"] + cy = geom["centre_y"] + r = geom["crop_radius"] + left = max(0.0, cx - r) + upper = max(0.0, cy - r) + right = min(float(image.width), cx + r) + lower = min(float(image.height), cy + r) + features = compute_geometry_features(disc_mask, cup_mask) + + info = { + "left": left, + "upper": upper, + "right": right, + "lower": lower, + "features": features, + } + if cache_path is not None: + np.savez( + cache_path, + left=left, + upper=upper, + right=right, + lower=lower, + width=float(image.width), + height=float(image.height), + scale=self.scale, + target_size=self.target_size, + features=features, + ) + return info + + def __call__(self, image: Image.Image, image_path: Path) -> Image.Image: + info = self._compute_crop_info(image, image_path) + if info is None: + return image + left = info["left"] + upper = info["upper"] + right = info["right"] + lower = info["lower"] + + if right <= left or lower <= upper: + return image + crop = image.crop((left, upper, right, lower)) + return crop.resize((self.target_size, self.target_size), Image.BILINEAR) + + def geometry_features(self, image: Image.Image, image_path: Path) -> Optional[np.ndarray]: + info = self._compute_crop_info(image, image_path) + if info is None: + return None + features = info.get("features") + if features is None: + return None + return np.asarray(features, dtype=np.float32) + +def _num(x): + """float(x) or None if NaN/None/invalid.""" + try: + v = float(x) + except Exception: + return None + return None if math.isnan(v) else v + +class _ClinicalView: + """Minimal shim so ClinicalDataset can iterate an epoch-specific DataFrame + while still delegating encoding/paths/labels to the DataBundle object.""" + def __init__(self, base: DataBundle, df): + self.base = base + self.df = df + + @property + def image_dir(self): + return self.base.image_dir + + @property + def clinical_dir(self): + return self.base.clinical_dir + + @property + def id_cols(self): + return ("Patient ID", "eyeID") + + @property + def label_col(self): + return self.base.label_col + + @property + def filename_template(self): + # prefer whatever the dataset defined; otherwise fall back to RET{pid}{eye}.jpg style + return getattr(self.base, "filename_template", "RET{pid:03d}{eye}.jpg") + + @property + def dim(self): + return self.base.feature_dim + + def encode_metadata(self, row): + # DataBundle returns numpy; convert to torch here to keep towers torch-only + import torch as _torch + vec = self.base.vectorize_row(row) + return _torch.as_tensor(vec, dtype=_torch.float32) + + def get_image_path(self, row): + return self.base.get_image_path(row) + + def get_label(self, row): + return int(row[self.base.label_col]) + + +class HyperTower: + + + def __init__(self, clinical: DataBundle, args): + # Expects a fully built DataBundle (add_df handled upstream) + self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + print(f"Using device: {self.device}") + + self.clinical = clinical + sample_mode = str(getattr(args, "sample_mode", "eye") or "eye").lower() + tower_warmup = getattr(args, "warmup_tower_epochs", None) + fused_warmup = getattr(args, "warmup_fused_epochs", None) + if tower_warmup is None: + tower_warmup = 8 if sample_mode == "patient" else 2 + if fused_warmup is None: + fused_warmup = 5 if sample_mode == "patient" else 3 + self.warmup_tower_epochs = int(tower_warmup) + self.warmup_fused_epochs = int(fused_warmup) + self.args = args # cache for checkpointing + self._early = None + self.aux_img = getattr(args, "aux_img", 0.05) + self.aux_md = getattr(args, "aux_md", 0.05) + self.aux_detach = getattr(args, "aux_detach", True) + # SE placement configuration + se_where = getattr(args, "se_where", "bridge") # 'bridge'|'tower'|'both'|'none' + use_se_tower = se_where in ("tower", "both") + # Optional disc-centric cropping + self.image_preprocessor = None + crop_manifest = getattr(args, "img_crop_manifest", None) + crop_weights = getattr(args, "img_crop_weights", None) + use_gt = getattr(args, "img_crop_gt", False) + if crop_manifest: + crop_cache = getattr(args, "img_crop_cache", Path("analysis_data/hypertower_crops")) + crop_cache = Path(crop_cache) + if use_gt: + self.image_preprocessor = ManifestImageCropper( + manifest_path=Path(crop_manifest), + scale=getattr(args, "img_crop_scale", 2.5), + target_size=getattr(args, "img_crop_size", 224), + cache_dir=crop_cache, + ) + print(f"[HyperTower] GT disc cropper enabled → cache at {crop_cache}") + elif crop_weights: + self.image_preprocessor = UNetImageCropper( + manifest_path=Path(crop_manifest), + weights_path=Path(crop_weights), + normalize=getattr(args, "img_crop_normalize", "per_image"), + threshold=getattr(args, "img_crop_threshold", 0.5), + tta=getattr(args, "img_crop_tta", False), + scale=getattr(args, "img_crop_scale", 2.5), + target_size=getattr(args, "img_crop_size", 224), + cache_dir=crop_cache, + ) + print(f"[HyperTower] UNet disc cropper enabled → cache at {crop_cache}") + else: + print("[HyperTower] img_crop_manifest provided but no weights/gt flag; skipping cropping") + + self.use_geometry_features = bool(getattr(args, "img_geometry_features", False)) + if self.use_geometry_features and self.image_preprocessor is None: + raise ValueError("img_geometry_features requires --img-crop-manifest with either --img-crop-weights or --img-crop-gt.") + self.geometry_dim = FEATURE_DIM if self.use_geometry_features else 0 + + # Towers derive their dimensions from DataBundle + self.img_tower = ImageTower( + backbone=getattr(args, "backbone", "efficientnet_b0"), + freeze_ratio=getattr(args, "freeze_ratio", 0.0), + use_se=use_se_tower, + se_reduction=getattr(args, "se_reduction_tower", getattr(args, "se_reduction", 16)), + se_pre_norm=getattr(args, "se_pre_norm_tower", getattr(args, "se_pre_norm", True)), + augment=getattr(args, "img_augment", True), + geometry_dim=self.geometry_dim, + ).to(self.device) + self.md_tower = MDTower( + self.clinical, + use_se=use_se_tower, + se_reduction=getattr(args, "se_reduction_tower", getattr(args, "se_reduction", 16)), + se_pre_norm=getattr(args, "se_pre_norm_tower", getattr(args, "se_pre_norm", True)), + ).to(self.device) + self.mode = args.fusion_mode # cache + # Training hyperparams / objects + self.batch_size = args.batch_size + # Main epochs exclude warmup; total training epochs adds warmup phases. + self.epochs = int(args.epochs) + self.total_epochs = int(self.warmup_tower_epochs + self.warmup_fused_epochs + self.epochs) + print( + f"[HyperTower] Warmup schedule: tower={self.warmup_tower_epochs}, " + f"fused={self.warmup_fused_epochs}, main={self.epochs}, total={self.total_epochs}" + ) + self.lr = args.lr + self.fold = args.fold + self.criterion = nn.CrossEntropyLoss() + self._ce_weight = self.criterion.weight.detach().clone() if self.criterion.weight is not None else None + if self._ce_weight is not None: + self._ce_weight = self._ce_weight.to(self.device) + self._ce_reduction = self.criterion.reduction + self.focal_gamma = float(getattr(args, "focal_gamma", 0.0) or 0.0) + # Run and model directories come from the script + self.run_dir = Path(getattr(args, "run_dir", ".")).resolve() + self.run_dir.mkdir(parents=True, exist_ok=True) + # Per-run log locations to avoid collisions across concurrent workers + self.epoch_log_path = self.run_dir / "epoch_log.csv" + self.train_log_path = self.run_dir / "train.log" + self.models_dir = Path(getattr(args, "models_dir", "models")).resolve() + self.models_dir.mkdir(parents=True, exist_ok=True) + + self.holdout_df = getattr(args, "holdout_df", None) + self.holdout_loader = None + if isinstance(self.holdout_df, pd.DataFrame) and not self.holdout_df.empty: + if getattr(args, "eval_mode", "multiclass") == "binary": + label_col = getattr(self.clinical, "label_col", None) + if label_col and label_col in self.holdout_df.columns: + self.holdout_df = self.holdout_df[self.holdout_df[label_col].isin([0, 1])].reset_index(drop=True) + self.holdout_loader = self._make_loader_for_df(self.holdout_df, is_train=False) + print(f"[HyperTower] Holdout loader prepared with {len(self.holdout_df)} samples") + + if self.mode == "vote": + # Per-tower classification heads (logits) + vote combiner + self.head_img = nn.Linear(self.img_tower.out_dim, args.num_classes).to(self.device) + self.head_md = nn.Linear(self.md_tower.out_dim, args.num_classes).to(self.device) + self.vote = VoteBridge(num_classes=args.num_classes).to(self.device) + + # Optimizer: towers + heads + vote + self.optimizer = torch.optim.Adam( + list(self.img_tower.parameters()) + + list(self.md_tower.parameters()) + + list(self.head_img.parameters()) + + list(self.head_md.parameters()) + + list(self.vote.parameters()), + lr=self.lr, + ) + else: + # Existing feature-fusion bridge + self.bridge = Bridge( + img_dim=self.img_tower.out_dim, + meta_dim=self.md_tower.out_dim, + num_classes=args.num_classes, + fusion_dim=256, + mode=args.fusion_mode, + use_se=(se_where in ("bridge","both")) and getattr(args, "use_se", True), + se_reduction=(getattr(args, "se_reduction", 16) if getattr(args, "use_se", True) else 0), + se_pre_norm=getattr(args, "se_pre_norm", True) + ).to(self.device) + + self.optimizer = torch.optim.Adam( + list(self.img_tower.parameters()) + + list(self.md_tower.parameters()) + + list(self.bridge.parameters()), + lr=self.lr, + ) + + + # EMA-forgiveness + BCD knobs; logging stays simple for now + self.ema_alpha = args.ema_alpha + self.bcd_prob = args.bcd_prob + self.ema_fused_loss = None + + # Shared V2 logger for train.log + epoch_log.csv + self.ht_logger = HypertowerLogger( + run_dir=self.run_dir, + train_log_path=self.train_log_path, + epoch_log_path=self.epoch_log_path, + ) + self.logger = self.ht_logger.logger + + # Dynamic BCD configuration (epoch baselines + batch nudges) + self.bcd_cfg = { + "metric": getattr(args, "bcd_metric", "auc"), + "p0": getattr(args, "bcd_p0", 0.20), + "k": getattr(args, "bcd_k", 0.4), + "pmin": getattr(args, "bcd_min", 0.05), + "pmax": getattr(args, "bcd_max", 0.30), + "alpha_batch": getattr(args, "bcd_alpha_batch", 0.2), + "alpha_tower": getattr(args, "bcd_alpha_tower", 0.3), + "explore_floor": getattr(args, "bcd_explore_floor", 0.15), + "entropy_ema": getattr(args, "entropy_ema", 0.7), + } + + # Track last epoch's eval metrics for baseline deficits; initialize safely + self.last_eval = { + "acc_fused": 0.0, "acc_img": 0.0, "acc_md": 0.0, + "auc_fused": 0.0, "auc_img": 0.0, "auc_md": 0.0, + } + # Running EMA of per-head uncertainty (entropy in [0,1]) + self.entropy_ema = {"fused": 0.5, "img": 0.5, "md": 0.5} + + # DataLoaders are (re)built each epoch from DataBundle's splits + self.train_loader = None + self.test_loader = None + self._last_step_mix = None + + if not getattr(self.clinical, "folds", None): + raise RuntimeError("DataBundle has no built folds. Did you call add_df(...) upstream?") + + @staticmethod + def _confusion(preds, labels): + # Quick TP/TN/FP/FN for binary debug (kept as-is) + tp = ((preds == 1) & (labels == 1)).sum().item() + tn = ((preds == 0) & (labels == 0)).sum().item() + fp = ((preds == 1) & (labels == 0)).sum().item() + fn = ((preds == 0) & (labels == 1)).sum().item() + return {"tp": tp, "tn": tn, "fp": fp, "fn": fn} + + def _unpack_batch(self, batch): + if len(batch) == 4: + imgs, metas, geometry, labels = batch + else: + imgs, metas, labels = batch + if self.geometry_dim > 0: + geometry = torch.zeros(imgs.size(0), self.geometry_dim, dtype=torch.float32) + else: + geometry = None + return imgs, metas, geometry, labels + + def _classification_loss(self, logits: torch.Tensor, labels: torch.Tensor) -> torch.Tensor: + """ + Shared classification loss (CrossEntropy or focal depending on gamma). + """ + weight = self._ce_weight + if weight is not None and weight.device != logits.device: + weight = weight.to(logits.device) + return focal_loss( + logits, + labels, + gamma=self.focal_gamma, + weight=weight, + reduction=self._ce_reduction, + ) + + def _step_image_only(self, imgs, metas, geometry, labels): + if self.mode == "vote": + img_feats = self.img_tower(imgs, geometry) + out_img = self.head_img(img_feats) + loss_i = self._classification_loss(out_img, labels) + preds_i = out_img.argmax(dim=1) + acc_i = (preds_i == labels).float().mean().item() + cm = self._confusion(preds_i, labels) + + self.optimizer.zero_grad() + loss_i.backward() + self.optimizer.step() + return None, loss_i.item(), None, None, acc_i, None, {"img": cm} + else:# Optimize the image head alone (used when BCD chooses image-only) + img_feats = self.img_tower(imgs, geometry) + out_img = self.bridge.classifier_img(img_feats) + loss_i = self._classification_loss(out_img, labels) + preds_i = out_img.argmax(dim=1) + acc_i = (preds_i == labels).float().mean().item() + cm = self._confusion(preds_i, labels) + self.optimizer.zero_grad() + loss_i.backward() + self.optimizer.step() + return None, loss_i.item(), None, None, acc_i, None, {"img": cm} + + def _step_meta_only(self, imgs, metas, geometry, labels): + # Optimize the metadata head alone (used when BCD chooses meta-only) + if self.mode == "vote": + meta_feats = self.md_tower(metas) + out_md = self.head_md(meta_feats) + loss_m = self._classification_loss(out_md, labels) + preds_m = out_md.argmax(dim=1) + acc_m = (preds_m == labels).float().mean().item() + cm = self._confusion(preds_m, labels) + + self.optimizer.zero_grad() + loss_m.backward() + self.optimizer.step() + return None, None, loss_m.item(), None, None, acc_m, {"meta": cm} + else: + meta_feats = self.md_tower(metas) + out_md = self.bridge.classifier_md(meta_feats) + loss_m = self._classification_loss(out_md, labels) + preds_m = out_md.argmax(dim=1) + acc_m = (preds_m == labels).float().mean().item() + cm = self._confusion(preds_m, labels) + self.optimizer.zero_grad() + loss_m.backward() + self.optimizer.step() + return None, None, loss_m.item(), None, None, acc_m, {"meta": cm} + + def _step_fused(self, imgs, metas, geometry, labels): + if self.mode == "vote": + img_feats = self.img_tower(imgs, geometry) + md_feats = self.md_tower(metas) + + out_img = self.head_img(img_feats) + out_md = self.head_md(md_feats) + out_fused = self.vote(out_img, out_md) + + preds_f = out_fused.argmax(dim=1) + acc_f = (preds_f == labels).float().mean().item() + loss_f = self._classification_loss(out_fused, labels) + loss_f_val = float(loss_f.detach().item()) + + # Optional: keep your EMA + small aux tower losses just like before + if self.ema_fused_loss is None: + self.ema_fused_loss = loss_f_val + L_total = (1.0 - self.ema_alpha) * loss_f + self.ema_alpha * loss_f.new_tensor(self.ema_fused_loss) + + aux_terms = 0.0 + if self.aux_img > 0: + aux_terms = aux_terms + self.aux_img * self._classification_loss( + out_img.detach() if self.aux_detach else out_img, + labels, + ) + if self.aux_md > 0: + aux_terms = aux_terms + self.aux_md * self._classification_loss( + out_md.detach() if self.aux_detach else out_md, + labels, + ) + L_total = L_total + aux_terms + + self.optimizer.zero_grad() + L_total.backward() + self.optimizer.step() + + self.ema_fused_loss = self.ema_alpha * self.ema_fused_loss + (1.0 - self.ema_alpha) * loss_f_val + + # Confusion tables for logging + cm = { + "fused": self._confusion(preds_f, labels), + "img": self._confusion(out_img.argmax(dim=1), labels), + "meta": self._confusion(out_md.argmax(dim=1), labels), + } + acc_i = (out_img.argmax(dim=1) == labels).float().mean().item() + acc_m = (out_md.argmax(dim=1) == labels).float().mean().item() + + with torch.no_grad(): + loss_img_val = self._classification_loss(out_img, labels).item() + loss_md_val = self._classification_loss(out_md, labels).item() + return loss_f_val, loss_img_val, loss_md_val, acc_f, acc_i, acc_m, cm + else: + img_feats = self.img_tower(imgs, geometry) + meta_feats = self.md_tower(metas) + out_fused, out_img, out_md = self.bridge(img_feats, meta_feats) + + preds_f = out_fused.argmax(dim=1) + acc_f = (preds_f == labels).float().mean().item() + loss_f = self._classification_loss(out_fused, labels) + loss_f_val = float(loss_f.detach().item()) + + cm = {"fused": self._confusion(preds_f, labels)} + loss_i = acc_i = loss_m = acc_m = None + + if out_img is not None: + with torch.no_grad(): + preds_i = out_img.argmax(dim=1) + acc_i = (preds_i == labels).float().mean().item() + loss_i = self._classification_loss(out_img, labels).item() + cm["img"] = self._confusion(preds_i, labels) + if out_md is not None: + with torch.no_grad(): + preds_m = out_md.argmax(dim=1) + acc_m = (preds_m == labels).float().mean().item() + loss_m = self._classification_loss(out_md, labels).item() + cm["meta"] = self._confusion(preds_m, labels) + + # EMA-blended fused loss + if self.ema_fused_loss is None: + self.ema_fused_loss = loss_f_val + L_total = (1.0 - self.ema_alpha) * loss_f + self.ema_alpha * loss_f.new_tensor(self.ema_fused_loss) + + # Auxiliary tower losses (small weights). Use detached features to calibrate heads only. + aux_terms = 0.0 + if self.aux_img > 0 and out_img is not None: + logits_img_for_aux = self.bridge.classifier_img(img_feats.detach()) if self.aux_detach else out_img + aux_terms = aux_terms + self.aux_img * self._classification_loss(logits_img_for_aux, labels) + if self.aux_md > 0 and out_md is not None: + logits_md_for_aux = self.bridge.classifier_md(meta_feats.detach()) if self.aux_detach else out_md + aux_terms = aux_terms + self.aux_md * self._classification_loss(logits_md_for_aux, labels) + + L_total = L_total + aux_terms + + self.optimizer.zero_grad() + L_total.backward() + self.optimizer.step() + + # update EMA after the step + self.ema_fused_loss = self.ema_alpha * self.ema_fused_loss + (1.0 - self.ema_alpha) * loss_f_val + + return loss_f_val, loss_i, loss_m, acc_f, acc_i, acc_m, cm + + + def _make_loader_for_df(self, df, is_train: bool = False): + # Rebuild a DataLoader for the current epoch's split + view = _ClinicalView(self.clinical, df) + geometry_provider = self.image_preprocessor if self.geometry_dim > 0 else None + ds = ClinicalDataset( + view, + self.img_tower.transform, + image_preprocessor=self.image_preprocessor, + geometry_provider=geometry_provider, + geometry_dim=self.geometry_dim, + ) + + # Optional: class-balanced bootstrapped sampling for TRAIN only + if is_train and getattr(self.args, "balanced_sampler", False): + import numpy as _np + y = _np.asarray(df[self.clinical.label_col].values) + # inverse-frequency weights per class + uniq, counts = _np.unique(y, return_counts=True) + inv = {c: (1.0 / cnt if cnt > 0 else 0.0) for c, cnt in zip(uniq, counts)} + w = _np.array([inv[c] for c in y], dtype=_np.float32) + sampler = WeightedRandomSampler(weights=w, num_samples=len(y), replacement=True) + return DataLoader(ds, batch_size=self.batch_size, sampler=sampler, shuffle=False) + + return DataLoader(ds, batch_size=self.batch_size, shuffle=not is_train) + + # ---- Dynamic BCD helpers ---- + def _metric_value(self, name: str) -> float: + # Pull either AUC or ACC from last_eval, falling back if NaN/zero + if self.bcd_cfg["metric"] == "auc": + v = self.last_eval.get(f"auc_{name}", 0.0) + if v == v: # not NaN + return float(v) + # fallback to accuracy + return float(self.last_eval.get(f"acc_{name}", 0.0)) + return float(self.last_eval.get(f"acc_{name}", 0.0)) + + def _epoch_bcd_baseline(self): + # Compute epoch-level baselines: p_bcd_epoch and tower weights w_i, w_m + p0 = self.bcd_cfg["p0"]; k = self.bcd_cfg["k"] + pmin = self.bcd_cfg["pmin"]; pmax = self.bcd_cfg["pmax"] + eps = 1e-6 + Af = self._metric_value("fused"); Ai = self._metric_value("img"); Am = self._metric_value("md") + di = max(0.0, Af - Ai); dm = max(0.0, Af - Am) + p_bcd_epoch = max(pmin, min(pmax, p0 + k * (di + dm) / 2.0)) + wi = (di + eps) / (di + dm + 2 * eps) + wm = 1.0 - wi + return p_bcd_epoch, wi, wm, {"di": di, "dm": dm, "Af": Af, "Ai": Ai, "Am": Am} + + @staticmethod + def _entropy_from_logits(logits: torch.Tensor, num_classes: int) -> float: + # Returns entropy normalized to [0,1] using log(K) denominator + with torch.no_grad(): + probs = F.softmax(logits, dim=1) + ent = -(probs * (probs.clamp_min(1e-12)).log()).sum(dim=1) + ent = ent / np.log(num_classes) + return float(ent.mean().item()) + + def _batch_uncertainty(self, imgs: torch.Tensor, metas: torch.Tensor, geometry: Optional[torch.Tensor] = None) -> dict: + self.img_tower.eval(); self.md_tower.eval() + if self.mode == "vote": + self.head_img.eval(); self.head_md.eval(); self.vote.eval() + with torch.no_grad(): + if geometry is None and self.geometry_dim > 0: + geometry = torch.zeros(imgs.size(0), self.geometry_dim, device=imgs.device, dtype=imgs.dtype) + img_feats = self.img_tower(imgs, geometry) + md_feats = self.md_tower(metas) + out_img = self.head_img(img_feats) + out_md = self.head_md(md_feats) + out_fused = self.vote(out_img, out_md) + K = out_fused.shape[1] + e_f = self._entropy_from_logits(out_fused, K) + e_i = self._entropy_from_logits(out_img, K) + e_m = self._entropy_from_logits(out_md, K) + # restore train() + self.img_tower.train(); self.md_tower.train() + self.head_img.train(); self.head_md.train(); self.vote.train() + else: + self.bridge.eval() + with torch.no_grad(): + if geometry is None and self.geometry_dim > 0: + geometry = torch.zeros(imgs.size(0), self.geometry_dim, device=imgs.device, dtype=imgs.dtype) + img_feats = self.img_tower(imgs, geometry) + meta_feats = self.md_tower(metas) + out_fused, out_img, out_md = self.bridge(img_feats, meta_feats) + K = out_fused.shape[1] + e_f = self._entropy_from_logits(out_fused, K) + e_i = self._entropy_from_logits(out_img, K) if out_img is not None else 0.5 + e_m = self._entropy_from_logits(out_md, K) if out_md is not None else 0.5 + self.img_tower.train(); self.md_tower.train(); self.bridge.train() + + a = self.bcd_cfg["entropy_ema"] + self.entropy_ema["fused"] = a * self.entropy_ema["fused"] + (1 - a) * e_f + self.entropy_ema["img"] = a * self.entropy_ema["img"] + (1 - a) * e_i + self.entropy_ema["md"] = a * self.entropy_ema["md"] + (1 - a) * e_m + return {"fused": self.entropy_ema["fused"], "img": self.entropy_ema["img"], "md": self.entropy_ema["md"]} + + def _write_epoch_log(self, row: dict, path: str | Path | None = None): + self.ht_logger.write_epoch_row(row=row, path=path) + + def _snapshot(self): + state = { + "img_tower": self.img_tower.state_dict(), + "md_tower": self.md_tower.state_dict(), + "optimizer": self.optimizer.state_dict(), + "args": vars(self.args), + } + if hasattr(self, "bridge"): state["bridge"] = self.bridge.state_dict() + if hasattr(self, "head_img"): state["head_img"] = self.head_img.state_dict() + if hasattr(self, "head_md"): state["head_md"] = self.head_md.state_dict() + return state + + + def train(self): + best_path = None + if getattr(self.args, "checkpoint_best", False): + best_path = str((self.models_dir / "best.pth").resolve()) + + # Always track the best model even if we don't stop early + monitor = getattr(self.args, "early_metric", None) + if not monitor: + # Prefer AUC-based monitoring by default; fall back to loss only if specified + if self.mode == "image_only": + monitor = "auc_img" + elif self.mode == "metadata_only": + monitor = "auc_md" + else: # fused or vote + monitor = "auc_fused" + mode = getattr(self.args, "early_mode", "auto") + # Optional switch: monitor holdout metrics instead of validation + use_holdout_monitor = bool(getattr(self.args, "early_monitor_holdout", False)) + if use_holdout_monitor and self.holdout_loader is None: + print("[early] Requested holdout monitoring but no holdout set is configured; falling back to validation metrics.") + use_holdout_monitor = False + if use_holdout_monitor: + # If the monitor isn't already a holdout metric, prepend it + if not monitor.startswith("holdout_"): + monitor = f"holdout_{monitor}" + # Force sensible default if no monitor was provided + if monitor == "holdout_auc_fused" and mode == "auto": + mode = "max" + if mode == "auto": + mode = ("min" if ("loss" in monitor.lower()) else "max") + self._best_info = { + "monitor": monitor, + "mode": mode, + "min_delta": float(getattr(self.args, "early_min_delta", 0.0)), + "value": (-math.inf if mode == "max" else math.inf), + "epoch": -1, + "state": None, + "save_path": best_path, + } + # keep args in sync so EarlyStopper uses the same monitor/mode + self.args.early_metric = monitor + self.args.early_mode = mode + # Separate tracker for best holdout performance (if a holdout set is provided) + self._holdout_best = None + if self.holdout_loader is not None: + self._holdout_best = { + "monitor": "holdout_auc_fused", + "mode": "max", + "min_delta": 0.0, + "value": -math.inf, + "epoch": -1, + "state": None, + "save_path": str((self.models_dir / "model_holdout_best.pt").resolve()), + } + + self._early = None + if getattr(self.args, "early_stop", False): + self._early = EarlyStopper( + monitor=monitor, + mode=mode, + patience=self.args.early_patience, + min_delta=self.args.early_min_delta, + save_path=best_path, + restore_best=True, + ) + # Gradual thaw config + gradual = bool(getattr(self.args, "gradual_thaw", False)) + thaw_ratio = float(getattr(self.args, "thaw_ratio", 0.33)) + thaw_phase = int(getattr(self.args, "thaw_phase_duration", 5)) + thaw_target = str(getattr(self.args, "thaw_target", "image")) + thaw_start_epoch = int(getattr(self.args, "thaw_start_epoch", -1)) + thaw_initial_freeze = bool(getattr(self.args, "thaw_initial_freeze", False)) + if thaw_start_epoch < 0: + thaw_start_epoch = int(getattr(self, "warmup_tower_epochs", 0)) + + for epoch in range(self.total_epochs): + # Apply thaw schedule at epoch start: start fully frozen and unfreeze by ratios + if gradual and thaw_phase > 0: + try: + if epoch < thaw_start_epoch: + if thaw_initial_freeze: + freeze_r = 1.0 + if thaw_target in ("image", "both") and hasattr(self, "img_tower"): + self.img_tower.set_freeze_ratio(freeze_r) + if thaw_target in ("metadata", "both") and hasattr(self, "md_tower"): + self.md_tower.set_freeze_ratio(freeze_r) + # else: leave current requires_grad as constructed (no forced freeze) + else: + phase_idx = (epoch - thaw_start_epoch) // thaw_phase + freeze_r = max(0.0, 1.0 - phase_idx * thaw_ratio) + if thaw_target in ("image", "both") and hasattr(self, "img_tower"): + self.img_tower.set_freeze_ratio(freeze_r) + if thaw_target in ("metadata", "both") and hasattr(self, "md_tower"): + self.md_tower.set_freeze_ratio(freeze_r) + except Exception: + pass + # build splits/loaders per-epoch + train_df, test_df = self.clinical.get_split_dfs(self.fold) + if hasattr(self.bridge, "reset_se_stats"): + self.bridge.reset_se_stats() + if getattr(self, "args", None) is None: + self.args = argparse.Namespace() # if not stashed already + # Expect user to pass --eval_mode and --num-classes==2 for binary + if getattr(self.args, "eval_mode", "multiclass") == "binary": + # Drop Suspect (assumed label==2 in PAPILA spreadsheets) + train_df = train_df[train_df[self.clinical.label_col].isin([0,1])].copy() + test_df = test_df [test_df [self.clinical.label_col].isin([0,1])].copy() + self.train_loader = self._make_loader_for_df(train_df, is_train=True) + self.test_loader = self._make_loader_for_df(test_df, is_train=False) + + self.img_tower.train(); self.md_tower.train(); self.bridge.train() + + total_losses = {"fused":0.0,"image_only":0.0,"metadata_only":0.0} + total_accs = {"fused":0.0,"image_only":0.0,"metadata_only":0.0} + counts = {"fused":0, "image_only":0, "metadata_only":0} + step_counts = {"fused":0, "image_only":0, "metadata_only":0} + + # phase selection + in_tower_warmup = epoch < self.warmup_tower_epochs + in_fused_warmup = (self.warmup_tower_epochs <= epoch < (self.warmup_tower_epochs + self.warmup_fused_epochs)) + + # epoch baselines (needed for splits and logging) + p_bcd_epoch, wi_epoch, wm_epoch, diag = self._epoch_bcd_baseline() + + if in_tower_warmup: + # tower-only warmup: alternate or use epoch split; default to 0.5 if empty + wi = wi_epoch if (diag["Ai"] or diag["Am"]) else 0.5 + for batch_idx, batch in enumerate(self.train_loader): + imgs, metas, geometry, labels = self._unpack_batch(batch) + imgs = imgs.to(self.device) + metas = metas.to(self.device) + labels = labels.to(self.device) + geometry = geometry.to(self.device) if geometry is not None else None + if random() < wi: + step_type = "image" + loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_image_only(imgs, metas, geometry, labels) + step_counts["image_only"] += 1 + else: + step_type = "meta" + loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_meta_only(imgs, metas, geometry, labels) + step_counts["metadata_only"] += 1 + + self.logger.info(f"epoch={epoch} batch={batch_idx} phase=tower_warmup step={step_type} " + f"loss_f={loss_f} loss_i={loss_i} loss_m={loss_m} acc_f={acc_f} acc_i={acc_i} acc_m={acc_m} cm={cm}") + + if loss_i is not None: + total_losses["image_only"] += loss_i; total_accs["image_only"] += acc_i; counts["image_only"] += 1 + if loss_m is not None: + total_losses["metadata_only"] += loss_m; total_accs["metadata_only"] += acc_m; counts["metadata_only"] += 1 + + elif in_fused_warmup: + # fused-only warmup + for batch_idx, batch in enumerate(self.train_loader): + imgs, metas, geometry, labels = self._unpack_batch(batch) + imgs = imgs.to(self.device) + metas = metas.to(self.device) + labels = labels.to(self.device) + geometry = geometry.to(self.device) if geometry is not None else None + step_type = "fused" + loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_fused(imgs, metas, geometry, labels) + step_counts["fused"] += 1 + + self.logger.info(f"epoch={epoch} batch={batch_idx} phase=fused_warmup step={step_type} " + f"loss_f={loss_f} acc_f={acc_f} cm={cm}") + + total_losses["fused"] += loss_f; total_accs["fused"] += acc_f; counts["fused"] += 1 + + else: + # dynamic BCD (fusion-centric) + self.logger.info( + f"epoch={epoch} bcd_epoch={p_bcd_epoch:.3f} wi_epoch={wi_epoch:.3f} wm_epoch={wm_epoch:.3f} " + f"deficits={{img:{diag['di']:.3f}, md:{diag['dm']:.3f}}} metrics={{Af:{diag['Af']:.3f}, Ai:{diag['Ai']:.3f}, Am:{diag['Am']:.3f}}}" + ) + for batch_idx, batch in enumerate(self.train_loader): + imgs, metas, geometry, labels = self._unpack_batch(batch) + imgs = imgs.to(self.device) + metas = metas.to(self.device) + labels = labels.to(self.device) + geometry = geometry.to(self.device) if geometry is not None else None + ents = self._batch_uncertainty(imgs, metas, geometry) + + # flip rule: higher fused entropy => fewer tower-only batches => more fused training + alpha_b = self.bcd_cfg["alpha_batch"]; pmin = self.bcd_cfg["pmin"]; pmax = self.bcd_cfg["pmax"] + p_bcd_batch = (1 - alpha_b) * p_bcd_epoch + alpha_b * (1.0 - ents["fused"]) + p_bcd_batch = max(pmin, min(pmax, p_bcd_batch)) + + # image vs meta split + alpha_t = self.bcd_cfg["alpha_tower"]; floor = self.bcd_cfg["explore_floor"]; eps = 1e-6 + s_img = ents["img"] / (ents["img"] + ents["md"] + eps) + p_img_batch = (1 - alpha_t) * wi_epoch + alpha_t * s_img + p_img_batch = max(floor, min(1.0 - floor, p_img_batch)) + + u = random(); v = random() + if u < p_bcd_batch: + if v < p_img_batch: + step_type = "image" + loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_image_only(imgs, metas, geometry, labels) + step_counts["image_only"] += 1 + else: + step_type = "meta" + loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_meta_only(imgs, metas, geometry, labels) + step_counts["metadata_only"] += 1 + else: + step_type = "fused" + loss_f, loss_i, loss_m, acc_f, acc_i, acc_m, cm = self._step_fused(imgs, metas, geometry, labels) + step_counts["fused"] += 1 + + self.logger.info( + f"epoch={epoch} batch={batch_idx} phase=dynamic step={step_type} " + f"p_bcd_batch={p_bcd_batch:.3f} p_img_batch={p_img_batch:.3f} " + f"ents={{f:{ents['fused']:.3f}, i:{ents['img']:.3f}, m:{ents['md']:.3f}}} " + f"loss_f={loss_f} loss_i={loss_i} loss_m={loss_m} acc_f={acc_f} acc_i={acc_i} acc_m={acc_m} cm={cm}" + ) + + if loss_f is not None: + total_losses["fused"] += loss_f; total_accs["fused"] += acc_f; counts["fused"] += 1 + if loss_i is not None: + total_losses["image_only"] += loss_i; total_accs["image_only"] += acc_i; counts["image_only"] += 1 + if loss_m is not None: + total_losses["metadata_only"] += loss_m; total_accs["metadata_only"] += acc_m; counts["metadata_only"] += 1 + + # epoch prints + avg_loss = total_losses["fused"] / counts["fused"] if counts["fused"] else 0.0 + avg_fused = total_accs["fused"] / counts["fused"] if counts["fused"] else 0.0 + avg_img = total_accs["image_only"] / counts["image_only"] if counts["image_only"] else 0.0 + avg_md = total_accs["metadata_only"] / counts["metadata_only"] if counts["metadata_only"] else 0.0 + + # realized mix for logging + tot_steps = sum(step_counts.values()) or 1 + self._last_step_mix = { + "pct_fused": step_counts["fused"] / tot_steps, + "pct_img": step_counts["image_only"] / tot_steps, + "pct_md": step_counts["metadata_only"] / tot_steps, + "phase": "tower_warmup" if in_tower_warmup else ("fused_warmup" if in_fused_warmup else "dynamic") + } + + print( + f"[Epoch {epoch+1}/{self.total_epochs}] Train Loss: {avg_loss:.4f} | " + f"Fused Acc: {100*avg_fused:.2f}% | Img Acc: {100*avg_img:.2f}% | Md Acc: {100*avg_md:.2f}%" + ) + row, stop_now = self.evaluate(epoch) + if stop_now: + print(f"[early] stopping on '{self.args.early_metric}' " + f"with best={self._early.best:.5f} at epoch {epoch+1 - self._early.bad_epochs}") + break + + torch.save(self._snapshot(), str(self.models_dir / "model_last.pt")) + # Ensure holdout-best checkpoint is written + if getattr(self, "_holdout_best", None): + hb = self._holdout_best + if hb.get("state") is not None and hb.get("save_path"): + try: + torch.save(hb["state"], hb["save_path"]) + except Exception: + pass + + def _copy_best_roc(epoch_idx: int, tag: str): + if epoch_idx is None or epoch_idx < 0: + return + src_dir = self.run_dir / "roc_curves" + if not src_dir.exists(): + return + prefix = f"epoch{epoch_idx + 1}_" + dest_dir = self.run_dir / f"roc_curves_{tag}" + dest_dir.mkdir(parents=True, exist_ok=True) + for path in src_dir.glob(f"{prefix}*.json"): + try: + shutil.copy2(path, dest_dir / path.name) + except Exception: + pass + if getattr(self, "_early", None): + print(f"[early] restoring best model at epoch {self._early.best_epoch+1}: {self._early.best:.5f}") + self._early.restore(self) + elif getattr(self, "_best_info", None) and self._best_info.get("state") is not None: + be = self._best_info + print(f"[best] restoring best model at epoch {be['epoch']+1}: {be['value']:.5f} (monitor={be['monitor']})") + self._restore_from_state(be["state"]) + torch.save(self._snapshot(), str(self.models_dir / "model_best.pt")) + # Snapshot ROC curves for both main-best and holdout-best epochs + if getattr(self, "_early", None) and getattr(self._early, "best_epoch", None) is not None: + _copy_best_roc(self._early.best_epoch, "best") + elif getattr(self, "_best_info", None): + _copy_best_roc(self._best_info.get("epoch", -1), "best") + if getattr(self, "_holdout_best", None): + _copy_best_roc(self._holdout_best.get("epoch", -1), "holdout_best") + self.ht_logger.close() + + + + def _evaluate_split(self, loader, epoch: int, split: str): + """ + Shared evaluation helper that computes core metrics (loss/accuracy/AUC/etc.) + for a given dataloader. Returns a dict with scalar metrics or None if the + loader is empty. + """ + if loader is None: + return None + + total_loss = 0.0 + total_counts = {"fused": 0, "image_only": 0, "metadata_only": 0} + total_accs = {"fused": 0.0, "image_only": 0.0, "metadata_only": 0.0} + all_y = [] + probs_f_list, probs_i_list, probs_m_list = [], [], [] + agree_f_img = 0 + agree_f_md = 0 + n_img_comp = 0 + n_md_comp = 0 + + with torch.no_grad(): + for batch in loader: + imgs, metas, geometry, labels = self._unpack_batch(batch) + imgs = imgs.to(self.device) + metas = metas.to(self.device) + labels = labels.to(self.device) + geometry = geometry.to(self.device) if geometry is not None else None + + if self.mode == "vote": + img_feats = self.img_tower(imgs, geometry) + md_feats = self.md_tower(metas) + out_img = self.head_img(img_feats) + out_md = self.head_md(md_feats) + out_fused = self.vote(out_img, out_md) + else: + img_feats = self.img_tower(imgs, geometry) + md_feats = self.md_tower(metas) + outputs = self.bridge(img_feats, md_feats) + if isinstance(outputs, tuple): + out_fused, out_img, out_md = outputs + else: + out_fused, out_img, out_md = outputs, None, None + + all_y.append(labels.cpu().numpy()) + probs_f_list.append(F.softmax(out_fused, dim=1).cpu().numpy()) + + loss = self._classification_loss(out_fused, labels) + total_loss += loss.item() + + preds_f = out_fused.argmax(dim=1) + total_accs["fused"] += (preds_f == labels).float().mean().item() + total_counts["fused"] += 1 + + if out_img is not None: + preds_i = out_img.argmax(dim=1) + total_accs["image_only"] += (preds_i == labels).float().mean().item() + total_counts["image_only"] += 1 + probs_i_list.append(F.softmax(out_img, dim=1).cpu().numpy()) + + agree_f_img += (preds_f == preds_i).sum().item() + n_img_comp += preds_f.numel() + + if out_md is not None: + preds_m = out_md.argmax(dim=1) + total_accs["metadata_only"] += (preds_m == labels).float().mean().item() + total_counts["metadata_only"] += 1 + probs_m_list.append(F.softmax(out_md, dim=1).cpu().numpy()) + + agree_f_md += (preds_f == preds_m).sum().item() + n_md_comp += preds_f.numel() + + if total_counts["fused"] == 0: + return None + + avg_loss = total_loss / total_counts["fused"] + avg_fused = total_accs["fused"] / total_counts["fused"] + avg_img = (total_accs["image_only"] / total_counts["image_only"]) if total_counts["image_only"] else 0.0 + avg_md = (total_accs["metadata_only"] / total_counts["metadata_only"]) if total_counts["metadata_only"] else 0.0 + + y_true = np.concatenate(all_y, axis=0) if all_y else np.array([]) + y_prob_f = np.concatenate(probs_f_list, axis=0) if probs_f_list else None + y_prob_i = np.concatenate(probs_i_list, axis=0) if probs_i_list else None + y_prob_m = np.concatenate(probs_m_list, axis=0) if probs_m_list else None + + def compute_multiclass_roc(y_true_np, prob_np): + if prob_np is None or prob_np.size == 0: + return None + C = int(prob_np.shape[1]) + classes = list(range(C)) + y_bin = label_binarize(y_true_np, classes=classes) + per_class = {} + aucs = [] + for c in range(C): + try: + fpr, tpr, _ = roc_curve(y_bin[:, c], prob_np[:, c]) + auc_val = float(auc(fpr, tpr)) if len(fpr) > 1 else float("nan") + per_class[c] = {"fpr": fpr.tolist(), "tpr": tpr.tolist(), "auc": auc_val} + aucs.append(auc_val) + except Exception: + per_class[c] = {"fpr": [0.0, 1.0], "tpr": [0.0, 1.0], "auc": float("nan")} + + try: + fpr_micro, tpr_micro, _ = roc_curve(y_bin.ravel(), prob_np[:, :C].ravel()) + auc_micro = float(auc(fpr_micro, tpr_micro)) + micro = {"fpr": fpr_micro.tolist(), "tpr": tpr_micro.tolist(), "auc": auc_micro} + except Exception: + micro = None + + auc_vals = [v["auc"] for v in per_class.values() if v["auc"] == v["auc"]] + macro_auc = float(np.mean(auc_vals)) if auc_vals else float("nan") + return {"per_class": per_class, "micro": micro, "macro_auc": macro_auc} + + def macro_ovr_auc(y, p): + try: + y = np.asarray(y) + if p is None: + return float("nan") + p = np.asarray(p) + if p.ndim == 2 and p.shape[1] == 2: + return roc_auc_score(y, p[:, 1]) + if p.ndim == 1 or p.shape[1] == 1: + return roc_auc_score(y, p.ravel()) + return roc_auc_score(y, p, multi_class="ovr", average="macro") + except Exception: + return float("nan") + + def top2_acc(y, p): + if p is None: + return float("nan") + if p.ndim != 2: + return float("nan") + k = 2 if p.shape[1] >= 2 else 1 + topk = np.argpartition(-p, kth=k - 1, axis=1)[:, :k] + return np.mean((topk == y[:, None]).any(axis=1).astype(np.float32)) + + def avg_margin(p): + if p is None or p.ndim != 2: + return float("nan") + s = np.sort(p, axis=1)[:, ::-1] + if s.shape[1] == 1: + return float("nan") + return float(np.mean(s[:, 0] - s[:, 1])) + + roc_f = compute_multiclass_roc(y_true, y_prob_f) + roc_i = compute_multiclass_roc(y_true, y_prob_i) + roc_m = compute_multiclass_roc(y_true, y_prob_m) + + roc_dir = self.run_dir / "roc_curves" + roc_dir.mkdir(parents=True, exist_ok=True) + + def dump_roc(blob, head: str): + if blob is None: + return + suffix = head if split == "val" else f"{split}_{head}" + path = roc_dir / f"epoch{epoch + 1}_{suffix}.json" + with open(path, "w") as f: + json.dump(blob, f) + + dump_roc(roc_f, "fused") + dump_roc(roc_i, "image") + dump_roc(roc_m, "metadata") + + auc_f = macro_ovr_auc(y_true, y_prob_f) if y_prob_f is not None else float("nan") + auc_i = macro_ovr_auc(y_true, y_prob_i) if y_prob_i is not None else float("nan") + auc_m = macro_ovr_auc(y_true, y_prob_m) if y_prob_m is not None else float("nan") + + top2_f = top2_acc(y_true, y_prob_f) + top2_i = top2_acc(y_true, y_prob_i) + top2_m = top2_acc(y_true, y_prob_m) + mar_f = avg_margin(y_prob_f) + mar_i = avg_margin(y_prob_i) + mar_m = avg_margin(y_prob_m) + + agree_rate_f_img = (agree_f_img / n_img_comp) if n_img_comp else float("nan") + agree_rate_f_md = (agree_f_md / n_md_comp) if n_md_comp else float("nan") + + return { + "loss": float(avg_loss), + "acc_fused": float(avg_fused), + "acc_img": float(avg_img), + "acc_md": float(avg_md), + "auc_fused": float(auc_f) if auc_f == auc_f else float("nan"), + "auc_img": float(auc_i) if auc_i == auc_i else float("nan"), + "auc_md": float(auc_m) if auc_m == auc_m else float("nan"), + "top2_fused": float(top2_f) if top2_f == top2_f else float("nan"), + "top2_img": float(top2_i) if top2_i == top2_i else float("nan"), + "top2_md": float(top2_m) if top2_m == top2_m else float("nan"), + "margin_fused": float(mar_f) if mar_f == mar_f else float("nan"), + "margin_img": float(mar_i) if mar_i == mar_i else float("nan"), + "margin_md": float(mar_m) if mar_m == mar_m else float("nan"), + "agree_fused_img": float(agree_rate_f_img) if agree_rate_f_img == agree_rate_f_img else float("nan"), + "agree_fused_md": float(agree_rate_f_md) if agree_rate_f_md == agree_rate_f_md else float("nan"), + } + + + def evaluate(self, epoch: int): + # Evaluation additionally collects per-head probabilities to report macro AUC + self.img_tower.eval() + self.md_tower.eval() + if self.mode == "vote": + self.head_img.eval(); self.head_md.eval(); self.vote.eval() + else: + self.bridge.eval() + + metrics_val = self._evaluate_split(self.test_loader, epoch, "val") + metrics_holdout = self._evaluate_split(self.holdout_loader, epoch, "holdout") if self.holdout_loader else None + + if metrics_val is None: + raise RuntimeError("Validation loader produced no batches; cannot compute metrics.") + + def ffmt(x): + return "NA" if (x != x) else f"{x:.3f}" + + print( + f"[Epoch {epoch+1}/{self.total_epochs}] Eval Loss: {metrics_val['loss']:.4f} | " + f"Fused Acc: {100*metrics_val['acc_fused']:.2f}% | " + f"Img Acc: {100*metrics_val['acc_img']:.2f}% | " + f"Md Acc: {100*metrics_val['acc_md']:.2f}% | " + f"Fused AUC: {ffmt(metrics_val['auc_fused'])} | " + f"Img AUC: {ffmt(metrics_val['auc_img'])} | " + f"Md AUC: {ffmt(metrics_val['auc_md'])}" + ) + + if metrics_holdout: + print( + f"[Epoch {epoch+1}/{self.total_epochs}] Holdout Loss: {metrics_holdout['loss']:.4f} | " + f"Fused Acc: {100*metrics_holdout['acc_fused']:.2f}% | " + f"Img Acc: {100*metrics_holdout['acc_img']:.2f}% | " + f"Md Acc: {100*metrics_holdout['acc_md']:.2f}% | " + f"Fused AUC: {ffmt(metrics_holdout['auc_fused'])} | " + f"Img AUC: {ffmt(metrics_holdout['auc_img'])} | " + f"Md AUC: {ffmt(metrics_holdout['auc_md'])}" + ) + + self.last_eval = { + "acc_fused": float(metrics_val["acc_fused"]), + "acc_img": float(metrics_val["acc_img"]), + "acc_md": float(metrics_val["acc_md"]), + "auc_fused": float(metrics_val["auc_fused"]) if metrics_val["auc_fused"] == metrics_val["auc_fused"] else 0.0, + "auc_img": float(metrics_val["auc_img"]) if metrics_val["auc_img"] == metrics_val["auc_img"] else 0.0, + "auc_md": float(metrics_val["auc_md"]) if metrics_val["auc_md"] == metrics_val["auc_md"] else 0.0, + } + + row = { + "epoch": int(epoch + 1), + "eval_loss": _num(metrics_val["loss"]), + "acc_fused": _num(metrics_val["acc_fused"]), + "acc_img": _num(metrics_val["acc_img"]), + "acc_md": _num(metrics_val["acc_md"]), + "auc_fused": _num(metrics_val["auc_fused"]), + "auc_img": _num(metrics_val["auc_img"]), + "auc_md": _num(metrics_val["auc_md"]), + "top2_fused": _num(metrics_val["top2_fused"]), + "top2_img": _num(metrics_val["top2_img"]), + "top2_md": _num(metrics_val["top2_md"]), + "margin_fused": _num(metrics_val["margin_fused"]), + "margin_img": _num(metrics_val["margin_img"]), + "margin_md": _num(metrics_val["margin_md"]), + "agree_fused_img": _num(metrics_val["agree_fused_img"]), + "agree_fused_md": _num(metrics_val["agree_fused_md"]), + } + + if metrics_holdout: + row.update({ + "holdout_loss": _num(metrics_holdout["loss"]), + "holdout_acc_fused": _num(metrics_holdout["acc_fused"]), + "holdout_acc_img": _num(metrics_holdout["acc_img"]), + "holdout_acc_md": _num(metrics_holdout["acc_md"]), + "holdout_auc_fused": _num(metrics_holdout["auc_fused"]), + "holdout_auc_img": _num(metrics_holdout["auc_img"]), + "holdout_auc_md": _num(metrics_holdout["auc_md"]), + }) + else: + row.setdefault("holdout_loss", None) + row.setdefault("holdout_acc_fused", None) + row.setdefault("holdout_acc_img", None) + row.setdefault("holdout_acc_md", None) + row.setdefault("holdout_auc_fused", None) + row.setdefault("holdout_auc_img", None) + row.setdefault("holdout_auc_md", None) + + # realized step mix from training (if present) + mix = getattr(self, "_last_step_mix", None) + if mix: + row.update({ + "pct_fused": _num(mix.get("pct_fused")), + "pct_img": _num(mix.get("pct_img")), + "pct_md": _num(mix.get("pct_md")), + "phase": mix.get("phase"), + }) + + # SE gate stats (if the bridge exposes them) + # fetch without clearing + bridge_module = getattr(self, "bridge", None) + get_se_stats = getattr(bridge_module, "get_se_stats", None) if bridge_module is not None else None + if callable(get_se_stats): + se_stats = get_se_stats(reset=False) + if se_stats: + row.update({ + "se_mean": _num(se_stats.get("mean")), + "se_std": _num(se_stats.get("std")), + "se_pct_lt_0.2": _num(se_stats.get("pct_lt_0.2")), + "se_pct_gt_0.8": _num(se_stats.get("pct_gt_0.8")), + }) + # debug print BEFORE reset so you can see the true count + try: + print(f"SE gate samples seen: {getattr(getattr(bridge_module, 'se_log', None), '_n', 0)}") + except Exception: + pass + # now clear for the next epoch + try: + get_se_stats(reset=True) + except Exception: + pass + row.setdefault(self.args.early_metric, None) + # early-stop decision (no break here) + should_stop = False + if getattr(self, "_early", None): + should_stop = self._early.step(row, trainer=self, epoch=epoch) + row.update({ + "early_best_so_far" : float(self._early.best), + "early_bad_epochs" : int(self._early.bad_epochs), + "early_improved" : int(self._early.last_improved), + "early_monitor" : self.args.early_metric, + }) + # concise console status each epoch + mon = self.args.early_metric + cur = row.get(mon, None) + if cur is not None and cur == cur: # not NaN + msg = ( + f"[early] epoch {epoch+1}: {mon}={cur:.5f} | best={self._early.best:.5f} | " + f"bad_epochs={self._early.bad_epochs}/{self._early.patience} | improved={'yes' if self._early.last_improved else 'no'}" + ) + print(msg) + try: + self.logger.info(msg) + except Exception: + pass + + # Holdout best-tracker (if a holdout set is present) + if getattr(self, "_holdout_best", None): + mon_h = self._holdout_best["monitor"] + mode_h = self._holdout_best["mode"] + min_delta_h = self._holdout_best["min_delta"] + val_h = row.get(mon_h, None) + improved_h = False + if val_h is not None and val_h == val_h: + best_val_h = self._holdout_best["value"] + if mode_h == "max": + improved_h = (val_h > best_val_h + min_delta_h) + else: + improved_h = (val_h < best_val_h - min_delta_h) + if improved_h: + self._holdout_best["value"] = float(val_h) + self._holdout_best["epoch"] = int(epoch) + st_h = self._snapshot() + self._holdout_best["state"] = st_h + if self._holdout_best.get("save_path"): + try: + torch.save(st_h, self._holdout_best["save_path"]) + except Exception: + pass + print(f"[holdout_best] ↑ new best {mon_h}={val_h:.5f} at epoch {epoch+1}") + row.update({ + "holdout_best_monitor": mon_h, + "holdout_best_so_far": (None if self._holdout_best["value"] in (math.inf, -math.inf) else float(self._holdout_best["value"])), + "holdout_best_epoch": (None if self._holdout_best["epoch"] < 0 else int(self._holdout_best["epoch"] + 1)), + }) + + # Best-tracker (always-on): save best model snapshot and optional checkpoint + if getattr(self, "_best_info", None): + # If early stopper is active, mirror its best info and avoid duplicate prints + if getattr(self, "_early", None): + row.update({ + "best_monitor": self._best_info["monitor"], + "best_so_far": float(self._early.best) if self._early.best == self._early.best else None, + "best_epoch": (int(self._early.best_epoch) + 1) if (self._early.best_epoch is not None) else None, + }) + # skip independent tracking/prints to avoid duplication + self._write_epoch_log(row) + return row, should_stop + mon = self._best_info["monitor"] + mode = self._best_info["mode"] + min_delta = self._best_info["min_delta"] + val = row.get(mon, None) + improved = False + if val is not None and val == val: # not NaN + best_val = self._best_info["value"] + if mode == "max": + improved = (val > best_val + min_delta) + else: + improved = (val < best_val - min_delta) + if improved: + self._best_info["value"] = float(val) + self._best_info["epoch"] = int(epoch) + st = self._snapshot() + self._best_info["state"] = st + if self._best_info.get("save_path"): + try: + torch.save(st, self._best_info["save_path"]) + except Exception: + pass + print(f"[best] ↑ new best {mon}={val:.5f} at epoch {epoch+1}") + # Add best-so-far to row + row.update({ + "best_monitor": mon, + "best_so_far": (None if self._best_info["value"] in (math.inf, -math.inf) else float(self._best_info["value"])), + "best_epoch": (None if self._best_info["epoch"] < 0 else int(self._best_info["epoch"] + 1)), + }) + + # finally: write once, after all updates + self._write_epoch_log(row) + return row, should_stop + + def _restore_from_state(self, st: dict): + if not st: + return + self.img_tower.load_state_dict(st["img_tower"]) + self.md_tower.load_state_dict(st["md_tower"]) + if "bridge" in st and hasattr(self, "bridge"): + self.bridge.load_state_dict(st["bridge"]) + if "head_img" in st and hasattr(self, "head_img"): + self.head_img.load_state_dict(st["head_img"]) + if "head_md" in st and hasattr(self, "head_md"): + self.head_md.load_state_dict(st["head_md"]) + if "optimizer" in st: + self.optimizer.load_state_dict(st["optimizer"]) + + +# --- V2 loader integration (no v1 hypertower import) --- + +from classes.v2 import build_papila_profile +from classes.v2.slot_dataset import SlotDataset, slot_collate + + +__all__ = [ + "HyperTower", + "V2HyperTower", + "V2ModeComparisonOps", + "V2ModeComparator", +] + + +def _tuple_collate(batch: list[dict[str, object]]): + """ + Convert a slot-collated batch dict into (imgs, metas, labels) tuples so + the legacy HyperTower training loop can consume it. + """ + data = slot_collate(batch) + imgs = data.get("image_1") + metas = data.get("matrix_1") + labels = data.get("label_1") + if labels is not None and not torch.is_tensor(labels): + labels = torch.as_tensor(labels, dtype=torch.long) + return imgs, metas, labels + + +class V2HyperTower(HyperTower): + """ + HyperTower that swaps the loader pipeline for V2 slot datasets. + This preserves the original training loop, logging, and metrics. + """ + + def __init__(self, clinical, args): + sample_mode = getattr(args, "sample_mode", "eye") + self._v2_profile = build_papila_profile( + patient_col="Patient ID", + label_col=getattr(clinical, "label_col", "Diagnosis"), + sample_mode=sample_mode, + ) + super().__init__(clinical, args) + + def _make_loader_for_df(self, df, is_train: bool = False): + samples = self._v2_profile.build_samples(df=df, clinical=self.clinical) + dataset = SlotDataset( + samples, + self._v2_profile.slot_descriptors(), + image_transform=self.img_tower.transform, + image_preprocessor=self.image_preprocessor, + ) + + # Optional: class-balanced bootstrapped sampling for TRAIN only + if is_train and getattr(self.args, "balanced_sampler", False): + y = np.asarray(df[self.clinical.label_col].values) + uniq, counts = np.unique(y, return_counts=True) + inv = {c: (1.0 / cnt if cnt > 0 else 0.0) for c, cnt in zip(uniq, counts)} + w = np.array([inv[c] for c in y], dtype=np.float32) + sampler = WeightedRandomSampler(weights=w, num_samples=len(y), replacement=True) + return DataLoader( + dataset, + batch_size=self.batch_size, + sampler=sampler, + shuffle=False, + collate_fn=_tuple_collate, + ) + + return DataLoader( + dataset, + batch_size=self.batch_size, + shuffle=not is_train, + collate_fn=_tuple_collate, + ) + + +class V2ModeComparisonOps: + """ + Shared mode-comparison training/eval ops hosted in V2 code so scripts + remain orchestration-only. + """ + + @staticmethod + def _to_label_tensor(labels, device: torch.device) -> torch.Tensor: + if torch.is_tensor(labels): + return labels.to(device=device, dtype=torch.long) + return torch.as_tensor(labels, dtype=torch.long, device=device) + + @staticmethod + def _set_requires_grad(module: nn.Module, enabled: bool) -> None: + for p in module.parameters(): + p.requires_grad = enabled + + @staticmethod + def _set_single_phase(model: nn.Module, phase: str) -> None: + if phase == "tower_warmup": + V2ModeComparisonOps._set_requires_grad(model.img_tower, True) + V2ModeComparisonOps._set_requires_grad(model.md_tower, True) + V2ModeComparisonOps._set_requires_grad(model.bridge.classifier_img, True) + V2ModeComparisonOps._set_requires_grad(model.bridge.classifier_md, True) + V2ModeComparisonOps._set_requires_grad(model.bridge.W_img, False) + V2ModeComparisonOps._set_requires_grad(model.bridge.W_md, False) + V2ModeComparisonOps._set_requires_grad(model.bridge.classifier_fused, False) + return + if phase == "fused_warmup": + V2ModeComparisonOps._set_requires_grad(model.img_tower, False) + V2ModeComparisonOps._set_requires_grad(model.md_tower, False) + V2ModeComparisonOps._set_requires_grad(model.bridge.classifier_img, False) + V2ModeComparisonOps._set_requires_grad(model.bridge.classifier_md, False) + V2ModeComparisonOps._set_requires_grad(model.bridge.W_img, True) + V2ModeComparisonOps._set_requires_grad(model.bridge.W_md, True) + V2ModeComparisonOps._set_requires_grad(model.bridge.classifier_fused, True) + return + V2ModeComparisonOps._set_requires_grad(model, True) + + @staticmethod + def _set_bilateral_phase(model: nn.Module, phase: str) -> None: + if phase == "tower_warmup": + V2ModeComparisonOps._set_requires_grad(model.eye_img_tower, True) + V2ModeComparisonOps._set_requires_grad(model.eye_md_tower, True) + V2ModeComparisonOps._set_requires_grad(model.joint_img, True) + V2ModeComparisonOps._set_requires_grad(model.joint_md, True) + V2ModeComparisonOps._set_requires_grad(model.aux_img, True) + V2ModeComparisonOps._set_requires_grad(model.aux_md, True) + V2ModeComparisonOps._set_requires_grad(model.bridge, False) + return + if phase == "fused_warmup": + V2ModeComparisonOps._set_requires_grad(model.eye_img_tower, False) + V2ModeComparisonOps._set_requires_grad(model.eye_md_tower, False) + V2ModeComparisonOps._set_requires_grad(model.joint_img, False) + V2ModeComparisonOps._set_requires_grad(model.joint_md, False) + V2ModeComparisonOps._set_requires_grad(model.aux_img, False) + V2ModeComparisonOps._set_requires_grad(model.aux_md, False) + V2ModeComparisonOps._set_requires_grad(model.bridge, True) + return + V2ModeComparisonOps._set_requires_grad(model, True) + + @staticmethod + def train_single_epoch(model: nn.Module, loader: DataLoader, opt, device: torch.device, *, phase: str, bcd_prob: float = 0.5): + model.train() + V2ModeComparisonOps._set_single_phase(model, phase) + total_loss = total_correct = total_n = 0 + for batch in loader: + x = batch.get("image_1") + m = batch.get("matrix_1") + y = batch.get("label_1") + if not torch.is_tensor(x) or not torch.is_tensor(m): + continue + x = x.to(device) + m = m.to(device) + y = V2ModeComparisonOps._to_label_tensor(y, device) + img_feats = model.img_tower(x) + md_feats = model.md_tower(m) + + if phase == "tower_warmup": + logits_i = model.bridge.classifier_img(img_feats) + logits_m = model.bridge.classifier_md(md_feats) + loss = 0.5 * (F.cross_entropy(logits_i, y) + F.cross_entropy(logits_m, y)) + logits = 0.5 * (F.softmax(logits_i, dim=1) + F.softmax(logits_m, dim=1)) + elif phase == "fused_warmup": + logits, _, _ = model.bridge(img_feats, md_feats) + loss = F.cross_entropy(logits, y) + else: + if random() < bcd_prob: + if random() < 0.5: + logits = model.bridge.classifier_img(img_feats) + else: + logits = model.bridge.classifier_md(md_feats) + loss = F.cross_entropy(logits, y) + else: + logits, _, _ = model.bridge(img_feats, 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 + return ( + total_loss / total_n if total_n else float("nan"), + total_correct / total_n if total_n else float("nan"), + ) + + @staticmethod + def train_bilateral_epoch(model: nn.Module, loader: DataLoader, opt, device: torch.device, *, phase: str, bcd_prob: float = 0.5): + model.train() + V2ModeComparisonOps._set_bilateral_phase(model, phase) + total_loss = total_correct = total_n = 0 + for batch in loader: + x1 = batch.get("image_1") + m1 = batch.get("matrix_1") + x2 = batch.get("image_2") + m2 = batch.get("matrix_2") + y = batch.get("label_1") + if not (torch.is_tensor(x1) and torch.is_tensor(m1) and torch.is_tensor(x2) and torch.is_tensor(m2)): + continue + x1 = x1.to(device); m1 = m1.to(device) + x2 = x2.to(device); m2 = m2.to(device) + y = V2ModeComparisonOps._to_label_tensor(y, device) + joint_img, joint_md = model.encode_joint(x1, m1, x2, m2) + + if phase == "tower_warmup": + logits_i = model.aux_img(joint_img) + logits_m = model.aux_md(joint_md) + loss = 0.5 * (F.cross_entropy(logits_i, y) + F.cross_entropy(logits_m, y)) + logits = 0.5 * (F.softmax(logits_i, dim=1) + F.softmax(logits_m, dim=1)) + elif phase == "fused_warmup": + logits, _, _ = model.bridge(joint_img, joint_md) + loss = F.cross_entropy(logits, y) + else: + if random() < bcd_prob: + if random() < 0.5: + logits = model.aux_img(joint_img) + else: + logits = model.aux_md(joint_md) + loss = F.cross_entropy(logits, y) + else: + logits, _, _ = model.bridge(joint_img, joint_md) + 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 + return ( + total_loss / total_n if total_n else float("nan"), + total_correct / total_n if total_n else float("nan"), + ) + + @staticmethod + def collect_probs_classic(model: nn.Module, loader: DataLoader, device: torch.device): + model.eval() + y_chunks, p_chunks = [], [] + 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") + y = batch.get("label_1") + if not (torch.is_tensor(x1) and torch.is_tensor(m1) and torch.is_tensor(x2) and torch.is_tensor(m2)): + continue + y_t = V2ModeComparisonOps._to_label_tensor(y, device) + p_od = F.softmax(model(x1.to(device), m1.to(device)), dim=1) + p_os = F.softmax(model(x2.to(device), m2.to(device)), dim=1) + y_np = y_t.cpu().numpy() + y_chunks += [y_np, y_np] + p_chunks += [p_od.cpu().numpy(), p_os.cpu().numpy()] + if not y_chunks: + return np.array([], dtype=np.int64), np.zeros((0, 0), dtype=np.float32) + return np.concatenate(y_chunks), np.concatenate(p_chunks, axis=0) + + @staticmethod + def collect_probs_ensemble(model: nn.Module, loader: DataLoader, device: torch.device): + model.eval() + y_chunks, p_chunks = [], [] + 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") + y = batch.get("label_1") + if not (torch.is_tensor(x1) and torch.is_tensor(m1) and torch.is_tensor(x2) and torch.is_tensor(m2)): + continue + y_t = V2ModeComparisonOps._to_label_tensor(y, device) + p_od = F.softmax(model(x1.to(device), m1.to(device)), dim=1) + p_os = F.softmax(model(x2.to(device), m2.to(device)), dim=1) + p = 0.5 * (p_od + p_os) + y_chunks.append(y_t.cpu().numpy()) + p_chunks.append(p.cpu().numpy()) + if not y_chunks: + return np.array([], dtype=np.int64), np.zeros((0, 0), dtype=np.float32) + return np.concatenate(y_chunks), np.concatenate(p_chunks, axis=0) + + @staticmethod + def collect_probs_bilateral(model: nn.Module, loader: DataLoader, device: torch.device): + model.eval() + y_chunks, p_chunks = [], [] + 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") + y = batch.get("label_1") + if not (torch.is_tensor(x1) and torch.is_tensor(m1) and torch.is_tensor(x2) and torch.is_tensor(m2)): + continue + y_t = V2ModeComparisonOps._to_label_tensor(y, device) + p = F.softmax(model(x1.to(device), m1.to(device), x2.to(device), m2.to(device)), dim=1) + y_chunks.append(y_t.cpu().numpy()) + p_chunks.append(p.cpu().numpy()) + if not y_chunks: + return np.array([], dtype=np.int64), np.zeros((0, 0), dtype=np.float32) + return np.concatenate(y_chunks), np.concatenate(p_chunks, axis=0) + + +class V2ModeComparator: + """ + V2 entrypoint for the three-mode comparison workflow: + classic, ensemble, bilateral. + """ + + @staticmethod + def build_parser(): + return build_parser() + + @staticmethod + def run(cli_args=None): + parser = V2ModeComparator.build_parser() + args = parser.parse_args(cli_args) + return run_mode(args) + + +# --- Mode comparison orchestration (migrated from mode_compare_engine) --- + +def seed_everything(seed: int) -> None: + pyrandom.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +class SingleEyeHT(nn.Module): + """ + ImageTower + MDTower + Bridge, trained on eye-level samples. + Supports both Classic (eye-level) and Ensemble (patient-level averaging) eval. + """ + + def __init__( + self, + *, + backbone: str, + freeze_ratio: float, + augment: bool, + clinical_data, + num_classes: int, + md_hidden_dim: int = 128, + fusion_dim: int = 256, + ): + super().__init__() + self.img_tower = ImageTower( + backbone=backbone, + freeze_ratio=freeze_ratio, + augment=augment, + use_se=False, + ) + self.md_tower = MDTower( + clinical_data=clinical_data, + hidden_dim=md_hidden_dim, + use_se=False, + ) + self.bridge = Bridge( + img_dim=self.img_tower.out_dim, + meta_dim=self.md_tower.out_dim, + num_classes=num_classes, + fusion_dim=fusion_dim, + mode="fused", + use_se=False, + ) + + @property + def transform(self): + return self.img_tower.transform + + def forward(self, x: torch.Tensor, meta: torch.Tensor) -> torch.Tensor: + img_feats = self.img_tower(x) + md_feats = self.md_tower(meta) + out_f, _, _ = self.bridge(img_feats, md_feats) + return out_f + + +class BilateralHT(nn.Module): + """ + Bilateral mode with joint towers: + - shared eye-level towers encode OD/OS independently + - joint image and metadata towers combine OD/OS embeddings + - standard Bridge fuses joint image + joint metadata embeddings + """ + + def __init__( + self, + *, + backbone: str, + freeze_ratio: float, + augment: bool, + clinical_data, + num_classes: int, + md_hidden_dim: int = 128, + fusion_dim: int = 256, + ): + super().__init__() + self.eye_img_tower = ImageTower( + backbone=backbone, + freeze_ratio=freeze_ratio, + augment=augment, + use_se=False, + ) + self.eye_md_tower = MDTower( + clinical_data=clinical_data, + hidden_dim=md_hidden_dim, + use_se=False, + ) + img_dim = self.eye_img_tower.out_dim + md_dim = self.eye_md_tower.out_dim + self.joint_img = nn.Sequential( + nn.Linear(2 * img_dim, fusion_dim), + nn.LayerNorm(fusion_dim), + nn.ReLU(), + nn.Dropout(0.3), + nn.Linear(fusion_dim, img_dim), + ) + self.joint_md = nn.Sequential( + nn.Linear(2 * md_dim, fusion_dim), + nn.LayerNorm(fusion_dim), + nn.ReLU(), + nn.Dropout(0.3), + nn.Linear(fusion_dim, md_dim), + ) + self.bridge = Bridge( + img_dim=img_dim, + meta_dim=md_dim, + num_classes=num_classes, + fusion_dim=fusion_dim, + mode="fused", + use_se=False, + ) + # Auxiliary heads for tower warmup / BCD tower steps. + self.aux_img = nn.Linear(img_dim, num_classes) + self.aux_md = nn.Linear(md_dim, num_classes) + + @property + def transform(self): + return self.eye_img_tower.transform + + def encode_joint( + self, + x_od: torch.Tensor, + meta_od: torch.Tensor, + x_os: torch.Tensor, + meta_os: torch.Tensor, + ) -> tuple[torch.Tensor, torch.Tensor]: + img_od = self.eye_img_tower(x_od) + md_od = self.eye_md_tower(meta_od) + img_os = self.eye_img_tower(x_os) + md_os = self.eye_md_tower(meta_os) + joint_img = self.joint_img(torch.cat([img_od, img_os], dim=1)) + joint_md = self.joint_md(torch.cat([md_od, md_os], dim=1)) + return joint_img, joint_md + + def forward( + self, + x_od: torch.Tensor, + meta_od: torch.Tensor, + x_os: torch.Tensor, + meta_os: torch.Tensor, + ) -> torch.Tensor: + joint_img, joint_md = self.encode_joint(x_od, meta_od, x_os, meta_os) + out_f, _, _ = self.bridge(joint_img, joint_md) + return out_f + + +def _set_requires_grad(module: nn.Module, enabled: bool) -> None: + V2ModeComparisonOps._set_requires_grad(module, enabled) + + +def _set_single_phase(model: SingleEyeHT, phase: str) -> None: + V2ModeComparisonOps._set_single_phase(model, phase) + + +def _set_bilateral_phase(model: BilateralHT, phase: str) -> None: + V2ModeComparisonOps._set_bilateral_phase(model, phase) + + +# --------------------------------------------------------------------------- +# Data helpers +# --------------------------------------------------------------------------- + +def filter_eye_samples(samples: list[dict]) -> list[dict]: + """Keep any single-eye sample with a valid image, matrix, and label.""" + return [ + s for s in samples + if s.get("image_1") is not None + and s.get("matrix_1") is not None + and s.get("label_1") is not None + ] + + +def filter_bilateral_samples(samples: list[dict]) -> list[dict]: + """Keep only patient-level samples where both eyes are fully present.""" + return [ + s for s in samples + if s.get("image_1") is not None + and s.get("matrix_1") is not None + and s.get("image_2") is not None + and s.get("matrix_2") is not None + and s.get("label_1") is not None + ] + + +def make_loader( + samples: list[dict], + slots: dict, + *, + image_transform, + image_preprocessor=None, + batch_size: int, + shuffle: bool, + num_workers: int, +) -> DataLoader: + ds = SlotDataset( + samples, + slots, + image_transform=image_transform, + image_preprocessor=image_preprocessor, + ) + return DataLoader( + ds, + batch_size=batch_size, + shuffle=shuffle, + num_workers=num_workers, + collate_fn=slot_collate, + ) + + +def to_label_tensor(labels, device: torch.device) -> torch.Tensor: + if torch.is_tensor(labels): + return labels.to(device=device, dtype=torch.long) + return torch.as_tensor(labels, dtype=torch.long, device=device) + + +def _drop_mixed_label_patients(df, *, patient_col: str, label_col: str): + per_patient = ( + df.groupby(patient_col)[label_col] + .agg(lambda s: set(pd.to_numeric(s, errors="coerce").dropna().astype(int).tolist())) + ) + mixed = [pid for pid, labels in per_patient.items() if len(labels) > 1] + if not mixed: + return df, [] + return df[~df[patient_col].isin(mixed)].reset_index(drop=True), mixed + + +def _relabel_mixed_patients_to_max(df, *, patient_col: str, label_col: str): + """Set all rows for each patient to that patient's max observed label.""" + out = df.copy() + labels = pd.to_numeric(out[label_col], errors="coerce") + patient_max = labels.groupby(out[patient_col]).transform("max") + changed_rows = int((labels != patient_max).fillna(False).sum()) + out[label_col] = patient_max.astype(int) + per_patient_unique = out.groupby(patient_col)[label_col].nunique(dropna=True) + still_mixed = per_patient_unique[per_patient_unique > 1].index.tolist() + return out.reset_index(drop=True), changed_rows, still_mixed + + +def build_image_preprocessor_from_args(args): + crop_manifest = getattr(args, "img_crop_manifest", None) + crop_weights = getattr(args, "img_crop_weights", None) + use_gt = bool(getattr(args, "img_crop_gt", False)) + if not crop_manifest: + return None + crop_cache = Path(getattr(args, "img_crop_cache", Path("analysis_data/hypertower_crops"))) + if use_gt: + pre = ManifestImageCropper( + manifest_path=Path(crop_manifest), + scale=getattr(args, "img_crop_scale", 2.5), + target_size=getattr(args, "img_crop_size", 224), + cache_dir=crop_cache, + ) + print(f"[V2 modes] GT disc cropper enabled -> cache at {crop_cache}", flush=True) + return pre + if crop_weights: + pre = UNetImageCropper( + manifest_path=Path(crop_manifest), + weights_path=Path(crop_weights), + normalize=getattr(args, "img_crop_normalize", "per_image"), + threshold=getattr(args, "img_crop_threshold", 0.5), + tta=getattr(args, "img_crop_tta", False), + scale=getattr(args, "img_crop_scale", 2.5), + target_size=getattr(args, "img_crop_size", 224), + cache_dir=crop_cache, + ) + print(f"[V2 modes] UNet disc cropper enabled -> cache at {crop_cache}", flush=True) + return pre + print( + "[V2 modes] img_crop_manifest provided but no --img-crop-gt or --img-crop-weights; cropping disabled.", + flush=True, + ) + return None + + +# --------------------------------------------------------------------------- +# Metrics helpers +# --------------------------------------------------------------------------- + +def compute_ece(y_true: np.ndarray, probs: np.ndarray, n_bins: int = 10) -> float: + """Expected Calibration Error: weighted mean of |confidence - accuracy| per bin.""" + if y_true.size == 0: + return float("nan") + confidences = probs.max(axis=1) + predictions = probs.argmax(axis=1) + bin_edges = np.linspace(0.0, 1.0, n_bins + 1) + ece = 0.0 + n = len(y_true) + for i, (lo, hi) in enumerate(zip(bin_edges[:-1], bin_edges[1:])): + mask = (confidences >= lo) & ( + confidences <= hi if i == n_bins - 1 else confidences < hi + ) + if not mask.any(): + continue + bin_acc = float((predictions[mask] == y_true[mask]).mean()) + bin_conf = float(confidences[mask].mean()) + ece += float(mask.sum()) / n * abs(bin_conf - bin_acc) + return float(ece) + + +def compute_extended_metrics( + y_true: np.ndarray, + probs: np.ndarray, + num_classes: int, + n_bins: int = 10, + preds_override: Optional[np.ndarray] = None, +) -> dict: + nan = float("nan") + if y_true.size == 0: + return dict( + kappa=nan, mcc=nan, macro_f1=nan, + per_class_recall=np.full(num_classes, nan), ece=nan, + ) + preds = preds_override if preds_override is not None else probs.argmax(axis=1) + try: + kappa = float(cohen_kappa_score(y_true, preds)) + except Exception: + kappa = nan + try: + mcc = float(matthews_corrcoef(y_true, preds)) + except Exception: + mcc = nan + try: + macro_f1 = float(f1_score(y_true, preds, average="macro", zero_division=0)) + except Exception: + macro_f1 = nan + try: + pcr = recall_score( + y_true, preds, average=None, + labels=list(range(num_classes)), zero_division=0, + ).astype(float) + except Exception: + pcr = np.full(num_classes, nan) + ece = compute_ece(y_true, probs, n_bins=n_bins) + return dict(kappa=kappa, mcc=mcc, macro_f1=macro_f1, per_class_recall=pcr, ece=ece) + + +def tune_binary_threshold(y_true: np.ndarray, p1: np.ndarray) -> float: + if y_true.size == 0: + return 0.5 + grid = np.linspace(0.0, 1.0, 1001) + best_t, best_acc = 0.5, -1.0 + for t in grid: + pred = (p1 >= t).astype(int) + acc = float((pred == y_true).mean()) + if acc > best_acc or (acc == best_acc and abs(t - 0.5) < abs(best_t - 0.5)): + best_acc, best_t = acc, float(t) + return best_t + + +def multiclass_acc_with_bias(y_true: np.ndarray, probs: np.ndarray, bias: np.ndarray) -> float: + if y_true.size == 0: + return float("nan") + logits = np.log(np.clip(probs, 1e-8, 1.0)) + bias.reshape(1, -1) + return float((np.argmax(logits, axis=1) == y_true).mean()) + + +def tune_multiclass_bias(y_true: np.ndarray, probs: np.ndarray, *, iters: int = 2) -> np.ndarray: + if y_true.size == 0 or probs.size == 0: + return np.zeros((0,), dtype=float) + c = probs.shape[1] + bias = np.zeros((c,), dtype=float) + grid = np.linspace(-1.0, 1.0, 41) + for _ in range(iters): + for k in range(c): + best_v = bias[k] + best_acc = multiclass_acc_with_bias(y_true, probs, bias) + old = bias[k] + for v in grid: + bias[k] = float(v) + acc = multiclass_acc_with_bias(y_true, probs, bias) + if acc > best_acc or (acc == best_acc and abs(v) < abs(best_v)): + best_acc, best_v = acc, float(v) + bias[k] = best_v + if np.isnan(best_acc): + bias[k] = old + return bias + + +def _svf(vec) -> Optional[str]: + if vec is None: + return None + arr = np.asarray(vec, dtype=float) + if arr.size == 0: + return None + return "|".join(f"{float(v):.4f}" for v in arr.tolist()) + + +def _score_arrays(y_true: np.ndarray, probs: np.ndarray, num_classes: int): + """Returns (acc, auc, n).""" + if y_true.size == 0: + return float("nan"), float("nan"), 0 + acc = float((probs.argmax(1) == y_true).mean()) + try: + auc = ( + float(roc_auc_score(y_true, probs[:, 1])) + if num_classes == 2 + else float(roc_auc_score(y_true, probs, multi_class="ovr", average="macro")) + ) + except Exception: + auc = float("nan") + return acc, auc, int(len(y_true)) + + +def build_eval_transform(backbone: str): + """Deterministic eval transform matching backbone normalization.""" + key = (backbone or "").lower() + if key not in BACKBONES: + raise ValueError(f"Unsupported backbone '{backbone}'") + spec = BACKBONES[key] + 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 + return transforms.Compose( + [ + transforms.Resize(256), + transforms.CenterCrop(crop), + transforms.ToTensor(), + transforms.Normalize(mean=mean, std=std), + ] + ) + + +# --------------------------------------------------------------------------- +# Train / collect +# --------------------------------------------------------------------------- + +def train_single_epoch( + model: SingleEyeHT, + loader: DataLoader, + opt, + device, + *, + phase: str, + bcd_prob: float = 0.5, +): + return V2ModeComparisonOps.train_single_epoch( + model, loader, opt, device, phase=phase, bcd_prob=bcd_prob + ) + + +def train_bilateral_epoch( + model: BilateralHT, + loader: DataLoader, + opt, + device, + *, + phase: str, + bcd_prob: float = 0.5, +): + return V2ModeComparisonOps.train_bilateral_epoch( + model, loader, opt, device, phase=phase, bcd_prob=bcd_prob + ) + + +def collect_probs_classic( + model: SingleEyeHT, + loader: DataLoader, + device: torch.device, +) -> tuple[np.ndarray, np.ndarray]: + """ + Classic eye-level eval using the bilateral val loader. + OD and OS are treated as independent samples (both contribute to the + arrays with the same patient label). Returns (y_true [2N], probs [2N, C]). + """ + return V2ModeComparisonOps.collect_probs_classic(model, loader, device) + + +def collect_probs_ensemble( + model: SingleEyeHT, + loader: DataLoader, + device: torch.device, +) -> tuple[np.ndarray, np.ndarray]: + """ + Patient-level ensemble eval: average OD and OS softmax probabilities. + Returns (y_true [N], probs [N, C]). + """ + return V2ModeComparisonOps.collect_probs_ensemble(model, loader, device) + + +def collect_probs_single_components( + model: SingleEyeHT, + loader: DataLoader, + device: torch.device, + *, + aggregate_patient: bool, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """ + Collect fused/img/md probabilities for SingleEyeHT. + - aggregate_patient=False: eye-level (OD/OS as independent samples) + - aggregate_patient=True : patient-level (average OD/OS per head) + """ + model.eval() + y_chunks = [] + pf_chunks, pi_chunks, pm_chunks = [], [], [] + 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") + y = batch.get("label_1") + if not (torch.is_tensor(x1) and torch.is_tensor(m1) and torch.is_tensor(x2) and torch.is_tensor(m2)): + continue + y_t = V2ModeComparisonOps._to_label_tensor(y, device) + + def _per_eye_probs(x, m): + img_feats = model.img_tower(x.to(device)) + md_feats = model.md_tower(m.to(device)) + out_f, out_i, out_m = model.bridge(img_feats, md_feats) + return ( + F.softmax(out_f, dim=1), + F.softmax(out_i, dim=1), + F.softmax(out_m, dim=1), + ) + + pf_od, pi_od, pm_od = _per_eye_probs(x1, m1) + pf_os, pi_os, pm_os = _per_eye_probs(x2, m2) + + if aggregate_patient: + y_chunks.append(y_t.cpu().numpy()) + pf_chunks.append((0.5 * (pf_od + pf_os)).cpu().numpy()) + pi_chunks.append((0.5 * (pi_od + pi_os)).cpu().numpy()) + pm_chunks.append((0.5 * (pm_od + pm_os)).cpu().numpy()) + else: + y_np = y_t.cpu().numpy() + y_chunks += [y_np, y_np] + pf_chunks += [pf_od.cpu().numpy(), pf_os.cpu().numpy()] + pi_chunks += [pi_od.cpu().numpy(), pi_os.cpu().numpy()] + pm_chunks += [pm_od.cpu().numpy(), pm_os.cpu().numpy()] + + if not y_chunks: + z = np.zeros((0, 0), dtype=np.float32) + return np.array([], dtype=np.int64), z, z, z + return ( + np.concatenate(y_chunks), + np.concatenate(pf_chunks, axis=0), + np.concatenate(pi_chunks, axis=0), + np.concatenate(pm_chunks, axis=0), + ) + + +def collect_probs_bilateral( + model: BilateralHT, + loader: DataLoader, + device: torch.device, +) -> tuple[np.ndarray, np.ndarray]: + """Patient-level bilateral eval. Returns (y_true [N], probs [N, C]).""" + return V2ModeComparisonOps.collect_probs_bilateral(model, loader, device) + + +def collect_probs_bilateral_components( + model: BilateralHT, + loader: DataLoader, + device: torch.device, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Collect fused/img/md probabilities for bilateral joint-tower model.""" + model.eval() + y_chunks = [] + pf_chunks, pi_chunks, pm_chunks = [], [], [] + 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") + y = batch.get("label_1") + if not (torch.is_tensor(x1) and torch.is_tensor(m1) and torch.is_tensor(x2) and torch.is_tensor(m2)): + continue + y_t = V2ModeComparisonOps._to_label_tensor(y, device) + joint_img, joint_md = model.encode_joint( + x1.to(device), m1.to(device), x2.to(device), m2.to(device) + ) + out_f, _, _ = model.bridge(joint_img, joint_md) + out_i = model.aux_img(joint_img) + out_m = model.aux_md(joint_md) + y_chunks.append(y_t.cpu().numpy()) + pf_chunks.append(F.softmax(out_f, dim=1).cpu().numpy()) + pi_chunks.append(F.softmax(out_i, dim=1).cpu().numpy()) + pm_chunks.append(F.softmax(out_m, dim=1).cpu().numpy()) + if not y_chunks: + z = np.zeros((0, 0), dtype=np.float32) + return np.array([], dtype=np.int64), z, z, z + return ( + np.concatenate(y_chunks), + np.concatenate(pf_chunks, axis=0), + np.concatenate(pi_chunks, axis=0), + np.concatenate(pm_chunks, axis=0), + ) + + +# --------------------------------------------------------------------------- +# Tuning helpers +# --------------------------------------------------------------------------- + +def _tune_and_snap( + y: np.ndarray, + p: np.ndarray, + acc: float, + num_classes: int, + args, + n_bins: int, +) -> tuple[dict, float, Optional[np.ndarray], Optional[np.ndarray]]: + """ + Apply threshold/bias tuning and compute extended metrics. + Returns (snap_dict, tuned_auc, threshold, bias). + """ + thr = 0.5 if num_classes == 2 else float("nan") + bias = None + ext_preds = None + + if args.tune_binary_threshold and num_classes == 2 and y.size > 0: + thr = tune_binary_threshold(y, p[:, 1]) + ext_preds = (p[:, 1] >= thr).astype(int) + acc = float((ext_preds == y).mean()) + elif args.tune_multiclass_bias and num_classes > 2 and y.size > 0: + bias = tune_multiclass_bias(y, p) + logits = np.log(np.clip(p, 1e-8, 1.0)) + bias.reshape(1, -1) + ext_preds = np.argmax(logits, axis=1) + acc = float((ext_preds == y).mean()) + + ext = compute_extended_metrics(y, p, num_classes, n_bins=n_bins, preds_override=ext_preds) + _, auc, n = _score_arrays(y, p, num_classes) + + snap = dict( + auc=auc, acc=acc, n=n, + kappa=ext["kappa"], mcc=ext["mcc"], macro_f1=ext["macro_f1"], + per_class_recall=ext["per_class_recall"], ece=ext["ece"], + threshold=thr, bias=bias, + ) + return snap, auc, thr, bias + + +# --------------------------------------------------------------------------- +# Result dataclass +# --------------------------------------------------------------------------- + +def _nan() -> float: + return float("nan") + + +@dataclass +class FoldResult: + mode: str + fold: int + # Epoch where each model hit its peak val AUC + best_epoch_single: int # SingleEyeHT — selected by ensemble val AUC + best_epoch_bilat: int # BilateralHT — selected by bilateral val AUC + # Classic (eye-level eval of SingleEyeHT; n = 2 * ensemble_val_n) + classic_val_auc: float + classic_val_acc: float + classic_val_kappa: float + classic_val_mcc: float + classic_val_f1: float + classic_val_recall: Optional[str] + classic_val_ece: float + classic_val_threshold: float + classic_val_bias: Optional[str] + classic_val_n: int + # Ensemble (patient-level eval of same SingleEyeHT) + ensemble_val_auc: float + ensemble_val_acc: float + ensemble_val_kappa: float + ensemble_val_mcc: float + ensemble_val_f1: float + ensemble_val_recall: Optional[str] + ensemble_val_ece: float + ensemble_val_threshold: float + ensemble_val_bias: Optional[str] + ensemble_val_n: int + # Bilateral (BilateralHT patient-level) + bilat_val_auc: float + bilat_val_acc: float + bilat_val_kappa: float + bilat_val_mcc: float + bilat_val_f1: float + bilat_val_recall: Optional[str] + bilat_val_ece: float + bilat_val_threshold: float + bilat_val_bias: Optional[str] + bilat_val_n: int + # Training sample counts + single_train_n: int + bilat_train_n: int + + +@dataclass +class FoldArtifacts: + y_true_classic: Optional[np.ndarray] + probs_classic: Optional[np.ndarray] + y_true_ensemble: Optional[np.ndarray] + probs_ensemble: Optional[np.ndarray] + y_true_bilat: Optional[np.ndarray] + probs_bilat: Optional[np.ndarray] + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +def _f(v) -> Optional[float]: + if v is None or (isinstance(v, float) and np.isnan(v)): + return None + return round(float(v), 6) + + +def _sv(vec) -> Optional[str]: + if vec is None: + return None + return "|".join(f"{float(v):.4f}" for v in vec) + + +# --------------------------------------------------------------------------- +# Main fold runner +# --------------------------------------------------------------------------- + +def run_fold( + fold: int, + split, + mode: str, + args, + device: torch.device, + data, + num_classes: int, + profile_eye, + profile_patient, + image_preprocessor, + fold_dir: Path, + tower_mode: str, +) -> tuple[FoldResult, FoldArtifacts]: + nan = _nan() + tower_mode = "single" if tower_mode == "classic" else tower_mode + run_single = tower_mode in ("single", "ensemble") + run_bilat = tower_mode == "bilateral" + global_warmup_tower = getattr(args, "warmup_tower_epochs", None) + global_warmup_fused = getattr(args, "warmup_fused_epochs", None) + single_warmup_tower = ( + int(args.single_warmup_tower_epochs) + if getattr(args, "single_warmup_tower_epochs", None) is not None + else int(global_warmup_tower) if global_warmup_tower is not None else 2 + ) + single_warmup_fused = ( + int(args.single_warmup_fused_epochs) + 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 + ) + bilat_warmup_tower = ( + int(args.bilat_warmup_tower_epochs) + if getattr(args, "bilat_warmup_tower_epochs", None) is not None + else int(global_warmup_tower) if global_warmup_tower is not None else 4 + ) + bilat_warmup_fused = ( + int(args.bilat_warmup_fused_epochs) + 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 + ) + if not run_single: + single_warmup_tower = 0 + single_warmup_fused = 0 + if not run_bilat: + 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_bilat_epochs = (bilat_warmup_tower + bilat_warmup_fused + main_epochs) if run_bilat else 0 + total_epochs = max(total_single_epochs, total_bilat_epochs) + + # ---- samples ------------------------------------------------------------- + eye_train = filter_eye_samples( + profile_eye.build_samples(df=split.train, clinical=data) + ) + bilat_train = filter_bilateral_samples( + profile_patient.build_samples(df=split.train, clinical=data) + ) + bilat_val = filter_bilateral_samples( + profile_patient.build_samples(df=split.val, clinical=data) + ) + + if len(bilat_val) == 0: + print(f" [fold {fold+1}] WARNING: no bilateral val samples; skipping fold.", flush=True) + empty = FoldResult( + mode=mode, fold=fold, + best_epoch_single=0, best_epoch_bilat=0, + classic_val_auc=nan, classic_val_acc=nan, classic_val_kappa=nan, + classic_val_mcc=nan, classic_val_f1=nan, classic_val_recall=None, + classic_val_ece=nan, classic_val_threshold=nan, classic_val_bias=None, + classic_val_n=0, + ensemble_val_auc=nan, ensemble_val_acc=nan, ensemble_val_kappa=nan, + ensemble_val_mcc=nan, ensemble_val_f1=nan, ensemble_val_recall=None, + ensemble_val_ece=nan, ensemble_val_threshold=nan, ensemble_val_bias=None, + ensemble_val_n=0, + bilat_val_auc=nan, bilat_val_acc=nan, bilat_val_kappa=nan, + bilat_val_mcc=nan, bilat_val_f1=nan, bilat_val_recall=None, + bilat_val_ece=nan, bilat_val_threshold=nan, bilat_val_bias=None, + bilat_val_n=0, + single_train_n=len(eye_train), bilat_train_n=len(bilat_train), + ) + return empty, FoldArtifacts( + y_true_classic=None, + probs_classic=None, + y_true_ensemble=None, + probs_ensemble=None, + y_true_bilat=None, + probs_bilat=None, + ) + + # ---- models -------------------------------------------------------------- + single = None + bilateral = None + if run_single: + single = SingleEyeHT( + backbone=args.backbone, freeze_ratio=args.freeze_ratio, + augment=args.augment, clinical_data=data, + num_classes=num_classes, + md_hidden_dim=args.md_hidden_dim, fusion_dim=args.fusion_dim, + ).to(device) + if run_bilat: + bilateral = BilateralHT( + backbone=args.backbone, freeze_ratio=args.freeze_ratio, + augment=args.augment, clinical_data=data, + num_classes=num_classes, + md_hidden_dim=args.md_hidden_dim, fusion_dim=args.fusion_dim, + ).to(device) + + slots_eye = profile_eye.slot_descriptors() + slots_patient = profile_patient.slot_descriptors() + loader_kw = dict(batch_size=args.batch_size, num_workers=args.num_workers) + + # ---- loaders ------------------------------------------------------------- + train_single_loader = None + train_bilat_loader = None + if run_single: + train_single_loader = make_loader( + eye_train, slots_eye, + image_transform=single.transform, + image_preprocessor=image_preprocessor, + shuffle=True, + **loader_kw, + ) + if run_bilat: + train_bilat_loader = make_loader( + bilat_train, slots_patient, + image_transform=bilateral.transform, + image_preprocessor=image_preprocessor, + shuffle=True, + **loader_kw, + ) + eval_transform = build_eval_transform(args.backbone) + val_loader = make_loader( + bilat_val, slots_patient, + image_transform=eval_transform, + image_preprocessor=image_preprocessor, + shuffle=False, + **loader_kw, + ) + + opt_single = torch.optim.Adam(single.parameters(), lr=args.lr) if run_single else None + opt_bilateral = torch.optim.Adam(bilateral.parameters(), lr=args.lr) if run_bilat else None + + # ---- epoch log ----------------------------------------------------------- + epoch_fields = [ + "fold", "epoch", + "phase_single", "phase_bilat", + "main_epoch_single", "main_epoch_bilat", + "single_active", "bilat_active", + "single_train_loss", "single_train_acc", + "classic_val_auc", "classic_val_acc", "classic_val_n", + "ensemble_val_auc", "ensemble_val_acc", "ensemble_val_n", + "bilat_train_loss", "bilat_train_acc", + "bilat_val_auc", "bilat_val_acc", "bilat_val_n", + "is_best_single", "is_best_bilat", + ] + fold_logger = HypertowerLogger(run_dir=fold_dir) + + # ---- best-epoch trackers ------------------------------------------------- + best_single_auc = -1.0 # tracked by ensemble AUC + best_bilat_auc = -1.0 + best_epoch_single = 0 + best_epoch_bilat = 0 + best_single_state: Optional[dict] = None + best_bilat_state: Optional[dict] = None + snap_classic: dict = {} + snap_ensemble: dict = {} + snap_bilat: dict = {} + + if run_single: + 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}", + flush=True, + ) + else: + print( + f" [fold {fold+1}] bilat_train_n={len(bilat_train)} (bilateral) " + f"val_n={len(bilat_val)} " + f"bilat_warmup={bilat_warmup_tower}+{bilat_warmup_fused} total={total_bilat_epochs}", + flush=True, + ) + + # ---- epoch loop ---------------------------------------------------------- + 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: + phase_single, main_epoch_single, single_active = "tower_warmup", 0, True + elif epoch < (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, + True, + ) + else: + phase_single, main_epoch_single, single_active = "done", main_epochs, False + + if not run_bilat: + phase_bilat, main_epoch_bilat, bilat_active = "inactive", 0, False + elif epoch < bilat_warmup_tower: + phase_bilat, main_epoch_bilat, bilat_active = "tower_warmup", 0, True + elif epoch < (bilat_warmup_tower + bilat_warmup_fused): + phase_bilat, main_epoch_bilat, bilat_active = "fused_warmup", 0, True + elif epoch < total_bilat_epochs: + phase_bilat, main_epoch_bilat, bilat_active = ( + "main", + epoch - bilat_warmup_tower - bilat_warmup_fused + 1, + True, + ) + else: + phase_bilat, main_epoch_bilat, bilat_active = "done", main_epochs, False + + if run_single and single_active: + sl_loss, sl_acc = train_single_epoch( + single, + train_single_loader, + opt_single, + device, + phase=phase_single, + bcd_prob=float(args.bcd_prob), + ) + else: + sl_loss, sl_acc = nan, nan + + if run_bilat and bilat_active: + bl_loss, bl_acc = train_bilateral_epoch( + bilateral, + train_bilat_loader, + opt_bilateral, + device, + phase=phase_bilat, + bcd_prob=float(args.bcd_prob), + ) + else: + bl_loss, bl_acc = nan, nan + + if run_single and tower_mode == "single": + y_cl, p_cl, p_cl_img, p_cl_md = collect_probs_single_components( + single, val_loader, device, aggregate_patient=False + ) + cl_acc, cl_auc, cl_n = _score_arrays(y_cl, p_cl, num_classes) + cl_acc_img = float((p_cl_img.argmax(1) == y_cl).mean()) if y_cl.size else nan + cl_acc_md = float((p_cl_md.argmax(1) == y_cl).mean()) if y_cl.size else nan + _, cl_auc_img, _ = _score_arrays(y_cl, p_cl_img, num_classes) + _, cl_auc_md, _ = _score_arrays(y_cl, p_cl_md, num_classes) + y_en = np.array([], dtype=np.int64) + p_en = np.zeros((0, 0), dtype=np.float32) + 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": + y_en, p_en, p_en_img, p_en_md = collect_probs_single_components( + single, val_loader, device, aggregate_patient=True + ) + en_acc, en_auc, en_n = _score_arrays(y_en, p_en, num_classes) + en_acc_img = float((p_en_img.argmax(1) == y_en).mean()) if y_en.size else nan + en_acc_md = float((p_en_md.argmax(1) == y_en).mean()) if y_en.size else nan + _, en_auc_img, _ = _score_arrays(y_en, p_en_img, num_classes) + _, en_auc_md, _ = _score_arrays(y_en, p_en_md, num_classes) + y_cl = np.array([], dtype=np.int64) + p_cl = np.zeros((0, 0), dtype=np.float32) + cl_acc = cl_auc = nan + cl_n = 0 + cl_acc_img = cl_acc_md = cl_auc_img = cl_auc_md = nan + else: + y_cl = y_en = np.array([], dtype=np.int64) + p_cl = p_en = np.zeros((0, 0), dtype=np.float32) + cl_acc = cl_auc = en_acc = en_auc = nan + cl_n = en_n = 0 + 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: + y_bi, p_bi, p_bi_img, p_bi_md = collect_probs_bilateral_components(bilateral, val_loader, device) + bi_acc, bi_auc, bi_n = _score_arrays(y_bi, p_bi, num_classes) + bi_acc_img = float((p_bi_img.argmax(1) == y_bi).mean()) if y_bi.size else nan + bi_acc_md = float((p_bi_md.argmax(1) == y_bi).mean()) if y_bi.size else nan + _, bi_auc_img, _ = _score_arrays(y_bi, p_bi_img, num_classes) + _, bi_auc_md, _ = _score_arrays(y_bi, p_bi_md, num_classes) + else: + y_bi = np.array([], dtype=np.int64) + p_bi = np.zeros((0, 0), dtype=np.float32) + bi_acc = bi_auc = nan + bi_n = 0 + bi_acc_img = bi_acc_md = bi_auc_img = bi_auc_md = nan + + # Best-epoch checks: checkpointing is restricted to the main phase only. + target_single_auc = cl_auc if tower_mode == "single" else en_auc + single_ckpt_eligible = run_single and (phase_single == "main") + is_best_single = ( + single_ckpt_eligible + and (not np.isnan(target_single_auc)) + and (target_single_auc > best_single_auc) + ) + if is_best_single: + best_single_auc = target_single_auc + best_epoch_single = epoch + 1 + best_single_state = copy.deepcopy(single.state_dict()) + if tower_mode == "single": + snap_cl, _, _, _ = _tune_and_snap(y_cl, p_cl, cl_acc, num_classes, args, args.ece_bins) + snap_classic = snap_cl + else: + snap_en, _, _, _ = _tune_and_snap(y_en, p_en, en_acc, num_classes, args, args.ece_bins) + snap_ensemble = snap_en + + bilat_ckpt_eligible = run_bilat and (phase_bilat == "main") + is_best_bilat = ( + bilat_ckpt_eligible + and (not np.isnan(bi_auc)) + and (bi_auc > best_bilat_auc) + ) + if is_best_bilat: + best_bilat_auc = bi_auc + best_epoch_bilat = epoch + 1 + best_bilat_state = copy.deepcopy(bilateral.state_dict()) + snap_bi, _, _, _ = _tune_and_snap(y_bi, p_bi, bi_acc, num_classes, args, args.ece_bins) + snap_bilat = snap_bi + + fold_logger.write_epoch_row({ + "fold": fold, "epoch": epoch + 1, + "phase_single": phase_single, + "phase_bilat": phase_bilat, + "main_epoch_single": main_epoch_single, + "main_epoch_bilat": main_epoch_bilat, + "single_active": int(single_active), + "bilat_active": int(bilat_active), + "single_train_loss": _f(sl_loss), "single_train_acc": _f(sl_acc), + "classic_val_auc": _f(cl_auc), "classic_val_acc": _f(cl_acc), "classic_val_n": cl_n, + "ensemble_val_auc": _f(en_auc), "ensemble_val_acc": _f(en_acc), "ensemble_val_n": en_n, + "bilat_train_loss": _f(bl_loss), "bilat_train_acc": _f(bl_acc), + "bilat_val_auc": _f(bi_auc), "bilat_val_acc": _f(bi_acc), "bilat_val_n": bi_n, + "is_best_single": int(is_best_single), + "is_best_bilat": int(is_best_bilat), + }, optional_cols=epoch_fields) + + if args.log_every > 0 and (epoch + 1) % args.log_every == 0: + if run_single: + if tower_mode == "single": + msg = ( + f" ep {epoch+1:>3}/{total_epochs} " + f"[single:{phase_single} {main_epoch_single}/{main_epochs}] " + f"fused(acc={cl_acc:.4f},auc={cl_auc:.4f}) " + f"img(acc={cl_acc_img:.4f},auc={cl_auc_img:.4f}) " + f"md(acc={cl_acc_md:.4f},auc={cl_auc_md:.4f}) " + f"(best_fused={best_single_auc:.4f} @ep{best_epoch_single})" + ) + else: + msg = ( + f" ep {epoch+1:>3}/{total_epochs} " + f"[single:{phase_single} {main_epoch_single}/{main_epochs}] " + f"fused(acc={en_acc:.4f},auc={en_auc:.4f}) " + f"img(acc={en_acc_img:.4f},auc={en_auc_img:.4f}) " + f"md(acc={en_acc_md:.4f},auc={en_auc_md:.4f}) " + f"(best_fused={best_single_auc:.4f} @ep{best_epoch_single})" + ) + else: + msg = ( + f" ep {epoch+1:>3}/{total_epochs} " + f"[bilat:{phase_bilat} {main_epoch_bilat}/{main_epochs}] " + f"fused(acc={bi_acc:.4f},auc={bi_auc:.4f}) " + f"img(acc={bi_acc_img:.4f},auc={bi_auc_img:.4f}) " + f"md(acc={bi_acc_md:.4f},auc={bi_auc_md:.4f}) " + f"(best_bilat={best_bilat_auc:.4f} @ep{best_epoch_bilat})" + ) + print(msg, flush=True) + fold_logger.info(msg) + + fold_logger.close() + + if args.save_checkpoints: + if best_single_state is not None: + torch.save(best_single_state, fold_dir / "best_single.pt") + if best_bilat_state is not None: + torch.save(best_bilat_state, fold_dir / "best_bilateral.pt") + + if run_single: + if tower_mode == "single": + print( + f" [fold {fold+1}] BEST " + f"fused(acc={snap_classic.get('acc', nan):.4f},auc={snap_classic.get('auc', nan):.4f}) " + f"kappa={snap_classic.get('kappa', nan):.4f} " + f"F1={snap_classic.get('macro_f1', nan):.4f} " + f"ECE={snap_classic.get('ece', nan):.4f} @ep{best_epoch_single}", + flush=True, + ) + else: + print( + f" [fold {fold+1}] BEST " + f"ensemble(acc={snap_ensemble.get('acc', nan):.4f},auc={snap_ensemble.get('auc', nan):.4f}) " + f"kappa={snap_ensemble.get('kappa', nan):.4f} " + f"F1={snap_ensemble.get('macro_f1', nan):.4f} " + f"ECE={snap_ensemble.get('ece', nan):.4f} @ep{best_epoch_single}", + flush=True, + ) + else: + print( + f" [fold {fold+1}] BEST " + f"fused(acc={snap_bilat.get('acc', nan):.4f},auc={snap_bilat.get('auc', nan):.4f}) " + f"kappa={snap_bilat.get('kappa', nan):.4f} " + f"F1={snap_bilat.get('macro_f1', nan):.4f} " + f"ECE={snap_bilat.get('ece', nan):.4f} @ep{best_epoch_bilat}", + flush=True, + ) + + # Export best-epoch prediction artifacts for easier side-by-side analysis. + if run_single and best_single_state is not None: + single.load_state_dict(best_single_state) + if run_bilat and best_bilat_state is not None: + bilateral.load_state_dict(best_bilat_state) + if run_single and tower_mode == "single": + y_cl_best, p_cl_best = collect_probs_classic(single, val_loader, device) + y_en_best = p_en_best = None + elif run_single and tower_mode == "ensemble": + y_en_best, p_en_best = collect_probs_ensemble(single, val_loader, device) + y_cl_best = p_cl_best = None + else: + y_cl_best = y_en_best = None + p_cl_best = p_en_best = None + if run_bilat: + y_bi_best, p_bi_best = collect_probs_bilateral(bilateral, val_loader, device) + else: + y_bi_best = p_bi_best = None + + return FoldResult( + mode=mode, fold=fold, + best_epoch_single=best_epoch_single, best_epoch_bilat=best_epoch_bilat, + classic_val_auc=snap_classic.get("auc", nan), + classic_val_acc=snap_classic.get("acc", nan), + classic_val_kappa=snap_classic.get("kappa", nan), + classic_val_mcc=snap_classic.get("mcc", nan), + classic_val_f1=snap_classic.get("macro_f1", nan), + classic_val_recall=_sv(snap_classic.get("per_class_recall")), + classic_val_ece=snap_classic.get("ece", nan), + classic_val_threshold=snap_classic.get("threshold", nan), + classic_val_bias=_svf(snap_classic.get("bias")), + classic_val_n=snap_classic.get("n", 0), + ensemble_val_auc=snap_ensemble.get("auc", nan), + ensemble_val_acc=snap_ensemble.get("acc", nan), + ensemble_val_kappa=snap_ensemble.get("kappa", nan), + ensemble_val_mcc=snap_ensemble.get("mcc", nan), + ensemble_val_f1=snap_ensemble.get("macro_f1", nan), + ensemble_val_recall=_sv(snap_ensemble.get("per_class_recall")), + ensemble_val_ece=snap_ensemble.get("ece", nan), + ensemble_val_threshold=snap_ensemble.get("threshold", nan), + ensemble_val_bias=_svf(snap_ensemble.get("bias")), + ensemble_val_n=snap_ensemble.get("n", 0), + bilat_val_auc=snap_bilat.get("auc", nan), + bilat_val_acc=snap_bilat.get("acc", nan), + bilat_val_kappa=snap_bilat.get("kappa", nan), + bilat_val_mcc=snap_bilat.get("mcc", nan), + bilat_val_f1=snap_bilat.get("macro_f1", nan), + bilat_val_recall=_sv(snap_bilat.get("per_class_recall")), + bilat_val_ece=snap_bilat.get("ece", nan), + bilat_val_threshold=snap_bilat.get("threshold", nan), + bilat_val_bias=_svf(snap_bilat.get("bias")), + bilat_val_n=snap_bilat.get("n", 0), + single_train_n=len(eye_train), + bilat_train_n=len(bilat_train), + ), FoldArtifacts( + y_true_classic=y_cl_best, + probs_classic=p_cl_best, + y_true_ensemble=y_en_best, + probs_ensemble=p_en_best, + y_true_bilat=y_bi_best, + probs_bilat=p_bi_best, + ) + + +# --------------------------------------------------------------------------- +# Summary helpers +# --------------------------------------------------------------------------- + +def _summary(results: list[FoldResult]) -> dict: + def _ms(vals): + v = np.array([x for x in vals if x is not None and not np.isnan(float(x))], dtype=float) + return (float(np.mean(v)) if v.size else None, float(np.std(v)) if v.size else None) + + out = {} + for label, prefix in [ + ("classic_best_val", "classic_val"), + ("ensemble_best_val", "ensemble_val"), + ("bilat_best_val", "bilat_val"), + ]: + sub = {} + for m in ["auc", "acc", "kappa", "mcc", "f1", "ece", "threshold"]: + vals = [getattr(r, f"{prefix}_{m}") for r in results] + mean, std = _ms(vals) + sub[f"{m}_mean"] = mean + if m in ("auc", "f1", "kappa"): + sub[f"{m}_std"] = std + out[label] = sub + + # Deltas: ensemble − classic (eval strategy effect, same model) + # bilateral − ensemble (bilateral training effect) + for delta_label, prefix_a, prefix_b in [ + ("delta_ensemble_vs_classic", "classic_val", "ensemble_val"), + ("delta_bilat_vs_ensemble", "ensemble_val", "bilat_val"), + ]: + delta = {} + for m in ["auc", "f1", "kappa"]: + pairs = [ + getattr(r, f"{prefix_b}_{m}") - getattr(r, f"{prefix_a}_{m}") + for r in results + if not np.isnan(float(getattr(r, f"{prefix_a}_{m}"))) + and not np.isnan(float(getattr(r, f"{prefix_b}_{m}"))) + ] + delta[f"{m}_mean"] = float(np.mean(pairs)) if pairs else None + delta[f"{m}_std"] = float(np.std(pairs)) if pairs else None + out[delta_label] = delta + + out["n_folds_completed"] = len(results) + out["single_train_mode"] = "eye-level (all OD+OS samples)" + out["bilat_train_mode"] = "patient-level (bilateral only)" + out["eval_note"] = ( + "classic=eye-level SingleEyeHT; " + "ensemble=patient-level SingleEyeHT (OD+OS averaged); " + "bilateral=patient-level BilateralHT" + ) + return out + + +def _print_summary(mode: str, s: dict, tower_mode: str | None = None) -> None: + def f(v): + return "nan" if v is None else f"{v:.4f}" + + cv = s["classic_best_val"] + ev = s["ensemble_best_val"] + bv = s["bilat_best_val"] + d1 = s["delta_ensemble_vs_classic"] + d2 = s["delta_bilat_vs_ensemble"] + + print(f"\n=== Summary [{mode}] — best-epoch val ===") + print(f" {'':26s} {'AUC':>8} {'ACC':>8} {'Kappa':>8} {'F1-mac':>8} {'ECE':>8}") + if tower_mode == "single": + rows = [("single (eye-lvl eval)", cv)] + elif tower_mode == "ensemble": + rows = [("ensemble (pat-lvl eval)", ev)] + elif tower_mode == "bilateral": + rows = [("bilateral (bilat eval)", bv)] + else: + rows = [ + ("classic (eye-lvl eval)", cv), + ("ensemble (pat-lvl eval)", ev), + ("bilateral (bilat eval)", bv), + ] + for label, d in rows: + print( + f" {label:26s} " + f"{f(d['auc_mean']):>8} {f(d['acc_mean']):>8} " + f"{f(d['kappa_mean']):>8} {f(d['f1_mean']):>8} {f(d['ece_mean']):>8}" + ) + if tower_mode is None: + print( + f" {'Δ ensemble−classic':26s} " + f"{f(d1['auc_mean']):>8} {'':>8} " + f"{f(d1['kappa_mean']):>8} {f(d1['f1_mean']):>8}" + ) + print( + f" {'Δ bilateral−ensemble':26s} " + f"{f(d2['auc_mean']):>8} {'':>8} " + f"{f(d2['kappa_mean']):>8} {f(d2['f1_mean']):>8}" + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def build_parser() -> argparse.ArgumentParser: + ap = argparse.ArgumentParser( + description=( + "Three HyperTower modes: Classic (eye-level), Ensemble (patient-level avg), " + "Bilateral (BilateralBridge with shared towers). Pure k-fold CV." + ) + ) + ap.add_argument("--image-dir", default="Papila/FundusImages") + ap.add_argument("--clinical-dir", default="Papila/ClinicalData") + ap.add_argument("--label-col", default="Diagnosis") + ap.add_argument("--cat-cols", nargs="*", default=["Gender", "Phakic/Pseudophakic"]) + ap.add_argument("--eval-mode", choices=["binary", "multiclass"], default="multiclass") + ap.add_argument( + "--tower-mode", choices=["single", "ensemble", "bilateral", "classic"], + default="single", + help="Train/evaluate a single tower mode.", + ) + ap.add_argument("--n-splits", type=int, default=5) + ap.add_argument("--fold-seed", type=int, default=42) + ap.add_argument( + "--folds", + type=int, + default=None, + help="Optional cap on how many folds to run (default: all --n-splits).", + ) + ap.add_argument("--epochs", type=int, default=40) + ap.add_argument( + "--warmup-tower-epochs", type=int, default=None, + help="Extra tower warmup epochs (added before main epochs). Default: auto by mode.", + ) + ap.add_argument( + "--warmup-fused-epochs", type=int, default=None, + help="Extra fused warmup epochs (added before main epochs). Default: auto by mode.", + ) + ap.add_argument("--single-warmup-tower-epochs", type=int, default=None, + 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("--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, + help="Bilateral model fused warmup (overrides --warmup-fused-epochs).") + ap.add_argument("--batch-size", type=int, default=8) + ap.add_argument("--lr", type=float, default=1e-4) + ap.add_argument("--bcd-prob", type=float, default=0.5, + help="Tower-only step probability during main phase (per model).") + ap.add_argument("--backbone", default="refugelike") + ap.add_argument("--freeze-ratio", type=float, default=0.0) + ap.add_argument("--augment", action="store_true") + ap.add_argument("--num-workers", type=int, default=0) + ap.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto") + ap.add_argument("--seed", type=int, default=1234) + ap.add_argument("--run-name", default=None) + ap.add_argument("--output-root", default="analysis_data") + # Optional ROI cropping (GT manifest or UNet-generated mask crop) + ap.add_argument("--img-crop-manifest", type=str, default=None, + help="Path to crop manifest CSV for ROI cropping.") + ap.add_argument("--img-crop-gt", action="store_true", + help="Use ground-truth masks/contours from manifest for ROI crop.") + ap.add_argument("--img-crop-weights", type=str, default=None, + help="UNet weights path for ROI cropping from predicted masks.") + ap.add_argument("--img-crop-normalize", type=str, default="per_image", + choices=["per_image", "imagenet"], + help="UNet input normalization mode.") + ap.add_argument("--img-crop-threshold", type=float, default=0.5, + help="UNet mask threshold for ROI extraction.") + ap.add_argument("--img-crop-tta", action="store_true", + help="Enable flip-TTA during UNet mask inference.") + ap.add_argument("--img-crop-scale", type=float, default=2.5, + help="Disc-radius multiplier for square crop.") + ap.add_argument("--img-crop-size", type=int, default=224, + help="Output ROI size before tower transforms.") + ap.add_argument("--img-crop-cache", type=str, default="analysis_data/hypertower_crops", + help="Cache directory for cropped images and geometry sidecars.") + # Architecture + ap.add_argument("--md-hidden-dim", type=int, default=128, + help="MDTower hidden dimension.") + ap.add_argument("--fusion-dim", type=int, default=256, + help="Bridge/BilateralBridge fusion dimension.") + # Mixed patients + ap.add_argument( + "--exclude-mixed-patients", + dest="exclude_mixed_patients", action="store_true", + help="Drop patients whose two eyes have different labels before splitting.", + ) + ap.add_argument( + "--include-mixed-patients", + dest="exclude_mixed_patients", action="store_false", + ) + ap.add_argument( + "--relabel-mixed-patients-to-max", + dest="relabel_mixed_patients_to_max", + action="store_true", + help="When mixed patients are included, relabel both eyes to patient max severity.", + ) + # Backward-compatible alias; default behavior is now to keep raw labels. + ap.add_argument( + "--keep-mixed-raw-labels", + dest="relabel_mixed_patients_to_max", + action="store_false", + help=argparse.SUPPRESS, + ) + ap.set_defaults(exclude_mixed_patients=False, relabel_mixed_patients_to_max=False) + # Tuning + ap.add_argument( + "--tune-binary-threshold", action="store_true", + help="Tune per-model binary threshold on validation each epoch.", + ) + ap.add_argument( + "--tune-multiclass-bias", action="store_true", + help="Tune per-model multiclass log-prob bias on validation each epoch.", + ) + ap.add_argument("--ece-bins", type=int, default=10) + ap.add_argument("--log-every", type=int, default=1) + ap.add_argument("--save-checkpoints", action="store_true") + return ap + + +def parse_args(): + return build_parser().parse_args() + + +def choose_device(name: str) -> torch.device: + if name == "cuda": + if not torch.cuda.is_available(): + raise RuntimeError("--device cuda requested but CUDA is not available.") + return torch.device("cuda") + if name == "cpu": + return torch.device("cpu") + return torch.device("cuda" if torch.cuda.is_available() else "cpu") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def run_mode(args) -> Path: + # Defensive local import for CLI/entrypoint execution paths. + import csv + + device = choose_device(args.device) + seed_everything(args.seed) + + print(f"Device: {device}", flush=True) + print("Loading PAPILA data...", flush=True) + data = build_papila_data( + image_dir=args.image_dir, + clinical_dir=args.clinical_dir, + label_col=args.label_col, + cat_cols=list(args.cat_cols), + n_splits=args.n_splits, + random_seed=args.fold_seed, + ) + print(f"Loaded: {len(data.df)} rows feature_dim={data.feature_dim}", flush=True) + image_preprocessor = build_image_preprocessor_from_args(args) + + ts = time.strftime("%Y%m%d_%H%M%S") + run_name = args.run_name or f"hypertower_modes_{ts}" + out_dir = Path(args.output_root) / run_name + out_dir.mkdir(parents=True, exist_ok=True) + + mode = args.eval_mode + tower_mode = "single" if args.tower_mode == "classic" else args.tower_mode + df_mode = data.df.copy() + + if args.exclude_mixed_patients: + before = df_mode["Patient ID"].nunique() + df_mode, mixed = _drop_mixed_label_patients( + df_mode, patient_col="Patient ID", label_col=args.label_col + ) + print( + f"[{mode}] dropped {len(mixed)} mixed-label patients " + f"({before} → {df_mode['Patient ID'].nunique()})", + flush=True, + ) + else: + if args.relabel_mixed_patients_to_max: + before_rows = len(df_mode) + df_mode, changed_rows, still_mixed = _relabel_mixed_patients_to_max( + df_mode, patient_col="Patient ID", label_col=args.label_col + ) + print( + f"[{mode}] relabeled mixed patients to max severity " + f"(changed={changed_rows}, rows={before_rows}→{len(df_mode)}, " + f"remaining_mixed={len(still_mixed)}).", + flush=True, + ) + else: + print(f"[{mode}] keeping mixed-label patients with raw per-eye labels.", flush=True) + + if mode == "binary": + df_mode = df_mode[df_mode[args.label_col].isin([0, 1])].reset_index(drop=True) + + num_classes = 2 if mode == "binary" else int(df_mode[args.label_col].nunique()) + print( + f"\n[{mode}] num_classes={num_classes} rows={len(df_mode)} " + f"patients={df_mode['Patient ID'].nunique()}", + flush=True, + ) + + split_manager = PatientFirstSplitManager( + patient_col="Patient ID", label_col=args.label_col + ) + split_args = SimpleNamespace( + eval_mode=mode, + holdout_per_class=0, + holdout_seed=123, + n_splits=args.n_splits, + fold_seed=args.fold_seed, + ) + clinical_ns = SimpleNamespace(df=df_mode, label_col=args.label_col) + plans = split_manager.build_plans(clinical=clinical_ns, args=split_args, profile=None) + requested_folds = args.n_splits if args.folds is None else int(args.folds) + n_folds = min(requested_folds, len(plans)) + + profile_eye = build_papila_profile( + patient_col="Patient ID", label_col=args.label_col, sample_mode="eye" + ) + profile_patient = build_papila_profile( + patient_col="Patient ID", label_col=args.label_col, sample_mode="patient" + ) + + tm_dir = out_dir / mode / tower_mode + tm_dir.mkdir(parents=True, exist_ok=True) + fold_results: list[FoldResult] = [] + for fold in range(n_folds): + seed_everything(args.seed + fold * 100) + fold_dir = tm_dir / f"fold{fold}" + fold_dir.mkdir(exist_ok=True) + + print(f"\n[{mode}:{tower_mode}] fold {fold+1}/{n_folds}", flush=True) + result, artifacts = run_fold( + fold=fold, + split=plans[fold], + mode=mode, + args=args, + device=device, + data=data, + num_classes=num_classes, + profile_eye=profile_eye, + profile_patient=profile_patient, + image_preprocessor=image_preprocessor, + fold_dir=fold_dir, + tower_mode=tower_mode, + ) + fold_results.append(result) + if artifacts.y_true_ensemble is not None: + np.save(fold_dir / "y_true.npy", artifacts.y_true_ensemble) + if artifacts.probs_ensemble is not None: + np.save(fold_dir / "probs_fused.npy", artifacts.probs_ensemble) + if artifacts.probs_classic is not None: + np.save(fold_dir / "probs_classic.npy", artifacts.probs_classic) + if artifacts.probs_bilat is not None: + np.save(fold_dir / "probs_bilat.npy", artifacts.probs_bilat) + + fold_csv = tm_dir / "fold_results.csv" + csv_fields = list(FoldResult.__dataclass_fields__.keys()) + with fold_csv.open("w", newline="", encoding="utf-8") as fh: + w = csv.DictWriter(fh, fieldnames=csv_fields) + w.writeheader() + for r in fold_results: + w.writerow({k: getattr(r, k) for k in csv_fields}) + + summary = _summary(fold_results) + _print_summary(f"{mode}:{tower_mode}", summary, tower_mode=tower_mode) + metric_key = { + "single": "classic_val_auc", + "ensemble": "ensemble_val_auc", + "bilateral": "bilat_val_auc", + }[tower_mode] + fold_metrics = [] + best_vals = [] + for r in fold_results: + best_val = getattr(r, metric_key) + fold_metrics.append( + { + "fold": r.fold, + "best_metric_value": _f(best_val), + "best_epoch": (r.best_epoch_bilat if tower_mode == "bilateral" else r.best_epoch_single), + "monitor": metric_key, + } + ) + if not np.isnan(float(best_val)): + best_vals.append(float(best_val)) + + mode_summary = { + "run_id": run_name, + "backbone": args.backbone, + "epochs": args.epochs, + "warmup_tower_epochs": args.warmup_tower_epochs, + "warmup_fused_epochs": args.warmup_fused_epochs, + "single_warmup_tower_epochs": args.single_warmup_tower_epochs, + "single_warmup_fused_epochs": args.single_warmup_fused_epochs, + "bilat_warmup_tower_epochs": args.bilat_warmup_tower_epochs, + "bilat_warmup_fused_epochs": args.bilat_warmup_fused_epochs, + "batch_size": args.batch_size, + "lr": args.lr, + "eval_mode": mode, + "tower_mode": tower_mode, + "n_splits": n_folds, + "best_metric": metric_key, + "best_metric_mode": "max", + "best_metric_mean": (float(np.mean(best_vals)) if best_vals else None), + "best_metric_std": (float(np.std(best_vals)) if best_vals else None), + "fold_metrics": fold_metrics, + "mode_summary": summary, + } + (tm_dir / "summary.json").write_text(json.dumps(mode_summary, indent=2), encoding="utf-8") + + # Merge run-level summary across sequential invocations. + root_summary_path = out_dir / "summary.json" + if root_summary_path.exists(): + try: + payload = json.loads(root_summary_path.read_text(encoding="utf-8")) + except Exception: + payload = {} + else: + payload = {} + payload.setdefault("run_name", run_name) + payload.setdefault("timestamp", ts) + payload["config"] = vars(args) + payload.setdefault("summaries", {}) + payload["summaries"][f"{mode}:{tower_mode}"] = summary + root_summary_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") + + print(f"\nOutputs written to: {out_dir}") + return out_dir diff --git a/hypertower_v2_config.json b/hypertower_v2_config.json new file mode 100644 index 0000000..502b4b2 --- /dev/null +++ b/hypertower_v2_config.json @@ -0,0 +1,293 @@ +{ + "nodes": [ + { + "id": "node_1770737490980_3a7n", + "type": "data", + "label": "Papila Images", + "inputKey": "", + "outputKey": "", + "inputType": "", + "inputIndex": "", + "outputType": "image", + "source": { + "mode": "directory", + "path": "FundusImages", + "count": null + }, + "selectedPath": null, + "sourceRef": { + "importId": "import_1770737490980_00lw", + "slot": "image_dir", + "mode": "directory" + }, + "x": 160, + "y": 760 + }, + { + "id": "node_1770737490980_az3a", + "type": "data", + "label": "Papila Metadata", + "inputKey": "", + "outputKey": "", + "inputType": "", + "inputIndex": "", + "outputType": "matrix", + "source": { + "mode": "dataframe", + "name": "clinical.df", + "rows": 488, + "cols": 15 + }, + "selectedPath": null, + "sourceRef": { + "importId": "import_1770737490980_00lw", + "slot": "clinical_df", + "mode": "dataframe" + }, + "x": 340, + "y": 760 + }, + { + "id": "node_1770737490980_xnkx", + "type": "transform", + "label": "Resize", + "inputKey": "", + "outputKey": "", + "inputType": "image", + "inputIndex": "1", + "outputType": "image", + "source": null, + "selectedPath": null, + "transformType": "resize", + "roiMaskSource": "gt", + "roiScale": 2.5, + "roiTargetSize": 224, + "roiFallback": true, + "centerCropSize": 224, + "jitterHFlip": true, + "jitterVFlip": true, + "jitterRotation": 15, + "jitterColorEnabled": true, + "jitterColor": "0.1,0.1,0.1,0.05", + "resizeSize": 256, + "x": 155.29958733477324, + "y": 622.2110067583806 + }, + { + "id": "node_1770737490981_bjwe", + "type": "transform", + "label": "Center Crop", + "inputKey": "", + "outputKey": "", + "inputType": "image", + "inputIndex": "1", + "outputType": "image", + "source": null, + "selectedPath": null, + "transformType": "center_crop", + "roiMaskSource": "gt", + "roiScale": 2.5, + "roiTargetSize": 224, + "roiFallback": true, + "centerCropSize": 224, + "jitterHFlip": true, + "jitterVFlip": true, + "jitterRotation": 15, + "jitterColorEnabled": true, + "jitterColor": "0.1,0.1,0.1,0.05", + "resizeSize": 256, + "x": 139.05693245845418, + "y": 550.529525335558 + }, + { + "id": "node_1770737490981_ybod", + "type": "transform", + "label": "Jitter", + "inputKey": "", + "outputKey": "", + "inputType": "image", + "inputIndex": "1", + "outputType": "image", + "source": null, + "selectedPath": null, + "transformType": "jitter_bundle", + "roiMaskSource": "gt", + "roiScale": 2.5, + "roiTargetSize": 224, + "roiFallback": true, + "centerCropSize": 224, + "jitterHFlip": true, + "jitterVFlip": true, + "jitterRotation": 15, + "jitterColorEnabled": true, + "jitterColor": "0.1,0.1,0.1,0.05", + "resizeSize": 256, + "x": 128.68982314179192, + "y": 467.09707170591474 + }, + { + "id": "node_1770737490981_ogmq", + "type": "loader", + "label": "Image Loader", + "inputKey": "image_1", + "outputKey": "image_1", + "inputType": "image", + "inputIndex": "1", + "outputType": "image", + "source": null, + "selectedPath": null, + "x": 160, + "y": 370 + }, + { + "id": "node_1770737490981_i8qg", + "type": "loader", + "label": "Metadata Loader", + "inputKey": "matrix_1", + "outputKey": "matrix_1", + "inputType": "matrix", + "inputIndex": "1", + "outputType": "matrix", + "source": null, + "selectedPath": null, + "x": 382.30379326203655, + "y": 374.70041266522685 + }, + { + "id": "node_1770737490981_f1rl", + "type": "image_tower", + "label": "Tower 1", + "inputKey": "", + "outputKey": "", + "inputType": "image", + "inputIndex": "1", + "outputType": "image", + "source": null, + "selectedPath": null, + "towerType": "image", + "imageBackbone": "efficientnet_b0", + "imageFreezeRatio": 0, + "imageAugment": true, + "imageGeometryDim": 0, + "imageUseSe": false, + "imageSeReduction": 16, + "imageSePreNorm": true, + "x": 105.94513543739873, + "y": 210.62237129546043 + }, + { + "id": "node_1770737490982_53hy", + "type": "metadata_tower", + "label": "Tower 2", + "inputKey": "", + "outputKey": "", + "inputType": "image", + "inputIndex": "1", + "outputType": "image", + "source": null, + "selectedPath": null, + "towerType": "metadata", + "mdHiddenDim": 128, + "mdDropout": 0.1, + "mdUseSe": false, + "mdSeReduction": 16, + "mdSePreNorm": true, + "mdFreezeRatio": 0, + "x": 446.7257671413001, + "y": 189.90718376822076 + }, + { + "id": "node_1770737490982_xmc1", + "type": "bridge", + "label": "Bridge", + "inputKey": "", + "outputKey": "", + "inputType": "image", + "inputIndex": "1", + "outputType": "image", + "source": null, + "selectedPath": null, + "bridgeMethod": "fusion", + "bridgeFusionDim": 256, + "bridgeUseSe": true, + "bridgeSeReduction": 16, + "bridgeSePreNorm": true, + "x": 311.5886354629202, + "y": 9.377640626755708 + }, + { + "id": "node_1770737490982_e9g4", + "type": "classifier", + "label": "Classifier", + "inputKey": "", + "outputKey": "", + "inputType": "image", + "inputIndex": "1", + "outputType": "image", + "source": null, + "selectedPath": null, + "x": 308.0632962358768, + "y": -139.42405168449085 + } + ], + "edges": [ + { + "from": "node_1770737490980_3a7n", + "to": "node_1770737490980_xnkx" + }, + { + "from": "node_1770737490980_xnkx", + "to": "node_1770737490981_bjwe" + }, + { + "from": "node_1770737490981_bjwe", + "to": "node_1770737490981_ybod" + }, + { + "from": "node_1770737490981_ybod", + "to": "node_1770737490981_ogmq" + }, + { + "from": "node_1770737490980_az3a", + "to": "node_1770737490981_i8qg" + }, + { + "from": "node_1770737490981_ogmq", + "to": "node_1770737490981_f1rl" + }, + { + "from": "node_1770737490981_i8qg", + "to": "node_1770737490982_53hy" + }, + { + "from": "node_1770737490981_f1rl", + "to": "node_1770737490982_xmc1" + }, + { + "from": "node_1770737490982_53hy", + "to": "node_1770737490982_xmc1" + }, + { + "from": "node_1770737490982_xmc1", + "to": "node_1770737490982_e9g4" + } + ], + "meta": { + "generated_at": "2026-02-10T15:32:18.476Z", + "imports": [ + { + "id": "import_1770737490980_00lw", + "className": "PapilaData", + "params": { + "image_dir": "FundusImages", + "clinical_dir": "ClinicalData", + "label_col": "Diagnosis", + "cat_cols": [ + "Gender", + "Phakic/Pseudophakic" + ] + } + } + ] + } +} \ No newline at end of file diff --git a/manifest.csv b/manifest.csv new file mode 100644 index 0000000..f7f7c29 --- /dev/null +++ b/manifest.csv @@ -0,0 +1,2089 @@ +sample_id,dataset,image_path,annotation_disc,annotation_cup,annotation_type_disc,annotation_type_cup,split +refuge1_train_g0001,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0001.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0001.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0001.bmp,mask,mask,train +refuge1_train_g0002,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0002.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0002.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0002.bmp,mask,mask,train +refuge1_train_g0003,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0003.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0003.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0003.bmp,mask,mask,train +refuge1_train_g0004,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0004.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0004.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0004.bmp,mask,mask,train +refuge1_train_g0005,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0005.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0005.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0005.bmp,mask,mask,train +refuge1_train_g0006,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0006.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0006.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0006.bmp,mask,mask,train +refuge1_train_g0007,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0007.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0007.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0007.bmp,mask,mask,train +refuge1_train_g0008,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0008.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0008.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0008.bmp,mask,mask,train +refuge1_train_g0009,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0009.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0009.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0009.bmp,mask,mask,train +refuge1_train_g0010,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0010.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0010.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0010.bmp,mask,mask,train +refuge1_train_g0011,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0011.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0011.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0011.bmp,mask,mask,train +refuge1_train_g0012,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0012.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0012.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0012.bmp,mask,mask,train +refuge1_train_g0013,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0013.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0013.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0013.bmp,mask,mask,train +refuge1_train_g0014,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0014.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0014.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0014.bmp,mask,mask,train +refuge1_train_g0015,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0015.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0015.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0015.bmp,mask,mask,train +refuge1_train_g0016,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0016.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0016.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0016.bmp,mask,mask,train +refuge1_train_g0017,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0017.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0017.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0017.bmp,mask,mask,train +refuge1_train_g0018,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0018.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0018.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0018.bmp,mask,mask,train +refuge1_train_g0019,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0019.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0019.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0019.bmp,mask,mask,train +refuge1_train_g0020,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0020.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0020.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0020.bmp,mask,mask,train +refuge1_train_g0021,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0021.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0021.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0021.bmp,mask,mask,train +refuge1_train_g0022,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0022.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0022.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0022.bmp,mask,mask,train +refuge1_train_g0023,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0023.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0023.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0023.bmp,mask,mask,train +refuge1_train_g0024,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0024.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0024.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0024.bmp,mask,mask,train +refuge1_train_g0025,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0025.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0025.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0025.bmp,mask,mask,train +refuge1_train_g0026,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0026.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0026.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0026.bmp,mask,mask,train +refuge1_train_g0027,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0027.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0027.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0027.bmp,mask,mask,train +refuge1_train_g0028,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0028.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0028.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0028.bmp,mask,mask,train +refuge1_train_g0029,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0029.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0029.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0029.bmp,mask,mask,train +refuge1_train_g0030,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0030.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0030.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0030.bmp,mask,mask,train +refuge1_train_g0031,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0031.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0031.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0031.bmp,mask,mask,train +refuge1_train_g0032,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0032.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0032.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0032.bmp,mask,mask,train +refuge1_train_g0033,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0033.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0033.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0033.bmp,mask,mask,train +refuge1_train_g0034,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0034.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0034.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0034.bmp,mask,mask,train +refuge1_train_g0035,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0035.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0035.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0035.bmp,mask,mask,train +refuge1_train_g0036,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0036.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0036.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0036.bmp,mask,mask,train +refuge1_train_g0037,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0037.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0037.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0037.bmp,mask,mask,train +refuge1_train_g0038,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0038.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0038.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0038.bmp,mask,mask,train +refuge1_train_g0039,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0039.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0039.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0039.bmp,mask,mask,train +refuge1_train_g0040,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Glaucoma/g0040.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0040.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Glaucoma/g0040.bmp,mask,mask,train +refuge1_train_n0001,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0001.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0001.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0001.bmp,mask,mask,train +refuge1_train_n0002,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0002.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0002.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0002.bmp,mask,mask,train +refuge1_train_n0003,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0003.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0003.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0003.bmp,mask,mask,train +refuge1_train_n0004,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0004.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0004.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0004.bmp,mask,mask,train +refuge1_train_n0005,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0005.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0005.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0005.bmp,mask,mask,train +refuge1_train_n0006,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0006.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0006.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0006.bmp,mask,mask,train +refuge1_train_n0007,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0007.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0007.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0007.bmp,mask,mask,train +refuge1_train_n0008,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0008.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0008.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0008.bmp,mask,mask,train +refuge1_train_n0009,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0009.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0009.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0009.bmp,mask,mask,train +refuge1_train_n0010,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0010.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0010.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0010.bmp,mask,mask,train +refuge1_train_n0011,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0011.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0011.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0011.bmp,mask,mask,train +refuge1_train_n0012,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0012.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0012.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0012.bmp,mask,mask,train +refuge1_train_n0013,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0013.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0013.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0013.bmp,mask,mask,train +refuge1_train_n0014,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0014.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0014.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0014.bmp,mask,mask,train +refuge1_train_n0015,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0015.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0015.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0015.bmp,mask,mask,train +refuge1_train_n0016,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0016.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0016.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0016.bmp,mask,mask,train +refuge1_train_n0017,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0017.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0017.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0017.bmp,mask,mask,train +refuge1_train_n0018,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0018.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0018.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0018.bmp,mask,mask,train +refuge1_train_n0019,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0019.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0019.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0019.bmp,mask,mask,train +refuge1_train_n0020,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0020.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0020.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0020.bmp,mask,mask,train +refuge1_train_n0021,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0021.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0021.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0021.bmp,mask,mask,train +refuge1_train_n0022,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0022.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0022.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0022.bmp,mask,mask,train +refuge1_train_n0023,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0023.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0023.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0023.bmp,mask,mask,train +refuge1_train_n0024,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0024.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0024.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0024.bmp,mask,mask,train +refuge1_train_n0025,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0025.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0025.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0025.bmp,mask,mask,train +refuge1_train_n0026,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0026.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0026.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0026.bmp,mask,mask,train +refuge1_train_n0027,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0027.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0027.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0027.bmp,mask,mask,train +refuge1_train_n0028,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0028.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0028.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0028.bmp,mask,mask,train +refuge1_train_n0029,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0029.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0029.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0029.bmp,mask,mask,train +refuge1_train_n0030,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0030.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0030.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0030.bmp,mask,mask,train +refuge1_train_n0031,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0031.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0031.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0031.bmp,mask,mask,train +refuge1_train_n0032,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0032.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0032.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0032.bmp,mask,mask,train +refuge1_train_n0033,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0033.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0033.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0033.bmp,mask,mask,train +refuge1_train_n0034,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0034.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0034.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0034.bmp,mask,mask,train +refuge1_train_n0035,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0035.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0035.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0035.bmp,mask,mask,train +refuge1_train_n0036,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0036.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0036.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0036.bmp,mask,mask,train +refuge1_train_n0037,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0037.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0037.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0037.bmp,mask,mask,train +refuge1_train_n0038,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0038.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0038.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0038.bmp,mask,mask,train +refuge1_train_n0039,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0039.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0039.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0039.bmp,mask,mask,train +refuge1_train_n0040,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0040.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0040.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0040.bmp,mask,mask,train +refuge1_train_n0041,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0041.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0041.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0041.bmp,mask,mask,train +refuge1_train_n0042,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0042.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0042.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0042.bmp,mask,mask,train +refuge1_train_n0043,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0043.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0043.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0043.bmp,mask,mask,train +refuge1_train_n0044,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0044.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0044.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0044.bmp,mask,mask,train +refuge1_train_n0045,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0045.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0045.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0045.bmp,mask,mask,train +refuge1_train_n0046,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0046.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0046.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0046.bmp,mask,mask,train +refuge1_train_n0047,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0047.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0047.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0047.bmp,mask,mask,train +refuge1_train_n0048,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0048.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0048.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0048.bmp,mask,mask,train +refuge1_train_n0049,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0049.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0049.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0049.bmp,mask,mask,train +refuge1_train_n0050,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0050.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0050.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0050.bmp,mask,mask,train +refuge1_train_n0051,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0051.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0051.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0051.bmp,mask,mask,train +refuge1_train_n0052,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0052.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0052.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0052.bmp,mask,mask,train +refuge1_train_n0053,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0053.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0053.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0053.bmp,mask,mask,train +refuge1_train_n0054,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0054.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0054.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0054.bmp,mask,mask,train +refuge1_train_n0055,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0055.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0055.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0055.bmp,mask,mask,train +refuge1_train_n0056,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0056.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0056.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0056.bmp,mask,mask,train +refuge1_train_n0057,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0057.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0057.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0057.bmp,mask,mask,train +refuge1_train_n0058,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0058.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0058.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0058.bmp,mask,mask,train +refuge1_train_n0059,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0059.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0059.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0059.bmp,mask,mask,train +refuge1_train_n0060,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0060.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0060.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0060.bmp,mask,mask,train +refuge1_train_n0061,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0061.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0061.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0061.bmp,mask,mask,train +refuge1_train_n0062,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0062.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0062.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0062.bmp,mask,mask,train +refuge1_train_n0063,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0063.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0063.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0063.bmp,mask,mask,train +refuge1_train_n0064,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0064.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0064.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0064.bmp,mask,mask,train +refuge1_train_n0065,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0065.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0065.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0065.bmp,mask,mask,train +refuge1_train_n0066,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0066.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0066.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0066.bmp,mask,mask,train +refuge1_train_n0067,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0067.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0067.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0067.bmp,mask,mask,train +refuge1_train_n0068,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0068.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0068.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0068.bmp,mask,mask,train +refuge1_train_n0069,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0069.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0069.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0069.bmp,mask,mask,train +refuge1_train_n0070,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0070.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0070.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0070.bmp,mask,mask,train +refuge1_train_n0071,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0071.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0071.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0071.bmp,mask,mask,train +refuge1_train_n0072,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0072.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0072.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0072.bmp,mask,mask,train +refuge1_train_n0073,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0073.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0073.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0073.bmp,mask,mask,train +refuge1_train_n0074,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0074.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0074.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0074.bmp,mask,mask,train +refuge1_train_n0075,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0075.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0075.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0075.bmp,mask,mask,train +refuge1_train_n0076,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0076.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0076.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0076.bmp,mask,mask,train +refuge1_train_n0077,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0077.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0077.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0077.bmp,mask,mask,train +refuge1_train_n0078,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0078.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0078.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0078.bmp,mask,mask,train +refuge1_train_n0079,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0079.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0079.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0079.bmp,mask,mask,train +refuge1_train_n0080,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0080.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0080.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0080.bmp,mask,mask,train +refuge1_train_n0081,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0081.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0081.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0081.bmp,mask,mask,train +refuge1_train_n0082,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0082.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0082.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0082.bmp,mask,mask,train +refuge1_train_n0083,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0083.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0083.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0083.bmp,mask,mask,train +refuge1_train_n0084,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0084.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0084.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0084.bmp,mask,mask,train +refuge1_train_n0085,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0085.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0085.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0085.bmp,mask,mask,train +refuge1_train_n0086,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0086.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0086.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0086.bmp,mask,mask,train +refuge1_train_n0087,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0087.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0087.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0087.bmp,mask,mask,train +refuge1_train_n0088,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0088.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0088.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0088.bmp,mask,mask,train +refuge1_train_n0089,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0089.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0089.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0089.bmp,mask,mask,train +refuge1_train_n0090,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0090.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0090.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0090.bmp,mask,mask,train +refuge1_train_n0091,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0091.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0091.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0091.bmp,mask,mask,train +refuge1_train_n0092,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0092.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0092.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0092.bmp,mask,mask,train +refuge1_train_n0093,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0093.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0093.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0093.bmp,mask,mask,train +refuge1_train_n0094,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0094.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0094.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0094.bmp,mask,mask,train +refuge1_train_n0095,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0095.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0095.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0095.bmp,mask,mask,train +refuge1_train_n0096,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0096.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0096.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0096.bmp,mask,mask,train +refuge1_train_n0097,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0097.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0097.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0097.bmp,mask,mask,train +refuge1_train_n0098,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0098.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0098.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0098.bmp,mask,mask,train +refuge1_train_n0099,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0099.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0099.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0099.bmp,mask,mask,train +refuge1_train_n0100,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0100.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0100.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0100.bmp,mask,mask,train +refuge1_train_n0101,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0101.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0101.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0101.bmp,mask,mask,train +refuge1_train_n0102,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0102.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0102.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0102.bmp,mask,mask,train +refuge1_train_n0103,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0103.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0103.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0103.bmp,mask,mask,train +refuge1_train_n0104,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0104.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0104.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0104.bmp,mask,mask,train +refuge1_train_n0105,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0105.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0105.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0105.bmp,mask,mask,train +refuge1_train_n0106,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0106.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0106.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0106.bmp,mask,mask,train +refuge1_train_n0107,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0107.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0107.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0107.bmp,mask,mask,train +refuge1_train_n0108,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0108.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0108.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0108.bmp,mask,mask,train +refuge1_train_n0109,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0109.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0109.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0109.bmp,mask,mask,train +refuge1_train_n0110,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0110.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0110.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0110.bmp,mask,mask,train +refuge1_train_n0111,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0111.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0111.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0111.bmp,mask,mask,train +refuge1_train_n0112,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0112.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0112.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0112.bmp,mask,mask,train +refuge1_train_n0113,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0113.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0113.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0113.bmp,mask,mask,train +refuge1_train_n0114,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0114.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0114.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0114.bmp,mask,mask,train +refuge1_train_n0115,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0115.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0115.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0115.bmp,mask,mask,train +refuge1_train_n0116,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0116.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0116.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0116.bmp,mask,mask,train +refuge1_train_n0117,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0117.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0117.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0117.bmp,mask,mask,train +refuge1_train_n0118,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0118.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0118.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0118.bmp,mask,mask,train +refuge1_train_n0119,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0119.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0119.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0119.bmp,mask,mask,train +refuge1_train_n0120,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0120.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0120.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0120.bmp,mask,mask,train +refuge1_train_n0121,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0121.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0121.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0121.bmp,mask,mask,train +refuge1_train_n0122,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0122.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0122.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0122.bmp,mask,mask,train +refuge1_train_n0123,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0123.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0123.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0123.bmp,mask,mask,train +refuge1_train_n0124,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0124.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0124.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0124.bmp,mask,mask,train +refuge1_train_n0125,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0125.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0125.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0125.bmp,mask,mask,train +refuge1_train_n0126,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0126.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0126.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0126.bmp,mask,mask,train +refuge1_train_n0127,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0127.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0127.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0127.bmp,mask,mask,train +refuge1_train_n0128,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0128.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0128.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0128.bmp,mask,mask,train +refuge1_train_n0129,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0129.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0129.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0129.bmp,mask,mask,train +refuge1_train_n0130,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0130.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0130.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0130.bmp,mask,mask,train +refuge1_train_n0131,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0131.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0131.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0131.bmp,mask,mask,train +refuge1_train_n0132,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0132.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0132.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0132.bmp,mask,mask,train +refuge1_train_n0133,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0133.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0133.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0133.bmp,mask,mask,train +refuge1_train_n0134,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0134.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0134.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0134.bmp,mask,mask,train +refuge1_train_n0135,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0135.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0135.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0135.bmp,mask,mask,train +refuge1_train_n0136,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0136.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0136.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0136.bmp,mask,mask,train +refuge1_train_n0137,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0137.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0137.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0137.bmp,mask,mask,train +refuge1_train_n0138,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0138.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0138.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0138.bmp,mask,mask,train +refuge1_train_n0139,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0139.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0139.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0139.bmp,mask,mask,train +refuge1_train_n0140,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0140.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0140.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0140.bmp,mask,mask,train +refuge1_train_n0141,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0141.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0141.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0141.bmp,mask,mask,train +refuge1_train_n0142,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0142.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0142.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0142.bmp,mask,mask,train +refuge1_train_n0143,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0143.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0143.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0143.bmp,mask,mask,train +refuge1_train_n0144,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0144.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0144.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0144.bmp,mask,mask,train +refuge1_train_n0145,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0145.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0145.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0145.bmp,mask,mask,train +refuge1_train_n0146,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0146.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0146.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0146.bmp,mask,mask,train +refuge1_train_n0147,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0147.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0147.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0147.bmp,mask,mask,train +refuge1_train_n0148,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0148.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0148.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0148.bmp,mask,mask,train +refuge1_train_n0149,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0149.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0149.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0149.bmp,mask,mask,train +refuge1_train_n0150,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0150.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0150.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0150.bmp,mask,mask,train +refuge1_train_n0151,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0151.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0151.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0151.bmp,mask,mask,train +refuge1_train_n0152,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0152.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0152.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0152.bmp,mask,mask,train +refuge1_train_n0153,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0153.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0153.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0153.bmp,mask,mask,train +refuge1_train_n0154,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0154.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0154.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0154.bmp,mask,mask,train +refuge1_train_n0155,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0155.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0155.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0155.bmp,mask,mask,train +refuge1_train_n0156,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0156.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0156.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0156.bmp,mask,mask,train +refuge1_train_n0157,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0157.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0157.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0157.bmp,mask,mask,train +refuge1_train_n0158,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0158.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0158.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0158.bmp,mask,mask,train +refuge1_train_n0159,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0159.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0159.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0159.bmp,mask,mask,train +refuge1_train_n0160,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0160.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0160.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0160.bmp,mask,mask,train +refuge1_train_n0161,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0161.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0161.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0161.bmp,mask,mask,train +refuge1_train_n0162,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0162.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0162.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0162.bmp,mask,mask,train +refuge1_train_n0163,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0163.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0163.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0163.bmp,mask,mask,train +refuge1_train_n0164,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0164.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0164.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0164.bmp,mask,mask,train +refuge1_train_n0165,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0165.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0165.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0165.bmp,mask,mask,train +refuge1_train_n0166,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0166.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0166.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0166.bmp,mask,mask,train +refuge1_train_n0167,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0167.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0167.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0167.bmp,mask,mask,train +refuge1_train_n0168,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0168.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0168.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0168.bmp,mask,mask,train +refuge1_train_n0169,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0169.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0169.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0169.bmp,mask,mask,train +refuge1_train_n0170,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0170.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0170.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0170.bmp,mask,mask,train +refuge1_train_n0171,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0171.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0171.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0171.bmp,mask,mask,train +refuge1_train_n0172,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0172.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0172.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0172.bmp,mask,mask,train +refuge1_train_n0173,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0173.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0173.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0173.bmp,mask,mask,train +refuge1_train_n0174,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0174.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0174.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0174.bmp,mask,mask,train +refuge1_train_n0175,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0175.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0175.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0175.bmp,mask,mask,train +refuge1_train_n0176,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0176.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0176.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0176.bmp,mask,mask,train +refuge1_train_n0177,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0177.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0177.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0177.bmp,mask,mask,train +refuge1_train_n0178,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0178.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0178.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0178.bmp,mask,mask,train +refuge1_train_n0179,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0179.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0179.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0179.bmp,mask,mask,train +refuge1_train_n0180,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0180.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0180.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0180.bmp,mask,mask,train +refuge1_train_n0181,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0181.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0181.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0181.bmp,mask,mask,train +refuge1_train_n0182,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0182.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0182.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0182.bmp,mask,mask,train +refuge1_train_n0183,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0183.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0183.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0183.bmp,mask,mask,train +refuge1_train_n0184,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0184.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0184.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0184.bmp,mask,mask,train +refuge1_train_n0185,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0185.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0185.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0185.bmp,mask,mask,train +refuge1_train_n0186,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0186.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0186.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0186.bmp,mask,mask,train +refuge1_train_n0187,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0187.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0187.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0187.bmp,mask,mask,train +refuge1_train_n0188,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0188.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0188.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0188.bmp,mask,mask,train +refuge1_train_n0189,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0189.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0189.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0189.bmp,mask,mask,train +refuge1_train_n0190,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0190.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0190.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0190.bmp,mask,mask,train +refuge1_train_n0191,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0191.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0191.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0191.bmp,mask,mask,train +refuge1_train_n0192,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0192.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0192.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0192.bmp,mask,mask,train +refuge1_train_n0193,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0193.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0193.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0193.bmp,mask,mask,train +refuge1_train_n0194,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0194.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0194.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0194.bmp,mask,mask,train +refuge1_train_n0195,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0195.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0195.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0195.bmp,mask,mask,train +refuge1_train_n0196,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0196.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0196.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0196.bmp,mask,mask,train +refuge1_train_n0197,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0197.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0197.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0197.bmp,mask,mask,train +refuge1_train_n0198,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0198.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0198.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0198.bmp,mask,mask,train +refuge1_train_n0199,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0199.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0199.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0199.bmp,mask,mask,train +refuge1_train_n0200,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0200.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0200.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0200.bmp,mask,mask,train +refuge1_train_n0201,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0201.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0201.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0201.bmp,mask,mask,train +refuge1_train_n0202,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0202.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0202.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0202.bmp,mask,mask,train +refuge1_train_n0203,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0203.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0203.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0203.bmp,mask,mask,train +refuge1_train_n0204,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0204.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0204.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0204.bmp,mask,mask,train +refuge1_train_n0205,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0205.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0205.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0205.bmp,mask,mask,train +refuge1_train_n0206,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0206.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0206.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0206.bmp,mask,mask,train +refuge1_train_n0207,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0207.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0207.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0207.bmp,mask,mask,train +refuge1_train_n0208,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0208.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0208.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0208.bmp,mask,mask,train +refuge1_train_n0209,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0209.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0209.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0209.bmp,mask,mask,train +refuge1_train_n0210,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0210.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0210.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0210.bmp,mask,mask,train +refuge1_train_n0211,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0211.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0211.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0211.bmp,mask,mask,train +refuge1_train_n0212,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0212.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0212.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0212.bmp,mask,mask,train +refuge1_train_n0213,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0213.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0213.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0213.bmp,mask,mask,train +refuge1_train_n0214,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0214.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0214.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0214.bmp,mask,mask,train +refuge1_train_n0215,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0215.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0215.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0215.bmp,mask,mask,train +refuge1_train_n0216,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0216.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0216.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0216.bmp,mask,mask,train +refuge1_train_n0217,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0217.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0217.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0217.bmp,mask,mask,train +refuge1_train_n0218,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0218.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0218.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0218.bmp,mask,mask,train +refuge1_train_n0219,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0219.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0219.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0219.bmp,mask,mask,train +refuge1_train_n0220,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0220.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0220.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0220.bmp,mask,mask,train +refuge1_train_n0221,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0221.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0221.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0221.bmp,mask,mask,train +refuge1_train_n0222,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0222.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0222.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0222.bmp,mask,mask,train +refuge1_train_n0223,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0223.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0223.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0223.bmp,mask,mask,train +refuge1_train_n0224,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0224.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0224.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0224.bmp,mask,mask,train +refuge1_train_n0225,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0225.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0225.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0225.bmp,mask,mask,train +refuge1_train_n0226,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0226.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0226.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0226.bmp,mask,mask,train +refuge1_train_n0227,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0227.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0227.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0227.bmp,mask,mask,train +refuge1_train_n0228,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0228.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0228.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0228.bmp,mask,mask,train +refuge1_train_n0229,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0229.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0229.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0229.bmp,mask,mask,train +refuge1_train_n0230,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0230.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0230.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0230.bmp,mask,mask,train +refuge1_train_n0231,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0231.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0231.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0231.bmp,mask,mask,train +refuge1_train_n0232,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0232.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0232.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0232.bmp,mask,mask,train +refuge1_train_n0233,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0233.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0233.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0233.bmp,mask,mask,train +refuge1_train_n0234,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0234.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0234.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0234.bmp,mask,mask,train +refuge1_train_n0235,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0235.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0235.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0235.bmp,mask,mask,train +refuge1_train_n0236,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0236.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0236.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0236.bmp,mask,mask,train +refuge1_train_n0237,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0237.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0237.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0237.bmp,mask,mask,train +refuge1_train_n0238,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0238.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0238.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0238.bmp,mask,mask,train +refuge1_train_n0239,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0239.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0239.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0239.bmp,mask,mask,train +refuge1_train_n0240,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0240.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0240.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0240.bmp,mask,mask,train +refuge1_train_n0241,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0241.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0241.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0241.bmp,mask,mask,train +refuge1_train_n0242,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0242.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0242.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0242.bmp,mask,mask,train +refuge1_train_n0243,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0243.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0243.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0243.bmp,mask,mask,train +refuge1_train_n0244,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0244.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0244.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0244.bmp,mask,mask,train +refuge1_train_n0245,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0245.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0245.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0245.bmp,mask,mask,train +refuge1_train_n0246,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0246.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0246.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0246.bmp,mask,mask,train +refuge1_train_n0247,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0247.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0247.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0247.bmp,mask,mask,train +refuge1_train_n0248,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0248.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0248.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0248.bmp,mask,mask,train +refuge1_train_n0249,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0249.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0249.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0249.bmp,mask,mask,train +refuge1_train_n0250,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0250.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0250.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0250.bmp,mask,mask,train +refuge1_train_n0251,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0251.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0251.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0251.bmp,mask,mask,train +refuge1_train_n0252,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0252.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0252.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0252.bmp,mask,mask,train +refuge1_train_n0253,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0253.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0253.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0253.bmp,mask,mask,train +refuge1_train_n0254,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0254.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0254.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0254.bmp,mask,mask,train +refuge1_train_n0255,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0255.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0255.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0255.bmp,mask,mask,train +refuge1_train_n0256,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0256.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0256.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0256.bmp,mask,mask,train +refuge1_train_n0257,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0257.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0257.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0257.bmp,mask,mask,train +refuge1_train_n0258,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0258.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0258.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0258.bmp,mask,mask,train +refuge1_train_n0259,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0259.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0259.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0259.bmp,mask,mask,train +refuge1_train_n0260,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0260.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0260.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0260.bmp,mask,mask,train +refuge1_train_n0261,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0261.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0261.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0261.bmp,mask,mask,train +refuge1_train_n0262,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0262.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0262.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0262.bmp,mask,mask,train +refuge1_train_n0263,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0263.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0263.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0263.bmp,mask,mask,train +refuge1_train_n0264,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0264.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0264.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0264.bmp,mask,mask,train +refuge1_train_n0265,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0265.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0265.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0265.bmp,mask,mask,train +refuge1_train_n0266,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0266.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0266.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0266.bmp,mask,mask,train +refuge1_train_n0267,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0267.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0267.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0267.bmp,mask,mask,train +refuge1_train_n0268,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0268.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0268.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0268.bmp,mask,mask,train +refuge1_train_n0269,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0269.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0269.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0269.bmp,mask,mask,train +refuge1_train_n0270,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0270.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0270.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0270.bmp,mask,mask,train +refuge1_train_n0271,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0271.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0271.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0271.bmp,mask,mask,train +refuge1_train_n0272,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0272.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0272.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0272.bmp,mask,mask,train +refuge1_train_n0273,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0273.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0273.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0273.bmp,mask,mask,train +refuge1_train_n0274,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0274.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0274.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0274.bmp,mask,mask,train +refuge1_train_n0275,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0275.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0275.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0275.bmp,mask,mask,train +refuge1_train_n0276,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0276.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0276.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0276.bmp,mask,mask,train +refuge1_train_n0277,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0277.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0277.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0277.bmp,mask,mask,train +refuge1_train_n0278,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0278.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0278.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0278.bmp,mask,mask,train +refuge1_train_n0279,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0279.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0279.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0279.bmp,mask,mask,train +refuge1_train_n0280,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0280.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0280.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0280.bmp,mask,mask,train +refuge1_train_n0281,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0281.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0281.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0281.bmp,mask,mask,train +refuge1_train_n0282,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0282.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0282.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0282.bmp,mask,mask,train +refuge1_train_n0283,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0283.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0283.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0283.bmp,mask,mask,train +refuge1_train_n0284,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0284.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0284.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0284.bmp,mask,mask,train +refuge1_train_n0285,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0285.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0285.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0285.bmp,mask,mask,train +refuge1_train_n0286,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0286.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0286.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0286.bmp,mask,mask,train +refuge1_train_n0287,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0287.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0287.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0287.bmp,mask,mask,train +refuge1_train_n0288,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0288.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0288.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0288.bmp,mask,mask,train +refuge1_train_n0289,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0289.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0289.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0289.bmp,mask,mask,train +refuge1_train_n0290,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0290.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0290.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0290.bmp,mask,mask,train +refuge1_train_n0291,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0291.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0291.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0291.bmp,mask,mask,train +refuge1_train_n0292,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0292.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0292.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0292.bmp,mask,mask,train +refuge1_train_n0293,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0293.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0293.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0293.bmp,mask,mask,train +refuge1_train_n0294,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0294.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0294.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0294.bmp,mask,mask,train +refuge1_train_n0295,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0295.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0295.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0295.bmp,mask,mask,train +refuge1_train_n0296,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0296.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0296.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0296.bmp,mask,mask,train +refuge1_train_n0297,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0297.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0297.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0297.bmp,mask,mask,train +refuge1_train_n0298,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0298.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0298.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0298.bmp,mask,mask,train +refuge1_train_n0299,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0299.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0299.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0299.bmp,mask,mask,train +refuge1_train_n0300,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0300.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0300.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0300.bmp,mask,mask,train +refuge1_train_n0301,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0301.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0301.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0301.bmp,mask,mask,train +refuge1_train_n0302,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0302.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0302.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0302.bmp,mask,mask,train +refuge1_train_n0303,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0303.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0303.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0303.bmp,mask,mask,train +refuge1_train_n0304,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0304.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0304.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0304.bmp,mask,mask,train +refuge1_train_n0305,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0305.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0305.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0305.bmp,mask,mask,train +refuge1_train_n0306,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0306.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0306.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0306.bmp,mask,mask,train +refuge1_train_n0307,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0307.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0307.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0307.bmp,mask,mask,train +refuge1_train_n0308,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0308.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0308.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0308.bmp,mask,mask,train +refuge1_train_n0309,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0309.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0309.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0309.bmp,mask,mask,train +refuge1_train_n0310,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0310.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0310.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0310.bmp,mask,mask,train +refuge1_train_n0311,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0311.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0311.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0311.bmp,mask,mask,train +refuge1_train_n0312,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0312.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0312.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0312.bmp,mask,mask,train +refuge1_train_n0313,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0313.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0313.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0313.bmp,mask,mask,train +refuge1_train_n0314,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0314.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0314.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0314.bmp,mask,mask,train +refuge1_train_n0315,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0315.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0315.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0315.bmp,mask,mask,train +refuge1_train_n0316,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0316.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0316.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0316.bmp,mask,mask,train +refuge1_train_n0317,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0317.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0317.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0317.bmp,mask,mask,train +refuge1_train_n0318,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0318.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0318.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0318.bmp,mask,mask,train +refuge1_train_n0319,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0319.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0319.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0319.bmp,mask,mask,train +refuge1_train_n0320,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0320.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0320.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0320.bmp,mask,mask,train +refuge1_train_n0321,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0321.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0321.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0321.bmp,mask,mask,train +refuge1_train_n0322,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0322.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0322.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0322.bmp,mask,mask,train +refuge1_train_n0323,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0323.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0323.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0323.bmp,mask,mask,train +refuge1_train_n0324,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0324.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0324.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0324.bmp,mask,mask,train +refuge1_train_n0325,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0325.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0325.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0325.bmp,mask,mask,train +refuge1_train_n0326,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0326.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0326.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0326.bmp,mask,mask,train +refuge1_train_n0327,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0327.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0327.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0327.bmp,mask,mask,train +refuge1_train_n0328,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0328.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0328.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0328.bmp,mask,mask,train +refuge1_train_n0329,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0329.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0329.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0329.bmp,mask,mask,train +refuge1_train_n0330,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0330.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0330.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0330.bmp,mask,mask,train +refuge1_train_n0331,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0331.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0331.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0331.bmp,mask,mask,train +refuge1_train_n0332,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0332.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0332.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0332.bmp,mask,mask,train +refuge1_train_n0333,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0333.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0333.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0333.bmp,mask,mask,train +refuge1_train_n0334,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0334.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0334.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0334.bmp,mask,mask,train +refuge1_train_n0335,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0335.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0335.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0335.bmp,mask,mask,train +refuge1_train_n0336,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0336.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0336.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0336.bmp,mask,mask,train +refuge1_train_n0337,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0337.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0337.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0337.bmp,mask,mask,train +refuge1_train_n0338,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0338.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0338.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0338.bmp,mask,mask,train +refuge1_train_n0339,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0339.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0339.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0339.bmp,mask,mask,train +refuge1_train_n0340,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0340.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0340.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0340.bmp,mask,mask,train +refuge1_train_n0341,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0341.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0341.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0341.bmp,mask,mask,train +refuge1_train_n0342,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0342.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0342.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0342.bmp,mask,mask,train +refuge1_train_n0343,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0343.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0343.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0343.bmp,mask,mask,train +refuge1_train_n0344,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0344.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0344.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0344.bmp,mask,mask,train +refuge1_train_n0345,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0345.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0345.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0345.bmp,mask,mask,train +refuge1_train_n0346,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0346.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0346.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0346.bmp,mask,mask,train +refuge1_train_n0347,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0347.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0347.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0347.bmp,mask,mask,train +refuge1_train_n0348,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0348.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0348.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0348.bmp,mask,mask,train +refuge1_train_n0349,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0349.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0349.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0349.bmp,mask,mask,train +refuge1_train_n0350,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0350.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0350.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0350.bmp,mask,mask,train +refuge1_train_n0351,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0351.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0351.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0351.bmp,mask,mask,train +refuge1_train_n0352,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0352.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0352.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0352.bmp,mask,mask,train +refuge1_train_n0353,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0353.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0353.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0353.bmp,mask,mask,train +refuge1_train_n0354,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0354.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0354.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0354.bmp,mask,mask,train +refuge1_train_n0355,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0355.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0355.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0355.bmp,mask,mask,train +refuge1_train_n0356,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0356.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0356.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0356.bmp,mask,mask,train +refuge1_train_n0357,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0357.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0357.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0357.bmp,mask,mask,train +refuge1_train_n0358,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0358.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0358.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0358.bmp,mask,mask,train +refuge1_train_n0359,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0359.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0359.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0359.bmp,mask,mask,train +refuge1_train_n0360,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Training400/Non-Glaucoma/n0360.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0360.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-train/Disc_Cup_Masks/Non-Glaucoma/n0360.bmp,mask,mask,train +refuge1_val_V0001,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0001.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0001.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0001.bmp,mask,mask,val +refuge1_val_V0002,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0002.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0002.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0002.bmp,mask,mask,val +refuge1_val_V0003,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0003.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0003.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0003.bmp,mask,mask,val +refuge1_val_V0004,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0004.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0004.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0004.bmp,mask,mask,val +refuge1_val_V0005,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0005.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0005.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0005.bmp,mask,mask,val +refuge1_val_V0006,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0006.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0006.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0006.bmp,mask,mask,val +refuge1_val_V0007,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0007.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0007.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0007.bmp,mask,mask,val +refuge1_val_V0008,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0008.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0008.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0008.bmp,mask,mask,val +refuge1_val_V0009,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0009.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0009.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0009.bmp,mask,mask,val +refuge1_val_V0010,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0010.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0010.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0010.bmp,mask,mask,val +refuge1_val_V0011,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0011.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0011.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0011.bmp,mask,mask,val +refuge1_val_V0012,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0012.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0012.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0012.bmp,mask,mask,val +refuge1_val_V0013,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0013.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0013.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0013.bmp,mask,mask,val +refuge1_val_V0014,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0014.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0014.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0014.bmp,mask,mask,val +refuge1_val_V0015,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0015.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0015.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0015.bmp,mask,mask,val +refuge1_val_V0016,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0016.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0016.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0016.bmp,mask,mask,val +refuge1_val_V0017,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0017.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0017.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0017.bmp,mask,mask,val +refuge1_val_V0018,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0018.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0018.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0018.bmp,mask,mask,val +refuge1_val_V0019,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0019.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0019.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0019.bmp,mask,mask,val +refuge1_val_V0020,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0020.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0020.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0020.bmp,mask,mask,val +refuge1_val_V0021,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0021.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0021.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0021.bmp,mask,mask,val +refuge1_val_V0022,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0022.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0022.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0022.bmp,mask,mask,val +refuge1_val_V0023,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0023.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0023.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0023.bmp,mask,mask,val +refuge1_val_V0024,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0024.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0024.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0024.bmp,mask,mask,val +refuge1_val_V0025,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0025.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0025.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0025.bmp,mask,mask,val +refuge1_val_V0026,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0026.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0026.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0026.bmp,mask,mask,val +refuge1_val_V0027,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0027.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0027.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0027.bmp,mask,mask,val +refuge1_val_V0028,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0028.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0028.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0028.bmp,mask,mask,val +refuge1_val_V0029,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0029.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0029.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0029.bmp,mask,mask,val +refuge1_val_V0030,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0030.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0030.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0030.bmp,mask,mask,val +refuge1_val_V0031,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0031.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0031.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0031.bmp,mask,mask,val +refuge1_val_V0032,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0032.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0032.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0032.bmp,mask,mask,val +refuge1_val_V0033,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0033.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0033.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0033.bmp,mask,mask,val +refuge1_val_V0034,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0034.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0034.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0034.bmp,mask,mask,val +refuge1_val_V0035,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0035.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0035.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0035.bmp,mask,mask,val +refuge1_val_V0036,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0036.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0036.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0036.bmp,mask,mask,val +refuge1_val_V0037,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0037.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0037.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0037.bmp,mask,mask,val +refuge1_val_V0038,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0038.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0038.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0038.bmp,mask,mask,val +refuge1_val_V0039,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0039.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0039.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0039.bmp,mask,mask,val +refuge1_val_V0040,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0040.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0040.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0040.bmp,mask,mask,val +refuge1_val_V0041,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0041.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0041.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0041.bmp,mask,mask,val +refuge1_val_V0042,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0042.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0042.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0042.bmp,mask,mask,val +refuge1_val_V0043,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0043.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0043.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0043.bmp,mask,mask,val +refuge1_val_V0044,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0044.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0044.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0044.bmp,mask,mask,val +refuge1_val_V0045,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0045.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0045.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0045.bmp,mask,mask,val +refuge1_val_V0046,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0046.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0046.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0046.bmp,mask,mask,val +refuge1_val_V0047,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0047.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0047.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0047.bmp,mask,mask,val +refuge1_val_V0048,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0048.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0048.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0048.bmp,mask,mask,val +refuge1_val_V0049,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0049.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0049.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0049.bmp,mask,mask,val +refuge1_val_V0050,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0050.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0050.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0050.bmp,mask,mask,val +refuge1_val_V0051,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0051.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0051.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0051.bmp,mask,mask,val +refuge1_val_V0052,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0052.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0052.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0052.bmp,mask,mask,val +refuge1_val_V0053,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0053.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0053.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0053.bmp,mask,mask,val +refuge1_val_V0054,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0054.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0054.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0054.bmp,mask,mask,val +refuge1_val_V0055,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0055.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0055.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0055.bmp,mask,mask,val +refuge1_val_V0056,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0056.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0056.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0056.bmp,mask,mask,val +refuge1_val_V0057,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0057.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0057.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0057.bmp,mask,mask,val +refuge1_val_V0058,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0058.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0058.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0058.bmp,mask,mask,val +refuge1_val_V0059,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0059.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0059.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0059.bmp,mask,mask,val +refuge1_val_V0060,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0060.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0060.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0060.bmp,mask,mask,val +refuge1_val_V0061,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0061.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0061.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0061.bmp,mask,mask,val +refuge1_val_V0062,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0062.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0062.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0062.bmp,mask,mask,val +refuge1_val_V0063,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0063.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0063.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0063.bmp,mask,mask,val +refuge1_val_V0064,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0064.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0064.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0064.bmp,mask,mask,val +refuge1_val_V0065,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0065.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0065.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0065.bmp,mask,mask,val +refuge1_val_V0066,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0066.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0066.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0066.bmp,mask,mask,val +refuge1_val_V0067,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0067.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0067.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0067.bmp,mask,mask,val +refuge1_val_V0068,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0068.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0068.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0068.bmp,mask,mask,val +refuge1_val_V0069,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0069.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0069.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0069.bmp,mask,mask,val +refuge1_val_V0070,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0070.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0070.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0070.bmp,mask,mask,val +refuge1_val_V0071,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0071.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0071.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0071.bmp,mask,mask,val +refuge1_val_V0072,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0072.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0072.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0072.bmp,mask,mask,val +refuge1_val_V0073,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0073.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0073.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0073.bmp,mask,mask,val +refuge1_val_V0074,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0074.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0074.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0074.bmp,mask,mask,val +refuge1_val_V0075,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0075.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0075.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0075.bmp,mask,mask,val +refuge1_val_V0076,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0076.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0076.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0076.bmp,mask,mask,val +refuge1_val_V0077,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0077.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0077.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0077.bmp,mask,mask,val +refuge1_val_V0078,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0078.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0078.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0078.bmp,mask,mask,val +refuge1_val_V0079,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0079.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0079.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0079.bmp,mask,mask,val +refuge1_val_V0080,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0080.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0080.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0080.bmp,mask,mask,val +refuge1_val_V0081,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0081.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0081.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0081.bmp,mask,mask,val +refuge1_val_V0082,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0082.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0082.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0082.bmp,mask,mask,val +refuge1_val_V0083,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0083.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0083.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0083.bmp,mask,mask,val +refuge1_val_V0084,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0084.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0084.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0084.bmp,mask,mask,val +refuge1_val_V0085,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0085.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0085.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0085.bmp,mask,mask,val +refuge1_val_V0086,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0086.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0086.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0086.bmp,mask,mask,val +refuge1_val_V0087,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0087.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0087.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0087.bmp,mask,mask,val +refuge1_val_V0088,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0088.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0088.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0088.bmp,mask,mask,val +refuge1_val_V0089,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0089.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0089.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0089.bmp,mask,mask,val +refuge1_val_V0090,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0090.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0090.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0090.bmp,mask,mask,val +refuge1_val_V0091,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0091.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0091.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0091.bmp,mask,mask,val +refuge1_val_V0092,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0092.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0092.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0092.bmp,mask,mask,val +refuge1_val_V0093,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0093.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0093.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0093.bmp,mask,mask,val +refuge1_val_V0094,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0094.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0094.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0094.bmp,mask,mask,val +refuge1_val_V0095,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0095.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0095.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0095.bmp,mask,mask,val +refuge1_val_V0096,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0096.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0096.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0096.bmp,mask,mask,val +refuge1_val_V0097,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0097.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0097.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0097.bmp,mask,mask,val +refuge1_val_V0098,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0098.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0098.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0098.bmp,mask,mask,val +refuge1_val_V0099,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0099.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0099.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0099.bmp,mask,mask,val +refuge1_val_V0100,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0100.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0100.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0100.bmp,mask,mask,val +refuge1_val_V0101,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0101.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0101.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0101.bmp,mask,mask,val +refuge1_val_V0102,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0102.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0102.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0102.bmp,mask,mask,val +refuge1_val_V0103,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0103.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0103.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0103.bmp,mask,mask,val +refuge1_val_V0104,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0104.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0104.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0104.bmp,mask,mask,val +refuge1_val_V0105,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0105.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0105.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0105.bmp,mask,mask,val +refuge1_val_V0106,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0106.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0106.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0106.bmp,mask,mask,val +refuge1_val_V0107,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0107.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0107.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0107.bmp,mask,mask,val +refuge1_val_V0108,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0108.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0108.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0108.bmp,mask,mask,val +refuge1_val_V0109,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0109.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0109.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0109.bmp,mask,mask,val +refuge1_val_V0110,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0110.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0110.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0110.bmp,mask,mask,val +refuge1_val_V0111,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0111.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0111.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0111.bmp,mask,mask,val +refuge1_val_V0112,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0112.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0112.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0112.bmp,mask,mask,val +refuge1_val_V0113,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0113.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0113.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0113.bmp,mask,mask,val +refuge1_val_V0114,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0114.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0114.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0114.bmp,mask,mask,val +refuge1_val_V0115,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0115.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0115.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0115.bmp,mask,mask,val +refuge1_val_V0116,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0116.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0116.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0116.bmp,mask,mask,val +refuge1_val_V0117,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0117.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0117.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0117.bmp,mask,mask,val +refuge1_val_V0118,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0118.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0118.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0118.bmp,mask,mask,val +refuge1_val_V0119,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0119.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0119.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0119.bmp,mask,mask,val +refuge1_val_V0120,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0120.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0120.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0120.bmp,mask,mask,val +refuge1_val_V0121,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0121.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0121.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0121.bmp,mask,mask,val +refuge1_val_V0122,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0122.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0122.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0122.bmp,mask,mask,val +refuge1_val_V0123,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0123.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0123.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0123.bmp,mask,mask,val +refuge1_val_V0124,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0124.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0124.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0124.bmp,mask,mask,val +refuge1_val_V0125,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0125.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0125.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0125.bmp,mask,mask,val +refuge1_val_V0126,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0126.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0126.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0126.bmp,mask,mask,val +refuge1_val_V0127,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0127.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0127.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0127.bmp,mask,mask,val +refuge1_val_V0128,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0128.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0128.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0128.bmp,mask,mask,val +refuge1_val_V0129,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0129.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0129.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0129.bmp,mask,mask,val +refuge1_val_V0130,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0130.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0130.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0130.bmp,mask,mask,val +refuge1_val_V0131,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0131.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0131.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0131.bmp,mask,mask,val +refuge1_val_V0132,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0132.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0132.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0132.bmp,mask,mask,val +refuge1_val_V0133,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0133.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0133.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0133.bmp,mask,mask,val +refuge1_val_V0134,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0134.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0134.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0134.bmp,mask,mask,val +refuge1_val_V0135,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0135.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0135.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0135.bmp,mask,mask,val +refuge1_val_V0136,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0136.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0136.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0136.bmp,mask,mask,val +refuge1_val_V0137,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0137.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0137.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0137.bmp,mask,mask,val +refuge1_val_V0138,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0138.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0138.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0138.bmp,mask,mask,val +refuge1_val_V0139,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0139.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0139.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0139.bmp,mask,mask,val +refuge1_val_V0140,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0140.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0140.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0140.bmp,mask,mask,val +refuge1_val_V0141,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0141.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0141.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0141.bmp,mask,mask,val +refuge1_val_V0142,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0142.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0142.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0142.bmp,mask,mask,val +refuge1_val_V0143,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0143.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0143.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0143.bmp,mask,mask,val +refuge1_val_V0144,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0144.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0144.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0144.bmp,mask,mask,val +refuge1_val_V0145,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0145.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0145.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0145.bmp,mask,mask,val +refuge1_val_V0146,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0146.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0146.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0146.bmp,mask,mask,val +refuge1_val_V0147,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0147.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0147.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0147.bmp,mask,mask,val +refuge1_val_V0148,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0148.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0148.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0148.bmp,mask,mask,val +refuge1_val_V0149,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0149.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0149.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0149.bmp,mask,mask,val +refuge1_val_V0150,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0150.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0150.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0150.bmp,mask,mask,val +refuge1_val_V0151,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0151.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0151.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0151.bmp,mask,mask,val +refuge1_val_V0152,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0152.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0152.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0152.bmp,mask,mask,val +refuge1_val_V0153,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0153.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0153.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0153.bmp,mask,mask,val +refuge1_val_V0154,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0154.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0154.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0154.bmp,mask,mask,val +refuge1_val_V0155,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0155.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0155.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0155.bmp,mask,mask,val +refuge1_val_V0156,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0156.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0156.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0156.bmp,mask,mask,val +refuge1_val_V0157,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0157.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0157.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0157.bmp,mask,mask,val +refuge1_val_V0158,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0158.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0158.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0158.bmp,mask,mask,val +refuge1_val_V0159,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0159.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0159.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0159.bmp,mask,mask,val +refuge1_val_V0160,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0160.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0160.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0160.bmp,mask,mask,val +refuge1_val_V0161,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0161.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0161.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0161.bmp,mask,mask,val +refuge1_val_V0162,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0162.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0162.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0162.bmp,mask,mask,val +refuge1_val_V0163,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0163.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0163.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0163.bmp,mask,mask,val +refuge1_val_V0164,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0164.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0164.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0164.bmp,mask,mask,val +refuge1_val_V0165,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0165.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0165.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0165.bmp,mask,mask,val +refuge1_val_V0166,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0166.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0166.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0166.bmp,mask,mask,val +refuge1_val_V0167,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0167.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0167.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0167.bmp,mask,mask,val +refuge1_val_V0168,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0168.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0168.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0168.bmp,mask,mask,val +refuge1_val_V0169,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0169.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0169.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0169.bmp,mask,mask,val +refuge1_val_V0170,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0170.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0170.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0170.bmp,mask,mask,val +refuge1_val_V0171,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0171.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0171.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0171.bmp,mask,mask,val +refuge1_val_V0172,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0172.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0172.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0172.bmp,mask,mask,val +refuge1_val_V0173,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0173.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0173.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0173.bmp,mask,mask,val +refuge1_val_V0174,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0174.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0174.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0174.bmp,mask,mask,val +refuge1_val_V0175,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0175.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0175.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0175.bmp,mask,mask,val +refuge1_val_V0176,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0176.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0176.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0176.bmp,mask,mask,val +refuge1_val_V0177,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0177.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0177.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0177.bmp,mask,mask,val +refuge1_val_V0178,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0178.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0178.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0178.bmp,mask,mask,val +refuge1_val_V0179,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0179.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0179.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0179.bmp,mask,mask,val +refuge1_val_V0180,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0180.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0180.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0180.bmp,mask,mask,val +refuge1_val_V0181,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0181.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0181.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0181.bmp,mask,mask,val +refuge1_val_V0182,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0182.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0182.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0182.bmp,mask,mask,val +refuge1_val_V0183,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0183.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0183.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0183.bmp,mask,mask,val +refuge1_val_V0184,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0184.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0184.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0184.bmp,mask,mask,val +refuge1_val_V0185,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0185.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0185.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0185.bmp,mask,mask,val +refuge1_val_V0186,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0186.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0186.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0186.bmp,mask,mask,val +refuge1_val_V0187,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0187.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0187.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0187.bmp,mask,mask,val +refuge1_val_V0188,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0188.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0188.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0188.bmp,mask,mask,val +refuge1_val_V0189,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0189.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0189.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0189.bmp,mask,mask,val +refuge1_val_V0190,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0190.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0190.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0190.bmp,mask,mask,val +refuge1_val_V0191,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0191.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0191.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0191.bmp,mask,mask,val +refuge1_val_V0192,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0192.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0192.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0192.bmp,mask,mask,val +refuge1_val_V0193,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0193.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0193.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0193.bmp,mask,mask,val +refuge1_val_V0194,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0194.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0194.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0194.bmp,mask,mask,val +refuge1_val_V0195,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0195.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0195.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0195.bmp,mask,mask,val +refuge1_val_V0196,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0196.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0196.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0196.bmp,mask,mask,val +refuge1_val_V0197,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0197.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0197.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0197.bmp,mask,mask,val +refuge1_val_V0198,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0198.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0198.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0198.bmp,mask,mask,val +refuge1_val_V0199,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0199.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0199.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0199.bmp,mask,mask,val +refuge1_val_V0200,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0200.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0200.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0200.bmp,mask,mask,val +refuge1_val_V0201,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0201.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0201.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0201.bmp,mask,mask,val +refuge1_val_V0202,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0202.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0202.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0202.bmp,mask,mask,val +refuge1_val_V0203,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0203.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0203.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0203.bmp,mask,mask,val +refuge1_val_V0204,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0204.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0204.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0204.bmp,mask,mask,val +refuge1_val_V0205,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0205.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0205.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0205.bmp,mask,mask,val +refuge1_val_V0206,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0206.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0206.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0206.bmp,mask,mask,val +refuge1_val_V0207,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0207.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0207.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0207.bmp,mask,mask,val +refuge1_val_V0208,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0208.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0208.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0208.bmp,mask,mask,val +refuge1_val_V0209,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0209.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0209.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0209.bmp,mask,mask,val +refuge1_val_V0210,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0210.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0210.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0210.bmp,mask,mask,val +refuge1_val_V0211,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0211.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0211.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0211.bmp,mask,mask,val +refuge1_val_V0212,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0212.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0212.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0212.bmp,mask,mask,val +refuge1_val_V0213,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0213.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0213.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0213.bmp,mask,mask,val +refuge1_val_V0214,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0214.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0214.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0214.bmp,mask,mask,val +refuge1_val_V0215,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0215.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0215.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0215.bmp,mask,mask,val +refuge1_val_V0216,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0216.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0216.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0216.bmp,mask,mask,val +refuge1_val_V0217,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0217.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0217.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0217.bmp,mask,mask,val +refuge1_val_V0218,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0218.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0218.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0218.bmp,mask,mask,val +refuge1_val_V0219,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0219.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0219.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0219.bmp,mask,mask,val +refuge1_val_V0220,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0220.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0220.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0220.bmp,mask,mask,val +refuge1_val_V0221,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0221.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0221.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0221.bmp,mask,mask,val +refuge1_val_V0222,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0222.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0222.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0222.bmp,mask,mask,val +refuge1_val_V0223,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0223.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0223.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0223.bmp,mask,mask,val +refuge1_val_V0224,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0224.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0224.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0224.bmp,mask,mask,val +refuge1_val_V0225,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0225.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0225.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0225.bmp,mask,mask,val +refuge1_val_V0226,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0226.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0226.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0226.bmp,mask,mask,val +refuge1_val_V0227,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0227.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0227.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0227.bmp,mask,mask,val +refuge1_val_V0228,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0228.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0228.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0228.bmp,mask,mask,val +refuge1_val_V0229,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0229.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0229.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0229.bmp,mask,mask,val +refuge1_val_V0230,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0230.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0230.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0230.bmp,mask,mask,val +refuge1_val_V0231,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0231.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0231.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0231.bmp,mask,mask,val +refuge1_val_V0232,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0232.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0232.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0232.bmp,mask,mask,val +refuge1_val_V0233,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0233.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0233.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0233.bmp,mask,mask,val +refuge1_val_V0234,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0234.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0234.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0234.bmp,mask,mask,val +refuge1_val_V0235,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0235.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0235.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0235.bmp,mask,mask,val +refuge1_val_V0236,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0236.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0236.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0236.bmp,mask,mask,val +refuge1_val_V0237,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0237.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0237.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0237.bmp,mask,mask,val +refuge1_val_V0238,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0238.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0238.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0238.bmp,mask,mask,val +refuge1_val_V0239,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0239.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0239.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0239.bmp,mask,mask,val +refuge1_val_V0240,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0240.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0240.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0240.bmp,mask,mask,val +refuge1_val_V0241,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0241.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0241.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0241.bmp,mask,mask,val +refuge1_val_V0242,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0242.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0242.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0242.bmp,mask,mask,val +refuge1_val_V0243,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0243.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0243.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0243.bmp,mask,mask,val +refuge1_val_V0244,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0244.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0244.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0244.bmp,mask,mask,val +refuge1_val_V0245,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0245.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0245.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0245.bmp,mask,mask,val +refuge1_val_V0246,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0246.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0246.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0246.bmp,mask,mask,val +refuge1_val_V0247,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0247.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0247.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0247.bmp,mask,mask,val +refuge1_val_V0248,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0248.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0248.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0248.bmp,mask,mask,val +refuge1_val_V0249,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0249.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0249.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0249.bmp,mask,mask,val +refuge1_val_V0250,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0250.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0250.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0250.bmp,mask,mask,val +refuge1_val_V0251,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0251.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0251.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0251.bmp,mask,mask,val +refuge1_val_V0252,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0252.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0252.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0252.bmp,mask,mask,val +refuge1_val_V0253,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0253.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0253.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0253.bmp,mask,mask,val +refuge1_val_V0254,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0254.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0254.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0254.bmp,mask,mask,val +refuge1_val_V0255,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0255.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0255.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0255.bmp,mask,mask,val +refuge1_val_V0256,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0256.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0256.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0256.bmp,mask,mask,val +refuge1_val_V0257,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0257.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0257.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0257.bmp,mask,mask,val +refuge1_val_V0258,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0258.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0258.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0258.bmp,mask,mask,val +refuge1_val_V0259,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0259.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0259.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0259.bmp,mask,mask,val +refuge1_val_V0260,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0260.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0260.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0260.bmp,mask,mask,val +refuge1_val_V0261,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0261.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0261.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0261.bmp,mask,mask,val +refuge1_val_V0262,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0262.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0262.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0262.bmp,mask,mask,val +refuge1_val_V0263,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0263.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0263.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0263.bmp,mask,mask,val +refuge1_val_V0264,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0264.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0264.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0264.bmp,mask,mask,val +refuge1_val_V0265,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0265.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0265.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0265.bmp,mask,mask,val +refuge1_val_V0266,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0266.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0266.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0266.bmp,mask,mask,val +refuge1_val_V0267,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0267.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0267.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0267.bmp,mask,mask,val +refuge1_val_V0268,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0268.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0268.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0268.bmp,mask,mask,val +refuge1_val_V0269,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0269.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0269.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0269.bmp,mask,mask,val +refuge1_val_V0270,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0270.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0270.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0270.bmp,mask,mask,val +refuge1_val_V0271,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0271.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0271.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0271.bmp,mask,mask,val +refuge1_val_V0272,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0272.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0272.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0272.bmp,mask,mask,val +refuge1_val_V0273,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0273.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0273.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0273.bmp,mask,mask,val +refuge1_val_V0274,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0274.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0274.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0274.bmp,mask,mask,val +refuge1_val_V0275,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0275.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0275.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0275.bmp,mask,mask,val +refuge1_val_V0276,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0276.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0276.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0276.bmp,mask,mask,val +refuge1_val_V0277,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0277.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0277.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0277.bmp,mask,mask,val +refuge1_val_V0278,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0278.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0278.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0278.bmp,mask,mask,val +refuge1_val_V0279,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0279.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0279.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0279.bmp,mask,mask,val +refuge1_val_V0280,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0280.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0280.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0280.bmp,mask,mask,val +refuge1_val_V0281,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0281.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0281.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0281.bmp,mask,mask,val +refuge1_val_V0282,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0282.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0282.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0282.bmp,mask,mask,val +refuge1_val_V0283,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0283.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0283.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0283.bmp,mask,mask,val +refuge1_val_V0284,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0284.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0284.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0284.bmp,mask,mask,val +refuge1_val_V0285,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0285.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0285.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0285.bmp,mask,mask,val +refuge1_val_V0286,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0286.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0286.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0286.bmp,mask,mask,val +refuge1_val_V0287,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0287.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0287.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0287.bmp,mask,mask,val +refuge1_val_V0288,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0288.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0288.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0288.bmp,mask,mask,val +refuge1_val_V0289,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0289.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0289.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0289.bmp,mask,mask,val +refuge1_val_V0290,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0290.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0290.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0290.bmp,mask,mask,val +refuge1_val_V0291,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0291.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0291.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0291.bmp,mask,mask,val +refuge1_val_V0292,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0292.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0292.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0292.bmp,mask,mask,val +refuge1_val_V0293,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0293.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0293.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0293.bmp,mask,mask,val +refuge1_val_V0294,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0294.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0294.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0294.bmp,mask,mask,val +refuge1_val_V0295,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0295.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0295.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0295.bmp,mask,mask,val +refuge1_val_V0296,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0296.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0296.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0296.bmp,mask,mask,val +refuge1_val_V0297,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0297.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0297.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0297.bmp,mask,mask,val +refuge1_val_V0298,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0298.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0298.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0298.bmp,mask,mask,val +refuge1_val_V0299,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0299.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0299.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0299.bmp,mask,mask,val +refuge1_val_V0300,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0300.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0300.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0300.bmp,mask,mask,val +refuge1_val_V0301,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0301.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0301.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0301.bmp,mask,mask,val +refuge1_val_V0302,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0302.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0302.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0302.bmp,mask,mask,val +refuge1_val_V0303,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0303.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0303.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0303.bmp,mask,mask,val +refuge1_val_V0304,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0304.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0304.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0304.bmp,mask,mask,val +refuge1_val_V0305,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0305.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0305.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0305.bmp,mask,mask,val +refuge1_val_V0306,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0306.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0306.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0306.bmp,mask,mask,val +refuge1_val_V0307,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0307.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0307.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0307.bmp,mask,mask,val +refuge1_val_V0308,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0308.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0308.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0308.bmp,mask,mask,val +refuge1_val_V0309,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0309.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0309.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0309.bmp,mask,mask,val +refuge1_val_V0310,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0310.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0310.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0310.bmp,mask,mask,val +refuge1_val_V0311,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0311.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0311.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0311.bmp,mask,mask,val +refuge1_val_V0312,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0312.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0312.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0312.bmp,mask,mask,val +refuge1_val_V0313,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0313.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0313.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0313.bmp,mask,mask,val +refuge1_val_V0314,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0314.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0314.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0314.bmp,mask,mask,val +refuge1_val_V0315,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0315.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0315.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0315.bmp,mask,mask,val +refuge1_val_V0316,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0316.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0316.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0316.bmp,mask,mask,val +refuge1_val_V0317,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0317.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0317.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0317.bmp,mask,mask,val +refuge1_val_V0318,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0318.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0318.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0318.bmp,mask,mask,val +refuge1_val_V0319,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0319.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0319.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0319.bmp,mask,mask,val +refuge1_val_V0320,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0320.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0320.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0320.bmp,mask,mask,val +refuge1_val_V0321,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0321.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0321.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0321.bmp,mask,mask,val +refuge1_val_V0322,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0322.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0322.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0322.bmp,mask,mask,val +refuge1_val_V0323,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0323.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0323.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0323.bmp,mask,mask,val +refuge1_val_V0324,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0324.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0324.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0324.bmp,mask,mask,val +refuge1_val_V0325,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0325.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0325.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0325.bmp,mask,mask,val +refuge1_val_V0326,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0326.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0326.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0326.bmp,mask,mask,val +refuge1_val_V0327,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0327.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0327.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0327.bmp,mask,mask,val +refuge1_val_V0328,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0328.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0328.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0328.bmp,mask,mask,val +refuge1_val_V0329,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0329.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0329.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0329.bmp,mask,mask,val +refuge1_val_V0330,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0330.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0330.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0330.bmp,mask,mask,val +refuge1_val_V0331,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0331.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0331.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0331.bmp,mask,mask,val +refuge1_val_V0332,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0332.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0332.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0332.bmp,mask,mask,val +refuge1_val_V0333,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0333.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0333.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0333.bmp,mask,mask,val +refuge1_val_V0334,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0334.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0334.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0334.bmp,mask,mask,val +refuge1_val_V0335,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0335.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0335.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0335.bmp,mask,mask,val +refuge1_val_V0336,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0336.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0336.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0336.bmp,mask,mask,val +refuge1_val_V0337,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0337.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0337.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0337.bmp,mask,mask,val +refuge1_val_V0338,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0338.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0338.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0338.bmp,mask,mask,val +refuge1_val_V0339,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0339.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0339.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0339.bmp,mask,mask,val +refuge1_val_V0340,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0340.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0340.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0340.bmp,mask,mask,val +refuge1_val_V0341,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0341.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0341.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0341.bmp,mask,mask,val +refuge1_val_V0342,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0342.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0342.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0342.bmp,mask,mask,val +refuge1_val_V0343,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0343.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0343.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0343.bmp,mask,mask,val +refuge1_val_V0344,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0344.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0344.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0344.bmp,mask,mask,val +refuge1_val_V0345,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0345.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0345.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0345.bmp,mask,mask,val +refuge1_val_V0346,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0346.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0346.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0346.bmp,mask,mask,val +refuge1_val_V0347,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0347.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0347.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0347.bmp,mask,mask,val +refuge1_val_V0348,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0348.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0348.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0348.bmp,mask,mask,val +refuge1_val_V0349,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0349.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0349.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0349.bmp,mask,mask,val +refuge1_val_V0350,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0350.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0350.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0350.bmp,mask,mask,val +refuge1_val_V0351,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0351.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0351.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0351.bmp,mask,mask,val +refuge1_val_V0352,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0352.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0352.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0352.bmp,mask,mask,val +refuge1_val_V0353,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0353.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0353.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0353.bmp,mask,mask,val +refuge1_val_V0354,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0354.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0354.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0354.bmp,mask,mask,val +refuge1_val_V0355,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0355.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0355.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0355.bmp,mask,mask,val +refuge1_val_V0356,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0356.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0356.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0356.bmp,mask,mask,val +refuge1_val_V0357,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0357.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0357.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0357.bmp,mask,mask,val +refuge1_val_V0358,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0358.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0358.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0358.bmp,mask,mask,val +refuge1_val_V0359,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0359.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0359.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0359.bmp,mask,mask,val +refuge1_val_V0360,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0360.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0360.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0360.bmp,mask,mask,val +refuge1_val_V0361,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0361.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0361.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0361.bmp,mask,mask,val +refuge1_val_V0362,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0362.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0362.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0362.bmp,mask,mask,val +refuge1_val_V0363,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0363.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0363.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0363.bmp,mask,mask,val +refuge1_val_V0364,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0364.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0364.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0364.bmp,mask,mask,val +refuge1_val_V0365,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0365.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0365.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0365.bmp,mask,mask,val +refuge1_val_V0366,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0366.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0366.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0366.bmp,mask,mask,val +refuge1_val_V0367,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0367.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0367.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0367.bmp,mask,mask,val +refuge1_val_V0368,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0368.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0368.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0368.bmp,mask,mask,val +refuge1_val_V0369,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0369.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0369.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0369.bmp,mask,mask,val +refuge1_val_V0370,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0370.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0370.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0370.bmp,mask,mask,val +refuge1_val_V0371,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0371.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0371.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0371.bmp,mask,mask,val +refuge1_val_V0372,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0372.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0372.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0372.bmp,mask,mask,val +refuge1_val_V0373,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0373.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0373.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0373.bmp,mask,mask,val +refuge1_val_V0374,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0374.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0374.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0374.bmp,mask,mask,val +refuge1_val_V0375,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0375.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0375.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0375.bmp,mask,mask,val +refuge1_val_V0376,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0376.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0376.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0376.bmp,mask,mask,val +refuge1_val_V0377,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0377.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0377.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0377.bmp,mask,mask,val +refuge1_val_V0378,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0378.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0378.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0378.bmp,mask,mask,val +refuge1_val_V0379,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0379.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0379.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0379.bmp,mask,mask,val +refuge1_val_V0380,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0380.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0380.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0380.bmp,mask,mask,val +refuge1_val_V0381,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0381.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0381.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0381.bmp,mask,mask,val +refuge1_val_V0382,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0382.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0382.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0382.bmp,mask,mask,val +refuge1_val_V0383,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0383.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0383.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0383.bmp,mask,mask,val +refuge1_val_V0384,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0384.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0384.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0384.bmp,mask,mask,val +refuge1_val_V0385,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0385.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0385.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0385.bmp,mask,mask,val +refuge1_val_V0386,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0386.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0386.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0386.bmp,mask,mask,val +refuge1_val_V0387,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0387.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0387.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0387.bmp,mask,mask,val +refuge1_val_V0388,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0388.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0388.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0388.bmp,mask,mask,val +refuge1_val_V0389,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0389.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0389.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0389.bmp,mask,mask,val +refuge1_val_V0390,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0390.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0390.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0390.bmp,mask,mask,val +refuge1_val_V0391,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0391.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0391.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0391.bmp,mask,mask,val +refuge1_val_V0392,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0392.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0392.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0392.bmp,mask,mask,val +refuge1_val_V0393,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0393.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0393.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0393.bmp,mask,mask,val +refuge1_val_V0394,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0394.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0394.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0394.bmp,mask,mask,val +refuge1_val_V0395,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0395.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0395.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0395.bmp,mask,mask,val +refuge1_val_V0396,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0396.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0396.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0396.bmp,mask,mask,val +refuge1_val_V0397,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0397.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0397.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0397.bmp,mask,mask,val +refuge1_val_V0398,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0398.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0398.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0398.bmp,mask,mask,val +refuge1_val_V0399,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0399.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0399.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0399.bmp,mask,mask,val +refuge1_val_V0400,refuge,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/REFUGE-Validation400/V0400.jpg,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0400.bmp,/home/rpotter/hypertower/REFUGE/Train/REFUGE1-val/Disc_Cup_Masks/V0400.bmp,mask,mask,val +refuge2_val_V0001,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0001.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0001.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0001.png,mask,mask,val +refuge2_val_V0002,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0002.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0002.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0002.png,mask,mask,val +refuge2_val_V0003,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0003.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0003.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0003.png,mask,mask,val +refuge2_val_V0004,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0004.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0004.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0004.png,mask,mask,val +refuge2_val_V0005,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0005.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0005.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0005.png,mask,mask,val +refuge2_val_V0006,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0006.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0006.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0006.png,mask,mask,val +refuge2_val_V0007,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0007.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0007.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0007.png,mask,mask,val +refuge2_val_V0008,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0008.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0008.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0008.png,mask,mask,val +refuge2_val_V0009,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0009.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0009.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0009.png,mask,mask,val +refuge2_val_V0010,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0010.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0010.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0010.png,mask,mask,val +refuge2_val_V0011,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0011.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0011.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0011.png,mask,mask,val +refuge2_val_V0012,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0012.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0012.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0012.png,mask,mask,val +refuge2_val_V0013,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0013.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0013.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0013.png,mask,mask,val +refuge2_val_V0014,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0014.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0014.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0014.png,mask,mask,val +refuge2_val_V0015,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0015.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0015.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0015.png,mask,mask,val +refuge2_val_V0016,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0016.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0016.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0016.png,mask,mask,val +refuge2_val_V0017,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0017.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0017.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0017.png,mask,mask,val +refuge2_val_V0018,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0018.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0018.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0018.png,mask,mask,val +refuge2_val_V0019,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0019.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0019.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0019.png,mask,mask,val +refuge2_val_V0020,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0020.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0020.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0020.png,mask,mask,val +refuge2_val_V0021,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0021.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0021.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0021.png,mask,mask,val +refuge2_val_V0022,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0022.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0022.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0022.png,mask,mask,val +refuge2_val_V0023,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0023.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0023.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0023.png,mask,mask,val +refuge2_val_V0024,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0024.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0024.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0024.png,mask,mask,val +refuge2_val_V0025,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0025.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0025.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0025.png,mask,mask,val +refuge2_val_V0026,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0026.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0026.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0026.png,mask,mask,val +refuge2_val_V0027,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0027.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0027.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0027.png,mask,mask,val +refuge2_val_V0028,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0028.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0028.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0028.png,mask,mask,val +refuge2_val_V0029,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0029.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0029.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0029.png,mask,mask,val +refuge2_val_V0030,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0030.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0030.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0030.png,mask,mask,val +refuge2_val_V0031,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0031.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0031.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0031.png,mask,mask,val +refuge2_val_V0032,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0032.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0032.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0032.png,mask,mask,val +refuge2_val_V0033,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0033.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0033.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0033.png,mask,mask,val +refuge2_val_V0034,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0034.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0034.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0034.png,mask,mask,val +refuge2_val_V0035,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0035.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0035.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0035.png,mask,mask,val +refuge2_val_V0036,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0036.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0036.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0036.png,mask,mask,val +refuge2_val_V0037,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0037.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0037.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0037.png,mask,mask,val +refuge2_val_V0038,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0038.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0038.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0038.png,mask,mask,val +refuge2_val_V0039,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0039.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0039.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0039.png,mask,mask,val +refuge2_val_V0040,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0040.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0040.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0040.png,mask,mask,val +refuge2_val_V0041,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0041.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0041.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0041.png,mask,mask,val +refuge2_val_V0042,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0042.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0042.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0042.png,mask,mask,val +refuge2_val_V0043,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0043.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0043.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0043.png,mask,mask,val +refuge2_val_V0044,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0044.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0044.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0044.png,mask,mask,val +refuge2_val_V0045,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0045.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0045.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0045.png,mask,mask,val +refuge2_val_V0046,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0046.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0046.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0046.png,mask,mask,val +refuge2_val_V0047,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0047.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0047.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0047.png,mask,mask,val +refuge2_val_V0048,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0048.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0048.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0048.png,mask,mask,val +refuge2_val_V0049,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0049.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0049.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0049.png,mask,mask,val +refuge2_val_V0050,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0050.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0050.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0050.png,mask,mask,val +refuge2_val_V0051,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0051.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0051.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0051.png,mask,mask,val +refuge2_val_V0052,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0052.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0052.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0052.png,mask,mask,val +refuge2_val_V0053,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0053.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0053.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0053.png,mask,mask,val +refuge2_val_V0054,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0054.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0054.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0054.png,mask,mask,val +refuge2_val_V0055,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0055.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0055.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0055.png,mask,mask,val +refuge2_val_V0056,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0056.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0056.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0056.png,mask,mask,val +refuge2_val_V0057,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0057.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0057.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0057.png,mask,mask,val +refuge2_val_V0058,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0058.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0058.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0058.png,mask,mask,val +refuge2_val_V0059,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0059.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0059.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0059.png,mask,mask,val +refuge2_val_V0060,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0060.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0060.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0060.png,mask,mask,val +refuge2_val_V0061,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0061.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0061.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0061.png,mask,mask,val +refuge2_val_V0062,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0062.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0062.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0062.png,mask,mask,val +refuge2_val_V0063,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0063.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0063.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0063.png,mask,mask,val +refuge2_val_V0064,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0064.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0064.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0064.png,mask,mask,val +refuge2_val_V0065,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0065.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0065.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0065.png,mask,mask,val +refuge2_val_V0066,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0066.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0066.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0066.png,mask,mask,val +refuge2_val_V0067,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0067.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0067.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0067.png,mask,mask,val +refuge2_val_V0068,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0068.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0068.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0068.png,mask,mask,val +refuge2_val_V0069,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0069.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0069.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0069.png,mask,mask,val +refuge2_val_V0070,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0070.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0070.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0070.png,mask,mask,val +refuge2_val_V0071,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0071.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0071.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0071.png,mask,mask,val +refuge2_val_V0072,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0072.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0072.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0072.png,mask,mask,val +refuge2_val_V0073,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0073.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0073.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0073.png,mask,mask,val +refuge2_val_V0074,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0074.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0074.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0074.png,mask,mask,val +refuge2_val_V0075,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0075.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0075.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0075.png,mask,mask,val +refuge2_val_V0076,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0076.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0076.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0076.png,mask,mask,val +refuge2_val_V0077,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0077.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0077.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0077.png,mask,mask,val +refuge2_val_V0078,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0078.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0078.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0078.png,mask,mask,val +refuge2_val_V0079,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0079.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0079.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0079.png,mask,mask,val +refuge2_val_V0080,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0080.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0080.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0080.png,mask,mask,val +refuge2_val_V0081,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0081.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0081.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0081.png,mask,mask,val +refuge2_val_V0082,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0082.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0082.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0082.png,mask,mask,val +refuge2_val_V0083,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0083.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0083.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0083.png,mask,mask,val +refuge2_val_V0084,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0084.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0084.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0084.png,mask,mask,val +refuge2_val_V0085,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0085.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0085.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0085.png,mask,mask,val +refuge2_val_V0086,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0086.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0086.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0086.png,mask,mask,val +refuge2_val_V0087,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0087.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0087.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0087.png,mask,mask,val +refuge2_val_V0088,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0088.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0088.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0088.png,mask,mask,val +refuge2_val_V0089,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0089.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0089.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0089.png,mask,mask,val +refuge2_val_V0090,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0090.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0090.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0090.png,mask,mask,val +refuge2_val_V0091,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0091.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0091.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0091.png,mask,mask,val +refuge2_val_V0092,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0092.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0092.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0092.png,mask,mask,val +refuge2_val_V0093,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0093.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0093.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0093.png,mask,mask,val +refuge2_val_V0094,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0094.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0094.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0094.png,mask,mask,val +refuge2_val_V0095,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0095.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0095.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0095.png,mask,mask,val +refuge2_val_V0096,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0096.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0096.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0096.png,mask,mask,val +refuge2_val_V0097,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0097.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0097.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0097.png,mask,mask,val +refuge2_val_V0098,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0098.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0098.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0098.png,mask,mask,val +refuge2_val_V0099,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0099.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0099.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0099.png,mask,mask,val +refuge2_val_V0100,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0100.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0100.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0100.png,mask,mask,val +refuge2_val_V0101,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0101.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0101.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0101.png,mask,mask,val +refuge2_val_V0102,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0102.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0102.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0102.png,mask,mask,val +refuge2_val_V0103,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0103.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0103.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0103.png,mask,mask,val +refuge2_val_V0104,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0104.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0104.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0104.png,mask,mask,val +refuge2_val_V0105,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0105.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0105.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0105.png,mask,mask,val +refuge2_val_V0106,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0106.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0106.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0106.png,mask,mask,val +refuge2_val_V0107,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0107.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0107.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0107.png,mask,mask,val +refuge2_val_V0108,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0108.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0108.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0108.png,mask,mask,val +refuge2_val_V0109,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0109.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0109.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0109.png,mask,mask,val +refuge2_val_V0110,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0110.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0110.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0110.png,mask,mask,val +refuge2_val_V0111,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0111.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0111.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0111.png,mask,mask,val +refuge2_val_V0112,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0112.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0112.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0112.png,mask,mask,val +refuge2_val_V0113,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0113.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0113.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0113.png,mask,mask,val +refuge2_val_V0114,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0114.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0114.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0114.png,mask,mask,val +refuge2_val_V0115,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0115.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0115.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0115.png,mask,mask,val +refuge2_val_V0116,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0116.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0116.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0116.png,mask,mask,val +refuge2_val_V0117,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0117.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0117.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0117.png,mask,mask,val +refuge2_val_V0118,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0118.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0118.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0118.png,mask,mask,val +refuge2_val_V0119,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0119.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0119.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0119.png,mask,mask,val +refuge2_val_V0120,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0120.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0120.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0120.png,mask,mask,val +refuge2_val_V0121,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0121.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0121.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0121.png,mask,mask,val +refuge2_val_V0122,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0122.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0122.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0122.png,mask,mask,val +refuge2_val_V0123,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0123.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0123.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0123.png,mask,mask,val +refuge2_val_V0124,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0124.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0124.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0124.png,mask,mask,val +refuge2_val_V0125,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0125.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0125.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0125.png,mask,mask,val +refuge2_val_V0126,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0126.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0126.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0126.png,mask,mask,val +refuge2_val_V0127,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0127.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0127.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0127.png,mask,mask,val +refuge2_val_V0128,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0128.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0128.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0128.png,mask,mask,val +refuge2_val_V0129,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0129.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0129.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0129.png,mask,mask,val +refuge2_val_V0130,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0130.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0130.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0130.png,mask,mask,val +refuge2_val_V0131,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0131.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0131.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0131.png,mask,mask,val +refuge2_val_V0132,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0132.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0132.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0132.png,mask,mask,val +refuge2_val_V0133,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0133.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0133.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0133.png,mask,mask,val +refuge2_val_V0134,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0134.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0134.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0134.png,mask,mask,val +refuge2_val_V0135,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0135.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0135.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0135.png,mask,mask,val +refuge2_val_V0136,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0136.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0136.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0136.png,mask,mask,val +refuge2_val_V0137,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0137.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0137.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0137.png,mask,mask,val +refuge2_val_V0138,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0138.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0138.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0138.png,mask,mask,val +refuge2_val_V0139,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0139.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0139.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0139.png,mask,mask,val +refuge2_val_V0140,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0140.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0140.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0140.png,mask,mask,val +refuge2_val_V0141,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0141.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0141.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0141.png,mask,mask,val +refuge2_val_V0142,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0142.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0142.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0142.png,mask,mask,val +refuge2_val_V0143,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0143.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0143.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0143.png,mask,mask,val +refuge2_val_V0144,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0144.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0144.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0144.png,mask,mask,val +refuge2_val_V0145,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0145.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0145.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0145.png,mask,mask,val +refuge2_val_V0146,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0146.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0146.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0146.png,mask,mask,val +refuge2_val_V0147,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0147.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0147.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0147.png,mask,mask,val +refuge2_val_V0148,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0148.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0148.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0148.png,mask,mask,val +refuge2_val_V0149,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0149.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0149.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0149.png,mask,mask,val +refuge2_val_V0150,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0150.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0150.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0150.png,mask,mask,val +refuge2_val_V0151,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0151.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0151.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0151.png,mask,mask,val +refuge2_val_V0152,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0152.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0152.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0152.png,mask,mask,val +refuge2_val_V0153,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0153.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0153.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0153.png,mask,mask,val +refuge2_val_V0154,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0154.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0154.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0154.png,mask,mask,val +refuge2_val_V0155,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0155.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0155.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0155.png,mask,mask,val +refuge2_val_V0156,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0156.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0156.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0156.png,mask,mask,val +refuge2_val_V0157,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0157.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0157.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0157.png,mask,mask,val +refuge2_val_V0158,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0158.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0158.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0158.png,mask,mask,val +refuge2_val_V0159,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0159.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0159.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0159.png,mask,mask,val +refuge2_val_V0160,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0160.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0160.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0160.png,mask,mask,val +refuge2_val_V0161,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0161.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0161.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0161.png,mask,mask,val +refuge2_val_V0162,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0162.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0162.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0162.png,mask,mask,val +refuge2_val_V0163,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0163.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0163.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0163.png,mask,mask,val +refuge2_val_V0164,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0164.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0164.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0164.png,mask,mask,val +refuge2_val_V0165,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0165.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0165.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0165.png,mask,mask,val +refuge2_val_V0166,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0166.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0166.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0166.png,mask,mask,val +refuge2_val_V0167,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0167.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0167.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0167.png,mask,mask,val +refuge2_val_V0168,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0168.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0168.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0168.png,mask,mask,val +refuge2_val_V0169,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0169.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0169.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0169.png,mask,mask,val +refuge2_val_V0170,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0170.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0170.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0170.png,mask,mask,val +refuge2_val_V0171,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0171.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0171.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0171.png,mask,mask,val +refuge2_val_V0172,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0172.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0172.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0172.png,mask,mask,val +refuge2_val_V0173,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0173.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0173.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0173.png,mask,mask,val +refuge2_val_V0174,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0174.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0174.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0174.png,mask,mask,val +refuge2_val_V0175,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0175.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0175.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0175.png,mask,mask,val +refuge2_val_V0176,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0176.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0176.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0176.png,mask,mask,val +refuge2_val_V0177,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0177.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0177.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0177.png,mask,mask,val +refuge2_val_V0178,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0178.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0178.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0178.png,mask,mask,val +refuge2_val_V0179,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0179.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0179.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0179.png,mask,mask,val +refuge2_val_V0180,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0180.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0180.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0180.png,mask,mask,val +refuge2_val_V0181,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0181.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0181.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0181.png,mask,mask,val +refuge2_val_V0182,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0182.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0182.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0182.png,mask,mask,val +refuge2_val_V0183,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0183.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0183.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0183.png,mask,mask,val +refuge2_val_V0184,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0184.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0184.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0184.png,mask,mask,val +refuge2_val_V0185,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0185.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0185.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0185.png,mask,mask,val +refuge2_val_V0186,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0186.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0186.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0186.png,mask,mask,val +refuge2_val_V0187,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0187.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0187.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0187.png,mask,mask,val +refuge2_val_V0188,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0188.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0188.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0188.png,mask,mask,val +refuge2_val_V0189,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0189.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0189.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0189.png,mask,mask,val +refuge2_val_V0190,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0190.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0190.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0190.png,mask,mask,val +refuge2_val_V0191,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0191.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0191.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0191.png,mask,mask,val +refuge2_val_V0192,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0192.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0192.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0192.png,mask,mask,val +refuge2_val_V0193,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0193.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0193.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0193.png,mask,mask,val +refuge2_val_V0194,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0194.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0194.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0194.png,mask,mask,val +refuge2_val_V0195,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0195.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0195.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0195.png,mask,mask,val +refuge2_val_V0196,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0196.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0196.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0196.png,mask,mask,val +refuge2_val_V0197,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0197.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0197.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0197.png,mask,mask,val +refuge2_val_V0198,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0198.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0198.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0198.png,mask,mask,val +refuge2_val_V0199,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0199.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0199.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0199.png,mask,mask,val +refuge2_val_V0200,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0200.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0200.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0200.png,mask,mask,val +refuge2_val_V0201,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0201.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0201.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0201.png,mask,mask,val +refuge2_val_V0202,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0202.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0202.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0202.png,mask,mask,val +refuge2_val_V0203,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0203.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0203.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0203.png,mask,mask,val +refuge2_val_V0204,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0204.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0204.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0204.png,mask,mask,val +refuge2_val_V0205,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0205.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0205.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0205.png,mask,mask,val +refuge2_val_V0206,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0206.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0206.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0206.png,mask,mask,val +refuge2_val_V0207,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0207.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0207.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0207.png,mask,mask,val +refuge2_val_V0208,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0208.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0208.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0208.png,mask,mask,val +refuge2_val_V0209,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0209.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0209.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0209.png,mask,mask,val +refuge2_val_V0210,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0210.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0210.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0210.png,mask,mask,val +refuge2_val_V0211,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0211.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0211.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0211.png,mask,mask,val +refuge2_val_V0212,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0212.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0212.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0212.png,mask,mask,val +refuge2_val_V0213,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0213.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0213.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0213.png,mask,mask,val +refuge2_val_V0214,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0214.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0214.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0214.png,mask,mask,val +refuge2_val_V0215,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0215.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0215.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0215.png,mask,mask,val +refuge2_val_V0216,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0216.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0216.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0216.png,mask,mask,val +refuge2_val_V0217,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0217.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0217.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0217.png,mask,mask,val +refuge2_val_V0218,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0218.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0218.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0218.png,mask,mask,val +refuge2_val_V0219,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0219.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0219.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0219.png,mask,mask,val +refuge2_val_V0220,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0220.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0220.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0220.png,mask,mask,val +refuge2_val_V0221,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0221.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0221.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0221.png,mask,mask,val +refuge2_val_V0222,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0222.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0222.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0222.png,mask,mask,val +refuge2_val_V0223,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0223.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0223.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0223.png,mask,mask,val +refuge2_val_V0224,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0224.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0224.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0224.png,mask,mask,val +refuge2_val_V0225,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0225.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0225.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0225.png,mask,mask,val +refuge2_val_V0226,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0226.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0226.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0226.png,mask,mask,val +refuge2_val_V0227,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0227.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0227.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0227.png,mask,mask,val +refuge2_val_V0228,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0228.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0228.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0228.png,mask,mask,val +refuge2_val_V0229,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0229.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0229.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0229.png,mask,mask,val +refuge2_val_V0230,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0230.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0230.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0230.png,mask,mask,val +refuge2_val_V0231,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0231.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0231.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0231.png,mask,mask,val +refuge2_val_V0232,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0232.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0232.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0232.png,mask,mask,val +refuge2_val_V0233,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0233.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0233.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0233.png,mask,mask,val +refuge2_val_V0234,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0234.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0234.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0234.png,mask,mask,val +refuge2_val_V0235,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0235.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0235.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0235.png,mask,mask,val +refuge2_val_V0236,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0236.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0236.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0236.png,mask,mask,val +refuge2_val_V0237,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0237.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0237.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0237.png,mask,mask,val +refuge2_val_V0238,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0238.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0238.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0238.png,mask,mask,val +refuge2_val_V0239,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0239.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0239.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0239.png,mask,mask,val +refuge2_val_V0240,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0240.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0240.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0240.png,mask,mask,val +refuge2_val_V0241,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0241.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0241.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0241.png,mask,mask,val +refuge2_val_V0242,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0242.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0242.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0242.png,mask,mask,val +refuge2_val_V0243,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0243.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0243.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0243.png,mask,mask,val +refuge2_val_V0244,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0244.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0244.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0244.png,mask,mask,val +refuge2_val_V0245,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0245.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0245.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0245.png,mask,mask,val +refuge2_val_V0246,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0246.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0246.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0246.png,mask,mask,val +refuge2_val_V0247,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0247.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0247.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0247.png,mask,mask,val +refuge2_val_V0248,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0248.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0248.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0248.png,mask,mask,val +refuge2_val_V0249,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0249.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0249.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0249.png,mask,mask,val +refuge2_val_V0250,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0250.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0250.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0250.png,mask,mask,val +refuge2_val_V0251,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0251.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0251.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0251.png,mask,mask,val +refuge2_val_V0252,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0252.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0252.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0252.png,mask,mask,val +refuge2_val_V0253,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0253.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0253.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0253.png,mask,mask,val +refuge2_val_V0254,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0254.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0254.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0254.png,mask,mask,val +refuge2_val_V0255,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0255.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0255.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0255.png,mask,mask,val +refuge2_val_V0256,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0256.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0256.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0256.png,mask,mask,val +refuge2_val_V0257,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0257.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0257.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0257.png,mask,mask,val +refuge2_val_V0258,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0258.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0258.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0258.png,mask,mask,val +refuge2_val_V0259,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0259.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0259.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0259.png,mask,mask,val +refuge2_val_V0260,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0260.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0260.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0260.png,mask,mask,val +refuge2_val_V0261,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0261.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0261.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0261.png,mask,mask,val +refuge2_val_V0262,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0262.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0262.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0262.png,mask,mask,val +refuge2_val_V0263,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0263.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0263.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0263.png,mask,mask,val +refuge2_val_V0264,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0264.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0264.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0264.png,mask,mask,val +refuge2_val_V0265,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0265.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0265.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0265.png,mask,mask,val +refuge2_val_V0266,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0266.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0266.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0266.png,mask,mask,val +refuge2_val_V0267,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0267.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0267.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0267.png,mask,mask,val +refuge2_val_V0268,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0268.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0268.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0268.png,mask,mask,val +refuge2_val_V0269,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0269.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0269.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0269.png,mask,mask,val +refuge2_val_V0270,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0270.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0270.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0270.png,mask,mask,val +refuge2_val_V0271,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0271.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0271.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0271.png,mask,mask,val +refuge2_val_V0272,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0272.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0272.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0272.png,mask,mask,val +refuge2_val_V0273,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0273.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0273.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0273.png,mask,mask,val +refuge2_val_V0274,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0274.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0274.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0274.png,mask,mask,val +refuge2_val_V0275,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0275.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0275.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0275.png,mask,mask,val +refuge2_val_V0276,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0276.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0276.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0276.png,mask,mask,val +refuge2_val_V0277,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0277.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0277.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0277.png,mask,mask,val +refuge2_val_V0278,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0278.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0278.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0278.png,mask,mask,val +refuge2_val_V0279,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0279.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0279.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0279.png,mask,mask,val +refuge2_val_V0280,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0280.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0280.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0280.png,mask,mask,val +refuge2_val_V0281,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0281.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0281.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0281.png,mask,mask,val +refuge2_val_V0282,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0282.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0282.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0282.png,mask,mask,val +refuge2_val_V0283,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0283.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0283.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0283.png,mask,mask,val +refuge2_val_V0284,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0284.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0284.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0284.png,mask,mask,val +refuge2_val_V0285,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0285.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0285.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0285.png,mask,mask,val +refuge2_val_V0286,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0286.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0286.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0286.png,mask,mask,val +refuge2_val_V0287,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0287.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0287.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0287.png,mask,mask,val +refuge2_val_V0288,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0288.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0288.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0288.png,mask,mask,val +refuge2_val_V0289,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0289.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0289.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0289.png,mask,mask,val +refuge2_val_V0290,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0290.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0290.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0290.png,mask,mask,val +refuge2_val_V0291,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0291.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0291.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0291.png,mask,mask,val +refuge2_val_V0292,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0292.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0292.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0292.png,mask,mask,val +refuge2_val_V0293,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0293.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0293.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0293.png,mask,mask,val +refuge2_val_V0294,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0294.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0294.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0294.png,mask,mask,val +refuge2_val_V0295,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0295.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0295.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0295.png,mask,mask,val +refuge2_val_V0296,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0296.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0296.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0296.png,mask,mask,val +refuge2_val_V0297,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0297.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0297.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0297.png,mask,mask,val +refuge2_val_V0298,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0298.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0298.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0298.png,mask,mask,val +refuge2_val_V0299,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0299.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0299.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0299.png,mask,mask,val +refuge2_val_V0300,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0300.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0300.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0300.png,mask,mask,val +refuge2_val_V0301,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0301.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0301.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0301.png,mask,mask,val +refuge2_val_V0302,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0302.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0302.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0302.png,mask,mask,val +refuge2_val_V0303,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0303.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0303.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0303.png,mask,mask,val +refuge2_val_V0304,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0304.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0304.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0304.png,mask,mask,val +refuge2_val_V0305,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0305.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0305.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0305.png,mask,mask,val +refuge2_val_V0306,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0306.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0306.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0306.png,mask,mask,val +refuge2_val_V0307,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0307.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0307.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0307.png,mask,mask,val +refuge2_val_V0308,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0308.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0308.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0308.png,mask,mask,val +refuge2_val_V0309,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0309.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0309.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0309.png,mask,mask,val +refuge2_val_V0310,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0310.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0310.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0310.png,mask,mask,val +refuge2_val_V0311,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0311.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0311.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0311.png,mask,mask,val +refuge2_val_V0312,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0312.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0312.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0312.png,mask,mask,val +refuge2_val_V0313,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0313.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0313.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0313.png,mask,mask,val +refuge2_val_V0314,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0314.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0314.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0314.png,mask,mask,val +refuge2_val_V0315,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0315.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0315.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0315.png,mask,mask,val +refuge2_val_V0316,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0316.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0316.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0316.png,mask,mask,val +refuge2_val_V0317,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0317.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0317.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0317.png,mask,mask,val +refuge2_val_V0318,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0318.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0318.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0318.png,mask,mask,val +refuge2_val_V0319,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0319.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0319.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0319.png,mask,mask,val +refuge2_val_V0320,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0320.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0320.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0320.png,mask,mask,val +refuge2_val_V0321,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0321.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0321.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0321.png,mask,mask,val +refuge2_val_V0322,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0322.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0322.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0322.png,mask,mask,val +refuge2_val_V0323,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0323.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0323.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0323.png,mask,mask,val +refuge2_val_V0324,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0324.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0324.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0324.png,mask,mask,val +refuge2_val_V0325,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0325.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0325.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0325.png,mask,mask,val +refuge2_val_V0326,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0326.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0326.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0326.png,mask,mask,val +refuge2_val_V0327,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0327.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0327.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0327.png,mask,mask,val +refuge2_val_V0328,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0328.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0328.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0328.png,mask,mask,val +refuge2_val_V0329,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0329.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0329.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0329.png,mask,mask,val +refuge2_val_V0330,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0330.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0330.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0330.png,mask,mask,val +refuge2_val_V0331,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0331.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0331.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0331.png,mask,mask,val +refuge2_val_V0332,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0332.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0332.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0332.png,mask,mask,val +refuge2_val_V0333,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0333.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0333.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0333.png,mask,mask,val +refuge2_val_V0334,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0334.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0334.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0334.png,mask,mask,val +refuge2_val_V0335,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0335.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0335.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0335.png,mask,mask,val +refuge2_val_V0336,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0336.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0336.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0336.png,mask,mask,val +refuge2_val_V0337,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0337.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0337.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0337.png,mask,mask,val +refuge2_val_V0338,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0338.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0338.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0338.png,mask,mask,val +refuge2_val_V0339,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0339.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0339.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0339.png,mask,mask,val +refuge2_val_V0340,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0340.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0340.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0340.png,mask,mask,val +refuge2_val_V0341,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0341.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0341.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0341.png,mask,mask,val +refuge2_val_V0342,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0342.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0342.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0342.png,mask,mask,val +refuge2_val_V0343,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0343.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0343.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0343.png,mask,mask,val +refuge2_val_V0344,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0344.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0344.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0344.png,mask,mask,val +refuge2_val_V0345,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0345.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0345.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0345.png,mask,mask,val +refuge2_val_V0346,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0346.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0346.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0346.png,mask,mask,val +refuge2_val_V0347,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0347.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0347.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0347.png,mask,mask,val +refuge2_val_V0348,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0348.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0348.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0348.png,mask,mask,val +refuge2_val_V0349,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0349.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0349.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0349.png,mask,mask,val +refuge2_val_V0350,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0350.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0350.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0350.png,mask,mask,val +refuge2_val_V0351,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0351.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0351.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0351.png,mask,mask,val +refuge2_val_V0352,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0352.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0352.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0352.png,mask,mask,val +refuge2_val_V0353,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0353.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0353.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0353.png,mask,mask,val +refuge2_val_V0354,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0354.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0354.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0354.png,mask,mask,val +refuge2_val_V0355,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0355.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0355.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0355.png,mask,mask,val +refuge2_val_V0356,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0356.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0356.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0356.png,mask,mask,val +refuge2_val_V0357,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0357.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0357.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0357.png,mask,mask,val +refuge2_val_V0358,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0358.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0358.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0358.png,mask,mask,val +refuge2_val_V0359,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0359.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0359.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0359.png,mask,mask,val +refuge2_val_V0360,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0360.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0360.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0360.png,mask,mask,val +refuge2_val_V0361,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0361.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0361.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0361.png,mask,mask,val +refuge2_val_V0362,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0362.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0362.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0362.png,mask,mask,val +refuge2_val_V0363,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0363.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0363.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0363.png,mask,mask,val +refuge2_val_V0364,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0364.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0364.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0364.png,mask,mask,val +refuge2_val_V0365,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0365.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0365.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0365.png,mask,mask,val +refuge2_val_V0366,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0366.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0366.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0366.png,mask,mask,val +refuge2_val_V0367,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0367.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0367.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0367.png,mask,mask,val +refuge2_val_V0368,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0368.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0368.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0368.png,mask,mask,val +refuge2_val_V0369,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0369.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0369.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0369.png,mask,mask,val +refuge2_val_V0370,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0370.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0370.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0370.png,mask,mask,val +refuge2_val_V0371,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0371.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0371.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0371.png,mask,mask,val +refuge2_val_V0372,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0372.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0372.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0372.png,mask,mask,val +refuge2_val_V0373,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0373.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0373.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0373.png,mask,mask,val +refuge2_val_V0374,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0374.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0374.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0374.png,mask,mask,val +refuge2_val_V0375,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0375.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0375.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0375.png,mask,mask,val +refuge2_val_V0376,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0376.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0376.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0376.png,mask,mask,val +refuge2_val_V0377,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0377.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0377.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0377.png,mask,mask,val +refuge2_val_V0378,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0378.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0378.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0378.png,mask,mask,val +refuge2_val_V0379,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0379.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0379.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0379.png,mask,mask,val +refuge2_val_V0380,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0380.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0380.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0380.png,mask,mask,val +refuge2_val_V0381,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0381.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0381.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0381.png,mask,mask,val +refuge2_val_V0382,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0382.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0382.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0382.png,mask,mask,val +refuge2_val_V0383,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0383.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0383.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0383.png,mask,mask,val +refuge2_val_V0384,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0384.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0384.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0384.png,mask,mask,val +refuge2_val_V0385,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0385.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0385.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0385.png,mask,mask,val +refuge2_val_V0386,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0386.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0386.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0386.png,mask,mask,val +refuge2_val_V0387,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0387.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0387.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0387.png,mask,mask,val +refuge2_val_V0388,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0388.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0388.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0388.png,mask,mask,val +refuge2_val_V0389,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0389.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0389.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0389.png,mask,mask,val +refuge2_val_V0390,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0390.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0390.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0390.png,mask,mask,val +refuge2_val_V0391,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0391.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0391.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0391.png,mask,mask,val +refuge2_val_V0392,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0392.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0392.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0392.png,mask,mask,val +refuge2_val_V0393,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0393.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0393.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0393.png,mask,mask,val +refuge2_val_V0394,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0394.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0394.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0394.png,mask,mask,val +refuge2_val_V0395,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0395.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0395.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0395.png,mask,mask,val +refuge2_val_V0396,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0396.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0396.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0396.png,mask,mask,val +refuge2_val_V0397,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0397.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0397.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0397.png,mask,mask,val +refuge2_val_V0398,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0398.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0398.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0398.png,mask,mask,val +refuge2_val_V0399,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0399.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0399.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0399.png,mask,mask,val +refuge2_val_V0400,refuge,/home/rpotter/hypertower/REFUGE/Validation/Images/V0400.jpg,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0400.png,/home/rpotter/hypertower/REFUGE/Validation/Disc_Masks/V0400.png,mask,mask,val +refuge2_test_T0001,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0001.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0001.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0001.png,mask,mask,holdout +refuge2_test_T0002,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0002.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0002.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0002.png,mask,mask,holdout +refuge2_test_T0003,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0003.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0003.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0003.png,mask,mask,holdout +refuge2_test_T0004,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0004.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0004.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0004.png,mask,mask,holdout +refuge2_test_T0005,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0005.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0005.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0005.png,mask,mask,holdout +refuge2_test_T0006,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0006.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0006.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0006.png,mask,mask,holdout +refuge2_test_T0007,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0007.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0007.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0007.png,mask,mask,holdout +refuge2_test_T0008,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0008.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0008.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0008.png,mask,mask,holdout +refuge2_test_T0009,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0009.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0009.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0009.png,mask,mask,holdout +refuge2_test_T0010,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0010.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0010.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0010.png,mask,mask,holdout +refuge2_test_T0011,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0011.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0011.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0011.png,mask,mask,holdout +refuge2_test_T0012,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0012.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0012.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0012.png,mask,mask,holdout +refuge2_test_T0013,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0013.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0013.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0013.png,mask,mask,holdout +refuge2_test_T0014,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0014.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0014.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0014.png,mask,mask,holdout +refuge2_test_T0015,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0015.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0015.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0015.png,mask,mask,holdout +refuge2_test_T0016,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0016.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0016.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0016.png,mask,mask,holdout +refuge2_test_T0017,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0017.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0017.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0017.png,mask,mask,holdout +refuge2_test_T0018,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0018.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0018.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0018.png,mask,mask,holdout +refuge2_test_T0019,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0019.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0019.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0019.png,mask,mask,holdout +refuge2_test_T0020,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0020.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0020.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0020.png,mask,mask,holdout +refuge2_test_T0021,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0021.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0021.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0021.png,mask,mask,holdout +refuge2_test_T0022,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0022.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0022.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0022.png,mask,mask,holdout +refuge2_test_T0023,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0023.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0023.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0023.png,mask,mask,holdout +refuge2_test_T0024,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0024.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0024.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0024.png,mask,mask,holdout +refuge2_test_T0025,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0025.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0025.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0025.png,mask,mask,holdout +refuge2_test_T0026,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0026.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0026.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0026.png,mask,mask,holdout +refuge2_test_T0027,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0027.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0027.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0027.png,mask,mask,holdout +refuge2_test_T0028,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0028.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0028.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0028.png,mask,mask,holdout +refuge2_test_T0029,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0029.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0029.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0029.png,mask,mask,holdout +refuge2_test_T0030,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0030.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0030.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0030.png,mask,mask,holdout +refuge2_test_T0031,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0031.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0031.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0031.png,mask,mask,holdout +refuge2_test_T0032,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0032.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0032.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0032.png,mask,mask,holdout +refuge2_test_T0033,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0033.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0033.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0033.png,mask,mask,holdout +refuge2_test_T0034,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0034.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0034.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0034.png,mask,mask,holdout +refuge2_test_T0035,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0035.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0035.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0035.png,mask,mask,holdout +refuge2_test_T0036,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0036.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0036.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0036.png,mask,mask,holdout +refuge2_test_T0037,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0037.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0037.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0037.png,mask,mask,holdout +refuge2_test_T0038,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0038.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0038.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0038.png,mask,mask,holdout +refuge2_test_T0039,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0039.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0039.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0039.png,mask,mask,holdout +refuge2_test_T0040,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0040.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0040.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0040.png,mask,mask,holdout +refuge2_test_T0041,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0041.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0041.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0041.png,mask,mask,holdout +refuge2_test_T0042,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0042.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0042.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0042.png,mask,mask,holdout +refuge2_test_T0043,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0043.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0043.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0043.png,mask,mask,holdout +refuge2_test_T0044,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0044.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0044.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0044.png,mask,mask,holdout +refuge2_test_T0045,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0045.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0045.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0045.png,mask,mask,holdout +refuge2_test_T0046,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0046.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0046.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0046.png,mask,mask,holdout +refuge2_test_T0047,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0047.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0047.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0047.png,mask,mask,holdout +refuge2_test_T0048,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0048.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0048.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0048.png,mask,mask,holdout +refuge2_test_T0049,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0049.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0049.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0049.png,mask,mask,holdout +refuge2_test_T0050,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0050.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0050.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0050.png,mask,mask,holdout +refuge2_test_T0051,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0051.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0051.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0051.png,mask,mask,holdout +refuge2_test_T0052,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0052.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0052.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0052.png,mask,mask,holdout +refuge2_test_T0053,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0053.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0053.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0053.png,mask,mask,holdout +refuge2_test_T0054,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0054.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0054.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0054.png,mask,mask,holdout +refuge2_test_T0055,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0055.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0055.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0055.png,mask,mask,holdout +refuge2_test_T0056,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0056.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0056.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0056.png,mask,mask,holdout +refuge2_test_T0057,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0057.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0057.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0057.png,mask,mask,holdout +refuge2_test_T0058,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0058.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0058.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0058.png,mask,mask,holdout +refuge2_test_T0059,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0059.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0059.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0059.png,mask,mask,holdout +refuge2_test_T0060,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0060.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0060.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0060.png,mask,mask,holdout +refuge2_test_T0061,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0061.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0061.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0061.png,mask,mask,holdout +refuge2_test_T0062,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0062.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0062.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0062.png,mask,mask,holdout +refuge2_test_T0063,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0063.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0063.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0063.png,mask,mask,holdout +refuge2_test_T0064,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0064.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0064.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0064.png,mask,mask,holdout +refuge2_test_T0065,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0065.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0065.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0065.png,mask,mask,holdout +refuge2_test_T0066,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0066.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0066.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0066.png,mask,mask,holdout +refuge2_test_T0067,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0067.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0067.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0067.png,mask,mask,holdout +refuge2_test_T0068,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0068.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0068.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0068.png,mask,mask,holdout +refuge2_test_T0069,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0069.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0069.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0069.png,mask,mask,holdout +refuge2_test_T0070,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0070.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0070.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0070.png,mask,mask,holdout +refuge2_test_T0071,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0071.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0071.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0071.png,mask,mask,holdout +refuge2_test_T0072,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0072.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0072.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0072.png,mask,mask,holdout +refuge2_test_T0073,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0073.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0073.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0073.png,mask,mask,holdout +refuge2_test_T0074,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0074.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0074.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0074.png,mask,mask,holdout +refuge2_test_T0075,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0075.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0075.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0075.png,mask,mask,holdout +refuge2_test_T0076,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0076.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0076.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0076.png,mask,mask,holdout +refuge2_test_T0077,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0077.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0077.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0077.png,mask,mask,holdout +refuge2_test_T0078,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0078.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0078.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0078.png,mask,mask,holdout +refuge2_test_T0079,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0079.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0079.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0079.png,mask,mask,holdout +refuge2_test_T0080,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0080.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0080.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0080.png,mask,mask,holdout +refuge2_test_T0081,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0081.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0081.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0081.png,mask,mask,holdout +refuge2_test_T0082,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0082.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0082.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0082.png,mask,mask,holdout +refuge2_test_T0083,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0083.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0083.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0083.png,mask,mask,holdout +refuge2_test_T0084,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0084.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0084.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0084.png,mask,mask,holdout +refuge2_test_T0085,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0085.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0085.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0085.png,mask,mask,holdout +refuge2_test_T0086,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0086.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0086.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0086.png,mask,mask,holdout +refuge2_test_T0087,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0087.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0087.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0087.png,mask,mask,holdout +refuge2_test_T0088,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0088.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0088.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0088.png,mask,mask,holdout +refuge2_test_T0089,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0089.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0089.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0089.png,mask,mask,holdout +refuge2_test_T0090,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0090.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0090.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0090.png,mask,mask,holdout +refuge2_test_T0091,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0091.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0091.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0091.png,mask,mask,holdout +refuge2_test_T0092,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0092.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0092.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0092.png,mask,mask,holdout +refuge2_test_T0093,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0093.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0093.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0093.png,mask,mask,holdout +refuge2_test_T0094,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0094.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0094.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0094.png,mask,mask,holdout +refuge2_test_T0095,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0095.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0095.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0095.png,mask,mask,holdout +refuge2_test_T0096,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0096.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0096.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0096.png,mask,mask,holdout +refuge2_test_T0097,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0097.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0097.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0097.png,mask,mask,holdout +refuge2_test_T0098,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0098.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0098.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0098.png,mask,mask,holdout +refuge2_test_T0099,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0099.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0099.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0099.png,mask,mask,holdout +refuge2_test_T0100,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0100.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0100.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0100.png,mask,mask,holdout +refuge2_test_T0101,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0101.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0101.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0101.png,mask,mask,holdout +refuge2_test_T0102,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0102.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0102.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0102.png,mask,mask,holdout +refuge2_test_T0103,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0103.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0103.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0103.png,mask,mask,holdout +refuge2_test_T0104,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0104.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0104.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0104.png,mask,mask,holdout +refuge2_test_T0105,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0105.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0105.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0105.png,mask,mask,holdout +refuge2_test_T0106,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0106.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0106.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0106.png,mask,mask,holdout +refuge2_test_T0107,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0107.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0107.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0107.png,mask,mask,holdout +refuge2_test_T0108,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0108.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0108.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0108.png,mask,mask,holdout +refuge2_test_T0109,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0109.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0109.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0109.png,mask,mask,holdout +refuge2_test_T0110,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0110.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0110.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0110.png,mask,mask,holdout +refuge2_test_T0111,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0111.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0111.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0111.png,mask,mask,holdout +refuge2_test_T0112,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0112.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0112.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0112.png,mask,mask,holdout +refuge2_test_T0113,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0113.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0113.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0113.png,mask,mask,holdout +refuge2_test_T0114,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0114.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0114.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0114.png,mask,mask,holdout +refuge2_test_T0115,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0115.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0115.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0115.png,mask,mask,holdout +refuge2_test_T0116,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0116.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0116.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0116.png,mask,mask,holdout +refuge2_test_T0117,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0117.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0117.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0117.png,mask,mask,holdout +refuge2_test_T0118,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0118.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0118.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0118.png,mask,mask,holdout +refuge2_test_T0119,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0119.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0119.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0119.png,mask,mask,holdout +refuge2_test_T0120,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0120.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0120.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0120.png,mask,mask,holdout +refuge2_test_T0121,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0121.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0121.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0121.png,mask,mask,holdout +refuge2_test_T0122,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0122.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0122.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0122.png,mask,mask,holdout +refuge2_test_T0123,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0123.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0123.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0123.png,mask,mask,holdout +refuge2_test_T0124,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0124.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0124.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0124.png,mask,mask,holdout +refuge2_test_T0125,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0125.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0125.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0125.png,mask,mask,holdout +refuge2_test_T0126,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0126.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0126.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0126.png,mask,mask,holdout +refuge2_test_T0127,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0127.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0127.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0127.png,mask,mask,holdout +refuge2_test_T0128,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0128.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0128.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0128.png,mask,mask,holdout +refuge2_test_T0129,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0129.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0129.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0129.png,mask,mask,holdout +refuge2_test_T0130,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0130.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0130.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0130.png,mask,mask,holdout +refuge2_test_T0131,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0131.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0131.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0131.png,mask,mask,holdout +refuge2_test_T0132,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0132.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0132.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0132.png,mask,mask,holdout +refuge2_test_T0133,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0133.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0133.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0133.png,mask,mask,holdout +refuge2_test_T0134,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0134.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0134.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0134.png,mask,mask,holdout +refuge2_test_T0135,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0135.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0135.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0135.png,mask,mask,holdout +refuge2_test_T0136,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0136.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0136.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0136.png,mask,mask,holdout +refuge2_test_T0137,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0137.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0137.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0137.png,mask,mask,holdout +refuge2_test_T0138,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0138.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0138.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0138.png,mask,mask,holdout +refuge2_test_T0139,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0139.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0139.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0139.png,mask,mask,holdout +refuge2_test_T0140,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0140.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0140.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0140.png,mask,mask,holdout +refuge2_test_T0141,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0141.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0141.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0141.png,mask,mask,holdout +refuge2_test_T0142,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0142.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0142.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0142.png,mask,mask,holdout +refuge2_test_T0143,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0143.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0143.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0143.png,mask,mask,holdout +refuge2_test_T0144,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0144.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0144.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0144.png,mask,mask,holdout +refuge2_test_T0145,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0145.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0145.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0145.png,mask,mask,holdout +refuge2_test_T0146,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0146.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0146.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0146.png,mask,mask,holdout +refuge2_test_T0147,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0147.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0147.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0147.png,mask,mask,holdout +refuge2_test_T0148,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0148.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0148.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0148.png,mask,mask,holdout +refuge2_test_T0149,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0149.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0149.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0149.png,mask,mask,holdout +refuge2_test_T0150,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0150.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0150.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0150.png,mask,mask,holdout +refuge2_test_T0151,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0151.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0151.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0151.png,mask,mask,holdout +refuge2_test_T0152,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0152.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0152.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0152.png,mask,mask,holdout +refuge2_test_T0153,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0153.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0153.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0153.png,mask,mask,holdout +refuge2_test_T0154,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0154.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0154.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0154.png,mask,mask,holdout +refuge2_test_T0155,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0155.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0155.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0155.png,mask,mask,holdout +refuge2_test_T0156,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0156.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0156.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0156.png,mask,mask,holdout +refuge2_test_T0157,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0157.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0157.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0157.png,mask,mask,holdout +refuge2_test_T0158,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0158.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0158.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0158.png,mask,mask,holdout +refuge2_test_T0159,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0159.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0159.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0159.png,mask,mask,holdout +refuge2_test_T0160,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0160.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0160.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0160.png,mask,mask,holdout +refuge2_test_T0161,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0161.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0161.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0161.png,mask,mask,holdout +refuge2_test_T0162,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0162.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0162.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0162.png,mask,mask,holdout +refuge2_test_T0163,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0163.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0163.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0163.png,mask,mask,holdout +refuge2_test_T0164,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0164.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0164.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0164.png,mask,mask,holdout +refuge2_test_T0165,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0165.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0165.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0165.png,mask,mask,holdout +refuge2_test_T0166,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0166.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0166.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0166.png,mask,mask,holdout +refuge2_test_T0167,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0167.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0167.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0167.png,mask,mask,holdout +refuge2_test_T0168,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0168.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0168.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0168.png,mask,mask,holdout +refuge2_test_T0169,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0169.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0169.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0169.png,mask,mask,holdout +refuge2_test_T0170,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0170.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0170.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0170.png,mask,mask,holdout +refuge2_test_T0171,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0171.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0171.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0171.png,mask,mask,holdout +refuge2_test_T0172,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0172.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0172.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0172.png,mask,mask,holdout +refuge2_test_T0173,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0173.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0173.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0173.png,mask,mask,holdout +refuge2_test_T0174,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0174.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0174.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0174.png,mask,mask,holdout +refuge2_test_T0175,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0175.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0175.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0175.png,mask,mask,holdout +refuge2_test_T0176,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0176.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0176.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0176.png,mask,mask,holdout +refuge2_test_T0177,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0177.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0177.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0177.png,mask,mask,holdout +refuge2_test_T0178,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0178.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0178.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0178.png,mask,mask,holdout +refuge2_test_T0179,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0179.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0179.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0179.png,mask,mask,holdout +refuge2_test_T0180,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0180.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0180.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0180.png,mask,mask,holdout +refuge2_test_T0181,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0181.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0181.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0181.png,mask,mask,holdout +refuge2_test_T0182,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0182.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0182.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0182.png,mask,mask,holdout +refuge2_test_T0183,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0183.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0183.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0183.png,mask,mask,holdout +refuge2_test_T0184,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0184.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0184.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0184.png,mask,mask,holdout +refuge2_test_T0185,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0185.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0185.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0185.png,mask,mask,holdout +refuge2_test_T0186,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0186.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0186.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0186.png,mask,mask,holdout +refuge2_test_T0187,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0187.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0187.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0187.png,mask,mask,holdout +refuge2_test_T0188,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0188.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0188.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0188.png,mask,mask,holdout +refuge2_test_T0189,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0189.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0189.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0189.png,mask,mask,holdout +refuge2_test_T0190,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0190.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0190.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0190.png,mask,mask,holdout +refuge2_test_T0191,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0191.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0191.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0191.png,mask,mask,holdout +refuge2_test_T0192,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0192.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0192.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0192.png,mask,mask,holdout +refuge2_test_T0193,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0193.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0193.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0193.png,mask,mask,holdout +refuge2_test_T0194,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0194.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0194.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0194.png,mask,mask,holdout +refuge2_test_T0195,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0195.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0195.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0195.png,mask,mask,holdout +refuge2_test_T0196,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0196.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0196.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0196.png,mask,mask,holdout +refuge2_test_T0197,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0197.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0197.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0197.png,mask,mask,holdout +refuge2_test_T0198,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0198.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0198.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0198.png,mask,mask,holdout +refuge2_test_T0199,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0199.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0199.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0199.png,mask,mask,holdout +refuge2_test_T0200,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0200.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0200.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0200.png,mask,mask,holdout +refuge2_test_T0201,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0201.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0201.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0201.png,mask,mask,holdout +refuge2_test_T0202,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0202.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0202.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0202.png,mask,mask,holdout +refuge2_test_T0203,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0203.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0203.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0203.png,mask,mask,holdout +refuge2_test_T0204,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0204.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0204.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0204.png,mask,mask,holdout +refuge2_test_T0205,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0205.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0205.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0205.png,mask,mask,holdout +refuge2_test_T0206,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0206.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0206.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0206.png,mask,mask,holdout +refuge2_test_T0207,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0207.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0207.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0207.png,mask,mask,holdout +refuge2_test_T0208,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0208.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0208.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0208.png,mask,mask,holdout +refuge2_test_T0209,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0209.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0209.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0209.png,mask,mask,holdout +refuge2_test_T0210,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0210.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0210.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0210.png,mask,mask,holdout +refuge2_test_T0211,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0211.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0211.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0211.png,mask,mask,holdout +refuge2_test_T0212,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0212.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0212.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0212.png,mask,mask,holdout +refuge2_test_T0213,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0213.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0213.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0213.png,mask,mask,holdout +refuge2_test_T0214,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0214.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0214.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0214.png,mask,mask,holdout +refuge2_test_T0215,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0215.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0215.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0215.png,mask,mask,holdout +refuge2_test_T0216,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0216.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0216.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0216.png,mask,mask,holdout +refuge2_test_T0217,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0217.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0217.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0217.png,mask,mask,holdout +refuge2_test_T0218,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0218.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0218.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0218.png,mask,mask,holdout +refuge2_test_T0219,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0219.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0219.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0219.png,mask,mask,holdout +refuge2_test_T0220,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0220.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0220.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0220.png,mask,mask,holdout +refuge2_test_T0221,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0221.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0221.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0221.png,mask,mask,holdout +refuge2_test_T0222,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0222.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0222.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0222.png,mask,mask,holdout +refuge2_test_T0223,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0223.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0223.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0223.png,mask,mask,holdout +refuge2_test_T0224,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0224.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0224.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0224.png,mask,mask,holdout +refuge2_test_T0225,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0225.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0225.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0225.png,mask,mask,holdout +refuge2_test_T0226,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0226.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0226.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0226.png,mask,mask,holdout +refuge2_test_T0227,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0227.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0227.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0227.png,mask,mask,holdout +refuge2_test_T0228,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0228.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0228.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0228.png,mask,mask,holdout +refuge2_test_T0229,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0229.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0229.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0229.png,mask,mask,holdout +refuge2_test_T0230,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0230.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0230.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0230.png,mask,mask,holdout +refuge2_test_T0231,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0231.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0231.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0231.png,mask,mask,holdout +refuge2_test_T0232,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0232.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0232.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0232.png,mask,mask,holdout +refuge2_test_T0233,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0233.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0233.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0233.png,mask,mask,holdout +refuge2_test_T0234,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0234.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0234.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0234.png,mask,mask,holdout +refuge2_test_T0235,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0235.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0235.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0235.png,mask,mask,holdout +refuge2_test_T0236,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0236.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0236.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0236.png,mask,mask,holdout +refuge2_test_T0237,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0237.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0237.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0237.png,mask,mask,holdout +refuge2_test_T0238,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0238.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0238.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0238.png,mask,mask,holdout +refuge2_test_T0239,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0239.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0239.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0239.png,mask,mask,holdout +refuge2_test_T0240,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0240.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0240.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0240.png,mask,mask,holdout +refuge2_test_T0241,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0241.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0241.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0241.png,mask,mask,holdout +refuge2_test_T0242,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0242.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0242.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0242.png,mask,mask,holdout +refuge2_test_T0243,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0243.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0243.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0243.png,mask,mask,holdout +refuge2_test_T0244,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0244.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0244.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0244.png,mask,mask,holdout +refuge2_test_T0245,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0245.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0245.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0245.png,mask,mask,holdout +refuge2_test_T0246,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0246.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0246.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0246.png,mask,mask,holdout +refuge2_test_T0247,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0247.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0247.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0247.png,mask,mask,holdout +refuge2_test_T0248,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0248.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0248.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0248.png,mask,mask,holdout +refuge2_test_T0249,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0249.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0249.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0249.png,mask,mask,holdout +refuge2_test_T0250,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0250.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0250.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0250.png,mask,mask,holdout +refuge2_test_T0251,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0251.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0251.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0251.png,mask,mask,holdout +refuge2_test_T0252,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0252.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0252.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0252.png,mask,mask,holdout +refuge2_test_T0253,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0253.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0253.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0253.png,mask,mask,holdout +refuge2_test_T0254,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0254.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0254.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0254.png,mask,mask,holdout +refuge2_test_T0255,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0255.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0255.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0255.png,mask,mask,holdout +refuge2_test_T0256,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0256.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0256.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0256.png,mask,mask,holdout +refuge2_test_T0257,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0257.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0257.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0257.png,mask,mask,holdout +refuge2_test_T0258,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0258.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0258.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0258.png,mask,mask,holdout +refuge2_test_T0259,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0259.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0259.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0259.png,mask,mask,holdout +refuge2_test_T0260,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0260.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0260.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0260.png,mask,mask,holdout +refuge2_test_T0261,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0261.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0261.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0261.png,mask,mask,holdout +refuge2_test_T0262,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0262.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0262.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0262.png,mask,mask,holdout +refuge2_test_T0263,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0263.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0263.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0263.png,mask,mask,holdout +refuge2_test_T0264,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0264.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0264.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0264.png,mask,mask,holdout +refuge2_test_T0265,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0265.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0265.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0265.png,mask,mask,holdout +refuge2_test_T0266,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0266.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0266.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0266.png,mask,mask,holdout +refuge2_test_T0267,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0267.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0267.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0267.png,mask,mask,holdout +refuge2_test_T0268,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0268.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0268.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0268.png,mask,mask,holdout +refuge2_test_T0269,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0269.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0269.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0269.png,mask,mask,holdout +refuge2_test_T0270,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0270.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0270.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0270.png,mask,mask,holdout +refuge2_test_T0271,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0271.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0271.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0271.png,mask,mask,holdout +refuge2_test_T0272,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0272.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0272.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0272.png,mask,mask,holdout +refuge2_test_T0273,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0273.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0273.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0273.png,mask,mask,holdout +refuge2_test_T0274,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0274.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0274.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0274.png,mask,mask,holdout +refuge2_test_T0275,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0275.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0275.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0275.png,mask,mask,holdout +refuge2_test_T0276,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0276.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0276.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0276.png,mask,mask,holdout +refuge2_test_T0277,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0277.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0277.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0277.png,mask,mask,holdout +refuge2_test_T0278,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0278.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0278.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0278.png,mask,mask,holdout +refuge2_test_T0279,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0279.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0279.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0279.png,mask,mask,holdout +refuge2_test_T0280,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0280.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0280.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0280.png,mask,mask,holdout +refuge2_test_T0281,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0281.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0281.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0281.png,mask,mask,holdout +refuge2_test_T0282,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0282.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0282.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0282.png,mask,mask,holdout +refuge2_test_T0283,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0283.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0283.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0283.png,mask,mask,holdout +refuge2_test_T0284,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0284.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0284.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0284.png,mask,mask,holdout +refuge2_test_T0285,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0285.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0285.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0285.png,mask,mask,holdout +refuge2_test_T0286,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0286.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0286.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0286.png,mask,mask,holdout +refuge2_test_T0287,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0287.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0287.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0287.png,mask,mask,holdout +refuge2_test_T0288,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0288.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0288.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0288.png,mask,mask,holdout +refuge2_test_T0289,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0289.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0289.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0289.png,mask,mask,holdout +refuge2_test_T0290,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0290.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0290.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0290.png,mask,mask,holdout +refuge2_test_T0291,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0291.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0291.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0291.png,mask,mask,holdout +refuge2_test_T0292,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0292.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0292.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0292.png,mask,mask,holdout +refuge2_test_T0293,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0293.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0293.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0293.png,mask,mask,holdout +refuge2_test_T0294,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0294.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0294.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0294.png,mask,mask,holdout +refuge2_test_T0295,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0295.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0295.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0295.png,mask,mask,holdout +refuge2_test_T0296,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0296.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0296.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0296.png,mask,mask,holdout +refuge2_test_T0297,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0297.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0297.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0297.png,mask,mask,holdout +refuge2_test_T0298,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0298.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0298.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0298.png,mask,mask,holdout +refuge2_test_T0299,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0299.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0299.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0299.png,mask,mask,holdout +refuge2_test_T0300,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0300.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0300.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0300.png,mask,mask,holdout +refuge2_test_T0301,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0301.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0301.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0301.png,mask,mask,holdout +refuge2_test_T0302,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0302.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0302.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0302.png,mask,mask,holdout +refuge2_test_T0303,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0303.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0303.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0303.png,mask,mask,holdout +refuge2_test_T0304,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0304.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0304.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0304.png,mask,mask,holdout +refuge2_test_T0305,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0305.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0305.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0305.png,mask,mask,holdout +refuge2_test_T0306,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0306.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0306.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0306.png,mask,mask,holdout +refuge2_test_T0307,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0307.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0307.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0307.png,mask,mask,holdout +refuge2_test_T0308,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0308.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0308.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0308.png,mask,mask,holdout +refuge2_test_T0309,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0309.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0309.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0309.png,mask,mask,holdout +refuge2_test_T0310,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0310.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0310.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0310.png,mask,mask,holdout +refuge2_test_T0311,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0311.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0311.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0311.png,mask,mask,holdout +refuge2_test_T0312,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0312.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0312.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0312.png,mask,mask,holdout +refuge2_test_T0313,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0313.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0313.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0313.png,mask,mask,holdout +refuge2_test_T0314,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0314.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0314.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0314.png,mask,mask,holdout +refuge2_test_T0315,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0315.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0315.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0315.png,mask,mask,holdout +refuge2_test_T0316,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0316.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0316.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0316.png,mask,mask,holdout +refuge2_test_T0317,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0317.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0317.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0317.png,mask,mask,holdout +refuge2_test_T0318,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0318.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0318.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0318.png,mask,mask,holdout +refuge2_test_T0319,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0319.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0319.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0319.png,mask,mask,holdout +refuge2_test_T0320,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0320.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0320.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0320.png,mask,mask,holdout +refuge2_test_T0321,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0321.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0321.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0321.png,mask,mask,holdout +refuge2_test_T0322,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0322.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0322.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0322.png,mask,mask,holdout +refuge2_test_T0323,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0323.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0323.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0323.png,mask,mask,holdout +refuge2_test_T0324,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0324.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0324.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0324.png,mask,mask,holdout +refuge2_test_T0325,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0325.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0325.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0325.png,mask,mask,holdout +refuge2_test_T0326,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0326.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0326.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0326.png,mask,mask,holdout +refuge2_test_T0327,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0327.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0327.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0327.png,mask,mask,holdout +refuge2_test_T0328,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0328.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0328.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0328.png,mask,mask,holdout +refuge2_test_T0329,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0329.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0329.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0329.png,mask,mask,holdout +refuge2_test_T0330,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0330.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0330.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0330.png,mask,mask,holdout +refuge2_test_T0331,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0331.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0331.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0331.png,mask,mask,holdout +refuge2_test_T0332,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0332.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0332.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0332.png,mask,mask,holdout +refuge2_test_T0333,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0333.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0333.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0333.png,mask,mask,holdout +refuge2_test_T0334,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0334.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0334.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0334.png,mask,mask,holdout +refuge2_test_T0335,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0335.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0335.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0335.png,mask,mask,holdout +refuge2_test_T0336,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0336.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0336.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0336.png,mask,mask,holdout +refuge2_test_T0337,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0337.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0337.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0337.png,mask,mask,holdout +refuge2_test_T0338,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0338.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0338.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0338.png,mask,mask,holdout +refuge2_test_T0339,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0339.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0339.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0339.png,mask,mask,holdout +refuge2_test_T0340,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0340.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0340.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0340.png,mask,mask,holdout +refuge2_test_T0341,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0341.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0341.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0341.png,mask,mask,holdout +refuge2_test_T0342,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0342.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0342.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0342.png,mask,mask,holdout +refuge2_test_T0343,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0343.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0343.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0343.png,mask,mask,holdout +refuge2_test_T0344,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0344.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0344.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0344.png,mask,mask,holdout +refuge2_test_T0345,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0345.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0345.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0345.png,mask,mask,holdout +refuge2_test_T0346,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0346.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0346.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0346.png,mask,mask,holdout +refuge2_test_T0347,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0347.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0347.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0347.png,mask,mask,holdout +refuge2_test_T0348,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0348.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0348.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0348.png,mask,mask,holdout +refuge2_test_T0349,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0349.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0349.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0349.png,mask,mask,holdout +refuge2_test_T0350,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0350.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0350.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0350.png,mask,mask,holdout +refuge2_test_T0351,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0351.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0351.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0351.png,mask,mask,holdout +refuge2_test_T0352,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0352.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0352.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0352.png,mask,mask,holdout +refuge2_test_T0353,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0353.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0353.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0353.png,mask,mask,holdout +refuge2_test_T0354,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0354.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0354.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0354.png,mask,mask,holdout +refuge2_test_T0355,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0355.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0355.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0355.png,mask,mask,holdout +refuge2_test_T0356,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0356.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0356.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0356.png,mask,mask,holdout +refuge2_test_T0357,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0357.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0357.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0357.png,mask,mask,holdout +refuge2_test_T0358,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0358.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0358.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0358.png,mask,mask,holdout +refuge2_test_T0359,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0359.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0359.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0359.png,mask,mask,holdout +refuge2_test_T0360,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0360.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0360.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0360.png,mask,mask,holdout +refuge2_test_T0361,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0361.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0361.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0361.png,mask,mask,holdout +refuge2_test_T0362,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0362.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0362.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0362.png,mask,mask,holdout +refuge2_test_T0363,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0363.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0363.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0363.png,mask,mask,holdout +refuge2_test_T0364,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0364.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0364.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0364.png,mask,mask,holdout +refuge2_test_T0365,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0365.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0365.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0365.png,mask,mask,holdout +refuge2_test_T0366,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0366.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0366.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0366.png,mask,mask,holdout +refuge2_test_T0367,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0367.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0367.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0367.png,mask,mask,holdout +refuge2_test_T0368,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0368.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0368.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0368.png,mask,mask,holdout +refuge2_test_T0369,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0369.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0369.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0369.png,mask,mask,holdout +refuge2_test_T0370,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0370.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0370.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0370.png,mask,mask,holdout +refuge2_test_T0371,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0371.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0371.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0371.png,mask,mask,holdout +refuge2_test_T0372,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0372.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0372.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0372.png,mask,mask,holdout +refuge2_test_T0373,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0373.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0373.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0373.png,mask,mask,holdout +refuge2_test_T0374,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0374.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0374.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0374.png,mask,mask,holdout +refuge2_test_T0375,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0375.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0375.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0375.png,mask,mask,holdout +refuge2_test_T0376,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0376.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0376.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0376.png,mask,mask,holdout +refuge2_test_T0377,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0377.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0377.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0377.png,mask,mask,holdout +refuge2_test_T0378,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0378.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0378.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0378.png,mask,mask,holdout +refuge2_test_T0379,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0379.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0379.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0379.png,mask,mask,holdout +refuge2_test_T0380,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0380.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0380.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0380.png,mask,mask,holdout +refuge2_test_T0381,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0381.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0381.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0381.png,mask,mask,holdout +refuge2_test_T0382,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0382.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0382.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0382.png,mask,mask,holdout +refuge2_test_T0383,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0383.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0383.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0383.png,mask,mask,holdout +refuge2_test_T0384,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0384.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0384.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0384.png,mask,mask,holdout +refuge2_test_T0385,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0385.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0385.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0385.png,mask,mask,holdout +refuge2_test_T0386,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0386.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0386.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0386.png,mask,mask,holdout +refuge2_test_T0387,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0387.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0387.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0387.png,mask,mask,holdout +refuge2_test_T0388,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0388.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0388.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0388.png,mask,mask,holdout +refuge2_test_T0389,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0389.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0389.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0389.png,mask,mask,holdout +refuge2_test_T0390,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0390.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0390.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0390.png,mask,mask,holdout +refuge2_test_T0391,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0391.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0391.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0391.png,mask,mask,holdout +refuge2_test_T0392,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0392.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0392.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0392.png,mask,mask,holdout +refuge2_test_T0393,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0393.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0393.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0393.png,mask,mask,holdout +refuge2_test_T0394,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0394.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0394.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0394.png,mask,mask,holdout +refuge2_test_T0395,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0395.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0395.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0395.png,mask,mask,holdout +refuge2_test_T0396,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0396.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0396.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0396.png,mask,mask,holdout +refuge2_test_T0397,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0397.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0397.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0397.png,mask,mask,holdout +refuge2_test_T0398,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0398.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0398.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0398.png,mask,mask,holdout +refuge2_test_T0399,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0399.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0399.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0399.png,mask,mask,holdout +refuge2_test_T0400,refuge,/home/rpotter/hypertower/REFUGE/Test/refuge2-test/T0400.jpg,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0400.png,/home/rpotter/hypertower/REFUGE/Test/Disc_Mask/T0400.png,mask,mask,holdout +papila_RET002OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET002OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET002OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET002OD_cup_exp2.txt,contour,contour,train +papila_RET002OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET002OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET002OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET002OS_cup_exp2.txt,contour,contour,train +papila_RET004OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET004OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET004OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET004OD_cup_exp2.txt,contour,contour,train +papila_RET004OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET004OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET004OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET004OS_cup_exp2.txt,contour,contour,train +papila_RET005OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET005OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET005OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET005OD_cup_exp2.txt,contour,contour,holdout +papila_RET005OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET005OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET005OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET005OS_cup_exp2.txt,contour,contour,train +papila_RET006OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET006OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET006OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET006OD_cup_exp2.txt,contour,contour,train +papila_RET006OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET006OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET006OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET006OS_cup_exp2.txt,contour,contour,holdout +papila_RET007OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET007OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET007OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET007OD_cup_exp2.txt,contour,contour,train +papila_RET007OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET007OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET007OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET007OS_cup_exp2.txt,contour,contour,train +papila_RET008OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET008OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET008OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET008OD_cup_exp2.txt,contour,contour,train +papila_RET008OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET008OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET008OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET008OS_cup_exp2.txt,contour,contour,train +papila_RET009OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET009OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET009OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET009OD_cup_exp2.txt,contour,contour,train +papila_RET009OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET009OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET009OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET009OS_cup_exp2.txt,contour,contour,train +papila_RET010OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET010OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET010OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET010OD_cup_exp2.txt,contour,contour,train +papila_RET010OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET010OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET010OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET010OS_cup_exp2.txt,contour,contour,train +papila_RET013OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET013OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET013OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET013OD_cup_exp2.txt,contour,contour,train +papila_RET013OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET013OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET013OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET013OS_cup_exp2.txt,contour,contour,train +papila_RET014OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET014OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET014OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET014OD_cup_exp2.txt,contour,contour,train +papila_RET014OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET014OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET014OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET014OS_cup_exp2.txt,contour,contour,train +papila_RET015OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET015OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET015OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET015OD_cup_exp2.txt,contour,contour,train +papila_RET015OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET015OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET015OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET015OS_cup_exp2.txt,contour,contour,train +papila_RET016OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET016OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET016OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET016OD_cup_exp2.txt,contour,contour,train +papila_RET016OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET016OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET016OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET016OS_cup_exp2.txt,contour,contour,train +papila_RET018OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET018OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET018OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET018OD_cup_exp2.txt,contour,contour,train +papila_RET018OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET018OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET018OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET018OS_cup_exp2.txt,contour,contour,train +papila_RET019OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET019OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET019OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET019OD_cup_exp2.txt,contour,contour,train +papila_RET019OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET019OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET019OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET019OS_cup_exp2.txt,contour,contour,train +papila_RET020OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET020OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET020OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET020OD_cup_exp2.txt,contour,contour,train +papila_RET020OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET020OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET020OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET020OS_cup_exp2.txt,contour,contour,train +papila_RET021OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET021OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET021OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET021OD_cup_exp2.txt,contour,contour,train +papila_RET021OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET021OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET021OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET021OS_cup_exp2.txt,contour,contour,train +papila_RET023OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET023OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET023OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET023OD_cup_exp2.txt,contour,contour,train +papila_RET023OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET023OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET023OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET023OS_cup_exp2.txt,contour,contour,train +papila_RET024OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET024OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET024OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET024OD_cup_exp2.txt,contour,contour,train +papila_RET024OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET024OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET024OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET024OS_cup_exp2.txt,contour,contour,train +papila_RET025OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET025OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET025OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET025OD_cup_exp2.txt,contour,contour,train +papila_RET025OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET025OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET025OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET025OS_cup_exp2.txt,contour,contour,train +papila_RET026OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET026OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET026OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET026OD_cup_exp2.txt,contour,contour,train +papila_RET026OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET026OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET026OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET026OS_cup_exp2.txt,contour,contour,train +papila_RET027OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET027OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET027OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET027OD_cup_exp2.txt,contour,contour,train +papila_RET027OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET027OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET027OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET027OS_cup_exp2.txt,contour,contour,holdout +papila_RET028OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET028OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET028OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET028OD_cup_exp2.txt,contour,contour,train +papila_RET028OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET028OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET028OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET028OS_cup_exp2.txt,contour,contour,train +papila_RET029OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET029OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET029OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET029OD_cup_exp2.txt,contour,contour,train +papila_RET029OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET029OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET029OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET029OS_cup_exp2.txt,contour,contour,holdout +papila_RET030OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET030OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET030OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET030OD_cup_exp2.txt,contour,contour,train +papila_RET030OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET030OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET030OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET030OS_cup_exp2.txt,contour,contour,train +papila_RET031OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET031OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET031OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET031OD_cup_exp2.txt,contour,contour,train +papila_RET031OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET031OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET031OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET031OS_cup_exp2.txt,contour,contour,train +papila_RET032OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET032OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET032OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET032OD_cup_exp2.txt,contour,contour,train +papila_RET032OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET032OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET032OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET032OS_cup_exp2.txt,contour,contour,train +papila_RET033OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET033OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET033OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET033OD_cup_exp2.txt,contour,contour,train +papila_RET033OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET033OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET033OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET033OS_cup_exp2.txt,contour,contour,train +papila_RET034OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET034OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET034OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET034OD_cup_exp2.txt,contour,contour,train +papila_RET034OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET034OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET034OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET034OS_cup_exp2.txt,contour,contour,train +papila_RET035OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET035OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET035OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET035OD_cup_exp2.txt,contour,contour,train +papila_RET035OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET035OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET035OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET035OS_cup_exp2.txt,contour,contour,train +papila_RET036OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET036OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET036OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET036OD_cup_exp2.txt,contour,contour,train +papila_RET036OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET036OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET036OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET036OS_cup_exp2.txt,contour,contour,train +papila_RET037OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET037OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET037OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET037OD_cup_exp2.txt,contour,contour,train +papila_RET037OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET037OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET037OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET037OS_cup_exp2.txt,contour,contour,train +papila_RET038OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET038OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET038OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET038OD_cup_exp2.txt,contour,contour,train +papila_RET038OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET038OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET038OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET038OS_cup_exp2.txt,contour,contour,train +papila_RET039OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET039OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET039OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET039OD_cup_exp2.txt,contour,contour,train +papila_RET039OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET039OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET039OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET039OS_cup_exp2.txt,contour,contour,train +papila_RET041OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET041OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET041OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET041OD_cup_exp2.txt,contour,contour,train +papila_RET041OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET041OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET041OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET041OS_cup_exp2.txt,contour,contour,train +papila_RET042OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET042OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET042OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET042OD_cup_exp2.txt,contour,contour,train +papila_RET042OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET042OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET042OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET042OS_cup_exp2.txt,contour,contour,train +papila_RET044OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET044OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET044OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET044OD_cup_exp2.txt,contour,contour,train +papila_RET044OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET044OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET044OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET044OS_cup_exp2.txt,contour,contour,train +papila_RET045OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET045OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET045OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET045OD_cup_exp2.txt,contour,contour,train +papila_RET045OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET045OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET045OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET045OS_cup_exp2.txt,contour,contour,train +papila_RET046OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET046OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET046OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET046OD_cup_exp2.txt,contour,contour,train +papila_RET046OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET046OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET046OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET046OS_cup_exp2.txt,contour,contour,train +papila_RET047OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET047OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET047OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET047OD_cup_exp2.txt,contour,contour,train +papila_RET047OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET047OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET047OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET047OS_cup_exp2.txt,contour,contour,train +papila_RET048OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET048OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET048OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET048OD_cup_exp2.txt,contour,contour,train +papila_RET048OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET048OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET048OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET048OS_cup_exp2.txt,contour,contour,train +papila_RET050OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET050OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET050OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET050OD_cup_exp2.txt,contour,contour,train +papila_RET050OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET050OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET050OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET050OS_cup_exp2.txt,contour,contour,train +papila_RET051OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET051OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET051OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET051OD_cup_exp2.txt,contour,contour,train +papila_RET051OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET051OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET051OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET051OS_cup_exp2.txt,contour,contour,train +papila_RET053OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET053OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET053OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET053OD_cup_exp2.txt,contour,contour,train +papila_RET053OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET053OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET053OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET053OS_cup_exp2.txt,contour,contour,train +papila_RET055OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET055OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET055OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET055OD_cup_exp2.txt,contour,contour,train +papila_RET055OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET055OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET055OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET055OS_cup_exp2.txt,contour,contour,train +papila_RET056OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET056OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET056OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET056OD_cup_exp2.txt,contour,contour,train +papila_RET056OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET056OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET056OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET056OS_cup_exp2.txt,contour,contour,train +papila_RET057OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET057OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET057OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET057OD_cup_exp2.txt,contour,contour,train +papila_RET057OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET057OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET057OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET057OS_cup_exp2.txt,contour,contour,train +papila_RET062OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET062OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET062OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET062OD_cup_exp2.txt,contour,contour,train +papila_RET062OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET062OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET062OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET062OS_cup_exp2.txt,contour,contour,train +papila_RET064OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET064OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET064OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET064OD_cup_exp2.txt,contour,contour,holdout +papila_RET064OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET064OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET064OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET064OS_cup_exp2.txt,contour,contour,train +papila_RET065OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET065OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET065OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET065OD_cup_exp2.txt,contour,contour,train +papila_RET065OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET065OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET065OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET065OS_cup_exp2.txt,contour,contour,train +papila_RET066OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET066OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET066OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET066OD_cup_exp2.txt,contour,contour,train +papila_RET066OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET066OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET066OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET066OS_cup_exp2.txt,contour,contour,train +papila_RET067OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET067OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET067OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET067OD_cup_exp2.txt,contour,contour,train +papila_RET067OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET067OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET067OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET067OS_cup_exp2.txt,contour,contour,train +papila_RET068OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET068OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET068OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET068OD_cup_exp2.txt,contour,contour,train +papila_RET068OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET068OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET068OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET068OS_cup_exp2.txt,contour,contour,train +papila_RET069OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET069OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET069OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET069OD_cup_exp2.txt,contour,contour,train +papila_RET069OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET069OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET069OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET069OS_cup_exp2.txt,contour,contour,train +papila_RET072OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET072OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET072OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET072OD_cup_exp2.txt,contour,contour,train +papila_RET072OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET072OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET072OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET072OS_cup_exp2.txt,contour,contour,train +papila_RET073OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET073OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET073OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET073OD_cup_exp2.txt,contour,contour,train +papila_RET073OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET073OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET073OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET073OS_cup_exp2.txt,contour,contour,train +papila_RET074OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET074OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET074OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET074OD_cup_exp2.txt,contour,contour,train +papila_RET074OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET074OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET074OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET074OS_cup_exp2.txt,contour,contour,train +papila_RET077OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET077OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET077OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET077OD_cup_exp2.txt,contour,contour,train +papila_RET077OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET077OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET077OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET077OS_cup_exp2.txt,contour,contour,train +papila_RET079OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET079OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET079OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET079OD_cup_exp2.txt,contour,contour,train +papila_RET079OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET079OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET079OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET079OS_cup_exp2.txt,contour,contour,train +papila_RET081OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET081OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET081OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET081OD_cup_exp2.txt,contour,contour,train +papila_RET081OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET081OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET081OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET081OS_cup_exp2.txt,contour,contour,train +papila_RET084OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET084OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET084OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET084OD_cup_exp2.txt,contour,contour,train +papila_RET084OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET084OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET084OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET084OS_cup_exp2.txt,contour,contour,train +papila_RET087OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET087OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET087OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET087OD_cup_exp2.txt,contour,contour,train +papila_RET087OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET087OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET087OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET087OS_cup_exp2.txt,contour,contour,train +papila_RET088OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET088OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET088OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET088OD_cup_exp2.txt,contour,contour,train +papila_RET088OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET088OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET088OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET088OS_cup_exp2.txt,contour,contour,train +papila_RET089OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET089OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET089OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET089OD_cup_exp2.txt,contour,contour,train +papila_RET089OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET089OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET089OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET089OS_cup_exp2.txt,contour,contour,train +papila_RET090OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET090OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET090OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET090OD_cup_exp2.txt,contour,contour,train +papila_RET090OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET090OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET090OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET090OS_cup_exp2.txt,contour,contour,train +papila_RET091OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET091OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET091OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET091OD_cup_exp2.txt,contour,contour,train +papila_RET091OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET091OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET091OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET091OS_cup_exp2.txt,contour,contour,holdout +papila_RET092OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET092OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET092OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET092OD_cup_exp2.txt,contour,contour,train +papila_RET092OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET092OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET092OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET092OS_cup_exp2.txt,contour,contour,train +papila_RET093OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET093OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET093OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET093OD_cup_exp2.txt,contour,contour,train +papila_RET093OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET093OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET093OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET093OS_cup_exp2.txt,contour,contour,train +papila_RET096OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET096OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET096OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET096OD_cup_exp2.txt,contour,contour,train +papila_RET096OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET096OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET096OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET096OS_cup_exp2.txt,contour,contour,train +papila_RET098OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET098OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET098OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET098OD_cup_exp2.txt,contour,contour,train +papila_RET098OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET098OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET098OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET098OS_cup_exp2.txt,contour,contour,train +papila_RET101OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET101OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET101OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET101OD_cup_exp2.txt,contour,contour,train +papila_RET101OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET101OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET101OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET101OS_cup_exp2.txt,contour,contour,train +papila_RET102OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET102OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET102OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET102OD_cup_exp2.txt,contour,contour,train +papila_RET102OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET102OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET102OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET102OS_cup_exp2.txt,contour,contour,train +papila_RET104OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET104OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET104OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET104OD_cup_exp2.txt,contour,contour,train +papila_RET104OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET104OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET104OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET104OS_cup_exp2.txt,contour,contour,train +papila_RET105OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET105OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET105OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET105OD_cup_exp2.txt,contour,contour,train +papila_RET105OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET105OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET105OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET105OS_cup_exp2.txt,contour,contour,train +papila_RET106OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET106OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET106OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET106OD_cup_exp2.txt,contour,contour,train +papila_RET106OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET106OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET106OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET106OS_cup_exp2.txt,contour,contour,train +papila_RET107OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET107OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET107OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET107OD_cup_exp2.txt,contour,contour,train +papila_RET107OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET107OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET107OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET107OS_cup_exp2.txt,contour,contour,train +papila_RET108OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET108OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET108OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET108OD_cup_exp2.txt,contour,contour,train +papila_RET108OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET108OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET108OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET108OS_cup_exp2.txt,contour,contour,train +papila_RET112OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET112OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET112OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET112OD_cup_exp2.txt,contour,contour,train +papila_RET112OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET112OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET112OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET112OS_cup_exp2.txt,contour,contour,train +papila_RET113OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET113OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET113OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET113OD_cup_exp2.txt,contour,contour,holdout +papila_RET113OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET113OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET113OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET113OS_cup_exp2.txt,contour,contour,train +papila_RET114OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET114OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET114OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET114OD_cup_exp2.txt,contour,contour,holdout +papila_RET114OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET114OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET114OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET114OS_cup_exp2.txt,contour,contour,train +papila_RET116OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET116OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET116OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET116OD_cup_exp2.txt,contour,contour,train +papila_RET116OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET116OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET116OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET116OS_cup_exp2.txt,contour,contour,train +papila_RET119OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET119OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET119OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET119OD_cup_exp2.txt,contour,contour,train +papila_RET119OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET119OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET119OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET119OS_cup_exp2.txt,contour,contour,train +papila_RET120OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET120OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET120OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET120OD_cup_exp2.txt,contour,contour,train +papila_RET120OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET120OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET120OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET120OS_cup_exp2.txt,contour,contour,train +papila_RET121OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET121OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET121OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET121OD_cup_exp2.txt,contour,contour,train +papila_RET121OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET121OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET121OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET121OS_cup_exp2.txt,contour,contour,train +papila_RET122OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET122OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET122OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET122OD_cup_exp2.txt,contour,contour,train +papila_RET122OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET122OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET122OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET122OS_cup_exp2.txt,contour,contour,train +papila_RET123OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET123OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET123OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET123OD_cup_exp2.txt,contour,contour,train +papila_RET123OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET123OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET123OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET123OS_cup_exp2.txt,contour,contour,train +papila_RET124OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET124OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET124OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET124OD_cup_exp2.txt,contour,contour,train +papila_RET124OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET124OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET124OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET124OS_cup_exp2.txt,contour,contour,train +papila_RET125OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET125OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET125OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET125OD_cup_exp2.txt,contour,contour,train +papila_RET125OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET125OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET125OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET125OS_cup_exp2.txt,contour,contour,train +papila_RET126OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET126OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET126OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET126OD_cup_exp2.txt,contour,contour,train +papila_RET126OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET126OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET126OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET126OS_cup_exp2.txt,contour,contour,holdout +papila_RET127OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET127OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET127OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET127OD_cup_exp2.txt,contour,contour,train +papila_RET127OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET127OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET127OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET127OS_cup_exp2.txt,contour,contour,train +papila_RET128OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET128OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET128OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET128OD_cup_exp2.txt,contour,contour,train +papila_RET128OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET128OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET128OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET128OS_cup_exp2.txt,contour,contour,train +papila_RET129OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET129OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET129OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET129OD_cup_exp2.txt,contour,contour,train +papila_RET129OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET129OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET129OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET129OS_cup_exp2.txt,contour,contour,train +papila_RET130OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET130OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET130OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET130OD_cup_exp2.txt,contour,contour,train +papila_RET130OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET130OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET130OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET130OS_cup_exp2.txt,contour,contour,train +papila_RET131OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET131OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET131OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET131OD_cup_exp2.txt,contour,contour,train +papila_RET131OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET131OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET131OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET131OS_cup_exp2.txt,contour,contour,train +papila_RET132OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET132OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET132OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET132OD_cup_exp2.txt,contour,contour,train +papila_RET132OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET132OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET132OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET132OS_cup_exp2.txt,contour,contour,train +papila_RET133OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET133OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET133OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET133OD_cup_exp2.txt,contour,contour,holdout +papila_RET133OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET133OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET133OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET133OS_cup_exp2.txt,contour,contour,train +papila_RET134OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET134OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET134OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET134OD_cup_exp2.txt,contour,contour,train +papila_RET134OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET134OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET134OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET134OS_cup_exp2.txt,contour,contour,train +papila_RET135OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET135OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET135OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET135OD_cup_exp2.txt,contour,contour,holdout +papila_RET135OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET135OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET135OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET135OS_cup_exp2.txt,contour,contour,train +papila_RET136OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET136OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET136OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET136OD_cup_exp2.txt,contour,contour,train +papila_RET136OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET136OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET136OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET136OS_cup_exp2.txt,contour,contour,train +papila_RET137OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET137OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET137OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET137OD_cup_exp2.txt,contour,contour,train +papila_RET137OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET137OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET137OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET137OS_cup_exp2.txt,contour,contour,train +papila_RET138OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET138OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET138OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET138OD_cup_exp2.txt,contour,contour,holdout +papila_RET138OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET138OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET138OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET138OS_cup_exp2.txt,contour,contour,train +papila_RET139OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET139OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET139OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET139OD_cup_exp2.txt,contour,contour,train +papila_RET139OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET139OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET139OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET139OS_cup_exp2.txt,contour,contour,train +papila_RET140OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET140OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET140OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET140OD_cup_exp2.txt,contour,contour,train +papila_RET140OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET140OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET140OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET140OS_cup_exp2.txt,contour,contour,train +papila_RET141OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET141OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET141OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET141OD_cup_exp2.txt,contour,contour,train +papila_RET141OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET141OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET141OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET141OS_cup_exp2.txt,contour,contour,train +papila_RET142OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET142OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET142OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET142OD_cup_exp2.txt,contour,contour,train +papila_RET142OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET142OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET142OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET142OS_cup_exp2.txt,contour,contour,train +papila_RET143OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET143OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET143OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET143OD_cup_exp2.txt,contour,contour,train +papila_RET143OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET143OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET143OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET143OS_cup_exp2.txt,contour,contour,holdout +papila_RET144OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET144OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET144OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET144OD_cup_exp2.txt,contour,contour,train +papila_RET144OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET144OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET144OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET144OS_cup_exp2.txt,contour,contour,train +papila_RET146OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET146OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET146OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET146OD_cup_exp2.txt,contour,contour,train +papila_RET146OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET146OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET146OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET146OS_cup_exp2.txt,contour,contour,train +papila_RET147OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET147OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET147OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET147OD_cup_exp2.txt,contour,contour,train +papila_RET147OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET147OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET147OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET147OS_cup_exp2.txt,contour,contour,train +papila_RET149OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET149OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET149OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET149OD_cup_exp2.txt,contour,contour,train +papila_RET149OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET149OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET149OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET149OS_cup_exp2.txt,contour,contour,train +papila_RET150OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET150OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET150OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET150OD_cup_exp2.txt,contour,contour,train +papila_RET150OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET150OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET150OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET150OS_cup_exp2.txt,contour,contour,train +papila_RET151OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET151OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET151OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET151OD_cup_exp2.txt,contour,contour,train +papila_RET151OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET151OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET151OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET151OS_cup_exp2.txt,contour,contour,train +papila_RET152OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET152OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET152OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET152OD_cup_exp2.txt,contour,contour,train +papila_RET152OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET152OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET152OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET152OS_cup_exp2.txt,contour,contour,train +papila_RET153OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET153OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET153OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET153OD_cup_exp2.txt,contour,contour,train +papila_RET153OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET153OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET153OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET153OS_cup_exp2.txt,contour,contour,train +papila_RET154OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET154OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET154OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET154OD_cup_exp2.txt,contour,contour,train +papila_RET154OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET154OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET154OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET154OS_cup_exp2.txt,contour,contour,train +papila_RET155OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET155OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET155OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET155OD_cup_exp2.txt,contour,contour,train +papila_RET155OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET155OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET155OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET155OS_cup_exp2.txt,contour,contour,train +papila_RET156OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET156OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET156OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET156OD_cup_exp2.txt,contour,contour,holdout +papila_RET156OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET156OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET156OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET156OS_cup_exp2.txt,contour,contour,train +papila_RET157OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET157OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET157OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET157OD_cup_exp2.txt,contour,contour,train +papila_RET157OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET157OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET157OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET157OS_cup_exp2.txt,contour,contour,train +papila_RET158OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET158OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET158OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET158OD_cup_exp2.txt,contour,contour,train +papila_RET158OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET158OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET158OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET158OS_cup_exp2.txt,contour,contour,train +papila_RET159OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET159OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET159OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET159OD_cup_exp2.txt,contour,contour,train +papila_RET159OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET159OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET159OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET159OS_cup_exp2.txt,contour,contour,train +papila_RET160OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET160OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET160OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET160OD_cup_exp2.txt,contour,contour,train +papila_RET160OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET160OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET160OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET160OS_cup_exp2.txt,contour,contour,train +papila_RET162OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET162OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET162OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET162OD_cup_exp2.txt,contour,contour,train +papila_RET162OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET162OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET162OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET162OS_cup_exp2.txt,contour,contour,train +papila_RET163OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET163OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET163OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET163OD_cup_exp2.txt,contour,contour,holdout +papila_RET163OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET163OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET163OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET163OS_cup_exp2.txt,contour,contour,train +papila_RET164OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET164OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET164OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET164OD_cup_exp2.txt,contour,contour,train +papila_RET164OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET164OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET164OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET164OS_cup_exp2.txt,contour,contour,train +papila_RET165OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET165OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET165OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET165OD_cup_exp2.txt,contour,contour,train +papila_RET165OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET165OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET165OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET165OS_cup_exp2.txt,contour,contour,train +papila_RET166OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET166OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET166OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET166OD_cup_exp2.txt,contour,contour,train +papila_RET166OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET166OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET166OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET166OS_cup_exp2.txt,contour,contour,train +papila_RET168OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET168OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET168OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET168OD_cup_exp2.txt,contour,contour,train +papila_RET168OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET168OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET168OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET168OS_cup_exp2.txt,contour,contour,train +papila_RET169OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET169OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET169OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET169OD_cup_exp2.txt,contour,contour,train +papila_RET169OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET169OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET169OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET169OS_cup_exp2.txt,contour,contour,train +papila_RET170OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET170OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET170OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET170OD_cup_exp2.txt,contour,contour,holdout +papila_RET170OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET170OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET170OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET170OS_cup_exp2.txt,contour,contour,train +papila_RET171OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET171OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET171OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET171OD_cup_exp2.txt,contour,contour,train +papila_RET171OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET171OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET171OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET171OS_cup_exp2.txt,contour,contour,train +papila_RET172OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET172OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET172OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET172OD_cup_exp2.txt,contour,contour,train +papila_RET172OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET172OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET172OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET172OS_cup_exp2.txt,contour,contour,train +papila_RET174OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET174OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET174OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET174OD_cup_exp2.txt,contour,contour,train +papila_RET174OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET174OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET174OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET174OS_cup_exp2.txt,contour,contour,train +papila_RET175OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET175OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET175OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET175OD_cup_exp2.txt,contour,contour,train +papila_RET175OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET175OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET175OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET175OS_cup_exp2.txt,contour,contour,train +papila_RET176OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET176OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET176OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET176OD_cup_exp2.txt,contour,contour,train +papila_RET176OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET176OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET176OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET176OS_cup_exp2.txt,contour,contour,train +papila_RET177OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET177OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET177OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET177OD_cup_exp2.txt,contour,contour,train +papila_RET177OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET177OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET177OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET177OS_cup_exp2.txt,contour,contour,train +papila_RET178OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET178OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET178OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET178OD_cup_exp2.txt,contour,contour,train +papila_RET178OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET178OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET178OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET178OS_cup_exp2.txt,contour,contour,train +papila_RET179OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET179OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET179OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET179OD_cup_exp2.txt,contour,contour,train +papila_RET179OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET179OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET179OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET179OS_cup_exp2.txt,contour,contour,train +papila_RET180OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET180OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET180OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET180OD_cup_exp2.txt,contour,contour,train +papila_RET180OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET180OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET180OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET180OS_cup_exp2.txt,contour,contour,train +papila_RET181OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET181OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET181OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET181OD_cup_exp2.txt,contour,contour,train +papila_RET181OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET181OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET181OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET181OS_cup_exp2.txt,contour,contour,train +papila_RET182OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET182OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET182OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET182OD_cup_exp2.txt,contour,contour,train +papila_RET182OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET182OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET182OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET182OS_cup_exp2.txt,contour,contour,train +papila_RET183OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET183OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET183OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET183OD_cup_exp2.txt,contour,contour,train +papila_RET183OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET183OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET183OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET183OS_cup_exp2.txt,contour,contour,train +papila_RET184OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET184OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET184OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET184OD_cup_exp2.txt,contour,contour,train +papila_RET184OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET184OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET184OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET184OS_cup_exp2.txt,contour,contour,train +papila_RET186OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET186OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET186OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET186OD_cup_exp2.txt,contour,contour,train +papila_RET186OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET186OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET186OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET186OS_cup_exp2.txt,contour,contour,train +papila_RET187OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET187OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET187OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET187OD_cup_exp2.txt,contour,contour,train +papila_RET187OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET187OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET187OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET187OS_cup_exp2.txt,contour,contour,train +papila_RET188OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET188OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET188OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET188OD_cup_exp2.txt,contour,contour,train +papila_RET188OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET188OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET188OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET188OS_cup_exp2.txt,contour,contour,train +papila_RET189OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET189OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET189OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET189OD_cup_exp2.txt,contour,contour,train +papila_RET189OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET189OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET189OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET189OS_cup_exp2.txt,contour,contour,train +papila_RET190OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET190OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET190OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET190OD_cup_exp2.txt,contour,contour,train +papila_RET190OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET190OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET190OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET190OS_cup_exp2.txt,contour,contour,train +papila_RET191OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET191OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET191OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET191OD_cup_exp2.txt,contour,contour,train +papila_RET191OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET191OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET191OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET191OS_cup_exp2.txt,contour,contour,train +papila_RET192OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET192OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET192OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET192OD_cup_exp2.txt,contour,contour,train +papila_RET192OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET192OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET192OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET192OS_cup_exp2.txt,contour,contour,train +papila_RET193OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET193OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET193OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET193OD_cup_exp2.txt,contour,contour,train +papila_RET193OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET193OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET193OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET193OS_cup_exp2.txt,contour,contour,train +papila_RET194OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET194OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET194OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET194OD_cup_exp2.txt,contour,contour,train +papila_RET194OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET194OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET194OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET194OS_cup_exp2.txt,contour,contour,train +papila_RET195OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET195OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET195OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET195OD_cup_exp2.txt,contour,contour,train +papila_RET195OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET195OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET195OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET195OS_cup_exp2.txt,contour,contour,train +papila_RET196OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET196OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET196OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET196OD_cup_exp2.txt,contour,contour,train +papila_RET196OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET196OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET196OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET196OS_cup_exp2.txt,contour,contour,train +papila_RET197OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET197OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET197OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET197OD_cup_exp2.txt,contour,contour,train +papila_RET197OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET197OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET197OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET197OS_cup_exp2.txt,contour,contour,train +papila_RET198OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET198OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET198OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET198OD_cup_exp2.txt,contour,contour,train +papila_RET198OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET198OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET198OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET198OS_cup_exp2.txt,contour,contour,train +papila_RET199OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET199OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET199OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET199OD_cup_exp2.txt,contour,contour,train +papila_RET199OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET199OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET199OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET199OS_cup_exp2.txt,contour,contour,train +papila_RET200OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET200OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET200OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET200OD_cup_exp2.txt,contour,contour,train +papila_RET200OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET200OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET200OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET200OS_cup_exp2.txt,contour,contour,train +papila_RET201OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET201OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET201OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET201OD_cup_exp2.txt,contour,contour,train +papila_RET201OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET201OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET201OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET201OS_cup_exp2.txt,contour,contour,train +papila_RET202OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET202OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET202OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET202OD_cup_exp2.txt,contour,contour,train +papila_RET202OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET202OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET202OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET202OS_cup_exp2.txt,contour,contour,train +papila_RET203OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET203OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET203OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET203OD_cup_exp2.txt,contour,contour,train +papila_RET203OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET203OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET203OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET203OS_cup_exp2.txt,contour,contour,train +papila_RET204OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET204OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET204OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET204OD_cup_exp2.txt,contour,contour,train +papila_RET204OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET204OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET204OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET204OS_cup_exp2.txt,contour,contour,train +papila_RET205OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET205OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET205OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET205OD_cup_exp2.txt,contour,contour,train +papila_RET205OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET205OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET205OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET205OS_cup_exp2.txt,contour,contour,train +papila_RET206OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET206OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET206OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET206OD_cup_exp2.txt,contour,contour,train +papila_RET206OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET206OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET206OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET206OS_cup_exp2.txt,contour,contour,holdout +papila_RET207OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET207OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET207OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET207OD_cup_exp2.txt,contour,contour,train +papila_RET207OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET207OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET207OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET207OS_cup_exp2.txt,contour,contour,train +papila_RET208OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET208OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET208OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET208OD_cup_exp2.txt,contour,contour,train +papila_RET208OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET208OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET208OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET208OS_cup_exp2.txt,contour,contour,train +papila_RET209OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET209OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET209OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET209OD_cup_exp2.txt,contour,contour,train +papila_RET209OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET209OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET209OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET209OS_cup_exp2.txt,contour,contour,train +papila_RET210OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET210OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET210OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET210OD_cup_exp2.txt,contour,contour,train +papila_RET210OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET210OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET210OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET210OS_cup_exp2.txt,contour,contour,train +papila_RET211OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET211OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET211OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET211OD_cup_exp2.txt,contour,contour,train +papila_RET211OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET211OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET211OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET211OS_cup_exp2.txt,contour,contour,train +papila_RET212OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET212OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET212OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET212OD_cup_exp2.txt,contour,contour,train +papila_RET212OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET212OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET212OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET212OS_cup_exp2.txt,contour,contour,train +papila_RET213OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET213OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET213OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET213OD_cup_exp2.txt,contour,contour,train +papila_RET213OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET213OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET213OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET213OS_cup_exp2.txt,contour,contour,holdout +papila_RET214OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET214OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET214OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET214OD_cup_exp2.txt,contour,contour,train +papila_RET214OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET214OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET214OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET214OS_cup_exp2.txt,contour,contour,train +papila_RET215OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET215OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET215OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET215OD_cup_exp2.txt,contour,contour,train +papila_RET215OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET215OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET215OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET215OS_cup_exp2.txt,contour,contour,train +papila_RET216OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET216OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET216OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET216OD_cup_exp2.txt,contour,contour,train +papila_RET216OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET216OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET216OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET216OS_cup_exp2.txt,contour,contour,train +papila_RET217OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET217OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET217OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET217OD_cup_exp2.txt,contour,contour,train +papila_RET217OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET217OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET217OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET217OS_cup_exp2.txt,contour,contour,train +papila_RET218OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET218OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET218OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET218OD_cup_exp2.txt,contour,contour,train +papila_RET218OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET218OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET218OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET218OS_cup_exp2.txt,contour,contour,train +papila_RET219OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET219OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET219OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET219OD_cup_exp2.txt,contour,contour,train +papila_RET219OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET219OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET219OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET219OS_cup_exp2.txt,contour,contour,train +papila_RET220OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET220OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET220OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET220OD_cup_exp2.txt,contour,contour,train +papila_RET220OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET220OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET220OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET220OS_cup_exp2.txt,contour,contour,train +papila_RET221OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET221OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET221OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET221OD_cup_exp2.txt,contour,contour,train +papila_RET221OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET221OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET221OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET221OS_cup_exp2.txt,contour,contour,train +papila_RET222OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET222OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET222OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET222OD_cup_exp2.txt,contour,contour,train +papila_RET222OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET222OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET222OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET222OS_cup_exp2.txt,contour,contour,train +papila_RET223OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET223OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET223OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET223OD_cup_exp2.txt,contour,contour,train +papila_RET223OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET223OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET223OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET223OS_cup_exp2.txt,contour,contour,train +papila_RET224OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET224OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET224OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET224OD_cup_exp2.txt,contour,contour,holdout +papila_RET224OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET224OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET224OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET224OS_cup_exp2.txt,contour,contour,train +papila_RET225OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET225OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET225OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET225OD_cup_exp2.txt,contour,contour,train +papila_RET225OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET225OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET225OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET225OS_cup_exp2.txt,contour,contour,train +papila_RET227OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET227OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET227OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET227OD_cup_exp2.txt,contour,contour,train +papila_RET227OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET227OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET227OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET227OS_cup_exp2.txt,contour,contour,train +papila_RET228OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET228OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET228OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET228OD_cup_exp2.txt,contour,contour,train +papila_RET228OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET228OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET228OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET228OS_cup_exp2.txt,contour,contour,train +papila_RET229OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET229OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET229OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET229OD_cup_exp2.txt,contour,contour,train +papila_RET229OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET229OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET229OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET229OS_cup_exp2.txt,contour,contour,train +papila_RET230OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET230OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET230OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET230OD_cup_exp2.txt,contour,contour,train +papila_RET230OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET230OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET230OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET230OS_cup_exp2.txt,contour,contour,train +papila_RET231OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET231OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET231OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET231OD_cup_exp2.txt,contour,contour,train +papila_RET231OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET231OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET231OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET231OS_cup_exp2.txt,contour,contour,train +papila_RET232OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET232OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET232OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET232OD_cup_exp2.txt,contour,contour,train +papila_RET232OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET232OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET232OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET232OS_cup_exp2.txt,contour,contour,train +papila_RET233OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET233OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET233OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET233OD_cup_exp2.txt,contour,contour,train +papila_RET233OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET233OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET233OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET233OS_cup_exp2.txt,contour,contour,train +papila_RET234OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET234OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET234OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET234OD_cup_exp2.txt,contour,contour,train +papila_RET234OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET234OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET234OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET234OS_cup_exp2.txt,contour,contour,train +papila_RET235OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET235OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET235OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET235OD_cup_exp2.txt,contour,contour,train +papila_RET235OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET235OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET235OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET235OS_cup_exp2.txt,contour,contour,train +papila_RET236OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET236OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET236OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET236OD_cup_exp2.txt,contour,contour,train +papila_RET236OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET236OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET236OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET236OS_cup_exp2.txt,contour,contour,train +papila_RET237OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET237OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET237OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET237OD_cup_exp2.txt,contour,contour,train +papila_RET237OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET237OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET237OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET237OS_cup_exp2.txt,contour,contour,train +papila_RET238OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET238OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET238OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET238OD_cup_exp2.txt,contour,contour,holdout +papila_RET238OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET238OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET238OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET238OS_cup_exp2.txt,contour,contour,holdout +papila_RET239OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET239OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET239OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET239OD_cup_exp2.txt,contour,contour,train +papila_RET239OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET239OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET239OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET239OS_cup_exp2.txt,contour,contour,holdout +papila_RET240OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET240OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET240OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET240OD_cup_exp2.txt,contour,contour,train +papila_RET240OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET240OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET240OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET240OS_cup_exp2.txt,contour,contour,train +papila_RET241OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET241OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET241OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET241OD_cup_exp2.txt,contour,contour,train +papila_RET241OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET241OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET241OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET241OS_cup_exp2.txt,contour,contour,train +papila_RET242OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET242OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET242OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET242OD_cup_exp2.txt,contour,contour,train +papila_RET242OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET242OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET242OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET242OS_cup_exp2.txt,contour,contour,train +papila_RET243OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET243OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET243OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET243OD_cup_exp2.txt,contour,contour,train +papila_RET243OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET243OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET243OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET243OS_cup_exp2.txt,contour,contour,train +papila_RET244OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET244OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET244OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET244OD_cup_exp2.txt,contour,contour,train +papila_RET244OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET244OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET244OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET244OS_cup_exp2.txt,contour,contour,train +papila_RET245OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET245OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET245OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET245OD_cup_exp2.txt,contour,contour,train +papila_RET245OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET245OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET245OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET245OS_cup_exp2.txt,contour,contour,train +papila_RET246OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET246OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET246OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET246OD_cup_exp2.txt,contour,contour,train +papila_RET246OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET246OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET246OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET246OS_cup_exp2.txt,contour,contour,train +papila_RET248OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET248OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET248OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET248OD_cup_exp2.txt,contour,contour,train +papila_RET248OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET248OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET248OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET248OS_cup_exp2.txt,contour,contour,train +papila_RET249OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET249OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET249OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET249OD_cup_exp2.txt,contour,contour,train +papila_RET249OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET249OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET249OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET249OS_cup_exp2.txt,contour,contour,train +papila_RET250OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET250OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET250OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET250OD_cup_exp2.txt,contour,contour,train +papila_RET250OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET250OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET250OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET250OS_cup_exp2.txt,contour,contour,train +papila_RET251OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET251OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET251OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET251OD_cup_exp2.txt,contour,contour,train +papila_RET251OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET251OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET251OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET251OS_cup_exp2.txt,contour,contour,train +papila_RET252OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET252OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET252OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET252OD_cup_exp2.txt,contour,contour,train +papila_RET252OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET252OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET252OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET252OS_cup_exp2.txt,contour,contour,train +papila_RET253OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET253OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET253OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET253OD_cup_exp2.txt,contour,contour,train +papila_RET253OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET253OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET253OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET253OS_cup_exp2.txt,contour,contour,train +papila_RET254OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET254OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET254OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET254OD_cup_exp2.txt,contour,contour,train +papila_RET254OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET254OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET254OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET254OS_cup_exp2.txt,contour,contour,train +papila_RET255OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET255OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET255OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET255OD_cup_exp2.txt,contour,contour,train +papila_RET255OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET255OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET255OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET255OS_cup_exp2.txt,contour,contour,train +papila_RET256OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET256OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET256OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET256OD_cup_exp2.txt,contour,contour,train +papila_RET256OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET256OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET256OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET256OS_cup_exp2.txt,contour,contour,train +papila_RET257OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET257OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET257OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET257OD_cup_exp2.txt,contour,contour,train +papila_RET257OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET257OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET257OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET257OS_cup_exp2.txt,contour,contour,train +papila_RET258OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET258OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET258OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET258OD_cup_exp2.txt,contour,contour,train +papila_RET258OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET258OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET258OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET258OS_cup_exp2.txt,contour,contour,train +papila_RET259OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET259OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET259OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET259OD_cup_exp2.txt,contour,contour,train +papila_RET259OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET259OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET259OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET259OS_cup_exp2.txt,contour,contour,train +papila_RET260OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET260OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET260OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET260OD_cup_exp2.txt,contour,contour,train +papila_RET260OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET260OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET260OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET260OS_cup_exp2.txt,contour,contour,train +papila_RET261OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET261OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET261OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET261OD_cup_exp2.txt,contour,contour,train +papila_RET261OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET261OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET261OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET261OS_cup_exp2.txt,contour,contour,train +papila_RET262OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET262OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET262OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET262OD_cup_exp2.txt,contour,contour,train +papila_RET262OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET262OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET262OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET262OS_cup_exp2.txt,contour,contour,train +papila_RET264OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET264OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET264OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET264OD_cup_exp2.txt,contour,contour,holdout +papila_RET264OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET264OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET264OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET264OS_cup_exp2.txt,contour,contour,train +papila_RET265OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET265OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET265OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET265OD_cup_exp2.txt,contour,contour,train +papila_RET265OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET265OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET265OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET265OS_cup_exp2.txt,contour,contour,train +papila_RET266OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET266OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET266OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET266OD_cup_exp2.txt,contour,contour,train +papila_RET266OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET266OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET266OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET266OS_cup_exp2.txt,contour,contour,train +papila_RET267OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET267OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET267OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET267OD_cup_exp2.txt,contour,contour,train +papila_RET267OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET267OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET267OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET267OS_cup_exp2.txt,contour,contour,train +papila_RET268OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET268OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET268OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET268OD_cup_exp2.txt,contour,contour,train +papila_RET268OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET268OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET268OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET268OS_cup_exp2.txt,contour,contour,train +papila_RET269OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET269OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET269OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET269OD_cup_exp2.txt,contour,contour,train +papila_RET269OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET269OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET269OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET269OS_cup_exp2.txt,contour,contour,train +papila_RET270OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET270OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET270OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET270OD_cup_exp2.txt,contour,contour,train +papila_RET270OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET270OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET270OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET270OS_cup_exp2.txt,contour,contour,train +papila_RET271OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET271OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET271OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET271OD_cup_exp2.txt,contour,contour,train +papila_RET271OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET271OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET271OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET271OS_cup_exp2.txt,contour,contour,train +papila_RET272OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET272OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET272OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET272OD_cup_exp2.txt,contour,contour,train +papila_RET272OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET272OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET272OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET272OS_cup_exp2.txt,contour,contour,train +papila_RET273OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET273OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET273OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET273OD_cup_exp2.txt,contour,contour,train +papila_RET273OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET273OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET273OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET273OS_cup_exp2.txt,contour,contour,train +papila_RET274OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET274OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET274OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET274OD_cup_exp2.txt,contour,contour,train +papila_RET274OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET274OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET274OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET274OS_cup_exp2.txt,contour,contour,train +papila_RET275OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET275OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET275OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET275OD_cup_exp2.txt,contour,contour,holdout +papila_RET275OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET275OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET275OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET275OS_cup_exp2.txt,contour,contour,train +papila_RET276OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET276OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET276OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET276OD_cup_exp2.txt,contour,contour,train +papila_RET276OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET276OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET276OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET276OS_cup_exp2.txt,contour,contour,train +papila_RET277OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET277OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET277OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET277OD_cup_exp2.txt,contour,contour,train +papila_RET277OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET277OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET277OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET277OS_cup_exp2.txt,contour,contour,train +papila_RET280OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET280OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET280OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET280OD_cup_exp2.txt,contour,contour,train +papila_RET280OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET280OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET280OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET280OS_cup_exp2.txt,contour,contour,train +papila_RET281OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET281OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET281OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET281OD_cup_exp2.txt,contour,contour,train +papila_RET281OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET281OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET281OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET281OS_cup_exp2.txt,contour,contour,train +papila_RET282OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET282OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET282OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET282OD_cup_exp2.txt,contour,contour,train +papila_RET282OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET282OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET282OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET282OS_cup_exp2.txt,contour,contour,train +papila_RET283OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET283OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET283OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET283OD_cup_exp2.txt,contour,contour,train +papila_RET283OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET283OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET283OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET283OS_cup_exp2.txt,contour,contour,train +papila_RET284OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET284OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET284OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET284OD_cup_exp2.txt,contour,contour,train +papila_RET284OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET284OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET284OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET284OS_cup_exp2.txt,contour,contour,train +papila_RET285OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET285OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET285OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET285OD_cup_exp2.txt,contour,contour,train +papila_RET285OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET285OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET285OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET285OS_cup_exp2.txt,contour,contour,train +papila_RET286OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET286OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET286OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET286OD_cup_exp2.txt,contour,contour,train +papila_RET286OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET286OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET286OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET286OS_cup_exp2.txt,contour,contour,train +papila_RET287OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET287OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET287OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET287OD_cup_exp2.txt,contour,contour,train +papila_RET287OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET287OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET287OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET287OS_cup_exp2.txt,contour,contour,train +papila_RET288OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET288OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET288OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET288OD_cup_exp2.txt,contour,contour,train +papila_RET288OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET288OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET288OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET288OS_cup_exp2.txt,contour,contour,train +papila_RET289OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET289OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET289OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET289OD_cup_exp2.txt,contour,contour,train +papila_RET289OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET289OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET289OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET289OS_cup_exp2.txt,contour,contour,train +papila_RET290OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET290OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET290OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET290OD_cup_exp2.txt,contour,contour,train +papila_RET290OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET290OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET290OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET290OS_cup_exp2.txt,contour,contour,train +papila_RET291OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET291OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET291OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET291OD_cup_exp2.txt,contour,contour,train +papila_RET291OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET291OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET291OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET291OS_cup_exp2.txt,contour,contour,train +papila_RET292OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET292OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET292OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET292OD_cup_exp2.txt,contour,contour,train +papila_RET292OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET292OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET292OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET292OS_cup_exp2.txt,contour,contour,train +papila_RET293OD,papila,/home/rpotter/hypertower/Papila/FundusImages/RET293OD.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET293OD_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET293OD_cup_exp2.txt,contour,contour,train +papila_RET293OS,papila,/home/rpotter/hypertower/Papila/FundusImages/RET293OS.jpg,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET293OS_disc_exp2.txt,/home/rpotter/hypertower/Papila/ExpertsSegmentations/Contours/RET293OS_cup_exp2.txt,contour,contour,train diff --git a/models/refuge/classifier/densenet/densenetrefuge.pt b/models/refuge/classifier/densenet/densenetrefuge.pt new file mode 100755 index 0000000..75f1680 Binary files /dev/null and b/models/refuge/classifier/densenet/densenetrefuge.pt differ diff --git a/models/refuge/classifier/refuge_densenet_backbone.pt b/models/refuge/classifier/refuge_densenet_backbone.pt new file mode 100755 index 0000000..75f1680 Binary files /dev/null and b/models/refuge/classifier/refuge_densenet_backbone.pt differ diff --git a/models/v2/refuge/segmentation/per_image/best.pt b/models/v2/refuge/segmentation/per_image/best.pt new file mode 100644 index 0000000..8b280b4 Binary files /dev/null and b/models/v2/refuge/segmentation/per_image/best.pt differ diff --git a/models/v2/refuge/segmentation/per_image_refuge_build/best.pt b/models/v2/refuge/segmentation/per_image_refuge_build/best.pt new file mode 100644 index 0000000..f33fe5d Binary files /dev/null and b/models/v2/refuge/segmentation/per_image_refuge_build/best.pt differ diff --git a/scripts/basic_analysis/basic_analysis.py b/scripts/basic_analysis/basic_analysis.py new file mode 100755 index 0000000..473a163 --- /dev/null +++ b/scripts/basic_analysis/basic_analysis.py @@ -0,0 +1,757 @@ +#!/usr/bin/env python3 +"""Basic analytics helpers for PAPILA clinical data.""" +import re +from pathlib import Path +from typing import Iterable, List, Tuple + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +from sklearn.base import clone +from sklearn.metrics import roc_curve, auc, roc_auc_score, accuracy_score +from sklearn.ensemble import RandomForestClassifier +from sklearn.model_selection import StratifiedKFold +from sklearn.linear_model import LogisticRegression +from sklearn.neighbors import KNeighborsClassifier +from sklearn.pipeline import Pipeline +from sklearn.preprocessing import StandardScaler +from sklearn.svm import SVC + +from classes import build_papila_clinical + + +class basic_analytics: + def __init__( + self, + image_dir: str = "Papila/FundusImages", + clinical_dir: str = "Papila/ClinicalData", + label_col: str = "Diagnosis", + cat_cols: Iterable[str] | None = None, + exclude_cols: Iterable[str] | None = None, + positive_label: int = 1, + negative_label: int = 0, + drop_labels: Iterable[int] = (2,), + output_dir: Path | str = Path("analysis_data/basic_analysis"), + debug: bool = False, + ) -> None: + self.image_dir = image_dir + self.clinical_dir = clinical_dir + self.label_col = label_col + self.cat_cols = ( + list(cat_cols) + if cat_cols is not None + else ["Gender", "Phakic/Pseudophakic"] + ) + base_excludes = {"Pneumatic", "Perkins"} + self.exclude_cols = base_excludes | set(exclude_cols or []) + self.positive_label = positive_label + self.negative_label = negative_label + self.drop_labels = list(drop_labels or []) + self.output_dir = Path(output_dir) + self.debug = debug + + @staticmethod + def _sanitize(name: str) -> str: + safe = re.sub(r"[^A-Za-z0-9._-]+", "_", str(name)).strip("_") + return safe or "var" + + def _build_clinical(self): + return build_papila_clinical( + image_dir=self.image_dir, + clinical_dir=self.clinical_dir, + label_col=self.label_col, + cat_cols=self.cat_cols, + ) + + def _select_binary_labels( + self, + labels: pd.Series, + ) -> Tuple[np.ndarray, np.ndarray]: + labels_num = pd.to_numeric(labels, errors="coerce") + use_num = labels_num.notna().any() + lab = labels_num if use_num else labels.astype(str) + + drop_set = set(self.drop_labels or []) + keep = lab.isin([self.positive_label, self.negative_label]) + if drop_set: + keep &= ~lab.isin(drop_set) + + y = (lab == self.positive_label).astype(int) + return y.values, keep.values + + def _base_exclude(self) -> set: + base_exclude = ( + {self.label_col, "Patient ID"} | self.exclude_cols | set(self.cat_cols) + ) + if "eyeID" not in self.cat_cols: + base_exclude.add("eyeID") + return base_exclude + + def _numeric_columns(self, df: pd.DataFrame) -> List[str]: + base_exclude = self._base_exclude() + candidate_cols = [c for c in df.columns if c not in base_exclude] + numeric_cols: List[str] = [] + for col in candidate_cols: + s = pd.to_numeric(df[col], errors="coerce") + if s.notna().any(): + numeric_cols.append(col) + return numeric_cols + + @staticmethod + def _compute_roc( + y: np.ndarray, scores: np.ndarray + ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, float]: + fpr, tpr, thresholds = roc_curve(y, scores, pos_label=1) + auc_val = float(auc(fpr, tpr)) + return fpr, tpr, thresholds, auc_val + + @staticmethod + def _best_threshold( + fpr: np.ndarray, tpr: np.ndarray, thresholds: np.ndarray + ) -> Tuple[float, float, float]: + youden = tpr - fpr + idx = int(np.nanargmax(youden)) + return float(thresholds[idx]), float(tpr[idx]), float(fpr[idx]) + + @staticmethod + def _plot_overlay(curves, title: str, out_path: Path) -> None: + fig, ax = plt.subplots(figsize=(7, 5.5)) + cmap = plt.get_cmap("tab20") + for i, (name, fpr, tpr, auc_val) in enumerate(curves): + color = cmap(i % cmap.N) + ax.plot(fpr, tpr, lw=1.6, color=color, label=f"{name} (AUC={auc_val:.3f})") + ax.plot([0, 1], [0, 1], "k--", lw=1) + ax.set_xlabel("False Positive Rate") + ax.set_ylabel("True Positive Rate") + ax.set_title(title) + ax.legend(loc="upper left", fontsize="small") + ax.grid(True, alpha=0.3, linestyle="--") + fig.tight_layout() + fig.savefig(out_path, dpi=170) + plt.close(fig) + + @staticmethod + def _plot_per_feature( + fpr: np.ndarray, tpr: np.ndarray, auc_val: float, title: str, out_path: Path + ) -> None: + fig, ax = plt.subplots(figsize=(5.5, 4.5)) + ax.plot(fpr, tpr, lw=1.8, label=f"AUC={auc_val:.3f}") + ax.plot([0, 1], [0, 1], "k--", lw=1) + ax.set_xlabel("False Positive Rate") + ax.set_ylabel("True Positive Rate") + ax.set_title(title) + ax.legend(loc="lower right") + ax.grid(True, alpha=0.3, linestyle="--") + fig.tight_layout() + fig.savefig(out_path, dpi=170) + plt.close(fig) + + @staticmethod + def _plot_roc_line( + fpr: np.ndarray, tpr: np.ndarray, auc_val: float, title: str, out_path: Path + ) -> None: + fig, ax = plt.subplots(figsize=(5.5, 4.5)) + ax.plot(fpr, tpr, lw=1.8, label=f"AUC={auc_val:.3f}") + ax.plot([0, 1], [0, 1], "k--", lw=1) + ax.set_xlabel("False Positive Rate") + ax.set_ylabel("True Positive Rate") + ax.set_title(title) + ax.legend(loc="lower right") + ax.grid(True, alpha=0.3, linestyle="--") + fig.tight_layout() + fig.savefig(out_path, dpi=170) + plt.close(fig) + + def _oof_scores( + self, + model, + X: np.ndarray, + y: np.ndarray, + n_splits: int, + random_state: int, + ) -> Tuple[np.ndarray, np.ndarray]: + skf = StratifiedKFold( + n_splits=n_splits, shuffle=True, random_state=random_state + ) + scores = np.zeros(len(y), dtype=float) + for train_idx, test_idx in skf.split(X, y): + X_train, X_test = X[train_idx], X[test_idx] + y_train = y[train_idx] + if np.unique(y_train).size < 2: + continue + fitted = clone(model) + fitted.fit(X_train, y_train) + if hasattr(fitted, "predict_proba"): + fold_scores = fitted.predict_proba(X_test)[:, 1] + elif hasattr(fitted, "decision_function"): + fold_scores = fitted.decision_function(X_test) + else: + fold_scores = fitted.predict(X_test) + scores[test_idx] = fold_scores + return y.astype(int), scores + + def _cv_roc_curves( + self, + model, + X: np.ndarray, + y: np.ndarray, + n_splits: int, + random_state: int, + ) -> List[Tuple[np.ndarray, np.ndarray, float]]: + skf = StratifiedKFold( + n_splits=n_splits, shuffle=True, random_state=random_state + ) + curves = [] + for train_idx, test_idx in skf.split(X, y): + X_train, X_test = X[train_idx], X[test_idx] + y_train, y_test = y[train_idx], y[test_idx] + if np.unique(y_train).size < 2 or np.unique(y_test).size < 2: + continue + fitted = clone(model) + fitted.fit(X_train, y_train) + if hasattr(fitted, "predict_proba"): + scores = fitted.predict_proba(X_test)[:, 1] + elif hasattr(fitted, "decision_function"): + scores = fitted.decision_function(X_test) + else: + scores = fitted.predict(X_test) + fpr, tpr, _ = roc_curve(y_test, scores, pos_label=1) + auc_val = float(auc(fpr, tpr)) + curves.append((fpr, tpr, auc_val)) + return curves + + @staticmethod + def _plot_mean_roc( + curves: List[Tuple[np.ndarray, np.ndarray, float]], + title: str, + out_path: Path, + ) -> None: + if not curves: + return + mean_fpr = np.linspace(0.0, 1.0, 200) + tprs = [] + aucs = [] + for fpr, tpr, auc_val in curves: + tpr_interp = np.interp(mean_fpr, fpr, tpr) + tpr_interp[0] = 0.0 + tprs.append(tpr_interp) + aucs.append(auc_val) + mean_tpr = np.mean(tprs, axis=0) + mean_tpr[-1] = 1.0 + std_tpr = np.std(tprs, axis=0) + mean_auc = float(np.mean(aucs)) + std_auc = float(np.std(aucs, ddof=0)) + + fig, ax = plt.subplots(figsize=(5.8, 4.6)) + ax.plot(mean_fpr, mean_tpr, lw=2, label=f"AUC={mean_auc:.3f}±{std_auc:.3f}") + ax.fill_between( + mean_fpr, + np.maximum(mean_tpr - std_tpr, 0), + np.minimum(mean_tpr + std_tpr, 1), + color="grey", + alpha=0.2, + ) + ax.plot([0, 1], [0, 1], "k--", lw=1) + ax.set_xlabel("False Positive Rate") + ax.set_ylabel("True Positive Rate") + ax.set_title(title) + ax.legend(loc="lower right") + ax.grid(True, alpha=0.3, linestyle="--") + fig.tight_layout() + fig.savefig(out_path, dpi=170) + plt.close(fig) + + def _iter_categorical(self, df: pd.DataFrame, cols: List[str]): + for col in cols: + if col not in df.columns: + continue + s = df[col] + vals = s.dropna().unique().tolist() + try: + vals = sorted(vals) + except Exception: + pass + for v in vals: + name = f"{col}=={v}" + ind = (s == v).astype(int) + yield name, ind + + def _feature_matrix( + self, df: pd.DataFrame, include_categorical: bool + ) -> Tuple[np.ndarray, np.ndarray, List[str]]: + numeric_cols = self._numeric_columns(df) + X_num = df[numeric_cols].apply(pd.to_numeric, errors="coerce") + for col in numeric_cols: + med = pd.to_numeric(X_num[col], errors="coerce").median() + X_num[col] = pd.to_numeric(X_num[col], errors="coerce").fillna(med) + + parts = [X_num] + feat_names = list(X_num.columns) + + if include_categorical and self.cat_cols: + cat_cols = [c for c in self.cat_cols if c in df.columns] + if cat_cols: + df_cats = pd.get_dummies( + df[cat_cols].astype("category"), drop_first=False, prefix=cat_cols + ) + parts.append(df_cats) + feat_names.extend(list(df_cats.columns)) + + X = pd.concat(parts, axis=1).values.astype(np.float32) + labels = df[self.label_col] + y_all, keep_mask = self._select_binary_labels(labels) + y = y_all[keep_mask] + X = X[keep_mask] + return X, y.astype(int), feat_names + + def univariate_roc( + self, merge: bool = False, include_categorical: bool = False + ) -> pd.DataFrame: + clinical = self._build_clinical() + df = clinical.df.copy() + labels = df[self.label_col] + y_all, keep_mask = self._select_binary_labels(labels) + + if self.debug: + for col in ("IOP_raw", "IOP_corr"): + if col not in df.columns: + print(f"[debug] {col} missing from df") + continue + s = pd.to_numeric(df[col], errors="coerce") + print( + f"[debug] {col}: non-null={int(s.notna().sum())}, unique={int(s.nunique(dropna=True))}" + ) + + plot_dir = self.output_dir / "papila_univariate_roc" / "plots" + plot_dir.mkdir(parents=True, exist_ok=True) + + rows = [] + curves = [] + + numeric_cols = self._numeric_columns(df) + for col in numeric_cols: + series = pd.to_numeric(df[col], errors="coerce") + mask = keep_mask & series.notna().values + y = y_all[mask] + scores = series.values[mask].astype(float) + if y.size < 2 or np.unique(y).size < 2: + continue + if np.nanmin(scores) == np.nanmax(scores): + continue + fpr, tpr, thresholds, auc_val = self._compute_roc(y, scores) + thr, best_tpr, best_fpr = self._best_threshold(fpr, tpr, thresholds) + direction = "high" if auc_val >= 0.5 else "low" + title = f"{col} (n={y.size}, direction={direction})" + if merge: + out_path = plot_dir / f"roc_{self._sanitize(col)}.png" + self._plot_per_feature(fpr, tpr, auc_val, title, out_path) + curves.append((col, fpr, tpr, auc_val)) + rows.append( + { + "feature": col, + "kind": "numeric", + "n": int(y.size), + "auc": auc_val, + "direction": direction, + "best_threshold": thr, + "best_tpr": best_tpr, + "best_fpr": best_fpr, + "best_specificity": 1.0 - best_fpr, + } + ) + + if include_categorical: + cat_cols_use = [c for c in self.cat_cols if c not in self.exclude_cols] + for name, ind in self._iter_categorical(df, cat_cols_use): + mask = keep_mask & ind.notna().values + y = y_all[mask] + scores = ind.values[mask].astype(float) + if y.size < 2 or np.unique(y).size < 2: + continue + if np.nanmin(scores) == np.nanmax(scores): + continue + fpr, tpr, thresholds, auc_val = self._compute_roc(y, scores) + thr, best_tpr, best_fpr = self._best_threshold(fpr, tpr, thresholds) + direction = "high" if auc_val >= 0.5 else "low" + title = f"{name} (n={y.size}, direction={direction})" + if merge: + out_path = plot_dir / f"roc_{self._sanitize(name)}.png" + self._plot_per_feature(fpr, tpr, auc_val, title, out_path) + curves.append((name, fpr, tpr, auc_val)) + rows.append( + { + "feature": name, + "kind": "categorical", + "n": int(y.size), + "auc": auc_val, + "direction": direction, + "best_threshold": thr, + "best_tpr": best_tpr, + "best_fpr": best_fpr, + "best_specificity": 1.0 - best_fpr, + } + ) + + if not rows: + raise SystemExit( + "No valid features produced ROC curves. Check labels and feature columns." + ) + + overlay_path = plot_dir / "roc_overlay.png" + if not merge: + self._plot_overlay(curves, "Univariate ROC curves", overlay_path) + + out_df = pd.DataFrame(rows).sort_values(by="auc", ascending=False) + out_csv = self.output_dir / "papila_univariate_roc" / "summary.csv" + out_csv.parent.mkdir(parents=True, exist_ok=True) + out_df.to_csv(out_csv, index=False) + return out_df + + def random_forest( + self, + include_categorical: bool = True, + n_estimators: int = 500, + max_depth: int | None = None, + min_samples_leaf: int = 1, + max_features: str | None = "sqrt", + class_weight: str | None = "balanced", + max_samples: float | None = None, + random_state: int = 42, + top_n: int = 25, + n_splits: int = 5, + drop_missing: bool = False, + nerf: bool = False, + drop_age: bool = False, + ) -> pd.DataFrame: + clinical = self._build_clinical() + df = clinical.df.copy() + original_exclude = set(self.exclude_cols) + if nerf: + self.exclude_cols = set(self.exclude_cols) + drop_missing = True + if drop_age: + self.exclude_cols = set(self.exclude_cols) | {"Age"} + if drop_missing: + numeric_cols = self._numeric_columns(df) + df = df.dropna(subset=numeric_cols) + X, y, feat_names = self._feature_matrix( + df, include_categorical=include_categorical + ) + + if X.size == 0 or np.unique(y).size < 2: + raise SystemExit( + "Not enough data after filtering labels for Random Forest." + ) + + if nerf: + n_estimators = 200 + max_depth = 5 + min_samples_leaf = 5 + max_features = "sqrt" + class_weight = None + max_samples = 0.7 + self.exclude_cols = original_exclude + + clf = RandomForestClassifier( + n_estimators=n_estimators, + max_depth=max_depth, + min_samples_leaf=min_samples_leaf, + max_features=max_features, + class_weight=class_weight, + max_samples=max_samples, + random_state=random_state, + n_jobs=-1, + ) + clf.fit(X, y) + importances = clf.feature_importances_.astype(float) + + rows = [] + for name, val in zip(feat_names, importances): + rows.append({"feature": name, "importance": float(val)}) + + out_df = pd.DataFrame(rows).sort_values(by="importance", ascending=False) + out_dir = self.output_dir / ( + "papila_random_forest_nerfed" if nerf else "papila_random_forest" + ) + out_dir.mkdir(parents=True, exist_ok=True) + out_df.to_csv(out_dir / "feature_importance.csv", index=False) + + top_df = out_df.head(top_n) + fig, ax = plt.subplots(figsize=(7, 6)) + ax.barh(top_df["feature"], top_df["importance"], color="steelblue") + ax.invert_yaxis() + ax.set_xlabel("Importance (Gini)") + ax.set_title(f"Random Forest Feature Importance") + fig.tight_layout() + fig.savefig(out_dir / "feature_importance_top.png", dpi=170) + plt.close(fig) + if self.debug: + age_rows = out_df[out_df["feature"] == "Age"] + if not age_rows.empty: + age_imp = float(age_rows["importance"].iloc[0]) + print(f"[debug] RF importance Age = {age_imp:.4f}") + + y_oof, scores_oof = self._oof_scores(clf, X, y, n_splits, random_state) + fpr, tpr, _, auc_val = self._compute_roc(y_oof, scores_oof) + curves = self._cv_roc_curves(clf, X, y, n_splits, random_state) + self._plot_mean_roc( + curves, + "Random Forest ROC (mean ± SD)", + out_dir / "roc_mean.png", + ) + + return out_df + + def _cv_binary_metrics( + self, + model, + X: np.ndarray, + y: np.ndarray, + n_splits: int, + random_state: int, + ) -> pd.DataFrame: + skf = StratifiedKFold( + n_splits=n_splits, shuffle=True, random_state=random_state + ) + rows = [] + for fold, (train_idx, test_idx) in enumerate(skf.split(X, y), start=1): + X_train, X_test = X[train_idx], X[test_idx] + y_train, y_test = y[train_idx], y[test_idx] + if np.unique(y_train).size < 2 or np.unique(y_test).size < 2: + continue + model.fit(X_train, y_train) + if hasattr(model, "predict_proba"): + scores = model.predict_proba(X_test)[:, 1] + elif hasattr(model, "decision_function"): + scores = model.decision_function(X_test) + else: + scores = model.predict(X_test) + preds = model.predict(X_test) + auc_val = float(roc_auc_score(y_test, scores)) + acc_val = float(accuracy_score(y_test, preds)) + rows.append( + { + "fold": int(fold), + "n": int(len(y_test)), + "auc": auc_val, + "acc": acc_val, + } + ) + return pd.DataFrame(rows) + + def svm( + self, + include_categorical: bool = True, + kernel: str = "rbf", + C: float = 1.0, + gamma: str = "scale", + n_splits: int = 5, + random_state: int = 42, + ) -> pd.DataFrame: + clinical = self._build_clinical() + df = clinical.df.copy() + X, y, feat_names = self._feature_matrix( + df, include_categorical=include_categorical + ) + if X.size == 0 or np.unique(y).size < 2: + raise SystemExit("Not enough data after filtering labels for SVM.") + + model = Pipeline( + [ + ("scale", StandardScaler()), + ( + "svm", + SVC( + kernel=kernel, + C=C, + gamma=gamma, + probability=True, + class_weight="balanced", + random_state=random_state, + ), + ), + ] + ) + fold_df = self._cv_binary_metrics(model, X, y, n_splits, random_state) + if fold_df.empty: + raise SystemExit("SVM produced no valid folds (check class balance).") + + y_oof, scores_oof = self._oof_scores(model, X, y, n_splits, random_state) + fpr, tpr, _, auc_val = self._compute_roc(y_oof, scores_oof) + curves = self._cv_roc_curves(model, X, y, n_splits, random_state) + + summary = pd.DataFrame( + [ + { + "metric": "auc", + "mean": float(fold_df["auc"].mean()), + "std": float(fold_df["auc"].std(ddof=0)), + "oof_auc": float(auc_val), + }, + { + "metric": "acc", + "mean": float(fold_df["acc"].mean()), + "std": float(fold_df["acc"].std(ddof=0)), + }, + ] + ) + out_dir = self.output_dir / "papila_svm" + out_dir.mkdir(parents=True, exist_ok=True) + fold_df.to_csv(out_dir / "fold_metrics.csv", index=False) + summary.to_csv(out_dir / "summary.csv", index=False) + self._plot_mean_roc( + curves, + "SVM ROC (mean ± SD)", + out_dir / "roc_mean.png", + ) + return fold_df + + def knn( + self, + include_categorical: bool = True, + n_neighbors: int = 5, + weights: str = "distance", + n_splits: int = 5, + random_state: int = 42, + ) -> pd.DataFrame: + clinical = self._build_clinical() + df = clinical.df.copy() + X, y, feat_names = self._feature_matrix( + df, include_categorical=include_categorical + ) + if X.size == 0 or np.unique(y).size < 2: + raise SystemExit("Not enough data after filtering labels for KNN.") + + model = Pipeline( + [ + ("scale", StandardScaler()), + ("knn", KNeighborsClassifier(n_neighbors=n_neighbors, weights=weights)), + ] + ) + fold_df = self._cv_binary_metrics(model, X, y, n_splits, random_state) + if fold_df.empty: + raise SystemExit("KNN produced no valid folds (check class balance).") + + y_oof, scores_oof = self._oof_scores(model, X, y, n_splits, random_state) + fpr, tpr, _, auc_val = self._compute_roc(y_oof, scores_oof) + curves = self._cv_roc_curves(model, X, y, n_splits, random_state) + + summary = pd.DataFrame( + [ + { + "metric": "auc", + "mean": float(fold_df["auc"].mean()), + "std": float(fold_df["auc"].std(ddof=0)), + "oof_auc": float(auc_val), + }, + { + "metric": "acc", + "mean": float(fold_df["acc"].mean()), + "std": float(fold_df["acc"].std(ddof=0)), + }, + ] + ) + out_dir = self.output_dir / "papila_knn" + out_dir.mkdir(parents=True, exist_ok=True) + fold_df.to_csv(out_dir / "fold_metrics.csv", index=False) + summary.to_csv(out_dir / "summary.csv", index=False) + self._plot_mean_roc( + curves, + "KNN ROC (mean ± SD)", + out_dir / "roc_mean.png", + ) + return fold_df + + def logistic_regression( + self, + include_categorical: bool = True, + C: float = 1.0, + max_iter: int = 1000, + n_splits: int = 5, + random_state: int = 42, + drop_age: bool = False, + nerf: bool = False, + ) -> pd.DataFrame: + clinical = self._build_clinical() + df = clinical.df.copy() + original_exclude = set(self.exclude_cols) + if drop_age: + self.exclude_cols = set(self.exclude_cols) | {"Age"} + X, y, feat_names = self._feature_matrix( + df, include_categorical=include_categorical + ) + self.exclude_cols = original_exclude + if X.size == 0 or np.unique(y).size < 2: + raise SystemExit( + "Not enough data after filtering labels for Logistic Regression." + ) + + class_weight = "balanced" + penalty = "l2" + solver = "lbfgs" + if nerf: + C = 0.05 + class_weight = None + penalty = "l1" + solver = "liblinear" + + model = Pipeline( + [ + ("scale", StandardScaler()), + ( + "logreg", + LogisticRegression( + C=C, + max_iter=max_iter, + class_weight=class_weight, + penalty=penalty, + solver=solver, + ), + ), + ] + ) + fold_df = self._cv_binary_metrics(model, X, y, n_splits, random_state) + if fold_df.empty: + raise SystemExit( + "Logistic Regression produced no valid folds (check class balance)." + ) + + y_oof, scores_oof = self._oof_scores(model, X, y, n_splits, random_state) + fpr, tpr, _, auc_val = self._compute_roc(y_oof, scores_oof) + curves = self._cv_roc_curves(model, X, y, n_splits, random_state) + + summary = pd.DataFrame( + [ + { + "metric": "auc", + "mean": float(fold_df["auc"].mean()), + "std": float(fold_df["auc"].std(ddof=0)), + "oof_auc": float(auc_val), + }, + { + "metric": "acc", + "mean": float(fold_df["acc"].mean()), + "std": float(fold_df["acc"].std(ddof=0)), + }, + ] + ) + out_dir = self.output_dir / "papila_logistic_regression" + out_dir.mkdir(parents=True, exist_ok=True) + fold_df.to_csv(out_dir / "fold_metrics.csv", index=False) + summary.to_csv(out_dir / "summary.csv", index=False) + self._plot_mean_roc( + curves, + "Logistic Regression ROC (mean ± SD)", + out_dir / "roc_mean.png", + ) + return fold_df + + +ba = basic_analytics() +roc_df = ba.univariate_roc(merge=False, include_categorical=False) +rf_df = ba.random_forest(include_categorical=True, nerf=False) +svm_df = ba.svm(include_categorical=True) +knn_df = ba.knn(include_categorical=True) +lr_df = ba.logistic_regression(include_categorical=True, nerf=True) +clinical = ba._build_clinical() +clinical.df["Diagnosis"].value_counts() diff --git a/scripts/basic_analysis/cnn_logits_rf_cv.py b/scripts/basic_analysis/cnn_logits_rf_cv.py new file mode 100755 index 0000000..6e7d630 --- /dev/null +++ b/scripts/basic_analysis/cnn_logits_rf_cv.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +"""Train a CNN (resnet50 backbone), extract logits, and train RF on logits+metadata with 5-fold CV.""" +from __future__ import annotations + +import random +from pathlib import Path +import sys +from typing import Dict, List, Tuple + +import numpy as np +import pandas as pd +from PIL import Image +import torch +from torch import nn +from torch.utils.data import DataLoader, Dataset +from torchvision import transforms +from sklearn.ensemble import RandomForestClassifier +from sklearn.metrics import accuracy_score, roc_auc_score + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes import build_papila_clinical +from classes.backbones import BACKBONES, load_backbone_weights + + +# --------------------------- +# Config (edit in IDE) +# --------------------------- +IMAGE_DIR = "Papila/FundusImages" +CLINICAL_DIR = "Papila/ClinicalData" +LABEL_COL = "Diagnosis" +CAT_COLS = ["Gender", "Phakic/Pseudophakic"] +EVAL_MODE = "binary" # "binary" or "multiclass" +N_SPLITS = 5 +FOLD_SEED = 42 +HOLDOUT_SEED = 123 +HOLDOUT_PATIENTS_PER_CLASS = 6 + +BACKBONE_NAME = "resnet50" +BATCH_SIZE = 8 +EPOCHS = 40 +LR = 1e-4 +WEIGHT_DECAY = 1e-5 + +RF_TREES = 500 +RF_MAX_DEPTH = None +RF_MIN_SAMPLES_LEAF = 1 + +DEVICE = "cuda" if torch.cuda.is_available() else "cpu" +OUTPUT_DIR = Path("analysis_data/basic_analysis/cnn_logits_rf_cv") +PRINT_EPOCH_REPORT = True +EPOCH_REPORT_EVERY = 1 + + +class PapilaImageDataset(Dataset): + def __init__( + self, clinical, df: pd.DataFrame, label_col: str, img_transform + ) -> None: + self.clinical = clinical + self.df = df.reset_index(drop=True) + self.label_col = label_col + self.img_transform = img_transform + + def __len__(self) -> int: + return len(self.df) + + def __getitem__(self, idx: int): + row = self.df.iloc[idx] + img_path = self.clinical.get_image_path(row) + image = Image.open(img_path).convert("RGB") + x_img = self.img_transform(image) + y = int(row[self.label_col]) + x_md = self.clinical.vectorize_row(row).astype(np.float32) + return x_img, y, x_md + + +class CNNHead(nn.Module): + def __init__(self, backbone_name: str, num_classes: int) -> None: + super().__init__() + spec = BACKBONES[backbone_name] + backbone = spec.ctor(weights=spec.weights_default) + if backbone_name.startswith("refuge"): + load_backbone_weights(backbone_name, backbone) + out_dim, backbone = spec.strip(backbone) + self.backbone = backbone + self.head = nn.Linear(out_dim, num_classes) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + feats = self.backbone(x) + return self.head(feats) + + +def _set_seed(seed: int) -> None: + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +def _auc_score(y_true: np.ndarray, probs: np.ndarray, num_classes: int) -> float: + try: + if num_classes == 2: + return float(roc_auc_score(y_true, probs[:, 1])) + return float(roc_auc_score(y_true, probs, multi_class="ovr", average="macro")) + except Exception: + return float("nan") + + +def _prepare_clinical() -> Tuple[object, pd.DataFrame]: + clinical = build_papila_clinical( + image_dir=IMAGE_DIR, + clinical_dir=CLINICAL_DIR, + label_col=LABEL_COL, + cat_cols=CAT_COLS, + n_splits=N_SPLITS, + random_seed=FOLD_SEED, + ) + df = clinical.df.copy() + if EVAL_MODE == "binary": + df = df[df[LABEL_COL].isin([0, 1])].reset_index(drop=True) + return clinical, df + + +def _split_holdout_by_patient(df: pd.DataFrame) -> Tuple[pd.DataFrame, pd.DataFrame]: + rng = np.random.default_rng(HOLDOUT_SEED) + patient_label = ( + df.groupby("Patient ID")[LABEL_COL] + .agg(lambda s: int(s.mode().iloc[0])) + .reset_index() + ) + holdout_patients = [] + for lbl, grp in patient_label.groupby(LABEL_COL): + candidates = grp["Patient ID"].to_numpy() + n = min(HOLDOUT_PATIENTS_PER_CLASS, len(candidates)) + if n <= 0: + continue + selected = rng.choice(candidates, size=n, replace=False) + holdout_patients.extend(selected.tolist()) + holdout_patients = sorted(set(holdout_patients)) + holdout_df = df[df["Patient ID"].isin(holdout_patients)].reset_index(drop=True) + train_df = df[~df["Patient ID"].isin(holdout_patients)].reset_index(drop=True) + return train_df, holdout_df + + +def _rebuild_clinical_from_df(clinical, df: pd.DataFrame) -> object: + clinical.frames = [df.copy()] + clinical.df = df.copy() + clinical._infer_or_validate_feature_types() + clinical._compute_numeric_stats() + clinical._build_cat_maps() + clinical._compute_feature_dim() + clinical._build_kfold_indices() + return clinical + + +def _train_cnn( + model: nn.Module, loader: DataLoader, num_classes: int, fold: int +) -> None: + model.train() + optimizer = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY) + criterion = nn.CrossEntropyLoss() + for epoch in range(EPOCHS): + running_loss = 0.0 + correct = 0 + total = 0 + for x_img, y, _x_md in loader: + x_img = x_img.to(DEVICE) + y = y.to(DEVICE) + optimizer.zero_grad() + logits = model(x_img) + loss = criterion(logits, y) + loss.backward() + optimizer.step() + running_loss += float(loss.item()) * int(y.size(0)) + pred = torch.argmax(logits, dim=1) + correct += int((pred == y).sum().item()) + total += int(y.size(0)) + + if PRINT_EPOCH_REPORT and ((epoch + 1) % EPOCH_REPORT_EVERY == 0): + avg_loss = running_loss / max(total, 1) + train_acc = correct / max(total, 1) + print( + f"[fold {fold + 1}/{N_SPLITS}] epoch {epoch + 1}/{EPOCHS} " + f"train_loss={avg_loss:.4f} train_acc={train_acc:.4f}" + ) + + +def _infer_logits( + model: nn.Module, loader: DataLoader +) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + model.eval() + logits_all, probs_all, y_all, md_all = [], [], [], [] + with torch.no_grad(): + for x_img, y, x_md in loader: + x_img = x_img.to(DEVICE) + logits = model(x_img).cpu().numpy() + probs = torch.softmax(torch.from_numpy(logits), dim=1).numpy() + logits_all.append(logits) + probs_all.append(probs) + y_all.append(y.numpy()) + md_all.append(x_md.numpy()) + return ( + np.concatenate(y_all, axis=0), + np.concatenate(logits_all, axis=0), + np.concatenate(md_all, axis=0), + ) + + +def main() -> None: + _set_seed(FOLD_SEED) + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + num_classes = 2 if EVAL_MODE == "binary" else 3 + train_tf = transforms.Compose( + [ + transforms.Resize((224, 224)), + transforms.RandomHorizontalFlip(), + transforms.ToTensor(), + transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), + ] + ) + eval_tf = transforms.Compose( + [ + transforms.Resize((224, 224)), + transforms.ToTensor(), + transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), + ] + ) + + clinical, df = _prepare_clinical() + train_df, holdout_df = _split_holdout_by_patient(df) + clinical = _rebuild_clinical_from_df(clinical, train_df) + holdout_df.to_csv(OUTPUT_DIR / "holdout_patients.csv", index=False) + + rows: List[Dict[str, object]] = [] + holdout_rows: List[Dict[str, object]] = [] + + for fold in range(N_SPLITS): + print(f"\n[info] Starting fold {fold + 1}/{N_SPLITS}") + fold_train_df, fold_val_df = clinical.get_split_dfs(fold) + ds_train = PapilaImageDataset(clinical, fold_train_df, LABEL_COL, train_tf) + ds_val = PapilaImageDataset(clinical, fold_val_df, LABEL_COL, eval_tf) + ds_holdout = PapilaImageDataset(clinical, holdout_df, LABEL_COL, eval_tf) + + dl_train = DataLoader( + ds_train, batch_size=BATCH_SIZE, shuffle=True, num_workers=0 + ) + dl_val = DataLoader(ds_val, batch_size=BATCH_SIZE, shuffle=False, num_workers=0) + dl_holdout = DataLoader( + ds_holdout, batch_size=BATCH_SIZE, shuffle=False, num_workers=0 + ) + + model = CNNHead(BACKBONE_NAME, num_classes=num_classes).to(DEVICE) + _train_cnn(model, dl_train, num_classes=num_classes, fold=fold) + + y_tr, log_tr, md_tr = _infer_logits( + model, + DataLoader(ds_train, batch_size=BATCH_SIZE, shuffle=False, num_workers=0), + ) + y_va, log_va, md_va = _infer_logits(model, dl_val) + y_ho, log_ho, md_ho = _infer_logits(model, dl_holdout) + + np.save(OUTPUT_DIR / f"fold{fold}_train_logits.npy", log_tr) + np.save(OUTPUT_DIR / f"fold{fold}_val_logits.npy", log_va) + np.save(OUTPUT_DIR / f"fold{fold}_holdout_logits.npy", log_ho) + + X_tr = np.concatenate([log_tr, md_tr], axis=1) + X_va = np.concatenate([log_va, md_va], axis=1) + X_ho = np.concatenate([log_ho, md_ho], axis=1) + + rf = RandomForestClassifier( + n_estimators=RF_TREES, + max_depth=RF_MAX_DEPTH, + min_samples_leaf=RF_MIN_SAMPLES_LEAF, + class_weight="balanced", + random_state=FOLD_SEED + fold, + n_jobs=-1, + ) + rf.fit(X_tr, y_tr) + + p_va = rf.predict_proba(X_va) + p_ho = rf.predict_proba(X_ho) + pred_va = np.argmax(p_va, axis=1) + pred_ho = np.argmax(p_ho, axis=1) + + rows.append( + { + "fold": fold, + "val_acc": float(accuracy_score(y_va, pred_va)), + "val_auc": _auc_score(y_va, p_va, num_classes), + "n_val": int(len(y_va)), + } + ) + holdout_rows.append( + { + "fold": fold, + "holdout_acc": float(accuracy_score(y_ho, pred_ho)), + "holdout_auc": _auc_score(y_ho, p_ho, num_classes), + "n_holdout": int(len(y_ho)), + } + ) + print( + f"[info] Fold {fold + 1} RF: val_acc={rows[-1]['val_acc']:.4f} val_auc={rows[-1]['val_auc']:.4f} " + f"| holdout_acc={holdout_rows[-1]['holdout_acc']:.4f} holdout_auc={holdout_rows[-1]['holdout_auc']:.4f}" + ) + + fold_df = pd.DataFrame(rows) + holdout_df = pd.DataFrame(holdout_rows) + fold_df.to_csv(OUTPUT_DIR / "rf_val_metrics.csv", index=False) + holdout_df.to_csv(OUTPUT_DIR / "rf_holdout_metrics.csv", index=False) + + print("\nRF validation metrics:") + print(fold_df.to_string(index=False, float_format=lambda x: f"{x:.4f}")) + print("\nRF holdout metrics:") + print(holdout_df.to_string(index=False, float_format=lambda x: f"{x:.4f}")) + print( + f"\nMeans: val_acc={fold_df['val_acc'].mean():.4f}, val_auc={fold_df['val_auc'].mean():.4f}, " + f"holdout_acc={holdout_df['holdout_acc'].mean():.4f}, holdout_auc={holdout_df['holdout_auc'].mean():.4f}" + ) + print(f"\nSaved outputs to: {OUTPUT_DIR}") + + +if __name__ == "__main__": + main() diff --git a/scripts/basic_analysis/compare_dual_eye_towers.py b/scripts/basic_analysis/compare_dual_eye_towers.py new file mode 100644 index 0000000..e50a45c --- /dev/null +++ b/scripts/basic_analysis/compare_dual_eye_towers.py @@ -0,0 +1,1338 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import csv +import json +import random +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace +from typing import Optional + +import numpy as np +import pandas as pd +import torch +import torch.nn.functional as F +from sklearn.metrics import roc_auc_score +from torch import nn +from torch.utils.data import DataLoader + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes.v2 import ( + Bridge, + ImageTower, + PatientFirstSplitManager, + SlotDataset, + build_papila_data, + build_papila_profile, + slot_collate, +) + + +def seed_everything(seed: int) -> None: + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +@dataclass +class FoldMetrics: + mode: str + fold: int + baseline_acc: float + baseline_auc: float + bilateral_acc: float + bilateral_auc: float + baseline_n: int + bilateral_n: int + os_acc: float + os_auc: float + os_n: int + holdout_baseline_acc: float + holdout_baseline_auc: float + holdout_bilateral_acc: float + holdout_bilateral_auc: float + holdout_baseline_n: int + holdout_bilateral_n: int + holdout_os_acc: float + holdout_os_auc: float + holdout_os_n: int + + +class EyeLevelCNN(nn.Module): + def __init__(self, *, backbone: str, freeze_ratio: float, num_classes: int, augment: bool): + super().__init__() + self.tower = ImageTower( + backbone=backbone, + freeze_ratio=freeze_ratio, + augment=augment, + use_se=False, + ) + self.head = nn.Linear(self.tower.out_dim, num_classes) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + feats = self.tower(x) + return self.head(feats) + + +class BilateralFusionCNN(nn.Module): + def __init__( + self, + *, + backbone: str, + freeze_ratio: float, + num_classes: int, + augment: bool, + use_se: bool, + fusion_dim: int, + ): + super().__init__() + self.tower_od = ImageTower( + backbone=backbone, + freeze_ratio=freeze_ratio, + augment=augment, + use_se=False, + ) + self.tower_os = ImageTower( + backbone=backbone, + freeze_ratio=freeze_ratio, + augment=augment, + use_se=False, + ) + self.bridge = Bridge( + img_dim=self.tower_od.out_dim, + meta_dim=self.tower_os.out_dim, + num_classes=num_classes, + fusion_dim=fusion_dim, + mode="fused", + use_se=use_se, + ) + + def forward(self, od: torch.Tensor, os: torch.Tensor) -> torch.Tensor: + f_od = self.tower_od(od) + f_os = self.tower_os(os) + out_fused, _, _ = self.bridge(f_od, f_os) + return out_fused + + +def patient_to_single_eye_samples(patient_samples: list[dict], eye_key: str) -> list[dict]: + out = [] + for s in patient_samples: + img = s.get(eye_key) + lbl = s.get("label_1") + if img is None or lbl is None: + continue + out.append({"id_1": s.get("id_1"), "image_1": img, "label_1": lbl}) + return out + + +def make_loader( + samples: list[dict], + slots: dict, + *, + image_transform, + batch_size: int, + shuffle: bool, + num_workers: int, +) -> DataLoader: + ds = SlotDataset(samples, slots, image_transform=image_transform) + return DataLoader( + ds, + batch_size=batch_size, + shuffle=shuffle, + num_workers=num_workers, + collate_fn=slot_collate, + ) + + +def filter_eye_samples(samples: list[dict]) -> list[dict]: + return [s for s in samples if s.get("image_1") is not None and s.get("label_1") is not None] + + +def filter_bilateral_samples(samples: list[dict]) -> list[dict]: + return [ + s + for s in samples + if s.get("image_1") is not None and s.get("image_2") is not None and s.get("label_1") is not None + ] + + +def to_label_tensor(labels, device: torch.device) -> torch.Tensor: + if torch.is_tensor(labels): + return labels.to(device=device, dtype=torch.long) + return torch.as_tensor(labels, dtype=torch.long, device=device) + + +def train_eye_epoch( + model: EyeLevelCNN, + loader: DataLoader, + optimizer: torch.optim.Optimizer, + device: torch.device, +): + model.train() + total_loss = 0.0 + total_correct = 0 + total_n = 0 + for batch in loader: + x = batch.get("image_1") + y = batch.get("label_1") + if not torch.is_tensor(x): + continue + y = to_label_tensor(y, device) + x = x.to(device) + logits = model(x) + loss = F.cross_entropy(logits, y) + optimizer.zero_grad() + loss.backward() + optimizer.step() + bs = int(y.shape[0]) + total_loss += float(loss.item()) * bs + total_correct += int((logits.argmax(dim=1) == y).sum().item()) + total_n += bs + avg_loss = float(total_loss / total_n) if total_n > 0 else float("nan") + acc = float(total_correct / total_n) if total_n > 0 else float("nan") + return avg_loss, acc, total_n + + +def train_bilateral_epoch( + model: BilateralFusionCNN, + loader: DataLoader, + optimizer: torch.optim.Optimizer, + device: torch.device, +): + model.train() + total_loss = 0.0 + total_correct = 0 + total_n = 0 + for batch in loader: + x1 = batch.get("image_1") + x2 = batch.get("image_2") + y = batch.get("label_1") + if not torch.is_tensor(x1) or not torch.is_tensor(x2): + continue + y = to_label_tensor(y, device) + x1 = x1.to(device) + x2 = x2.to(device) + logits = model(x1, x2) + loss = F.cross_entropy(logits, y) + optimizer.zero_grad() + loss.backward() + optimizer.step() + bs = int(y.shape[0]) + total_loss += float(loss.item()) * bs + total_correct += int((logits.argmax(dim=1) == y).sum().item()) + total_n += bs + avg_loss = float(total_loss / total_n) if total_n > 0 else float("nan") + acc = float(total_correct / total_n) if total_n > 0 else float("nan") + return avg_loss, acc, total_n + + +def evaluate_eye(model: EyeLevelCNN, loader: DataLoader, device: torch.device, num_classes: int): + model.eval() + y_true = [] + y_prob = [] + total_loss = 0.0 + total_n = 0 + with torch.no_grad(): + for batch in loader: + x = batch.get("image_1") + y = batch.get("label_1") + if not torch.is_tensor(x): + continue + y_t = to_label_tensor(y, device) + logits = model(x.to(device)) + bs = int(y_t.shape[0]) + total_loss += float(F.cross_entropy(logits, y_t).item()) * bs + total_n += bs + probs = F.softmax(logits, dim=1).cpu().numpy() + y_true.append(y_t.cpu().numpy()) + y_prob.append(probs) + acc, auc, n = _score_arrays(y_true, y_prob, num_classes) + avg_loss = float(total_loss / total_n) if total_n > 0 else float("nan") + return avg_loss, acc, auc, n + + +def evaluate_bilateral( + model: BilateralFusionCNN, + loader: DataLoader, + device: torch.device, + num_classes: int, +): + model.eval() + y_true = [] + y_prob = [] + total_loss = 0.0 + total_n = 0 + with torch.no_grad(): + for batch in loader: + x1 = batch.get("image_1") + x2 = batch.get("image_2") + y = batch.get("label_1") + if not torch.is_tensor(x1) or not torch.is_tensor(x2): + continue + y_t = to_label_tensor(y, device) + logits = model(x1.to(device), x2.to(device)) + bs = int(y_t.shape[0]) + total_loss += float(F.cross_entropy(logits, y_t).item()) * bs + total_n += bs + probs = F.softmax(logits, dim=1).cpu().numpy() + y_true.append(y_t.cpu().numpy()) + y_prob.append(probs) + acc, auc, n = _score_arrays(y_true, y_prob, num_classes) + avg_loss = float(total_loss / total_n) if total_n > 0 else float("nan") + return avg_loss, acc, auc, n + + +def evaluate_two_single_merge( + model_od: EyeLevelCNN, + model_os: EyeLevelCNN, + loader: DataLoader, + device: torch.device, + num_classes: int, +): + model_od.eval() + model_os.eval() + y_true = [] + y_prob = [] + total_loss = 0.0 + total_n = 0 + with torch.no_grad(): + for batch in loader: + x1 = batch.get("image_1") + x2 = batch.get("image_2") + y = batch.get("label_1") + if not torch.is_tensor(x1) or not torch.is_tensor(x2): + continue + y_t = to_label_tensor(y, device) + p1 = F.softmax(model_od(x1.to(device)), dim=1) + p2 = F.softmax(model_os(x2.to(device)), dim=1) + p = 0.5 * (p1 + p2) + bs = int(y_t.shape[0]) + total_loss += float(F.nll_loss(torch.log(p.clamp_min(1e-8)), y_t).item()) * bs + total_n += bs + y_true.append(y_t.cpu().numpy()) + y_prob.append(p.cpu().numpy()) + acc, auc, n = _score_arrays(y_true, y_prob, num_classes) + avg_loss = float(total_loss / total_n) if total_n > 0 else float("nan") + return avg_loss, acc, auc, n + + +def collect_binary_probs_eye(model: EyeLevelCNN, loader: DataLoader, device: torch.device): + model.eval() + y_true = [] + p1 = [] + with torch.no_grad(): + for batch in loader: + x = batch.get("image_1") + y = batch.get("label_1") + if not torch.is_tensor(x): + continue + y_t = to_label_tensor(y, device) + probs = F.softmax(model(x.to(device)), dim=1)[:, 1] + y_true.append(y_t.cpu().numpy()) + p1.append(probs.cpu().numpy()) + if not y_true: + return np.array([]), np.array([]) + return np.concatenate(y_true), np.concatenate(p1) + + +def collect_binary_probs_bilateral(model: BilateralFusionCNN, loader: DataLoader, device: torch.device): + model.eval() + y_true = [] + p1 = [] + with torch.no_grad(): + for batch in loader: + x1 = batch.get("image_1") + x2 = batch.get("image_2") + y = batch.get("label_1") + if not torch.is_tensor(x1) or not torch.is_tensor(x2): + continue + y_t = to_label_tensor(y, device) + probs = F.softmax(model(x1.to(device), x2.to(device)), dim=1)[:, 1] + y_true.append(y_t.cpu().numpy()) + p1.append(probs.cpu().numpy()) + if not y_true: + return np.array([]), np.array([]) + return np.concatenate(y_true), np.concatenate(p1) + + +def collect_binary_probs_merge(model_od: EyeLevelCNN, model_os: EyeLevelCNN, loader: DataLoader, device: torch.device): + model_od.eval() + model_os.eval() + y_true = [] + p1 = [] + with torch.no_grad(): + for batch in loader: + x1 = batch.get("image_1") + x2 = batch.get("image_2") + y = batch.get("label_1") + if not torch.is_tensor(x1) or not torch.is_tensor(x2): + continue + y_t = to_label_tensor(y, device) + p_od = F.softmax(model_od(x1.to(device)), dim=1)[:, 1] + p_os = F.softmax(model_os(x2.to(device)), dim=1)[:, 1] + p = 0.5 * (p_od + p_os) + y_true.append(y_t.cpu().numpy()) + p1.append(p.cpu().numpy()) + if not y_true: + return np.array([]), np.array([]) + return np.concatenate(y_true), np.concatenate(p1) + + +def tune_binary_threshold(y_true: np.ndarray, p1: np.ndarray) -> float: + if y_true.size == 0: + return 0.5 + grid = np.linspace(0.0, 1.0, 1001) + best_t = 0.5 + best_acc = -1.0 + for t in grid: + pred = (p1 >= t).astype(int) + acc = float((pred == y_true).mean()) + if acc > best_acc or (acc == best_acc and abs(t - 0.5) < abs(best_t - 0.5)): + best_acc = acc + best_t = float(t) + return best_t + + +def binary_acc_at_threshold(y_true: np.ndarray, p1: np.ndarray, t: float) -> float: + if y_true.size == 0: + return float("nan") + pred = (p1 >= t).astype(int) + return float((pred == y_true).mean()) + + +def collect_probs_eye(model: EyeLevelCNN, loader: DataLoader, device: torch.device): + model.eval() + y_true = [] + probs_all = [] + with torch.no_grad(): + for batch in loader: + x = batch.get("image_1") + y = batch.get("label_1") + if not torch.is_tensor(x): + continue + y_t = to_label_tensor(y, device) + probs = F.softmax(model(x.to(device)), dim=1).cpu().numpy() + y_true.append(y_t.cpu().numpy()) + probs_all.append(probs) + if not y_true: + return np.array([]), np.zeros((0, 0), dtype=float) + return np.concatenate(y_true), np.concatenate(probs_all, axis=0) + + +def collect_probs_bilateral(model: BilateralFusionCNN, loader: DataLoader, device: torch.device): + model.eval() + y_true = [] + probs_all = [] + with torch.no_grad(): + for batch in loader: + x1 = batch.get("image_1") + x2 = batch.get("image_2") + y = batch.get("label_1") + if not torch.is_tensor(x1) or not torch.is_tensor(x2): + continue + y_t = to_label_tensor(y, device) + probs = F.softmax(model(x1.to(device), x2.to(device)), dim=1).cpu().numpy() + y_true.append(y_t.cpu().numpy()) + probs_all.append(probs) + if not y_true: + return np.array([]), np.zeros((0, 0), dtype=float) + return np.concatenate(y_true), np.concatenate(probs_all, axis=0) + + +def collect_probs_merge(model_od: EyeLevelCNN, model_os: EyeLevelCNN, loader: DataLoader, device: torch.device): + model_od.eval() + model_os.eval() + y_true = [] + probs_all = [] + with torch.no_grad(): + for batch in loader: + x1 = batch.get("image_1") + x2 = batch.get("image_2") + y = batch.get("label_1") + if not torch.is_tensor(x1) or not torch.is_tensor(x2): + continue + y_t = to_label_tensor(y, device) + p_od = F.softmax(model_od(x1.to(device)), dim=1) + p_os = F.softmax(model_os(x2.to(device)), dim=1) + probs = (0.5 * (p_od + p_os)).cpu().numpy() + y_true.append(y_t.cpu().numpy()) + probs_all.append(probs) + if not y_true: + return np.array([]), np.zeros((0, 0), dtype=float) + return np.concatenate(y_true), np.concatenate(probs_all, axis=0) + + +def multiclass_acc_with_bias(y_true: np.ndarray, probs: np.ndarray, bias: np.ndarray) -> float: + if y_true.size == 0: + return float("nan") + logits = np.log(np.clip(probs, 1e-8, 1.0)) + bias.reshape(1, -1) + pred = np.argmax(logits, axis=1) + return float((pred == y_true).mean()) + + +def tune_multiclass_bias(y_true: np.ndarray, probs: np.ndarray, *, iters: int = 2) -> np.ndarray: + if y_true.size == 0 or probs.size == 0: + return np.zeros((0,), dtype=float) + c = probs.shape[1] + bias = np.zeros((c,), dtype=float) + grid = np.linspace(-1.0, 1.0, 41) + for _ in range(iters): + for k in range(c): + best_v = bias[k] + best_acc = multiclass_acc_with_bias(y_true, probs, bias) + old = bias[k] + for v in grid: + bias[k] = float(v) + acc = multiclass_acc_with_bias(y_true, probs, bias) + if acc > best_acc or (acc == best_acc and abs(v) < abs(best_v)): + best_acc = acc + best_v = float(v) + bias[k] = best_v + # small stabilization around baseline + if np.isnan(best_acc): + bias[k] = old + return bias + + +def _serialize_vec(vec: np.ndarray | None) -> str | None: + if vec is None: + return None + if vec.size == 0: + return None + return "|".join(f"{float(v):.4f}" for v in vec.tolist()) + + +def _score_arrays(y_true_chunks, y_prob_chunks, num_classes: int): + if not y_true_chunks: + return float("nan"), float("nan"), 0 + y = np.concatenate(y_true_chunks, axis=0) + p = np.concatenate(y_prob_chunks, axis=0) + acc = float((p.argmax(axis=1) == y).mean()) + try: + if num_classes == 2: + auc = float(roc_auc_score(y, p[:, 1])) + else: + auc = float(roc_auc_score(y, p, multi_class="ovr", average="macro")) + except Exception: + auc = float("nan") + return acc, auc, int(y.shape[0]) + + +def _drop_mixed_label_patients(df: pd.DataFrame, *, patient_col: str, label_col: str): + per_patient = ( + df.groupby(patient_col)[label_col] + .agg(lambda s: set(pd.to_numeric(s, errors="coerce").dropna().astype(int).tolist())) + ) + mixed_ids = [pid for pid, labels in per_patient.items() if len(labels) > 1] + if not mixed_ids: + return df, [] + keep = ~df[patient_col].isin(mixed_ids) + return df[keep].reset_index(drop=True), mixed_ids + + +def choose_device(name: str) -> torch.device: + if name == "cuda": + if not torch.cuda.is_available(): + raise RuntimeError("Requested --device cuda but CUDA is not available.") + return torch.device("cuda") + if name == "cpu": + return torch.device("cpu") + return torch.device("cuda" if torch.cuda.is_available() else "cpu") + + +def parse_args(): + ap = argparse.ArgumentParser( + description="Compare eye-level single-tower CNN vs patient-level bilateral dual-tower fusion model." + ) + ap.add_argument("--image-dir", default="Papila/FundusImages") + ap.add_argument("--clinical-dir", default="Papila/ClinicalData") + ap.add_argument("--label-col", default="Diagnosis") + ap.add_argument("--cat-cols", nargs="*", default=["Gender", "Phakic/Pseudophakic"]) + ap.add_argument("--eval-mode", choices=["multiclass", "binary"], default="binary") + ap.add_argument( + "--eval-modes", + nargs="+", + choices=["multiclass", "binary"], + default=None, + help="Optional list of modes to run in one pass (e.g. --eval-modes binary multiclass).", + ) + ap.add_argument("--n-splits", type=int, default=5) + ap.add_argument("--fold-seed", type=int, default=42) + ap.add_argument("--holdout-per-class", type=int, default=0) + ap.add_argument("--holdout-seed", type=int, default=123) + ap.add_argument("--folds", type=int, default=5, help="How many folds to run (<= n-splits).") + ap.add_argument("--epochs", type=int, default=8) + ap.add_argument("--batch-size", type=int, default=8) + ap.add_argument("--lr", type=float, default=1e-4) + ap.add_argument("--backbone", default="resnet50") + ap.add_argument("--freeze-ratio", type=float, default=0.0) + ap.add_argument("--augment", action="store_true", help="Enable image augmentation during training.") + ap.add_argument("--fusion-dim", type=int, default=256) + ap.add_argument("--bridge-se", action="store_true", help="Enable SE in bilateral bridge.") + ap.add_argument( + "--bilateral-method", + choices=["bridge", "two-single-merge"], + default="bridge", + help="Bilateral comparator: learned dual-tower bridge or two separate single-eye models merged by prob average.", + ) + ap.add_argument("--num-workers", type=int, default=0) + ap.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto") + ap.add_argument("--seed", type=int, default=1234) + ap.add_argument("--run-name", default=None) + ap.add_argument("--output-root", default="analysis_data/basic_analysis") + ap.add_argument( + "--exclude-binary-mixed-patients", + action="store_true", + help="Drop patients with mixed eye labels (any disagreement across eyes) before splitting.", + ) + ap.add_argument( + "--log-every", + type=int, + default=0, + help="If > 0, print epoch progress every N epochs within each fold.", + ) + ap.add_argument( + "--tune-binary-threshold", + action="store_true", + help="In binary mode, tune decision thresholds on validation probs and apply them to val/holdout accuracy.", + ) + ap.add_argument( + "--tune-multiclass-bias", + action="store_true", + help="In multiclass mode, tune per-class log-prob biases on validation and apply to val/holdout accuracy.", + ) + return ap.parse_args() + + +def _serialize_float(v: float) -> Optional[float]: + return None if np.isnan(v) else float(v) + + +def _rows_from_metrics(metrics: list[FoldMetrics]) -> list[dict]: + rows = [] + for m in metrics: + rows.append( + { + "mode": m.mode, + "fold": m.fold, + "baseline_acc": _serialize_float(m.baseline_acc), + "baseline_auc": _serialize_float(m.baseline_auc), + "bilateral_acc": _serialize_float(m.bilateral_acc), + "bilateral_auc": _serialize_float(m.bilateral_auc), + "baseline_n": m.baseline_n, + "bilateral_n": m.bilateral_n, + "os_acc": _serialize_float(m.os_acc), + "os_auc": _serialize_float(m.os_auc), + "os_n": m.os_n, + "holdout_baseline_acc": _serialize_float(m.holdout_baseline_acc), + "holdout_baseline_auc": _serialize_float(m.holdout_baseline_auc), + "holdout_bilateral_acc": _serialize_float(m.holdout_bilateral_acc), + "holdout_bilateral_auc": _serialize_float(m.holdout_bilateral_auc), + "holdout_baseline_n": m.holdout_baseline_n, + "holdout_bilateral_n": m.holdout_bilateral_n, + "holdout_os_acc": _serialize_float(m.holdout_os_acc), + "holdout_os_auc": _serialize_float(m.holdout_os_auc), + "holdout_os_n": m.holdout_os_n, + } + ) + return rows + + +def _summary_for_mode(mode: str, metrics: list[FoldMetrics]) -> dict: + b_accs = np.array([m.baseline_acc for m in metrics], dtype=float) + b_aucs = np.array([m.baseline_auc for m in metrics], dtype=float) + d_accs = np.array([m.bilateral_acc for m in metrics], dtype=float) + d_aucs = np.array([m.bilateral_auc for m in metrics], dtype=float) + hb_accs = np.array([m.holdout_baseline_acc for m in metrics], dtype=float) + hb_aucs = np.array([m.holdout_baseline_auc for m in metrics], dtype=float) + hd_accs = np.array([m.holdout_bilateral_acc for m in metrics], dtype=float) + hd_aucs = np.array([m.holdout_bilateral_auc for m in metrics], dtype=float) + + return { + "mode": mode, + "baseline": { + "acc_mean": _serialize_float(float(np.nanmean(b_accs))), + "acc_std": _serialize_float(float(np.nanstd(b_accs))), + "auc_mean": _serialize_float(float(np.nanmean(b_aucs))), + "auc_std": _serialize_float(float(np.nanstd(b_aucs))), + }, + "bilateral": { + "acc_mean": _serialize_float(float(np.nanmean(d_accs))), + "acc_std": _serialize_float(float(np.nanstd(d_accs))), + "auc_mean": _serialize_float(float(np.nanmean(d_aucs))), + "auc_std": _serialize_float(float(np.nanstd(d_aucs))), + }, + "delta_bilateral_minus_baseline": { + "acc_mean": _serialize_float(float(np.nanmean(d_accs - b_accs))), + "auc_mean": _serialize_float(float(np.nanmean(d_aucs - b_aucs))), + }, + "holdout_baseline": { + "acc_mean": _serialize_float(float(np.nanmean(hb_accs))), + "acc_std": _serialize_float(float(np.nanstd(hb_accs))), + "auc_mean": _serialize_float(float(np.nanmean(hb_aucs))), + "auc_std": _serialize_float(float(np.nanstd(hb_aucs))), + }, + "holdout_bilateral": { + "acc_mean": _serialize_float(float(np.nanmean(hd_accs))), + "acc_std": _serialize_float(float(np.nanstd(hd_accs))), + "auc_mean": _serialize_float(float(np.nanmean(hd_aucs))), + "auc_std": _serialize_float(float(np.nanstd(hd_aucs))), + }, + "holdout_delta_bilateral_minus_baseline": { + "acc_mean": _serialize_float(float(np.nanmean(hd_accs - hb_accs))), + "auc_mean": _serialize_float(float(np.nanmean(hd_aucs - hb_aucs))), + }, + } + + +def _print_summary(mode: str, summary: dict) -> None: + def fmt(v): + return "nan" if v is None else f"{v:.4f}" + + print(f"\n=== Summary ({mode}) ===") + print( + "baseline_eye_cnn " + f"acc={fmt(summary['baseline']['acc_mean'])}±{fmt(summary['baseline']['acc_std'])} " + f"auc={fmt(summary['baseline']['auc_mean'])}±{fmt(summary['baseline']['auc_std'])}" + ) + print( + "bilateral_dual_img " + f"acc={fmt(summary['bilateral']['acc_mean'])}±{fmt(summary['bilateral']['acc_std'])} " + f"auc={fmt(summary['bilateral']['auc_mean'])}±{fmt(summary['bilateral']['auc_std'])}" + ) + print( + "delta(bilateral-baseline) " + f"acc={fmt(summary['delta_bilateral_minus_baseline']['acc_mean'])} " + f"auc={fmt(summary['delta_bilateral_minus_baseline']['auc_mean'])}" + ) + if summary["holdout_baseline"]["acc_mean"] is not None: + print( + "holdout baseline_eye_cnn " + f"acc={fmt(summary['holdout_baseline']['acc_mean'])}±{fmt(summary['holdout_baseline']['acc_std'])} " + f"auc={fmt(summary['holdout_baseline']['auc_mean'])}±{fmt(summary['holdout_baseline']['auc_std'])}" + ) + print( + "holdout bilateral_dual_img " + f"acc={fmt(summary['holdout_bilateral']['acc_mean'])}±{fmt(summary['holdout_bilateral']['acc_std'])} " + f"auc={fmt(summary['holdout_bilateral']['auc_mean'])}±{fmt(summary['holdout_bilateral']['auc_std'])}" + ) + print( + "holdout delta(bilateral-baseline) " + f"acc={fmt(summary['holdout_delta_bilateral_minus_baseline']['acc_mean'])} " + f"auc={fmt(summary['holdout_delta_bilateral_minus_baseline']['auc_mean'])}" + ) + + +def run_mode(args, mode: str, device: torch.device, data, out_dir: Path) -> tuple[list[FoldMetrics], dict]: + df_mode = data.df.copy() + if args.exclude_binary_mixed_patients: + before_rows = len(df_mode) + before_patients = int(df_mode["Patient ID"].nunique()) + df_mode, mixed_ids = _drop_mixed_label_patients( + df_mode, patient_col="Patient ID", label_col=args.label_col + ) + print( + f"[mode={mode}] excluded {len(mixed_ids)} mixed-label patients " + f"(rows {before_rows}->{len(df_mode)}, patients {before_patients}->{df_mode['Patient ID'].nunique()})", + flush=True, + ) + + if mode == "binary": + df_mode = df_mode[df_mode[args.label_col].isin([0, 1])].reset_index(drop=True) + + num_classes = 2 if mode == "binary" else int(df_mode[args.label_col].nunique()) + print( + f"\n[mode={mode}] preparing splits (num_classes={num_classes}, rows={len(df_mode)}, patients={df_mode['Patient ID'].nunique()})...", + flush=True, + ) + + split_manager = PatientFirstSplitManager(patient_col="Patient ID", label_col=args.label_col) + split_args = SimpleNamespace( + eval_mode=mode, + holdout_per_class=args.holdout_per_class, + holdout_seed=args.holdout_seed, + n_splits=args.n_splits, + fold_seed=args.fold_seed, + ) + clinical_for_split = SimpleNamespace(df=df_mode, label_col=args.label_col) + plans = split_manager.build_plans(clinical=clinical_for_split, args=split_args, profile=None) + n_folds = min(args.folds, len(plans)) + + profile_eye = build_papila_profile(patient_col="Patient ID", label_col=args.label_col, sample_mode="eye") + profile_patient = build_papila_profile(patient_col="Patient ID", label_col=args.label_col, sample_mode="patient") + + fold_metrics: list[FoldMetrics] = [] + mode_dir = out_dir / mode + mode_dir.mkdir(parents=True, exist_ok=True) + for fold in range(n_folds): + split = plans[fold] + holdout_df = split.holdout + fold_seed = args.seed + fold * 100 + seed_everything(fold_seed) + print( + f"[mode={mode}] fold {fold+1}/{n_folds}: building models/loaders...", + flush=True, + ) + fold_dir = mode_dir / f"fold{fold}" + fold_dir.mkdir(parents=True, exist_ok=True) + train_log_path = fold_dir / "train.log" + epoch_log_path = fold_dir / "epoch_log.csv" + + baseline_model = None + bilateral_model = None + model_od = None + model_os = None + if args.bilateral_method == "bridge": + baseline_model = EyeLevelCNN( + backbone=args.backbone, + freeze_ratio=args.freeze_ratio, + num_classes=num_classes, + augment=args.augment, + ).to(device) + bilateral_model = BilateralFusionCNN( + backbone=args.backbone, + freeze_ratio=args.freeze_ratio, + num_classes=num_classes, + augment=args.augment, + use_se=args.bridge_se, + fusion_dim=args.fusion_dim, + ).to(device) + else: + model_od = EyeLevelCNN( + backbone=args.backbone, + freeze_ratio=args.freeze_ratio, + num_classes=num_classes, + augment=args.augment, + ).to(device) + model_os = EyeLevelCNN( + backbone=args.backbone, + freeze_ratio=args.freeze_ratio, + num_classes=num_classes, + augment=args.augment, + ).to(device) + baseline_model = model_od + + eye_train_samples = filter_eye_samples(profile_eye.build_samples(df=split.train, clinical=data)) + eye_val_samples = filter_eye_samples(profile_eye.build_samples(df=split.val, clinical=data)) + bilat_train_samples = filter_bilateral_samples(profile_patient.build_samples(df=split.train, clinical=data)) + bilat_val_samples = filter_bilateral_samples(profile_patient.build_samples(df=split.val, clinical=data)) + od_train_samples = patient_to_single_eye_samples(bilat_train_samples, "image_1") + od_val_samples = patient_to_single_eye_samples(bilat_val_samples, "image_1") + os_train_samples = patient_to_single_eye_samples(bilat_train_samples, "image_2") + os_val_samples = patient_to_single_eye_samples(bilat_val_samples, "image_2") + + eye_holdout_samples = [] + bilat_holdout_samples = [] + od_holdout_samples = [] + os_holdout_samples = [] + if holdout_df is not None and not holdout_df.empty: + eye_holdout_samples = filter_eye_samples(profile_eye.build_samples(df=holdout_df, clinical=data)) + bilat_holdout_samples = filter_bilateral_samples( + profile_patient.build_samples(df=holdout_df, clinical=data) + ) + od_holdout_samples = patient_to_single_eye_samples(bilat_holdout_samples, "image_1") + os_holdout_samples = patient_to_single_eye_samples(bilat_holdout_samples, "image_2") + baseline_train = None + baseline_val = None + if args.bilateral_method == "bridge": + baseline_train = make_loader( + eye_train_samples, + profile_eye.slot_descriptors(), + image_transform=baseline_model.tower.transform, + batch_size=args.batch_size, + shuffle=True, + num_workers=args.num_workers, + ) + baseline_val = make_loader( + eye_val_samples, + profile_eye.slot_descriptors(), + image_transform=baseline_model.tower.transform, + batch_size=args.batch_size, + shuffle=False, + num_workers=args.num_workers, + ) + else: + baseline_train = make_loader( + od_train_samples, + profile_eye.slot_descriptors(), + image_transform=model_od.tower.transform, + batch_size=args.batch_size, + shuffle=True, + num_workers=args.num_workers, + ) + baseline_val = make_loader( + od_val_samples, + profile_eye.slot_descriptors(), + image_transform=model_od.tower.transform, + batch_size=args.batch_size, + shuffle=False, + num_workers=args.num_workers, + ) + bilateral_train = make_loader( + bilat_train_samples, + profile_patient.slot_descriptors(), + image_transform=(bilateral_model.tower_od.transform if bilateral_model is not None else model_od.tower.transform), + batch_size=args.batch_size, + shuffle=True, + num_workers=args.num_workers, + ) + bilateral_val = make_loader( + bilat_val_samples, + profile_patient.slot_descriptors(), + image_transform=(bilateral_model.tower_od.transform if bilateral_model is not None else model_od.tower.transform), + batch_size=args.batch_size, + shuffle=False, + num_workers=args.num_workers, + ) + od_train = None + od_val = None + os_train = None + os_val = None + if args.bilateral_method == "two-single-merge": + od_train = make_loader( + od_train_samples, + profile_eye.slot_descriptors(), + image_transform=model_od.tower.transform, + batch_size=args.batch_size, + shuffle=True, + num_workers=args.num_workers, + ) + os_train = make_loader( + os_train_samples, + profile_eye.slot_descriptors(), + image_transform=model_os.tower.transform, + batch_size=args.batch_size, + shuffle=True, + num_workers=args.num_workers, + ) + od_val = make_loader( + od_val_samples, + profile_eye.slot_descriptors(), + image_transform=model_od.tower.transform, + batch_size=args.batch_size, + shuffle=False, + num_workers=args.num_workers, + ) + os_val = make_loader( + os_val_samples, + profile_eye.slot_descriptors(), + image_transform=model_os.tower.transform, + batch_size=args.batch_size, + shuffle=False, + num_workers=args.num_workers, + ) + + baseline_holdout = None + bilateral_holdout = None + os_holdout = None + if eye_holdout_samples: + if args.bilateral_method == "bridge": + baseline_holdout = make_loader( + eye_holdout_samples, + profile_eye.slot_descriptors(), + image_transform=baseline_model.tower.transform, + batch_size=args.batch_size, + shuffle=False, + num_workers=args.num_workers, + ) + else: + baseline_holdout = make_loader( + od_holdout_samples, + profile_eye.slot_descriptors(), + image_transform=model_od.tower.transform, + batch_size=args.batch_size, + shuffle=False, + num_workers=args.num_workers, + ) + os_holdout = make_loader( + os_holdout_samples, + profile_eye.slot_descriptors(), + image_transform=model_os.tower.transform, + batch_size=args.batch_size, + shuffle=False, + num_workers=args.num_workers, + ) + if bilat_holdout_samples: + bilateral_holdout = make_loader( + bilat_holdout_samples, + profile_patient.slot_descriptors(), + image_transform=(bilateral_model.tower_od.transform if bilateral_model is not None else model_od.tower.transform), + batch_size=args.batch_size, + shuffle=False, + num_workers=args.num_workers, + ) + + opt_base = None + opt_bilat = None + opt_od = None + opt_os = None + if args.bilateral_method == "bridge": + opt_base = torch.optim.Adam(baseline_model.parameters(), lr=args.lr) + opt_bilat = torch.optim.Adam(bilateral_model.parameters(), lr=args.lr) + else: + opt_od = torch.optim.Adam(model_od.parameters(), lr=args.lr) + opt_os = torch.optim.Adam(model_os.parameters(), lr=args.lr) + epoch_fields = [ + "mode", + "fold", + "epoch", + "baseline_train_loss", + "baseline_train_acc", + "baseline_train_n", + "baseline_val_loss", + "baseline_val_acc", + "baseline_val_auc", + "baseline_val_n", + "baseline_threshold", + "baseline_bias", + "bilateral_train_loss", + "bilateral_train_acc", + "bilateral_train_n", + "bilateral_val_loss", + "bilateral_val_acc", + "bilateral_val_auc", + "bilateral_val_n", + "bilateral_threshold", + "bilateral_bias", + "os_val_loss", + "os_val_acc", + "os_val_auc", + "os_val_n", + "os_threshold", + "os_bias", + "holdout_baseline_loss", + "holdout_baseline_acc", + "holdout_baseline_auc", + "holdout_baseline_n", + "holdout_bilateral_loss", + "holdout_bilateral_acc", + "holdout_bilateral_auc", + "holdout_bilateral_n", + "holdout_os_loss", + "holdout_os_acc", + "holdout_os_auc", + "holdout_os_n", + ] + epoch_fp = epoch_log_path.open("w", newline="", encoding="utf-8") + epoch_writer = csv.DictWriter(epoch_fp, fieldnames=epoch_fields) + epoch_writer.writeheader() + + print( + f"[mode={mode}] fold {fold+1}/{n_folds}: training " + f"(epochs={args.epochs}, baseline_train_n={len(eye_train_samples)}, " + f"bilateral_train_n={len(bilat_train_samples)}, method={args.bilateral_method})", + flush=True, + ) + b_loss = b_acc = b_auc = float("nan") + b_n = 0 + b_thr = 0.5 + b_bias = None + os_loss = os_acc = os_auc = float("nan") + os_n = 0 + os_thr = 0.5 + os_bias = None + d_loss = d_acc = d_auc = float("nan") + d_n = 0 + d_thr = 0.5 + d_bias = None + hb_loss = hb_acc = hb_auc = float("nan") + hb_n = 0 + hos_loss = hos_acc = hos_auc = float("nan") + hos_n = 0 + hd_loss = hd_acc = hd_auc = float("nan") + hd_n = 0 + with train_log_path.open("w", encoding="utf-8") as train_log: + for epoch in range(args.epochs): + bt_loss = bt_acc = float("nan") + bt_n = 0 + ot_loss = ot_acc = float("nan") + ot_n = 0 + if args.bilateral_method == "bridge": + bt_loss, bt_acc, bt_n = train_eye_epoch(baseline_model, baseline_train, opt_base, device) + else: + bt_loss, bt_acc, bt_n = train_eye_epoch(model_od, od_train, opt_od, device) + ot_loss, ot_acc, ot_n = train_eye_epoch(model_os, os_train, opt_os, device) + if args.bilateral_method == "bridge": + dt_loss, dt_acc, dt_n = train_bilateral_epoch(bilateral_model, bilateral_train, opt_bilat, device) + else: + dt_loss = float(np.nanmean([bt_loss, ot_loss])) + dt_acc = float(np.nanmean([bt_acc, ot_acc])) + dt_n = int(min(bt_n, ot_n)) + b_loss, b_acc, b_auc, b_n = evaluate_eye(baseline_model, baseline_val, device, num_classes) + if args.bilateral_method == "two-single-merge": + os_loss, os_acc, os_auc, os_n = evaluate_eye(model_os, os_val, device, num_classes) + if args.bilateral_method == "bridge": + d_loss, d_acc, d_auc, d_n = evaluate_bilateral(bilateral_model, bilateral_val, device, num_classes) + else: + d_loss, d_acc, d_auc, d_n = evaluate_two_single_merge( + model_od, model_os, bilateral_val, device, num_classes + ) + + if args.tune_binary_threshold and num_classes == 2: + yb, pb = collect_binary_probs_eye(baseline_model, baseline_val, device) + b_thr = tune_binary_threshold(yb, pb) + b_acc = binary_acc_at_threshold(yb, pb, b_thr) + if args.bilateral_method == "bridge": + yd, pd = collect_binary_probs_bilateral(bilateral_model, bilateral_val, device) + else: + yd, pd = collect_binary_probs_merge(model_od, model_os, bilateral_val, device) + d_thr = tune_binary_threshold(yd, pd) + d_acc = binary_acc_at_threshold(yd, pd, d_thr) + if args.bilateral_method == "two-single-merge": + yo, po = collect_binary_probs_eye(model_os, os_val, device) + os_thr = tune_binary_threshold(yo, po) + os_acc = binary_acc_at_threshold(yo, po, os_thr) + elif args.tune_multiclass_bias and num_classes > 2: + yb, pb = collect_probs_eye(baseline_model, baseline_val, device) + b_bias = tune_multiclass_bias(yb, pb) + b_acc = multiclass_acc_with_bias(yb, pb, b_bias) + if args.bilateral_method == "bridge": + yd, pd = collect_probs_bilateral(bilateral_model, bilateral_val, device) + else: + yd, pd = collect_probs_merge(model_od, model_os, bilateral_val, device) + d_bias = tune_multiclass_bias(yd, pd) + d_acc = multiclass_acc_with_bias(yd, pd, d_bias) + if args.bilateral_method == "two-single-merge": + yo, po = collect_probs_eye(model_os, os_val, device) + os_bias = tune_multiclass_bias(yo, po) + os_acc = multiclass_acc_with_bias(yo, po, os_bias) + + hb_loss = hb_acc = hb_auc = float("nan") + hb_n = 0 + hos_loss = hos_acc = hos_auc = float("nan") + hos_n = 0 + hd_loss = hd_acc = hd_auc = float("nan") + hd_n = 0 + if baseline_holdout is not None: + hb_loss, hb_acc, hb_auc, hb_n = evaluate_eye(baseline_model, baseline_holdout, device, num_classes) + if os_holdout is not None: + hos_loss, hos_acc, hos_auc, hos_n = evaluate_eye(model_os, os_holdout, device, num_classes) + if bilateral_holdout is not None: + if args.bilateral_method == "bridge": + hd_loss, hd_acc, hd_auc, hd_n = evaluate_bilateral( + bilateral_model, bilateral_holdout, device, num_classes + ) + else: + hd_loss, hd_acc, hd_auc, hd_n = evaluate_two_single_merge( + model_od, model_os, bilateral_holdout, device, num_classes + ) + if args.tune_binary_threshold and num_classes == 2: + if baseline_holdout is not None: + yhb, phb = collect_binary_probs_eye(baseline_model, baseline_holdout, device) + hb_acc = binary_acc_at_threshold(yhb, phb, b_thr) + if os_holdout is not None: + yho, pho = collect_binary_probs_eye(model_os, os_holdout, device) + hos_acc = binary_acc_at_threshold(yho, pho, os_thr) + if bilateral_holdout is not None: + if args.bilateral_method == "bridge": + yhd, phd = collect_binary_probs_bilateral(bilateral_model, bilateral_holdout, device) + else: + yhd, phd = collect_binary_probs_merge(model_od, model_os, bilateral_holdout, device) + hd_acc = binary_acc_at_threshold(yhd, phd, d_thr) + elif args.tune_multiclass_bias and num_classes > 2: + if baseline_holdout is not None and b_bias is not None: + yhb, phb = collect_probs_eye(baseline_model, baseline_holdout, device) + hb_acc = multiclass_acc_with_bias(yhb, phb, b_bias) + if os_holdout is not None and os_bias is not None: + yho, pho = collect_probs_eye(model_os, os_holdout, device) + hos_acc = multiclass_acc_with_bias(yho, pho, os_bias) + if bilateral_holdout is not None and d_bias is not None: + if args.bilateral_method == "bridge": + yhd, phd = collect_probs_bilateral(bilateral_model, bilateral_holdout, device) + else: + yhd, phd = collect_probs_merge(model_od, model_os, bilateral_holdout, device) + hd_acc = multiclass_acc_with_bias(yhd, phd, d_bias) + + row = { + "mode": mode, + "fold": fold, + "epoch": epoch + 1, + "baseline_train_loss": _serialize_float(bt_loss), + "baseline_train_acc": _serialize_float(bt_acc), + "baseline_train_n": bt_n, + "baseline_val_loss": _serialize_float(b_loss), + "baseline_val_acc": _serialize_float(b_acc), + "baseline_val_auc": _serialize_float(b_auc), + "baseline_val_n": b_n, + "baseline_threshold": _serialize_float(b_thr if num_classes == 2 else float("nan")), + "baseline_bias": _serialize_vec(b_bias if num_classes > 2 else None), + "bilateral_train_loss": _serialize_float(dt_loss), + "bilateral_train_acc": _serialize_float(dt_acc), + "bilateral_train_n": dt_n, + "bilateral_val_loss": _serialize_float(d_loss), + "bilateral_val_acc": _serialize_float(d_acc), + "bilateral_val_auc": _serialize_float(d_auc), + "bilateral_val_n": d_n, + "bilateral_threshold": _serialize_float(d_thr if num_classes == 2 else float("nan")), + "bilateral_bias": _serialize_vec(d_bias if num_classes > 2 else None), + "os_val_loss": _serialize_float(os_loss), + "os_val_acc": _serialize_float(os_acc), + "os_val_auc": _serialize_float(os_auc), + "os_val_n": os_n, + "os_threshold": _serialize_float(os_thr if (num_classes == 2 and args.bilateral_method == "two-single-merge") else float("nan")), + "os_bias": _serialize_vec(os_bias if (num_classes > 2 and args.bilateral_method == "two-single-merge") else None), + "holdout_baseline_loss": _serialize_float(hb_loss), + "holdout_baseline_acc": _serialize_float(hb_acc), + "holdout_baseline_auc": _serialize_float(hb_auc), + "holdout_baseline_n": hb_n, + "holdout_bilateral_loss": _serialize_float(hd_loss), + "holdout_bilateral_acc": _serialize_float(hd_acc), + "holdout_bilateral_auc": _serialize_float(hd_auc), + "holdout_bilateral_n": hd_n, + "holdout_os_loss": _serialize_float(hos_loss), + "holdout_os_acc": _serialize_float(hos_acc), + "holdout_os_auc": _serialize_float(hos_auc), + "holdout_os_n": hos_n, + } + epoch_writer.writerow(row) + epoch_fp.flush() + + line_train = ( + f"[mode={mode} fold={fold+1}/{n_folds} epoch={epoch+1}/{args.epochs}] " + f"train baseline(loss={bt_loss:.4f}, acc={bt_acc:.4f}, n={bt_n}) " + f"bilateral(loss={dt_loss:.4f}, acc={dt_acc:.4f}, n={dt_n})" + ) + line_val = ( + f"[mode={mode} fold={fold+1}/{n_folds} epoch={epoch+1}/{args.epochs}] " + f"val baseline(loss={b_loss:.4f}, acc={b_acc:.4f}, auc={b_auc:.4f}, n={b_n}) " + f"bilateral(loss={d_loss:.4f}, acc={d_acc:.4f}, auc={d_auc:.4f}, n={d_n})" + ) + if args.bilateral_method == "two-single-merge": + line_val += f" os(loss={os_loss:.4f}, acc={os_acc:.4f}, auc={os_auc:.4f}, n={os_n})" + print(line_train, flush=True) + print(line_val, flush=True) + train_log.write(line_train + "\n") + train_log.write(line_val + "\n") + if hb_n > 0 or hd_n > 0: + line_holdout = ( + f"[mode={mode} fold={fold+1}/{n_folds} epoch={epoch+1}/{args.epochs}] " + f"holdout baseline(loss={hb_loss:.4f}, acc={hb_acc:.4f}, auc={hb_auc:.4f}, n={hb_n}) " + f"bilateral(loss={hd_loss:.4f}, acc={hd_acc:.4f}, auc={hd_auc:.4f}, n={hd_n})" + ) + if args.bilateral_method == "two-single-merge": + line_holdout += ( + f" os(loss={hos_loss:.4f}, acc={hos_acc:.4f}, auc={hos_auc:.4f}, n={hos_n})" + ) + print(line_holdout, flush=True) + train_log.write(line_holdout + "\n") + if args.log_every > 0 and ((epoch + 1) % args.log_every == 0 or (epoch + 1) == args.epochs): + print( + f"[mode={mode}] fold {fold+1}/{n_folds}: epoch {epoch+1}/{args.epochs} checkpoint", + flush=True, + ) + epoch_fp.close() + + fold_metrics.append( + FoldMetrics( + mode=mode, + fold=fold, + baseline_acc=b_acc, + baseline_auc=b_auc, + bilateral_acc=d_acc, + bilateral_auc=d_auc, + baseline_n=b_n, + bilateral_n=d_n, + os_acc=os_acc, + os_auc=os_auc, + os_n=os_n, + holdout_baseline_acc=hb_acc, + holdout_baseline_auc=hb_auc, + holdout_bilateral_acc=hd_acc, + holdout_bilateral_auc=hd_auc, + holdout_baseline_n=hb_n, + holdout_bilateral_n=hd_n, + holdout_os_acc=hos_acc, + holdout_os_auc=hos_auc, + holdout_os_n=hos_n, + ) + ) + msg = ( + f"mode={mode} fold={fold} baseline(val loss={b_loss:.4f}, acc={b_acc:.4f}, auc={b_auc:.4f}, n={b_n}) " + f"bilateral(val loss={d_loss:.4f}, acc={d_acc:.4f}, auc={d_auc:.4f}, n={d_n})" + ) + if args.bilateral_method == "two-single-merge": + msg += f" | os(val loss={os_loss:.4f}, acc={os_acc:.4f}, auc={os_auc:.4f}, n={os_n})" + if hb_n > 0 or hd_n > 0: + msg += ( + f" | holdout baseline(loss={hb_loss:.4f}, acc={hb_acc:.4f}, auc={hb_auc:.4f}, n={hb_n}) " + f"bilateral(loss={hd_loss:.4f}, acc={hd_acc:.4f}, auc={hd_auc:.4f}, n={hd_n})" + ) + if args.bilateral_method == "two-single-merge": + msg += ( + f" os(loss={hos_loss:.4f}, acc={hos_acc:.4f}, auc={hos_auc:.4f}, n={hos_n})" + ) + print(msg) + + summary = _summary_for_mode(mode, fold_metrics) + _print_summary(mode, summary) + return fold_metrics, summary + + +def main(): + args = parse_args() + device = choose_device(args.device) + seed_everything(args.seed) + + print(f"Device: {device}", flush=True) + print("Loading PAPILA data...", flush=True) + data = build_papila_data( + image_dir=args.image_dir, + clinical_dir=args.clinical_dir, + label_col=args.label_col, + cat_cols=list(args.cat_cols), + n_splits=args.n_splits, + random_seed=args.fold_seed, + ) + print(f"Loaded PAPILA data: rows={len(data.df)}", flush=True) + + eval_modes = args.eval_modes if args.eval_modes else [args.eval_mode] + print(f"Eval modes: {eval_modes}", flush=True) + ts = time.strftime("%Y%m%d_%H%M%S") + run_name = args.run_name or f"dual_eye_compare_{ts}" + out_dir = Path(args.output_root) / run_name + out_dir.mkdir(parents=True, exist_ok=True) + + all_rows = [] + summaries = {} + for mode in eval_modes: + fold_metrics, summary = run_mode(args, mode, device, data, out_dir) + rows = _rows_from_metrics(fold_metrics) + all_rows.extend(rows) + summaries[mode] = summary + + mode_csv = out_dir / f"{mode}_fold_metrics.csv" + if rows: + with mode_csv.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + + all_csv = out_dir / "all_fold_metrics.csv" + if all_rows: + with all_csv.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=list(all_rows[0].keys())) + writer.writeheader() + writer.writerows(all_rows) + + payload = { + "run_name": run_name, + "timestamp": ts, + "config": vars(args), + "summaries": summaries, + } + summary_json = out_dir / "summary.json" + summary_json.write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"\nOutputs written to: {out_dir}") + + +if __name__ == "__main__": + main() diff --git a/scripts/basic_analysis/compare_hypertower_modes.py b/scripts/basic_analysis/compare_hypertower_modes.py new file mode 100644 index 0000000..e2ef054 --- /dev/null +++ b/scripts/basic_analysis/compare_hypertower_modes.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Thin CLI wrapper that runs V2 hypertower modes sequentially.""" + +from __future__ import annotations + +from pathlib import Path +import sys +import argparse + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes.v2.v2_hypertower import build_parser, run_mode + + +def parse_args(): + ap = argparse.ArgumentParser( + description="Run selected eval/tower mode combinations sequentially." + ) + ap.add_argument( + "--eval-modes", + nargs="+", + choices=["binary", "multiclass"], + default=["binary", "multiclass"], + ) + ap.add_argument( + "--tower-modes", + nargs="+", + choices=["single", "ensemble", "bilateral", "classic"], + default=["single", "ensemble", "bilateral"], + ) + return ap.parse_known_args() + + +def main(): + seq_args, remaining = parse_args() + base_parser = build_parser() + for eval_mode in seq_args.eval_modes: + for tower_mode in seq_args.tower_modes: + tower_mode = "single" if tower_mode == "classic" else tower_mode + cli = list(remaining) + ["--eval-mode", eval_mode, "--tower-mode", tower_mode] + args = base_parser.parse_args(cli) + run_mode(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/basic_analysis/compare_siamese_tower.py b/scripts/basic_analysis/compare_siamese_tower.py new file mode 100644 index 0000000..e6563fb --- /dev/null +++ b/scripts/basic_analysis/compare_siamese_tower.py @@ -0,0 +1,725 @@ +#!/usr/bin/env python3 +""" +Compare single-eye OD baseline vs SiameseImageTower bilateral model. + +Key differences from compare_dual_eye_towers.py: + - Uses SiameseImageTower (shared backbone, f_mean + f_delta output). + - Reports BEST-epoch val metrics per fold (not final-epoch), with the + corresponding holdout metrics snapped at the same checkpoint. + - Both models are always evaluated on patient-level samples (matched n). + - Optionally includes two-single-merge as a second reference point. +""" +from __future__ import annotations + +import argparse +import copy +import csv +import json +import random +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from types import SimpleNamespace +from typing import Optional + +import numpy as np +import torch +import torch.nn.functional as F +from sklearn.metrics import roc_auc_score +from torch import nn +from torch.utils.data import DataLoader + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes.v2 import ( + ImageTower, + PatientFirstSplitManager, + SiameseImageTower, + SlotDataset, + build_papila_data, + build_papila_profile, + slot_collate, +) + + +# --------------------------------------------------------------------------- +# Reproducibility +# --------------------------------------------------------------------------- + +def seed_everything(seed: int) -> None: + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +class BaselineCNN(nn.Module): + """Single-eye (OD) image tower with a linear head.""" + + def __init__(self, *, backbone: str, freeze_ratio: float, num_classes: int, augment: bool): + super().__init__() + self.tower = ImageTower( + backbone=backbone, + freeze_ratio=freeze_ratio, + augment=augment, + use_se=False, + ) + self.head = nn.Linear(self.tower.out_dim, num_classes) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.head(self.tower(x)) + + +class SiameseCNN(nn.Module): + """ + Bilateral image model using SiameseImageTower. + forward(x_od, x_os) -> logits + """ + + def __init__(self, *, backbone: str, freeze_ratio: float, num_classes: int, augment: bool): + super().__init__() + self.tower = SiameseImageTower( + backbone=backbone, + freeze_ratio=freeze_ratio, + augment=augment, + use_se=False, + ) + self.head = nn.Linear(self.tower.out_dim, num_classes) + + def forward(self, x_od: torch.Tensor, x_os: torch.Tensor) -> torch.Tensor: + return self.head(self.tower(x_od, x_os)) + + +# --------------------------------------------------------------------------- +# Data helpers +# --------------------------------------------------------------------------- + +def filter_od_samples(patient_samples: list[dict]) -> list[dict]: + """Extract patient-level OD-only samples (image_1 = OD).""" + out = [] + for s in patient_samples: + if s.get("image_1") is not None and s.get("label_1") is not None: + out.append({"id_1": s.get("id_1"), "image_1": s["image_1"], "label_1": s["label_1"]}) + return out + + +def filter_bilateral_samples(patient_samples: list[dict]) -> list[dict]: + return [ + s for s in patient_samples + if s.get("image_1") is not None + and s.get("image_2") is not None + and s.get("label_1") is not None + ] + + +def make_loader(samples, slots, *, image_transform, batch_size, shuffle, num_workers) -> DataLoader: + ds = SlotDataset(samples, slots, image_transform=image_transform) + return DataLoader( + ds, + batch_size=batch_size, + shuffle=shuffle, + num_workers=num_workers, + collate_fn=slot_collate, + ) + + +def to_label_tensor(labels, device: torch.device) -> torch.Tensor: + if torch.is_tensor(labels): + return labels.to(device=device, dtype=torch.long) + return torch.as_tensor(labels, dtype=torch.long, device=device) + + +def _drop_mixed_label_patients(df, *, patient_col: str, label_col: str): + import pandas as pd + per_patient = ( + df.groupby(patient_col)[label_col] + .agg(lambda s: set(pd.to_numeric(s, errors="coerce").dropna().astype(int).tolist())) + ) + mixed = [pid for pid, labels in per_patient.items() if len(labels) > 1] + if not mixed: + return df, [] + return df[~df[patient_col].isin(mixed)].reset_index(drop=True), mixed + + +# --------------------------------------------------------------------------- +# Score helpers +# --------------------------------------------------------------------------- + +def _score(y_true_chunks, y_prob_chunks, num_classes: int): + if not y_true_chunks: + return float("nan"), float("nan"), 0 + y = np.concatenate(y_true_chunks) + p = np.concatenate(y_prob_chunks) + acc = float((p.argmax(1) == y).mean()) + try: + auc = ( + float(roc_auc_score(y, p[:, 1])) + if num_classes == 2 + else float(roc_auc_score(y, p, multi_class="ovr", average="macro")) + ) + except Exception: + auc = float("nan") + return acc, auc, int(len(y)) + + +# --------------------------------------------------------------------------- +# Train / evaluate +# --------------------------------------------------------------------------- + +def train_baseline_epoch(model, loader, opt, device): + model.train() + total_loss = total_correct = total_n = 0 + for batch in loader: + x = batch.get("image_1") + y = batch.get("label_1") + if not torch.is_tensor(x): + continue + y = to_label_tensor(y, device) + x = x.to(device) + logits = model(x) + 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 + return (total_loss / total_n if total_n else float("nan"), + total_correct / total_n if total_n else float("nan")) + + +def train_siamese_epoch(model, loader, opt, device): + model.train() + total_loss = total_correct = total_n = 0 + for batch in loader: + x1 = batch.get("image_1") + x2 = batch.get("image_2") + y = batch.get("label_1") + if not torch.is_tensor(x1) or not torch.is_tensor(x2): + continue + y = to_label_tensor(y, device) + logits = model(x1.to(device), x2.to(device)) + 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 + return (total_loss / total_n if total_n else float("nan"), + total_correct / total_n if total_n else float("nan")) + + +def evaluate_baseline(model, loader, device, num_classes): + model.eval() + y_true, y_prob = [], [] + with torch.no_grad(): + for batch in loader: + x = batch.get("image_1") + y = batch.get("label_1") + if not torch.is_tensor(x): + continue + y_t = to_label_tensor(y, device) + p = F.softmax(model(x.to(device)), dim=1).cpu().numpy() + y_true.append(y_t.cpu().numpy()) + y_prob.append(p) + return _score(y_true, y_prob, num_classes) + + +def evaluate_siamese(model, loader, device, num_classes): + model.eval() + y_true, y_prob = [], [] + with torch.no_grad(): + for batch in loader: + x1 = batch.get("image_1") + x2 = batch.get("image_2") + y = batch.get("label_1") + if not torch.is_tensor(x1) or not torch.is_tensor(x2): + continue + y_t = to_label_tensor(y, device) + p = F.softmax(model(x1.to(device), x2.to(device)), dim=1).cpu().numpy() + y_true.append(y_t.cpu().numpy()) + y_prob.append(p) + return _score(y_true, y_prob, num_classes) + + +# --------------------------------------------------------------------------- +# Result dataclass +# --------------------------------------------------------------------------- + +@dataclass +class FoldResult: + mode: str + fold: int + # Best-epoch validation metrics + best_epoch: int + baseline_best_val_auc: float + baseline_best_val_acc: float + siamese_best_val_auc: float + siamese_best_val_acc: float + # Holdout metrics at the respective best-epoch checkpoint + baseline_holdout_auc: float + baseline_holdout_acc: float + baseline_holdout_n: int + siamese_holdout_auc: float + siamese_holdout_acc: float + siamese_holdout_n: int + # Sample sizes + baseline_n: int + siamese_n: int + + +def _nan() -> float: + return float("nan") + + +# --------------------------------------------------------------------------- +# Main fold runner +# --------------------------------------------------------------------------- + +def run_fold( + fold: int, + split, + mode: str, + args, + device: torch.device, + data, + num_classes: int, + profile_od, + profile_patient, + fold_dir: Path, +) -> FoldResult: + holdout_df = split.holdout + + # ---- build samples ---- + bilat_train = filter_bilateral_samples(profile_patient.build_samples(df=split.train, clinical=data)) + bilat_val = filter_bilateral_samples(profile_patient.build_samples(df=split.val, clinical=data)) + od_train = filter_od_samples(bilat_train) + od_val = filter_od_samples(bilat_val) + + bilat_holdout = [] + od_holdout = [] + if holdout_df is not None and not holdout_df.empty: + bilat_holdout = filter_bilateral_samples(profile_patient.build_samples(df=holdout_df, clinical=data)) + od_holdout = filter_od_samples(bilat_holdout) + + # ---- models ---- + baseline = BaselineCNN( + backbone=args.backbone, freeze_ratio=args.freeze_ratio, + num_classes=num_classes, augment=args.augment, + ).to(device) + siamese = SiameseCNN( + backbone=args.backbone, freeze_ratio=args.freeze_ratio, + num_classes=num_classes, augment=args.augment, + ).to(device) + + slots_od = profile_od.slot_descriptors() + slots_patient = profile_patient.slot_descriptors() + + # ---- loaders ---- + loader_kw = dict(batch_size=args.batch_size, num_workers=args.num_workers) + train_base = make_loader(od_train, slots_od, image_transform=baseline.tower.transform, shuffle=True, **loader_kw) + val_base = make_loader(od_val, slots_od, image_transform=baseline.tower.transform, shuffle=False, **loader_kw) + train_siam = make_loader(bilat_train, slots_patient, image_transform=siamese.tower.transform, shuffle=True, **loader_kw) + val_siam = make_loader(bilat_val, slots_patient, image_transform=siamese.tower.transform, shuffle=False, **loader_kw) + + ho_base = ( + make_loader(od_holdout, slots_od, image_transform=baseline.tower.transform, shuffle=False, **loader_kw) + if od_holdout else None + ) + ho_siam = ( + make_loader(bilat_holdout, slots_patient, image_transform=siamese.tower.transform, shuffle=False, **loader_kw) + if bilat_holdout else None + ) + + opt_base = torch.optim.Adam(baseline.parameters(), lr=args.lr) + opt_siam = torch.optim.Adam(siamese.parameters(), lr=args.lr) + + # ---- epoch log ---- + epoch_log_path = fold_dir / "epoch_log.csv" + epoch_fields = [ + "fold", "epoch", + "base_train_loss", "base_train_acc", + "base_val_auc", "base_val_acc", "base_val_n", + "siam_train_loss", "siam_train_acc", + "siam_val_auc", "siam_val_acc", "siam_val_n", + "ho_base_auc", "ho_base_acc", "ho_base_n", + "ho_siam_auc", "ho_siam_acc", "ho_siam_n", + ] + epoch_fp = epoch_log_path.open("w", newline="", encoding="utf-8") + epoch_writer = csv.DictWriter(epoch_fp, fieldnames=epoch_fields) + epoch_writer.writeheader() + + def _f(v): + return None if (v is None or (isinstance(v, float) and np.isnan(v))) else round(float(v), 6) + + # ---- best-epoch tracking ---- + best_base_auc = -1.0 + best_siam_auc = -1.0 + best_base_state: Optional[dict] = None + best_siam_state: Optional[dict] = None + best_base_val_acc = _nan() + best_siam_val_acc = _nan() + # Holdout metrics snapped at best-val checkpoint + snap_ho_base_auc = _nan() + snap_ho_base_acc = _nan() + snap_ho_base_n = 0 + snap_ho_siam_auc = _nan() + snap_ho_siam_acc = _nan() + snap_ho_siam_n = 0 + best_epoch = 0 + + print( + f" [fold {fold+1}] training {args.epochs} epochs | " + f"baseline n_train={len(od_train)} n_val={len(od_val)} | " + f"siamese n_train={len(bilat_train)} n_val={len(bilat_val)}", + flush=True, + ) + + for epoch in range(args.epochs): + bl_loss, bl_acc = train_baseline_epoch(baseline, train_base, opt_base, device) + si_loss, si_acc = train_siamese_epoch(siamese, train_siam, opt_siam, device) + + b_val_acc, b_val_auc, b_val_n = evaluate_baseline(baseline, val_base, device, num_classes) + s_val_acc, s_val_auc, s_val_n = evaluate_siamese( siamese, val_siam, device, num_classes) + + # Holdout at this epoch (always evaluated for logging, cheaply) + hb_auc, hb_acc, hb_n = (_nan(), _nan(), 0) + hs_auc, hs_acc, hs_n = (_nan(), _nan(), 0) + if ho_base is not None: + hb_acc, hb_auc, hb_n = evaluate_baseline(baseline, ho_base, device, num_classes) + if ho_siam is not None: + hs_acc, hs_auc, hs_n = evaluate_siamese(siamese, ho_siam, device, num_classes) + + # Best-epoch tracking: snapshot state independently per model + if not np.isnan(b_val_auc) and b_val_auc > best_base_auc: + best_base_auc = b_val_auc + best_base_val_acc = b_val_acc + best_base_state = copy.deepcopy(baseline.state_dict()) + snap_ho_base_auc = hb_auc + snap_ho_base_acc = hb_acc + snap_ho_base_n = hb_n + + if not np.isnan(s_val_auc) and s_val_auc > best_siam_auc: + best_siam_auc = s_val_auc + best_siam_val_acc = s_val_acc + best_siam_state = copy.deepcopy(siamese.state_dict()) + snap_ho_siam_auc = hs_auc + snap_ho_siam_acc = hs_acc + snap_ho_siam_n = hs_n + best_epoch = epoch + 1 + + row = { + "fold": fold, "epoch": epoch + 1, + "base_train_loss": _f(bl_loss), "base_train_acc": _f(bl_acc), + "base_val_auc": _f(b_val_auc), "base_val_acc": _f(b_val_acc), "base_val_n": b_val_n, + "siam_train_loss": _f(si_loss), "siam_train_acc": _f(si_acc), + "siam_val_auc": _f(s_val_auc), "siam_val_acc": _f(s_val_acc), "siam_val_n": s_val_n, + "ho_base_auc": _f(hb_auc), "ho_base_acc": _f(hb_acc), "ho_base_n": hb_n, + "ho_siam_auc": _f(hs_auc), "ho_siam_acc": _f(hs_acc), "ho_siam_n": hs_n, + } + epoch_writer.writerow(row) + epoch_fp.flush() + + if args.log_every > 0 and (epoch + 1) % args.log_every == 0: + print( + f" ep {epoch+1:>3}/{args.epochs} " + f"base val AUC={b_val_auc:.4f} siam val AUC={s_val_auc:.4f} " + f"(best base={best_base_auc:.4f} best siam={best_siam_auc:.4f})", + flush=True, + ) + + epoch_fp.close() + + # Save best checkpoints + if best_base_state is not None: + torch.save(best_base_state, fold_dir / "best_baseline.pt") + if best_siam_state is not None: + torch.save(best_siam_state, fold_dir / "best_siamese.pt") + + result = FoldResult( + mode=mode, fold=fold, + best_epoch=best_epoch, + baseline_best_val_auc=best_base_auc, + baseline_best_val_acc=best_base_val_acc, + siamese_best_val_auc=best_siam_auc, + siamese_best_val_acc=best_siam_val_acc, + baseline_holdout_auc=snap_ho_base_auc, + baseline_holdout_acc=snap_ho_base_acc, + baseline_holdout_n=snap_ho_base_n, + siamese_holdout_auc=snap_ho_siam_auc, + siamese_holdout_acc=snap_ho_siam_acc, + siamese_holdout_n=snap_ho_siam_n, + baseline_n=len(od_val), + siamese_n=len(bilat_val), + ) + + print( + f" [fold {fold+1}] BEST " + f"base val AUC={best_base_auc:.4f} acc={best_base_val_acc:.4f} " + f"siam val AUC={best_siam_auc:.4f} acc={best_siam_val_acc:.4f} " + f"(siam best epoch={best_epoch})", + flush=True, + ) + if snap_ho_base_n > 0 or snap_ho_siam_n > 0: + print( + f" [fold {fold+1}] HOUT " + f"base AUC={snap_ho_base_auc:.4f} acc={snap_ho_base_acc:.4f} (n={snap_ho_base_n}) " + f"siam AUC={snap_ho_siam_auc:.4f} acc={snap_ho_siam_acc:.4f} (n={snap_ho_siam_n})", + flush=True, + ) + + return result + + +# --------------------------------------------------------------------------- +# Summary helpers +# --------------------------------------------------------------------------- + +def _summary(results: list[FoldResult]) -> dict: + def _means(vals): + v = np.array([x for x in vals if not np.isnan(x)], dtype=float) + return (float(np.mean(v)) if len(v) else None, + float(np.std(v)) if len(v) else None) + + b_val_aucs = [r.baseline_best_val_auc for r in results] + s_val_aucs = [r.siamese_best_val_auc for r in results] + b_ho_aucs = [r.baseline_holdout_auc for r in results] + s_ho_aucs = [r.siamese_holdout_auc for r in results] + b_val_accs = [r.baseline_best_val_acc for r in results] + s_val_accs = [r.siamese_best_val_acc for r in results] + b_ho_accs = [r.baseline_holdout_acc for r in results] + s_ho_accs = [r.siamese_holdout_acc for r in results] + + deltas_val_auc = [s - b for b, s in zip(b_val_aucs, s_val_aucs) + if not np.isnan(b) and not np.isnan(s)] + deltas_ho_auc = [s - b for b, s in zip(b_ho_aucs, s_ho_aucs) + if not np.isnan(b) and not np.isnan(s)] + + bva_m, bva_s = _means(b_val_aucs) + sva_m, sva_s = _means(s_val_aucs) + bha_m, bha_s = _means(b_ho_aucs) + sha_m, sha_s = _means(s_ho_aucs) + + return { + "baseline_best_val": {"auc_mean": bva_m, "auc_std": bva_s, "acc_mean": _means(b_val_accs)[0]}, + "siamese_best_val": {"auc_mean": sva_m, "auc_std": sva_s, "acc_mean": _means(s_val_accs)[0]}, + "delta_val_auc": {"mean": float(np.mean(deltas_val_auc)) if deltas_val_auc else None, + "std": float(np.std(deltas_val_auc)) if deltas_val_auc else None}, + "baseline_holdout": {"auc_mean": bha_m, "auc_std": bha_s, "acc_mean": _means(b_ho_accs)[0]}, + "siamese_holdout": {"auc_mean": sha_m, "auc_std": sha_s, "acc_mean": _means(s_ho_accs)[0]}, + "delta_holdout_auc": {"mean": float(np.mean(deltas_ho_auc)) if deltas_ho_auc else None, + "std": float(np.std(deltas_ho_auc)) if deltas_ho_auc else None}, + } + + +def _print_summary(mode: str, s: dict) -> None: + def f(v): + return "nan" if v is None else f"{v:.4f}" + + bv = s["baseline_best_val"] + sv = s["siamese_best_val"] + dv = s["delta_val_auc"] + bh = s["baseline_holdout"] + sh = s["siamese_holdout"] + dh = s["delta_holdout_auc"] + + print(f"\n=== Summary [{mode}] (best-epoch metrics) ===") + print(f" val baseline AUC={f(bv['auc_mean'])}±{f(bv['auc_std'])} acc={f(bv['acc_mean'])}") + print(f" val siamese AUC={f(sv['auc_mean'])}±{f(sv['auc_std'])} acc={f(sv['acc_mean'])}") + print(f" val delta AUC={f(dv['mean'])}±{f(dv['std'])}") + print(f" hout baseline AUC={f(bh['auc_mean'])}±{f(bh['auc_std'])} acc={f(bh['acc_mean'])}") + print(f" hout siamese AUC={f(sh['auc_mean'])}±{f(sh['auc_std'])} acc={f(sh['acc_mean'])}") + print(f" hout delta AUC={f(dh['mean'])}±{f(dh['std'])}") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def parse_args(): + ap = argparse.ArgumentParser( + description="Baseline single-eye vs SiameseImageTower bilateral comparison." + ) + ap.add_argument("--image-dir", default="Papila/FundusImages") + ap.add_argument("--clinical-dir", default="Papila/ClinicalData") + ap.add_argument("--label-col", default="Diagnosis") + ap.add_argument("--cat-cols", nargs="*", default=["Gender", "Phakic/Pseudophakic"]) + ap.add_argument("--eval-mode", choices=["binary", "multiclass"], default="binary") + ap.add_argument( + "--eval-modes", nargs="+", choices=["binary", "multiclass"], default=None, + help="Run multiple modes in one pass, e.g. --eval-modes binary multiclass", + ) + ap.add_argument("--n-splits", type=int, default=5) + ap.add_argument("--fold-seed", type=int, default=42) + ap.add_argument("--holdout-per-class", type=int, default=0) + ap.add_argument("--holdout-seed", type=int, default=123) + ap.add_argument("--folds", type=int, default=5) + ap.add_argument("--epochs", type=int, default=40) + ap.add_argument("--batch-size", type=int, default=8) + ap.add_argument("--lr", type=float, default=1e-4) + ap.add_argument("--backbone", default="refugelike") + ap.add_argument("--freeze-ratio", type=float, default=0.0) + ap.add_argument("--augment", action="store_true") + ap.add_argument("--num-workers", type=int, default=0) + ap.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto") + ap.add_argument("--seed", type=int, default=1234) + ap.add_argument("--run-name", default=None) + ap.add_argument("--output-root", default="analysis_data/basic_analysis") + ap.add_argument( + "--exclude-mixed-patients", action="store_true", + help="Drop patients whose two eyes have different labels before splitting.", + ) + ap.add_argument( + "--log-every", type=int, default=5, + help="Print epoch progress every N epochs (0 to disable).", + ) + return ap.parse_args() + + +def choose_device(name: str) -> torch.device: + if name == "cuda": + if not torch.cuda.is_available(): + raise RuntimeError("--device cuda requested but CUDA is not available.") + return torch.device("cuda") + if name == "cpu": + return torch.device("cpu") + return torch.device("cuda" if torch.cuda.is_available() else "cpu") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(): + args = parse_args() + device = choose_device(args.device) + seed_everything(args.seed) + + print(f"Device: {device}", flush=True) + print("Loading PAPILA data...", flush=True) + data = build_papila_data( + image_dir=args.image_dir, + clinical_dir=args.clinical_dir, + label_col=args.label_col, + cat_cols=list(args.cat_cols), + n_splits=args.n_splits, + random_seed=args.fold_seed, + ) + print(f"Loaded: {len(data.df)} rows", flush=True) + + ts = time.strftime("%Y%m%d_%H%M%S") + run_name = args.run_name or f"siamese_compare_{ts}" + out_dir = Path(args.output_root) / run_name + out_dir.mkdir(parents=True, exist_ok=True) + + eval_modes = args.eval_modes if args.eval_modes else [args.eval_mode] + + all_results: dict[str, list[FoldResult]] = {} + summaries: dict[str, dict] = {} + + for mode in eval_modes: + import pandas as pd + df_mode = data.df.copy() + + if args.exclude_mixed_patients: + before = df_mode["Patient ID"].nunique() + df_mode, mixed = _drop_mixed_label_patients( + df_mode, patient_col="Patient ID", label_col=args.label_col + ) + print(f"[{mode}] dropped {len(mixed)} mixed-label patients " + f"({before} -> {df_mode['Patient ID'].nunique()})", flush=True) + + if mode == "binary": + df_mode = df_mode[df_mode[args.label_col].isin([0, 1])].reset_index(drop=True) + + num_classes = 2 if mode == "binary" else int(df_mode[args.label_col].nunique()) + print( + f"\n[{mode}] num_classes={num_classes} rows={len(df_mode)} " + f"patients={df_mode['Patient ID'].nunique()}", + flush=True, + ) + + split_manager = PatientFirstSplitManager( + patient_col="Patient ID", label_col=args.label_col + ) + split_args = SimpleNamespace( + eval_mode=mode, + holdout_per_class=args.holdout_per_class, + holdout_seed=args.holdout_seed, + n_splits=args.n_splits, + fold_seed=args.fold_seed, + ) + clinical_ns = SimpleNamespace(df=df_mode, label_col=args.label_col) + plans = split_manager.build_plans(clinical=clinical_ns, args=split_args, profile=None) + n_folds = min(args.folds, len(plans)) + + profile_od = build_papila_profile( + patient_col="Patient ID", label_col=args.label_col, sample_mode="eye" + ) + profile_patient = build_papila_profile( + patient_col="Patient ID", label_col=args.label_col, sample_mode="patient" + ) + + mode_dir = out_dir / mode + mode_dir.mkdir(exist_ok=True) + + fold_results: list[FoldResult] = [] + for fold in range(n_folds): + fold_seed = args.seed + fold * 100 + seed_everything(fold_seed) + fold_dir = mode_dir / f"fold{fold}" + fold_dir.mkdir(exist_ok=True) + + print(f"\n[{mode}] fold {fold+1}/{n_folds}", flush=True) + result = run_fold( + fold=fold, + split=plans[fold], + mode=mode, + args=args, + device=device, + data=data, + num_classes=num_classes, + profile_od=profile_od, + profile_patient=profile_patient, + fold_dir=fold_dir, + ) + fold_results.append(result) + + # Write per-mode CSV + fold_csv = out_dir / f"{mode}_fold_results.csv" + csv_fields = list(FoldResult.__dataclass_fields__.keys()) + with fold_csv.open("w", newline="", encoding="utf-8") as fh: + w = csv.DictWriter(fh, fieldnames=csv_fields) + w.writeheader() + for r in fold_results: + w.writerow({k: getattr(r, k) for k in csv_fields}) + + summary = _summary(fold_results) + _print_summary(mode, summary) + + all_results[mode] = fold_results + summaries[mode] = summary + + payload = { + "run_name": run_name, + "timestamp": ts, + "config": vars(args), + "summaries": summaries, + } + (out_dir / "summary.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"\nOutputs written to: {out_dir}") + + +if __name__ == "__main__": + main() diff --git a/scripts/basic_analysis/compare_siamese_v2.py b/scripts/basic_analysis/compare_siamese_v2.py new file mode 100644 index 0000000..858d693 --- /dev/null +++ b/scripts/basic_analysis/compare_siamese_v2.py @@ -0,0 +1,1025 @@ +#!/usr/bin/env python3 +""" +Compare all-eye baseline (ImageTower) vs SiameseImageTower bilateral model. + +Key differences from compare_siamese_tower.py (v1): + - Baseline trains on ALL eye samples (both OD + OS rows) — matches the + original grid-search training regime, not just OD-from-bilateral. + - No holdout set: pure k-fold CV is sufficient for architecture comparison. + - Both models evaluated at patient level on the same bilateral val set: + - Baseline: runs on OD and OS separately, mean-pools probabilities. + - Siamese: runs on both eyes simultaneously. + - Extended metrics at best-epoch snapshots: kappa, MCC, macro-F1, + per-class recall, and ECE (Expected Calibration Error). +""" +from __future__ import annotations + +import argparse +import copy +import csv +import json +import random +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace +from typing import Optional + +import numpy as np +import pandas as pd +import torch +import torch.nn.functional as F +from sklearn.metrics import ( + cohen_kappa_score, + f1_score, + matthews_corrcoef, + recall_score, + roc_auc_score, +) +from torch import nn +from torch.utils.data import DataLoader + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes.v2 import ( + ImageTower, + PatientFirstSplitManager, + SiameseImageTower, + SlotDataset, + build_papila_data, + build_papila_profile, + slot_collate, +) + + +# --------------------------------------------------------------------------- +# Reproducibility +# --------------------------------------------------------------------------- + +def seed_everything(seed: int) -> None: + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if torch.cuda.is_available(): + torch.cuda.manual_seed_all(seed) + + +# --------------------------------------------------------------------------- +# Models +# --------------------------------------------------------------------------- + +class BaselineCNN(nn.Module): + """Single-eye image tower with a linear head.""" + + def __init__(self, *, backbone: str, freeze_ratio: float, num_classes: int, augment: bool): + super().__init__() + self.tower = ImageTower( + backbone=backbone, + freeze_ratio=freeze_ratio, + augment=augment, + use_se=False, + ) + self.head = nn.Linear(self.tower.out_dim, num_classes) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + return self.head(self.tower(x)) + + +class SiameseCNN(nn.Module): + """Bilateral image model using SiameseImageTower (shared backbone).""" + + def __init__(self, *, backbone: str, freeze_ratio: float, num_classes: int, augment: bool): + super().__init__() + self.tower = SiameseImageTower( + backbone=backbone, + freeze_ratio=freeze_ratio, + augment=augment, + use_se=False, + ) + self.head = nn.Linear(self.tower.out_dim, num_classes) + + def forward(self, x_od: torch.Tensor, x_os: torch.Tensor) -> torch.Tensor: + return self.head(self.tower(x_od, x_os)) + + +# --------------------------------------------------------------------------- +# Data helpers +# --------------------------------------------------------------------------- + +def filter_eye_samples(samples: list[dict]) -> list[dict]: + """Keep any single-eye sample with a valid image and label (OD or OS).""" + return [s for s in samples if s.get("image_1") is not None and s.get("label_1") is not None] + + +def filter_bilateral_samples(samples: list[dict]) -> list[dict]: + """Keep only patient-level samples where both eyes are present.""" + return [ + s for s in samples + if s.get("image_1") is not None + and s.get("image_2") is not None + and s.get("label_1") is not None + ] + + +def make_loader( + samples: list[dict], + slots: dict, + *, + image_transform, + batch_size: int, + shuffle: bool, + num_workers: int, +) -> DataLoader: + ds = SlotDataset(samples, slots, image_transform=image_transform) + return DataLoader( + ds, + batch_size=batch_size, + shuffle=shuffle, + num_workers=num_workers, + collate_fn=slot_collate, + ) + + +def to_label_tensor(labels, device: torch.device) -> torch.Tensor: + if torch.is_tensor(labels): + return labels.to(device=device, dtype=torch.long) + return torch.as_tensor(labels, dtype=torch.long, device=device) + + +def _drop_mixed_label_patients(df, *, patient_col: str, label_col: str): + per_patient = ( + df.groupby(patient_col)[label_col] + .agg(lambda s: set(pd.to_numeric(s, errors="coerce").dropna().astype(int).tolist())) + ) + mixed = [pid for pid, labels in per_patient.items() if len(labels) > 1] + if not mixed: + return df, [] + return df[~df[patient_col].isin(mixed)].reset_index(drop=True), mixed + + +def _relabel_mixed_patients_to_max(df, *, patient_col: str, label_col: str): + """Set all rows for each patient to that patient's max observed label.""" + out = df.copy() + labels = pd.to_numeric(out[label_col], errors="coerce") + patient_max = labels.groupby(out[patient_col]).transform("max") + changed_rows = int((labels != patient_max).fillna(False).sum()) + out[label_col] = patient_max.astype(int) + per_patient_unique = ( + out.groupby(patient_col)[label_col] + .nunique(dropna=True) + ) + mixed_patients = per_patient_unique[per_patient_unique > 1].index.tolist() + return out.reset_index(drop=True), changed_rows, mixed_patients + + +# --------------------------------------------------------------------------- +# Metrics helpers +# --------------------------------------------------------------------------- + +def compute_ece(y_true: np.ndarray, probs: np.ndarray, n_bins: int = 10) -> float: + """Expected Calibration Error: weighted mean of |confidence - accuracy| per bin.""" + if y_true.size == 0: + return float("nan") + confidences = probs.max(axis=1) + predictions = probs.argmax(axis=1) + bin_edges = np.linspace(0.0, 1.0, n_bins + 1) + ece = 0.0 + n = len(y_true) + for i, (lo, hi) in enumerate(zip(bin_edges[:-1], bin_edges[1:])): + mask = (confidences >= lo) & (confidences <= hi if i == n_bins - 1 else confidences < hi) + if not mask.any(): + continue + bin_acc = float((predictions[mask] == y_true[mask]).mean()) + bin_conf = float(confidences[mask].mean()) + ece += float(mask.sum()) / n * abs(bin_conf - bin_acc) + return float(ece) + + +def compute_extended_metrics( + y_true: np.ndarray, + probs: np.ndarray, + num_classes: int, + n_bins: int = 10, + preds_override: Optional[np.ndarray] = None, +) -> dict: + """ + Returns kappa, mcc, macro_f1, per_class_recall (np.ndarray), ece. + All float('nan') on empty input or single-class edge cases. + """ + nan = float("nan") + if y_true.size == 0: + return dict(kappa=nan, mcc=nan, macro_f1=nan, + per_class_recall=np.full(num_classes, nan), ece=nan) + preds = preds_override if preds_override is not None else probs.argmax(axis=1) + try: + kappa = float(cohen_kappa_score(y_true, preds)) + except Exception: + kappa = nan + try: + mcc = float(matthews_corrcoef(y_true, preds)) + except Exception: + mcc = nan + try: + macro_f1 = float(f1_score(y_true, preds, average="macro", zero_division=0)) + except Exception: + macro_f1 = nan + try: + pcr = recall_score( + y_true, preds, average=None, + labels=list(range(num_classes)), zero_division=0, + ).astype(float) + except Exception: + pcr = np.full(num_classes, nan) + ece = compute_ece(y_true, probs, n_bins=n_bins) + return dict(kappa=kappa, mcc=mcc, macro_f1=macro_f1, per_class_recall=pcr, ece=ece) + + +def tune_binary_threshold(y_true: np.ndarray, p1: np.ndarray) -> float: + if y_true.size == 0: + return 0.5 + grid = np.linspace(0.0, 1.0, 1001) + best_t = 0.5 + best_acc = -1.0 + for t in grid: + pred = (p1 >= t).astype(int) + acc = float((pred == y_true).mean()) + if acc > best_acc or (acc == best_acc and abs(t - 0.5) < abs(best_t - 0.5)): + best_acc = acc + best_t = float(t) + return best_t + + +def multiclass_acc_with_bias(y_true: np.ndarray, probs: np.ndarray, bias: np.ndarray) -> float: + if y_true.size == 0: + return float("nan") + logits = np.log(np.clip(probs, 1e-8, 1.0)) + bias.reshape(1, -1) + pred = np.argmax(logits, axis=1) + return float((pred == y_true).mean()) + + +def tune_multiclass_bias(y_true: np.ndarray, probs: np.ndarray, *, iters: int = 2) -> np.ndarray: + if y_true.size == 0 or probs.size == 0: + return np.zeros((0,), dtype=float) + c = probs.shape[1] + bias = np.zeros((c,), dtype=float) + grid = np.linspace(-1.0, 1.0, 41) + for _ in range(iters): + for k in range(c): + best_v = bias[k] + best_acc = multiclass_acc_with_bias(y_true, probs, bias) + old = bias[k] + for v in grid: + bias[k] = float(v) + acc = multiclass_acc_with_bias(y_true, probs, bias) + if acc > best_acc or (acc == best_acc and abs(v) < abs(best_v)): + best_acc = acc + best_v = float(v) + bias[k] = best_v + if np.isnan(best_acc): + bias[k] = old + return bias + + +def _svf(vec) -> Optional[str]: + if vec is None: + return None + arr = np.asarray(vec, dtype=float) + if arr.size == 0: + return None + return "|".join(f"{float(v):.4f}" for v in arr.tolist()) + + +def _score_arrays(y_true: np.ndarray, probs: np.ndarray, num_classes: int): + """Score pre-collected arrays. Returns (acc, auc, n).""" + if y_true.size == 0: + return float("nan"), float("nan"), 0 + acc = float((probs.argmax(1) == y_true).mean()) + try: + auc = ( + float(roc_auc_score(y_true, probs[:, 1])) + if num_classes == 2 + else float(roc_auc_score(y_true, probs, multi_class="ovr", average="macro")) + ) + except Exception: + auc = float("nan") + return acc, auc, int(len(y_true)) + + +# --------------------------------------------------------------------------- +# Train / collect +# --------------------------------------------------------------------------- + +def train_baseline_epoch(model: BaselineCNN, loader: DataLoader, opt, device): + """Train on single-eye batches (image_1).""" + model.train() + total_loss = total_correct = total_n = 0 + for batch in loader: + x = batch.get("image_1") + y = batch.get("label_1") + if not torch.is_tensor(x): + continue + y = to_label_tensor(y, device) + x = x.to(device) + logits = model(x) + 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 + return ( + total_loss / total_n if total_n else float("nan"), + total_correct / total_n if total_n else float("nan"), + ) + + +def train_siamese_epoch(model: SiameseCNN, loader: DataLoader, opt, device): + """Train on bilateral patient batches (image_1 = OD, image_2 = OS).""" + model.train() + total_loss = total_correct = total_n = 0 + for batch in loader: + x1 = batch.get("image_1") + x2 = batch.get("image_2") + y = batch.get("label_1") + if not torch.is_tensor(x1) or not torch.is_tensor(x2): + continue + y = to_label_tensor(y, device) + logits = model(x1.to(device), x2.to(device)) + 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 + return ( + total_loss / total_n if total_n else float("nan"), + total_correct / total_n if total_n else float("nan"), + ) + + +def collect_probs_baseline_bilateral( + model: BaselineCNN, + loader: DataLoader, + device: torch.device, +) -> tuple[np.ndarray, np.ndarray]: + """ + Evaluate baseline on bilateral (patient-level) samples. + + For each patient, runs the single-eye model on OD (image_1) and OS + (image_2) separately, then mean-pools the softmax probabilities. + Returns (y_true [N], probs [N, C]) at patient level. + """ + model.eval() + y_chunks, p_chunks = [], [] + with torch.no_grad(): + for batch in loader: + x1 = batch.get("image_1") + x2 = batch.get("image_2") + y = batch.get("label_1") + if not torch.is_tensor(x1) or not torch.is_tensor(x2): + continue + y_t = to_label_tensor(y, device) + p_od = F.softmax(model(x1.to(device)), dim=1) + p_os = F.softmax(model(x2.to(device)), dim=1) + p = 0.5 * (p_od + p_os) + y_chunks.append(y_t.cpu().numpy()) + p_chunks.append(p.cpu().numpy()) + if not y_chunks: + return np.array([], dtype=np.int64), np.zeros((0, 0), dtype=np.float32) + return np.concatenate(y_chunks), np.concatenate(p_chunks, axis=0) + + +def collect_probs_siamese( + model: SiameseCNN, + loader: DataLoader, + device: torch.device, +) -> tuple[np.ndarray, np.ndarray]: + """Evaluate siamese on bilateral (patient-level) samples.""" + model.eval() + y_chunks, p_chunks = [], [] + with torch.no_grad(): + for batch in loader: + x1 = batch.get("image_1") + x2 = batch.get("image_2") + y = batch.get("label_1") + if not torch.is_tensor(x1) or not torch.is_tensor(x2): + continue + y_t = to_label_tensor(y, device) + p = F.softmax(model(x1.to(device), x2.to(device)), dim=1) + y_chunks.append(y_t.cpu().numpy()) + p_chunks.append(p.cpu().numpy()) + if not y_chunks: + return np.array([], dtype=np.int64), np.zeros((0, 0), dtype=np.float32) + return np.concatenate(y_chunks), np.concatenate(p_chunks, axis=0) + + +# --------------------------------------------------------------------------- +# Result dataclass +# --------------------------------------------------------------------------- + +def _nan() -> float: + return float("nan") + + +@dataclass +class FoldResult: + mode: str + fold: int + # Epoch where each model hit its peak val AUC + best_epoch_base: int + best_epoch_siam: int + # Baseline metrics at best-val epoch (patient-level, prob-aggregated) + base_val_auc: float + base_val_acc: float + base_val_kappa: float + base_val_mcc: float + base_val_f1: float + base_val_recall: Optional[str] # pipe-delimited per-class recall + base_val_ece: float + base_val_threshold: float + base_val_bias: Optional[str] + base_val_n: int + # Siamese metrics at best-val epoch + siam_val_auc: float + siam_val_acc: float + siam_val_kappa: float + siam_val_mcc: float + siam_val_f1: float + siam_val_recall: Optional[str] + siam_val_ece: float + siam_val_threshold: float + siam_val_bias: Optional[str] + siam_val_n: int + # Train sample sizes (informational) + base_train_n: int + siam_train_n: int + + +# --------------------------------------------------------------------------- +# Output helpers +# --------------------------------------------------------------------------- + +def _f(v) -> Optional[float]: + """Nan-safe float serialiser.""" + if v is None or (isinstance(v, float) and np.isnan(v)): + return None + return round(float(v), 6) + + +def _sv(vec) -> Optional[str]: + """Serialise a numeric vector to a pipe-delimited string.""" + if vec is None: + return None + return "|".join(f"{float(v):.4f}" for v in vec) + + +# --------------------------------------------------------------------------- +# Main fold runner +# --------------------------------------------------------------------------- + +def run_fold( + fold: int, + split, + mode: str, + args, + device: torch.device, + data, + num_classes: int, + profile_eye, + profile_patient, + fold_dir: Path, +) -> FoldResult: + # ---- build samples ------------------------------------------------------- + # Baseline trains on ALL eye-level samples (OD + OS as separate rows) + eye_train = filter_eye_samples( + profile_eye.build_samples(df=split.train, clinical=data) + ) + # Siamese trains on bilateral patient-level samples + bilat_train = filter_bilateral_samples( + profile_patient.build_samples(df=split.train, clinical=data) + ) + # Val: bilateral patients only — shared between both model evaluations + bilat_val = filter_bilateral_samples( + profile_patient.build_samples(df=split.val, clinical=data) + ) + + if len(bilat_val) == 0: + print(f" [fold {fold+1}] WARNING: no bilateral val samples; skipping fold.", flush=True) + nan = _nan() + return FoldResult( + mode=mode, fold=fold, + best_epoch_base=0, best_epoch_siam=0, + base_val_auc=nan, base_val_acc=nan, base_val_kappa=nan, + base_val_mcc=nan, base_val_f1=nan, base_val_recall=None, + base_val_ece=nan, base_val_threshold=nan, base_val_bias=None, base_val_n=0, + siam_val_auc=nan, siam_val_acc=nan, siam_val_kappa=nan, + siam_val_mcc=nan, siam_val_f1=nan, siam_val_recall=None, + siam_val_ece=nan, siam_val_threshold=nan, siam_val_bias=None, siam_val_n=0, + base_train_n=len(eye_train), siam_train_n=len(bilat_train), + ) + + # ---- models -------------------------------------------------------------- + baseline = BaselineCNN( + backbone=args.backbone, freeze_ratio=args.freeze_ratio, + num_classes=num_classes, augment=args.augment, + ).to(device) + siamese = SiameseCNN( + backbone=args.backbone, freeze_ratio=args.freeze_ratio, + num_classes=num_classes, augment=args.augment, + ).to(device) + + slots_eye = profile_eye.slot_descriptors() + slots_patient = profile_patient.slot_descriptors() + loader_kw = dict(batch_size=args.batch_size, num_workers=args.num_workers) + + # ---- loaders ------------------------------------------------------------- + # Baseline uses eye-level transform; siamese shares the same backbone + # transform so both models see the same normalisation at eval time. + train_base = make_loader( + eye_train, slots_eye, + image_transform=baseline.tower.transform, shuffle=True, **loader_kw, + ) + train_siam = make_loader( + bilat_train, slots_patient, + image_transform=siamese.tower.transform, shuffle=True, **loader_kw, + ) + # Shared val loader — both models read from this + val_loader = make_loader( + bilat_val, slots_patient, + image_transform=baseline.tower.transform, shuffle=False, **loader_kw, + ) + + opt_base = torch.optim.Adam(baseline.parameters(), lr=args.lr) + opt_siam = torch.optim.Adam(siamese.parameters(), lr=args.lr) + + # ---- epoch log ----------------------------------------------------------- + epoch_fields = [ + "fold", "epoch", + "base_train_loss", "base_train_acc", + "base_val_auc", "base_val_acc", "base_val_n", + "base_val_threshold", + "base_val_bias", + "siam_train_loss", "siam_train_acc", + "siam_val_auc", "siam_val_acc", "siam_val_n", + "siam_val_threshold", + "siam_val_bias", + "is_best_base", "is_best_siam", + ] + epoch_fp = (fold_dir / "epoch_log.csv").open("w", newline="", encoding="utf-8") + epoch_writer = csv.DictWriter(epoch_fp, fieldnames=epoch_fields) + epoch_writer.writeheader() + + # ---- best-epoch trackers ------------------------------------------------- + best_base_auc = -1.0 + best_siam_auc = -1.0 + best_base_state: Optional[dict] = None + best_siam_state: Optional[dict] = None + best_epoch_base = 0 + best_epoch_siam = 0 + snap_base: dict = {} + snap_siam: dict = {} + + print( + f" [fold {fold+1}] base_train_n={len(eye_train)} (eye-level) " + f"siam_train_n={len(bilat_train)} (bilateral) val_n={len(bilat_val)}", + flush=True, + ) + + # ---- epoch loop ---------------------------------------------------------- + for epoch in range(args.epochs): + bl_loss, bl_acc = train_baseline_epoch(baseline, train_base, opt_base, device) + si_loss, si_acc = train_siamese_epoch(siamese, train_siam, opt_siam, device) + + y_b, p_b = collect_probs_baseline_bilateral(baseline, val_loader, device) + y_s, p_s = collect_probs_siamese(siamese, val_loader, device) + + b_acc, b_auc, b_n = _score_arrays(y_b, p_b, num_classes) + s_acc, s_auc, s_n = _score_arrays(y_s, p_s, num_classes) + b_thr = 0.5 + s_thr = 0.5 + b_bias = None + s_bias = None + b_ext_preds = None + s_ext_preds = None + if args.tune_binary_threshold and num_classes == 2 and b_n > 0 and s_n > 0: + b_thr = tune_binary_threshold(y_b, p_b[:, 1]) + s_thr = tune_binary_threshold(y_s, p_s[:, 1]) + b_ext_preds = (p_b[:, 1] >= b_thr).astype(int) + s_ext_preds = (p_s[:, 1] >= s_thr).astype(int) + b_acc = float((b_ext_preds == y_b).mean()) + s_acc = float((s_ext_preds == y_s).mean()) + elif args.tune_multiclass_bias and num_classes > 2 and b_n > 0 and s_n > 0: + b_bias = tune_multiclass_bias(y_b, p_b) + s_bias = tune_multiclass_bias(y_s, p_s) + b_logits = np.log(np.clip(p_b, 1e-8, 1.0)) + b_bias.reshape(1, -1) + s_logits = np.log(np.clip(p_s, 1e-8, 1.0)) + s_bias.reshape(1, -1) + b_ext_preds = np.argmax(b_logits, axis=1) + s_ext_preds = np.argmax(s_logits, axis=1) + b_acc = float((b_ext_preds == y_b).mean()) + s_acc = float((s_ext_preds == y_s).mean()) + + # Independent best-epoch update per model + is_best_base = (not np.isnan(b_auc)) and (b_auc > best_base_auc) + if is_best_base: + best_base_auc = b_auc + best_base_state = copy.deepcopy(baseline.state_dict()) + best_epoch_base = epoch + 1 + ext = compute_extended_metrics( + y_b, p_b, num_classes, n_bins=args.ece_bins, preds_override=b_ext_preds + ) + snap_base = dict( + auc=b_auc, acc=b_acc, n=b_n, + kappa=ext["kappa"], mcc=ext["mcc"], macro_f1=ext["macro_f1"], + per_class_recall=ext["per_class_recall"], ece=ext["ece"], threshold=b_thr, bias=b_bias, + ) + + is_best_siam = (not np.isnan(s_auc)) and (s_auc > best_siam_auc) + if is_best_siam: + best_siam_auc = s_auc + best_siam_state = copy.deepcopy(siamese.state_dict()) + best_epoch_siam = epoch + 1 + ext = compute_extended_metrics( + y_s, p_s, num_classes, n_bins=args.ece_bins, preds_override=s_ext_preds + ) + snap_siam = dict( + auc=s_auc, acc=s_acc, n=s_n, + kappa=ext["kappa"], mcc=ext["mcc"], macro_f1=ext["macro_f1"], + per_class_recall=ext["per_class_recall"], ece=ext["ece"], threshold=s_thr, bias=s_bias, + ) + + epoch_writer.writerow({ + "fold": fold, "epoch": epoch + 1, + "base_train_loss": _f(bl_loss), "base_train_acc": _f(bl_acc), + "base_val_auc": _f(b_auc), "base_val_acc": _f(b_acc), "base_val_n": b_n, + "base_val_threshold": _f(b_thr if num_classes == 2 else float("nan")), + "base_val_bias": _svf(b_bias if num_classes > 2 else None), + "siam_train_loss": _f(si_loss), "siam_train_acc": _f(si_acc), + "siam_val_auc": _f(s_auc), "siam_val_acc": _f(s_acc), "siam_val_n": s_n, + "siam_val_threshold": _f(s_thr if num_classes == 2 else float("nan")), + "siam_val_bias": _svf(s_bias if num_classes > 2 else None), + "is_best_base": int(is_best_base), + "is_best_siam": int(is_best_siam), + }) + epoch_fp.flush() + + if args.log_every > 0 and (epoch + 1) % args.log_every == 0: + print( + f" ep {epoch+1:>3}/{args.epochs} " + f"base val AUC={b_auc:.4f} siam val AUC={s_auc:.4f} " + f"(best base={best_base_auc:.4f} @ep{best_epoch_base} " + f"best siam={best_siam_auc:.4f} @ep{best_epoch_siam})", + flush=True, + ) + + epoch_fp.close() + + if args.save_checkpoints: + if best_base_state is not None: + torch.save(best_base_state, fold_dir / "best_baseline.pt") + if best_siam_state is not None: + torch.save(best_siam_state, fold_dir / "best_siamese.pt") + + nan = _nan() + + print( + f" [fold {fold+1}] BEST " + f"base AUC={snap_base.get('auc', nan):.4f} " + f"kappa={snap_base.get('kappa', nan):.4f} " + f"F1={snap_base.get('macro_f1', nan):.4f} " + f"ECE={snap_base.get('ece', nan):.4f} @ep{best_epoch_base} | " + f"siam AUC={snap_siam.get('auc', nan):.4f} " + f"kappa={snap_siam.get('kappa', nan):.4f} " + f"F1={snap_siam.get('macro_f1', nan):.4f} " + f"ECE={snap_siam.get('ece', nan):.4f} @ep{best_epoch_siam}", + flush=True, + ) + + return FoldResult( + mode=mode, fold=fold, + best_epoch_base=best_epoch_base, best_epoch_siam=best_epoch_siam, + base_val_auc=snap_base.get("auc", nan), + base_val_acc=snap_base.get("acc", nan), + base_val_kappa=snap_base.get("kappa", nan), + base_val_mcc=snap_base.get("mcc", nan), + base_val_f1=snap_base.get("macro_f1", nan), + base_val_recall=_sv(snap_base.get("per_class_recall")), + base_val_ece=snap_base.get("ece", nan), + base_val_threshold=snap_base.get("threshold", nan), + base_val_bias=_svf(snap_base.get("bias")), + base_val_n=snap_base.get("n", 0), + siam_val_auc=snap_siam.get("auc", nan), + siam_val_acc=snap_siam.get("acc", nan), + siam_val_kappa=snap_siam.get("kappa", nan), + siam_val_mcc=snap_siam.get("mcc", nan), + siam_val_f1=snap_siam.get("macro_f1", nan), + siam_val_recall=_sv(snap_siam.get("per_class_recall")), + siam_val_ece=snap_siam.get("ece", nan), + siam_val_threshold=snap_siam.get("threshold", nan), + siam_val_bias=_svf(snap_siam.get("bias")), + siam_val_n=snap_siam.get("n", 0), + base_train_n=len(eye_train), + siam_train_n=len(bilat_train), + ) + + +# --------------------------------------------------------------------------- +# Summary helpers +# --------------------------------------------------------------------------- + +def _summary(results: list[FoldResult]) -> dict: + def _ms(vals): + v = np.array([x for x in vals if not np.isnan(float(x)) if x is not None], dtype=float) + return ( + float(np.mean(v)) if v.size else None, + float(np.std(v)) if v.size else None, + ) + + metrics = ["auc", "acc", "kappa", "mcc", "f1", "ece", "threshold"] + out = {} + for label, prefix in [("baseline_best_val", "base_val"), ("siamese_best_val", "siam_val")]: + sub = {} + for m in metrics: + vals = [getattr(r, f"{prefix}_{m}") for r in results] + mean, std = _ms(vals) + sub[f"{m}_mean"] = mean + if m in ("auc", "f1", "kappa"): + sub[f"{m}_std"] = std + out[label] = sub + + # Per-fold deltas (siamese − baseline) + delta = {} + for m in ["auc", "f1", "kappa"]: + pairs = [ + getattr(r, f"siam_val_{m}") - getattr(r, f"base_val_{m}") + for r in results + if not np.isnan(float(getattr(r, f"base_val_{m}"))) + and not np.isnan(float(getattr(r, f"siam_val_{m}"))) + ] + delta[f"{m}_mean"] = float(np.mean(pairs)) if pairs else None + delta[f"{m}_std"] = float(np.std(pairs)) if pairs else None + out["delta_val"] = delta + + out["n_folds_completed"] = len(results) + out["base_train_mode"] = "eye-level (all OD+OS samples)" + out["siam_train_mode"] = "patient-level (bilateral only)" + out["eval_mode"] = "patient-level bilateral (both models, same val set)" + return out + + +def _print_summary(mode: str, s: dict) -> None: + def f(v): + return "nan" if v is None else f"{v:.4f}" + + bv = s["baseline_best_val"] + sv = s["siamese_best_val"] + dv = s["delta_val"] + + print(f"\n=== Summary [{mode}] — best-epoch, patient-level bilateral val ===") + print(f" {'':22s} {'AUC':>8} {'ACC':>8} {'Kappa':>8} {'F1-mac':>8} {'ECE':>8} {'Thr':>8}") + print( + f" {'baseline (eye-lvl tr)':22s} " + f"{f(bv['auc_mean']):>8} {f(bv['acc_mean']):>8} " + f"{f(bv['kappa_mean']):>8} {f(bv['f1_mean']):>8} {f(bv['ece_mean']):>8} {f(bv['threshold_mean']):>8}" + ) + print( + f" {'siamese (bilateral tr)':22s} " + f"{f(sv['auc_mean']):>8} {f(sv['acc_mean']):>8} " + f"{f(sv['kappa_mean']):>8} {f(sv['f1_mean']):>8} {f(sv['ece_mean']):>8} {f(sv['threshold_mean']):>8}" + ) + print( + f" {'delta (siam − base)':22s} " + f"{f(dv['auc_mean']):>8} {'':>8} " + f"{f(dv['kappa_mean']):>8} {f(dv['f1_mean']):>8}" + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def parse_args(): + ap = argparse.ArgumentParser( + description=( + "All-eye baseline (ImageTower) vs SiameseImageTower bilateral model. " + "Pure k-fold CV — no holdout set." + ) + ) + ap.add_argument("--image-dir", default="Papila/FundusImages") + ap.add_argument("--clinical-dir", default="Papila/ClinicalData") + ap.add_argument("--label-col", default="Diagnosis") + ap.add_argument("--cat-cols", nargs="*", default=["Gender", "Phakic/Pseudophakic"]) + ap.add_argument("--eval-mode", choices=["binary", "multiclass"], default="multiclass") + ap.add_argument( + "--eval-modes", nargs="+", choices=["binary", "multiclass"], default=None, + help="Run multiple eval modes in one pass.", + ) + ap.add_argument("--n-splits", type=int, default=5) + ap.add_argument("--fold-seed", type=int, default=42) + ap.add_argument("--folds", type=int, default=5) + ap.add_argument("--epochs", type=int, default=40) + ap.add_argument("--batch-size", type=int, default=8) + ap.add_argument("--lr", type=float, default=1e-4) + ap.add_argument("--backbone", default="refugelike") + ap.add_argument("--freeze-ratio", type=float, default=0.0) + ap.add_argument("--augment", action="store_true") + ap.add_argument("--num-workers", type=int, default=0) + ap.add_argument("--device", choices=["auto", "cpu", "cuda"], default="auto") + ap.add_argument("--seed", type=int, default=1234) + ap.add_argument("--run-name", default=None) + ap.add_argument("--output-root", default="analysis_data/basic_analysis") + ap.add_argument( + "--exclude-mixed-patients", + dest="exclude_mixed_patients", + action="store_true", + help="Drop patients whose two eyes have different labels before splitting.", + ) + ap.add_argument( + "--include-mixed-patients", + dest="exclude_mixed_patients", + action="store_false", + help="Keep mixed-label patients (default behavior).", + ) + ap.add_argument( + "--keep-mixed-raw-labels", + action="store_true", + help="When mixed patients are included, keep original per-eye labels (default is relabel to patient max label).", + ) + ap.set_defaults(exclude_mixed_patients=False) + ap.add_argument("--log-every", type=int, default=5) + ap.add_argument( + "--tune-binary-threshold", + action="store_true", + help="Tune per-model binary threshold on validation each epoch and use it for ACC/F1/Kappa/MCC/recall.", + ) + ap.add_argument( + "--tune-multiclass-bias", + action="store_true", + help="Tune per-model multiclass log-prob bias on validation each epoch and use it for ACC/F1/Kappa/MCC/recall.", + ) + ap.add_argument("--ece-bins", type=int, default=10, + help="Number of bins for ECE calibration calculation.") + ap.add_argument("--save-checkpoints", action="store_true", + help="Save best model state dicts (disabled by default to save disk).") + return ap.parse_args() + + +def choose_device(name: str) -> torch.device: + if name == "cuda": + if not torch.cuda.is_available(): + raise RuntimeError("--device cuda requested but CUDA is not available.") + return torch.device("cuda") + if name == "cpu": + return torch.device("cpu") + return torch.device("cuda" if torch.cuda.is_available() else "cpu") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main(): + args = parse_args() + device = choose_device(args.device) + seed_everything(args.seed) + + print(f"Device: {device}", flush=True) + print("Loading PAPILA data...", flush=True) + data = build_papila_data( + image_dir=args.image_dir, + clinical_dir=args.clinical_dir, + label_col=args.label_col, + cat_cols=list(args.cat_cols), + n_splits=args.n_splits, + random_seed=args.fold_seed, + ) + print(f"Loaded: {len(data.df)} rows", flush=True) + + ts = time.strftime("%Y%m%d_%H%M%S") + run_name = args.run_name or f"siamese_v2_{ts}" + out_dir = Path(args.output_root) / run_name + out_dir.mkdir(parents=True, exist_ok=True) + + eval_modes = args.eval_modes if args.eval_modes else [args.eval_mode] + + all_results: dict[str, list[FoldResult]] = {} + summaries: dict[str, dict] = {} + + for mode in eval_modes: + df_mode = data.df.copy() + + if args.exclude_mixed_patients: + before = df_mode["Patient ID"].nunique() + df_mode, mixed = _drop_mixed_label_patients( + df_mode, patient_col="Patient ID", label_col=args.label_col + ) + print( + f"[{mode}] dropped {len(mixed)} mixed-label patients " + f"({before} → {df_mode['Patient ID'].nunique()})", + flush=True, + ) + else: + if args.keep_mixed_raw_labels: + print(f"[{mode}] keeping mixed-label patients with raw per-eye labels.", flush=True) + else: + before_rows = len(df_mode) + df_mode, changed_rows, still_mixed = _relabel_mixed_patients_to_max( + df_mode, patient_col="Patient ID", label_col=args.label_col + ) + print( + f"[{mode}] included mixed-label patients; relabeled to patient max severity " + f"(changed_rows={changed_rows}, rows={before_rows}->{len(df_mode)}, remaining_mixed={len(still_mixed)}).", + flush=True, + ) + + if mode == "binary": + df_mode = df_mode[df_mode[args.label_col].isin([0, 1])].reset_index(drop=True) + + num_classes = 2 if mode == "binary" else int(df_mode[args.label_col].nunique()) + print( + f"\n[{mode}] num_classes={num_classes} rows={len(df_mode)} " + f"patients={df_mode['Patient ID'].nunique()}", + flush=True, + ) + + split_manager = PatientFirstSplitManager( + patient_col="Patient ID", label_col=args.label_col + ) + split_args = SimpleNamespace( + eval_mode=mode, + holdout_per_class=0, # No holdout by design + holdout_seed=123, + n_splits=args.n_splits, + fold_seed=args.fold_seed, + ) + clinical_ns = SimpleNamespace(df=df_mode, label_col=args.label_col) + plans = split_manager.build_plans(clinical=clinical_ns, args=split_args, profile=None) + n_folds = min(args.folds, len(plans)) + + # Two profiles: eye-level for baseline training, patient-level for + # siamese training and shared bilateral val evaluation. + profile_eye = build_papila_profile( + patient_col="Patient ID", label_col=args.label_col, sample_mode="eye" + ) + profile_patient = build_papila_profile( + patient_col="Patient ID", label_col=args.label_col, sample_mode="patient" + ) + + mode_dir = out_dir / mode + mode_dir.mkdir(exist_ok=True) + + fold_results: list[FoldResult] = [] + for fold in range(n_folds): + fold_seed = args.seed + fold * 100 + seed_everything(fold_seed) + fold_dir = mode_dir / f"fold{fold}" + fold_dir.mkdir(exist_ok=True) + + print(f"\n[{mode}] fold {fold+1}/{n_folds}", flush=True) + result = run_fold( + fold=fold, + split=plans[fold], + mode=mode, + args=args, + device=device, + data=data, + num_classes=num_classes, + profile_eye=profile_eye, + profile_patient=profile_patient, + fold_dir=fold_dir, + ) + fold_results.append(result) + + # Write per-mode fold CSV + fold_csv = out_dir / f"{mode}_fold_results.csv" + csv_fields = list(FoldResult.__dataclass_fields__.keys()) + with fold_csv.open("w", newline="", encoding="utf-8") as fh: + w = csv.DictWriter(fh, fieldnames=csv_fields) + w.writeheader() + for r in fold_results: + w.writerow({k: getattr(r, k) for k in csv_fields}) + + summary = _summary(fold_results) + _print_summary(mode, summary) + + all_results[mode] = fold_results + summaries[mode] = summary + + payload = { + "run_name": run_name, + "timestamp": ts, + "config": vars(args), + "summaries": summaries, + } + (out_dir / "summary.json").write_text(json.dumps(payload, indent=2), encoding="utf-8") + print(f"\nOutputs written to: {out_dir}") + + +if __name__ == "__main__": + main() diff --git a/scripts/basic_analysis/papila_univariate_roc.py b/scripts/basic_analysis/papila_univariate_roc.py new file mode 100755 index 0000000..cb96634 --- /dev/null +++ b/scripts/basic_analysis/papila_univariate_roc.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Univariate ROC curves for PAPILA clinical variables.""" +import re +from pathlib import Path +from typing import Iterable, List, Tuple +import sys + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +from sklearn.metrics import roc_curve, auc + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes import build_papila_clinical + + +# --------------------------- +# Config (edit in IDE) +# --------------------------- +IMAGE_DIR = "Papila/FundusImages" +CLINICAL_DIR = "Papila/ClinicalData" +LABEL_COL = "Diagnosis" +CAT_COLS = ["Gender", "Phakic/Pseudophakic"] +EXCLUDE_COLS = {"Pneumatic", "Perkins"} +INCLUDE_CATEGORICAL = False +POSITIVE_LABEL = 1 +NEGATIVE_LABEL = 0 +DROP_LABELS = [2] +OUTPUT_DIR = Path("analysis_data/basic_analysis/papila_univariate_roc") +DEBUG_PRINTS = False +PLOT_PER_FEATURE = False +DI_OPTRE_COL_PREFIXES = ("dioptre",) +ADD_DIOPTRE_ABS = True +ADD_DIOPTRE_SQUARED = True + + +def _sanitize(name: str) -> str: + safe = re.sub(r"[^A-Za-z0-9._-]+", "_", str(name)).strip("_") + return safe or "var" + + +def _select_binary_labels(labels: pd.Series, + positive_label: int, + negative_label: int, + drop_labels: Iterable[int]) -> Tuple[np.ndarray, np.ndarray]: + labels_num = pd.to_numeric(labels, errors="coerce") + use_num = labels_num.notna().any() + lab = labels_num if use_num else labels.astype(str) + + drop_set = set(drop_labels or []) + keep = lab.isin([positive_label, negative_label]) + if drop_set: + keep &= ~lab.isin(drop_set) + + y = (lab == positive_label).astype(int) + return y.values, keep.values + + +def _compute_roc(y: np.ndarray, scores: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray, float]: + fpr, tpr, thresholds = roc_curve(y, scores, pos_label=1) + auc_val = float(auc(fpr, tpr)) + return fpr, tpr, thresholds, auc_val + + +def _best_threshold(fpr: np.ndarray, tpr: np.ndarray, thresholds: np.ndarray) -> Tuple[float, float, float]: + youden = tpr - fpr + idx = int(np.nanargmax(youden)) + return float(thresholds[idx]), float(tpr[idx]), float(fpr[idx]) + + +def _plot_roc(fpr: np.ndarray, tpr: np.ndarray, auc_val: float, title: str, out_path: Path) -> None: + fig, ax = plt.subplots(figsize=(5.5, 4.5)) + ax.plot(fpr, tpr, lw=1.8, label=f"AUC={auc_val:.3f}") + ax.plot([0, 1], [0, 1], "k--", lw=1) + ax.set_xlabel("False Positive Rate") + ax.set_ylabel("True Positive Rate") + ax.set_title(title) + ax.legend(loc="lower right") + ax.grid(True, alpha=0.3, linestyle="--") + fig.tight_layout() + fig.savefig(out_path, dpi=170) + plt.close(fig) + + +def _plot_overlay(curves, title: str, out_path: Path) -> None: + fig, ax = plt.subplots(figsize=(7, 5.5)) + cmap = plt.get_cmap("tab20") + for i, (name, fpr, tpr, auc_val) in enumerate(curves): + color = cmap(i % cmap.N) + ax.plot(fpr, tpr, lw=1.6, color=color, label=f"{name} (AUC={auc_val:.3f})") + ax.plot([0, 1], [0, 1], "k--", lw=1) + ax.set_xlabel("False Positive Rate") + ax.set_ylabel("True Positive Rate") + ax.set_title(title) + ax.legend(loc="upper left", fontsize="small") + ax.grid(True, alpha=0.3, linestyle="--") + fig.tight_layout() + fig.savefig(out_path, dpi=170) + plt.close(fig) + + +def _iter_numeric(df: pd.DataFrame, cols: List[str]): + for col in cols: + if col not in df.columns: + continue + s = pd.to_numeric(df[col], errors="coerce") + yield col, s + + +def _is_dioptre_col(col: str) -> bool: + name = str(col).strip().lower() + return any(name.startswith(prefix) for prefix in DI_OPTRE_COL_PREFIXES) + + +def _iter_numeric_with_transforms(df: pd.DataFrame, cols: List[str]): + for col, s in _iter_numeric(df, cols): + yield col, s + if _is_dioptre_col(col): + if ADD_DIOPTRE_ABS: + yield f"{col}_abs", s.abs() + if ADD_DIOPTRE_SQUARED: + yield f"{col}_sq", s.pow(2) + + +def _include_in_overlay(feature_name: str) -> bool: + name = str(feature_name).strip().lower() + if _is_dioptre_col(name) and not name.endswith("_abs"): + return False + return True + + +def _iter_categorical(df: pd.DataFrame, cols: List[str]): + for col in cols: + if col not in df.columns: + continue + s = df[col] + vals = s.dropna().unique().tolist() + try: + vals = sorted(vals) + except Exception: + pass + for v in vals: + name = f"{col}=={v}" + ind = (s == v).astype(int) + yield name, ind + + +def main() -> None: + clinical = build_papila_clinical( + image_dir=IMAGE_DIR, + clinical_dir=CLINICAL_DIR, + label_col=LABEL_COL, + cat_cols=CAT_COLS, + ) + + df = clinical.df.copy() + labels = df[LABEL_COL] + y_all, keep_mask = _select_binary_labels(labels, POSITIVE_LABEL, NEGATIVE_LABEL, DROP_LABELS) + + out_dir = OUTPUT_DIR + plot_dir = out_dir / "plots" + plot_dir.mkdir(parents=True, exist_ok=True) + + rows = [] + curves = [] + + base_exclude = {LABEL_COL, "Patient ID"} | EXCLUDE_COLS | set(CAT_COLS) + if "eyeID" not in CAT_COLS: + base_exclude.add("eyeID") + candidate_cols = [c for c in df.columns if c not in base_exclude] + numeric_cols = [] + for col in candidate_cols: + s = pd.to_numeric(df[col], errors="coerce") + if s.notna().any(): + numeric_cols.append(col) + + if DEBUG_PRINTS: + for col in ("IOP_raw", "IOP_corr"): + if col not in df.columns: + print(f"[debug] {col} missing from df") + continue + s = pd.to_numeric(df[col], errors="coerce") + print(f"[debug] {col}: non-null={int(s.notna().sum())}, unique={int(s.nunique(dropna=True))}") + for col, series in _iter_numeric_with_transforms(df, numeric_cols): + mask = keep_mask & series.notna().values + y = y_all[mask] + scores = series.values[mask].astype(float) + if y.size < 2 or np.unique(y).size < 2: + continue + if np.nanmin(scores) == np.nanmax(scores): + continue + fpr, tpr, thresholds, auc_val = _compute_roc(y, scores) + thr, best_tpr, best_fpr = _best_threshold(fpr, tpr, thresholds) + direction = "high" if auc_val >= 0.5 else "low" + title = f"{col} (n={y.size}, direction={direction})" + if PLOT_PER_FEATURE: + out_path = plot_dir / f"roc_{_sanitize(col)}.png" + _plot_roc(fpr, tpr, auc_val, title, out_path) + if _include_in_overlay(col): + curves.append((col, fpr, tpr, auc_val)) + rows.append({ + "feature": col, + "kind": "numeric", + "n": int(y.size), + "auc": auc_val, + "direction": direction, + "best_threshold": thr, + "best_tpr": best_tpr, + "best_fpr": best_fpr, + "best_specificity": 1.0 - best_fpr, + }) + + if INCLUDE_CATEGORICAL: + cat_cols_use = [c for c in clinical.cat_cols if c not in EXCLUDE_COLS] + for name, ind in _iter_categorical(df, cat_cols_use): + mask = keep_mask & ind.notna().values + y = y_all[mask] + scores = ind.values[mask].astype(float) + if y.size < 2 or np.unique(y).size < 2: + continue + if np.nanmin(scores) == np.nanmax(scores): + continue + fpr, tpr, thresholds, auc_val = _compute_roc(y, scores) + thr, best_tpr, best_fpr = _best_threshold(fpr, tpr, thresholds) + direction = "high" if auc_val >= 0.5 else "low" + title = f"{name} (n={y.size}, direction={direction})" + if PLOT_PER_FEATURE: + out_path = plot_dir / f"roc_{_sanitize(name)}.png" + _plot_roc(fpr, tpr, auc_val, title, out_path) + if _include_in_overlay(name): + curves.append((name, fpr, tpr, auc_val)) + rows.append({ + "feature": name, + "kind": "categorical", + "n": int(y.size), + "auc": auc_val, + "direction": direction, + "best_threshold": thr, + "best_tpr": best_tpr, + "best_fpr": best_fpr, + "best_specificity": 1.0 - best_fpr, + }) + + if not rows: + raise SystemExit("No valid features produced ROC curves. Check labels and feature columns.") + + overlay_path = plot_dir / "roc_overlay.png" + _plot_overlay(curves, "Univariate ROC curves", overlay_path) + + out_df = pd.DataFrame(rows).sort_values(by="auc", ascending=False) + out_dir.mkdir(parents=True, exist_ok=True) + out_df.to_csv(out_dir / "summary.csv", index=False) + print(out_df.to_string(index=False, float_format=lambda x: f"{x:.4f}")) + print(f"\nSaved overlay plot to: {overlay_path}") + if PLOT_PER_FEATURE: + print(f"Saved per-feature plots to: {plot_dir}") + print(f"Saved summary to: {out_dir / 'summary.csv'}") + + +if __name__ == "__main__": + main() diff --git a/scripts/deprecated/inspect_gt_masks.py b/scripts/deprecated/inspect_gt_masks.py new file mode 100755 index 0000000..a43e8cf --- /dev/null +++ b/scripts/deprecated/inspect_gt_masks.py @@ -0,0 +1,142 @@ +"""Generate ground-truth mask overlays for REFUGE and Papila samples.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.append(str(ROOT)) + +import numpy as np +from collections import Counter +from PIL import Image +from PIL.Image import Resampling + +from classes.unet_segmenter import UNetSegmenter + +REFUGE_ROOT = Path("REFUGE") +DEFAULT_MANIFEST = Path("manifest.csv") +OUTPUT_DIR = Path("temp/gt_test") + + +def to_mask_colors(disc: np.ndarray, cup: np.ndarray) -> Image.Image: + h, w = disc.shape + canvas = np.ones((h, w, 3), dtype=np.uint8) * 255 + disc_mask = disc.astype(bool) + cup_mask = cup.astype(bool) + canvas[disc_mask] = [128, 128, 128] + canvas[cup_mask] = [0, 0, 0] + return Image.fromarray(canvas) + + +def overlay( + original: Image.Image, mask_rgb: Image.Image, alpha: float = 0.6 +) -> Image.Image: + mask_rgba = mask_rgb.convert("RGBA") + updates = np.array(mask_rgba, dtype=np.float32) + updates[..., 3] = alpha * 255 * (updates[..., :3] != 255).any(axis=-1) + base = original.convert("RGBA") + return Image.alpha_composite( + base, Image.fromarray(updates.astype(np.uint8)) + ).convert("RGB") + + +def original_mask_to_rgb(mask_path: Path) -> Image.Image: + mask_img = Image.open(mask_path) + arr = np.asarray(mask_img) + h, w = arr.shape[:2] + canvas = np.ones((h, w, 3), dtype=np.uint8) * 255 + + if arr.ndim == 2: + border = np.concatenate([arr[0, :], arr[-1, :], arr[:, 0], arr[:, -1]]) + bg_value = Counter(border.tolist()).most_common(1)[0][0] + disc_mask = arr != bg_value + fg_counts = Counter(arr[arr != bg_value].flatten()) + if fg_counts: + # For REFUGE-style masks: cup should be the darkest (minimum value) + cup_value = min(fg_counts.keys()) + cup_mask = arr == cup_value + else: + cup_mask = np.zeros_like(arr, dtype=bool) + else: + edges = np.concatenate( + [arr[0, :, :], arr[-1, :, :], arr[:, 0, :], arr[:, -1, :]], axis=0 + ) + bg_color = Counter(map(tuple, edges)).most_common(1)[0][0] + disc_mask = ~np.all(arr == bg_color, axis=-1) + color_counts = Counter(map(tuple, arr.reshape(-1, arr.shape[2]))) + cup_mask = np.zeros((h, w), dtype=bool) + candidates = {} + for color, count in color_counts.items(): + if color == bg_color: + continue + mask = np.all(arr == color, axis=-1) + candidates[color] = mask + if candidates: + # For REFUGE-style masks: cup should be the darkest color (closest to black) + cup_color = min(candidates.keys(), key=lambda color: sum(color)) + cup_mask = candidates[cup_color] + disc_mask = disc_mask.astype(bool) + cup_mask = cup_mask & disc_mask + + canvas[disc_mask] = [128, 128, 128] + canvas[cup_mask] = [0, 0, 0] + return Image.fromarray(canvas) + + +def process_entries( + segmenter: UNetSegmenter, entries, prefix: str, count: int, dest: Path +) -> None: + for entry in entries[:count]: + img_path = Path(entry.image_path) + if not img_path.exists(): + continue + orig = Image.open(img_path).convert("RGB") + image = segmenter.preprocess_image(orig) + disc, cup = segmenter.load_masks(entry) + disc_coords = segmenter._mask_to_coords(disc) + cup_coords = segmenter._mask_to_coords(cup) + print( + f"{entry.sample_id}: disc coords {disc_coords.shape[0] if disc_coords is not None else 0}, " + f"cup coords {cup_coords.shape[0] if cup_coords is not None else 0}" + ) + mask_rgb = to_mask_colors(disc, cup) + overlay_img = overlay(image, mask_rgb) + mask_rgb.save(dest / f"{prefix}_{entry.sample_id}_mask.png") + overlay_img.save(dest / f"{prefix}_{entry.sample_id}_overlay.png") + + if entry.annotation_type_disc == "mask": + gt_mask_rgb = original_mask_to_rgb(entry.annotation_disc) + gt_overlay = overlay( + orig.resize(gt_mask_rgb.size, Resampling.BILINEAR), gt_mask_rgb + ) + gt_mask_rgb.save(dest / f"{prefix}_{entry.sample_id}_gt_mask.png") + gt_overlay.save(dest / f"{prefix}_{entry.sample_id}_gt_overlay.png") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Inspect ground-truth masks for REFUGE and Papila" + ) + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + parser.add_argument("--output", type=Path, default=OUTPUT_DIR) + parser.add_argument( + "--count", type=int, default=10, help="Number of samples per dataset" + ) + args = parser.parse_args() + + args.output.mkdir(parents=True, exist_ok=True) + + segmenter = UNetSegmenter(args.manifest) + refuge_entries = [e for e in segmenter._manifest if e.dataset == "refuge"] + papila_entries = [e for e in segmenter._manifest if e.dataset == "papila"] + + process_entries(segmenter, refuge_entries, "refuge", args.count, args.output) + process_entries(segmenter, papila_entries, "papila", args.count, args.output) + print(f"Saved overlays to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/scripts/grid_search_analytics/best_holdout_multiclass.py b/scripts/grid_search_analytics/best_holdout_multiclass.py new file mode 100755 index 0000000..0813968 --- /dev/null +++ b/scripts/grid_search_analytics/best_holdout_multiclass.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Rank multiclass runs by mean holdout AUC (fused) across folds.""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +import numpy as np +import pandas as pd + + +# --------------------------- +# Config (edit in IDE) +# --------------------------- +ANALYSIS_DIR = Path("analysis_data/grid_search") +TOP_N = 20 +HEAD = "fused" # fused | image | metadata +OUTPUT_CSV = Path("analysis_data/grid_search/plots/best_holdout_multiclass.csv") + + +def _read_json(path: Path) -> Optional[Dict[str, Any]]: + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + except Exception: + return None + return data if isinstance(data, dict) else None + + +def _infer_mode(summary: Optional[Dict[str, Any]]) -> Optional[str]: + if not summary: + return None + eval_mode = summary.get("eval_mode") + if isinstance(eval_mode, str): + mode = eval_mode.strip().lower() + if mode == "binary": + return "binary" + if mode in {"multiclass", "multi", "multi-class"}: + return "multiclass" + num_classes = summary.get("num_classes") + if isinstance(num_classes, (int, float)): + return "binary" if int(num_classes) <= 2 else "multiclass" + class_names = summary.get("class_names") + if isinstance(class_names, list) and class_names: + return "binary" if len(class_names) <= 2 else "multiclass" + return None + + +def _simple_fields(summary: Dict[str, Any]) -> Dict[str, Any]: + keep: Dict[str, Any] = {} + for key, val in summary.items(): + if key == "fold_metrics": + continue + if isinstance(val, (str, int, float, bool)) or val is None: + keep[key] = val + return keep + + +def _collect_fold_values(summary: Dict[str, Any], metric_key: str) -> List[float]: + values: List[float] = [] + for entry in summary.get("fold_metrics") or []: + if not isinstance(entry, dict): + continue + stats = entry.get("stats") if isinstance(entry.get("stats"), dict) else {} + val = stats.get(metric_key) + if isinstance(val, (int, float)): + values.append(float(val)) + return values + + +def main() -> None: + metric_key = f"holdout_auc_{HEAD}" + rows: List[Dict[str, Any]] = [] + + for run_dir in sorted(ANALYSIS_DIR.iterdir()): + if not run_dir.is_dir(): + continue + summary = _read_json(run_dir / "summary.json") + mode = _infer_mode(summary) + if mode != "multiclass": + continue + + values = _collect_fold_values(summary, metric_key) + if not values: + continue + + mean_val = float(np.mean(values)) + std_val = float(np.std(values, ddof=1)) if len(values) > 1 else float("nan") + + row = { + "run_id": summary.get("run_id", run_dir.name), + "run_dir": str(run_dir), + "metric": metric_key, + "mean": mean_val, + "std": std_val, + "n_folds": len(values), + **_simple_fields(summary), + } + rows.append(row) + + if not rows: + raise SystemExit("No multiclass runs with holdout AUC found.") + + df = pd.DataFrame(rows).sort_values(by="mean", ascending=False) + top_df = df.head(TOP_N) if TOP_N else df + + OUTPUT_CSV.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(OUTPUT_CSV, index=False) + + print(top_df.to_string(index=False, float_format=lambda x: f"{x:.4f}")) + print(f"\nSaved full ranking to: {OUTPUT_CSV}") + + +if __name__ == "__main__": + main() diff --git a/scripts/grid_search_analytics/check_crop_cache_vs_gt.py b/scripts/grid_search_analytics/check_crop_cache_vs_gt.py new file mode 100755 index 0000000..ebdaa60 --- /dev/null +++ b/scripts/grid_search_analytics/check_crop_cache_vs_gt.py @@ -0,0 +1,376 @@ +#!/usr/bin/env python3 +"""Compare cached crop bounds/features vs GT-derived crops from the manifest.""" +from __future__ import annotations + +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import sys + +import numpy as np +import pandas as pd +from PIL import Image +import torch +from torchvision import transforms + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes.hypertower import ManifestImageCropper, UNetImageCropper +from classes.refuge_segmentation import UNet as RefugeUNet + + +# --------------------------- +# Config (edit in IDE) +# --------------------------- +CACHE_DIR = Path("analysis_data/hypertower_crops") +MANIFEST_PATH = Path("manifest.csv") +IMAGE_DIR = Path("Papila/FundusImages") +SCALE = 2.5 +MAX_SAMPLES = 200 # set None to scan all +TOL_BOUNDS = 1.0 # pixels +TOL_FEATURES = 1e-3 +UNET_VARIANTS = [ + ("norm_imagenet", Path("models/unet_segmenter/norm_imagenet/best.pt"), "imagenet"), + ("normalize_none", Path("models/unet_segmenter/normalize_none/best.pt"), "none"), + ("norm_per_image", Path("models/unet_segmenter/norm_per_image/best.pt"), "per_image"), +] +REFUGE_SEG_WEIGHTS = Path("models/refuge/segmentation/refuge_segmentation_best.pt") + + +def _load_cache(path: Path) -> Optional[Dict[str, np.ndarray]]: + try: + data = np.load(path, allow_pickle=False) + except Exception: + return None + return {k: data[k] for k in data.files} + + +def _parse_stem(path: Path) -> str: + # expects RET###OS_s250.npz -> RET###OS + stem = path.stem + if "_s" in stem: + stem = stem.split("_s")[0] + return stem + + +def _image_path_from_stem(stem: str) -> Optional[Path]: + cand = IMAGE_DIR / f"{stem}.jpg" + if cand.exists(): + return cand + cand = IMAGE_DIR / f"{stem}.png" + if cand.exists(): + return cand + return None + + +def _gt_info( + cropper: ManifestImageCropper, image_path: Path +) -> Optional[Dict[str, float]]: + try: + image = Image.open(image_path).convert("RGB") + except Exception: + return None + info = cropper._compute_crop_info(image, image_path) + return info + + +def _unet_info( + cropper: UNetImageCropper, image_path: Path +) -> Optional[Dict[str, float]]: + try: + image = Image.open(image_path).convert("RGB") + except Exception: + return None + info = cropper._compute_crop_info(image, image_path) + return info + + +def _load_refuge_model(device: str) -> Optional[RefugeUNet]: + if not REFUGE_SEG_WEIGHTS.exists(): + return None + model = RefugeUNet() + try: + state = torch.load(REFUGE_SEG_WEIGHTS, map_location=device) + except Exception: + return None + state_dict = state.get("model", state) if isinstance(state, dict) else state + try: + model.load_state_dict(state_dict) + except Exception: + return None + model.to(device) + model.eval() + return model + + +def _refuge_seg_info( + model: RefugeUNet, device: str, image_path: Path +) -> Optional[Dict[str, float]]: + try: + image = Image.open(image_path).convert("RGB") + except Exception: + return None + original_size = image.size + image_resized = image.resize((512, 512), Image.BILINEAR) + tensor = transforms.ToTensor()(image_resized).unsqueeze(0).to(device) + with torch.no_grad(): + logits = model(tensor) + mask = torch.sigmoid(logits)[0, 0] + mask_np = (mask.cpu().numpy() > 0.5).astype(np.float32) + mask_img = Image.fromarray(mask_np) + mask_img = mask_img.resize(original_size, Image.NEAREST) + mask_np = np.array(mask_img, dtype=np.float32) + coords = np.argwhere(mask_np > 0.5) + if coords.size == 0: + return None + 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 + left = max(0.0, centre_x - crop_radius) + upper = max(0.0, centre_y - crop_radius) + right = min(float(image.width), centre_x + crop_radius) + lower = min(float(image.height), centre_y + crop_radius) + return { + "left": left, + "upper": upper, + "right": right, + "lower": lower, + } + + +def _diff_bounds(cache: Dict[str, np.ndarray], gt: Dict[str, float]) -> Optional[float]: + keys = ("left", "upper", "right", "lower") + if not all(k in cache for k in keys): + return None + diffs = [abs(float(cache[k]) - float(gt[k])) for k in keys] + return float(max(diffs)) + + +def _diff_features( + cache: Dict[str, np.ndarray], gt: Dict[str, float] +) -> Optional[float]: + if "features" not in cache or "features" not in gt: + return None + cf = np.asarray(cache["features"], dtype=float).ravel() + gf = np.asarray(gt["features"], dtype=float).ravel() + if cf.shape != gf.shape: + return None + return float(np.max(np.abs(cf - gf))) + + +def main() -> None: + if not CACHE_DIR.exists(): + raise SystemExit(f"Cache dir not found: {CACHE_DIR}") + if not MANIFEST_PATH.exists(): + raise SystemExit(f"Manifest not found: {MANIFEST_PATH}") + + cache_files = sorted(CACHE_DIR.glob(f"*_s{int(SCALE * 100)}.npz")) + if MAX_SAMPLES is not None: + cache_files = cache_files[:MAX_SAMPLES] + print(f"[debug] cache files found: {len(cache_files)}") + + try: + manifest_df = pd.read_csv(MANIFEST_PATH) + except Exception as exc: + raise SystemExit(f"Failed to read manifest: {exc}") + manifest_images = manifest_df.get("image_path") + if manifest_images is None: + raise SystemExit("Manifest is missing image_path column.") + manifest_images = manifest_images.dropna().astype(str) + manifest_stems = {Path(p).stem for p in manifest_images} + print(f"[debug] manifest image_path count: {len(manifest_images)}") + print(f"[debug] manifest unique stems: {len(manifest_stems)}") + + cache_stems = {_parse_stem(p) for p in cache_files} + overlap = cache_stems & manifest_stems + print( + f"[debug] cache stems: {len(cache_stems)} overlap with manifest stems: {len(overlap)}" + ) + if cache_files: + print(f"[debug] example cache stems: {sorted(list(cache_stems))[:5]}") + if manifest_stems: + print(f"[debug] example manifest stems: {sorted(list(manifest_stems))[:5]}") + + cropper = ManifestImageCropper( + manifest_path=MANIFEST_PATH, + scale=SCALE, + target_size=224, + cache_dir=None, + ) + + rows: List[Dict[str, object]] = [] + for cache_path in cache_files: + cache = _load_cache(cache_path) + if cache is None: + continue + stem = _parse_stem(cache_path) + image_path = _image_path_from_stem(stem) + if image_path is None: + continue + + gt = _gt_info(cropper, image_path) + if gt is None: + continue + + bounds_diff = _diff_bounds(cache, gt) + feat_diff = _diff_features(cache, gt) + + rows.append( + { + "file": cache_path.name, + "bounds_diff": bounds_diff, + "features_diff": feat_diff, + "bounds_match": bounds_diff is not None and bounds_diff <= TOL_BOUNDS, + "features_match": feat_diff is not None and feat_diff <= TOL_FEATURES, + } + ) + + if not rows: + print("[warn] No cache entries matched GT manifest entries.") + else: + df = pd.DataFrame(rows) + print(df.head(10).to_string(index=False)) + print("\nSummary:") + print(df[["bounds_diff", "features_diff"]].describe().to_string()) + if df["bounds_match"].notna().any(): + match_rate = df["bounds_match"].mean() + print(f"\nBounds match rate (<= {TOL_BOUNDS}px): {match_rate:.3f}") + if df["features_match"].notna().any(): + match_rate = df["features_match"].mean() + print(f"Features match rate (<= {TOL_FEATURES}): {match_rate:.3f}") + + print("\nUNet variant comparisons (no cache writes):") + for name, weights, normalize in UNET_VARIANTS: + if not weights.exists(): + print(f"[warn] {name}: weights not found at {weights}") + continue + + unet = UNetImageCropper( + manifest_path=MANIFEST_PATH, + weights_path=weights, + normalize=normalize, + threshold=0.5, + tta=False, + scale=SCALE, + target_size=224, + cache_dir=None, # ensure no cache writes + ) + + u_rows: List[Dict[str, object]] = [] + missing_images = 0 + unet_none = 0 + cache_missing = 0 + exceptions = 0 + for cache_path in cache_files: + cache = _load_cache(cache_path) + if cache is None: + cache_missing += 1 + continue + stem = _parse_stem(cache_path) + image_path = _image_path_from_stem(stem) + if image_path is None: + missing_images += 1 + continue + try: + info = _unet_info(unet, image_path) + except Exception: + exceptions += 1 + continue + if info is None: + unet_none += 1 + continue + bounds_diff = _diff_bounds(cache, info) + feat_diff = _diff_features(cache, info) + u_rows.append( + { + "bounds_diff": bounds_diff, + "features_diff": feat_diff, + "bounds_match": bounds_diff is not None + and bounds_diff <= TOL_BOUNDS, + "features_match": feat_diff is not None + and feat_diff <= TOL_FEATURES, + } + ) + + if not u_rows: + print( + f"[warn] {name}: no comparisons computed " + f"(cache_missing={cache_missing}, missing_images={missing_images}, " + f"unet_none={unet_none}, exceptions={exceptions})" + ) + continue + u_df = pd.DataFrame(u_rows) + b_mean = float(u_df["bounds_diff"].mean()) + f_mean = float(u_df["features_diff"].mean()) + b_match = float(u_df["bounds_match"].mean()) + f_match = float(u_df["features_match"].mean()) + + print( + f"{name}: mean bounds diff={b_mean:.3f}, mean feat diff={f_mean:.6f}, " + f"bounds match rate={b_match:.3f}, features match rate={f_match:.3f}" + ) + + print("\nRefuge segmentation model comparison (bounds only, no cache writes):") + device = "cuda" if torch.cuda.is_available() else "cpu" + refuge_model = _load_refuge_model(device) + if refuge_model is None: + print(f"[warn] refuge_segmentation_best.pt not found or failed to load at {REFUGE_SEG_WEIGHTS}") + return + + r_rows: List[Dict[str, object]] = [] + missing_images = 0 + cache_missing = 0 + model_none = 0 + exceptions = 0 + for cache_path in cache_files: + cache = _load_cache(cache_path) + if cache is None: + cache_missing += 1 + continue + stem = _parse_stem(cache_path) + image_path = _image_path_from_stem(stem) + if image_path is None: + missing_images += 1 + continue + try: + info = _refuge_seg_info(refuge_model, device, image_path) + except Exception: + exceptions += 1 + continue + if info is None: + model_none += 1 + continue + bounds_diff = _diff_bounds(cache, info) + r_rows.append( + { + "bounds_diff": bounds_diff, + "bounds_match": bounds_diff is not None and bounds_diff <= TOL_BOUNDS, + } + ) + + if not r_rows: + print( + "[warn] refuge_segmentation_best: no comparisons computed " + f"(cache_missing={cache_missing}, missing_images={missing_images}, " + f"model_none={model_none}, exceptions={exceptions})" + ) + return + + r_df = pd.DataFrame(r_rows) + b_mean = float(r_df["bounds_diff"].mean()) + b_match = float(r_df["bounds_match"].mean()) + print( + f"refuge_segmentation_best: mean bounds diff={b_mean:.3f}, " + f"bounds match rate={b_match:.3f}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/grid_search_analytics/compare_suspect_geometry_vs_image.py b/scripts/grid_search_analytics/compare_suspect_geometry_vs_image.py new file mode 100755 index 0000000..c2eb997 --- /dev/null +++ b/scripts/grid_search_analytics/compare_suspect_geometry_vs_image.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +"""Compare suspect AUC from image tower vs crop-derived geometry (CDR).""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import sys + +import numpy as np +import pandas as pd +from PIL import Image +from sklearn.metrics import roc_auc_score + +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes import build_papila_clinical +from classes.hypertower import UNetImageCropper, ManifestImageCropper + + +# --------------------------- +# Config (edit in IDE) +# --------------------------- +RUN_DIRS = [ + Path("analysis_data/1030_Balanced_Unet_Perimg_Resnet_SE16NormB_SE16NormT_multi_fused/1030_Balanced_Unet_Perimg_Resnet_SE16NormB_SE16NormT_multi_fused_20251030_091842"), + Path("analysis_data/1030_Balanced_GT_Perimg_Resnet_SE16NormB_SE16NormT_multi_fused/1030_Balanced_GT_Perimg_Resnet_SE16NormB_SE16NormT_multi_fused_20251030_113730"), +] +GEOM_CACHE_ROOT = Path("analysis_data/geometry_cache") +SUSPECT_LABEL = 2 + + +def _load_json(path: Path) -> Dict: + if not path.exists(): + return {} + try: + return json.loads(path.read_text()) + except Exception: + return {} + + +def _drop_holdout_rows(clinical, holdout_path: Path) -> None: + if not holdout_path.exists(): + return + holdout = pd.read_csv(holdout_path) + if holdout.empty: + return + if "Patient ID" not in holdout.columns or "eyeID" not in holdout.columns: + return + holdout_keys = set(zip(holdout["Patient ID"].astype(int), holdout["eyeID"].astype(str))) + df = clinical.df.copy() + df["_key"] = list(zip(df["Patient ID"].astype(int), df["eyeID"].astype(str))) + df = df[~df["_key"].isin(holdout_keys)].drop(columns=["_key"]).reset_index(drop=True) + + clinical.frames = [df.copy()] + clinical.df = df.copy() + clinical._infer_or_validate_feature_types() + clinical._compute_numeric_stats() + clinical._build_cat_maps() + clinical._compute_feature_dim() + clinical._build_kfold_indices() + + +def _make_cropper(args: Dict, cache_dir: Path): + manifest = args.get("img_crop_manifest") + if not manifest: + raise RuntimeError("img_crop_manifest missing; cannot compute geometry features.") + + scale = float(args.get("img_crop_scale", 2.5)) + target_size = int(args.get("img_crop_size", 224)) + use_gt = bool(args.get("img_crop_gt", False)) + + if use_gt: + return ManifestImageCropper( + manifest_path=Path(manifest), + scale=scale, + target_size=target_size, + cache_dir=cache_dir, + ) + + weights = args.get("img_crop_weights") + if not weights: + raise RuntimeError("img_crop_weights missing for UNet cropper.") + + normalize = args.get("img_crop_normalize", "per_image") + threshold = float(args.get("img_crop_threshold", 0.5)) + tta = bool(args.get("img_crop_tta", False)) + return UNetImageCropper( + manifest_path=Path(manifest), + weights_path=Path(weights), + normalize=normalize, + threshold=threshold, + tta=tta, + scale=scale, + target_size=target_size, + cache_dir=cache_dir, + ) + + +def _geometry_scores( + clinical, + cropper, + test_df: pd.DataFrame, +) -> Tuple[np.ndarray, np.ndarray]: + scores: List[float] = [] + keep_mask: List[bool] = [] + for _, row in test_df.iterrows(): + img_path = clinical.get_image_path(row) + try: + image = Image.open(img_path).convert("RGB") + except Exception: + scores.append(float("nan")) + keep_mask.append(False) + continue + feats = cropper.geometry_features(image, img_path) + if feats is None or len(feats) == 0: + scores.append(float("nan")) + keep_mask.append(False) + else: + scores.append(float(feats[0])) # area_ratio (CDR) + keep_mask.append(True) + return np.asarray(scores, dtype=float), np.asarray(keep_mask, dtype=bool) + + +def _suspect_auc(y_true: np.ndarray, scores: np.ndarray) -> float: + y = (y_true == SUSPECT_LABEL).astype(int) + if y.sum() == 0 or y.sum() == len(y): + return float("nan") + return float(roc_auc_score(y, scores)) + + +def main() -> None: + rows: List[Dict[str, object]] = [] + + for run_dir in RUN_DIRS: + cli_path = run_dir / "cli_args.json" + cli_args = _load_json(cli_path) + if not cli_args: + print(f"[warn] Missing cli_args.json in {run_dir}") + continue + + label_col = cli_args.get("label_col", "Diagnosis") + cat_cols = cli_args.get("cat_cols", ["Gender", "Phakic/Pseudophakic"]) + n_splits = int(cli_args.get("n_splits", 5)) + fold_seed = int(cli_args.get("fold_seed", 42)) + eval_mode = str(cli_args.get("eval_mode", "multiclass")).lower() + + clinical = build_papila_clinical( + image_dir=cli_args.get("image_dir", "Papila/FundusImages"), + clinical_dir=cli_args.get("clinical_dir", "Papila/ClinicalData"), + label_col=label_col, + cat_cols=cat_cols, + n_splits=n_splits, + random_seed=fold_seed, + ) + + if eval_mode == "binary": + clinical.df = clinical.df[clinical.df[label_col].isin([0, 1])].reset_index(drop=True) + clinical.frames = [clinical.df.copy()] + clinical._infer_or_validate_feature_types() + clinical._compute_numeric_stats() + clinical._build_cat_maps() + clinical._compute_feature_dim() + clinical._build_kfold_indices() + + _drop_holdout_rows(clinical, run_dir / "holdout.csv") + + cache_dir = GEOM_CACHE_ROOT / run_dir.name + cache_dir.mkdir(parents=True, exist_ok=True) + cropper = _make_cropper(cli_args, cache_dir=cache_dir) + + all_geom_scores: List[float] = [] + all_img_scores: List[float] = [] + all_y: List[int] = [] + + for fold in range(n_splits): + y_path = run_dir / f"fold{fold}_y_true.npy" + p_img_path = run_dir / f"fold{fold}_probs_img.npy" + if not y_path.exists() or not p_img_path.exists(): + continue + + y_true = np.load(y_path) + probs_img = np.load(p_img_path) + if probs_img.ndim != 2 or probs_img.shape[1] <= SUSPECT_LABEL: + continue + + _, test_df = clinical.get_split_dfs(fold) + if len(test_df) != len(y_true): + print( + f"[warn] {run_dir.name} fold{fold}: test_df len {len(test_df)} != y_true len {len(y_true)}" + ) + + geom_scores, keep_mask = _geometry_scores(clinical, cropper, test_df) + if keep_mask.sum() == 0: + print(f"[warn] {run_dir.name} fold{fold}: no valid geometry features") + continue + + y_fold = y_true[: len(geom_scores)][keep_mask] + geom_fold = geom_scores[keep_mask] + img_fold = probs_img[: len(geom_scores), SUSPECT_LABEL][keep_mask] + + geom_auc = _suspect_auc(y_fold, geom_fold) + img_auc = _suspect_auc(y_fold, img_fold) + + rows.append( + { + "run": run_dir.name, + "fold": fold, + "metric": "suspect_auc", + "image_auc": img_auc, + "geometry_auc": geom_auc, + "n": int(len(y_fold)), + } + ) + + all_geom_scores.append(geom_fold) + all_img_scores.append(img_fold) + all_y.append(y_fold) + + if all_y: + y_all = np.concatenate(all_y) + geom_all = np.concatenate(all_geom_scores) + img_all = np.concatenate(all_img_scores) + rows.append( + { + "run": run_dir.name, + "fold": "all", + "metric": "suspect_auc", + "image_auc": _suspect_auc(y_all, img_all), + "geometry_auc": _suspect_auc(y_all, geom_all), + "n": int(len(y_all)), + } + ) + + if not rows: + raise SystemExit("No results produced; check run paths and files.") + + df = pd.DataFrame(rows) + out_path = GEOM_CACHE_ROOT / "suspect_auc_geometry_vs_image.csv" + out_path.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(out_path, index=False) + print(df.to_string(index=False, float_format=lambda x: f"{x:.4f}")) + print(f"\nSaved: {out_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/grid_search_analytics/derived_analysis.py b/scripts/grid_search_analytics/derived_analysis.py new file mode 100755 index 0000000..0c63505 --- /dev/null +++ b/scripts/grid_search_analytics/derived_analysis.py @@ -0,0 +1,1826 @@ +#!/usr/bin/env python3 +"""Derived analysis wrapper for grid search analytics.""" +from __future__ import annotations + +import json +import math +import re +from pathlib import Path +from typing import Dict, Iterable, Iterator, List, Optional + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +from scipy import stats +from tqdm import tqdm + + +class derived_analysis: + def __init__( + self, + analysis_dir: Path | str, + exclude_keys: Optional[Iterable[str]] = None, + classification_mode: str = "binary", + ) -> None: + self.analysis_dir = Path(analysis_dir) + if not self.analysis_dir.exists(): + raise FileNotFoundError(f"analysis_dir does not exist: {self.analysis_dir}") + + mode = str(classification_mode).strip().lower() + if mode not in {"binary", "multiclass"}: + raise ValueError( + f"classification_mode must be 'binary' or 'multiclass' (got {classification_mode!r})" + ) + self.classification_mode = mode + self.exclude_keys = set(exclude_keys or []) + + self.fusion_corrections = pd.DataFrame() + self.fusion_errors = pd.DataFrame() + self.statistics_df = pd.DataFrame() + self.primary_metrics = pd.DataFrame() + self.fusion_performance_corr = pd.DataFrame() + self.param_perf_corr = pd.DataFrame() + self.se_mode_effects = {} + + def identify_fusion_corrections( + self, shallow: bool = True, existing: bool = True + ) -> pd.DataFrame: + cache_path = self._fusion_corrections_path() + if existing and cache_path.exists(): + df = self._read_fusion_corrections(cache_path) + self.fusion_corrections = df + errors_path = self._fusion_errors_path() + if errors_path.exists(): + self.fusion_errors = self._read_fusion_errors(errors_path) + else: + self.fusion_errors = pd.DataFrame() + self.statistics_df = self._build_statistics_df(df, shallow=shallow) + return self.fusion_corrections + + rows: List[Dict[str, object]] = [] + error_rows: List[Dict[str, object]] = [] + stats_rows: List[Dict[str, object]] = [] + + for run_dir in self._iter_run_dirs(shallow=shallow, show_progress=True): + summary = self._read_summary(run_dir) + cli = self._read_cli_args(run_dir) + mode = self._infer_mode(summary, cli) + if mode != self.classification_mode: + continue + + run_id = self._read_run_id(run_dir, summary) + folds = self._available_folds(run_dir, summary) + run_count = 0 + grid_params = self._grid_params_from_cli(cli, summary) + + for fold in folds: + y_true = self._load_y_true(run_dir, fold) + if y_true is None: + continue + epoch_prob_paths = self._collect_epoch_prob_paths(run_dir, fold) + if not epoch_prob_paths: + base_paths = self._collect_base_prob_paths(run_dir, fold) + if base_paths: + epoch_hint = self._fold_epoch_hint(summary, fold) + epoch_prob_paths = { + epoch_hint if epoch_hint is not None else 0: base_paths + } + + for epoch, paths in epoch_prob_paths.items(): + arrays = { + head: self._load_probs_array(path) + for head, path in paths.items() + } + if not self._has_all_heads(arrays): + continue + events = self._fusion_corrections_for_probs( + y_true, arrays, run_id, fold, epoch + ) + run_count += len(events) + rows.extend(events) + errors = self._fusion_errors_for_probs( + y_true, arrays, run_id, fold, epoch + ) + error_rows.extend(errors) + + stats_rows.append( + { + "run_id": run_id, + "run_dir": str(run_dir), + "classification_mode": mode, + "fusion_corrections": int(run_count), + **grid_params, + } + ) + + self.fusion_corrections = pd.DataFrame(rows) + self.fusion_errors = pd.DataFrame(error_rows) + self.statistics_df = pd.DataFrame(stats_rows) + return self.fusion_corrections + + def write_fusion_corrections(self, output_path: Path | str | None = None) -> Path: + if self.fusion_corrections.empty: + self.identify_fusion_corrections(existing=True) + path = Path(output_path) if output_path else self._fusion_corrections_path() + path.parent.mkdir(parents=True, exist_ok=True) + self.fusion_corrections.to_csv(path, index=False) + return path + + def write_fusion_errors(self, output_path: Path | str | None = None) -> Path: + if self.fusion_errors.empty: + self.identify_fusion_corrections(existing=True) + path = Path(output_path) if output_path else self._fusion_errors_path() + path.parent.mkdir(parents=True, exist_ok=True) + self.fusion_errors.to_csv(path, index=False) + return path + + def populate_primary_metrics( + self, shallow: bool = True, show_progress: bool = True, existing: bool = True + ) -> pd.DataFrame: + cache_path = self._primary_metrics_path() + if existing and cache_path.exists(): + df = self._read_primary_metrics(cache_path) + self.primary_metrics = df + summary_df = self._aggregate_primary_metrics(df) + if summary_df.empty: + if self.statistics_df.empty: + self.statistics_df = summary_df + else: + if self.statistics_df.empty: + self.statistics_df = summary_df + else: + self.statistics_df = self.statistics_df.merge( + summary_df, + on=["run_id", "run_dir", "classification_mode"], + how="left", + ) + return self.primary_metrics + + rows: List[Dict[str, object]] = [] + for run_dir in self._iter_run_dirs( + shallow=shallow, show_progress=show_progress + ): + summary = self._read_summary(run_dir) + cli = self._read_cli_args(run_dir) + mode = self._infer_mode(summary, cli) + if mode != self.classification_mode: + continue + + run_id = self._read_run_id(run_dir, summary) + folds = self._available_folds(run_dir, summary) + if not folds: + continue + + for fold in folds: + log_df = self._read_epoch_log(run_dir, fold) + best_epoch, holdout_best_epoch = self._extract_best_epochs(log_df) + if best_epoch is None or holdout_best_epoch is None: + continue + + row: Dict[str, object] = { + "run_id": run_id, + "run_dir": str(run_dir), + "classification_mode": mode, + "fold": int(fold), + "best_epoch": int(best_epoch), + "holdout_best_epoch": int(holdout_best_epoch), + } + + for head_key, log_suffix, roc_suffix in ( + ("fused", "fused", "fused"), + ("image", "img", "image"), + ("metadata", "md", "metadata"), + ): + best_auc = self._load_auc_for_epoch( + run_dir, fold, best_epoch, roc_suffix, holdout=False + ) + hold_auc = self._load_auc_for_epoch( + run_dir, fold, holdout_best_epoch, roc_suffix, holdout=True + ) + row[f"best_auc_{head_key}"] = best_auc + row[f"holdout_best_auc_{head_key}"] = hold_auc + + if log_df is not None: + best_row = self._row_for_epoch(log_df, best_epoch) + hold_row = self._row_for_epoch(log_df, holdout_best_epoch) + best_acc = self._metric_from_row(best_row, f"acc_{log_suffix}") + hold_acc = self._metric_from_row( + hold_row, f"holdout_acc_{log_suffix}" + ) + row[f"best_acc_{head_key}"] = best_acc + row[f"holdout_best_acc_{head_key}"] = hold_acc + + rows.append(row) + + self.primary_metrics = pd.DataFrame(rows) + summary_df = self._aggregate_primary_metrics(self.primary_metrics) + if summary_df.empty: + if self.statistics_df.empty: + self.statistics_df = summary_df + else: + if self.statistics_df.empty: + self.statistics_df = summary_df + else: + self.statistics_df = self.statistics_df.merge( + summary_df, + on=["run_id", "run_dir", "classification_mode"], + how="left", + ) + return self.primary_metrics + + def write_primary_metrics(self, output_path: Path | str | None = None) -> Path: + if self.primary_metrics.empty: + self.populate_primary_metrics() + path = Path(output_path) if output_path else self._primary_metrics_path() + path.parent.mkdir(parents=True, exist_ok=True) + self.primary_metrics.to_csv(path, index=False) + return path + + def plot_fusion_corrections_errors( + self, + output_path: Path | str | None = None, + shallow: bool = True, + existing: bool = True, + top_n: int | None = None, + ) -> pd.DataFrame: + if self.fusion_corrections.empty: + self.identify_fusion_corrections(shallow=shallow, existing=existing) + if self.fusion_corrections.empty: + raise RuntimeError( + "fusion_corrections is empty; run identify_fusion_corrections() first." + ) + + corrections = ( + self.fusion_corrections.groupby("run_id") + .size() + .rename("fusion_corrections") + ) + if self.fusion_errors.empty: + errors = corrections.copy() * 0 + errors.name = "fusion_errors" + else: + errors = self.fusion_errors.groupby("run_id").size().rename("fusion_errors") + + df = pd.concat([corrections, errors], axis=1).fillna(0).reset_index() + + df = df.sort_values(by="run_id") + if top_n is not None: + df = df.head(int(top_n)) + + out_path = ( + Path(output_path) + if output_path + else ( + self.analysis_dir + / "plots" + / f"fusion_corrections_errors_{self.classification_mode}.png" + ) + ) + out_path.parent.mkdir(parents=True, exist_ok=True) + + fig, ax = plt.subplots(figsize=(10, 4.8)) + x = np.arange(len(df)) + ax.bar( + x, + df["fusion_corrections"], + color="steelblue", + width=1.0, + label="fusion_corrections", + ) + ax.bar( + x, + df["fusion_errors"], + bottom=df["fusion_corrections"], + color="tomato", + width=1.0, + label="fusion_errors", + ) + ax.set_xticks([]) + ax.set_ylabel("Count") + ax.set_title( + f"Fusion corrections + errors per run ({self.classification_mode})" + ) + ax.legend(loc="upper right") + ax.grid(True, axis="y", alpha=0.3, linestyle="--") + fig.tight_layout() + fig.savefig(out_path, dpi=170) + plt.close(fig) + + return df + + def plot_conf_delta_boxplot( + self, + output_path: Path | str | None = None, + shallow: bool = True, + existing: bool = True, + top_n: int | None = None, + ) -> pd.DataFrame: + if self.fusion_corrections.empty: + self.identify_fusion_corrections(shallow=shallow, existing=existing) + if self.fusion_corrections.empty: + raise RuntimeError( + "fusion_corrections is empty; run identify_fusion_corrections() first." + ) + + df = self.fusion_corrections.copy() + df["conf_delta"] = df["conf_fused"] - 0.5 * (df["conf_img"] + df["conf_md"]) + + mean_order = df.groupby("run_id")["conf_delta"].mean().sort_values() + run_order = mean_order.index.tolist() + if top_n is not None: + run_order = run_order[: int(top_n)] + + data = [ + df.loc[df["run_id"] == run_id, "conf_delta"].values for run_id in run_order + ] + + out_path = ( + Path(output_path) + if output_path + else ( + self.analysis_dir + / "plots" + / f"conf_delta_box_{self.classification_mode}.png" + ) + ) + out_path.parent.mkdir(parents=True, exist_ok=True) + + fig, ax = plt.subplots(figsize=(10, 4.8)) + ax.boxplot(data, widths=0.6, showfliers=False) + ax.set_xticks([]) + ax.set_ylabel("conf_delta (fused - mean(towers))") + ax.set_title(f"Confidence delta per run ({self.classification_mode})") + ax.grid(True, axis="y", alpha=0.3, linestyle="--") + fig.tight_layout() + fig.savefig(out_path, dpi=170) + plt.close(fig) + + return df + + def param_performance_correlations( + self, + output_path: Path | str | None = None, + shallow: bool = True, + existing: bool = True, + method: str = "spearman", + cat_method: str = "kruskal", + ) -> pd.DataFrame: + if self.statistics_df.empty: + self.identify_fusion_corrections(shallow=shallow, existing=existing) + if self.primary_metrics.empty: + self.populate_primary_metrics(shallow=shallow, existing=existing) + + df = self.statistics_df.copy() + if df.empty: + raise RuntimeError( + "statistics_df is empty; run identify_fusion_corrections() first." + ) + + if self.fusion_corrections.empty: + self.identify_fusion_corrections(shallow=shallow, existing=existing) + + conf_delta = None + if not self.fusion_corrections.empty: + fc = self.fusion_corrections.copy() + fc["conf_delta"] = fc["conf_fused"] - 0.5 * (fc["conf_img"] + fc["conf_md"]) + conf_delta = ( + fc.groupby("run_id")["conf_delta"].mean().rename("conf_delta_mean") + ) + df = df.merge(conf_delta.reset_index(), on="run_id", how="left") + + if self.fusion_errors.empty: + errors_path = self._fusion_errors_path() + if errors_path.exists(): + self.fusion_errors = self._read_fusion_errors(errors_path) + if not self.fusion_errors.empty and "fusion_corrections" in df.columns: + err_counts = ( + self.fusion_errors.groupby("run_id").size().rename("fusion_errors") + ) + df = df.merge(err_counts.reset_index(), on="run_id", how="left") + df["fusion_errors"] = df["fusion_errors"].fillna(0) + eps = 1e-6 + df["error_correction_ratio"] = (df["fusion_errors"] + eps) / ( + df["fusion_corrections"] + eps + ) + + metric_cols = [] + for cand in ("holdout_best_acc_fused_mean", "best_acc_fused_mean"): + if cand in df.columns: + metric_cols.append(("acc", cand)) + break + for cand in ("holdout_best_auc_fused_mean", "best_auc_fused_mean"): + if cand in df.columns: + metric_cols.append(("auc", cand)) + break + if "fusion_corrections" in df.columns: + metric_cols.append(("fusion_corrections", "fusion_corrections")) + if conf_delta is not None and "conf_delta_mean" in df.columns: + metric_cols.append(("conf_delta", "conf_delta_mean")) + if "error_correction_ratio" in df.columns: + metric_cols.append(("error_correction_ratio", "error_correction_ratio")) + + if not metric_cols: + raise RuntimeError("No metrics found in statistics_df for correlation.") + + grid_param_keys = [ + "crop_variant", + "crop_normalize", + "crop_weights", + "crop_tta", + "loss_mode", + "thaw_mode", + "se_mode", + "se_bridge_pre_norm", + "se_tower_pre_norm", + ] + param_cols = [c for c in grid_param_keys if c in df.columns] + + def _to_float(v): + if v is None: + return None + if isinstance(v, bool): + return None + if isinstance(v, (int, float)) and not math.isnan(float(v)): + return float(v) + try: + return float(v) + except Exception: + return None + + def _format_value(v: object) -> str: + if v is None: + return "" + if isinstance(v, bool): + return "true" if v else "false" + if isinstance(v, int): + return str(v) + if isinstance(v, float): + return f"{v:.6g}" + return str(v) + + def _rankdata(vals: List[float]) -> List[float]: + order = sorted(range(len(vals)), key=lambda i: vals[i]) + ranks = [0.0] * len(vals) + i = 0 + while i < len(vals): + j = i + while j + 1 < len(vals) and vals[order[j + 1]] == vals[order[i]]: + j += 1 + avg_rank = (i + j) / 2.0 + 1.0 + for k in range(i, j + 1): + ranks[order[k]] = avg_rank + i = j + 1 + return ranks + + def _pearson(x: List[float], y: List[float]) -> Optional[float]: + if len(x) < 2: + return None + mx = sum(x) / len(x) + my = sum(y) / len(y) + num = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y)) + denx = sum((xi - mx) ** 2 for xi in x) + deny = sum((yi - my) ** 2 for yi in y) + if denx <= 0 or deny <= 0: + return None + return num / math.sqrt(denx * deny) + + def _spearman(x: List[float], y: List[float]) -> Optional[float]: + return _pearson(_rankdata(x), _rankdata(y)) + + def _eta(categories: List[object], values: List[float]) -> Optional[float]: + if len(values) < 2: + return None + overall = sum(values) / len(values) + total = sum((v - overall) ** 2 for v in values) + if total <= 0: + return None + groups = {} + for cat, val in zip(categories, values): + groups.setdefault(cat, []).append(val) + between = 0.0 + for vals in groups.values(): + avg = sum(vals) / len(vals) + between += len(vals) * (avg - overall) ** 2 + return math.sqrt(between / total) + + cat_method_norm = cat_method.strip().lower() + if cat_method_norm not in {"eta", "anova", "kruskal"}: + raise ValueError( + f"cat_method must be 'eta', 'anova', or 'kruskal' (got {cat_method!r})" + ) + + rows: List[Dict[str, object]] = [] + for name, metric_col in metric_cols: + metric_vals = df[metric_col] + for param in param_cols: + param_vals = df[param] + pairs = [ + (p, m) + for p, m in zip(param_vals, metric_vals) + if m is not None and not (isinstance(m, float) and math.isnan(m)) + ] + if len(pairs) < 3: + continue + p_vals, m_vals = zip(*pairs) + group_means: Dict[object, float] = {} + for p, m in pairs: + group_means.setdefault(p, []).append(m) + group_means = { + k: float(sum(v) / len(v)) for k, v in group_means.items() + } + if name == "error_correction_ratio": + best_value = min(group_means.items(), key=lambda item: item[1])[0] + else: + best_value = max(group_means.items(), key=lambda item: item[1])[0] + num_vals = [] + numeric_ok = True + for v in p_vals: + num = _to_float(v) + if num is None: + numeric_ok = False + break + num_vals.append(num) + if numeric_ok and len(set(num_vals)) >= 3: + p_val = None + if method == "spearman": + try: + corr, p_val = stats.spearmanr(num_vals, list(m_vals)) + except Exception: + corr = None + else: + try: + corr, p_val = stats.pearsonr(num_vals, list(m_vals)) + except Exception: + corr = None + if corr is not None and corr != corr: + corr = None + rows.append( + { + "metric": name, + "metric_col": metric_col, + "param": param, + "type": "numeric", + "n": len(pairs), + "corr": corr, + "stat": corr, + "p_value": p_val, + "method": method, + "best": _format_value(best_value), + } + ) + else: + stat_val = None + p_val = None + corr = None + groups: Dict[object, List[float]] = {} + for p, m in pairs: + groups.setdefault(p, []).append(m) + group_vals = [vals for vals in groups.values() if len(vals) > 0] + + if cat_method_norm == "eta": + corr = _eta(list(p_vals), list(m_vals)) + stat_val = corr + elif cat_method_norm == "anova": + if len(group_vals) >= 2: + try: + stat_val, p_val = stats.f_oneway(*group_vals) + corr = stat_val + except Exception: + stat_val = None + elif cat_method_norm == "kruskal": + if len(group_vals) >= 2: + try: + stat_val, p_val = stats.kruskal(*group_vals) + corr = stat_val + except Exception: + stat_val = None + + rows.append( + { + "metric": name, + "metric_col": metric_col, + "param": param, + "type": "categorical", + "n": len(pairs), + "corr": corr, + "stat": stat_val, + "p_value": p_val, + "method": cat_method_norm, + "best": _format_value(best_value), + } + ) + + out_df = pd.DataFrame(rows).sort_values( + by=["metric", "corr"], ascending=[True, False] + ) + self.param_perf_corr = out_df + out_path = ( + Path(output_path) + if output_path + else ( + self.analysis_dir + / "plots" + / f"param_perf_corr_{self.classification_mode}.csv" + ) + ) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_df.to_csv(out_path, index=False) + return out_df + + def plot_param_perf_corr_panels( + self, + corr_df: pd.DataFrame, + output_path: Path | str | None = None, + ) -> pd.DataFrame: + if corr_df.empty: + raise RuntimeError( + "corr_df is empty; run param_performance_correlations() first." + ) + + metrics = ["auc", "acc", "fusion_corrections", "conf_delta"] + auc_df = corr_df[corr_df["metric"] == "auc"].copy() + if auc_df.empty: + raise RuntimeError("No 'auc' metric rows found in corr_df.") + + auc_df = auc_df.sort_values(by="corr", ascending=False) + order = auc_df["param"].tolist() + + out_path = ( + Path(output_path) + if output_path + else ( + self.analysis_dir + / "plots" + / f"param_perf_corr_panels_{self.classification_mode}.png" + ) + ) + out_path.parent.mkdir(parents=True, exist_ok=True) + + fig, axes = plt.subplots(2, 2, figsize=(12, 8), sharey=True) + axes = axes.flatten() + + for idx, metric in enumerate(metrics): + ax = axes[idx] + sub = corr_df[corr_df["metric"] == metric].set_index("param") + sub = sub.reindex(order) + values = sub["corr"].astype(float).values + y = np.arange(len(order)) + ax.barh(y, values, color="steelblue") + ax.axvline(0.0, color="black", lw=1) + ax.set_title(metric) + ax.set_yticks(y) + ax.set_yticklabels(order, fontsize=7) + ax.grid(True, axis="x", alpha=0.3, linestyle="--") + + # annotate p-values when available + for i, param in enumerate(order): + if param not in sub.index: + continue + p_val = sub.loc[param, "p_value"] + if p_val is None or (isinstance(p_val, float) and np.isnan(p_val)): + continue + ax.text( + values[i] if not np.isnan(values[i]) else 0.0, + i, + f" p={p_val:.3g}", + va="center", + ha="left" if values[i] >= 0 else "right", + fontsize=7, + ) + + # print p-values to console for each metric + print(f"\n[{metric}] p-values") + for param in order: + if param not in sub.index: + continue + p_val = sub.loc[param, "p_value"] + if p_val is None or (isinstance(p_val, float) and np.isnan(p_val)): + continue + print(f" {param}: p={p_val:.4g}") + + fig.suptitle("Parameter correlations (ordered by AUC correlation)", fontsize=12) + fig.tight_layout(rect=[0, 0.02, 1, 0.96]) + fig.savefig(out_path, dpi=170) + plt.close(fig) + + return corr_df + + def se_mode_effects_summary( + self, + metric: str = "auc", + metric_col: str | None = None, + head: str = "fused", + prefer_holdout: bool = True, + top_n: int | None = None, + top_metric_col: str | None = None, + output_dir: Path | str | None = None, + pairwise_method: str = "mannwhitney", + shallow: bool = True, + existing: bool = True, + ) -> Dict[str, pd.DataFrame]: + if self.statistics_df.empty: + self.identify_fusion_corrections(shallow=shallow, existing=existing) + if self.primary_metrics.empty: + self.populate_primary_metrics(shallow=shallow, existing=existing) + + df = self.statistics_df.copy() + if df.empty: + raise RuntimeError( + "statistics_df is empty; run identify_fusion_corrections() first." + ) + + metric_norm = metric.strip().lower() + if metric_norm not in {"acc", "auc"}: + raise ValueError(f"metric must be 'acc' or 'auc' (got {metric!r})") + + if metric_col is None: + candidates = [] + if prefer_holdout: + candidates.append(f"holdout_best_{metric_norm}_{head}_mean") + candidates.append(f"best_{metric_norm}_{head}_mean") + else: + candidates.append(f"best_{metric_norm}_{head}_mean") + candidates.append(f"holdout_best_{metric_norm}_{head}_mean") + for cand in candidates: + if cand in df.columns: + metric_col = cand + break + + if metric_col is None or metric_col not in df.columns: + raise RuntimeError( + "Could not find a metric column to summarize; run populate_primary_metrics() " + "or pass metric_col explicitly." + ) + + if "se_mode" not in df.columns: + raise RuntimeError("statistics_df is missing se_mode column.") + + use_cols = ["run_id", "se_mode", metric_col] + if "se_bridge_pre_norm" in df.columns: + use_cols.append("se_bridge_pre_norm") + if "se_tower_pre_norm" in df.columns: + use_cols.append("se_tower_pre_norm") + + df = df[use_cols].copy() + df = df.dropna(subset=[metric_col, "se_mode"]) + if df.empty: + raise RuntimeError( + "No rows available after filtering for se_mode and metric." + ) + + if top_n is not None: + top_metric = top_metric_col or metric_col + if top_metric not in df.columns: + raise RuntimeError( + f"top_metric_col {top_metric!r} not found in statistics_df." + ) + df = df.sort_values(by=top_metric, ascending=False).head(int(top_n)) + if df.empty: + raise RuntimeError("No rows available after applying top_n filter.") + + summary = ( + df.groupby("se_mode")[metric_col] + .agg(["count", "mean", "median", "std"]) + .reset_index() + .rename(columns={"count": "n"}) + ) + + # Pairwise comparisons + pairwise_rows: List[Dict[str, object]] = [] + modes = summary["se_mode"].tolist() + pairwise_method_norm = pairwise_method.strip().lower() + if pairwise_method_norm not in {"mannwhitney", "ttest"}: + raise ValueError( + f"pairwise_method must be 'mannwhitney' or 'ttest' (got {pairwise_method!r})" + ) + + def _cohens_d(a: np.ndarray, b: np.ndarray) -> float: + if len(a) < 2 or len(b) < 2: + return float("nan") + va = np.var(a, ddof=1) + vb = np.var(b, ddof=1) + pooled = ((len(a) - 1) * va + (len(b) - 1) * vb) / max( + len(a) + len(b) - 2, 1 + ) + if pooled <= 0: + return float("nan") + return (np.mean(a) - np.mean(b)) / math.sqrt(pooled) + + for i, m1 in enumerate(modes): + vals1 = df.loc[df["se_mode"] == m1, metric_col].astype(float).values + if vals1.size == 0: + continue + for m2 in modes[i + 1 :]: + vals2 = df.loc[df["se_mode"] == m2, metric_col].astype(float).values + if vals2.size == 0: + continue + p_val = None + stat_val = None + if pairwise_method_norm == "mannwhitney": + try: + stat_val, p_val = stats.mannwhitneyu( + vals1, vals2, alternative="two-sided" + ) + except Exception: + stat_val, p_val = None, None + else: + try: + stat_val, p_val = stats.ttest_ind(vals1, vals2, equal_var=False) + except Exception: + stat_val, p_val = None, None + + pairwise_rows.append( + { + "metric_col": metric_col, + "se_mode_a": m1, + "se_mode_b": m2, + "n_a": int(vals1.size), + "n_b": int(vals2.size), + "mean_a": float(np.mean(vals1)), + "mean_b": float(np.mean(vals2)), + "mean_diff": float(np.mean(vals1) - np.mean(vals2)), + "median_a": float(np.median(vals1)), + "median_b": float(np.median(vals2)), + "median_diff": float(np.median(vals1) - np.median(vals2)), + "cohens_d": _cohens_d(vals1, vals2), + "stat": stat_val, + "p_value": p_val, + "method": pairwise_method_norm, + } + ) + + pairwise_df = pd.DataFrame(pairwise_rows) + + # Stratified by pre-norm options (within relevant se_mode) + bridge_df = pd.DataFrame() + if "se_bridge_pre_norm" in df.columns: + bridge_df = ( + df[df["se_mode"].isin(["bridge", "both"])] + .groupby(["se_mode", "se_bridge_pre_norm"])[metric_col] + .agg(["count", "mean", "median", "std"]) + .reset_index() + .rename(columns={"count": "n"}) + ) + tower_df = pd.DataFrame() + if "se_tower_pre_norm" in df.columns: + tower_df = ( + df[df["se_mode"].isin(["tower", "both"])] + .groupby(["se_mode", "se_tower_pre_norm"])[metric_col] + .agg(["count", "mean", "median", "std"]) + .reset_index() + .rename(columns={"count": "n"}) + ) + + result = { + "summary": summary, + "pairwise": pairwise_df, + "bridge_pre_norm": bridge_df, + "tower_pre_norm": tower_df, + } + self.se_mode_effects = result + + if output_dir is not None: + out_dir = Path(output_dir) + else: + out_dir = self.analysis_dir / "plots" + out_dir.mkdir(parents=True, exist_ok=True) + summary.to_csv(out_dir / f"se_mode_summary_{metric_col}.csv", index=False) + if not pairwise_df.empty: + pairwise_df.to_csv( + out_dir / f"se_mode_pairwise_{metric_col}.csv", index=False + ) + if not bridge_df.empty: + bridge_df.to_csv( + out_dir / f"se_mode_bridge_pre_norm_{metric_col}.csv", index=False + ) + if not tower_df.empty: + tower_df.to_csv( + out_dir / f"se_mode_tower_pre_norm_{metric_col}.csv", index=False + ) + + return result + + def fusion_corrections_correlation( + self, + output_path: Path | str | None = None, + method: str = "pearson", + metric_type: str = "acc", + ) -> pd.DataFrame: + if self.fusion_corrections.empty: + raise RuntimeError( + "fusion_corrections is empty; run identify_fusion_corrections() first." + ) + if self.primary_metrics.empty: + raise RuntimeError( + "primary_metrics is empty; run populate_primary_metrics() first." + ) + if "fold" not in self.primary_metrics.columns: + raise RuntimeError( + "primary_metrics missing fold column; refresh populate_primary_metrics()." + ) + + method_norm = method.strip().lower() + if method_norm not in {"pearson", "spearman"}: + raise ValueError(f"method must be 'pearson' or 'spearman' (got {method!r})") + + metric_norm = metric_type.strip().lower() + if metric_norm not in {"acc", "auc"}: + raise ValueError( + f"metric_type must be 'acc' or 'auc' (got {metric_type!r})" + ) + + metric_cols = [ + c + for c in self.primary_metrics.columns + if c.startswith(("best_", "holdout_best_")) + and f"_{metric_norm}_" in c + and not c.endswith(("_mean", "_sd")) + ] + if not metric_cols: + raise RuntimeError( + "No primary metric columns found in primary_metrics; run populate_primary_metrics() first." + ) + + fold_counts = self._fold_sample_and_opportunity_counts(self.primary_metrics) + + best_epochs = self.primary_metrics[ + ["run_id", "run_dir", "fold", "best_epoch"] + ].dropna() + warmup_map = self._build_warmup_map(best_epochs) + best_epochs = best_epochs.merge(warmup_map, on="run_id", how="left") + best_epochs["warmup_end"] = best_epochs["warmup_end"].fillna(0).astype(int) + best_epochs["best_epoch"] = best_epochs["best_epoch"].astype(int) + + events = self.fusion_corrections.merge( + best_epochs[["run_id", "fold", "best_epoch", "warmup_end"]], + on=["run_id", "fold"], + how="inner", + ) + if "epoch" in events.columns: + events = events[ + (events["epoch"] >= events["warmup_end"]) + & (events["epoch"] <= events["best_epoch"]) + ] + + counts = ( + events.groupby(["run_id", "fold"], as_index=False) + .size() + .rename(columns={"size": "fusion_corrections"}) + ) + merged = self.primary_metrics.merge(counts, on=["run_id", "fold"], how="left") + merged = merged.merge(fold_counts, on=["run_id", "run_dir", "fold"], how="left") + merged["fusion_corrections"] = merged["fusion_corrections"].fillna(0) + merged["n_samples"] = merged["n_samples"].replace(0, np.nan) + merged["both_wrong"] = merged["both_wrong"].replace(0, np.nan) + merged["fusion_corrections_rate"] = ( + merged["fusion_corrections"] / merged["n_samples"] + ) + merged["fusion_corrections_per_opportunity"] = ( + merged["fusion_corrections"] / merged["both_wrong"] + ) + + merged = self._add_fusion_gain_columns(merged) + gain_cols = [ + c + for c in merged.columns + if c.endswith("_fusion_gain") + and c.startswith(("best_", "holdout_best_")) + and f"_{metric_norm}_" in c + ] + + error_counts = self._fusion_errors_counts(merged) + merged = merged.merge(error_counts, on=["run_id", "fold"], how="left") + merged["fusion_errors"] = merged["fusion_errors"].fillna(0) + eps = 1e-6 + merged["correction_error_rate"] = (merged["fusion_corrections"] + eps) / ( + merged["fusion_errors"] + eps + ) + + rows = [] + x_metrics = [ + "fusion_corrections", + "fusion_corrections_rate", + "fusion_corrections_per_opportunity", + "fusion_errors", + "correction_error_rate", + ] + all_metrics = metric_cols + gain_cols + for x in x_metrics: + if x not in merged.columns: + continue + for col in all_metrics: + sub = merged[[x, col]].dropna() + if len(sub) < 2: + corr = np.nan + else: + corr = float(sub[x].corr(sub[col], method=method_norm)) + rows.append( + { + "x_metric": x, + "metric": col, + "corr": corr, + "n": int(len(sub)), + "metric_type": metric_norm, + } + ) + + out_df = pd.DataFrame(rows).sort_values( + by=["x_metric", "corr"], ascending=[True, False] + ) + self.fusion_performance_corr = out_df + + plot_x = "fusion_corrections_per_opportunity" + if plot_x not in out_df["x_metric"].unique(): + plot_x = "fusion_corrections" + plot_df = out_df[out_df["x_metric"] == plot_x] + + path = ( + Path(output_path) + if output_path + else self._fusion_performance_corr_path(method_norm, metric_norm) + ) + path.parent.mkdir(parents=True, exist_ok=True) + self._plot_correlation_bars( + plot_df, path, method=method_norm, x_metric=plot_x, metric_type=metric_norm + ) + return out_df + + def plot_fusion_perf_summary( + self, + corr_acc: pd.DataFrame, + corr_auc: pd.DataFrame, + output_path: Path | str | None = None, + method: str = "spearman", + x_metric: str = "fusion_corrections", + ) -> pd.DataFrame: + keep_templates = [ + "best_acc_fused", + "holdout_best_acc_fused", + "best_acc_fusion_gain", + "holdout_best_acc_fusion_gain", + "best_auc_fused", + "holdout_best_auc_fused", + "best_auc_fusion_gain", + "holdout_best_auc_fusion_gain", + ] + + def _select(df: pd.DataFrame) -> pd.DataFrame: + if df.empty: + return df + sub = df[df["x_metric"] == x_metric].copy() + sub = sub[sub["metric"].isin(keep_templates)] + sub = sub.drop_duplicates(subset=["metric"]) + sub["metric"] = sub["metric"].str.replace("_fused", "", regex=False) + return sub + + acc_df = _select(corr_acc) + auc_df = _select(corr_auc) + merged = pd.concat([acc_df, auc_df], axis=0, ignore_index=True) + if merged.empty: + raise RuntimeError("No matching rows found in corr_acc/corr_auc.") + + merged = ( + merged.set_index("metric") + .loc[[m.replace("_fused", "") for m in keep_templates]] + .reset_index() + ) + + safe_x = x_metric.replace("fusion_", "") + out_path = ( + Path(output_path) + if output_path + else ( + self.analysis_dir + / "plots" + / f"fusion_{safe_x}_vs_performance_{method}.png" + ) + ) + out_path.parent.mkdir(parents=True, exist_ok=True) + self._plot_correlation_bars( + merged, + out_path, + method=method, + x_metric=x_metric, + metric_type="acc/auc", + title=f"Fusion corrections vs performance ({method})", + ) + return merged + + def _iter_run_dirs( + self, shallow: bool = True, show_progress: bool = False + ) -> Iterator[Path]: + if shallow: + candidates = [p for p in sorted(self.analysis_dir.iterdir()) if p.is_dir()] + else: + candidates = [p for p in self.analysis_dir.rglob("*") if p.is_dir()] + + iterator = ( + tqdm(candidates, desc="Scanning runs", unit="run", leave=False) + if show_progress + else candidates + ) + for run_dir in iterator: + summary = run_dir / "summary.json" + cli = run_dir / "cli_args.json" + if summary.exists() or cli.exists(): + yield run_dir + + def _fusion_corrections_path(self) -> Path: + fname = f"fusion_corrections_{self.classification_mode}.csv" + return self.analysis_dir / fname + + def _fusion_errors_path(self) -> Path: + fname = f"fusion_errors_{self.classification_mode}.csv" + return self.analysis_dir / fname + + @staticmethod + def _read_fusion_errors(path: Path) -> pd.DataFrame: + try: + df = pd.read_csv(path) + except Exception: + return pd.DataFrame() + return df + + def _primary_metrics_path(self) -> Path: + fname = f"primary_metrics_{self.classification_mode}.csv" + return self.analysis_dir / fname + + def _fusion_performance_corr_path(self, method: str, metric_type: str) -> Path: + fname = f"fusion_performance_corr_{self.classification_mode}_{metric_type}_{method}.png" + return self.analysis_dir / fname + + @staticmethod + def _read_primary_metrics(path: Path) -> pd.DataFrame: + try: + return pd.read_csv(path) + except Exception: + return pd.DataFrame() + + @staticmethod + def _read_fusion_corrections(path: Path) -> pd.DataFrame: + try: + df = pd.read_csv(path) + except Exception: + return pd.DataFrame() + return df + + def _build_statistics_df( + self, df: pd.DataFrame, shallow: bool = True + ) -> pd.DataFrame: + counts = {} + if not df.empty and "run_id" in df.columns: + counts = df.groupby("run_id").size().to_dict() + + rows: List[Dict[str, object]] = [] + for run_dir in self._iter_run_dirs(shallow=shallow, show_progress=False): + summary = self._read_summary(run_dir) + cli = self._read_cli_args(run_dir) + mode = self._infer_mode(summary, cli) + if mode != self.classification_mode: + continue + run_id = self._read_run_id(run_dir, summary) + grid_params = self._grid_params_from_cli(cli, summary) + rows.append( + { + "run_id": run_id, + "run_dir": str(run_dir), + "classification_mode": mode, + "fusion_corrections": int(counts.get(run_id, 0)), + **grid_params, + } + ) + + return pd.DataFrame(rows) + + @staticmethod + def _aggregate_primary_metrics(df: pd.DataFrame) -> pd.DataFrame: + if df.empty: + return pd.DataFrame() + required = ["run_id", "run_dir", "classification_mode"] + if not all(col in df.columns for col in required): + return pd.DataFrame() + metric_cols = [ + c + for c in df.columns + if c.startswith(("best_", "holdout_best_")) + and not c.endswith(("_mean", "_sd")) + ] + if not metric_cols: + return pd.DataFrame() + + grouped = df.groupby(required) + agg = grouped[metric_cols].agg(["mean", "std"]) + agg.columns = [ + f"{col}_{stat}".replace("std", "sd") for col, stat in agg.columns + ] + return agg.reset_index() + + def _fold_sample_and_opportunity_counts(self, df: pd.DataFrame) -> pd.DataFrame: + if df.empty: + return pd.DataFrame() + cols = ["run_id", "run_dir", "fold"] + rows = [] + for run_id, run_dir, fold in df[cols].drop_duplicates().itertuples(index=False): + run_path = Path(run_dir) + y_true = self._load_y_true(run_path, int(fold)) + n_samples = int(len(y_true)) if y_true is not None else np.nan + both_wrong = self._count_both_wrong(run_path, int(fold), y_true) + rows.append( + { + "run_id": run_id, + "run_dir": str(run_path), + "fold": int(fold), + "n_samples": n_samples, + "both_wrong": both_wrong, + } + ) + return pd.DataFrame(rows) + + @staticmethod + def _count_both_wrong( + run_dir: Path, fold: int, y_true: Optional[np.ndarray] + ) -> Optional[int]: + if y_true is None: + return np.nan + img_path = run_dir / f"fold{fold}_probs_img.npy" + md_path = run_dir / f"fold{fold}_probs_md.npy" + if not img_path.exists() or not md_path.exists(): + return np.nan + try: + p_img = np.load(img_path) + p_md = np.load(md_path) + except Exception: + return np.nan + if p_img.ndim != 2 or p_md.ndim != 2: + return np.nan + if len(p_img) != len(y_true) or len(p_md) != len(y_true): + return np.nan + img_pred = p_img.argmax(axis=1) + md_pred = p_md.argmax(axis=1) + both_wrong = (img_pred != y_true) & (md_pred != y_true) + return int(both_wrong.sum()) + + @staticmethod + def _add_fusion_gain_columns(df: pd.DataFrame) -> pd.DataFrame: + out = df.copy() + for prefix in ("best", "holdout_best"): + acc_cols = [ + f"{prefix}_acc_fused", + f"{prefix}_acc_image", + f"{prefix}_acc_metadata", + ] + auc_cols = [ + f"{prefix}_auc_fused", + f"{prefix}_auc_image", + f"{prefix}_auc_metadata", + ] + if all(c in out.columns for c in acc_cols): + max_acc = out[[acc_cols[1], acc_cols[2]]].max(axis=1) + out[f"{prefix}_acc_fusion_gain"] = out[acc_cols[0]] - max_acc + if all(c in out.columns for c in auc_cols): + max_auc = out[[auc_cols[1], auc_cols[2]]].max(axis=1) + out[f"{prefix}_auc_fusion_gain"] = out[auc_cols[0]] - max_auc + return out + + def _fusion_errors_counts(self, merged: pd.DataFrame) -> pd.DataFrame: + if self.fusion_errors.empty: + return pd.DataFrame(columns=["run_id", "fold", "fusion_errors"]) + counts = ( + self.fusion_errors.groupby(["run_id", "fold"], as_index=False) + .size() + .rename(columns={"size": "fusion_errors"}) + ) + return counts + + def _build_warmup_map(self, df: pd.DataFrame) -> pd.DataFrame: + if df.empty or "run_id" not in df.columns or "run_dir" not in df.columns: + return pd.DataFrame(columns=["run_id", "warmup_end"]) + rows = [] + for run_id, run_dir in ( + df[["run_id", "run_dir"]].drop_duplicates().itertuples(index=False) + ): + cli = self._read_cli_args(Path(run_dir)) + warmup_end = self._warmup_end_epoch(cli) + rows.append({"run_id": run_id, "warmup_end": warmup_end}) + return pd.DataFrame(rows) + + @staticmethod + def _warmup_end_epoch(cli: Optional[Dict[str, object]]) -> int: + if not cli: + return 0 + tower = cli.get("warmup_tower_epochs") + fused = cli.get("warmup_fused_epochs") + try: + tower_val = int(tower) if tower is not None else 0 + except Exception: + tower_val = 0 + try: + fused_val = int(fused) if fused is not None else 0 + except Exception: + fused_val = 0 + return max(0, tower_val + fused_val) + + def _grid_params_from_cli( + self, cli: Optional[Dict[str, object]], summary: Optional[Dict[str, object]] + ) -> Dict[str, object]: + params: Dict[str, object] = { + "eval_mode": None, + "crop_variant": None, + "crop_normalize": None, + "crop_weights": None, + "crop_tta": None, + "loss_mode": None, + "thaw_mode": None, + "se_mode": None, + "se_bridge_pre_norm": None, + "se_tower_pre_norm": None, + } + + eval_mode = None + for payload in (cli, summary): + if payload and isinstance(payload.get("eval_mode"), str): + eval_mode = payload["eval_mode"] + break + params["eval_mode"] = eval_mode + + if cli: + crop_weights = cli.get("img_crop_weights") + crop_normalize = cli.get("img_crop_normalize") + crop_tta = cli.get("img_crop_tta") + params["crop_weights"] = crop_weights + params["crop_normalize"] = crop_normalize + params["crop_tta"] = crop_tta + params["crop_variant"] = self._infer_crop_variant(crop_weights) + params["loss_mode"] = self._infer_loss_mode(cli) + params["thaw_mode"] = self._infer_thaw_mode(cli) + params["se_mode"] = self._infer_se_mode(cli) + params["se_bridge_pre_norm"] = cli.get("se_pre_norm") + params["se_tower_pre_norm"] = cli.get("se_pre_norm_tower") + + return params + + @staticmethod + def _infer_crop_variant(crop_weights: object) -> Optional[str]: + if not crop_weights: + return None + text = str(crop_weights) + for key in ("norm_imagenet", "normalize_none", "norm_per_image"): + if key in text: + return key + return None + + @staticmethod + def _infer_loss_mode(cli: Dict[str, object]) -> Optional[str]: + if cli.get("balanced_sampler"): + return "balanced" + gamma = cli.get("focal_gamma") + try: + if gamma is not None and float(gamma) > 0: + return "focal" + except Exception: + pass + return "none" + + @staticmethod + def _infer_thaw_mode(cli: Dict[str, object]) -> Optional[str]: + return "gradual" if cli.get("gradual_thaw") else "none" + + @staticmethod + def _infer_se_mode(cli: Dict[str, object]) -> Optional[str]: + if not cli.get("use_se"): + return "none" + se_where = cli.get("se_where") + if isinstance(se_where, str) and se_where.strip(): + return se_where.strip() + return "bridge" + + @staticmethod + def _read_json(path: Path) -> Optional[Dict[str, object]]: + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + except Exception: + return None + if not isinstance(data, dict): + return None + return data + + @staticmethod + def _read_epoch_log(run_dir: Path, fold: int) -> Optional[pd.DataFrame]: + path = run_dir / f"fold{fold}_epoch_log.csv" + if not path.exists(): + return None + try: + return pd.read_csv(path) + except Exception: + return None + + def _read_summary(self, run_dir: Path) -> Optional[Dict[str, object]]: + return self._read_json(run_dir / "summary.json") + + def _read_cli_args(self, run_dir: Path) -> Optional[Dict[str, object]]: + return self._read_json(run_dir / "cli_args.json") + + @staticmethod + def _read_run_id(run_dir: Path, summary: Optional[Dict[str, object]]) -> str: + if summary: + rid = summary.get("run_id") + if isinstance(rid, str) and rid: + return rid + return run_dir.name + + @staticmethod + def _infer_mode( + summary: Optional[Dict[str, object]], cli: Optional[Dict[str, object]] + ) -> Optional[str]: + for payload in (summary, cli): + if not payload: + continue + eval_mode = payload.get("eval_mode") + if isinstance(eval_mode, str): + mode = eval_mode.strip().lower() + if mode == "binary": + return "binary" + if mode in {"multiclass", "multi", "multi-class"}: + return "multiclass" + num_classes = payload.get("num_classes") + if isinstance(num_classes, (int, float)): + return "binary" if int(num_classes) <= 2 else "multiclass" + class_names = payload.get("class_names") + if isinstance(class_names, list) and class_names: + return "binary" if len(class_names) <= 2 else "multiclass" + return None + + @staticmethod + def _fold_epoch_hint( + summary: Optional[Dict[str, object]], fold: int + ) -> Optional[int]: + if not summary: + return None + fold_metrics = summary.get("fold_metrics") or [] + for entry in fold_metrics: + if not isinstance(entry, dict): + continue + if entry.get("fold") == fold: + stats = ( + entry.get("stats") if isinstance(entry.get("stats"), dict) else {} + ) + epoch = stats.get("epoch") or entry.get("best_epoch") + if isinstance(epoch, (int, float)): + return int(epoch) + return None + + @staticmethod + def _extract_best_epochs( + log_df: Optional[pd.DataFrame], + ) -> tuple[Optional[int], Optional[int]]: + if log_df is None or log_df.empty: + return None, None + best_epoch = None + holdout_best_epoch = None + if "best_epoch" in log_df.columns: + try: + best_epoch = int(log_df["best_epoch"].iloc[-1]) + except Exception: + best_epoch = None + if "holdout_best_epoch" in log_df.columns: + try: + holdout_best_epoch = int(log_df["holdout_best_epoch"].iloc[-1]) + except Exception: + holdout_best_epoch = None + return best_epoch, holdout_best_epoch + + @staticmethod + def _row_for_epoch( + log_df: Optional[pd.DataFrame], epoch: Optional[int] + ) -> Optional[pd.Series]: + if log_df is None or epoch is None: + return None + if "epoch" not in log_df.columns: + return None + rows = log_df[log_df["epoch"] == epoch] + if rows.empty: + return None + return rows.iloc[-1] + + @staticmethod + def _metric_from_row(row: Optional[pd.Series], key: str) -> Optional[float]: + if row is None or key not in row: + return None + try: + val = float(row[key]) + except Exception: + return None + if np.isnan(val): + return None + return val + + @staticmethod + def _available_folds( + run_dir: Path, summary: Optional[Dict[str, object]] + ) -> List[int]: + folds: List[int] = [] + if summary: + for entry in summary.get("fold_metrics") or []: + if not isinstance(entry, dict): + continue + fold_idx = entry.get("fold") + if isinstance(fold_idx, int): + folds.append(fold_idx) + if not folds: + pattern = re.compile(r"fold(\d+)_y_true\.npy$") + for path in run_dir.glob("fold*_y_true.npy"): + match = pattern.match(path.name) + if match: + folds.append(int(match.group(1))) + return sorted(set(folds)) + + @staticmethod + def _load_y_true(run_dir: Path, fold: int) -> Optional[np.ndarray]: + path = run_dir / f"fold{fold}_y_true.npy" + if not path.exists(): + return None + try: + return np.load(path) + except Exception: + return None + + @staticmethod + def _collect_epoch_prob_paths( + run_dir: Path, fold: int + ) -> Dict[int, Dict[str, Path]]: + pattern = re.compile(rf"fold{fold}_epoch(\d+)_probs_(\w+)\.npy$") + epoch_paths: Dict[int, Dict[str, Path]] = {} + for path in run_dir.glob(f"fold{fold}_epoch*_probs_*.npy"): + match = pattern.match(path.name) + if not match: + continue + epoch = int(match.group(1)) + head = match.group(2) + epoch_paths.setdefault(epoch, {})[head] = path + return epoch_paths + + @staticmethod + def _collect_base_prob_paths(run_dir: Path, fold: int) -> Dict[str, Path]: + paths: Dict[str, Path] = {} + for head in ("fused", "img", "md"): + candidate = run_dir / f"fold{fold}_probs_{head}.npy" + if candidate.exists(): + paths[head] = candidate + return paths + + @staticmethod + def _load_probs_array(path: Path) -> Optional[np.ndarray]: + try: + return np.load(path) + except Exception: + return None + + def _load_auc_for_epoch( + self, run_dir: Path, fold: int, epoch: int, head: str, holdout: bool + ) -> Optional[float]: + if epoch is None: + return None + tag = "holdout_" if holdout else "" + # Prefer per-epoch ROC curves (same evaluation used for epoch_log ACC). + epoch_dir = run_dir / f"fold{fold}_roc_curves" + if epoch_dir.exists(): + path = epoch_dir / f"epoch{epoch}_{tag}{head}.json" + else: + path = None + + # Fallback to best/holdout_best exports if epoch curves missing. + if path is None or not path.exists(): + folder = ( + "fold{}_roc_curves_holdout_best".format(fold) + if holdout + else "fold{}_roc_curves_best".format(fold) + ) + target_dir = run_dir / folder + path = target_dir / f"epoch{epoch}_{tag}{head}.json" + if not path.exists(): + return None + return self._extract_auc_from_json(path) + + @staticmethod + def _extract_auc_from_json(path: Path) -> Optional[float]: + try: + payload = json.loads(path.read_text()) + except Exception: + return None + if not isinstance(payload, dict): + return None + per_class = payload.get("per_class") + if isinstance(per_class, dict): + + def _to_float(val): + try: + f = float(val) + except Exception: + return None + if np.isnan(f): + return None + return f + + # Binary fix: class 0 stored with class-1 labels against class-0 scores. + if "0" in per_class and "1" in per_class: + v0 = _to_float(per_class.get("0", {}).get("auc")) + v1 = _to_float(per_class.get("1", {}).get("auc")) + if v0 is not None and v1 is None: + return float(1.0 - v0) + if v1 is not None and v0 is None: + return float(1.0 - v1) + if v0 is not None and v1 is not None: + return float(np.mean([v0, v1])) + vals = [] + for entry in per_class.values(): + if not isinstance(entry, dict): + continue + auc_val = entry.get("auc") + val = _to_float(auc_val) + if val is not None: + vals.append(val) + if vals: + return float(np.mean(vals)) + macro_auc = payload.get("macro_auc") + try: + if macro_auc is not None and not np.isnan(float(macro_auc)): + return float(macro_auc) + except Exception: + pass + return None + + @staticmethod + def _plot_correlation_bars( + df: pd.DataFrame, + path: Path, + method: str = "pearson", + x_metric: str = "fusion_corrections", + metric_type: str = "acc", + title: Optional[str] = None, + ) -> None: + if df.empty: + return + height = max(4.0, 0.28 * len(df)) + fig, ax = plt.subplots(figsize=(8.5, height)) + ax.barh(df["metric"], df["corr"], color="steelblue") + ax.axvline(0.0, color="black", lw=1) + label = "Spearman ρ" if method == "spearman" else "Pearson r" + ax.set_xlabel(label) + ax.set_title(title or f"{x_metric} vs {metric_type} metrics ({method})") + fig.tight_layout() + fig.savefig(path, dpi=170) + plt.close(fig) + + @staticmethod + def _prepare_probs(arr: np.ndarray) -> Optional[np.ndarray]: + if arr is None: + return None + probs = np.asarray(arr, dtype=float) + if probs.ndim == 1: + probs = np.stack([1.0 - probs, probs], axis=1) + if probs.ndim != 2: + return None + return probs + + @staticmethod + def _has_all_heads(arrays: Dict[str, Optional[np.ndarray]]) -> bool: + needed = ("fused", "img", "md") + return all(arrays.get(head) is not None for head in needed) + + def _fusion_corrections_for_probs( + self, + y_true: np.ndarray, + arrays: Dict[str, np.ndarray], + run_id: str, + fold: int, + epoch: int, + ) -> List[Dict[str, object]]: + fused = self._prepare_probs(arrays.get("fused")) + img = self._prepare_probs(arrays.get("img")) + md = self._prepare_probs(arrays.get("md")) + if fused is None or img is None or md is None: + return [] + if not (len(fused) == len(img) == len(md) == len(y_true)): + return [] + + fused_pred = fused.argmax(axis=1) + img_pred = img.argmax(axis=1) + md_pred = md.argmax(axis=1) + + fused_conf = np.take_along_axis(fused, fused_pred[:, None], axis=1).squeeze(1) + img_conf = np.take_along_axis(img, img_pred[:, None], axis=1).squeeze(1) + md_conf = np.take_along_axis(md, md_pred[:, None], axis=1).squeeze(1) + + mask = (fused_pred == y_true) & (img_pred != y_true) & (md_pred != y_true) + indices = np.nonzero(mask)[0] + + events: List[Dict[str, object]] = [] + for idx in indices: + events.append( + { + "run_id": run_id, + "fold": fold, + "epoch": epoch, + "index": int(idx), + "y_true": int(y_true[idx]), + "pred_fused": int(fused_pred[idx]), + "pred_img": int(img_pred[idx]), + "pred_md": int(md_pred[idx]), + "conf_fused": float(fused_conf[idx]), + "conf_img": float(img_conf[idx]), + "conf_md": float(md_conf[idx]), + } + ) + return events + + def _fusion_errors_for_probs( + self, + y_true: np.ndarray, + arrays: Dict[str, np.ndarray], + run_id: str, + fold: int, + epoch: int, + ) -> List[Dict[str, object]]: + fused = self._prepare_probs(arrays.get("fused")) + img = self._prepare_probs(arrays.get("img")) + md = self._prepare_probs(arrays.get("md")) + if fused is None or img is None or md is None: + return [] + if not (len(fused) == len(img) == len(md) == len(y_true)): + return [] + + fused_pred = fused.argmax(axis=1) + img_pred = img.argmax(axis=1) + md_pred = md.argmax(axis=1) + + fused_conf = np.take_along_axis(fused, fused_pred[:, None], axis=1).squeeze(1) + img_conf = np.take_along_axis(img, img_pred[:, None], axis=1).squeeze(1) + md_conf = np.take_along_axis(md, md_pred[:, None], axis=1).squeeze(1) + + mask = (fused_pred != y_true) & (img_pred == y_true) & (md_pred == y_true) + indices = np.nonzero(mask)[0] + + events: List[Dict[str, object]] = [] + for idx in indices: + events.append( + { + "run_id": run_id, + "fold": fold, + "epoch": epoch, + "index": int(idx), + "y_true": int(y_true[idx]), + "pred_fused": int(fused_pred[idx]), + "pred_img": int(img_pred[idx]), + "pred_md": int(md_pred[idx]), + "conf_fused": float(fused_conf[idx]), + "conf_img": float(img_conf[idx]), + "conf_md": float(md_conf[idx]), + } + ) + return events + + +if __name__ == "__main__": + analysis = derived_analysis( + Path("analysis_data/grid_search"), classification_mode="binary" + ) + + df = analysis.identify_fusion_corrections(existing=True) + # analysis.write_fusion_corrections() + # analysis.write_fusion_errors() + print(f"Fusion correction events: {len(df)}") + analysis.primary_metrics = analysis.populate_primary_metrics(existing=True) + # analysis.write_primary_metrics() + # corr_acc = analysis.fusion_corrections_correlation( + # method="spearman", metric_type="acc" + # ) + # corr_auc = analysis.fusion_corrections_correlation( + # method="spearman", metric_type="auc" + # ) + # print(corr_acc) + # summary = analysis.plot_fusion_perf_summary( + # corr_acc, + # corr_auc, + # method="spearman", + # x_metric="fusion_corrections_per_opportunity", + # ) + analysis.primary_metrics + df = analysis.param_performance_correlations(method="spearman") + df.columns + # analysis.plot_param_perf_corr_panels(df) + out = analysis.se_mode_effects_summary(metric="auc") + out2 = analysis.se_mode_effects_summary(metric="acc") + + out3 = analysis.se_mode_effects_summary(metric="auc", head="fused", prefer_holdout=True, top_n=25) + out4 = analysis.se_mode_effects_summary(metric="acc", head="fused", prefer_holdout=True, top_n=25) + print(out["summary"]) + print(out2["summary"]) + print(out3["summary"]) + print(out4["summary"]) + print(out["pairwise"]) + print(out3["pairwise"]) +# analysis.fusion_corrections +# analysis.plot_fusion_corrections_errors() +# analysis.plot_conf_delta_boxplot() diff --git a/scripts/grid_search_analytics/derived_statistics.py b/scripts/grid_search_analytics/derived_statistics.py new file mode 100755 index 0000000..fae9796 --- /dev/null +++ b/scripts/grid_search_analytics/derived_statistics.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +""" +Shared helpers for building grid search analytics. + +The class below will gradually accumulate reusable utilities for working with +grid search outputs (summary.json, cli_args.json, etc). +""" + +from __future__ import annotations + +import json +import csv +import re +from pathlib import Path +from typing import Dict, Iterable, Iterator, List, Optional + +import numpy as np + +DEFAULT_EXCLUDE_KEYS = { + "run_id", + "fold_metrics", + "best_metric", + "best_metric_mode", + "best_metric_mean", + "best_metric_std", + "eval_mode", + "n_splits", + "num_classes", +} + + +class GridSearchAnalytics: + """Utility wrapper for inspecting grid search result directories.""" + + def __init__(self, + analysis_dir: Path | str, + exclude_keys: Optional[Iterable[str]] = None) -> None: + self.analysis_dir = Path(analysis_dir) + if not self.analysis_dir.exists(): + raise FileNotFoundError(f"analysis_dir does not exist: {self.analysis_dir}") + self.exclude_keys = set(exclude_keys or DEFAULT_EXCLUDE_KEYS) + + def iter_run_dirs(self, shallow: bool = True) -> Iterator[Path]: + """ + Yield run directories containing grid search artifacts. + + Shallow iteration only walks direct children. Deep iteration scans the + entire subtree. + """ + candidates: Iterable[Path] + if shallow: + candidates = (p for p in sorted(self.analysis_dir.iterdir()) if p.is_dir()) + else: + candidates = (p for p in self.analysis_dir.rglob("*") if p.is_dir()) + for run_dir in candidates: + summary = run_dir / "summary.json" + cli = run_dir / "cli_args.json" + if summary.exists() or cli.exists(): + yield run_dir + + def read_summary(self, run_dir: Path) -> Optional[Dict[str, object]]: + """Load summary.json for a run directory.""" + return self._read_json(run_dir / "summary.json") + + def read_cli_args(self, run_dir: Path) -> Optional[Dict[str, object]]: + """Load cli_args.json for a run directory.""" + return self._read_json(run_dir / "cli_args.json") + + def read_run_id(self, + run_dir: Path, + summary: Optional[Dict[str, object]]) -> str: + if summary: + rid = summary.get("run_id") + if isinstance(rid, str) and rid: + return rid + return run_dir.name + + def task_from_summary(self, summary: Optional[Dict[str, object]]) -> Optional[str]: + """Infer task (binary vs multiclass) from a summary payload.""" + if not summary: + return None + eval_mode = summary.get("eval_mode") + if isinstance(eval_mode, str): + mode = eval_mode.strip().lower() + if mode == "binary": + return "binary" + if mode in {"multiclass", "multi", "multi-class"}: + return "multiclass" + num_classes = summary.get("num_classes") + if isinstance(num_classes, (int, float)): + return "binary" if int(num_classes) <= 2 else "multiclass" + return None + + def flatten_config(self, + data: Dict[str, object], + prefix: str = "", + exclude_keys: Optional[Iterable[str]] = None) -> Dict[str, object]: + """Flatten nested CLI args or config dictionaries for analysis.""" + out: Dict[str, object] = {} + excludes = set(exclude_keys or self.exclude_keys) + for key, value in data.items(): + if key in excludes or key.startswith("best_"): + continue + full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}" + if isinstance(value, dict): + out.update(self.flatten_config(value, full_key, exclude_keys=excludes)) + continue + if isinstance(value, list): + continue + out[full_key] = value + return out + + def fusion_correction_events(self, + output_csv: Path | str | None = None, + shallow: bool = True) -> Path: + """ + Build a table of cases where the fused head is correct while both towers + are wrong. Rows are written to CSV for downstream analysis. + """ + output_path = Path(output_csv) if output_csv else Path("analysis_data/grid_search_analytics/fusion_corrections.csv") + output_path.parent.mkdir(parents=True, exist_ok=True) + + rows: List[Dict[str, object]] = [] + for run_dir in self.iter_run_dirs(shallow=shallow): + summary = self.read_summary(run_dir) + run_id = self.read_run_id(run_dir, summary) + folds = self._available_folds(run_dir, summary) + for fold in folds: + y_true = self._load_y_true(run_dir, fold) + if y_true is None: + continue + epoch_prob_paths = self._collect_epoch_prob_paths(run_dir, fold) + if not epoch_prob_paths: + # Per-epoch dumps were not found; fall back to the saved fold-level probabilities. + base_paths = self._collect_base_prob_paths(run_dir, fold) + if base_paths: + epoch_hint = self._fold_epoch_hint(summary, fold) + epoch_prob_paths = {epoch_hint if epoch_hint is not None else 0: base_paths} + for epoch, paths in epoch_prob_paths.items(): + arrays = {head: self._load_probs_array(path) for head, path in paths.items()} + if not self._has_all_heads(arrays): + continue + events = self._fusion_corrections_for_probs(y_true, arrays, run_id, fold, epoch) + rows.extend(events) + + if rows: + fieldnames = [ + "run_id", + "fold", + "epoch", + "index", + "y_true", + "pred_fused", + "pred_img", + "pred_md", + "conf_fused", + "conf_img", + "conf_md", + ] + with output_path.open("w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + else: + output_path.write_text("") + return output_path + + @staticmethod + def _read_json(path: Path) -> Optional[Dict[str, object]]: + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + except Exception: + return None + if not isinstance(data, dict): + return None + return data + + @staticmethod + def _fold_epoch_hint(summary: Optional[Dict[str, object]], fold: int) -> Optional[int]: + if not summary: + return None + fold_metrics = summary.get("fold_metrics") or [] + for entry in fold_metrics: + if not isinstance(entry, dict): + continue + if entry.get("fold") == fold: + stats = entry.get("stats") if isinstance(entry.get("stats"), dict) else {} + epoch = stats.get("epoch") or entry.get("best_epoch") + if isinstance(epoch, (int, float)): + return int(epoch) + return None + + @staticmethod + def _available_folds(run_dir: Path, summary: Optional[Dict[str, object]]) -> List[int]: + folds: List[int] = [] + if summary: + for entry in summary.get("fold_metrics") or []: + if not isinstance(entry, dict): + continue + fold_idx = entry.get("fold") + if isinstance(fold_idx, int): + folds.append(fold_idx) + if not folds: + pattern = re.compile(r"fold(\d+)_y_true\.npy$") + for path in run_dir.glob("fold*_y_true.npy"): + match = pattern.match(path.name) + if match: + folds.append(int(match.group(1))) + return sorted(set(folds)) + + @staticmethod + def _load_y_true(run_dir: Path, fold: int) -> Optional[np.ndarray]: + path = run_dir / f"fold{fold}_y_true.npy" + if not path.exists(): + return None + try: + return np.load(path) + except Exception: + return None + + @staticmethod + def _collect_epoch_prob_paths(run_dir: Path, fold: int) -> Dict[int, Dict[str, Path]]: + pattern = re.compile(rf"fold{fold}_epoch(\d+)_probs_(\w+)\.npy$") + epoch_paths: Dict[int, Dict[str, Path]] = {} + for path in run_dir.glob(f"fold{fold}_epoch*_probs_*.npy"): + match = pattern.match(path.name) + if not match: + continue + epoch = int(match.group(1)) + head = match.group(2) + epoch_paths.setdefault(epoch, {})[head] = path + return epoch_paths + + @staticmethod + def _collect_base_prob_paths(run_dir: Path, fold: int) -> Dict[str, Path]: + paths: Dict[str, Path] = {} + for head in ("fused", "img", "md"): + candidate = run_dir / f"fold{fold}_probs_{head}.npy" + if candidate.exists(): + paths[head] = candidate + return paths + + @staticmethod + def _load_probs_array(path: Path) -> Optional[np.ndarray]: + try: + return np.load(path) + except Exception: + return None + + @staticmethod + def _prepare_probs(arr: np.ndarray) -> Optional[np.ndarray]: + if arr is None: + return None + probs = np.asarray(arr, dtype=float) + if probs.ndim == 1: + probs = np.stack([1.0 - probs, probs], axis=1) + if probs.ndim != 2: + return None + return probs + + @staticmethod + def _has_all_heads(arrays: Dict[str, Optional[np.ndarray]]) -> bool: + needed = ("fused", "img", "md") + return all(arrays.get(head) is not None for head in needed) + + def _fusion_corrections_for_probs(self, + y_true: np.ndarray, + arrays: Dict[str, np.ndarray], + run_id: str, + fold: int, + epoch: int) -> List[Dict[str, object]]: + fused = self._prepare_probs(arrays.get("fused")) + img = self._prepare_probs(arrays.get("img")) + md = self._prepare_probs(arrays.get("md")) + if fused is None or img is None or md is None: + return [] + if not (len(fused) == len(img) == len(md) == len(y_true)): + return [] + + fused_pred = fused.argmax(axis=1) + img_pred = img.argmax(axis=1) + md_pred = md.argmax(axis=1) + + fused_conf = np.take_along_axis(fused, fused_pred[:, None], axis=1).squeeze(1) + img_conf = np.take_along_axis(img, img_pred[:, None], axis=1).squeeze(1) + md_conf = np.take_along_axis(md, md_pred[:, None], axis=1).squeeze(1) + + mask = (fused_pred == y_true) & (img_pred != y_true) & (md_pred != y_true) + indices = np.nonzero(mask)[0] + + events: List[Dict[str, object]] = [] + for idx in indices: + events.append({ + "run_id": run_id, + "fold": fold, + "epoch": epoch, + "index": int(idx), + "y_true": int(y_true[idx]), + "pred_fused": int(fused_pred[idx]), + "pred_img": int(img_pred[idx]), + "pred_md": int(md_pred[idx]), + "conf_fused": float(fused_conf[idx]), + "conf_img": float(img_conf[idx]), + "conf_md": float(md_conf[idx]), + }) + return events + + +if __name__ == "__main__": + analytics = GridSearchAnalytics(Path("analysis_data/grid_search")) + output = analytics.fusion_correction_events() + print(f"Fusion correction events written to {output}") diff --git a/scripts/grid_search_analytics/grid_search_heatmap.py b/scripts/grid_search_analytics/grid_search_heatmap.py new file mode 100755 index 0000000..86ad752 --- /dev/null +++ b/scripts/grid_search_analytics/grid_search_heatmap.py @@ -0,0 +1,729 @@ +#!/usr/bin/env python3 +""" +Build an HTML heatmap-style grid for grid search runs. + +Each column is a run. The header shows mean metrics (auc, acc, holdout_auc, +holdout_acc). Rows encode hyperparameter options as red/green boxes. + +Example: + python scripts/grid_search_analytics/grid_search_heatmap.py \ + --analysis-dir analysis_data/grid_search \ + --task binary \ + --sort-by holdout_auc --desc \ + --top 40 \ + --format plot \ + --output analysis_data/grid_search_heatmap.png +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys +import time +from html import escape +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + +DEFAULT_EXCLUDE_KEYS = { + "run_id", + "fold_metrics", + "best_metric", + "best_metric_mode", + "best_metric_mean", + "best_metric_std", + "eval_mode", + "n_splits", + "num_classes", +} + + +def to_float(value: Optional[object]) -> Optional[float]: + if value is None: + return None + if isinstance(value, (int, float)): + num = float(value) + if math.isnan(num): + return None + return num + if not isinstance(value, str): + return None + value = value.strip() + if not value: + return None + try: + num = float(value) + except ValueError: + return None + if math.isnan(num): + return None + return num + + +def mean(values: List[float]) -> Optional[float]: + return (sum(values) / len(values)) if values else None + + +def read_summary(run_dir: Path) -> Optional[Dict[str, object]]: + summary_path = run_dir / "summary.json" + if not summary_path.exists(): + return None + try: + data = json.loads(summary_path.read_text()) + except Exception: + return None + if not isinstance(data, dict): + return None + return data + + +def read_cli_args(run_dir: Path) -> Optional[Dict[str, object]]: + cli_path = run_dir / "cli_args.json" + if not cli_path.exists(): + return None + try: + data = json.loads(cli_path.read_text()) + except Exception: + return None + if not isinstance(data, dict): + return None + return data + + +def read_run_id(run_dir: Path, summary: Optional[Dict[str, object]]) -> str: + if summary: + rid = summary.get("run_id") + if isinstance(rid, str) and rid: + return rid + return run_dir.name + + +def task_from_summary(summary: Optional[Dict[str, object]]) -> Optional[str]: + if not summary: + return None + eval_mode = summary.get("eval_mode") + if isinstance(eval_mode, str): + mode = eval_mode.strip().lower() + if mode == "binary": + return "binary" + if mode in {"multiclass", "multi", "multi-class"}: + return "multiclass" + num_classes = summary.get("num_classes") + if isinstance(num_classes, (int, float)): + return "binary" if int(num_classes) <= 2 else "multiclass" + return None + + +def metric_from_stats(stats: Dict[str, object], metric: str) -> Optional[float]: + if metric.startswith("holdout_") and stats.get("holdout_best_monitor") == metric: + best_val = to_float(stats.get("holdout_best_so_far")) + if best_val is not None: + return best_val + return to_float(stats.get(metric)) + + +def mean_metric(summary: Dict[str, object], metric: str) -> Optional[float]: + folds = summary.get("fold_metrics") or [] + if not isinstance(folds, list) or not folds: + return None + values = [] + for fold in folds: + stats = fold.get("stats") if isinstance(fold, dict) else None + if not isinstance(stats, dict): + return None + val = metric_from_stats(stats, metric) + if val is None: + return None + values.append(val) + return mean(values) + + +def flatten_config(data: Dict[str, object], + prefix: str = "", + exclude_keys: Optional[Iterable[str]] = None) -> Dict[str, object]: + out: Dict[str, object] = {} + excludes = set(exclude_keys or []) + for key, value in data.items(): + if key in excludes or key.startswith("best_"): + continue + full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}" + if isinstance(value, dict): + out.update(flatten_config(value, full_key, exclude_keys=excludes)) + continue + if isinstance(value, list): + continue + out[full_key] = value + return out + + +def sort_value_key(value: object) -> Tuple[int, object]: + if value is None: + return (2, "") + if isinstance(value, bool): + return (0, int(value)) + if isinstance(value, (int, float)): + return (0, value) + return (1, str(value)) + + +def format_value(value: object) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return f"{value:.6g}" + return str(value) + + +def format_metric(value: Optional[float]) -> str: + if value is None: + return "" + return f"{value:.4f}" + + +def render_progress(current: int, total: Optional[int], matched: int) -> str: + if total: + width = 30 + filled = int(width * current / total) + bar = "#" * filled + "-" * (width - filled) + return f"[{bar}] {current}/{total} matched {matched}" + return f"Scanned {current} dirs, matched {matched}" + + +def iter_run_dirs(root: Path, shallow: bool, show_progress: bool) -> Iterable[Path]: + if shallow: + entries = [entry for entry in root.iterdir() if entry.is_dir()] + entries.sort(key=lambda p: p.name) + total = len(entries) + matched = 0 + last_update = 0.0 + for idx, entry in enumerate(entries, start=1): + if show_progress: + now = time.monotonic() + if now - last_update >= 0.1 or idx == total: + msg = render_progress(idx, total, matched) + print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True) + last_update = now + if (entry / "summary.json").is_file(): + matched += 1 + yield entry + if show_progress: + print(file=sys.stderr) + return + + matched = 0 + scanned = 0 + last_update = 0.0 + for dirpath, dirnames, filenames in os.walk(root): + scanned += 1 + if show_progress: + now = time.monotonic() + if now - last_update >= 0.2: + msg = render_progress(scanned, None, matched) + print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True) + last_update = now + if "summary.json" in filenames: + matched += 1 + yield Path(dirpath) + if show_progress: + msg = render_progress(scanned, None, matched) + print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True) + print(file=sys.stderr) + + +def build_html(runs: List[Dict[str, object]], + row_specs: List[Tuple[str, object]], + title: str, + filters: List[str]) -> str: + lines: List[str] = [] + lines.append("") + lines.append("") + lines.append("") + lines.append("") + lines.append(f"{escape(title)}") + lines.append("") + lines.append("") + lines.append("") + lines.append(f"

{escape(title)}

") + if filters: + lines.append("
") + for item in filters: + lines.append(f"
{escape(item)}
") + lines.append("
") + lines.append("
") + lines.append("") + lines.append("") + lines.append("") + lines.append("") + for run in runs: + run_id = escape(str(run.get("run_id", ""))) + rel_path = escape(str(run.get("relative_path", ""))) + title_attr = f" title=\"{rel_path}\"" if rel_path else "" + lines.append(f"") + lines.append("") + for metric_key, label in [ + ("auc", "auc"), + ("acc", "acc"), + ("holdout_auc", "holdout_auc"), + ("holdout_acc", "holdout_acc"), + ]: + lines.append("") + lines.append(f"") + for run in runs: + metrics = run.get("metrics", {}) + value = metrics.get(metric_key) if isinstance(metrics, dict) else None + lines.append(f"") + lines.append("") + lines.append("") + lines.append("") + for key, value in row_specs: + label = f"{key}={format_value(value)}" + lines.append("") + lines.append(f"") + for run in runs: + config = run.get("config", {}) + current = config.get(key) if isinstance(config, dict) else None + cell_class = "on" if current == value else "off" + lines.append(f"") + lines.append("") + lines.append("") + lines.append("
run_id{run_id}
{label}{escape(format_metric(value))}
{escape(label)}
") + lines.append("
") + lines.append("") + lines.append("") + return "\n".join(lines) + + +def truncate(text: str, width: int) -> str: + if len(text) <= width: + return text + if width <= 3: + return text[:width] + return text[:width - 3] + "..." + + +def build_text_grid(runs: List[Dict[str, object]], + row_specs: List[Tuple[str, object]], + filters: List[str], + col_width: int, + row_width: int, + color: bool) -> str: + sep = " " + lines: List[str] = [] + if filters: + lines.extend(filters) + lines.append("") + + def pad(text: str, width: int) -> str: + return truncate(text, width).ljust(width) + + def colorize(text: str, enabled: bool) -> str: + if not color: + return text + color_code = "\x1b[32m" if enabled else "\x1b[31m" + return f"{color_code}{text}\x1b[0m" + + def row_line(label: str, values: List[str]) -> str: + return pad(label, row_width) + sep + sep.join(pad(v, col_width) for v in values) + + run_ids = [str(run.get("run_id", "")) for run in runs] + lines.append(row_line("run_id", run_ids)) + for metric_key, label in [ + ("auc", "auc"), + ("acc", "acc"), + ("holdout_auc", "holdout_auc"), + ("holdout_acc", "holdout_acc"), + ]: + values = [] + for run in runs: + metrics = run.get("metrics", {}) + value = metrics.get(metric_key) if isinstance(metrics, dict) else None + values.append(format_metric(value)) + lines.append(row_line(label, values)) + + divider = "-" * row_width + sep + sep.join("-" * col_width for _ in runs) + lines.append(divider) + + for key, value in row_specs: + label = f"{key}={format_value(value)}" + cells: List[str] = [] + for run in runs: + config = run.get("config", {}) + current = config.get(key) if isinstance(config, dict) else None + enabled = current == value + cell = colorize("##", enabled) if enabled else colorize("..", enabled) + cells.append(cell) + lines.append(row_line(label, cells)) + + lines.append("") + lines.append("Legend: ##=on ..=off") + if color: + lines.append("Colors: green=on red=off") + return "\n".join(lines) + + +def parse_figsize(value: Optional[str], n_cols: int, n_rows: int) -> Tuple[float, float]: + if value: + parts = [p.strip() for p in value.split(",") if p.strip()] + if len(parts) == 2: + try: + return float(parts[0]), float(parts[1]) + except ValueError: + pass + width = min(40.0, max(8.0, n_cols * 0.3)) + height = min(40.0, max(6.0, (n_rows + 6) * 0.3)) + return width, height + + +def plot_heatmap(runs: List[Dict[str, object]], + row_specs: List[Tuple[str, object]], + filters: List[str], + output_path: Optional[Path], + figsize: Tuple[float, float], + dpi: int, + show: bool) -> None: + if not show: + import matplotlib + matplotlib.use("Agg") + + import matplotlib.pyplot as plt + from matplotlib.colors import ListedColormap + + try: + import seaborn as sns + except ImportError: + sns = None + + metric_labels = ["auc", "acc", "holdout_auc", "holdout_acc"] + metric_matrix: List[List[float]] = [] + for label in metric_labels: + row: List[float] = [] + for run in runs: + metrics = run.get("metrics", {}) + value = metrics.get(label) if isinstance(metrics, dict) else None + row.append(float(value) if value is not None else float("nan")) + metric_matrix.append(row) + + param_labels = [f"{key}={format_value(value)}" for key, value in row_specs] + param_matrix: List[List[int]] = [] + for key, value in row_specs: + row = [] + for run in runs: + config = run.get("config", {}) + current = config.get(key) if isinstance(config, dict) else None + row.append(1 if current == value else 0) + param_matrix.append(row) + + fig = plt.figure(figsize=figsize, dpi=dpi) + grid_rows = 2 if param_matrix else 1 + height_ratios = [2, max(2, len(param_matrix) * 0.5)] if param_matrix else [2] + gs = fig.add_gridspec(grid_rows, 1, height_ratios=height_ratios, hspace=0.05) + + ax_metrics = fig.add_subplot(gs[0, 0]) + if sns: + sns.heatmap( + metric_matrix, + ax=ax_metrics, + cmap="viridis", + annot=True, + fmt=".3f", + cbar=True, + yticklabels=metric_labels, + xticklabels=False, + ) + else: + im = ax_metrics.imshow(metric_matrix, aspect="auto", cmap="viridis") + ax_metrics.set_yticks(range(len(metric_labels))) + ax_metrics.set_yticklabels(metric_labels) + fig.colorbar(im, ax=ax_metrics, fraction=0.02, pad=0.01) + for i, row in enumerate(metric_matrix): + for j, value in enumerate(row): + if math.isnan(value): + continue + ax_metrics.text(j, i, f"{value:.3f}", ha="center", va="center", fontsize=7, color="white") + ax_metrics.set_ylabel("metrics") + + if param_matrix: + ax_params = fig.add_subplot(gs[1, 0], sharex=ax_metrics) + cmap = ListedColormap(["#d9534f", "#4caf50"]) + if sns: + sns.heatmap( + param_matrix, + ax=ax_params, + cmap=cmap, + cbar=False, + yticklabels=param_labels, + xticklabels=[run.get("run_id", "") for run in runs], + vmin=0, + vmax=1, + ) + else: + ax_params.imshow(param_matrix, aspect="auto", cmap=cmap, vmin=0, vmax=1) + ax_params.set_yticks(range(len(param_labels))) + ax_params.set_yticklabels(param_labels) + ax_params.set_xticks(range(len(runs))) + ax_params.set_xticklabels([run.get("run_id", "") for run in runs], rotation=90) + ax_params.set_xlabel("runs") + else: + ax_metrics.set_xticks(range(len(runs))) + ax_metrics.set_xticklabels([run.get("run_id", "") for run in runs], rotation=90) + ax_metrics.set_xlabel("runs") + + if filters: + fig.suptitle("Grid Search Heatmap\n" + " | ".join(filters), fontsize=10) + else: + fig.suptitle("Grid Search Heatmap", fontsize=10) + + if output_path is not None: + output_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output_path, bbox_inches="tight") + if show: + plt.show() + plt.close(fig) + + +def main() -> None: + ap = argparse.ArgumentParser(description="Build an HTML heatmap grid for grid search runs.") + ap.add_argument("--analysis-dir", type=Path, default=Path("analysis_data/grid_search"), + help="Directory containing run subdirectories") + ap.add_argument("--format", choices=["text", "html", "plot"], default="text", + help="Output format (default: text)") + ap.add_argument("--task", choices=["binary", "multiclass", "all"], default="all", + help="Filter runs by task type (default: all)") + ap.add_argument("--sort-by", choices=["auc", "acc", "holdout_auc", "holdout_acc"], default=None, + help="Metric to sort columns by (default: none)") + ap.add_argument("--asc", action="store_true", + help="Sort in ascending order (default: descending)") + ap.add_argument("--desc", action="store_true", + help="Sort in descending order (default: descending)") + ap.add_argument("--top", type=int, default=None, + help="Limit to the top N runs after sorting") + ap.add_argument("--cluster-rows", dest="cluster_rows", action="store_true", + help="Order parameter rows by prevalence in the selected runs (default)") + ap.add_argument("--no-cluster-rows", dest="cluster_rows", action="store_false", + help="Keep parameter rows sorted alphabetically") + ap.set_defaults(cluster_rows=True) + ap.add_argument("--output", type=Path, default=None, + help="Optional path to write output") + ap.add_argument("--params", default=None, + help="Comma-separated list of parameter keys to include") + ap.add_argument("--exclude", default=None, + help="Comma-separated list of parameter keys to exclude") + ap.add_argument("--match", default=None, + help="Only include run directories whose name contains this substring") + ap.add_argument("--shallow", action="store_true", + help="Only scan directories directly under analysis-dir") + ap.add_argument("--no-progress", action="store_true", + help="Disable progress output") + ap.add_argument("--col-width", type=int, default=13, + help="Column width for text output (default: 13)") + ap.add_argument("--row-width", type=int, default=36, + help="Row label width for text output (default: 36)") + ap.add_argument("--color", action="store_true", + help="Use ANSI colors in text output") + ap.add_argument("--figsize", default=None, + help="Figure size as 'width,height' (inches), for plot output") + ap.add_argument("--dpi", type=int, default=140, + help="Figure DPI for plot output") + ap.add_argument("--show", action="store_true", + help="Display plot window (only for format=plot)") + args = ap.parse_args() + + root = args.analysis_dir + if not root.exists(): + raise SystemExit(f"Analysis directory not found: {root}") + + exclude_keys = set(DEFAULT_EXCLUDE_KEYS) + if args.exclude: + for item in args.exclude.split(","): + item = item.strip() + if item: + exclude_keys.add(item) + + runs: List[Dict[str, object]] = [] + values_by_key: Dict[str, List[object]] = {} + missing_summary = 0 + unknown_task = 0 + missing_cli = 0 + + for run_dir in iter_run_dirs(root, shallow=args.shallow, show_progress=not args.no_progress): + if args.match and args.match not in run_dir.name: + continue + summary = read_summary(run_dir) + if summary is None: + missing_summary += 1 + continue + task_label = task_from_summary(summary) + if args.task != "all": + if task_label is None: + unknown_task += 1 + continue + if task_label != args.task: + continue + + metrics = { + "auc": mean_metric(summary, "auc_fused"), + "acc": mean_metric(summary, "acc_fused"), + "holdout_auc": mean_metric(summary, "holdout_auc_fused"), + "holdout_acc": mean_metric(summary, "holdout_acc_fused"), + } + if any(val is None for val in metrics.values()): + continue + + cli_args = read_cli_args(run_dir) + if cli_args is None: + missing_cli += 1 + config_source = cli_args if cli_args is not None else summary + config = flatten_config(config_source, exclude_keys=exclude_keys) + run_id = read_run_id(run_dir, summary) + runs.append({ + "run_id": run_id, + "relative_path": str(run_dir.relative_to(root)), + "task": task_label, + "metrics": metrics, + "config": config, + }) + for key, value in config.items(): + values_by_key.setdefault(key, []).append(value) + + if not runs: + print("No matching runs found.") + return + + if args.params: + param_keys = [p.strip() for p in args.params.split(",") if p.strip()] + else: + param_keys = [] + for key, values in values_by_key.items(): + unique_values = {format_value(v) for v in values} + if len(unique_values) > 1: + param_keys.append(key) + param_keys.sort() + + row_specs: List[Tuple[str, object]] = [] + for key in param_keys: + values = values_by_key.get(key, []) + unique_values = [] + seen = set() + for val in values: + marker = (type(val), val) + if marker in seen: + continue + seen.add(marker) + unique_values.append(val) + unique_values.sort(key=sort_value_key) + for value in unique_values: + row_specs.append((key, value)) + + if args.asc and args.desc: + raise SystemExit("Choose only one of --asc or --desc.") + + if args.sort_by: + def sort_key(item: Dict[str, object]) -> float: + metrics = item.get("metrics", {}) + val = metrics.get(args.sort_by) if isinstance(metrics, dict) else None + if val is None: + return float("inf") if args.asc else float("-inf") + return float(val) + + runs.sort(key=sort_key, reverse=not args.asc) + else: + runs.sort(key=lambda r: str(r.get("run_id", ""))) + + if args.top is not None: + runs = runs[:args.top] + + if args.cluster_rows and runs: + total = len(runs) + counts_by_spec: Dict[Tuple[str, object], int] = {} + for key, value in row_specs: + counts_by_spec[(key, value)] = 0 + for run in runs: + config = run.get("config", {}) + if not isinstance(config, dict): + continue + for key, value in row_specs: + if config.get(key) == value: + counts_by_spec[(key, value)] += 1 + + def row_sort(spec: Tuple[str, object]) -> Tuple[float, str, str]: + count = counts_by_spec.get(spec, 0) + score = count / total if total else 0.0 + key, value = spec + return (-score, str(key), format_value(value)) + + row_specs.sort(key=row_sort) + + filters = [] + if args.task != "all": + filters.append(f"Task filter: {args.task}") + if args.match: + filters.append(f"Name filter: {args.match}") + filters.append(f"Runs: {len(runs)}") + filters.append(f"Params: {len(row_specs)}") + filters.append(f"Row clustering: {'on' if args.cluster_rows else 'off'}") + if missing_summary or unknown_task: + filters.append(f"Skipped: {missing_summary} missing summary, {unknown_task} unknown task") + if missing_cli: + filters.append(f"Missing cli_args: {missing_cli}") + + title = "Grid Search Heatmap" + if args.format == "html": + html = build_html(runs, row_specs, title=title, filters=filters) + output_path = args.output or Path("analysis_data/grid_search_heatmap.html") + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(html) + print(f"Wrote {output_path}") + elif args.format == "plot": + output_path = args.output + if output_path is None and not args.show: + output_path = Path("analysis_data/grid_search_heatmap.png") + figsize = parse_figsize(args.figsize, n_cols=len(runs), n_rows=len(row_specs)) + plot_heatmap( + runs, + row_specs, + filters=filters, + output_path=output_path, + figsize=figsize, + dpi=args.dpi, + show=args.show, + ) + if output_path is not None: + print(f"Wrote {output_path}") + else: + text = build_text_grid( + runs, + row_specs, + filters=filters, + col_width=max(4, args.col_width), + row_width=max(12, args.row_width), + color=args.color, + ) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text) + print(f"Wrote {args.output}") + else: + print(text) + + +if __name__ == "__main__": + main() diff --git a/scripts/grid_search_analytics/holdout_roc_for_run.py b/scripts/grid_search_analytics/holdout_roc_for_run.py new file mode 100755 index 0000000..25ce3ea --- /dev/null +++ b/scripts/grid_search_analytics/holdout_roc_for_run.py @@ -0,0 +1,572 @@ +#!/usr/bin/env python3 +"""Plot holdout ROC curves for a specific grid search run.""" +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Dict, List, Tuple + +import matplotlib.pyplot as plt +import numpy as np +from sklearn.metrics import auc, roc_curve + + +# --------------------------- +# Config (edit in IDE) +# --------------------------- +RUN_ID = "20251129-0312" +ANALYSIS_ROOT = Path("analysis_data/grid_search") +HEADS = ["fused", "image", "metadata"] +OUTPUT_SUBDIR = Path("plots/holdout_rocs") +BEST_OUTPUT_SUBDIR = Path("plots/best_rocs") +POSITIVE_CLASS = 1 +DEBUG = True +USE_JSON_ROC = True +USE_HOLDOUT_PROBS = True +ALLOW_FALLBACK_TO_VALIDATION = False +PLOT_VALIDATION_FROM_HOLDOUT_EPOCH = True +PLOT_BEST_EPOCH = True +PLOT_HOLDOUT_FROM_BEST_EPOCH = True +PLOT_ALL_CLASSES = True +FORCE_PROBS_FOR_BEST_BINARY = True +FORCE_PROBS_FOR_HOLDOUT_BINARY = False + + +HEAD_FILE_KEYS = { + "fused": "fused", + "image": "img", + "metadata": "md", +} + +JSON_DIR_NAMES = [ + "roc_curves_holdout_best", + "roc_curves", +] + + +def _epoch_from_name(path: Path) -> int: + m = re.search(r"epoch(\d+)", path.name) + return int(m.group(1)) if m else -1 + + +def _load_json(path: Path) -> Dict: + try: + return json.loads(path.read_text()) + except Exception: + return {} + + +def _infer_run_info(run_dir: Path) -> Tuple[str | None, int | None, List[str] | None]: + cli = _load_json(run_dir / "cli_args.json") + summary = _load_json(run_dir / "summary.json") + payloads = [cli, summary] + eval_mode = None + num_classes = None + class_names = None + for payload in payloads: + if not payload: + continue + if eval_mode is None: + em = payload.get("eval_mode") + if isinstance(em, str): + eval_mode = em.strip().lower() + if num_classes is None: + nc = payload.get("num_classes") + if isinstance(nc, (int, float)): + num_classes = int(nc) + if class_names is None: + cn = payload.get("class_names") + if isinstance(cn, list) and cn: + class_names = [str(x) for x in cn] + if num_classes is None and eval_mode: + num_classes = 2 if eval_mode == "binary" else 3 + return eval_mode, num_classes, class_names + + +def _collect_holdout_json_files(run_dir: Path, head: str) -> Dict[int, Path]: + fold_files: Dict[int, Path] = {} + # Fold-scoped folders + for folder_name in JSON_DIR_NAMES: + for fold_dir in run_dir.glob(f"fold*_{folder_name}"): + fold_match = re.search(r"fold(\d+)_", fold_dir.name) + if not fold_match: + continue + fold_idx = int(fold_match.group(1)) + candidates = list(fold_dir.glob(f"epoch*_holdout_{head}.json")) + if not candidates: + candidates = list(fold_dir.glob(f"epoch*_{head}.json")) + if candidates: + candidates.sort(key=_epoch_from_name) + fold_files[fold_idx] = candidates[-1] + if fold_files: + return fold_files + # Fallback: unscoped roc_curves in run_dir (single-fold or in-progress) + for folder_name in JSON_DIR_NAMES: + base_dir = run_dir / folder_name + if not base_dir.exists(): + continue + candidates = list(base_dir.glob(f"epoch*_holdout_{head}.json")) + if not candidates: + candidates = list(base_dir.glob(f"epoch*_{head}.json")) + if candidates: + candidates.sort(key=_epoch_from_name) + fold_files[0] = candidates[-1] + break + return fold_files + + +def _collect_validation_json_files( + holdout_files: Dict[int, Path], head: str +) -> Dict[int, Path]: + validation_files: Dict[int, Path] = {} + for fold_idx, holdout_path in holdout_files.items(): + epoch = _epoch_from_name(holdout_path) + if epoch < 0: + continue + candidate = holdout_path.parent / f"epoch{epoch}_{head}.json" + if candidate.exists(): + validation_files[fold_idx] = candidate + continue + # Fallback: try the same epoch under roc_curves (if holdout_best folder omitted it). + for folder_name in JSON_DIR_NAMES: + alt_dir = holdout_path.parent.parent / f"fold{fold_idx}_{folder_name}" + alt_candidate = alt_dir / f"epoch{epoch}_{head}.json" + if alt_candidate.exists(): + validation_files[fold_idx] = alt_candidate + break + return validation_files + + +def _collect_holdout_from_validation_files( + validation_files: Dict[int, Path], head: str +) -> Dict[int, Path]: + holdout_files: Dict[int, Path] = {} + for fold_idx, val_path in validation_files.items(): + epoch = _epoch_from_name(val_path) + if epoch < 0: + continue + candidate = val_path.parent / f"epoch{epoch}_holdout_{head}.json" + if candidate.exists(): + holdout_files[fold_idx] = candidate + continue + for folder_name in ("roc_curves", "roc_curves_holdout_best"): + alt_dir = val_path.parent.parent / f"fold{fold_idx}_{folder_name}" + alt_candidate = alt_dir / f"epoch{epoch}_holdout_{head}.json" + if alt_candidate.exists(): + holdout_files[fold_idx] = alt_candidate + break + return holdout_files + + +def _collect_best_json_files(run_dir: Path, head: str) -> Dict[int, Path]: + fold_files: Dict[int, Path] = {} + for fold_dir in run_dir.glob("fold*_roc_curves_best"): + fold_match = re.search(r"fold(\d+)_", fold_dir.name) + if not fold_match: + continue + fold_idx = int(fold_match.group(1)) + candidates = list(fold_dir.glob(f"epoch*_{head}.json")) + if candidates: + candidates.sort(key=_epoch_from_name) + fold_files[fold_idx] = candidates[-1] + return fold_files + + +def _extract_curves(data: Dict) -> Dict[str, Tuple[List[float], List[float], float]]: + curves: Dict[str, Tuple[List[float], List[float], float]] = {} + per_class = data.get("per_class") if isinstance(data, dict) else None + if not isinstance(per_class, dict): + return curves + for cls, entry in per_class.items(): + if not isinstance(entry, dict): + continue + fpr = entry.get("fpr") + tpr = entry.get("tpr") + auc_val = entry.get("auc") + if not isinstance(fpr, list) or not isinstance(tpr, list): + continue + try: + auc_f = float(auc_val) if auc_val is not None else float("nan") + except Exception: + auc_f = float("nan") + curves[str(cls)] = (fpr, tpr, auc_f) + return curves + + +def _derive_positive_from_class0( + curves_by_class: Dict[str, List[Tuple[int, List[float], List[float], float]]], + positive_class: int, +) -> None: + zero_key = "0" + if zero_key not in curves_by_class: + return + derived = [] + for fold_idx, fpr0, tpr0, auc0 in curves_by_class.get(zero_key, []): + # The JSON for binary currently stores class-1 labels with class-0 scores, + # so invert the curve to recover the true class-1 ROC. + fpr1 = [1.0 - float(x) for x in fpr0] + tpr1 = [1.0 - float(x) for x in tpr0] + # Ensure increasing FPR for plotting. + if len(fpr1) > 1 and fpr1[0] > fpr1[-1]: + fpr1 = list(reversed(fpr1)) + tpr1 = list(reversed(tpr1)) + auc1 = 1.0 - auc0 if auc0 == auc0 else auc0 + derived.append((fold_idx, fpr1, tpr1, auc1)) + curves_by_class[str(positive_class)] = derived + + +def _needs_positive_derivation( + curves_by_class: Dict[str, List[Tuple[int, List[float], List[float], float]]], + positive_class: int, +) -> bool: + curves = curves_by_class.get(str(positive_class)) + if not curves: + return True + for _, fpr, tpr, auc_val in curves: + if auc_val == auc_val and len(fpr) > 2 and len(tpr) > 2: + return False + return True + + +def _collect_prob_files(run_dir: Path, suffix: str) -> Dict[int, Dict[str, Path]]: + files: Dict[int, Dict[str, Path]] = {} + for y_file in run_dir.glob(f"fold*_y_true{suffix}.npy"): + fold_str = y_file.stem.split("_")[0].replace("fold", "") + try: + fold_idx = int(fold_str) + except ValueError: + continue + files.setdefault(fold_idx, {})["y_true"] = y_file + for head, key in HEAD_FILE_KEYS.items(): + for p_file in run_dir.glob(f"fold*_probs_{key}{suffix}.npy"): + fold_str = p_file.stem.split("_")[0].replace("fold", "") + try: + fold_idx = int(fold_str) + except ValueError: + continue + files.setdefault(fold_idx, {})[head] = p_file + return files + + +def _load_array(path: Path) -> np.ndarray | None: + try: + return np.load(path) + except Exception: + return None + + +def _compute_binary_curve( + y_true: np.ndarray, probs: np.ndarray, positive_class: int +) -> Tuple[List[float], List[float], float] | None: + if probs.ndim == 1: + scores = probs + elif probs.ndim == 2 and probs.shape[1] > positive_class: + scores = probs[:, positive_class] + else: + return None + y_bin = (y_true == positive_class).astype(int) + if y_bin.sum() == 0 or y_bin.sum() == len(y_bin): + return None + fpr, tpr, _ = roc_curve(y_bin, scores) + auc_val = float(auc(fpr, tpr)) + return fpr.tolist(), tpr.tolist(), auc_val + + +def _compute_multiclass_curves( + y_true: np.ndarray, probs: np.ndarray +) -> Dict[str, Tuple[List[float], List[float], float]]: + curves: Dict[str, Tuple[List[float], List[float], float]] = {} + if probs.ndim != 2: + return curves + num_classes = probs.shape[1] + for cls in range(num_classes): + y_bin = (y_true == cls).astype(int) + if y_bin.sum() == 0 or y_bin.sum() == len(y_bin): + continue + fpr, tpr, _ = roc_curve(y_bin, probs[:, cls]) + curves[str(cls)] = (fpr.tolist(), tpr.tolist(), float(auc(fpr, tpr))) + return curves + + +def _plot_overlays( + curves_by_fold: List[Tuple[int, List[float], List[float], float]], + title: str, + out_path: Path, +) -> None: + fig, ax = plt.subplots(figsize=(6, 5)) + for fold_idx, fpr, tpr, auc_val in curves_by_fold: + label = ( + f"fold{fold_idx} AUC={auc_val:.3f}" + if auc_val == auc_val + else f"fold{fold_idx}" + ) + ax.plot(fpr, tpr, lw=1.4, label=label) + ax.plot([0, 1], [0, 1], "k--", lw=1) + ax.set_xlabel("False Positive Rate") + ax.set_ylabel("True Positive Rate") + ax.set_title(title) + ax.legend(loc="lower right", fontsize="small") + ax.grid(True, alpha=0.3, linestyle="--") + fig.tight_layout() + out_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(out_path, dpi=170) + plt.close(fig) + + +def main() -> None: + run_dir = ANALYSIS_ROOT / RUN_ID + if not run_dir.exists(): + raise SystemExit(f"Run not found: {run_dir}") + eval_mode, num_classes, class_names = _infer_run_info(run_dir) + is_binary = eval_mode == "binary" or num_classes == 2 + + def _plot_set( + label: str, + head: str, + json_files: Dict[int, Path], + out_dir: Path, + paired_files: Dict[int, Path] | None, + paired_suffix: str, + class_names: List[str] | None, + ) -> None: + if not json_files: + return + curves_by_class: Dict[ + str, List[Tuple[int, List[float], List[float], float]] + ] = {} + paired_curves_by_class: Dict[ + str, List[Tuple[int, List[float], List[float], float]] + ] = {} + for fold_idx, path in sorted(json_files.items()): + if DEBUG: + print(f"[debug] {label} head={head} fold={fold_idx} json={path}") + data = _load_json(path) + curves = _extract_curves(data) + for cls, (fpr, tpr, auc_val) in curves.items(): + curves_by_class.setdefault(cls, []).append( + (fold_idx, fpr, tpr, auc_val) + ) + if paired_files: + p_path = paired_files.get(fold_idx) + if p_path is not None: + if DEBUG: + print( + f"[debug] {label} head={head} fold={fold_idx} paired_json={p_path}" + ) + p_data = _load_json(p_path) + p_curves = _extract_curves(p_data) + for cls, (fpr, tpr, auc_val) in p_curves.items(): + paired_curves_by_class.setdefault(cls, []).append( + (fold_idx, fpr, tpr, auc_val) + ) + if _needs_positive_derivation(curves_by_class, POSITIVE_CLASS): + _derive_positive_from_class0(curves_by_class, POSITIVE_CLASS) + if paired_curves_by_class and _needs_positive_derivation( + paired_curves_by_class, POSITIVE_CLASS + ): + _derive_positive_from_class0(paired_curves_by_class, POSITIVE_CLASS) + + if PLOT_ALL_CLASSES: + classes = list(curves_by_class.keys()) + else: + classes = ( + [str(POSITIVE_CLASS)] + if str(POSITIVE_CLASS) in curves_by_class + else list(curves_by_class.keys()) + ) + if not classes: + return + + for cls in classes: + fold_curves = curves_by_class.get(cls, []) + if not fold_curves: + continue + class_label = cls + if class_names is not None: + try: + idx = int(cls) + if 0 <= idx < len(class_names): + class_label = f"{cls} ({class_names[idx]})" + except Exception: + pass + title = f"{RUN_ID} {label} ROC — head={head} class={class_label}" + out_path = out_dir / f"{label}_{head}_class{cls}.png" + _plot_overlays(fold_curves, title, out_path) + print(f"[ok] {out_path}") + + if paired_curves_by_class: + p_curves = paired_curves_by_class.get(cls, []) + if p_curves: + p_title = f"{RUN_ID} {label} {paired_suffix} ROC — head={head} class={class_label}" + p_path = out_dir / f"{label}_{head}_class{cls}_{paired_suffix}.png" + _plot_overlays(p_curves, p_title, p_path) + print(f"[ok] {p_path}") + + def _plot_from_probs( + label: str, + head: str, + out_dir: Path, + suffix: str, + class_names: List[str] | None, + ) -> None: + curves_by_class: Dict[ + str, List[Tuple[int, List[float], List[float], float]] + ] = {} + files = _collect_prob_files(run_dir, suffix) + if not files and suffix and ALLOW_FALLBACK_TO_VALIDATION: + files = _collect_prob_files(run_dir, "") + if files: + print( + "[warn] Holdout probability dumps not found; using validation probabilities instead." + ) + if not files: + return + for fold_idx in sorted(files.keys()): + fold_files = files[fold_idx] + y_path = fold_files.get("y_true") + p_path = fold_files.get(head) + if y_path is None or p_path is None: + continue + y_true = _load_array(y_path) + probs = _load_array(p_path) + if y_true is None or probs is None: + continue + curves = _compute_multiclass_curves(y_true, probs) + for cls, payload in curves.items(): + curves_by_class.setdefault(cls, []).append((fold_idx, *payload)) + if not curves_by_class: + return + classes = list(curves_by_class.keys()) + for cls in classes: + fold_curves = curves_by_class.get(cls, []) + if not fold_curves: + continue + class_label = cls + if class_names is not None: + try: + idx = int(cls) + if 0 <= idx < len(class_names): + class_label = f"{cls} ({class_names[idx]})" + except Exception: + pass + title = f"{RUN_ID} {label} ROC — head={head} class={class_label}" + out_path = out_dir / f"{label}_{head}_class{cls}.png" + _plot_overlays(fold_curves, title, out_path) + print(f"[ok] {out_path}") + + any_holdout_json = False + if USE_JSON_ROC: + out_dir = run_dir / OUTPUT_SUBDIR + for head in HEADS: + files = _collect_holdout_json_files(run_dir, head) + if files: + any_holdout_json = True + paired = ( + _collect_validation_json_files(files, head) + if PLOT_VALIDATION_FROM_HOLDOUT_EPOCH + else None + ) + if is_binary and FORCE_PROBS_FOR_HOLDOUT_BINARY: + _plot_from_probs("holdout", head, out_dir, "_holdout", class_names) + else: + _plot_set( + "holdout", + head, + files, + out_dir, + paired, + "validation", + class_names, + ) + if not any_holdout_json and DEBUG: + print("[debug] no JSON ROC files found; falling back to probs") + + if PLOT_BEST_EPOCH: + best_out_dir = run_dir / BEST_OUTPUT_SUBDIR + for head in HEADS: + if is_binary and FORCE_PROBS_FOR_BEST_BINARY: + _plot_from_probs("best", head, best_out_dir, "", class_names) + continue + best_files = _collect_best_json_files(run_dir, head) + if best_files: + paired = ( + _collect_holdout_from_validation_files(best_files, head) + if PLOT_HOLDOUT_FROM_BEST_EPOCH + else None + ) + _plot_set( + "best", + head, + best_files, + best_out_dir, + paired, + "holdout", + class_names, + ) + + # Fallback to probs for holdout plots if JSON wasn't found. + if USE_JSON_ROC and any_holdout_json: + return + + out_dir = run_dir / OUTPUT_SUBDIR + for head in HEADS: + curves_by_class: Dict[ + str, List[Tuple[int, List[float], List[float], float]] + ] = {} + suffix = "_holdout" if USE_HOLDOUT_PROBS else "" + files = _collect_prob_files(run_dir, suffix) + if not files and USE_HOLDOUT_PROBS and ALLOW_FALLBACK_TO_VALIDATION: + suffix = "" + files = _collect_prob_files(run_dir, suffix) + if files: + print( + "[warn] Holdout probability dumps not found; using validation probabilities instead." + ) + if not files: + raise SystemExit( + "No saved probability dumps found. If you want holdout ROC curves, " + "run scripts/rebuild_run_best_plots.py with --use-holdout --overwrite " + "to generate fold*_y_true_holdout.npy and fold*_probs_*_holdout.npy files." + ) + for fold_idx in sorted(files.keys()): + fold_files = files[fold_idx] + y_path = fold_files.get("y_true") + p_path = fold_files.get(head) + if y_path is None or p_path is None: + continue + y_true = _load_array(y_path) + probs = _load_array(p_path) + if y_true is None or probs is None: + continue + curves = _compute_multiclass_curves(y_true, probs) + for cls, payload in curves.items(): + if payload is None: + continue + fpr, tpr, auc_val = payload + curves_by_class.setdefault(cls, []).append( + (fold_idx, fpr, tpr, auc_val) + ) + if not curves_by_class: + continue + classes = sorted(curves_by_class.keys(), key=lambda x: (float(x), str(x))) + for cls in classes: + fold_curves = curves_by_class.get(cls, []) + if not fold_curves: + continue + class_label = cls + if class_names is not None: + try: + idx = int(cls) + if 0 <= idx < len(class_names): + class_label = f"{cls} ({class_names[idx]})" + except Exception: + pass + title = f"{RUN_ID} holdout ROC — head={head} class={class_label}" + out_path = out_dir / f"holdout_{head}_class{cls}.png" + _plot_overlays(fold_curves, title, out_path) + print(f"[ok] {out_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/grid_search_analytics/param_perf_correlations.py b/scripts/grid_search_analytics/param_perf_correlations.py new file mode 100755 index 0000000..74eb263 --- /dev/null +++ b/scripts/grid_search_analytics/param_perf_correlations.py @@ -0,0 +1,514 @@ +#!/usr/bin/env python3 +""" +Analyze correlations between grid search parameters and performance metrics. + +Example: + python scripts/grid_search_analytics/param_perf_correlations.py \ + --analysis-dir analysis_data/grid_search \ + --task binary \ + --metric holdout_auc \ + --top 30 +""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import sys +import time +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + +DEFAULT_EXCLUDE_KEYS = { + "run_id", + "fold_metrics", + "best_metric", + "best_metric_mode", + "best_metric_mean", + "best_metric_std", + "eval_mode", + "n_splits", + "num_classes", +} + +METRIC_MAP = { + "auc": "auc_fused", + "acc": "acc_fused", + "holdout_auc": "holdout_auc_fused", + "holdout_acc": "holdout_acc_fused", +} + + +def to_float(value: Optional[object]) -> Optional[float]: + if value is None: + return None + if isinstance(value, (int, float)) and not isinstance(value, bool): + num = float(value) + if math.isnan(num): + return None + return num + if not isinstance(value, str): + return None + value = value.strip() + if not value: + return None + try: + num = float(value) + except ValueError: + return None + if math.isnan(num): + return None + return num + + +def mean(values: List[float]) -> Optional[float]: + return (sum(values) / len(values)) if values else None + + +def read_json(path: Path) -> Optional[Dict[str, object]]: + if not path.exists(): + return None + try: + data = json.loads(path.read_text()) + except Exception: + return None + if not isinstance(data, dict): + return None + return data + + +def read_summary(run_dir: Path) -> Optional[Dict[str, object]]: + return read_json(run_dir / "summary.json") + + +def read_cli_args(run_dir: Path) -> Optional[Dict[str, object]]: + return read_json(run_dir / "cli_args.json") + + +def read_run_id(run_dir: Path, summary: Optional[Dict[str, object]]) -> str: + if summary: + rid = summary.get("run_id") + if isinstance(rid, str) and rid: + return rid + return run_dir.name + + +def task_from_summary(summary: Optional[Dict[str, object]]) -> Optional[str]: + if not summary: + return None + eval_mode = summary.get("eval_mode") + if isinstance(eval_mode, str): + mode = eval_mode.strip().lower() + if mode == "binary": + return "binary" + if mode in {"multiclass", "multi", "multi-class"}: + return "multiclass" + num_classes = summary.get("num_classes") + if isinstance(num_classes, (int, float)): + return "binary" if int(num_classes) <= 2 else "multiclass" + return None + + +def metric_from_stats(stats: Dict[str, object], metric: str) -> Optional[float]: + if metric.startswith("holdout_") and stats.get("holdout_best_monitor") == metric: + best_val = to_float(stats.get("holdout_best_so_far")) + if best_val is not None: + return best_val + return to_float(stats.get(metric)) + + +def mean_metric(summary: Dict[str, object], metric: str) -> Optional[float]: + folds = summary.get("fold_metrics") or [] + if not isinstance(folds, list) or not folds: + return None + values = [] + for fold in folds: + stats = fold.get("stats") if isinstance(fold, dict) else None + if not isinstance(stats, dict): + return None + val = metric_from_stats(stats, metric) + if val is None: + return None + values.append(val) + return mean(values) + + +def flatten_config(data: Dict[str, object], + prefix: str = "", + exclude_keys: Optional[Iterable[str]] = None) -> Dict[str, object]: + out: Dict[str, object] = {} + excludes = set(exclude_keys or []) + for key, value in data.items(): + if key in excludes or key.startswith("best_"): + continue + full_key = f"{prefix}{key}" if not prefix else f"{prefix}.{key}" + if isinstance(value, dict): + out.update(flatten_config(value, full_key, exclude_keys=excludes)) + continue + if isinstance(value, list): + continue + out[full_key] = value + return out + + +def rankdata(values: List[float]) -> List[float]: + order = sorted(range(len(values)), key=lambda i: values[i]) + ranks = [0.0] * len(values) + i = 0 + while i < len(values): + j = i + while j + 1 < len(values) and values[order[j + 1]] == values[order[i]]: + j += 1 + avg_rank = (i + j) / 2.0 + 1.0 + for k in range(i, j + 1): + ranks[order[k]] = avg_rank + i = j + 1 + return ranks + + +def pearson(x: List[float], y: List[float]) -> Optional[float]: + if len(x) != len(y) or len(x) < 2: + return None + mean_x = sum(x) / len(x) + mean_y = sum(y) / len(y) + num = sum((xi - mean_x) * (yi - mean_y) for xi, yi in zip(x, y)) + den_x = sum((xi - mean_x) ** 2 for xi in x) + den_y = sum((yi - mean_y) ** 2 for yi in y) + if den_x <= 0 or den_y <= 0: + return None + return num / math.sqrt(den_x * den_y) + + +def spearman(x: List[float], y: List[float]) -> Optional[float]: + rx = rankdata(x) + ry = rankdata(y) + return pearson(rx, ry) + + +def correlation_ratio(categories: List[object], values: List[float]) -> Optional[float]: + if len(categories) != len(values) or len(values) < 2: + return None + overall = mean(values) + if overall is None: + return None + total = sum((v - overall) ** 2 for v in values) + if total <= 0: + return None + sums: Dict[object, List[float]] = {} + for cat, val in zip(categories, values): + sums.setdefault(cat, []).append(val) + between = 0.0 + for vals in sums.values(): + avg = mean(vals) + if avg is None: + continue + between += len(vals) * (avg - overall) ** 2 + return math.sqrt(between / total) + + +def format_value(value: object) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return f"{value:.6g}" + return str(value) + + +def format_metric(value: Optional[float]) -> str: + if value is None: + return "" + return f"{value:.4f}" + + +def format_table(rows: List[Dict[str, object]], columns: List[str]) -> str: + col_widths = { + col: max(len(col), max((len(str(row.get(col, ""))) for row in rows), default=0)) + for col in columns + } + header = " | ".join(col.ljust(col_widths[col]) for col in columns) + divider = "-+-".join("-" * col_widths[col] for col in columns) + body = [ + " | ".join(str(row.get(col, "")).ljust(col_widths[col]) for col in columns) + for row in rows + ] + return "\n".join([header, divider, *body]) + + +def render_progress(current: int, total: Optional[int], matched: int) -> str: + if total: + width = 30 + filled = int(width * current / total) + bar = "#" * filled + "-" * (width - filled) + return f"[{bar}] {current}/{total} matched {matched}" + return f"Scanned {current} dirs, matched {matched}" + + +def iter_run_dirs(root: Path, shallow: bool, show_progress: bool) -> Iterable[Path]: + if shallow: + entries = [entry for entry in root.iterdir() if entry.is_dir()] + entries.sort(key=lambda p: p.name) + total = len(entries) + matched = 0 + last_update = 0.0 + for idx, entry in enumerate(entries, start=1): + if show_progress: + now = time.monotonic() + if now - last_update >= 0.1 or idx == total: + msg = render_progress(idx, total, matched) + print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True) + last_update = now + if (entry / "summary.json").is_file(): + matched += 1 + yield entry + if show_progress: + print(file=sys.stderr) + return + + matched = 0 + scanned = 0 + last_update = 0.0 + for dirpath, dirnames, filenames in os.walk(root): + scanned += 1 + if show_progress: + now = time.monotonic() + if now - last_update >= 0.2: + msg = render_progress(scanned, None, matched) + print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True) + last_update = now + if "summary.json" in filenames: + matched += 1 + yield Path(dirpath) + if show_progress: + msg = render_progress(scanned, None, matched) + print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True) + print(file=sys.stderr) + + +def main() -> None: + ap = argparse.ArgumentParser(description="Correlate grid search parameters with performance.") + ap.add_argument("--analysis-dir", type=Path, default=Path("analysis_data/grid_search"), + help="Directory containing run subdirectories") + ap.add_argument("--task", choices=["binary", "multiclass", "all"], default="all", + help="Filter runs by task type (default: all)") + ap.add_argument("--metric", choices=sorted(METRIC_MAP.keys()), default="holdout_auc", + help="Performance metric to analyze (default: holdout_auc)") + ap.add_argument("--sort-by", choices=["score", "abs_rho", "rho", "r", "eta"], default="score", + help="Sorting key for results (default: score)") + ap.add_argument("--asc", action="store_true", + help="Sort ascending (default: descending)") + ap.add_argument("--desc", action="store_true", + help="Sort descending (default: descending)") + ap.add_argument("--top", type=int, default=30, + help="Limit output to top N parameters (default: 30)") + ap.add_argument("--params", default=None, + help="Comma-separated list of parameter keys to include") + ap.add_argument("--exclude", default=None, + help="Comma-separated list of parameter keys to exclude") + ap.add_argument("--min-count", type=int, default=10, + help="Minimum runs required to analyze a parameter (default: 10)") + ap.add_argument("--min-unique", type=int, default=2, + help="Minimum unique values required (default: 2)") + ap.add_argument("--match", default=None, + help="Only include run directories whose name contains this substring") + ap.add_argument("--shallow", action="store_true", + help="Only scan directories directly under analysis-dir") + ap.add_argument("--no-progress", action="store_true", + help="Disable progress output") + args = ap.parse_args() + + if args.asc and args.desc: + raise SystemExit("Choose only one of --asc or --desc.") + + root = args.analysis_dir + if not root.exists(): + raise SystemExit(f"Analysis directory not found: {root}") + + exclude_keys = set(DEFAULT_EXCLUDE_KEYS) + if args.exclude: + for item in args.exclude.split(","): + item = item.strip() + if item: + exclude_keys.add(item) + + runs: List[Dict[str, object]] = [] + values_by_key: Dict[str, List[object]] = {} + missing_summary = 0 + unknown_task = 0 + missing_cli = 0 + + metric_key = METRIC_MAP[args.metric] + + for run_dir in iter_run_dirs(root, shallow=args.shallow, show_progress=not args.no_progress): + if args.match and args.match not in run_dir.name: + continue + summary = read_summary(run_dir) + if summary is None: + missing_summary += 1 + continue + task_label = task_from_summary(summary) + if args.task != "all": + if task_label is None: + unknown_task += 1 + continue + if task_label != args.task: + continue + + metric_value = mean_metric(summary, metric_key) + if metric_value is None: + continue + + cli_args = read_cli_args(run_dir) + if cli_args is None: + missing_cli += 1 + config_source = cli_args if cli_args is not None else summary + config = flatten_config(config_source, exclude_keys=exclude_keys) + + run_id = read_run_id(run_dir, summary) + runs.append({ + "run_id": run_id, + "metric": metric_value, + "config": config, + }) + for key, value in config.items(): + values_by_key.setdefault(key, []).append(value) + + if not runs: + print("No matching runs found.") + return + + if args.params: + param_keys = [p.strip() for p in args.params.split(",") if p.strip()] + else: + param_keys = [] + for key, values in values_by_key.items(): + unique_values = {format_value(v) for v in values} + if len(unique_values) >= args.min_unique: + param_keys.append(key) + param_keys.sort() + + rows: List[Dict[str, object]] = [] + for key in param_keys: + values = [] + metrics = [] + for run in runs: + config = run.get("config", {}) + if key not in config: + continue + values.append(config[key]) + metrics.append(run["metric"]) + + if len(values) < args.min_count: + continue + + unique_values = {format_value(v) for v in values} + if len(unique_values) < args.min_unique: + continue + + numeric_values: List[float] = [] + numeric_ok = True + for v in values: + num = to_float(v) + if num is None or isinstance(v, bool): + numeric_ok = False + break + numeric_values.append(num) + + groups: Dict[object, List[float]] = {} + for val, metric in zip(values, metrics): + groups.setdefault(val, []).append(metric) + group_means = {k: mean(v) for k, v in groups.items()} + best_group = max(group_means.items(), key=lambda item: item[1] or float("-inf")) + worst_group = min(group_means.items(), key=lambda item: item[1] or float("inf")) + + if numeric_ok and len(set(numeric_values)) >= 3: + rho = spearman(numeric_values, metrics) + r = pearson(numeric_values, metrics) + score = abs(rho) if rho is not None else None + row = { + "param": key, + "type": "numeric", + "n": len(values), + "distinct": len(unique_values), + "score": format_metric(score) if score is not None else "", + "rho": format_metric(rho), + "r": format_metric(r), + "best_value": format_value(best_group[0]), + "best_mean": format_metric(best_group[1]), + "worst_value": format_value(worst_group[0]), + "worst_mean": format_metric(worst_group[1]), + } + else: + eta = correlation_ratio(values, metrics) + score = eta + row = { + "param": key, + "type": "categorical", + "n": len(values), + "distinct": len(unique_values), + "score": format_metric(score) if score is not None else "", + "rho": "", + "r": "", + "best_value": format_value(best_group[0]), + "best_mean": format_metric(best_group[1]), + "worst_value": format_value(worst_group[0]), + "worst_mean": format_metric(worst_group[1]), + } + + rows.append(row) + + if not rows: + print("No parameters met the minimum requirements.") + return + + def sort_key(row: Dict[str, object]) -> float: + raw = row.get(args.sort_by) + if isinstance(raw, str): + val = to_float(raw) + else: + val = to_float(raw) + if val is None: + return float("inf") if args.asc else float("-inf") + return float(val) + + rows.sort(key=sort_key, reverse=not args.asc) + if args.top is not None: + rows = rows[:args.top] + + header_lines = [] + header_lines.append(f"Metric: {args.metric} (mean over folds)") + if args.task != "all": + header_lines.append(f"Task filter: {args.task}") + if args.match: + header_lines.append(f"Name filter: {args.match}") + header_lines.append(f"Runs: {len(runs)}") + if missing_cli: + header_lines.append(f"Missing cli_args: {missing_cli}") + if missing_summary or unknown_task: + header_lines.append(f"Skipped: {missing_summary} missing summary, {unknown_task} unknown task") + header_lines.append("") + print("\n".join(header_lines)) + + columns = [ + "param", + "type", + "n", + "distinct", + "score", + "rho", + "r", + "best_value", + "best_mean", + "worst_value", + "worst_mean", + ] + print(format_table(rows, columns)) + + +if __name__ == "__main__": + main() diff --git a/scripts/grid_search_analytics/rerun_grid_item.py b/scripts/grid_search_analytics/rerun_grid_item.py new file mode 100755 index 0000000..57fc243 --- /dev/null +++ b/scripts/grid_search_analytics/rerun_grid_item.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""Re-run a single grid-search configuration into analysis_data/re_runs.""" +from __future__ import annotations + +import argparse +import csv +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Dict, List + + +# --------------------------- +# Config (edit in IDE) +# --------------------------- +RUN_ID = "20251129-0063" # fallback if --run-number is not provided +OUTPUT_RUN_ID = RUN_ID # fallback output run id +SHORTNAME = "re_runs" # output root under analysis_data/ and models/ +GRID_PLAN = Path("analysis_data/grid_search/grid_plan.csv") +MANIFEST = Path("manifest.csv") +RUN_SCRIPT = Path("scripts/run_multifold.py") +REBUILD_SCRIPT = Path("scripts/rebuild_run_best_plots.py") +PLOT_HEADS = ["fused", "image", "metadata"] +USE_HOLDOUT_BEST_FOR_PLOTS = True +OVERWRITE_HOLDOUT_PROBS = True +ALLOW_EXISTING_RUN_DIR = False + + +def _parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser(description="Re-run a single grid-search item.") + ap.add_argument( + "--run-number", + type=str, + default=None, + help="Last 4 digits of run_id (e.g., 0063).", + ) + ap.add_argument( + "--output-run-id", + type=str, + default=None, + help="Optional output run id; defaults to matched run_id.", + ) + return ap.parse_args() + + +def _read_plan(path: Path) -> List[Dict[str, str]]: + if not path.exists(): + raise FileNotFoundError(f"Grid plan not found: {path}") + with path.open(newline="") as fh: + reader = csv.DictReader(fh) + return list(reader) + + +def _find_row(rows: List[Dict[str, str]], run_id: str) -> Dict[str, str]: + for row in rows: + if row.get("run_id") == run_id: + return row + raise ValueError(f"run_id not found in grid plan: {run_id}") + + +def _resolve_run_id(rows: List[Dict[str, str]], run_number: str | None) -> str: + if not run_number: + return RUN_ID + run_number = str(run_number).strip() + if run_number.isdigit(): + run_number = run_number.zfill(4) + matches = [ + r.get("run_id", "") + for r in rows + if str(r.get("run_id", "")).endswith(f"-{run_number}") + ] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise ValueError( + f"Multiple run_ids matched run-number '{run_number}': {matches[:5]}{' ...' if len(matches) > 5 else ''}" + ) + raise ValueError(f"No run_id found ending with '-{run_number}'") + + +def _build_run_command(row: Dict[str, str], output_run_id: str) -> List[str]: + cmd = [ + sys.executable, + str(RUN_SCRIPT), + "--backbone", + "resnet50", + "--fusion-mode", + "fused", + "--epochs", + "40", + "--batch-size", + "8", + "--img-crop-manifest", + str(MANIFEST), + "--img-crop-weights", + row["crop_weights"], + "--img-crop-normalize", + row["crop_normalize"], + "--eval_mode", + row["eval_mode"], + "--holdout-per-class", + "12", + "--run-id", + output_run_id, + "--shortname", + SHORTNAME, + ] + if row.get("crop_tta") == "True": + cmd.append("--img-crop-tta") + + loss_mode = row.get("loss_mode") + if loss_mode == "focal": + cmd.extend(["--focal-gamma", "2.0"]) + elif loss_mode == "balanced": + cmd.append("--balanced-sampler") + + thaw_mode = row.get("thaw_mode") + if thaw_mode == "gradual": + cmd.append("--gradual-thaw") + cmd.extend(["--thaw-ratio", "0.33"]) + cmd.extend(["--thaw-start-epoch", "10"]) + cmd.extend(["--thaw-target", "image"]) + + se_mode = row.get("se_mode") + if se_mode == "none": + cmd.append("--no-se") + else: + cmd.extend(["--se-reduction", "16"]) + cmd.extend(["--se-reduction-tower", "16"]) + cmd.extend(["--se-where", se_mode]) + bridge_pre = row.get("se_bridge_pre_norm") + tower_pre = row.get("se_tower_pre_norm") + if bridge_pre == "True": + cmd.append("--se-pre-norm") + elif bridge_pre == "False": + cmd.append("--no-se-pre-norm") + if tower_pre == "True": + cmd.append("--se-pre-norm-tower") + elif tower_pre == "False": + cmd.append("--no-se-pre-norm-tower") + + return cmd + + +def _swap_in_holdout_best(models_dir: Path) -> None: + for fold_dir in sorted(models_dir.glob("fold*")): + if not fold_dir.is_dir(): + continue + holdout_best = fold_dir / "model_holdout_best.pt" + model_best = fold_dir / "model_best.pt" + if not holdout_best.exists(): + print(f"[warn] {holdout_best} missing; skipping.") + continue + if model_best.exists(): + backup = fold_dir / "model_best_from_train.pt" + if not backup.exists(): + try: + shutil.copy2(model_best, backup) + except Exception: + pass + try: + shutil.copy2(holdout_best, model_best) + except Exception as exc: + print(f"[warn] failed to replace {model_best}: {exc}") + + +def _run_rebuild(run_dir: Path) -> None: + for head in PLOT_HEADS: + cmd = [ + sys.executable, + str(REBUILD_SCRIPT), + "--run-dir", + str(run_dir), + "--head", + head, + "--use-holdout", + ] + if OVERWRITE_HOLDOUT_PROBS: + cmd.append("--overwrite") + print("[rerun] Rebuilding holdout ROC plots:", " ".join(cmd)) + subprocess.run(cmd, check=True) + + +def main() -> None: + args = _parse_args() + rows = _read_plan(GRID_PLAN) + run_id = _resolve_run_id(rows, args.run_number) + row = _find_row(rows, run_id) + output_run_id = args.output_run_id or run_id + + run_dir = Path("analysis_data") / SHORTNAME / output_run_id + if run_dir.exists() and not ALLOW_EXISTING_RUN_DIR: + raise SystemExit( + f"Run directory already exists: {run_dir} (set ALLOW_EXISTING_RUN_DIR=True to reuse)" + ) + + cmd = _build_run_command(row, output_run_id=output_run_id) + print("[rerun] Launching:", " ".join(cmd)) + subprocess.run(cmd, check=True) + + models_dir = Path("models") / SHORTNAME / output_run_id + if USE_HOLDOUT_BEST_FOR_PLOTS: + print("[rerun] Swapping in holdout-best checkpoints for plotting.") + _swap_in_holdout_best(models_dir) + + _run_rebuild(run_dir) + print(f"[rerun] Done. Outputs in {run_dir}") + + +if __name__ == "__main__": + main() diff --git a/scripts/grid_search_analytics/rerun_grid_item_v2.py b/scripts/grid_search_analytics/rerun_grid_item_v2.py new file mode 100644 index 0000000..e7c5d63 --- /dev/null +++ b/scripts/grid_search_analytics/rerun_grid_item_v2.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Re-run a single grid-search configuration using the V2 loader pipeline.""" +from __future__ import annotations + +import argparse +import csv +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Dict, List + + +# --------------------------- +# Config (edit in IDE) +# --------------------------- +RUN_ID = "20251129-0063" # fallback if --run-number is not provided +OUTPUT_RUN_ID = RUN_ID # fallback output run id +SHORTNAME = "re_runs_v2" # output root under analysis_data/ and models/ +GRID_PLAN = Path("analysis_data/grid_search/grid_plan.csv") +MANIFEST = Path("manifest.csv") +RUN_SCRIPT = Path("scripts/run_multifold_v2.py") +REBUILD_SCRIPT = Path("scripts/rebuild_run_best_plots.py") +PLOT_HEADS = ["fused", "image", "metadata"] +USE_HOLDOUT_BEST_FOR_PLOTS = True +OVERWRITE_HOLDOUT_PROBS = True +ALLOW_EXISTING_RUN_DIR = False +SAMPLE_MODE = "eye" # eye-level for parity with v1 grid runs + + +def _parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser(description="Re-run a single grid-search item with V2 loaders.") + ap.add_argument( + "--run-number", + type=str, + default=None, + help="Last 4 digits of run_id (e.g., 0063).", + ) + ap.add_argument( + "--output-run-id", + type=str, + default=None, + help="Optional output run id; defaults to matched run_id.", + ) + return ap.parse_args() + + +def _read_plan(path: Path) -> List[Dict[str, str]]: + if not path.exists(): + raise FileNotFoundError(f"Grid plan not found: {path}") + with path.open(newline="") as fh: + reader = csv.DictReader(fh) + return list(reader) + + +def _find_row(rows: List[Dict[str, str]], run_id: str) -> Dict[str, str]: + for row in rows: + if row.get("run_id") == run_id: + return row + raise ValueError(f"run_id not found in grid plan: {run_id}") + + +def _resolve_run_id(rows: List[Dict[str, str]], run_number: str | None) -> str: + if not run_number: + return RUN_ID + run_number = str(run_number).strip() + if run_number.isdigit(): + run_number = run_number.zfill(4) + matches = [ + r.get("run_id", "") + for r in rows + if str(r.get("run_id", "")).endswith(f"-{run_number}") + ] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise ValueError( + f"Multiple run_ids matched run-number '{run_number}': {matches[:5]}{' ...' if len(matches) > 5 else ''}" + ) + raise ValueError(f"No run_id found ending with '-{run_number}'") + + +def _build_run_command(row: Dict[str, str], output_run_id: str) -> List[str]: + cmd = [ + sys.executable, + str(RUN_SCRIPT), + "--backbone", + "resnet50", + "--fusion-mode", + "fused", + "--epochs", + "40", + "--batch-size", + "8", + "--img-crop-manifest", + str(MANIFEST), + "--img-crop-weights", + row["crop_weights"], + "--img-crop-normalize", + row["crop_normalize"], + "--eval_mode", + row["eval_mode"], + "--holdout-per-class", + "12", + "--run-id", + output_run_id, + "--shortname", + SHORTNAME, + "--sample-mode", + SAMPLE_MODE, + ] + if row.get("crop_tta") == "True": + cmd.append("--img-crop-tta") + + loss_mode = row.get("loss_mode") + if loss_mode == "focal": + cmd.extend(["--focal-gamma", "2.0"]) + elif loss_mode == "balanced": + cmd.append("--balanced-sampler") + + thaw_mode = row.get("thaw_mode") + if thaw_mode == "gradual": + cmd.append("--gradual-thaw") + cmd.extend(["--thaw-ratio", "0.33"]) + cmd.extend(["--thaw-start-epoch", "10"]) + cmd.extend(["--thaw-target", "image"]) + + se_mode = row.get("se_mode") + if se_mode == "none": + cmd.append("--no-se") + else: + cmd.extend(["--se-reduction", "16"]) + cmd.extend(["--se-reduction-tower", "16"]) + cmd.extend(["--se-where", se_mode]) + bridge_pre = row.get("se_bridge_pre_norm") + tower_pre = row.get("se_tower_pre_norm") + if bridge_pre == "True": + cmd.append("--se-pre-norm") + elif bridge_pre == "False": + cmd.append("--no-se-pre-norm") + if tower_pre == "True": + cmd.append("--se-pre-norm-tower") + elif tower_pre == "False": + cmd.append("--no-se-pre-norm-tower") + + return cmd + + +def _swap_in_holdout_best(models_dir: Path) -> None: + for fold_dir in sorted(models_dir.glob("fold*")): + if not fold_dir.is_dir(): + continue + holdout_best = fold_dir / "model_holdout_best.pt" + model_best = fold_dir / "model_best.pt" + if not holdout_best.exists(): + print(f"[warn] {holdout_best} missing; skipping.") + continue + if model_best.exists(): + backup = fold_dir / "model_best_from_train.pt" + if not backup.exists(): + try: + shutil.copy2(model_best, backup) + except Exception: + pass + try: + shutil.copy2(holdout_best, model_best) + except Exception as exc: + print(f"[warn] failed to replace {model_best}: {exc}") + + +def _run_rebuild(run_dir: Path) -> None: + for head in PLOT_HEADS: + cmd = [ + sys.executable, + str(REBUILD_SCRIPT), + "--run-dir", + str(run_dir), + "--head", + head, + "--use-holdout", + ] + if OVERWRITE_HOLDOUT_PROBS: + cmd.append("--overwrite") + print("[rerun] Rebuilding holdout ROC plots:", " ".join(cmd)) + subprocess.run(cmd, check=True) + + +def main() -> None: + args = _parse_args() + rows = _read_plan(GRID_PLAN) + run_id = _resolve_run_id(rows, args.run_number) + row = _find_row(rows, run_id) + output_run_id = args.output_run_id or run_id + + run_dir = Path("analysis_data") / SHORTNAME / output_run_id + if run_dir.exists() and not ALLOW_EXISTING_RUN_DIR: + raise SystemExit( + f"Run directory already exists: {run_dir} (set ALLOW_EXISTING_RUN_DIR=True to reuse)" + ) + + cmd = _build_run_command(row, output_run_id=output_run_id) + print("[rerun] Launching:", " ".join(cmd)) + subprocess.run(cmd, check=True) + + models_dir = Path("models") / SHORTNAME / output_run_id + if USE_HOLDOUT_BEST_FOR_PLOTS: + print("[rerun] Swapping in holdout-best checkpoints for plotting.") + _swap_in_holdout_best(models_dir) + + _run_rebuild(run_dir) + print(f"[rerun] Done. Outputs in {run_dir}") + + +if __name__ == "__main__": + main() diff --git a/scripts/grid_search_analytics/run_derived_analysis.py b/scripts/grid_search_analytics/run_derived_analysis.py new file mode 100644 index 0000000..3eacf13 --- /dev/null +++ b/scripts/grid_search_analytics/run_derived_analysis.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +from pathlib import Path + +from scripts.grid_search_analytics.derived_analysis import derived_analysis + + +def parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser( + description="Generate derived grid-search analytics artifacts (fusion/error + param-performance)." + ) + ap.add_argument("--analysis-dir", default="analysis_data/grid_search") + ap.add_argument("--mode", choices=["binary", "multiclass"], default="multiclass") + ap.add_argument("--method", choices=["pearson", "spearman"], default="spearman") + ap.add_argument( + "--x-metric", + choices=["fusion_corrections", "fusion_corrections_per_opportunity"], + default="fusion_corrections_per_opportunity", + help="Fusion-correlation x-axis metric for summary bar plot.", + ) + ap.add_argument( + "--cat-method", + choices=["eta", "anova", "kruskal"], + default="kruskal", + help="Categorical-test method for param-performance correlations.", + ) + ap.add_argument("--top-n", type=int, default=None, help="Optional cap for per-run plots.") + ap.add_argument( + "--recompute", + action="store_true", + help="Recompute from run artifacts instead of preferring cached CSVs.", + ) + ap.add_argument( + "--deep-scan", + action="store_true", + help="Scan nested directories instead of direct children only.", + ) + return ap.parse_args() + + +def main() -> int: + args = parse_args() + analysis = derived_analysis( + Path(args.analysis_dir), + classification_mode=args.mode, + ) + + shallow = not args.deep_scan + existing = not args.recompute + + analysis.identify_fusion_corrections(shallow=shallow, existing=existing) + analysis.populate_primary_metrics(shallow=shallow, existing=existing) + + analysis.write_fusion_corrections() + analysis.write_fusion_errors() + analysis.write_primary_metrics() + + analysis.plot_fusion_corrections_errors( + shallow=shallow, existing=existing, top_n=args.top_n + ) + analysis.plot_conf_delta_boxplot( + shallow=shallow, existing=existing, top_n=args.top_n + ) + + corr_df = analysis.param_performance_correlations( + shallow=shallow, + existing=existing, + method=args.method, + cat_method=args.cat_method, + ) + analysis.plot_param_perf_corr_panels(corr_df) + + corr_acc = analysis.fusion_corrections_correlation( + method=args.method, metric_type="acc" + ) + corr_auc = analysis.fusion_corrections_correlation( + method=args.method, metric_type="auc" + ) + try: + analysis.plot_fusion_perf_summary( + corr_acc, + corr_auc, + method=args.method, + x_metric=args.x_metric, + ) + except RuntimeError: + analysis.plot_fusion_perf_summary( + corr_acc, + corr_auc, + method=args.method, + x_metric="fusion_corrections", + ) + + print(f"Done. Outputs written under: {Path(args.analysis_dir) / 'plots'}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/legacy/classic_ml_roc.py b/scripts/legacy/classic_ml_roc.py new file mode 100755 index 0000000..b8695dd --- /dev/null +++ b/scripts/legacy/classic_ml_roc.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Run classical ML models and plot *combined* ROC curves (multimodel overlays). + +Keeps your original workflow for folds/tests exactly the same. +Only changes: collects predictions per test and makes: + • One ROC plot per class (OvR), overlaying all models + • One binary ROC plot (Healthy vs Glaucoma), overlaying all models +""" +import os +import re +from pathlib import Path +from typing import Iterable, List, Tuple, Dict, Optional + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt + +from sklearn.preprocessing import StandardScaler, label_binarize +from sklearn.pipeline import Pipeline +from sklearn.linear_model import LogisticRegression +from sklearn.neighbors import KNeighborsClassifier +from sklearn.ensemble import RandomForestClassifier +from sklearn.svm import SVC +from sklearn.metrics import roc_curve, auc + +from classes import build_papila_clinical + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +SPLIT_ROOT = Path("HelpCode/kfold") +TRUST_INDEX_COL = False + +# --------------------------------------------------------------------------- +# Feature matrix +# --------------------------------------------------------------------------- +def build_feature_matrix(clinical): + df = clinical.df.copy() + scalars = ["Age", "dioptre_1", "dioptre_2", "astigmatism", "Pachymetry", "Axial_Length", "IOP_corr"] + cats = ["Gender", "Phakic/Pseudophakic"] + X = pd.concat([df[scalars], pd.get_dummies(df[cats].astype("category"), drop_first=False, prefix=cats)], axis=1) + y = df[clinical.label_col].astype(int).values + return X, y, scalars, df # X keeps NaNs; we impute per-fold + +# --------------------------------------------------------------------------- +# Models with tuned hyper-parameters (unchanged) +# --------------------------------------------------------------------------- +def make_models() -> Dict[str, Pipeline]: + return { + "LogReg": Pipeline([ + ("scaler", StandardScaler()), + ("clf", LogisticRegression( + C=1, + class_weight="balanced", + max_iter=200, + solver="lbfgs", + multi_class="auto")), + ]), + "kNN": Pipeline([ + ("scaler", StandardScaler()), + ("clf", KNeighborsClassifier( + n_neighbors=11, weights="distance")), + ]), + "RF": Pipeline([ + ("clf", RandomForestClassifier(n_estimators=200, max_depth=8, + min_samples_split=4, random_state=42)), + ]), + "SVM": Pipeline([ + ("scaler", StandardScaler()), + ("clf", SVC(C=10, kernel="rbf", gamma=0.1, probability=True)), + ]), + } + +# --------------------------------------------------------------------------- +# Split helpers copied from paper_clinical_baselines_official.py (unchanged) +# --------------------------------------------------------------------------- +_FNAME_RE = re.compile(r"RET\s*(\d+)\s*([Oo][DSs])\.jpg$", re.IGNORECASE) + +def _read_sheet_any(p: Path) -> pd.DataFrame: + if p.suffix.lower() == ".xlsx": + return pd.read_excel(p) + if p.suffix.lower() == ".csv": + return pd.read_csv(p) + if p.suffix.lower() == ".txt": + lines = [ln.strip() for ln in p.read_text(encoding="utf-8", errors="ignore").splitlines() if ln.strip()] + return pd.DataFrame({"filename": lines}) + raise ValueError(f"Unsupported split file type: {p.suffix}") + +def _normcols(cols: List[str]) -> Dict[str, str]: + def norm(s: str) -> str: + return re.sub(r"[^a-z0-9]", "", s.lower()) + return {norm(c): c for c in cols} + +def _parse_fname_to_pid_eye(fname: str) -> Optional[Tuple[int, str]]: + base = os.path.basename(str(fname)) + m = _FNAME_RE.search(base.replace(" ", "")) + if not m: + return None + return int(m.group(1)), m.group(2).upper() + +def _rows_from_sheet(sheet: pd.DataFrame, df_master: pd.DataFrame) -> List[int]: + cols = _normcols(list(sheet.columns)) + if "filename" in cols: + fn_col = cols["filename"] + lookup: Dict[str, List[int]] = {} + for i, (pid, eye) in enumerate(zip(df_master["Patient ID"].astype(int), df_master["eyeID"].astype(str))): + lookup.setdefault(f"{pid}|{eye.upper()}", []).append(i) + rows: List[int] = [] + for fn in sheet[fn_col].astype(str).tolist(): + pe = _parse_fname_to_pid_eye(fn) + if pe is None: + continue + pid, eye = pe + rows.extend(lookup.get(f"{pid}|{eye}", [])) + return rows + if "patientid" in cols and "eyeid" in cols: + pid_col, eye_col = cols["patientid"], cols["eyeid"] + lookup = {} + for i, (pid, eye) in enumerate(zip(df_master["Patient ID"].astype(int), df_master["eyeID"].astype(str))): + lookup.setdefault(f"{pid}|{eye.upper()}", []).append(i) + rows = [] + for pid, eye in zip(sheet[pid_col], sheet[eye_col]): + rows.extend(lookup.get(f"{int(pid)}|{str(eye).upper()}", [])) + return rows + if TRUST_INDEX_COL and "index" in cols: + idx = sheet[cols["index"]].astype(int).tolist() + n = len(df_master) + return [i for i in idx if 0 <= i < n] + raise RuntimeError("Split sheet missing usable columns") + +def _pair_train_test_files(dir_train: Path, dir_test: Path) -> List[Tuple[Path, Path]]: + def fold_key(p: Path) -> str: + m = re.search(r"(\d+)", p.stem) + return m.group(1) if m else p.stem.lower() + trains = sorted([p for p in dir_train.iterdir() if p.is_file() and p.suffix.lower() in (".xlsx", ".csv", ".txt")], key=fold_key) + tests = sorted([p for p in dir_test.iterdir() if p.is_file() and p.suffix.lower() in (".xlsx", ".csv", ".txt")], key=fold_key) + return [(trains[i], tests[i]) for i in range(min(len(trains), len(tests)))] + +def iter_official_folds_xlsx(clinical, split_root: Path, test_name: str) -> Iterable[Tuple[pd.DataFrame, pd.DataFrame]]: + df_master = clinical.df.copy() + test_dir = split_root / test_name + dir_train = test_dir / "Train" + dir_test = test_dir / "Test" + if not dir_train.exists() or not dir_test.exists(): + raise FileNotFoundError(f"Expected: {dir_train} and {dir_test}") + for train_file, test_file in _pair_train_test_files(dir_train, dir_test): + sh_tr, sh_te = _read_sheet_any(train_file), _read_sheet_any(test_file) + tr_rows, te_rows = _rows_from_sheet(sh_tr, df_master), _rows_from_sheet(sh_te, df_master) + tr_df, te_df = df_master.iloc[tr_rows].copy(), df_master.iloc[te_rows].copy() + yield tr_df, te_df + +# --------------------------------------------------------------------------- +# Utilities (unchanged) +# --------------------------------------------------------------------------- +def _prepare_fold_X(X: pd.DataFrame, scalars: List[str], tr_idx: np.ndarray, te_idx: np.ndarray): + Xtr, Xte = X.iloc[tr_idx].copy(), X.iloc[te_idx].copy() + med = Xtr[scalars].median(numeric_only=True) + Xtr[scalars] = Xtr[scalars].fillna(med) + Xte[scalars] = Xte[scalars].fillna(med) + return Xtr.values.astype(np.float32), Xte.values.astype(np.float32) + +# --------------------------------------------------------------------------- +# NEW: combined plotting helpers (multimodel overlays) +# --------------------------------------------------------------------------- +def _plot_multiclass_overlay(y_true: np.ndarray, prob_dict: Dict[str, np.ndarray], out_dir: Path, test_tag: str): + """One figure per class (OvR), overlaying all models.""" + n_classes = next(iter(prob_dict.values())).shape[1] + class_names = [f"Class{k}" for k in range(n_classes)] + y_bin = label_binarize(y_true, classes=list(range(n_classes))) + + for k in range(n_classes): + fig, ax = plt.subplots(figsize=(6, 5)) + for model_name, proba in prob_dict.items(): + fpr, tpr, _ = roc_curve(y_bin[:, k], proba[:, k]) + auc_val = auc(fpr, tpr) + ax.plot(fpr, tpr, lw=1.8, label=f"{model_name} (AUC={auc_val:.3f})") + ax.plot([0, 1], [0, 1], "k--", lw=1) + ax.set_xlabel("False Positive Rate") + ax.set_ylabel("True Positive Rate") + ax.set_title(f"{class_names[k]} vs Rest — {test_tag}") + ax.legend(loc="lower right") + ax.grid(True, alpha=0.3, linestyle="--") + fig.tight_layout() + fig.savefig(out_dir / f"{test_tag}_{class_names[k]}.png", dpi=170) + plt.close(fig) + +def _plot_binary_overlay(y_true: np.ndarray, prob1d_dict: Dict[str, np.ndarray], out_dir: Path, test_tag: str): + """One figure (Healthy vs Glaucoma), overlaying all models. Assumes y_true ∈ {0,1}.""" + fig, ax = plt.subplots(figsize=(6, 5)) + any_curve = False + for model_name, scores in prob1d_dict.items(): + if scores.size == 0: + continue + fpr, tpr, _ = roc_curve(y_true, scores, pos_label=1) + auc_val = auc(fpr, tpr) + ax.plot(fpr, tpr, lw=1.8, label=f"{model_name} (AUC={auc_val:.3f})") + any_curve = True + ax.plot([0, 1], [0, 1], "k--", lw=1) + ax.set_xlabel("False Positive Rate") + ax.set_ylabel("True Positive Rate") + ax.set_title(f"Binary Healthy vs Glaucoma — {test_tag}") + if any_curve: + ax.legend(loc="lower right") + ax.grid(True, alpha=0.3, linestyle="--") + fig.tight_layout() + fig.savefig(out_dir / f"{test_tag}_binary.png", dpi=170) + plt.close(fig) + +# --------------------------------------------------------------------------- +# Main (same folds/tests flow; only result collation & plotting changed) +# --------------------------------------------------------------------------- +def main(): + clinical = build_papila_clinical( + image_dir="Papila/FundusImages", + clinical_dir="Papila/ClinicalData", + label_col="Diagnosis", + cat_cols=["Gender", "Phakic/Pseudophakic"], + ) + X, y, scalars, _ = build_feature_matrix(clinical) + models = make_models() + out_dir = Path("analysis_data/roc_baselines") + out_dir.mkdir(parents=True, exist_ok=True) + + tests = [ + ("Test 3", False), ("Test 4", True) + ] if (SPLIT_ROOT / "Test 3").exists() else [ + ("Test 1", False), ("Test 2", True) + ] + + for test_name, is_binary in tests: + # Collect per-model probabilities following your original per-model loop. + # For multiclass: dict[model] -> (N, C) + # For binary: dict[model] -> (N,) (probability of class 1) + prob_dict_multi: Dict[str, np.ndarray] = {} + prob_dict_bin: Dict[str, np.ndarray] = {} + y_ref_multi: Optional[np.ndarray] = None + y_ref_bin: Optional[np.ndarray] = None + + for model_name, model in models.items(): + y_all: List[np.ndarray] = [] + p_all: List[np.ndarray] = [] + + for fold_idx, (train_df, test_df) in enumerate(iter_official_folds_xlsx(clinical, SPLIT_ROOT, test_name), 1): + # Keep your exact masking/handling + dup_rows = set(train_df.index).intersection(set(test_df.index)) + shared_pids = set(train_df["Patient ID"]).intersection(set(test_df["Patient ID"])) + if test_name in ("Test 1", "Test 2") and shared_pids: + train_df = train_df[~train_df["Patient ID"].isin(shared_pids)].copy() + dup_rows = set(train_df.index).intersection(set(test_df.index)) + shared_pids = set(train_df["Patient ID"]).intersection(set(test_df["Patient ID"])) + + tr_idx, te_idx = train_df.index.values, test_df.index.values + + if is_binary: + # original binary handling: drop Suspects on both sets + mask_tr = np.isin(y[tr_idx], [0, 1]) + mask_te = np.isin(y[te_idx], [0, 1]) + if not mask_tr.any() or not mask_te.any(): + # skip empty fold (keeps behavior safe without changing fold logic) + continue + Xtr, Xte = _prepare_fold_X(X, scalars, tr_idx[mask_tr], te_idx[mask_te]) + ytr, yte = y[tr_idx][mask_tr], y[te_idx][mask_te] + else: + Xtr, Xte = _prepare_fold_X(X, scalars, tr_idx, te_idx) + ytr, yte = y[tr_idx], y[te_idx] + + # Fit and score (unchanged approach) + model.fit(Xtr, ytr) + if is_binary: + if hasattr(model[-1], "predict_proba"): + prob = model.predict_proba(Xte)[:, 1] + else: + dec = model.decision_function(Xte) + prob = 1.0 / (1.0 + np.exp(-dec)) if np.ptp(dec) > 0 else np.full_like(dec, 0.5) + y_all.append(yte) + p_all.append(prob) + else: + if hasattr(model[-1], "predict_proba"): + prob = model.predict_proba(Xte) + else: + dec = model.decision_function(Xte) + if dec.ndim == 1: + dec = np.stack([-dec, dec], axis=1) + e = np.exp(dec - dec.max(axis=1, keepdims=True)) + prob = e / e.sum(axis=1, keepdims=True) + y_all.append(yte) + p_all.append(prob) + + if not y_all: + # No valid folds for this model under this test (e.g., all-bad after mask); skip + continue + + y_cat = np.concatenate(y_all) + p_cat = np.concatenate(p_all) + + if is_binary: + # Store 1D scores per model + prob_dict_bin[model_name] = p_cat + if y_ref_bin is None: + y_ref_bin = y_cat + else: + # Align lengths defensively (should match in normal use) + n = min(len(y_ref_bin), len(y_cat)) + y_ref_bin = y_ref_bin[:n] + prob_dict_bin[model_name] = prob_dict_bin[model_name][:n] + else: + # Store (N, C) per model + prob_dict_multi[model_name] = p_cat + if y_ref_multi is None: + y_ref_multi = y_cat + else: + # Align lengths defensively (should match in normal use) + n = min(len(y_ref_multi), len(y_cat)) + y_ref_multi = y_ref_multi[:n] + prob_dict_multi[model_name] = prob_dict_multi[model_name][:n, :] + + tag = test_name.replace(" ", "") + + # Produce overlays + if prob_dict_multi and y_ref_multi is not None: + _plot_multiclass_overlay(y_ref_multi, prob_dict_multi, out_dir, tag) + if prob_dict_bin and y_ref_bin is not None: + _plot_binary_overlay(y_ref_bin, prob_dict_bin, out_dir, tag) + +if __name__ == "__main__": + main() diff --git a/scripts/legacy/eval_papila_classifier.py b/scripts/legacy/eval_papila_classifier.py new file mode 100755 index 0000000..ba93e40 --- /dev/null +++ b/scripts/legacy/eval_papila_classifier.py @@ -0,0 +1,384 @@ +"""Evaluate REFUGE-trained classifier on Papila images using UNet crops.""" + +from __future__ import annotations + +import argparse +import csv +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Set + +import numpy as np +import torch +from torch.utils.data import DataLoader +from tqdm import tqdm +from PIL import Image, ImageDraw + +import sys + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes.refuge_preprocessing import RefugePreprocessing, RefugeSample +from classes.refuge_segmentation import RefugeSegmentation +from classes.refuge_classification import ( + RefugeClassification, + RefugeClassificationDataset, + RefugeClassificationRecord, + UNetGeometryProvider, + _default_image_transform, + _geometry_from_mask, +) +from classes.backbones import BACKBONES, load_backbone_weights +from classes.unet_segmenter import UNetSegmenter +from classes.papila_builders import build_papila_clinical + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Evaluate classifier on Papila with UNet crops") + parser.add_argument("--filtered-metrics", type=Path, required=True, help="CSV of Papila samples with acceptable Dice") + parser.add_argument("--segmenter-manifest", type=Path, required=True, help="Manifest used to train the UNet segmenter") + parser.add_argument("--segmenter-weights", type=Path, required=True, help="Path to trained UNet weights (best.pt)") + parser.add_argument("--classifier-weights", type=Path, required=False, help="Path to classifier checkpoint (refuge_classifier_best.pt)") + parser.add_argument("--refuge-root", type=Path, default=Path("REFUGE")) + parser.add_argument("--image-dir", type=Path, default=Path("Papila/FundusImages")) + parser.add_argument("--clinical-dir", type=Path, default=Path("Papila/ClinicalData")) + parser.add_argument("--label-col", type=str, default="Diagnosis", help="Column name holding Papila labels") + parser.add_argument( + "--positive-labels", + nargs="*", + default=["glaucoma", "glaucoma suspect", "suspect"], + help="Values treated as glaucoma-positive when labels are non-numeric", + ) + parser.add_argument("--dice-threshold", type=float, default=0.01, help="Minimum Dice (disc or cup) to keep a sample") + parser.add_argument("--segmenter-threshold", type=float, default=0.5, help="Probability threshold for UNet geometry") + parser.add_argument("--segmenter-normalize", choices=["none", "imagenet", "per_image"], default="per_image") + parser.add_argument("--segmenter-tta", action="store_true", help="Enable TTA (H/V flips) when deriving geometry") + parser.add_argument("--crop-scale", type=float, default=2.5) + parser.add_argument("--crop-size", type=int, default=224) + parser.add_argument("--batch-size", type=int, default=32) + parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") + parser.add_argument("--output", type=Path, default=None, help="Optional CSV to store per-sample probabilities") + parser.add_argument( + "--cache-dir", + type=Path, + default=Path("analysis_data/classifier_cache"), + help="Directory to reuse classifier preprocessing cache", + ) + parser.add_argument( + "--use-gt-masks", + action="store_true", + help="Use ground truth Papila contours instead of UNet predictions", + ) + parser.add_argument( + "--gt-contours-dir", + type=Path, + default=Path("Papila/ExpertsSegmentations/Contours"), + help="Directory containing Papila contour text files", + ) + parser.add_argument( + "--backbone", + type=str, + default=None, + help="Optional backbone name (e.g. inception_v3, densenet121). Requires matching classifier weights.", + ) + return parser.parse_args() + + +def load_allowed_ids(path: Path, dice_threshold: float) -> Set[str]: + allowed: Set[str] = set() + with path.open(newline="") as fp: + reader = csv.DictReader(fp) + for row in reader: + sample_id = row.get("sample_id") + if not sample_id or sample_id == "__mean__": + continue + try: + disc = float(row.get("dice_disc", "nan")) + cup = float(row.get("dice_cup", "nan")) + except ValueError: + continue + if disc < dice_threshold and cup < dice_threshold: + continue + allowed.add(sample_id) + return allowed + + +def build_papila_samples( + image_dir: Path, + clinical_dir: Path, + label_col: str, + positive_labels: Sequence[str], + allowed_ids: Set[str], +) -> List[RefugeSample]: + clinical = build_papila_clinical( + image_dir=str(image_dir), + clinical_dir=str(clinical_dir), + label_col=label_col, + cat_cols=[], + ) + positives = {lbl.lower() for lbl in positive_labels} + samples: Dict[str, RefugeSample] = {} + for _, row in clinical.df.iterrows(): + image_path = clinical.get_image_path(row) + sample_id = f"papila_{image_path.stem}" + if sample_id not in allowed_ids or sample_id in samples: + continue + value = row.get(label_col) + if value is None or (isinstance(value, float) and np.isnan(value)): + continue + label: Optional[int] + try: + label_int = int(value) + if label_int == 2: + continue + label = 1 if label_int > 0 else 0 + except (TypeError, ValueError): + label = 1 if str(value).strip().lower() in positives else 0 + samples[sample_id] = RefugeSample( + sample_id=sample_id, + dataset="papila", + split="eval", + image_path=Path(image_path), + label=label, + device=None, + mask_path=None, + fovea_coord=None, + ) + return list(samples.values()) + + +def load_contour(path: Path) -> np.ndarray: + coords = np.loadtxt(path) + if coords.ndim == 1: + coords = coords.reshape(-1, 2) + return coords + + +def contour_to_mask(coords: np.ndarray, size: Sequence[int]) -> np.ndarray: + if coords is None or coords.size == 0: + return np.zeros((size[1], size[0]), dtype=np.uint8) + img = Image.new("L", size, 0) + draw = ImageDraw.Draw(img) + points = [tuple(map(float, pt)) for pt in coords] + draw.polygon(points, outline=1, fill=1) + return np.array(img, dtype=np.uint8) + + +class PapilaGTGeometryProvider: + def __init__(self, contours_dir: Path) -> None: + self.contours_dir = contours_dir + + def _pick(self, base: str, kind: str) -> Optional[Path]: + for exp in ("exp2", "exp1"): + cand = self.contours_dir / f"{base}_{kind}_{exp}.txt" + if cand.exists(): + return cand + return None + + def __call__(self, sample: RefugeSample, scale: float): + base = Path(sample.image_path).stem + disc_path = self._pick(base, "disc") + cup_path = self._pick(base, "cup") + if disc_path is None or cup_path is None: + raise RuntimeError(f"Missing ground-truth contours for {sample.sample_id}") + + image = Image.open(sample.image_path).convert("RGB") + disc_coords = load_contour(disc_path) + cup_coords = load_contour(cup_path) + disc_mask = contour_to_mask(disc_coords, image.size) + cup_mask = contour_to_mask(cup_coords, image.size) + cup_mask = ((cup_mask > 0) & (disc_mask > 0)).astype(np.uint8) + geom = _geometry_from_mask(disc_mask, scale) + return geom, disc_mask.astype(np.uint8), cup_mask.astype(np.uint8) + + +def build_backbone(name: Optional[str]) -> Optional[torch.nn.Module]: + if not name: + return None + key = name.lower() + if key not in BACKBONES: + raise ValueError(f"Unknown backbone '{name}'. Available: {', '.join(sorted(BACKBONES.keys()))}") + spec = BACKBONES[key] + model = spec.ctor(weights=spec.weights_default) + out_dim, model = spec.strip(model) + setattr(model, "_feature_dim", out_dim) + if key == "refugelike": + load_backbone_weights(key, model) + return model + + +def evaluate_records( + clf: RefugeClassification, + records: Sequence[RefugeClassificationRecord], + device: str, + batch_size: int, +) -> Dict[str, float]: + dataset = RefugeClassificationDataset( + records, + transform=clf.eval_transform, + polar_transform=clf.polar_transform, + size=clf.crop_size, + ) + loader = DataLoader(dataset, batch_size=batch_size, shuffle=False, num_workers=0) + clf.backbone.to(device).eval() + clf.classifier_head.to(device).eval() + preds: List[float] = [] + targets: List[int] = [] + with torch.no_grad(): + for batch in tqdm(loader, desc="Papila Eval", leave=False, unit="batch"): + images = batch["image"].to(device) + polars = batch["polar"].to(device) + extra_feats = batch["features"].to(device) + labels = batch["label"].cpu().numpy().tolist() + feats_img = clf.backbone(images) + feats = feats_img + if clf.use_polar: + feats_polar = clf.backbone(polars) + feats = torch.cat([feats, feats_polar], dim=1) + if clf.extra_feature_dim > 0: + feats = torch.cat([feats, extra_feats], dim=1) + logits = clf.classifier_head(feats) + probs = torch.softmax(logits, dim=1)[:, 1].cpu().numpy().tolist() + preds.extend(probs) + targets.extend(labels) + metrics: Dict[str, float] = {"count": float(len(targets))} + unique_labels = set(targets) + if len(unique_labels) >= 2: + metrics["auc"] = float(torchmetrics_auc(targets, preds)) + else: + metrics["auc"] = float("nan") + preds_bin = [1 if p >= 0.5 else 0 for p in preds] + accuracy = sum(int(p == t) for p, t in zip(preds_bin, targets)) / max(1, len(targets)) + metrics["accuracy"] = float(accuracy) + metrics["mean_prob"] = float(np.mean(preds)) if preds else float("nan") + metrics["labels_pos"] = float(sum(targets)) + if preds: + metrics["probs_std"] = float(np.std(preds)) + return metrics + + +def torchmetrics_auc(targets: Sequence[int], preds: Sequence[float]) -> float: + try: + from sklearn.metrics import roc_auc_score + except ImportError as exc: + raise RuntimeError("scikit-learn is required to compute AUC") from exc + + return float(roc_auc_score(targets, preds)) + + +def main() -> None: + args = parse_args() + device = args.device + + allowed_ids = load_allowed_ids(args.filtered_metrics, args.dice_threshold) + if not allowed_ids: + raise SystemExit("No Papila samples passed the Dice threshold.") + + papila_samples = build_papila_samples( + args.image_dir, + args.clinical_dir, + args.label_col, + args.positive_labels, + allowed_ids, + ) + if not papila_samples: + raise SystemExit("No Papila samples with labels matched the filtered metrics.") + + cache_dir = args.cache_dir + if args.use_gt_masks and cache_dir is not None: + cache_dir = cache_dir / "gt" + + if args.use_gt_masks: + geometry_provider = PapilaGTGeometryProvider(args.gt_contours_dir) + segmenter = None + else: + segmenter = UNetSegmenter( + manifest_path=args.segmenter_manifest, + device=device, + normalize=args.segmenter_normalize, + ) + seg_state = torch.load(args.segmenter_weights, map_location=device) + seg_state_dict = seg_state.get("model", seg_state) + segmenter.model.load_state_dict(seg_state_dict) + segmenter.model.to(device) + geometry_provider = UNetGeometryProvider( + segmenter=segmenter, + threshold=args.segmenter_threshold, + tta=args.segmenter_tta, + ) + + pre = RefugePreprocessing(args.refuge_root) + dummy_seg = RefugeSegmentation(pre) + backbone = build_backbone(args.backbone) + clf = RefugeClassification( + pre, + dummy_seg, + geometry_fn=geometry_provider, + cache_dir=cache_dir, + backbone=backbone, + ) + clf.crop_scale = args.crop_scale + clf.crop_size = args.crop_size + clf.eval_transform = _default_image_transform(args.crop_size) + clf.ttt_transform = clf.eval_transform + + if args.classifier_weights is not None: + clf_state = torch.load(args.classifier_weights, map_location=device) + clf.backbone.load_state_dict(clf_state["backbone"]) + clf.classifier_head.load_state_dict(clf_state["classifier"]) + clf.rotation_head.load_state_dict(clf_state["rotation"]) + if "feature_reg" in clf_state and getattr(clf, "feature_reg_head", None) is not None: + clf.feature_reg_head.load_state_dict(clf_state["feature_reg"]) + + records = clf.build_records_for_samples( + papila_samples, + crop_scale=args.crop_scale, + progress_prefix="papila_eval", + ) + if not records: + raise SystemExit("Unable to build any records; check geometry predictions or labels.") + + metrics = evaluate_records(clf, records, device=device, batch_size=args.batch_size) + print(f"Samples evaluated: {int(metrics['count'])}") + print(f"AUC: {metrics['auc']:.4f}" if not np.isnan(metrics['auc']) else "AUC: NaN") + print(f"Accuracy @0.5: {metrics['accuracy']:.4f}") + print(f"Mean glaucoma prob: {metrics['mean_prob']:.4f}") + + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + with args.output.open("w", newline="") as fp: + writer = csv.writer(fp) + writer.writerow(["sample_id", "prob_glaucoma", "label"]) + clf.backbone.eval() + clf.classifier_head.eval() + dataset = RefugeClassificationDataset( + records, + transform=clf.eval_transform, + polar_transform=clf.polar_transform, + size=clf.crop_size, + ) + loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=False, num_workers=0) + with torch.no_grad(): + for batch in tqdm(loader, desc="Papila Output", leave=False, unit="batch"): + images = batch["image"].to(device) + polars = batch["polar"].to(device) + extra_feats = batch["features"].to(device) + ids = batch["sample_id"] + labels = batch["label"].tolist() + feats_img = clf.backbone(images) + feats = feats_img + if clf.use_polar: + feats_polar = clf.backbone(polars) + feats = torch.cat([feats, feats_polar], dim=1) + if clf.extra_feature_dim > 0: + feats = torch.cat([feats, extra_feats], dim=1) + logits = clf.classifier_head(feats) + probs = torch.softmax(logits, dim=1)[:, 1].cpu().numpy().tolist() + for sid, prob, label in zip(ids, probs, labels): + writer.writerow([sid, prob, label]) + print(f"Per-sample probabilities written to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/scripts/legacy/extract_best_auc.py b/scripts/legacy/extract_best_auc.py new file mode 100755 index 0000000..db1e68d --- /dev/null +++ b/scripts/legacy/extract_best_auc.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +Quick utility to recover the best epoch metrics from HyperTower run folders. + +Example: + python scripts/extract_best_auc.py analysis_data/img_only_densenet_gt_bin/img_only_densenet_gt_bin_20251028_112733 + +By default it looks for columns named like `auc_fused` (set via --metric) inside each +`fold{n}_epoch_log.csv`, returning the epoch with the highest value plus the holdout +metrics, if present. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +from pathlib import Path +from typing import Dict, Optional, Tuple + + +def to_float(value: Optional[str]) -> Optional[float]: + if value is None: + return None + value = value.strip() + if not value: + return None + try: + out = float(value) + except ValueError: + return None + if math.isnan(out): + return None + return out + + +def best_row(path: Path, metric: str) -> Optional[Dict[str, str]]: + if not path.exists(): + return None + best: Optional[Tuple[float, int, Dict[str, str]]] = None + with path.open("r", newline="") as fp: + reader = csv.DictReader(fp) + for row in reader: + val = to_float(row.get(metric)) + if val is None: + continue + epoch = int(row.get("epoch", reader.line_num)) + if best is None or val > best[0]: + best = (val, epoch, row) + return best[2] if best else None + + +def summarize_fold(row: Dict[str, str], metric: str) -> Dict[str, float]: + data: Dict[str, float] = {} + for key in (metric, f"holdout_{metric.split('_', 1)[-1]}", "holdout_auc_img", "holdout_auc_fused"): + val = to_float(row.get(key)) + if val is not None: + data[key] = val + epoch_val = to_float(row.get("epoch")) + if epoch_val is not None: + data["epoch"] = int(epoch_val) + return data + + +def main() -> None: + ap = argparse.ArgumentParser(description="Extract best-per-fold metric from HyperTower runs.") + ap.add_argument("run_dir", type=Path, help="Run directory (contains fold*_epoch_log.csv)") + ap.add_argument("--metric", default="auc_fused", help="Metric column to maximise (default: auc_fused)") + ap.add_argument("--json", type=Path, default=None, help="Optional path to dump JSON summary") + args = ap.parse_args() + + run_dir: Path = args.run_dir + metric: str = args.metric + + if not run_dir.exists(): + raise SystemExit(f"Run directory not found: {run_dir}") + + fold_summaries: Dict[str, Dict[str, float]] = {} + metric_values = [] + + for csv_path in sorted(run_dir.glob("fold*_epoch_log.csv")): + best = best_row(csv_path, metric) + fold_name = csv_path.stem.replace("_epoch_log", "") + if best is None: + print(f"{fold_name}: no valid '{metric}' values found") + continue + summary = summarize_fold(best, metric) + fold_summaries[fold_name] = summary + val = summary.get(metric) + if val is not None: + metric_values.append(val) + holdout_val = summary.get(f"holdout_{metric.split('_', 1)[-1]}") + print(f"{fold_name}: epoch={summary.get('epoch')} {metric}={val:.4f}" if val is not None else f"{fold_name}: epoch={summary.get('epoch')}") + if holdout_val is not None: + print(f" holdout_{metric.split('_', 1)[-1]}={holdout_val:.4f}") + + if metric_values: + mean_val = sum(metric_values) / len(metric_values) + print(f"\nMean best {metric}: {mean_val:.4f}") + + if args.json: + payload = { + "run_dir": str(run_dir), + "metric": metric, + "folds": fold_summaries, + "mean_metric": (sum(metric_values) / len(metric_values)) if metric_values else None, + } + args.json.parent.mkdir(parents=True, exist_ok=True) + args.json.write_text(json.dumps(payload, indent=2)) + print(f"Summary written to {args.json}") + + +if __name__ == "__main__": + main() diff --git a/scripts/legacy/paper_metadata.py b/scripts/legacy/paper_metadata.py new file mode 100755 index 0000000..5e5e5cd --- /dev/null +++ b/scripts/legacy/paper_metadata.py @@ -0,0 +1,493 @@ + +import pandas as pd +from classes import HyperTower, ClinicalData, list_names, build_papila_clinical +from pathlib import Path +import shutil, json, textwrap +from datetime import datetime +import numpy as np +from typing import Dict, List, Tuple +from sklearn.model_selection import GroupKFold +from sklearn.preprocessing import StandardScaler +from sklearn.pipeline import Pipeline +from sklearn.metrics import roc_auc_score +from sklearn.linear_model import LogisticRegression +from sklearn.neighbors import KNeighborsClassifier +from sklearn.ensemble import RandomForestClassifier +from sklearn.svm import SVC + +from sklearn.preprocessing import label_binarize + +def _proba_from_model(model, X): + if hasattr(model[-1], "predict_proba"): + return model.predict_proba(X) + dec = model.decision_function(X) + if dec.ndim == 1: # binary margins -> make 2-col + dec = np.stack([-dec, dec], axis=1) + e = np.exp(dec - dec.max(axis=1, keepdims=True)) + return e / e.sum(axis=1, keepdims=True) + +def _cv_auc_multiclass_per_class(X, y, groups, model, n_splits=5) -> np.ndarray: + """ + Returns a length-3 array of mean OvR AUCs for Class0/1/2 across GroupKFold. + Uses nan-safe means if a class is absent in a fold's test split. + """ + gkf = GroupKFold(n_splits=n_splits) + per_class_lists = [[], [], []] + for tr, te in gkf.split(X, y, groups): + model.fit(X[tr], y[tr]) + proba = _proba_from_model(model, X[te]) + y_te = y[te] + y_bin = label_binarize(y_te, classes=[0, 1, 2]) # (n,3) + for k in range(3): + yk = y_bin[:, k] + if yk.min() != yk.max(): # both classes present + per_class_lists[k].append(roc_auc_score(yk, proba[:, k])) + else: + per_class_lists[k].append(np.nan) + return np.array([np.nanmean(per_class_lists[k]) for k in range(3)], dtype=float) + +def _cv_auc_binary(X, y, groups, model, n_splits=5) -> float: + mask = np.isin(y, [0, 1]) + Xb, yb, gb = X[mask], y[mask], groups[mask] + gkf = GroupKFold(n_splits=n_splits) + aucs = [] + for tr, te in gkf.split(Xb, yb, gb): + model.fit(Xb[tr], yb[tr]) + if hasattr(model[-1], "predict_proba"): + p = model.predict_proba(Xb[te])[:, 1] + else: + p = model.decision_function(Xb[te]) + # logistic squash for safety + if np.ptp(p) > 0: + p = 1.0 / (1.0 + np.exp(-p)) + else: + p = np.full_like(p, 0.5, dtype=float) + # only compute if both classes present + if len(np.unique(yb[te])) == 2: + aucs.append(roc_auc_score(yb[te], p)) + else: + aucs.append(np.nan) + return float(np.nanmean(aucs)) + + + +# ----------------------------------- +# 1) Build Clinical Data (paper-faithful) +# ----------------------------------- +IMAGE_DIR = "Papila/FundusImages" +CLINICAL_DIR = "Papila/ClinicalData" +LABEL_COL = "Diagnosis" +CAT_COLS = ["Gender", "Phakic/Pseudophakic"] + +paper_auc = { + "TEST3_multiclass": { # Class0=Healthy, Class1=Glaucoma, Class2=Suspect + "LogReg": {"Class0": 0.67, "Class1": 0.66, "Class2": 0.67}, # from Fig. 7 (rounded) + "kNN": {"Class0": 0.72, "Class1": 0.70, "Class2": 0.76}, # your read of Fig. 7 + "RF": {"Class0": 0.66, "Class1": 0.66, "Class2": 0.67}, # from Fig. 7 (rounded) + "SVM": {"Class0": 0.66, "Class1": 0.65, "Class2": 0.66}, # from Fig. 7 (rounded) + }, + "TEST4_binary": { # Healthy vs Glaucoma (Suspects removed) + "LogReg": 0.71, # from text/Fig. 7 range midpoint + "kNN": 0.75, # your read of Fig. 7 + "RF": 0.70, # from Fig. 7 (rounded) + "SVM": 0.69, # from Fig. 7 (rounded) + } +} + + +clinical = build_papila_clinical( + image_dir=IMAGE_DIR, + clinical_dir=CLINICAL_DIR, + label_col=LABEL_COL, + cat_cols=CAT_COLS, +) + +# ----------------------------------- +# 2) Feature matrix (no MD; IOP_corr already present) +# ----------------------------------- +def build_feature_matrix(clinical) -> Tuple[np.ndarray, np.ndarray, np.ndarray, List[str]]: + """ + Returns: + X: features (N x D) + y: labels (Diagnosis: 0 healthy, 1 glaucoma, 2 suspect) + groups: patient IDs for GroupKFold + feat_names: list of feature names in X order + """ + df = clinical.df.copy() + + # Scalars used in paper-style baselines (no VF_MD) + scalars = ["Age", "dioptre_1", "dioptre_2", "astigmatism", + "Pachymetry", "Axial_Length", "IOP_corr"] + + # Categorical one-hot + cats = ["Gender", "Phakic/Pseudophakic"] + df_cats = pd.get_dummies(df[cats].astype("category"), drop_first=False, prefix=cats) + + # Combine + X = pd.concat([df[scalars], df_cats], axis=1) + + # Median impute numerics (simple, consistent) + for c in scalars: + med = pd.to_numeric(X[c], errors="coerce").median() + X[c] = pd.to_numeric(X[c], errors="coerce").fillna(med) + + y = df[LABEL_COL].astype(int).values + groups = df["Patient ID"].astype(int).values + feat_names = list(X.columns) + return X.values.astype(np.float32), y, groups, feat_names + +# ---------------------------- +# 3) Model zoo (the four methods used in the paper) +# ---------------------------- +def make_models(best_params: dict | None = None, random_state: int = 42) -> dict: + """ + Build paper-like baseline models. If best_params is provided (a dict mapping + model-name -> param dict with pipeline-style keys like 'clf__C'), those + params are applied to the corresponding pipelines. + """ + models = { + "LogReg": Pipeline([ + ("scaler", StandardScaler()), + ("clf", LogisticRegression( + max_iter=100, + solver="lbfgs", + multi_class="auto" + )) + ]), + "kNN": Pipeline([ + ("scaler", StandardScaler()), + ("clf", KNeighborsClassifier( + n_neighbors=5, + weights="uniform", + metric="minkowski", + p=2 + )) + ]), + "RF": Pipeline([ + ("clf", RandomForestClassifier( + n_estimators=100, + criterion="gini", + max_depth=None, + min_samples_split=2, + min_samples_leaf=1, + max_features="sqrt", + bootstrap=True, + # random_state left as default; set via best_params if desired + )) + ]), + "SVM": Pipeline([ + ("scaler", StandardScaler()), + ("clf", SVC( + C=1.0, + kernel="rbf", + gamma="scale", + probability=False + )) + ]), + } + + # Apply overrides if provided + if best_params: + for name, params in best_params.items(): + if name in models and params: + models[name].set_params(**params) + + return models + + +# ----------------------------------- +# 4) CV AUCs (mean over 5 folds; GroupKFold by patient) +# ----------------------------------- +def _cv_auc_multiclass(X, y, groups, model, n_splits=5) -> float: + gkf = GroupKFold(n_splits=n_splits) + aucs = [] + for tr, te in gkf.split(X, y, groups): + model.fit(X[tr], y[tr]) + if hasattr(model[-1], "predict_proba"): + proba = model.predict_proba(X[te]) + else: + dec = model.decision_function(X[te]) + if dec.ndim == 1: + dec = np.stack([-dec, dec], axis=1) + e = np.exp(dec - dec.max(axis=1, keepdims=True)) + proba = e / e.sum(axis=1, keepdims=True) + aucs.append(roc_auc_score(y[te], proba, multi_class="ovr", average="macro")) + return float(np.mean(aucs)) + + +def _cv_auc_binary(X, y, groups, model, n_splits=5) -> float: + # Keep classes 0 (healthy) and 1 (glaucoma); drop suspects (2) + mask = np.isin(y, [0, 1]) + Xb, yb, gb = X[mask], y[mask], groups[mask] + + gkf = GroupKFold(n_splits=n_splits) + aucs = [] + for tr, te in gkf.split(Xb, yb, gb): + model.fit(Xb[tr], yb[tr]) + if hasattr(model[-1], "predict_proba"): + p = model.predict_proba(Xb[te])[:, 1] + else: + p = model.decision_function(Xb[te]) + # simple logistic squashing if needed + if np.ptp(p) > 0: + p = 1.0 / (1.0 + np.exp(-p)) + else: + p = np.full_like(p, 0.5, dtype=float) + aucs.append(roc_auc_score(yb[te], p)) + return float(np.mean(aucs)) + +# ----------------------------------- +# 5) Run both tests (multiclass + binary) and print table +# ----------------------------------- +def run_papila_clinical_baselines(clinical, n_splits: int = 5, + random_state: int = 42, + best_params: dict | None = None) -> pd.DataFrame: + X, y, groups, feat_names = build_feature_matrix(clinical) + models = make_models(best_params=best_params, random_state=random_state) + + rows = [] + for name, model in models.items(): + c0, c1, c2 = _cv_auc_multiclass_per_class(X, y, groups, model, n_splits=n_splits) + auc_bin = _cv_auc_binary(X, y, groups, model, n_splits=n_splits) + rows.append({"model": name, "Class0": c0, "Class1": c1, "Class2": c2, "Binary": auc_bin}) + + df = pd.DataFrame(rows).set_index("model").sort_index() + return df + + +results = run_papila_clinical_baselines(clinical, n_splits=5) +# print(results.to_string(float_format=lambda x: f"{x:.3f}")) + + + + + + + + + + + + + + + +############################## +from sklearn.model_selection import ParameterGrid +from sklearn.base import clone +from sklearn.preprocessing import label_binarize +from sklearn.utils import check_random_state + +# ============================== +# Helper: per-class & binary AUC with GroupKFold +# ============================== +def _proba_from_model(model, X): + if hasattr(model[-1], "predict_proba"): + return model.predict_proba(X) + # decision_function fallback + dec = model.decision_function(X) + if dec.ndim == 1: # binary margin -> 2-col probs + dec = np.stack([-dec, dec], axis=1) + e = np.exp(dec - dec.max(axis=1, keepdims=True)) + return e / e.sum(axis=1, keepdims=True) + +def _cv_auc_perclass_and_binary(X, y, groups, model, n_splits=5): + """ + Returns: + per_class_auc: length-3 array (Class0, Class1, Class2) averaged over folds + binary_auc: scalar (0 vs 1) averaged over folds + """ + gkf = GroupKFold(n_splits=n_splits) + + # Hold fold-wise per-class AUCs (list of arrays of length 3) + perclass_fold_scores = [] + binary_fold_scores = [] + + for tr, te in gkf.split(X, y, groups): + y_te = y[te] + # Multiclass per-class (OvR) + model.fit(X[tr], y[tr]) + proba = _proba_from_model(model, X[te]) + + # One-vs-rest per-class AUCs (skip a class if absent in test fold) + y_bin = label_binarize(y_te, classes=[0, 1, 2]) # shape (n, 3) + perclass_scores = [] + for k in range(3): + yk = y_bin[:, k] + # Only compute if both 0 and 1 are present + if yk.min() != yk.max(): + perclass_scores.append(roc_auc_score(yk, proba[:, k])) + else: + perclass_scores.append(np.nan) + perclass_fold_scores.append(perclass_scores) + + # Binary AUC (0 vs 1; drop class 2) + mask = np.isin(y_te, [0, 1]) + if mask.sum() > 0 and len(np.unique(y_te[mask])) == 2: + # we need probabilities/margins for class 1 among (0,1) + # Map proba[:, 1] if the model was trained 3-way; we restrict te samples to 0/1 + binary_p = proba[mask, 1] + binary_y = y_te[mask] + binary_fold_scores.append(roc_auc_score(binary_y, binary_p)) + else: + binary_fold_scores.append(np.nan) + + # Average over folds (ignore NaNs if a class was missing in a fold) + perclass_arr = np.array(perclass_fold_scores, dtype=float) # (n_folds, 3) + per_class_auc = np.nanmean(perclass_arr, axis=0) + binary_auc = float(np.nanmean(np.array(binary_fold_scores, dtype=float))) + return per_class_auc, binary_auc + +# ============================== +# Distance-to-paper objective +# ============================== +def _distance_to_paper(model_name: str, + per_class_auc: np.ndarray, + binary_auc: float, + paper_auc: Dict, + w_mc: float = 1.0, + w_bin: float = 1.0) -> float: + mc_targets = paper_auc["TEST3_multiclass"][model_name] + tvec = np.array([mc_targets["Class0"], mc_targets["Class1"], mc_targets["Class2"]], dtype=float) + mc_diff = np.nanmean(np.abs(per_class_auc - tvec)) # mean absolute difference over 3 classes + + bin_target = paper_auc["TEST4_binary"][model_name] + bin_diff = abs(binary_auc - bin_target) + + return float(w_mc * mc_diff + w_bin * bin_diff) + +# ============================== +# Parameter grids (paper-ish, not crazy-large) +# ============================== +def get_param_grids() -> Dict[str, List[dict]]: + return { + "LogReg": [ + { + "clf__C": [0.01, 0.1, 1.0, 3.0, 10.0], + "clf__class_weight": [None, "balanced"], + "clf__max_iter": [200, 500], + # lbfgs + l2 is implied + } + ], + "kNN": [ + { + "clf__n_neighbors": [3, 5, 7, 9, 11], + "clf__weights": ["uniform", "distance"], + "clf__p": [1, 2], # Manhattan vs Euclidean + } + ], + "RF": [ + { + "clf__n_estimators": [200, 500, 1000], + "clf__max_depth": [None, 5, 10, 20], + "clf__max_features": ["sqrt", "log2", 0.5], + "clf__min_samples_leaf": [1, 2, 5], + "clf__class_weight": [None, "balanced"], + # If you want determinism add: "clf__random_state": [42] + } + ], + "SVM": [ + { + "clf__C": [0.1, 1.0, 3.0, 10.0], + "clf__gamma": ["scale", "auto", 0.1, 0.01, 0.001], + "clf__kernel": ["rbf"], # fixed to rbf as in paper-like default + } + ], + } + +# ============================== +# Grid search loop minimizing distance-to-paper +# ============================== +def search_params_to_match_paper( + clinical, + models: Dict[str, Pipeline], + paper_auc: Dict, + n_splits: int = 5, + w_mc: float = 1.0, + w_bin: float = 1.0, + verbose: bool = True, +) -> Tuple[pd.DataFrame, Dict[str, dict]]: + X, y, groups, feat_names = build_feature_matrix(clinical) + grids = get_param_grids() + + summary_rows = [] + best_params_by_model = {} + + for name, base_model in models.items(): + if name not in grids: + if verbose: + print(f"[warn] No grid for {name}, skipping.") + continue + + best_loss = np.inf + best_params = None + best_mc = None + best_bin = None + + for param_set in ParameterGrid(grids[name]): + model = clone(base_model).set_params(**param_set) + per_class_auc, binary_auc = _cv_auc_perclass_and_binary( + X, y, groups, model, n_splits=n_splits + ) + loss = _distance_to_paper( + name, per_class_auc, binary_auc, paper_auc, w_mc=w_mc, w_bin=w_bin + ) + + if verbose: + mc_str = " / ".join(f"{a:.3f}" if np.isfinite(a) else "nan" for a in per_class_auc) + print(f"[{name}] params={param_set} | mc per-class={mc_str} | bin={binary_auc:.3f} | loss={loss:.4f}") + + if loss < best_loss: + best_loss = loss + best_params = param_set + best_mc = per_class_auc + best_bin = binary_auc + + # store + best_params_by_model[name] = best_params + summary_rows.append({ + "model": name, + "best_loss": best_loss, + "best_params": json.dumps(best_params), + "mc_Class0": float(best_mc[0]), + "mc_Class1": float(best_mc[1]), + "mc_Class2": float(best_mc[2]), + "binary_auc": float(best_bin), + "paper_mc_Class0": paper_auc["TEST3_multiclass"][name]["Class0"], + "paper_mc_Class1": paper_auc["TEST3_multiclass"][name]["Class1"], + "paper_mc_Class2": paper_auc["TEST3_multiclass"][name]["Class2"], + "paper_binary": paper_auc["TEST4_binary"][name], + }) + + df = pd.DataFrame(summary_rows).set_index("model").sort_values("best_loss") + return df, best_params_by_model + +# ============================== +# Run the search +# ============================== +models = make_models(random_state=42) +df_match, best_params = search_params_to_match_paper( + clinical=clinical, + models=models, + paper_auc=paper_auc, + n_splits=5, + w_mc=1.0, # weight multiclass distance + w_bin=1.0, # weight binary distance + verbose=True +) + +# print("\n=== Best params found (by minimal distance-to-paper) ===") +# print(df_match[["best_loss","best_params","mc_Class0","mc_Class1","mc_Class2","binary_auc", +# "paper_mc_Class0","paper_mc_Class1","paper_mc_Class2","paper_binary"]]) + +# print("\nBest param dicts:") +for k, v in best_params.items(): + print(k, "->", v) + +results2 = run_papila_clinical_baselines(clinical, n_splits=5, random_state=42, best_params=best_params) +print(f"Default Settings: {results.round(2)}") +print(f"Best Params Settings: {results2.round(2)}") +print(f" Paper Results: {pd.DataFrame({ + model: {**vals, "Binary": paper_auc["TEST4_binary"][model]} + for model, vals in paper_auc["TEST3_multiclass"].items() +}).T[["Class0","Class1","Class2","Binary"]]}") \ No newline at end of file diff --git a/scripts/legacy/run_all_sweep.sh b/scripts/legacy/run_all_sweep.sh new file mode 100755 index 0000000..4bfc813 --- /dev/null +++ b/scripts/legacy/run_all_sweep.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: +# bash scripts/run_all_sweep.sh --epochs 25 --n-splits 5 --batch-size 8 [extra args] +# +# Merged sweep: runs the SE attention grid (bridge/tower/both × R=8/16/32), +# skipping tower-only non-normalized variants (tower normalization is a no-op), +# and includes binary eval counterparts for each baseline run. It also submits +# the full gradual-thaw grid (multiclass + binary variants). + +ARGS=("$@") + +run() { + local SHORT="$1"; shift + echo "=== Running: $SHORT ===" + # Skip if a summary for this shortname already exists + if ls "analysis_data/${SHORT}_"*.md >/dev/null 2>&1; then + echo "… skipping ${SHORT} (summary already present)" + return 0 + fi + python3 scripts/run_multifold.py \ + --shortname "$SHORT" \ + "$@" \ + "${ARGS[@]}" || true +} + +echo "--- SE Grid (bridge/tower/both × R=8/16/32; tower nonorm skipped) ---" +for R in 8 16 32; do + # Bridge-only + run "se_bridge_R${R}_norm" --se-where bridge --se-reduction ${R} --se-pre-norm --checkpoint-best + run "se_bridge_R${R}_norm_bin" --se-where bridge --se-reduction ${R} --se-pre-norm --checkpoint-best --eval_mode binary + run "se_bridge_R${R}_nonorm" --se-where bridge --se-reduction ${R} --no-se-pre-norm --checkpoint-best + run "se_bridge_R${R}_nonorm_bin" --se-where bridge --se-reduction ${R} --no-se-pre-norm --checkpoint-best --eval_mode binary + + # Tower-only + run "se_tower_R${R}_norm" --se-where tower --se-reduction-tower ${R} --se-pre-norm-tower --checkpoint-best + run "se_tower_R${R}_norm_bin" --se-where tower --se-reduction-tower ${R} --se-pre-norm-tower --checkpoint-best --eval_mode binary + + # Tower+Bridge + run "se_tower_bridge_R${R}_norm" \ + --se-where both --se-reduction ${R} --se-reduction-tower ${R} \ + --se-pre-norm --se-pre-norm-tower --checkpoint-best + run "se_tower_bridge_R${R}_norm_bin" \ + --se-where both --se-reduction ${R} --se-reduction-tower ${R} \ + --se-pre-norm --se-pre-norm-tower --checkpoint-best --eval_mode binary + run "se_tower_bridge_R${R}_nonorm" \ + --se-where both --se-reduction ${R} --se-reduction-tower ${R} \ + --no-se-pre-norm --no-se-pre-norm-tower --checkpoint-best + run "se_tower_bridge_R${R}_nonorm_bin" \ + --se-where both --se-reduction ${R} --se-reduction-tower ${R} \ + --no-se-pre-norm --no-se-pre-norm-tower --checkpoint-best --eval_mode binary +done + +THAW_COMMON_ARGS=( + --gradual-thaw + --thaw-phase-duration 5 + --thaw-ratio 0.33 + --thaw-start-epoch 5 + --early-stop + --early-patience 5 +) + +echo "--- Gradual Thaw Grid (multiclass + binary) ---" + +# Bridge-only thaw runs (norm and nonorm) +for R in 8 16 32; do + for MODE in norm nonorm; do + if [[ "$MODE" == "norm" ]]; then + FLAGS=(--se-where bridge --se-reduction "$R" --se-pre-norm --checkpoint-best) + else + FLAGS=(--se-where bridge --se-reduction "$R" --no-se-pre-norm --checkpoint-best) + fi + run "thaw_se_bridge_R${R}_${MODE}" "${FLAGS[@]}" "${THAW_COMMON_ARGS[@]}" + run "thawbin_se_bridge_R${R}_${MODE}" "${FLAGS[@]}" "${THAW_COMMON_ARGS[@]}" --eval_mode binary + done +done + +# Tower-only thaw runs (norm and nonorm) +for R in 8 16 32; do + for MODE in norm nonorm; do + if [[ "$MODE" == "norm" ]]; then + FLAGS=(--se-where tower --se-reduction-tower "$R" --se-pre-norm-tower --checkpoint-best) + else + FLAGS=(--se-where tower --se-reduction-tower "$R" --no-se-pre-norm-tower --checkpoint-best) + fi + run "thaw_se_tower_R${R}_${MODE}" "${FLAGS[@]}" "${THAW_COMMON_ARGS[@]}" + run "thawbin_se_tower_R${R}_${MODE}" "${FLAGS[@]}" "${THAW_COMMON_ARGS[@]}" --eval_mode binary + done +done + +# Tower+bridge thaw runs (norm and nonorm) +for R in 8 16 32; do + for MODE in norm nonorm; do + if [[ "$MODE" == "norm" ]]; then + FLAGS=( + --se-where both + --se-reduction "$R" + --se-reduction-tower "$R" + --se-pre-norm + --se-pre-norm-tower + --checkpoint-best + ) + else + FLAGS=( + --se-where both + --se-reduction "$R" + --se-reduction-tower "$R" + --no-se-pre-norm + --no-se-pre-norm-tower + --checkpoint-best + ) + fi + run "thaw_se_tower_bridge_R${R}_${MODE}" "${FLAGS[@]}" "${THAW_COMMON_ARGS[@]}" + run "thawbin_se_tower_bridge_R${R}_${MODE}" "${FLAGS[@]}" "${THAW_COMMON_ARGS[@]}" --eval_mode binary + done +done + +echo "Merged sweep submitted. Check analysis_data/* and models/* for outputs." diff --git a/scripts/legacy/run_gradual_thaw_top5.sh b/scripts/legacy/run_gradual_thaw_top5.sh new file mode 100755 index 0000000..5109c24 --- /dev/null +++ b/scripts/legacy/run_gradual_thaw_top5.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: +# bash scripts/run_gradual_thaw_top5.sh --epochs 20 --n-splits 5 --batch-size 8 [extra args] +# +# Runs the full gradual-thaw grid aligned with the SE sweep (bridge/tower/both × R=8/16/32 × norm vs nonorm). + +ARGS=("$@") + +run() { + local SHORT="$1"; shift + echo "=== Running: $SHORT ===" + if ls "analysis_data/${SHORT}_"*.md >/dev/null 2>&1; then + echo "… skipping ${SHORT} (summary already present)" + return 0 + fi + python3 scripts/run_multifold.py \ + --shortname "$SHORT" \ + --gradual-thaw --thaw-phase-duration 5 --thaw-ratio 0.33 --thaw-start-epoch 5 \ + --early-stop --early-patience 5 \ + "$@" \ + "${ARGS[@]}" || true +} + +# Bridge-only thaw runs +for R in 8 16 32; do + for MODE in norm nonorm; do + if [[ "$MODE" == "norm" ]]; then + FLAGS=(--se-where bridge --se-reduction "$R" --se-pre-norm --checkpoint-best) + else + FLAGS=(--se-where bridge --se-reduction "$R" --no-se-pre-norm --checkpoint-best) + fi + run "thaw_se_bridge_R${R}_${MODE}" "${FLAGS[@]}" + run "thawbin_se_bridge_R${R}_${MODE}" "${FLAGS[@]}" --eval_mode binary + done +done + +# Tower-only thaw runs +for R in 8 16 32; do + for MODE in norm nonorm; do + if [[ "$MODE" == "norm" ]]; then + FLAGS=(--se-where tower --se-reduction-tower "$R" --se-pre-norm-tower --checkpoint-best) + else + FLAGS=(--se-where tower --se-reduction-tower "$R" --no-se-pre-norm-tower --checkpoint-best) + fi + run "thaw_se_tower_R${R}_${MODE}" "${FLAGS[@]}" + run "thawbin_se_tower_R${R}_${MODE}" "${FLAGS[@]}" --eval_mode binary + done +done + +# Tower+bridge thaw runs +for R in 8 16 32; do + for MODE in norm nonorm; do + if [[ "$MODE" == "norm" ]]; then + FLAGS=( + --se-where both + --se-reduction "$R" + --se-reduction-tower "$R" + --se-pre-norm + --se-pre-norm-tower + --checkpoint-best + ) + else + FLAGS=( + --se-where both + --se-reduction "$R" + --se-reduction-tower "$R" + --no-se-pre-norm + --no-se-pre-norm-tower + --checkpoint-best + ) + fi + run "thaw_se_tower_bridge_R${R}_${MODE}" "${FLAGS[@]}" + run "thawbin_se_tower_bridge_R${R}_${MODE}" "${FLAGS[@]}" --eval_mode binary + done +done + +echo "Gradual thaw grid submitted. Check analysis_data/* and models/* for outputs." diff --git a/scripts/legacy/run_multifold_gui.py b/scripts/legacy/run_multifold_gui.py new file mode 100755 index 0000000..faf9e30 --- /dev/null +++ b/scripts/legacy/run_multifold_gui.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Launch the Tkinter front-end for run_multifold.""" + +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes.frontend import launch_frontend + + +if __name__ == "__main__": + launch_frontend() diff --git a/scripts/legacy/run_multimodel.py b/scripts/legacy/run_multimodel.py new file mode 100755 index 0000000..726faa0 --- /dev/null +++ b/scripts/legacy/run_multimodel.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +import argparse, subprocess, sys, time, json +from pathlib import Path + +# Backbones in the paper that torchvision supports +BACKBONES = [ + "efficientnet_b0", + "resnet50", + "densenet121", + "vgg16", + "mobilenet_v2", + "inception_v3", + # (Xception omitted; not in torchvision — add via timm later if needed) +] + +MODES = [ + ("multiclass", ["Healthy", "Glaucoma", "Suspect"]), + ("binary", ["Healthy", "Glaucoma"]), +] + +def run(cmd): + print("\n$ " + " ".join(map(str, cmd))) + res = subprocess.run(cmd, check=True) + return res.returncode + +def main(): + ap = argparse.ArgumentParser(description="Run all paper CNNs across folds in multiclass + binary, then compile plots.") + ap.add_argument("--epochs", type=int, default=5, help="Epochs per fold (fast sanity first).") + ap.add_argument("--shortname", type=str, default="papergrid", help="Prefix for run IDs.") + ap.add_argument("--n-splits", type=int, default=5, help="Number of folds.") + ap.add_argument("--fusion-mode", type=str, default="fused", choices=["image_only","fused","metadata_only","vote"], + help="Paper CNNs are image-only; leave as image_only unless you’re testing others.") + ap.add_argument("--freeze-ratio", type=float, default=0.0, help="0.0 = full fine-tune (as in the paper).") + # You can override data roots if needed + ap.add_argument("--image-dir", default="Papila/FundusImages") + ap.add_argument("--clinical-dir", default="Papila/ClinicalData") + ap.add_argument("--label-col", default="Diagnosis") + ap.add_argument("--cat-cols", nargs="*", default=["Gender", "Phakic/Pseudophakic"]) + args = ap.parse_args() + + ts = time.strftime("%Y%m%d_%H%M%S") + master_tag = f"{args.shortname}_{ts}" + master_dir = Path("analysis_data") / master_tag + master_dir.mkdir(parents=True, exist_ok=True) + + # Keep a log of all subruns for the master report + index = [] + + for backbone in BACKBONES: + for eval_mode, class_names in MODES: + # build a child shortname per (backbone, mode) + sub_prefix = f"{args.shortname}_{backbone}_{eval_mode}" + cmd = [ + sys.executable, "scripts/run_multifold.py", + "--backbone", backbone, + "--freeze-ratio", str(args.freeze_ratio), + "--fusion-mode", args.fusion_mode, + "--epochs", str(args.epochs), + "--n-splits", str(args.n_splits), + "--shortname", sub_prefix, + "--eval_mode", eval_mode, + "--image-dir", args.image_dir, + "--clinical-dir", args.clinical_dir, + "--label-col", args.label_col, + ] + + # class names by mode (ensures plot legends are correct) + cmd += ["--class-names", *class_names] + + plot_head_map = { + "image_only": "image", + "fused" : "fused", + "metadata_only": "metadata", + "vote": "fused", + } + # We always aggregate/plot the image head for paper CNNs + cmd += ["--plot-head", plot_head_map.get(args.fusion_mode)] + + # Delegate the whole run to run_multifold.py + run(cmd) + + # Discover the child run folder (the newest folder matching the shortname prefix) + # We do this because run_multifold appends its own timestamp. + adir = Path("analysis_data") + children = sorted([p for p in adir.glob(f"{sub_prefix}_*") if p.is_dir()]) + if not children: + print(f"[WARN] No analysis_data folder found for {sub_prefix}; skipping index entry.") + continue + run_dir = children[-1] + summary_json = run_dir / "summary.json" + plots_dir = run_dir / "plots" + + # Record entry + entry = { + "backbone": backbone, + "eval_mode": eval_mode, + "run_dir": str(run_dir), + "summary_json": str(summary_json) if summary_json.exists() else None, + "plots": { + "mean": str(plots_dir / "roc_image_mean_ovr.png"), + "overlay": str(plots_dir / "roc_image_perfold_overlay.png"), + } + } + # Try to read AUCs + try: + if summary_json.exists(): + entry.update(json.loads(summary_json.read_text())) + except Exception: + pass + index.append(entry) + + # Write a master JSON + markdown report + (master_dir / "index.json").write_text(json.dumps(index, indent=2), encoding="utf-8") + + # Simple markdown table of results with links + lines = [ + f"# Multimodel grid — {master_tag}", + "", + f"- Epochs per fold: **{args.epochs}**", + f"- Folds: **{args.n_splits}**", + f"- Fusion mode: **{args.fusion_mode}** (paper CNNs = image-only)", + f"- Freeze ratio: **{args.freeze_ratio}**", + "", + "| Backbone | Mode | Mean AUC (macro/mc or ROC-AUC/bin) | Plots | Run folder |", + "|---|---|---:|---|---|", + ] + for e in index: + auc_mean = e.get("macro_ovr_auc_mean", None) + if auc_mean is not None: + auc_str = f"{auc_mean:.3f}" + else: + auc_str = "—" + mean_png = e["plots"]["mean"] + overlay_png = e["plots"]["overlay"] + plots_md = f"[mean]({mean_png}) / [overlay]({overlay_png})" + lines.append( + f"| `{e['backbone']}` | `{e['eval_mode']}` | {auc_str} | {plots_md} | `{e['run_dir']}` |" + ) + (master_dir / "README.md").write_text("\n".join(lines) + "\n", encoding="utf-8") + + print(f"\nAll done.\n- Master index: {master_dir/'index.json'}\n- Report: {master_dir/'README.md'}") + print(f"- Individual runs live under analysis_data/ with plots and summaries.") + +if __name__ == "__main__": + main() diff --git a/scripts/legacy/run_se_sweep.sh b/scripts/legacy/run_se_sweep.sh new file mode 100755 index 0000000..1bf307c --- /dev/null +++ b/scripts/legacy/run_se_sweep.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: +# bash scripts/run_se_sweep.sh --epochs 50 --n-splits 5 --batch-size 8 --eval_mode multiclass [extra args] +# +# This will launch a series of runs covering the grid from the slide: +# - Bridge-only R8/R16/R32 (normalized and non-normalized) +# - Tower-only R8/R16/R32 (normalized and non-normalized) +# - Tower+Bridge R8/R16/R32 (normalized and non-normalized) + +ARGS=("$@") + +run() { + local SHORT="$1"; shift + echo "=== Running: $SHORT ===" + # Skip if a summary for this shortname already exists + if ls "analysis_data/${SHORT}_"*.md >/dev/null 2>&1; then + echo "… skipping ${SHORT} (summary already present)" + return 0 + fi + python3 scripts/run_multifold.py \ + --shortname "$SHORT" \ + "$@" \ + "${ARGS[@]}" || true +} + +# Bridge-only (normalized + non-normalized) +for R in 8 16 32; do + run "se_bridge_R${R}_norm" --se-where bridge --se-reduction ${R} --se-pre-norm --checkpoint-best + run "se_bridge_R${R}_nonorm" --se-where bridge --se-reduction ${R} --no-se-pre-norm --checkpoint-best +done + +# Tower-only (normalized + non-normalized) +for R in 8 16 32; do + run "se_tower_R${R}_norm" \ + --se-where tower --se-reduction-tower ${R} --se-pre-norm-tower \ + --checkpoint-best + run "se_tower_R${R}_nonorm" \ + --se-where tower --se-reduction-tower ${R} --no-se-pre-norm-tower \ + --checkpoint-best +done + +# Tower+Bridge (normalized + non-normalized) +for R in 8 16 32; do + # normalized (both pre-norm on) + run "se_tower_bridge_R${R}_norm" \ + --se-where both --se-reduction ${R} --se-reduction-tower ${R} \ + --se-pre-norm --se-pre-norm-tower --checkpoint-best + # non-normalized (both pre-norm off) + run "se_tower_bridge_R${R}_nonorm" \ + --se-where both --se-reduction ${R} --se-reduction-tower ${R} \ + --no-se-pre-norm --no-se-pre-norm-tower --checkpoint-best +done + +echo "Sweep submitted. Check analysis_data/* and models/* for outputs." diff --git a/scripts/main/pipeline.ipynb b/scripts/main/pipeline.ipynb new file mode 100644 index 0000000..43b0f24 --- /dev/null +++ b/scripts/main/pipeline.ipynb @@ -0,0 +1,178 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Hypertower Repro Pipeline\n", + "\n", + "This notebook documents the full run sequence used to reproduce current results." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 0) Environment + Paths\n", + "\n", + "- Activate `fundus_imaging` environment\n", + "- Run from repo root\n", + "- Confirm data paths:\n", + " - `Papila/FundusImages`\n", + " - `Papila/ClinicalData`\n", + " - `Papila/ExpertsSegmentations`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "\n", + "required = [\n", + " Path(\"Papila/FundusImages\"),\n", + " Path(\"Papila/ClinicalData\"),\n", + " Path(\"Papila/ExpertsSegmentations\"),\n", + " Path(\"REFUGE\"),\n", + "]\n", + "for p in required:\n", + " print(f\"{p}:\", \"OK\" if p.exists() else \"MISSING\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1) Build UNet Manifest" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!python3 scripts/main/refuge/build_manifest.py --output manifest.csv" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2) Train UNet Segmenter (per-image normalization)\n", + "\n", + "Current tuned baseline:\n", + "- `--device cuda`\n", + "- `--batch-size 8`\n", + "- `--loader-workers 14`\n", + "- `--in-memory-cache`\n", + "- `--cache-workers 4`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!python3 scripts/run_unet_segmenter.py \\\n", + " --manifest manifest.csv \\\n", + " --train --evaluate \\\n", + " --normalize per_image \\\n", + " --train-datasets refuge --val-datasets refuge --holdout-datasets refuge \\\n", + " --epochs 40 --batch-size 8 \\\n", + " --device cuda --loader-workers 14 \\\n", + " --in-memory-cache --cache-workers 4 \\\n", + " --checkpoint-dir models/v2/refuge/segmentation/per_image \\\n", + " --eval-output analysis_data/segmenter_eval/v2_refuge_per_image \\\n", + " --eval-metrics-path analysis_data/segmenter_eval/v2_refuge_per_image/metrics.csv" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3) Run V2 Hypertower Modes (cropped with UNet)\n", + "\n", + "Runs binary + multiclass across:\n", + "- `single`\n", + "- `ensemble`\n", + "- `bilateral`" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "!python3 scripts/basic_analysis/compare_hypertower_modes.py \\\n", + " --eval-modes binary multiclass \\\n", + " --tower-modes single ensemble bilateral \\\n", + " --epochs 40 \\\n", + " --n-splits 5 \\\n", + " --batch-size 8 \\\n", + " --backbone refugelike \\\n", + " --img-crop-manifest manifest.csv \\\n", + " --img-crop-weights models/v2/refuge/segmentation/per_image/best.pt \\\n", + " --img-crop-normalize per_image \\\n", + " --img-crop-cache analysis_data/v2_crops_unet_refuge \\\n", + " --run-name v2_modes_full_40ep_5fold_unet_perimage" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4) Quick Result Snapshot" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "from pathlib import Path\n", + "\n", + "root = Path(\"analysis_data/v2_modes_full_40ep_5fold_unet_perimage\")\n", + "summary = root / \"summary.json\"\n", + "if summary.exists():\n", + " data = json.loads(summary.read_text())\n", + " print(\"run_name:\", data.get(\"run_name\"))\n", + " print(\"timestamp:\", data.get(\"timestamp\"))\n", + " print(\"keys:\", list(data.get(\"summaries\", {}).keys()))\n", + "else:\n", + " print(\"Summary not found:\", summary)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5) Notes / Decisions\n", + "\n", + "- Mixed-label patient handling used:\n", + "- Warmup settings used:\n", + "- Backbone / batch / workers used:\n", + "- Any deviations from default run:" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/scripts/main/refuge/build_manifest.py b/scripts/main/refuge/build_manifest.py new file mode 100755 index 0000000..dbdb0e6 --- /dev/null +++ b/scripts/main/refuge/build_manifest.py @@ -0,0 +1,140 @@ +"""Build manifest for U-Net segmenter combining REFUGE and Papila annotations.""" + +from __future__ import annotations + +import argparse +import random +from pathlib import Path +from typing import Optional + +import pandas as pd + +import sys + +REPO_ROOT = Path(__file__).resolve().parents[3] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes.refuge_preprocessing import RefugePreprocessing + +REFUGE_ROOT = Path("REFUGE") +PAPILA_IMAGES = Path("Papila/FundusImages") +PAPILA_CONTOURS = Path("Papila/ExpertsSegmentations/Contours") +DEFAULT_OUTPUT = Path("manifest.csv") + + +def pick_contour(base: str, kind: str) -> Optional[Path]: + """Return contour path for Papila image (disc/cup).""" + candidates = [ + PAPILA_CONTOURS / f"{base}_{kind}_exp2.txt", + PAPILA_CONTOURS / f"{base}_{kind}_exp1.txt", + ] + for path in candidates: + if path.exists(): + return path + return None + + +def collect_refuge() -> pd.DataFrame: + pre = RefugePreprocessing(REFUGE_ROOT) + samples = [] + for sample in pre.build_manifest(refresh=True): + if sample.mask_path is None: + continue + split = sample.split + if split == "test": + split = "holdout" + samples.append( + { + "sample_id": sample.sample_id, + "dataset": "refuge", + "image_path": sample.image_path.resolve(), + "annotation_disc": sample.mask_path.resolve(), + "annotation_cup": sample.mask_path.resolve(), + "annotation_type_disc": "mask", + "annotation_type_cup": "mask", + "split": split, + } + ) + return pd.DataFrame(samples) + + +def collect_papila() -> pd.DataFrame: + samples = [] + if not PAPILA_IMAGES.exists(): + return pd.DataFrame(samples) + for img_path in sorted(PAPILA_IMAGES.glob("RET*")): + base = img_path.stem + disc = pick_contour(base, "disc") + cup = pick_contour(base, "cup") + if disc is None or cup is None: + continue + samples.append( + { + "sample_id": f"papila_{base}", + "dataset": "papila", + "image_path": img_path.resolve(), + "annotation_disc": disc.resolve(), + "annotation_cup": cup.resolve(), + "annotation_type_disc": "contour", + "annotation_type_cup": "contour", + } + ) + return pd.DataFrame(samples) + + +def assign_splits(df: pd.DataFrame, holdout_ratio: float, seed: int) -> pd.DataFrame: + rng = random.Random(seed) + df = df.copy() + if "split" not in df.columns: + df["split"] = None + for dataset, group in df.groupby("dataset"): + indices = list(group.index) + + # Preserve provided splits (e.g., REFUGE train/val/test); only populate + # missing entries with "train" so downstream code has a default. + split_series = df.loc[indices, "split"] + missing = split_series.isna() | (split_series.astype(str).str.strip() == "") + if missing.any(): + df.loc[missing[missing].index, "split"] = "train" + split_series = df.loc[indices, "split"] + + if dataset != "papila": + continue + + if holdout_ratio <= 0: + continue + + desired_holdout = max(1, int(len(indices) * holdout_ratio)) + split_series = df.loc[indices, "split"] + current_holdout_mask = split_series == "holdout" + current_holdout = int(current_holdout_mask.sum()) + remaining = desired_holdout - current_holdout + if remaining <= 0: + continue + + candidate_indices = list(split_series[split_series == "train"].index) + rng.shuffle(candidate_indices) + selected = candidate_indices[:remaining] + df.loc[selected, "split"] = "holdout" + return df + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build U-Net manifest") + parser.add_argument("--holdout", type=float, default=0.05) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + args = parser.parse_args() + + refuge_df = collect_refuge() + papila_df = collect_papila() + combined = pd.concat([refuge_df, papila_df], ignore_index=True) + combined = assign_splits(combined, holdout_ratio=args.holdout, seed=args.seed) + args.output.parent.mkdir(parents=True, exist_ok=True) + combined.to_csv(args.output, index=False) + print(f"Manifest saved to {args.output} with {len(combined)} entries") + + +if __name__ == "__main__": + main() diff --git a/scripts/main/refuge/build_unet_manifest.py b/scripts/main/refuge/build_unet_manifest.py new file mode 100644 index 0000000..260749d --- /dev/null +++ b/scripts/main/refuge/build_unet_manifest.py @@ -0,0 +1,138 @@ +"""Build manifest for U-Net segmenter combining REFUGE and Papila annotations.""" + +from __future__ import annotations + +import argparse +import random +from pathlib import Path +from typing import Optional + +import sys + +import pandas as pd + +ROOT = Path(__file__).resolve().parents[1] +sys.path.append(str(ROOT)) + +from classes.refuge_preprocessing import RefugePreprocessing + +REFUGE_ROOT = Path("REFUGE") +PAPILA_IMAGES = Path("FundusImages") +PAPILA_CONTOURS = Path("Papila/ExpertsSegmentations/Contours") +DEFAULT_OUTPUT = Path("Papila/analysis_data/unet_manifest.csv") + + +def pick_contour(base: str, kind: str) -> Optional[Path]: + """Return contour path for Papila image (disc/cup).""" + candidates = [ + PAPILA_CONTOURS / f"{base}_{kind}_exp2.txt", + PAPILA_CONTOURS / f"{base}_{kind}_exp1.txt", + ] + for path in candidates: + if path.exists(): + return path + return None + + +def collect_refuge() -> pd.DataFrame: + pre = RefugePreprocessing(REFUGE_ROOT) + samples = [] + for sample in pre.build_manifest(refresh=True): + if sample.mask_path is None: + continue + split = sample.split + if split == "test": + split = "holdout" + samples.append( + { + "sample_id": sample.sample_id, + "dataset": "refuge", + "image_path": sample.image_path.resolve(), + "annotation_disc": sample.mask_path.resolve(), + "annotation_cup": sample.mask_path.resolve(), + "annotation_type_disc": "mask", + "annotation_type_cup": "mask", + "split": split, + } + ) + return pd.DataFrame(samples) + + +def collect_papila() -> pd.DataFrame: + samples = [] + if not PAPILA_IMAGES.exists(): + return pd.DataFrame(samples) + for img_path in sorted(PAPILA_IMAGES.glob("RET*")): + base = img_path.stem + disc = pick_contour(base, "disc") + cup = pick_contour(base, "cup") + if disc is None or cup is None: + continue + samples.append( + { + "sample_id": f"papila_{base}", + "dataset": "papila", + "image_path": img_path.resolve(), + "annotation_disc": disc.resolve(), + "annotation_cup": cup.resolve(), + "annotation_type_disc": "contour", + "annotation_type_cup": "contour", + } + ) + return pd.DataFrame(samples) + + +def assign_splits(df: pd.DataFrame, holdout_ratio: float, seed: int) -> pd.DataFrame: + rng = random.Random(seed) + df = df.copy() + if "split" not in df.columns: + df["split"] = None + for dataset, group in df.groupby("dataset"): + indices = list(group.index) + + # Preserve provided splits (e.g., REFUGE train/val/test); only populate + # missing entries with "train" so downstream code has a default. + split_series = df.loc[indices, "split"] + missing = split_series.isna() | (split_series.astype(str).str.strip() == "") + if missing.any(): + df.loc[missing[missing].index, "split"] = "train" + split_series = df.loc[indices, "split"] + + if dataset != "papila": + continue + + if holdout_ratio <= 0: + continue + + desired_holdout = max(1, int(len(indices) * holdout_ratio)) + split_series = df.loc[indices, "split"] + current_holdout_mask = split_series == "holdout" + current_holdout = int(current_holdout_mask.sum()) + remaining = desired_holdout - current_holdout + if remaining <= 0: + continue + + candidate_indices = list(split_series[split_series == "train"].index) + rng.shuffle(candidate_indices) + selected = candidate_indices[:remaining] + df.loc[selected, "split"] = "holdout" + return df + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build U-Net manifest") + parser.add_argument("--holdout", type=float, default=0.05) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + args = parser.parse_args() + + refuge_df = collect_refuge() + papila_df = collect_papila() + combined = pd.concat([refuge_df, papila_df], ignore_index=True) + combined = assign_splits(combined, holdout_ratio=args.holdout, seed=args.seed) + combined.to_csv(args.output, index=False) + print(f"Manifest saved to {args.output} with {len(combined)} entries") + + +if __name__ == "__main__": + main() diff --git a/scripts/main/refuge/refuge_build.py b/scripts/main/refuge/refuge_build.py new file mode 100644 index 0000000..db7bbf6 --- /dev/null +++ b/scripts/main/refuge/refuge_build.py @@ -0,0 +1,976 @@ +"""REFUGE training/evaluation helper. + +Usage examples (after activating .venv_refuge): + + python refuge_build.py --train-seg + python refuge_build.py --train-clf + python refuge_build.py --eval --with-ttt + +The script expects the REFUGE folder and writes checkpoints under +models/refuge/segmentation and models/refuge/classifier. +""" + +from __future__ import annotations + +import argparse +import csv +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Set, Tuple +import shutil +import sys + +import torch +import numpy as np +from PIL import Image, ImageDraw +from torch.utils.data import DataLoader +from sklearn.metrics import roc_auc_score +from tqdm import tqdm +from torch import nn +from torchvision import models + +REPO_ROOT = Path(__file__).resolve().parents[3] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes.refuge_preprocessing import RefugePreprocessing, RefugeSample +from classes.refuge_segmentation import RefugeSegmentation +from classes.refuge_classification import ( + RefugeClassification, + RefugeClassificationRecord, + RefugeClassificationDataset, + _default_image_transform, + _geometry_from_mask, + UNetGeometryProvider, +) +from classes.unet_segmenter import UNetSegmenter +from classes.papila_builders import build_papila_clinical + +REFUGE_ROOT = Path("REFUGE") +SEG_CKPT = Path("models/refuge/segmentation/refuge_segmentation_best.pt") +CLF_DIR = Path("models/refuge/classifier") +UNET_WEIGHT_CANDIDATES = ( + Path("models/v2/refuge/segmentation/per_image/best.pt"), + Path("models/v2/refuge/segmentation/best.pt"), + Path("models/unet_segmenter/best.pt"), +) + +CLASSIFIER_BACKBONES = { + "resnet50": models.ResNet50_Weights.DEFAULT, + "densenet121": models.DenseNet121_Weights.DEFAULT, + "efficientnet_b0": models.EfficientNet_B0_Weights.DEFAULT, + "efficientnet_b7": models.EfficientNet_B7_Weights.DEFAULT, +} + + +def build_classifier_backbone(name: str) -> nn.Module: + name = name.lower() + if name not in CLASSIFIER_BACKBONES: + raise ValueError(f"Unsupported classifier backbone '{name}'") + + weights = CLASSIFIER_BACKBONES[name] + + if name == "resnet50": + model = models.resnet50(weights=weights) + feat_dim = model.fc.in_features + model.fc = nn.Identity() + elif name == "densenet121": + model = models.densenet121(weights=weights) + feat_dim = model.classifier.in_features + model.classifier = nn.Identity() + elif name == "efficientnet_b0": + model = models.efficientnet_b0(weights=weights) + feat_dim = model.classifier[-1].in_features # type: ignore[index] + model.classifier = nn.Identity() + elif name == "efficientnet_b7": + model = models.efficientnet_b7(weights=weights) + feat_dim = model.classifier[-1].in_features # type: ignore[index] + model.classifier = nn.Identity() + else: # pragma: no cover + raise ValueError(f"Unsupported classifier backbone '{name}'") + + setattr(model, "_feature_dim", int(feat_dim)) + return model + + +def classifier_checkpoint_dir(backbone_name: str) -> Path: + return CLF_DIR / backbone_name + + +def classifier_checkpoint_path(backbone_name: str) -> Path: + return classifier_checkpoint_dir(backbone_name) / "refuge_classifier_best.pt" + + +def resolve_unet_weights(explicit: Optional[Path]) -> Path: + if explicit is not None: + return explicit + for cand in UNET_WEIGHT_CANDIDATES: + if cand.exists(): + return cand + return UNET_WEIGHT_CANDIDATES[0] + + +def ensure_preprocessing() -> RefugePreprocessing: + if not REFUGE_ROOT.exists(): + raise FileNotFoundError(f"REFUGE directory not found at {REFUGE_ROOT}") + return RefugePreprocessing(REFUGE_ROOT) + + +def load_allowed_ids( + csv_path: Optional[Path], dice_threshold: float +) -> Optional[Set[str]]: + if csv_path is None or not csv_path.exists(): + return None + allowed: Set[str] = set() + with csv_path.open(newline="") as fh: + reader = csv.DictReader(fh) + for row in reader: + sample_id = row.get("sample_id") + if not sample_id or sample_id == "__mean__": + continue + try: + disc = float(row.get("dice_disc", "nan")) + cup = float(row.get("dice_cup", "nan")) + except (TypeError, ValueError): + continue + if disc < dice_threshold and cup < dice_threshold: + continue + allowed.add(sample_id) + return allowed + + +def build_papila_samples( + image_dir: Path, + clinical_dir: Path, + label_col: str, + positive_labels: Sequence[str], + allowed_ids: Optional[Set[str]], +) -> List[RefugeSample]: + clinical = build_papila_clinical( + image_dir=str(image_dir), + clinical_dir=str(clinical_dir), + label_col=label_col, + cat_cols=[], + ) + positives = {lbl.lower() for lbl in positive_labels} + samples: Dict[str, RefugeSample] = {} + for _, row in clinical.df.iterrows(): + image_path = clinical.get_image_path(row) + sample_id = f"papila_{Path(image_path).stem}" + if allowed_ids is not None and sample_id not in allowed_ids: + continue + if sample_id in samples: + continue + value = row.get(label_col) + if value is None or (isinstance(value, float) and np.isnan(value)): + continue + try: + label_int = int(value) + if label_int == 2: + continue + label = 1 if label_int > 0 else 0 + except (TypeError, ValueError): + label = 1 if str(value).strip().lower() in positives else 0 + samples[sample_id] = RefugeSample( + sample_id=sample_id, + dataset="papila", + split="holdout", + image_path=Path(image_path), + label=label, + device=None, + mask_path=None, + fovea_coord=None, + ) + return list(samples.values()) + + +def load_contour(path: Path) -> np.ndarray: + coords = np.loadtxt(path) + if coords.ndim == 1: + coords = coords.reshape(-1, 2) + return coords + + +def contour_to_mask(coords: np.ndarray, size: Tuple[int, int]) -> np.ndarray: + if coords is None or coords.size == 0: + return np.zeros((size[1], size[0]), dtype=np.uint8) + img = Image.new("L", size, 0) + draw = ImageDraw.Draw(img) + points = [tuple(map(float, pt)) for pt in coords] + draw.polygon(points, outline=1, fill=1) + return np.array(img, dtype=np.uint8) + + +class PapilaGTGeometryProvider: + def __init__(self, contours_dir: Path) -> None: + self.contours_dir = contours_dir + + def _pick(self, base: str, kind: str) -> Optional[Path]: + for exp in ("exp2", "exp1"): + cand = self.contours_dir / f"{base}_{kind}_{exp}.txt" + if cand.exists(): + return cand + return None + + def __call__(self, sample: RefugeSample, scale: float): + base = Path(sample.image_path).stem + disc_path = self._pick(base, "disc") + cup_path = self._pick(base, "cup") + if disc_path is None or cup_path is None: + raise RuntimeError(f"Missing ground-truth contours for {sample.sample_id}") + + image = Image.open(sample.image_path).convert("RGB") + disc_coords = load_contour(disc_path) + cup_coords = load_contour(cup_path) + disc_mask = contour_to_mask(disc_coords, image.size) + cup_mask = contour_to_mask(cup_coords, image.size) + cup_mask = ((cup_mask > 0) & (disc_mask > 0)).astype(np.uint8) + geom = _geometry_from_mask(disc_mask, scale) + return geom, disc_mask.astype(np.uint8), cup_mask.astype(np.uint8) + + +def build_papila_records( + args: argparse.Namespace, + pre: RefugePreprocessing, + checkpoint_path: Path, +) -> Tuple[List[RefugeClassificationRecord], Optional[RefugeClassification]]: + allowed = load_allowed_ids( + getattr(args, "papila_metrics", None), + getattr(args, "papila_dice_threshold", 0.01), + ) + samples = build_papila_samples( + args.papila_image_dir, + args.papila_clinical_dir, + args.papila_label_col, + args.papila_positive_labels, + allowed, + ) + if not samples: + return [], None + + cache_dir = args.clf_cache_dir + if cache_dir is not None and getattr(args, "papila_use_gt", False): + cache_dir = cache_dir / "gt" + + if getattr(args, "papila_use_gt", False): + geometry_fn = PapilaGTGeometryProvider(args.papila_contours_dir) + provider = geometry_fn + else: + seg_manifest = getattr(args, "seg_manifest", None) + seg_weights = resolve_unet_weights(getattr(args, "seg_weights", None)) + if seg_manifest is None or seg_weights is None: + raise SystemExit( + "Papila evaluation without GT masks requires --seg-manifest and --seg-weights" + ) + segmenter = UNetSegmenter( + manifest_path=seg_manifest, + device=args.device, + normalize=args.seg_normalize, + ) + seg_state = torch.load(seg_weights, map_location=args.device) + seg_state_dict = seg_state.get("model", seg_state) + segmenter.model.load_state_dict(seg_state_dict) + segmenter.model.to(args.device) + provider = UNetGeometryProvider( + segmenter=segmenter, + threshold=args.segmenter_threshold, + tta=args.segmenter_tta, + ) + geometry_fn = provider + + papila_seg = RefugeSegmentation(pre) + backbone = build_classifier_backbone(args.clf_backbone) + papila_clf = RefugeClassification( + pre, + papila_seg, + backbone=backbone, + geometry_fn=provider, + cache_dir=cache_dir, + ) + papila_clf.crop_scale = args.crop_scale + papila_clf.crop_size = args.crop_size + papila_clf.eval_transform = _default_image_transform(args.crop_size) + papila_clf.ttt_transform = papila_clf.eval_transform + papila_state = torch.load(checkpoint_path, map_location=args.device) + papila_clf.backbone.load_state_dict(papila_state["backbone"]) + papila_clf.classifier_head.load_state_dict(papila_state["classifier"]) + papila_clf.rotation_head.load_state_dict(papila_state["rotation"]) + papila_clf.backbone.to(args.device) + papila_clf.classifier_head.to(args.device) + papila_clf.rotation_head.to(args.device) + + records = papila_clf.build_records_for_samples( + samples, crop_scale=args.crop_scale, progress_prefix="papila" + ) + print(f"[eval] Prepared {len(records)} PAPILA records") + return records, papila_clf + + +def train_segmentation(args: argparse.Namespace) -> None: + pre = ensure_preprocessing() + seg = RefugeSegmentation(pre) + seg.build_datasets( + image_size=args.seg_image_size, + batch_size=args.seg_batch_size, + num_workers=args.num_workers, + ) + history = seg.train( + epochs=args.seg_epochs, + lr=args.seg_lr, + weight_decay=args.seg_weight_decay, + checkpoint_dir=SEG_CKPT.parent, + device=args.device, + ) + print("Segmentation training complete. Best Dice:", history.get("best_dice")) + + +def train_unet_segmenter(args: argparse.Namespace) -> None: + manifest_path = args.seg_manifest or Path("manifest.csv") + mask_cache_dir = None if args.in_memory_cache else args.mask_cache_dir + image_cache_dir = None if args.in_memory_cache else args.image_cache_dir + if args.in_memory_cache and (args.mask_cache_dir or args.image_cache_dir): + print("[unet-seg] in_memory_cache enabled: disk caches disabled for this run.") + + segmenter = UNetSegmenter( + manifest_path=manifest_path, + device=args.device, + target_size=args.seg_image_size, + normalize=args.seg_normalize, + use_stronger_aug=args.seg_strong_aug, + train_datasets=args.seg_train_datasets, + val_datasets=args.seg_val_datasets, + holdout_datasets=args.seg_holdout_datasets, + mask_cache_dir=mask_cache_dir, + image_cache_dir=image_cache_dir, + in_memory_cache=args.in_memory_cache, + loader_workers=args.loader_workers, + ) + if mask_cache_dir: + print(f"[unet-seg] mask_cache_dir={mask_cache_dir}") + if image_cache_dir: + print(f"[unet-seg] image_cache_dir={image_cache_dir}") + if args.in_memory_cache: + print("[unet-seg] prebuilding in-memory cache") + segmenter.prebuild_in_memory_cache( + cache_workers=max(0, int(args.cache_workers)), + include_train=True, + include_val=True, + include_holdout=False, + ) + + segmenter.train( + epochs=args.seg_epochs, + batch_size=args.seg_batch_size, + lr=args.seg_lr, + weight_decay=args.seg_weight_decay, + checkpoint_dir=args.seg_checkpoint_dir, + ) + print( + "[unet-seg] Training complete. Best checkpoint stored at", + (args.seg_checkpoint_dir / "best.pt").resolve(), + ) + + +def _load_segmentation( + pre: RefugePreprocessing, args: argparse.Namespace +) -> RefugeSegmentation: + seg = RefugeSegmentation(pre) + seg.build_datasets( + image_size=args.seg_image_size, + batch_size=args.seg_batch_size, + num_workers=args.num_workers, + ) + if not SEG_CKPT.exists(): + raise FileNotFoundError(f"Segmentation checkpoint missing: {SEG_CKPT}") + state = torch.load(SEG_CKPT, map_location=args.device) + seg.model.load_state_dict(state) + seg.model.to(args.device) + return seg + + +def train_classifier(args: argparse.Namespace) -> None: + pre = ensure_preprocessing() + seg = _load_segmentation(pre, args) + backbone = build_classifier_backbone(args.clf_backbone) + + print(f"[classifier] Using backbone: {args.clf_backbone}") + + clf = RefugeClassification( + pre, + seg, + backbone=backbone, + cache_dir=args.clf_cache_dir, + use_all_labeled=args.clf_use_all, + auto_val_ratio=args.clf_auto_val_ratio, + ) + clf.build_datasets( + crop_scale=args.crop_scale, + crop_size=args.crop_size, + batch_size=args.clf_batch_size, + num_workers=args.num_workers, + ) + + default_ckpt_path = classifier_checkpoint_path(args.clf_backbone) + ckpt_path = args.clf_checkpoint_path or default_ckpt_path + ckpt_dir = ckpt_path.parent + history = clf.train( + epochs=args.clf_epochs, + lr=args.clf_lr, + weight_decay=args.clf_weight_decay, + rotation_weight=args.rotation_weight, + checkpoint_dir=ckpt_dir, + device=args.device, + ) + print("Classifier training complete. Best AUC:", history.get("best_auc")) + print(f"Checkpoint directory: {ckpt_dir}") + saved_path = ckpt_dir / "refuge_classifier_best.pt" + if ckpt_path != saved_path: + ckpt_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(saved_path, ckpt_path) + print(f"Checkpoint copied to: {ckpt_path}") + + +def _load_classifier( + pre: RefugePreprocessing, seg: RefugeSegmentation, args: argparse.Namespace +) -> Tuple[RefugeClassification, Path]: + backbone = build_classifier_backbone(args.clf_backbone) + clf = RefugeClassification( + pre, + seg, + backbone=backbone, + cache_dir=args.clf_cache_dir, + use_all_labeled=args.clf_use_all, + auto_val_ratio=args.clf_auto_val_ratio, + ) + clf.build_datasets( + crop_scale=args.crop_scale, + crop_size=args.crop_size, + batch_size=args.clf_batch_size, + num_workers=args.num_workers, + ) + ckpt_path = args.clf_checkpoint_path or classifier_checkpoint_path( + args.clf_backbone + ) + if not ckpt_path.exists(): + raise FileNotFoundError(f"Classifier checkpoint missing: {ckpt_path}") + print(f"[classifier] Loading checkpoint: {ckpt_path}") + state = torch.load(ckpt_path, map_location=args.device) + clf.backbone.load_state_dict(state["backbone"]) + clf.classifier_head.load_state_dict(state["classifier"]) + clf.rotation_head.load_state_dict(state["rotation"]) + clf.backbone.to(args.device) + clf.classifier_head.to(args.device) + clf.rotation_head.to(args.device) + return clf, ckpt_path + + +def _collect_records( + pre: RefugePreprocessing, + seg: RefugeSegmentation, + clf: RefugeClassification, + dataset_name: str, + split: str, + scale: float, +) -> List[RefugeClassificationRecord]: + manifest = pre.build_manifest() + samples = [ + sample + for sample in manifest + if sample.dataset == dataset_name + and sample.split == split + and sample.label is not None + ] + if not samples: + return [] + print(f"[eval] Preparing {len(samples)} samples for {dataset_name.upper()} {split}") + return clf.build_records_for_samples( + samples, crop_scale=scale, progress_prefix=f"{dataset_name}_{split}" + ) + + +def _auc_for_records( + clf: RefugeClassification, + records: List[RefugeClassificationRecord], + device: str, +) -> float: + if not records: + return float("nan") + dataset = RefugeClassificationDataset( + records, + transform=clf.eval_transform, + polar_transform=clf.polar_transform, + size=clf.crop_size, + ) + loader = DataLoader(dataset, batch_size=64, shuffle=False, num_workers=0) + clf.backbone.to(device).eval() + clf.classifier_head.to(device).eval() + preds: List[float] = [] + targets: List[int] = [] + with torch.no_grad(): + for batch in tqdm(loader, desc="Eval", leave=False, unit="batch"): + images = batch["image"].to(device) + polars = batch["polar"].to(device) + extra_feats = batch["features"].to(device) + labels = batch["label"].cpu().numpy().tolist() + feats_img = clf.backbone(images) + feats = feats_img + if getattr(clf, "use_polar", False): + feats_polar = clf.backbone(polars) + feats = torch.cat([feats, feats_polar], dim=1) + if getattr(clf, "extra_feature_dim", 0) > 0: + feats = torch.cat([feats, extra_feats], dim=1) + logits = clf.classifier_head(feats) + probs = torch.softmax(logits, dim=1)[:, 1].cpu().numpy().tolist() + preds.extend(probs) + targets.extend(labels) + if len(set(targets)) < 2: + return float("nan") + return float(roc_auc_score(targets, preds)) + + +def evaluate(args: argparse.Namespace) -> None: + pre = ensure_preprocessing() + seg = _load_segmentation(pre, args) + clf, clf_ckpt = _load_classifier(pre, seg, args) + + def evaluate_subset( + clf_obj: RefugeClassification, + records: List[RefugeClassificationRecord], + label: str, + ) -> None: + if not records: + print(f"[eval] No samples found for {label}; skipping.") + return + + base_state = { + "backbone": clf_obj.backbone.state_dict(), + "rotation": clf_obj.rotation_head.state_dict(), + } + + auc_no_ttt = _auc_for_records(clf_obj, records, device=args.device) + + auc_ttt = float("nan") + if args.with_ttt: + ttt_loader = DataLoader( + RefugeClassificationDataset( + records, + transform=clf_obj.ttt_transform, + polar_transform=clf_obj.polar_transform, + size=clf_obj.crop_size, + ), + batch_size=16, + shuffle=False, + num_workers=0, + ) + ttt_iter = tqdm(range(args.ttt_steps), desc="TTT", unit="step") + for _ in ttt_iter: + clf_obj.apply_ttt(ttt_loader, device=args.device, steps=1) + auc_ttt = _auc_for_records(clf_obj, records, device=args.device) + clf_obj.backbone.load_state_dict(base_state["backbone"]) + clf_obj.rotation_head.load_state_dict(base_state["rotation"]) + + print( + f"{label}: AUC (no TTT) = {auc_no_ttt:.4f}" + + (f", AUC (TTT) = {auc_ttt:.4f}" if args.with_ttt else "") + ) + + if args.eval_datasets: + for dataset_name in dict.fromkeys(args.eval_datasets): + if dataset_name.lower() == "papila": + papila_records, papila_clf = build_papila_records(args, pre, clf_ckpt) + if papila_clf is None: + print("[eval] Papila evaluation aborted; no samples built.") + else: + evaluate_subset(papila_clf, papila_records, "PAPILA holdout") + else: + records = _collect_records( + pre, + seg, + clf, + dataset_name, + "holdout", + scale=args.crop_scale, + ) + evaluate_subset(clf, records, f"{dataset_name.upper()} holdout") + return + + # Do not mix splits: report per dataset + split + subsets = [ + ("refuge1", "val"), + ("refuge2", "val"), + ("refuge2", "test"), + ] + + for dataset_name, split in subsets: + records = _collect_records( + pre, seg, clf, dataset_name, split, scale=args.crop_scale + ) + evaluate_subset(clf, records, f"{dataset_name.upper()} {split}") + + if args.dump_masks and dataset_name == "refuge1" and split == "val": + out_dir = Path(args.dump_masks) + out_dir.mkdir(parents=True, exist_ok=True) + for rec in records: + sample = rec.sample + if sample is None: + continue + pred = seg.predict_mask(sample, device=args.device).numpy() + Image.fromarray((pred * 255).astype(np.uint8)).save( + out_dir / f"{sample.sample_id}_pred.png" + ) + if sample.mask_path and sample.mask_path.exists(): + Image.open(sample.mask_path).convert("L").save( + out_dir / f"{sample.sample_id}_gt.png" + ) + + +def evaluate_segmentation(args: argparse.Namespace) -> None: + manifest_path = args.seg_manifest or Path("manifest.csv") + mask_cache_dir = None if args.in_memory_cache else args.mask_cache_dir + image_cache_dir = None if args.in_memory_cache else args.image_cache_dir + + segmenter = UNetSegmenter( + manifest_path=manifest_path, + normalize=args.seg_normalize, + device=args.device, + mask_cache_dir=mask_cache_dir, + image_cache_dir=image_cache_dir, + in_memory_cache=args.in_memory_cache, + loader_workers=args.loader_workers, + ) + if args.in_memory_cache: + segmenter.prebuild_in_memory_cache( + cache_workers=max(0, int(args.cache_workers)), + include_train=False, + include_val=bool(args.eval_seg_splits is None or "val" in args.eval_seg_splits), + include_holdout=bool(args.eval_seg_splits is None or "holdout" in args.eval_seg_splits), + ) + + ckpt = resolve_unet_weights(args.seg_weights) + if ckpt.exists(): + state = torch.load(ckpt, map_location=segmenter.device) + state_dict = state.get("model", state) + segmenter.model.load_state_dict(state_dict, strict=False) + print(f"[seg-eval] Loaded weights from {ckpt}") + else: + raise FileNotFoundError(f"Segmentation weights not found at {ckpt}") + + dataset_filter = args.eval_seg_datasets + split_filter = args.eval_seg_splits + output_dir = args.eval_seg_output or Path("analysis_data/segmenter_eval") + metrics_path = args.eval_seg_metrics_path + + segmenter.evaluate_dataset( + dataset_filter=dataset_filter, + split_filter=split_filter, + output_dir=output_dir, + save_overlays=not args.eval_seg_no_overlays, + metrics_path=metrics_path, + threshold=args.eval_seg_threshold, + tta=args.eval_seg_tta, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="REFUGE pipeline helper") + parser.add_argument( + "--train-seg", action="store_true", help="Train the segmentation model" + ) + parser.add_argument( + "--train-unet-seg", + action="store_true", + help="Train the UNet segmenter (replacement for scripts/run_unet_segmenter.py)", + ) + parser.add_argument( + "--train-clf", action="store_true", help="Train the classification model" + ) + parser.add_argument( + "--eval", action="store_true", help="Run evaluation on stored checkpoints" + ) + parser.add_argument( + "--with-ttt", + action="store_true", + help="Apply test-time training during evaluation", + ) + parser.add_argument( + "--ttt-steps", type=int, default=1, help="TTT epochs over evaluation loader" + ) + parser.add_argument( + "--export-backbone", + type=Path, + default=None, + help="Optional path to export the trained backbone weights", + ) + parser.add_argument( + "--dump-masks", + type=Path, + default=None, + help="Optional directory to dump predicted/GT masks during eval", + ) + parser.add_argument( + "--device", default="cuda" if torch.cuda.is_available() else "cpu" + ) + parser.add_argument("--num-workers", type=int, default=4) + # Segmentation hyperparameters + parser.add_argument("--seg-epochs", type=int, default=40) + parser.add_argument("--seg-lr", type=float, default=1e-3) + parser.add_argument("--seg-weight-decay", type=float, default=1e-5) + parser.add_argument("--seg-image-size", type=int, default=512) + parser.add_argument("--seg-batch-size", type=int, default=4) + parser.add_argument( + "--seg-manifest", + type=Path, + default=Path("manifest.csv"), + help="Manifest CSV for the UNet segmenter (default: manifest.csv)", + ) + parser.add_argument( + "--seg-weights", + type=Path, + default=None, + help="Path to UNet segmenter weights (default: models/unet_segmenter/best.pt)", + ) + parser.add_argument( + "--seg-normalize", + choices=["none", "imagenet", "per_image"], + default="none", + help="Normalization mode used when running the UNet segmenter", + ) + parser.add_argument( + "--seg-strong-aug", + action="store_true", + help="Enable stronger geometric augmentations when training the UNet segmenter", + ) + parser.add_argument( + "--seg-train-datasets", + nargs="+", + default=["refuge"], + help="Datasets to use for UNet segmenter training (default: refuge)", + ) + parser.add_argument( + "--seg-val-datasets", + nargs="+", + default=["refuge"], + help="Datasets eligible for validation sampling (default: refuge)", + ) + parser.add_argument( + "--seg-holdout-datasets", + nargs="+", + default=["refuge"], + help="Datasets reserved for holdout set during UNet segmenter training (default: refuge)", + ) + parser.add_argument( + "--seg-checkpoint-dir", + type=Path, + default=Path("models/v2/refuge/segmentation/per_image"), + help="Directory to store UNet segmenter checkpoints", + ) + parser.add_argument( + "--loader-workers", + type=int, + default=0, + help="DataLoader workers for UNet segmenter train/eval.", + ) + parser.add_argument( + "--mask-cache-dir", + type=Path, + default=None, + help="Optional cache dir for parsed/resized disc+cup masks.", + ) + parser.add_argument( + "--image-cache-dir", + type=Path, + default=None, + help="Optional cache dir for resized RGB images before augmentation.", + ) + parser.add_argument( + "--in-memory-cache", + action="store_true", + help="Cache preprocessed images and masks in RAM (per DataLoader worker process).", + ) + parser.add_argument( + "--cache-workers", + type=int, + default=0, + help="Worker threads for prebuilding in-memory cache before training/eval.", + ) + # Classification hyperparameters + parser.add_argument("--clf-epochs", type=int, default=30) + parser.add_argument("--clf-lr", type=float, default=1e-4) + parser.add_argument("--clf-weight-decay", type=float, default=1e-4) + parser.add_argument("--clf-batch-size", type=int, default=16) + parser.add_argument( + "--clf-backbone", + choices=sorted(CLASSIFIER_BACKBONES.keys()), + default="resnet50", + help="Backbone architecture for the REFUGE classifier", + ) + parser.add_argument("--rotation-weight", type=float, default=0.5) + parser.add_argument("--crop-scale", type=float, default=2.5) + parser.add_argument("--crop-size", type=int, default=224) + parser.add_argument( + "--clf-cache-dir", + type=Path, + default=Path("analysis_data/classifier_cache"), + help="Directory to cache classifier preprocessing artifacts", + ) + parser.add_argument( + "--clf-use-all", + action="store_true", + help="Use all labelled samples (train+val) when building classifier dataset", + ) + parser.add_argument( + "--clf-auto-val-ratio", + type=float, + default=0.1, + help="Fraction for automatic validation split when no explicit val set is used", + ) + parser.add_argument( + "--clf-checkpoint-path", + type=Path, + default=None, + help="Optional explicit path for the classifier checkpoint (defaults to models/refuge/classifier//refuge_classifier_best.pt)", + ) + parser.add_argument( + "--eval-datasets", + nargs="+", + help="Datasets to evaluate during --eval (e.g. papila). Defaults to REFUGE splits.", + ) + # Segmentation evaluation parameters + parser.add_argument( + "--eval-seg", + action="store_true", + help="Evaluate the segmentation model on specified datasets/splits", + ) + parser.add_argument( + "--eval-seg-datasets", + nargs="+", + default=["refuge"], + help="Segmentation datasets to evaluate (default: refuge)", + ) + parser.add_argument( + "--eval-seg-splits", + nargs="+", + choices=["train", "val", "holdout"], + help="Segmentation splits to evaluate (default: val)", + ) + parser.add_argument( + "--eval-seg-output", + type=Path, + default=Path("analysis_data/segmenter_eval"), + help="Directory to store segmentation metrics CSVs", + ) + parser.add_argument( + "--eval-seg-threshold", + type=float, + default=0.5, + help="Threshold for binarising predicted masks during segmentation eval", + ) + parser.add_argument( + "--eval-seg-metrics-path", + type=Path, + default=None, + help="Optional explicit CSV path for segmentation metrics output", + ) + parser.add_argument( + "--eval-seg-no-overlays", + action="store_true", + help="Skip saving GT/pred overlay images during segmentation evaluation", + ) + parser.add_argument( + "--eval-seg-tta", + action="store_true", + help="Enable horizontal/vertical flip TTA during segmentation evaluation", + ) + parser.add_argument( + "--papila-metrics", + type=Path, + default=None, + help="Optional CSV of Papila Dice metrics used to filter samples", + ) + parser.add_argument( + "--papila-dice-threshold", + type=float, + default=0.01, + help="Minimum Dice required (disc or cup) when filtering Papila metrics", + ) + parser.add_argument( + "--papila-positive-labels", + nargs="+", + default=["glaucoma", "glaucoma suspect", "suspect"], + help="Papila label values treated as positive when labels are non-numeric", + ) + parser.add_argument( + "--papila-image-dir", + type=Path, + default=Path("Papila/FundusImages"), + help="Path to Papila fundus images", + ) + parser.add_argument( + "--papila-clinical-dir", + type=Path, + default=Path("Papila/ClinicalData"), + help="Path to Papila clinical CSVs", + ) + parser.add_argument( + "--papila-label-col", + type=str, + default="Diagnosis", + help="Column name containing Papila labels", + ) + parser.add_argument( + "--papila-use-gt", + action="store_true", + help="Use Papila ground-truth contours when evaluating classifiers", + ) + parser.add_argument( + "--papila-contours-dir", + type=Path, + default=Path("Papila/ExpertsSegmentations/Contours"), + help="Directory containing Papila contour text files", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + if not any( + [ + args.train_seg, + args.train_unet_seg, + args.train_clf, + args.eval, + args.eval_seg, + args.export_backbone, + ] + ): + raise SystemExit( + "Specify at least one action: --train-seg, --train-unet-seg, --train-clf, --eval, --eval-seg, or --export-backbone" + ) + + if args.train_seg: + train_segmentation(args) + + if args.train_unet_seg: + train_unet_segmenter(args) + + if args.train_clf: + train_classifier(args) + + if args.eval: + evaluate(args) + + if args.eval_seg: + evaluate_segmentation(args) + + if args.export_backbone: + pre = ensure_preprocessing() + seg = _load_segmentation(pre, args) + clf = _load_classifier(pre, seg, args) + out_path = args.export_backbone + out_path.parent.mkdir(parents=True, exist_ok=True) + torch.save(clf.extract_backbone().state_dict(), out_path) + print(f"Backbone weights exported to {out_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/main/refuge/run_unet_segmenter.py b/scripts/main/refuge/run_unet_segmenter.py new file mode 100755 index 0000000..fcd6f9b --- /dev/null +++ b/scripts/main/refuge/run_unet_segmenter.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Train and evaluate the U-Net optic disc/cup segmenter.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +import torch + +import sys + +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 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="UNet segmenter runner") + parser.add_argument("--manifest", type=Path, required=True, help="Path to manifest CSV") + parser.add_argument("--train", action="store_true", help="Train the segmenter") + parser.add_argument("--evaluate", action="store_true", help="Evaluate on holdout set") + parser.add_argument("--epochs", type=int, default=40) + parser.add_argument("--batch-size", type=int, default=4) + parser.add_argument("--lr", type=float, default=1e-3) + parser.add_argument("--weight-decay", type=float, default=1e-5) + parser.add_argument("--disc-weight", type=float, default=1.0) + parser.add_argument("--cup-weight", type=float, default=1.0) + parser.add_argument("--checkpoint-dir", type=Path, default=Path("models/unet_segmenter")) + parser.add_argument("--eval-output", type=Path, default=Path("analysis_data/segmenter_eval")) + parser.add_argument( + "--normalize", + choices=["none", "imagenet", "per_image"], + default="none", + help="Image normalization mode for train/eval", + ) + parser.add_argument( + "--strong-aug", + action="store_true", + help="Enable stronger train-time augmentations (flips/rotations)", + ) + parser.add_argument("--train-datasets", nargs="+", help="Datasets to use for training/validation (default: all)") + parser.add_argument("--val-datasets", nargs="+", help="Datasets eligible for validation sampling (default: match training)") + parser.add_argument("--holdout-datasets", nargs="+", help="Restrict holdout entries to these datasets (default: all)") + parser.add_argument( + "--val-ratio", + type=float, + default=0.1, + help="Fraction of training data reserved for validation (default: 0.1)", + ) + parser.add_argument("--eval-datasets", nargs="+", help="Datasets to evaluate (default: holdout split only)") + parser.add_argument("--eval-splits", nargs="+", help="Splits to evaluate (default: holdout or all when --eval-datasets is set)") + parser.add_argument("--eval-metrics-path", type=Path, help="Optional CSV path for evaluation metrics output") + parser.add_argument("--no-eval-overlays", action="store_true", help="Skip writing overlay images during evaluation") + parser.add_argument("--threshold", type=float, default=0.5, help="Probability threshold for binarizing predictions") + parser.add_argument("--tta", action="store_true", help="Enable simple test-time augmentation (H/V flips) during evaluation") + parser.add_argument( + "--weights", + type=Path, + help="Optional model weights (.pt) for eval-only runs; defaults to /best.pt", + ) + parser.add_argument("--device", choices=["auto", "cuda", "cpu"], default="auto", help="Execution device for UNet (default: auto).") + parser.add_argument("--loader-workers", type=int, default=0, help="DataLoader workers for train/eval.") + parser.add_argument("--mask-cache-dir", type=Path, default=None, help="Optional cache dir for parsed/resized disc+cup masks.") + parser.add_argument("--image-cache-dir", type=Path, default=None, help="Optional cache dir for resized RGB images before augmentation.") + parser.add_argument("--in-memory-cache", action="store_true", help="Cache preprocessed images and masks in RAM (per DataLoader worker process).") + parser.add_argument("--cache-workers", type=int, default=0, help="Worker threads for prebuilding in-memory cache before training/eval.") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.device == "auto": + selected_device = "cuda" if torch.cuda.is_available() else "cpu" + else: + selected_device = args.device + if selected_device == "cuda" and not torch.cuda.is_available(): + raise RuntimeError("Requested --device cuda but CUDA is not available.") + + print( + f"[UNet] device={selected_device} " + f"(cuda_available={torch.cuda.is_available()}, workers={args.loader_workers})" + ) + if selected_device == "cuda": + idx = torch.cuda.current_device() + print(f"[UNet] gpu={torch.cuda.get_device_name(idx)}") + + mask_cache_dir = None if args.in_memory_cache else args.mask_cache_dir + image_cache_dir = None if args.in_memory_cache else args.image_cache_dir + if args.in_memory_cache and (args.mask_cache_dir or args.image_cache_dir): + print("[UNet] in_memory_cache enabled: disk caches disabled for this run.") + + segmenter = UNetSegmenter( + manifest_path=args.manifest, + device=selected_device, + cup_weight=args.cup_weight, + disc_weight=args.disc_weight, + val_ratio=args.val_ratio, + train_datasets=args.train_datasets, + val_datasets=args.val_datasets, + holdout_datasets=args.holdout_datasets, + normalize=args.normalize, + use_stronger_aug=args.strong_aug, + mask_cache_dir=mask_cache_dir, + image_cache_dir=image_cache_dir, + in_memory_cache=args.in_memory_cache, + loader_workers=args.loader_workers, + ) + if mask_cache_dir: + print(f"[UNet] mask_cache_dir={mask_cache_dir}") + if image_cache_dir: + print(f"[UNet] image_cache_dir={image_cache_dir}") + if args.in_memory_cache: + print("[UNet] in_memory_cache=enabled (note: memory use scales with loader workers)") + segmenter.prebuild_in_memory_cache( + cache_workers=max(0, int(args.cache_workers)), + include_train=bool(args.train), + include_val=bool(args.train), + include_holdout=bool(args.evaluate), + ) + + if args.train: + segmenter.train( + epochs=args.epochs, + batch_size=args.batch_size, + lr=args.lr, + weight_decay=args.weight_decay, + checkpoint_dir=args.checkpoint_dir, + ) + + if args.evaluate: + if not args.train: + ckpt = args.weights or (args.checkpoint_dir / "best.pt") + if ckpt and ckpt.exists(): + state = torch.load(ckpt, map_location=segmenter.device) + state_dict = state.get("model", state) + segmenter.model.load_state_dict(state_dict, strict=False) + print(f"Loaded weights from {ckpt}") + else: + print(f"[warn] No checkpoint found at {ckpt}. Evaluating untrained weights.") + + split_filter = {"holdout"} if args.eval_splits is None else args.eval_splits + segmenter.evaluate_dataset( + dataset_filter=args.eval_datasets, + split_filter=split_filter, + output_dir=args.eval_output, + save_overlays=not args.no_eval_overlays, + metrics_path=args.eval_metrics_path, + threshold=args.threshold, + tta=args.tta, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/main/v1/run_multifold.py b/scripts/main/v1/run_multifold.py new file mode 100755 index 0000000..34cb123 --- /dev/null +++ b/scripts/main/v1/run_multifold.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""CLI wrapper that delegates to classes.frontend.Multifold.""" + +from pathlib import Path +import sys + +# ensure repo root on path +REPO_ROOT = Path(__file__).resolve().parents[2] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes.frontend import Multifold + + +def run_cli(cli_args=None): + parser = Multifold.build_parser() + args = parser.parse_args(cli_args) + runner = Multifold(args) + runner.run() + + +def main(): + run_cli() + + +if __name__ == "__main__": + main() diff --git a/scripts/main/v1/run_multifold_grid.py b/scripts/main/v1/run_multifold_grid.py new file mode 100755 index 0000000..ffe5d0b --- /dev/null +++ b/scripts/main/v1/run_multifold_grid.py @@ -0,0 +1,431 @@ +#!/usr/bin/env python3 +""" +Grid-search runner for run_multifold experiments. + +Features: + * Enumerates the requested configuration grid and writes grid_plan.csv. + * Picks the next incomplete run, marks it running, executes run_multifold.py. + * Records AUC/accuracy metrics per fold into grid_report.csv. + * Removes model checkpoints for runs dominated (80%+ metrics worse) by others. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import os +import shutil +import subprocess +import sys +from contextlib import contextmanager +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional + +import fcntl + +REPO_ROOT = Path(__file__).resolve().parents[2] +RUN_SCRIPT = REPO_ROOT / "scripts" / "run_multifold.py" +MANIFEST = REPO_ROOT / "manifest.csv" +GRID_DIR = REPO_ROOT / "analysis_data" / "grid_search" +PLAN_PATH = GRID_DIR / "grid_plan.csv" +REPORT_PATH = GRID_DIR / "grid_report.csv" +LOCK_PATH = GRID_DIR / ".grid_lock" +MODELS_ROOT = REPO_ROOT / "models" / "grid_search" + + +def parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser(description="Grid-search orchestrator for run_multifold.") + ap.add_argument("--plan-date", default=datetime.now().strftime("%Y%m%d"), + help="Date prefix used when generating run IDs (default: today).") + ap.add_argument("--regen-plan", action="store_true", + help="Rebuild the grid plan from scratch (overwrites existing plan).") + ap.add_argument("--manifest", type=Path, default=MANIFEST, + help="UNet manifest CSV for cropper.") + ap.add_argument("--weights-dir", type=Path, default=REPO_ROOT / "models" / "unet_segmenter", + help="Directory containing norm_* subfolders with best.pt.") + ap.add_argument("--dry-run", action="store_true", help="Enumerate next run without executing.") + ap.add_argument("--max-runs", type=int, default=1, + help="Maximum runs to execute in this invocation (default: 1).") + ap.add_argument("--run-all", action="store_true", + help="Execute runs sequentially until plan is exhausted (overrides --max-runs).") + return ap.parse_args() + + +@contextmanager +def file_lock(lock_path: Path): + lock_path.parent.mkdir(parents=True, exist_ok=True) + with open(lock_path, "w") as lock_file: + fcntl.flock(lock_file, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(lock_file, fcntl.LOCK_UN) + + +def read_csv(path: Path) -> List[Dict[str, str]]: + if not path.exists(): + return [] + with path.open(newline="") as fh: + reader = csv.DictReader(fh) + return list(reader) + + +def write_csv(path: Path, rows: List[Dict[str, str]], headers: List[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=headers) + writer.writeheader() + for row in rows: + writer.writerow(row) + + +def grid_configs(base_date: str, weights_dir: Path) -> List[Dict[str, str]]: + eval_modes = ["binary", "multiclass"] + crop_variants = [ + ("norm_imagenet", "imagenet"), + ("normalize_none", "none"), + ("norm_per_image", "per_image"), + ] + tta_opts = [False, True] + loss_modes = ["focal", "balanced", "none"] + thaw_modes = ["none", "gradual"] + + se_configs = [] + # none + se_configs.append(("none", {"se_enabled": False})) + # bridge only + for pre in (True, False): + se_configs.append(( + "bridge", + {"se_enabled": True, "se_where": "bridge", "bridge_pre_norm": pre, "tower_pre_norm": None}, + )) + # tower only + for pre in (True, False): + se_configs.append(( + "tower", + {"se_enabled": True, "se_where": "tower", "bridge_pre_norm": None, "tower_pre_norm": pre}, + )) + # both (four combos) + for b_pre in (True, False): + for t_pre in (True, False): + se_configs.append(( + "both", + { + "se_enabled": True, + "se_where": "both", + "bridge_pre_norm": b_pre, + "tower_pre_norm": t_pre, + }, + )) + + combos = [] + idx = 0 + for eval_mode in eval_modes: + for variant, norm in crop_variants: + weights_path = weights_dir / variant / "best.pt" + for tta in tta_opts: + for loss in loss_modes: + for thaw in thaw_modes: + for se_name, se_opts in se_configs: + run_id = f"{base_date}-{idx:04d}" + combos.append({ + "run_id": run_id, + "status": "incomplete", + "eval_mode": eval_mode, + "crop_variant": variant, + "crop_normalize": norm, + "crop_weights": str(weights_path), + "crop_tta": str(tta), + "loss_mode": loss, + "thaw_mode": thaw, + "se_mode": se_name, + "se_bridge_pre_norm": str(se_opts.get("bridge_pre_norm")), + "se_tower_pre_norm": str(se_opts.get("tower_pre_norm")), + }) + idx += 1 + return combos + + +PLAN_HEADERS = [ + "run_id", + "status", + "eval_mode", + "crop_variant", + "crop_normalize", + "crop_weights", + "crop_tta", + "loss_mode", + "thaw_mode", + "se_mode", + "se_bridge_pre_norm", + "se_tower_pre_norm", +] + + +def ensure_plan(args: argparse.Namespace) -> None: + if args.regen_plan or not PLAN_PATH.exists(): + combos = grid_configs(args.plan_date, args.weights_dir) + write_csv(PLAN_PATH, combos, PLAN_HEADERS) + print(f"[grid] Plan created with {len(combos)} runs at {PLAN_PATH}") + + +def select_next_run() -> Optional[Dict[str, str]]: + rows = read_csv(PLAN_PATH) + for row in rows: + if row["status"] == "incomplete": + row["status"] = "running" + write_csv(PLAN_PATH, rows, PLAN_HEADERS) + return row + return None + + +def update_run_status(run_id: str, new_status: str) -> None: + rows = read_csv(PLAN_PATH) + for row in rows: + if row["run_id"] == run_id: + row["status"] = new_status + break + write_csv(PLAN_PATH, rows, PLAN_HEADERS) + + +def build_run_command(row: Dict[str, str], manifest: Path) -> List[str]: + cmd = [ + sys.executable, + str(RUN_SCRIPT), + "--backbone", + "resnet50", + "--fusion-mode", + "fused", + "--epochs", + "40", + "--batch-size", + "8", + "--img-crop-manifest", + str(manifest), + "--img-crop-weights", + row["crop_weights"], + "--img-crop-normalize", + row["crop_normalize"], + "--eval_mode", + row["eval_mode"], + "--holdout-per-class", + "12", + "--run-id", + row["run_id"], + "--shortname", + "grid_search", + ] + if row["crop_tta"] == "True": + cmd.append("--img-crop-tta") + + # Loss/balancing modes + if row["loss_mode"] == "focal": + cmd.extend(["--focal-gamma", "2.0"]) + elif row["loss_mode"] == "balanced": + cmd.append("--balanced-sampler") + + # Thaw schedule + if row["thaw_mode"] == "gradual": + cmd.append("--gradual-thaw") + cmd.extend(["--thaw-ratio", "0.33"]) + cmd.extend(["--thaw-start-epoch", "10"]) + cmd.extend(["--thaw-target", "image"]) + + # SE settings + if row["se_mode"] == "none": + cmd.append("--no-se") + else: + cmd.extend(["--se-reduction", "16"]) + cmd.extend(["--se-reduction-tower", "16"]) + cmd.extend(["--se-where", row["se_mode"]]) + bridge_pre = row["se_bridge_pre_norm"] + tower_pre = row["se_tower_pre_norm"] + if bridge_pre == "True": + cmd.append("--se-pre-norm") + elif bridge_pre == "False": + cmd.append("--no-se-pre-norm") + if tower_pre == "True": + cmd.append("--se-pre-norm-tower") + elif tower_pre == "False": + cmd.append("--no-se-pre-norm-tower") + return cmd + + +def run_command(cmd: List[str]) -> None: + print("[grid] Launching:", " ".join(cmd)) + subprocess.run(cmd, check=True) + + +METRIC_KEYS = ["auc_fused", "auc_img", "auc_md", "acc_fused", "acc_img", "acc_md"] + + +def extract_metrics(run_id: str) -> Dict[str, str]: + summary_path = REPO_ROOT / "analysis_data" / "grid_search" / run_id / "summary.json" + if not summary_path.exists(): + raise FileNotFoundError(f"Missing summary.json for run {run_id}") + with summary_path.open() as fh: + summary = json.load(fh) + + rows = {} + for fold in summary.get("fold_metrics", []): + if not isinstance(fold, dict): + continue + f_idx = fold.get("fold") + stats = fold.get("stats") or {} + if not isinstance(stats, dict): + continue + for key in METRIC_KEYS: + val = stats.get(key) + if val is None: + continue + rows[f"metric_fold{f_idx}_{key}"] = str(val) + best_mean = summary.get("best_metric_mean") + if best_mean is not None: + rows["metric_best_mean"] = str(best_mean) + return rows + + +def update_report(row: Dict[str, str], metrics: Dict[str, str]) -> None: + existing = read_csv(REPORT_PATH) + # Remove existing entry for run_id + existing = [r for r in existing if r.get("run_id") != row["run_id"]] + record = {**row, **metrics} + existing.append(record) + headers = sorted({key for r in existing for key in r.keys()}) + write_csv(REPORT_PATH, existing, headers) + + +def load_report_rows() -> List[Dict[str, str]]: + return read_csv(REPORT_PATH) + + +def metric_columns(rows: List[Dict[str, str]]) -> List[str]: + keys = set() + for row in rows: + for key in row: + if key.startswith("metric_"): + keys.add(key) + return sorted(keys) + + +def _to_float(val: str) -> Optional[float]: + try: + f = float(val) + if math.isnan(f): + return None + return f + except Exception: + return None + + +def prune_dominated(rows: List[Dict[str, str]]) -> None: + """ + Remove model directories for runs that are clearly dominated by another run. + A run is dominated if: + * Another run has a strictly higher metric_best_mean, OR + * Another run is >= on >=80% of overlapping metrics and strictly better on at least one. + """ + metrics = metric_columns(rows) + if not metrics: + return + + dominated = set() + for row in rows: + run_id = row["run_id"] + row_vals = {m: row.get(m) for m in metrics} + row_best = _to_float(row_vals.get("metric_best_mean")) + + for other in rows: + if other["run_id"] == run_id: + continue + + other_vals = {m: other.get(m) for m in metrics} + other_best = _to_float(other_vals.get("metric_best_mean")) + + # Fast path: compare aggregate best mean if both have it + if row_best is not None and other_best is not None and other_best > row_best: + dominated.add(run_id) + break + + # Fallback: overlap-wise dominance + comparisons = [] + better = 0 + for key in metrics: + v1 = _to_float(row_vals.get(key)) + v2 = _to_float(other_vals.get(key)) + if v1 is None or v2 is None: + continue + comparisons.append(v2 >= v1) + if v2 > v1: + better += 1 + if not comparisons: + continue + fraction = sum(comparisons) / len(comparisons) + if fraction >= 0.8 and better > 0: + dominated.add(run_id) + break + + for run_id in dominated: + model_dir = MODELS_ROOT / run_id + if model_dir.exists(): + print(f"[grid] Removing dominated model artifacts for {run_id}") + try: + shutil.rmtree(model_dir) + except OSError as exc: + # Don't fail the grid run if cleanup isn't permitted (e.g., locked SMB dirs). + print(f"[grid] Warning: could not remove {model_dir}: {exc}") + + +def main(): + args = parse_args() + ensure_plan(args) + if args.dry_run: + with file_lock(LOCK_PATH): + next_run = select_next_run() + if next_run is None: + print("[grid] No incomplete runs remaining.") + return + update_run_status(next_run["run_id"], "incomplete") + print("[grid] Next run:", next_run) + return + + max_runs = None if args.run_all else args.max_runs + runs_done = 0 + + while True: + with file_lock(LOCK_PATH): + next_run = select_next_run() + if next_run is None: + if runs_done == 0: + print("[grid] All runs completed.") + else: + print(f"[grid] No more runs remaining after {runs_done} run(s).") + return + + run_id = next_run["run_id"] + try: + cmd = build_run_command(next_run, args.manifest) + run_command(cmd) + metrics = extract_metrics(run_id) + with file_lock(LOCK_PATH): + update_run_status(run_id, "completed") + update_report(next_run, metrics) + report_rows = load_report_rows() + prune_dominated(report_rows) + print(f"[grid] Run {run_id} completed.") + except Exception as exc: + with file_lock(LOCK_PATH): + update_run_status(run_id, "incomplete") + raise SystemExit(f"[grid] Run {run_id} failed: {exc}") from exc + + runs_done += 1 + if max_runs is not None and runs_done >= max_runs: + print(f"[grid] Reached run limit ({max_runs}); stopping.") + return + + +if __name__ == "__main__": + main() diff --git a/scripts/main/v2/run_multifold_v2.py b/scripts/main/v2/run_multifold_v2.py new file mode 100644 index 0000000..c3c63b8 --- /dev/null +++ b/scripts/main/v2/run_multifold_v2.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""CLI wrapper that delegates to classes.frontend.Multifold with V2 loaders.""" + +from __future__ import annotations + +from pathlib import Path +import sys + +# ensure repo root on path +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +import classes.frontend as frontend +from classes.v2.v2_hypertower import V2HyperTower + + +def run_cli(cli_args=None): + parser = frontend.Multifold.build_parser() + parser.set_defaults(warmup_tower_epochs=None, warmup_fused_epochs=None) + parser.add_argument( + "--sample-mode", + choices=["eye", "patient"], + default="eye", + help="Build samples per eye (row-level) or per patient (multi-slot).", + ) + args = parser.parse_args(cli_args) + + # Monkeypatch the HyperTower class used inside Multifold. + frontend.HyperTower = V2HyperTower + + runner = frontend.Multifold(args) + runner.run() + + +def main(): + run_cli() + + +if __name__ == "__main__": + main() diff --git a/scripts/main/v2/run_multifold_v2_modes.py b/scripts/main/v2/run_multifold_v2_modes.py new file mode 100644 index 0000000..01b58fb --- /dev/null +++ b/scripts/main/v2/run_multifold_v2_modes.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""CLI wrapper for the V2 three-mode comparison (classic/ensemble/bilateral).""" + +from __future__ import annotations + +from pathlib import Path +import sys + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes.v2.v2_hypertower import V2ModeComparator + + +def main(): + V2ModeComparator.run() + + +if __name__ == "__main__": + main() diff --git a/scripts/output_analysis/diagnostics/fold_confusion_matrix.py b/scripts/output_analysis/diagnostics/fold_confusion_matrix.py new file mode 100755 index 0000000..6bdb7e5 --- /dev/null +++ b/scripts/output_analysis/diagnostics/fold_confusion_matrix.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +""" +Inspect saved validation/holdout logits for a multifold run. +Prints per-class AUCs and sample counts so we can sanity-check unusually high scores. +Can also print per-fold confusion matrices. + +Example: + python scripts/fold_confusion_matrix.py \ + --run-dir analysis_data/1030_Balanced_Unet_Perimg_Resnet_SE16NormB_SE16NormT_multi_fused/1030_Balanced_Unet_Perimg_Resnet_SE16NormB_SE16NormT_multi_fused_20251030_091842 \ + --head fused + python scripts/fold_confusion_matrix.py --run-dir ... --head fused --use-holdout --confusion +""" + +from __future__ import annotations + +import argparse +import json +import math +from pathlib import Path +from typing import Dict, List + +import numpy as np +from sklearn.metrics import roc_auc_score, confusion_matrix + + +def parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser(description="Inspect saved logits for a run and report per-class AUCs.") + ap.add_argument("--run-dir", required=True, type=Path, help="Path to the run directory under analysis_data.") + ap.add_argument("--head", choices=["fused", "image", "metadata"], default="fused", + help="Which prediction head's saved probabilities to load.") + ap.add_argument("--use-holdout", action="store_true", + help="Look for *_holdout.npy dumps instead of validation splits.") + ap.add_argument("--class-names", nargs="*", default=None, + help="Optional override for class labels (order should match numeric labels).") + ap.add_argument("--macro", action="store_true", help="Also print macro-average AUC across classes.") + ap.add_argument("--confusion", action="store_true", help="Print confusion matrix for each fold.") + return ap.parse_args() + + +def load_cli_args(run_dir: Path) -> Dict: + path = run_dir / "cli_args.json" + if not path.exists(): + raise FileNotFoundError(f"Missing cli_args.json in {run_dir}") + with path.open("r", encoding="utf-8") as fh: + return json.load(fh) + + +def find_fold_files(run_dir: Path, suffix: str) -> Dict[int, Dict[str, Path]]: + files: Dict[int, Dict[str, Path]] = {} + for y_file in run_dir.glob(f"fold*_y_true{suffix}.npy"): + fold_str = y_file.stem.split("_")[0].replace("fold", "") + try: + fold_idx = int(fold_str) + except ValueError: + continue + files.setdefault(fold_idx, {})["y_true"] = y_file + for head_key, glob_pat in [ + ("fused", f"fold*_probs_fused{suffix}.npy"), + ("image", f"fold*_probs_img{suffix}.npy"), + ("metadata", f"fold*_probs_md{suffix}.npy"), + ]: + for p_file in run_dir.glob(glob_pat): + fold_str = p_file.stem.split("_")[0].replace("fold", "") + try: + fold_idx = int(fold_str) + except ValueError: + continue + files.setdefault(fold_idx, {})[head_key] = p_file + return files + + +def compute_auc(y_true: np.ndarray, probs: np.ndarray, class_names: List[str], macro: bool) -> List[int]: + num_classes = probs.shape[1] + unique = np.unique(y_true) + print(f" classes present: {sorted(unique.tolist())}") + + aucs = [] + seen_classes: List[int] = [] + for cls in range(num_classes): + name = class_names[cls] if cls < len(class_names) else f"class_{cls}" + mask = (y_true == cls) + pos = int(mask.sum()) + neg = len(y_true) - pos + if pos == 0 or neg == 0: + print(f" {name:<15} -> insufficient positives/negatives (pos={pos}, neg={neg}); skipping AUC") + continue + try: + auc = roc_auc_score((y_true == cls).astype(int), probs[:, cls]) + except ValueError as exc: + print(f" {name:<15} -> AUC error: {exc}") + continue + aucs.append(auc) + seen_classes.append(cls) + print(f" {name:<15} -> AUC={auc:.4f} (pos={pos}, neg={neg})") + + if macro and aucs: + mean = float(np.mean(aucs)) + std = float(np.std(aucs, ddof=0)) if len(aucs) > 1 else math.nan + print(f" macro AUC across reported classes: {mean:.4f} (std={std:.4f})") + return seen_classes + + +def print_confusion(y_true: np.ndarray, probs: np.ndarray, class_names: List[str]) -> None: + num_classes = probs.shape[1] + preds = probs.argmax(axis=1) + labels = list(range(num_classes)) + cm = confusion_matrix(y_true, preds, labels=labels) + names = [class_names[i] if i < len(class_names) else f"class_{i}" for i in labels] + header = " " * 14 + "".join(f"{name:>12}" for name in names) + print(" Confusion matrix (rows=true, cols=pred):") + print(header) + for idx, row in enumerate(cm): + label = names[idx] + row_str = "".join(f"{int(val):>12}" for val in row) + print(f" {label:<12}{row_str}") + + +def main() -> None: + args = parse_args() + run_dir = args.run_dir.resolve() + if not run_dir.exists(): + raise FileNotFoundError(run_dir) + + cli_args = load_cli_args(run_dir) + eval_mode = cli_args.get("eval_mode", "multiclass") + if args.class_names: + class_names = args.class_names + else: + if eval_mode == "binary": + class_names = ["Healthy", "Glaucoma"] + else: + class_names = cli_args.get("class_names") or ["Healthy", "Glaucoma", "Suspect"] + + suffix = "_holdout" if args.use_holdout else "" + files = find_fold_files(run_dir, suffix) + if not files: + raise SystemExit(f"No saved probability files matching suffix '{suffix}' found in {run_dir}. " + "Run scripts/rebuild_run_best_plots.py first if needed.") + + print(f"[info] Inspecting head='{args.head}' ({'holdout' if args.use_holdout else 'validation'})") + for fold_idx in sorted(files.keys()): + fold = files[fold_idx] + if "y_true" not in fold: + print(f"[warning] Fold {fold_idx}: missing y_true file; skipping.") + continue + head_key = { + "fused": "fused", + "image": "image", + "metadata": "metadata", + }[args.head] + prob_path = fold.get(head_key) + if prob_path is None: + print(f"[warning] Fold {fold_idx}: missing probability file for head '{args.head}'; skipping.") + continue + + y_true = np.load(fold["y_true"]) + probs = np.load(prob_path) + print(f"\n Fold {fold_idx} -> samples={len(y_true)} file={prob_path.name}") + compute_auc(y_true, probs, class_names, args.macro) + if args.confusion: + print_confusion(y_true, probs, class_names) + + +if __name__ == "__main__": + main() diff --git a/scripts/output_analysis/grid_search/batch_best_metrics.py b/scripts/output_analysis/grid_search/batch_best_metrics.py new file mode 100755 index 0000000..5dce082 --- /dev/null +++ b/scripts/output_analysis/grid_search/batch_best_metrics.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +""" +Scan an analysis directory for HyperTower run folders, extract the best per-fold +metric/accuracy from the epoch logs, and emit a combined summary. + +Example: + python scripts/batch_best_metrics.py \ + --analysis-dir analysis_data + + # Holdout ranking (faster, uses summary.json): + python scripts/batch_best_metrics.py \ + --analysis-dir analysis_data/grid_search \ + --metric holdout_auc_fused \ + --acc-metric holdout_acc_fused \ + --source summary \ + --sort-by mean_auc --desc --top 10 + +The script assumes each run directory contains files named `fold{n}_epoch_log.csv`. +It reports runs that have all five folds (fold0..fold4) present by default. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import sys +import time +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Tuple + +REQUIRED_FOLDS = {f"fold{i}_epoch_log.csv" for i in range(5)} + + +def to_float(value: Optional[object]) -> Optional[float]: + if value is None: + return None + if isinstance(value, (int, float)): + num = float(value) + if math.isnan(num): + return None + return num + if not isinstance(value, str): + return None + value = value.strip() + if not value: + return None + try: + num = float(value) + except ValueError: + return None + if math.isnan(num): + return None + return num + + +def best_value_from_csv(csv_path: Path, metric: str) -> Optional[Tuple[float, int]]: + best: Optional[Tuple[float, int]] = None + with csv_path.open("r", newline="") as fp: + reader = csv.DictReader(fp) + for row in reader: + val = to_float(row.get(metric)) + if val is None: + continue + epoch = int(to_float(row.get("epoch")) or reader.line_num) + if best is None or val > best[0]: + best = (val, epoch) + return best + + +def render_progress(current: int, total: Optional[int], matched: int) -> str: + if total: + width = 30 + filled = int(width * current / total) + bar = "#" * filled + "-" * (width - filled) + return f"[{bar}] {current}/{total} matched {matched}" + return f"Scanned {current} dirs, matched {matched}" + + +def find_run_directories(root: Path, + shallow: bool, + required_files: Iterable[str], + show_progress: bool) -> Iterable[Path]: + """ + Yield directories that look like HyperTower runs (contain at least the required fold logs). + """ + required_set = set(required_files) + if shallow: + entries = [entry for entry in root.iterdir() if entry.is_dir()] + entries.sort(key=lambda p: p.name) + total = len(entries) + matched = 0 + last_update = 0.0 + for idx, entry in enumerate(entries, start=1): + if show_progress: + now = time.monotonic() + if now - last_update >= 0.1 or idx == total: + msg = render_progress(idx, total, matched) + print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True) + last_update = now + if not entry.is_dir(): + continue + if all((entry / filename).is_file() for filename in required_set): + matched += 1 + yield entry + if show_progress: + print(file=sys.stderr) + return + + matched = 0 + scanned = 0 + last_update = 0.0 + for dirpath, dirnames, filenames in os_walk_sorted(root): + scanned += 1 + if show_progress: + now = time.monotonic() + if now - last_update >= 0.2: + msg = render_progress(scanned, None, matched) + print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True) + last_update = now + files = set(filenames) + if required_set.issubset(files): + matched += 1 + yield Path(dirpath) + if show_progress: + msg = render_progress(scanned, None, matched) + print(f"\rScanning {msg}", end="", file=sys.stderr, flush=True) + print(file=sys.stderr) + + +def os_walk_sorted(root: Path): + """ + Wrapper around os.walk that yields deterministic, sorted directory order. + """ + import os + + for dirpath, dirnames, filenames in os.walk(root): + dirnames.sort() + filenames.sort() + yield dirpath, dirnames, filenames + + +def read_summary(run_dir: Path) -> Optional[Dict[str, object]]: + summary_path = run_dir / "summary.json" + if not summary_path.exists(): + return None + try: + data = json.loads(summary_path.read_text()) + except Exception: + return None + if not isinstance(data, dict): + return None + return data + + +def read_run_id(run_dir: Path, summary: Optional[Dict[str, object]] = None) -> str: + data = summary if summary is not None else read_summary(run_dir) + if data: + rid = data.get("run_id") + if isinstance(rid, str) and rid: + return rid + return run_dir.name + + +def mean(values: List[float]) -> Optional[float]: + return (sum(values) / len(values)) if values else None + + +def metric_from_stats(stats: Dict[str, object], metric: str) -> Optional[float]: + if stats.get("holdout_best_monitor") == metric: + best_val = to_float(stats.get("holdout_best_so_far")) + if best_val is not None: + return best_val + return to_float(stats.get(metric)) + +def task_from_summary(summary: Optional[Dict[str, object]]) -> Optional[str]: + if not summary: + return None + eval_mode = summary.get("eval_mode") + if isinstance(eval_mode, str): + mode = eval_mode.strip().lower() + if mode == "binary": + return "binary" + if mode in {"multiclass", "multi", "multi-class"}: + return "multiclass" + num_classes = summary.get("num_classes") + if isinstance(num_classes, (int, float)): + return "binary" if int(num_classes) <= 2 else "multiclass" + return None + + +def format_table(rows: List[Dict[str, Optional[object]]], columns: List[str]) -> str: + col_widths = { + col: max(len(col), max((len(fmt_value(row.get(col))) for row in rows), default=0)) + for col in columns + } + header = " | ".join(col.ljust(col_widths[col]) for col in columns) + divider = "-+-".join("-" * col_widths[col] for col in columns) + body_lines = [ + " | ".join(fmt_value(row.get(col)).ljust(col_widths[col]) for col in columns) + for row in rows + ] + return "\n".join([header, divider, *body_lines]) + + +def fmt_value(value: Optional[object]) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, int): + return str(value) + return f"{value:.4f}" + + +def main() -> None: + ap = argparse.ArgumentParser(description="Aggregate best per-fold metrics from HyperTower runs.") + ap.add_argument("--analysis-dir", type=Path, default=Path("analysis_data"), + help="Directory containing run subdirectories (default: analysis_data)") + ap.add_argument("--metric", default="auc_fused", + help="Metric column to maximise (default: auc_fused)") + ap.add_argument("--acc-metric", default="acc_fused", + help="Accuracy column to maximise (default: acc_fused)") + ap.add_argument("--shallow", action="store_true", + help="Only scan directories directly under analysis-dir") + ap.add_argument("--source", choices=["epoch_logs", "summary"], default="epoch_logs", + help="Where to read metrics from (default: epoch_logs)") + ap.add_argument("--task", choices=["binary", "multiclass", "all"], default="all", + help="Filter runs by task type (default: all)") + ap.add_argument("--no-progress", action="store_true", + help="Disable progress output") + ap.add_argument("--match", default=None, + help="Only include run directories whose name contains this substring") + ap.add_argument("--sort-by", choices=["mean_auc", "mean_acc"], default=None, + help="Optional column to sort by (default: none)") + ap.add_argument("--desc", action="store_true", + help="Sort in descending order (default: ascending)") + ap.add_argument("--top", type=int, default=None, + help="Limit output to the top N rows after sorting") + ap.add_argument("--output-file", type=Path, default=None, + help="Optional path to write CSV summary") + args = ap.parse_args() + + root = args.analysis_dir + if not root.exists(): + raise SystemExit(f"Analysis directory not found: {root}") + + rows: List[Dict[str, Optional[object]]] = [] + missing_summary = 0 + unknown_task = 0 + + required_files = REQUIRED_FOLDS if args.source == "epoch_logs" else ["summary.json"] + for run_dir in find_run_directories( + root, + shallow=args.shallow, + required_files=required_files, + show_progress=not args.no_progress, + ): + if args.match and args.match not in run_dir.name: + continue + summary = None + task_label = None + if args.task != "all" or args.source == "summary": + summary = read_summary(run_dir) + if summary is None: + missing_summary += 1 + continue + task_label = task_from_summary(summary) + if args.task != "all": + if task_label is None: + unknown_task += 1 + continue + if task_label != args.task: + continue + + run_id = read_run_id(run_dir, summary) + best_metrics: List[float] = [] + best_accs: List[float] = [] + if args.source == "summary": + folds = summary.get("fold_metrics") if summary else None + if not folds: + continue + for fold in folds: + stats = fold.get("stats") or {} + metric_val = metric_from_stats(stats, args.metric) + acc_val = metric_from_stats(stats, args.acc_metric) + if metric_val is None or acc_val is None: + best_metrics = [] + best_accs = [] + break + best_metrics.append(metric_val) + best_accs.append(acc_val) + else: + for fold_idx in range(5): + csv_path = run_dir / f"fold{fold_idx}_epoch_log.csv" + metric_entry = best_value_from_csv(csv_path, args.metric) + acc_entry = best_value_from_csv(csv_path, args.acc_metric) + if metric_entry is None or acc_entry is None: + # Skip this run if any fold is missing data + best_metrics = [] + best_accs = [] + break + best_metrics.append(metric_entry[0]) + best_accs.append(acc_entry[0]) + + if not best_metrics or not best_accs: + continue + + rows.append({ + "run_id": run_id, + "task": task_label, + "relative_path": str(run_dir.relative_to(root)), + "mean_auc": mean(best_metrics), + "mean_acc": mean(best_accs), + }) + + if not rows: + print("No matching runs found.") + return + + if args.sort_by: + def sort_key(row: Dict[str, Optional[float]]) -> float: + value = row.get(args.sort_by) + if value is None: + return float("-inf") if args.desc else float("inf") + return float(value) + + rows.sort(key=sort_key, reverse=args.desc) + + if args.top is not None: + rows = rows[:args.top] + + columns = ["run_id", "task", "relative_path", "mean_auc", "mean_acc"] + if args.task != "all": + print(f"Task filter: {args.task}") + if args.match: + print(f"Name filter: {args.match}") + print(f"Runs: {len(rows)}\n") + print(format_table(rows, columns)) + + if args.output_file: + out_path = args.output_file + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w", newline="") as fp: + writer = csv.DictWriter(fp, fieldnames=columns) + writer.writeheader() + for row in rows: + writer.writerow(row) + print(f"\nSummary written to {out_path}") + if args.task != "all" and (missing_summary or unknown_task): + print(f"\nSkipped {missing_summary} runs without summary.json and {unknown_task} with unknown task type.") + + +if __name__ == "__main__": + main() diff --git a/scripts/output_analysis/grid_search/plot_fold_auc_accuracy.py b/scripts/output_analysis/grid_search/plot_fold_auc_accuracy.py new file mode 100755 index 0000000..aebc09e --- /dev/null +++ b/scripts/output_analysis/grid_search/plot_fold_auc_accuracy.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""Aggregate per-fold metrics across runs and visualize AUC vs accuracy. + +The script scans every `summary.json` under the provided analysis directory, +loads the per-fold macro AUC values, and combines them with per-fold +predictions to compute accuracy. Two scatter plots are produced: + +1. AUC vs. fold index (with jitter) coloured by fold. +2. Accuracy (x-axis) vs. AUC (y-axis) coloured by fold. + +This helps identify folds that persistently underperform across experiments. +""" +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional + +import numpy as np + +try: + import matplotlib.pyplot as plt + from matplotlib.cm import get_cmap + from matplotlib.lines import Line2D +except ImportError as exc: # pragma: no cover - forward-friendly error for runtime + raise SystemExit("matplotlib is required to run this script") from exc + + +@dataclass +class FoldMetric: + run_id: str + fold: int + auc: float + accuracy: float + summary_path: Path + fusion_mode: Optional[str] + plot_head: str + + +HEAD_SUFFIX = { + "fused": "fused", + "metadata": "md", + "metadata_only": "md", + "image": "img", + "image_only": "img", + "img": "img", + "md": "md", +} + + +def infer_head(summary: Dict[str, object]) -> str: + """Return the prediction head name used for evaluation.""" + plot_head = summary.get("plot_head") + if isinstance(plot_head, str) and plot_head: + key = plot_head.lower() + if key in HEAD_SUFFIX: + return key + fusion_mode = summary.get("fusion_mode") + if isinstance(fusion_mode, str): + key = fusion_mode.lower() + if key in HEAD_SUFFIX: + return key + # Fall back to fused head if nothing else matches + return "fused" + + +def prediction_suffix(head: str) -> str: + key = head.lower() + if key in {"metadata", "metadata_only", "md"}: + return "md" + if key in {"image", "image_only", "img"}: + return "img" + return "fused" + + +def compute_accuracy(probs: np.ndarray, y_true: np.ndarray) -> float: + if probs.ndim == 1: + preds = (probs >= 0.5).astype(int) + else: + preds = np.argmax(probs, axis=1) + y_int = y_true.astype(int) + return float((preds == y_int).mean()) if y_int.size else np.nan + + +def load_summary(path: Path) -> Optional[Dict[str, object]]: + try: + with path.open("r") as f: + return json.load(f) + except Exception as exc: + print(f"[warn] Could not parse {path}: {exc}", file=sys.stderr) + return None + + +def collect_metrics(summary_path: Path) -> Iterable[FoldMetric]: + summary = load_summary(summary_path) + if not summary: + return [] + # Only keep multiclass experiments (num_classes > 2 or eval_mode explicitly multiclass) + num_classes = summary.get("num_classes") + eval_mode = summary.get("eval_mode") + if (isinstance(num_classes, int) and num_classes <= 2) or (isinstance(eval_mode, str) and eval_mode.lower() == "binary"): + return [] + + head = infer_head(summary) + per_fold_auc = summary.get("per_fold_macro_ovr_auc") or summary.get("per_fold_auc") + if not isinstance(per_fold_auc, list): + # Fallback for summaries that only store fold_metrics[*].stats. + metric_key = f"auc_{prediction_suffix(head)}" + fold_metrics = summary.get("fold_metrics") + if not isinstance(fold_metrics, list): + return [] + per_fold_auc = [] + for entry in fold_metrics: + if not isinstance(entry, dict): + return [] + stats = entry.get("stats") + if not isinstance(stats, dict): + return [] + auc_val = stats.get(metric_key) + try: + per_fold_auc.append(float(auc_val)) + except (TypeError, ValueError): + return [] + + suffix = prediction_suffix(head) + run_id = summary.get("run_id", summary_path.parent.name) + fusion_mode = summary.get("fusion_mode") + + for fold_idx, auc_val in enumerate(per_fold_auc): + try: + auc = float(auc_val) + except (TypeError, ValueError): + continue + + base = summary_path.parent + probs_path = base / f"fold{fold_idx}_probs_{suffix}.npy" + y_true_path = base / f"fold{fold_idx}_y_true.npy" + if not probs_path.exists() or not y_true_path.exists(): + # fall back: if fused missing for metadata mode (or vice versa), try md or img + if suffix != "fused": + alt_probs_path = base / f"fold{fold_idx}_probs_fused.npy" + if alt_probs_path.exists(): + probs_path = alt_probs_path + if not probs_path.exists(): + print( + f"[warn] Missing predictions for fold {fold_idx} in {base}; skipped", + file=sys.stderr, + ) + continue + try: + probs = np.load(probs_path) + y_true = np.load(y_true_path) + except Exception as exc: + print(f"[warn] Failed loading predictions for {base}: {exc}", file=sys.stderr) + continue + accuracy = compute_accuracy(probs, y_true) + yield FoldMetric( + run_id=str(run_id), + fold=fold_idx, + auc=auc, + accuracy=accuracy, + summary_path=summary_path, + fusion_mode=fusion_mode if isinstance(fusion_mode, str) else None, + plot_head=head, + ) + + +def build_plot(metrics: List[FoldMetric], output: Path, jitter: float, seed: int, show: bool) -> None: + rng = np.random.default_rng(seed) + folds = sorted({m.fold for m in metrics}) + fold_to_color: Dict[int, tuple] = {} + cmap = get_cmap("tab10", max(len(folds), 1)) + for idx, fold in enumerate(folds): + fold_to_color[fold] = cmap(idx) + + # Prepare arrays for plotting + aucs = np.array([m.auc for m in metrics]) + accs = np.array([m.accuracy for m in metrics]) + fold_indices = np.array([m.fold for m in metrics]) + colors = [fold_to_color[m.fold] for m in metrics] + jitter_offsets = rng.uniform(-jitter, jitter, size=len(metrics)) + + fig, axes = plt.subplots(1, 2, figsize=(13, 5), constrained_layout=True) + + # Panel 1: Fold vs AUC scatter with jitter + ax0 = axes[0] + ax0.scatter(fold_indices + 1 + jitter_offsets, aucs, c=colors, edgecolor="k", linewidth=0.4, alpha=0.85) + ax0.set_xticks([f + 1 for f in folds]) + ax0.set_xlabel("Fold index") + ax0.set_ylabel("Macro AUC") + ax0.set_title("Per-fold AUC across runs") + ax0.grid(True, linestyle=":", linewidth=0.5, alpha=0.4) + + # Panel 2: Accuracy vs AUC scatter + ax1 = axes[1] + ax1.scatter(accs, aucs, c=colors, edgecolor="k", linewidth=0.4, alpha=0.85) + ax1.set_xlabel("Accuracy") + ax1.set_ylabel("Macro AUC") + ax1.set_title("Accuracy vs AUC by fold") + ax1.grid(True, linestyle=":", linewidth=0.5, alpha=0.4) + + # Shared legend + legend_handles = [ + Line2D( + [0], + [0], + marker="o", + color="w", + label=f"Fold {fold + 1}", + markerfacecolor=fold_to_color[fold], + markeredgecolor="k", + markersize=8, + ) + for fold in folds + ] + for ax in axes: + ax.legend(handles=legend_handles, frameon=False, loc="lower right") + + fig.suptitle("Fold-level performance across experiments", fontsize=14) + + output.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(output, dpi=200) + print(f"Saved plot to {output}") + + if show: + plt.show() + plt.close(fig) + + +def print_summary(metrics: List[FoldMetric]) -> None: + total_runs = len({m.run_id for m in metrics}) + print(f"Collected {len(metrics)} fold metrics from {total_runs} runs.") + by_fold: Dict[int, List[FoldMetric]] = {} + for metric in metrics: + by_fold.setdefault(metric.fold, []).append(metric) + for fold, entries in sorted(by_fold.items()): + aucs = np.array([m.auc for m in entries]) + accs = np.array([m.accuracy for m in entries]) + print( + f" Fold {fold + 1}: AUC {aucs.mean():.3f} ± {aucs.std(ddof=0):.3f} | " + f"Accuracy {accs.mean():.3f} ± {accs.std(ddof=0):.3f} (n={len(entries)})" + ) + + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Plot per-fold AUCs and accuracies across runs.") + parser.add_argument( + "--analysis-root", + default="analysis_data", + help="Root directory that contains run folders with summary.json files (default: analysis_data)", + ) + parser.add_argument( + "--output", + default="analysis_data/fold_auc_vs_accuracy.png", + help="Where to save the generated figure (default: analysis_data/fold_auc_vs_accuracy.png)", + ) + parser.add_argument("--jitter", type=float, default=0.08, help="Horizontal jitter for fold scatter plot") + parser.add_argument("--seed", type=int, default=17, help="Random seed for jitter replication") + parser.add_argument("--show", action="store_true", help="Display the plot interactively after saving") + args = parser.parse_args(argv) + + analysis_root = Path(args.analysis_root) + if not analysis_root.exists(): + raise SystemExit(f"Analysis root {analysis_root} does not exist") + + summary_files = sorted(analysis_root.rglob("summary.json")) + if not summary_files: + raise SystemExit(f"No summary.json files found under {analysis_root}") + + metrics: List[FoldMetric] = [] + for summary_path in summary_files: + metrics.extend(collect_metrics(summary_path)) + + if not metrics: + raise SystemExit("No fold metrics collected. Check that prediction files are present.") + + print_summary(metrics) + build_plot(metrics, Path(args.output), jitter=args.jitter, seed=args.seed, show=args.show) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/output_analysis/segmenter/filter_low_dice.py b/scripts/output_analysis/segmenter/filter_low_dice.py new file mode 100755 index 0000000..6dfe1e5 --- /dev/null +++ b/scripts/output_analysis/segmenter/filter_low_dice.py @@ -0,0 +1,78 @@ +"""Filter segmentation metrics rows with near-zero Dice scores.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import pandas as pd + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Drop samples where both disc and cup Dice are below a threshold " + "(default 0.01) and report how many were removed." + ) + ) + parser.add_argument("input", type=Path, help="Path to metrics CSV to filter") + parser.add_argument( + "--output", + type=Path, + help="Destination CSV. Defaults to _filtered.csv in the same directory.", + ) + parser.add_argument( + "--threshold", + type=float, + default=0.01, + help="Dice cutoff; rows with both dice_disc and dice_cup below this are removed.", + ) + parser.add_argument( + "--keep-summary", + action="store_true", + help="Always keep summary rows (sample_id == '__mean__').", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + df = pd.read_csv(args.input) + + mask_low = (df["dice_disc"] < args.threshold) & (df["dice_cup"] < args.threshold) + if args.keep_summary and "sample_id" in df.columns: + mask_low &= df["sample_id"].ne("__mean__") + + removed = int(mask_low.sum()) + filtered = df.loc[~mask_low].copy() + + # Recompute summary if original file contained one + if "sample_id" in filtered.columns: + summary_mask = filtered["sample_id"].eq("__mean__") + filtered = filtered.loc[~summary_mask].copy() + if not filtered.empty: + summary = filtered[["dice_disc", "dice_cup"]].mean() + summary_row = { + "sample_id": "__mean__", + "dataset": "summary", + "split": "summary", + "dice_disc": summary["dice_disc"], + "dice_cup": summary["dice_cup"], + } + filtered = pd.concat([filtered, pd.DataFrame([summary_row])], ignore_index=True) + + remaining = len(filtered) + + output_path = args.output + if output_path is None: + output_path = args.input.with_name(f"{args.input.stem}_filtered.csv") + + filtered.to_csv(output_path, index=False) + + print(f"Removed rows: {removed}") + print(f"Remaining rows: {remaining}") + print(f"Filtered metrics saved to: {output_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/output_analysis/visualizations/aggregate_roc_perclass_all_models.py b/scripts/output_analysis/visualizations/aggregate_roc_perclass_all_models.py new file mode 100755 index 0000000..b7d4b5d --- /dev/null +++ b/scripts/output_analysis/visualizations/aggregate_roc_perclass_all_models.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +""" +Per-class ROC: one figure per class (multiclass) OR one figure total (binary), +with ALL models (runs under a tag) plotted as separate lines. + +Outputs under analysis_data/: + - multiclass: + _class0_roc.png (e.g., Healthy) + _class1_roc.png (e.g., Glaucoma) + _class2_roc.png (e.g., Suspect) + _perclass_summary.json + - binary: + _binary_roc.png + _perclass_summary.json +""" + +import argparse, json, re +from pathlib import Path +import numpy as np +import matplotlib.pyplot as plt +from sklearn.metrics import roc_curve, auc, roc_auc_score + +HEAD_ALIASES = {"image": ["image","img"], "fused": ["fused"], "metadata": ["metadata","md"]} + +def find_run_dirs(tag_prefix: str, analysis_dir: Path): + return sorted([p for p in analysis_dir.glob(f"{tag_prefix}_*") if p.is_dir()]) + +def read_summary(run_dir: Path) -> dict: + p = run_dir / "summary.json" + if p.exists(): + try: + return json.loads(p.read_text()) + except Exception: + pass + return {} + +def find_folds(run_dir: Path, head: str): + variants = HEAD_ALIASES.get(head, [head]) + y_files = sorted(run_dir.glob("fold*_y_true.npy")) + folds = [] + for yf in y_files: + m = re.search(r"fold(\d+)_y_true\.npy$", yf.name) + if not m: continue + idx = int(m.group(1)) + if any((run_dir / f"fold{idx}_probs_{v}.npy").exists() for v in variants): + folds.append(idx) + return folds + +def load_probs(run_dir: Path, fold: int, head: str): + variants = HEAD_ALIASES.get(head, [head]) + y = np.load(run_dir / f"fold{fold}_y_true.npy") + p = None + tried = [] + for v in variants: + pp = run_dir / f"fold{fold}_probs_{v}.npy" + tried.append(pp.name) + if pp.exists(): + p = np.load(pp); break + if p is None: + raise FileNotFoundError(f"Missing probs for fold {fold} in {run_dir}; tried {tried}") + return y, p + +def infer_mode_from_files(run_dir: Path, head: str): + f = find_folds(run_dir, head) + if not f: return None + _, p = load_probs(run_dir, f[0], head) + if p.ndim == 2 and p.shape[1] == 2: return "binary" + if p.ndim == 2 and p.shape[1] >= 3: return "multiclass" + return None + +def per_class_roc(y, p): + """Return {k: (fpr, tpr, auc)} for OVR.""" + K = p.shape[1] + out = {} + for k in range(K): + yb = (y == k).astype(np.uint8) + fpr, tpr, _ = roc_curve(yb, p[:, k]) + out[k] = (fpr, tpr, auc(fpr, tpr) if len(fpr) > 1 else np.nan) + return out + +def make_per_model_class_curves(run_dir: Path, head: str, mode: str): + """ + Returns: + label (model/backbone name), + class_curves: dict[k] -> dict with keys: + 'fpr': grid, 'tpr_mean': mean across folds on grid, 'auc_mean': mean across folds, + 'tpr_std' and 'auc_std' also included. + K = number of classes (2 or 3+) + """ + summary = read_summary(run_dir) + label = summary.get("backbone") or run_dir.name + folds = find_folds(run_dir, head) + if not folds: + return None + + # collect per-fold per-class curves + per_fold = [] + for f in folds: + y, p = load_probs(run_dir, f, head) + if mode == "binary": + keep = np.isin(y, [0,1]) + if keep.sum() == 0: + continue + y, p = y[keep], p[keep] + if p.shape[1] > 2: # safety; binary should have 2 cols + p = p[:, :2] + else: + if p.ndim != 2 or p.shape[1] < 3: + continue + per_fold.append(per_class_roc(y, p)) + if not per_fold: + return None + + # interpolate on a common grid, avg across folds + grid = np.linspace(0, 1, 501) + K = max(per_fold[0].keys()) + 1 + class_curves = {} + for k in range(K): + tprs, aucs = [], [] + for d in per_fold: + if k not in d: + continue + fpr, tpr, a = d[k] + tprs.append(np.interp(grid, fpr, tpr)) + aucs.append(a) + if not tprs: + continue + tprs = np.vstack(tprs) + class_curves[k] = { + "fpr": grid, + "tpr_mean": tprs.mean(axis=0), + "tpr_std": tprs.std(axis=0), + "auc_mean": float(np.nanmean(aucs)), + "auc_std": float(np.nanstd(aucs)), + } + return label, class_curves + +def main(): + ap = argparse.ArgumentParser(description="Per-class ROC with all models as separate lines.") + ap.add_argument("--tag", required=True, help="analysis_data prefix like 'papergrid'") + ap.add_argument("--head", default="image", choices=["image","fused","metadata"]) + ap.add_argument("--mode", choices=["binary","multiclass"], required=True, + help="Select which experiment style to aggregate.") + ap.add_argument("--fusion-mode", choices=["image_only","fused","metadata_only","vote"], default=None, + help="Filter runs by fusion mode to avoid mixing.") + ap.add_argument("--analysis-dir", default="analysis_data") + ap.add_argument("--class-names", nargs="*", default=["Healthy","Glaucoma","Suspect"]) + ap.add_argument("--shade", action="store_true", help="Shade ±1 SD per model (can get busy).") + args = ap.parse_args() + + analysis_dir = Path(args.analysis_dir) / args.tag + run_dirs_all = find_run_dirs(args.tag, analysis_dir) + if not run_dirs_all: + raise SystemExit(f"No run directories found starting with '{args.tag}_' under {analysis_dir}") + + # filter runs + selected = [] + skipped = [] + for rd in run_dirs_all: + sj = read_summary(rd) + m = sj.get("eval_mode") or infer_mode_from_files(rd, args.head) + if m != args.mode: + skipped.append((rd, f"mode={m}")); continue + if args.fusion_mode: + fm = sj.get("fusion_mode") + if fm and fm != args.fusion_mode: + skipped.append((rd, f"fusion_mode={fm}")); continue + selected.append(rd) + + if not selected: + raise SystemExit("No runs matched filters (mode/fusion-mode).") + + # build per-model curves + per_model = [] # list of (label, class_curves) + for rd in selected: + res = make_per_model_class_curves(rd, args.head, args.mode) + if res is None: + skipped.append((rd, "no_usable_folds")); continue + per_model.append(res) + + if not per_model: + raise SystemExit("No usable runs after fold parsing/interpolation.") + + # determine classes to plot + maxK = max((max(curves.keys())+1) for _, curves in per_model) + if args.mode == "binary": + # Only class 1 (positive) is typically plotted + classes_to_plot = [1] + class_names = [args.class_names[1] if len(args.class_names) > 1 else "Positive"] + outfile_names = [f"{args.tag}_binary_roc.png"] + title_suffixes = ["Binary (positive class)"] + else: + classes_to_plot = list(range(min(3, maxK))) # usually 0,1,2 + class_names = [args.class_names[i] if i < len(args.class_names) else f"class {i}" for i in classes_to_plot] + outfile_names = [f"{args.tag}_class{i}_roc.png" for i in classes_to_plot] + title_suffixes = [f"Class: {name}" for name in class_names] + + # plot per class: all models on same axes + out_json = {"tag": args.tag, "mode": args.mode, "head": args.head, + "fusion_mode_filter": args.fusion_mode, "figures": []} + + for k, cname, out_name, t_suffix in zip(classes_to_plot, class_names, outfile_names, title_suffixes): + fig = plt.figure(figsize=(10, 8)); ax = fig.add_subplot(111) + ax.plot([0,1],[0,1], linestyle="--", linewidth=1) + ax.set_xlabel("False Positive Rate"); ax.set_ylabel("True Positive Rate") + title_bits = [f"Combined ROC — {args.tag}", t_suffix, f"[{args.head}]"] + if args.fusion_mode: title_bits.append(f"[{args.fusion_mode}]") + ax.set_title(" — ".join(title_bits)) + + entries = [] + for label, curves in per_model: + if k not in curves: + continue + c = curves[k] + ax.plot(c["fpr"], c["tpr_mean"], linewidth=2, + label=f"{label} (AUC {c['auc_mean']:.3f}±{c['auc_std']:.3f})") + if args.shade: + ax.fill_between(c["fpr"], + np.maximum(c["tpr_mean"] - c["tpr_std"], 0), + np.minimum(c["tpr_mean"] + c["tpr_std"], 1), + alpha=0.10) + entries.append({"label": label, "auc_mean": c["auc_mean"], "auc_std": c["auc_std"]}) + + ax.legend(loc="lower right") + fig.tight_layout() + + out_path = analysis_dir / out_name + fig.savefig(out_path, dpi=160); plt.close(fig) + + out_json["figures"].append({ + "class_index": k, "class_name": cname, "output_png": str(out_path), + "models": entries + }) + + # metadata file + meta_path = analysis_dir / f"{args.tag}_perclass_summary.json" + meta_path.write_text(json.dumps(out_json, indent=2), encoding="utf-8") + print(f"Wrote figures + {meta_path}") + +if __name__ == "__main__": + main() diff --git a/scripts/output_analysis/visualizations/rebuild_run_best_plots.py b/scripts/output_analysis/visualizations/rebuild_run_best_plots.py new file mode 100755 index 0000000..c81bbe9 --- /dev/null +++ b/scripts/output_analysis/visualizations/rebuild_run_best_plots.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +""" +Recompute per-fold ROC plots for a completed multifold run using the saved +best checkpoints instead of the final epoch. + +Example: + python scripts/rebuild_run_best_plots.py \ + --run-dir analysis_data/1029_Baseline_Balanced_Resnet/1029_Baseline_Balanced_Resnet_20251029_163906 \ + --head image +""" + + +import argparse +import logging +import json +from pathlib import Path +from types import SimpleNamespace + +import matplotlib +import sys + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) +matplotlib.use("Agg") +import matplotlib.pyplot as plt # noqa: E402 +import numpy as np # noqa: E402 +import pandas as pd # noqa: E402 +import torch # noqa: E402 +from sklearn.metrics import auc, roc_auc_score, roc_curve # noqa: E402 + +from classes import build_papila_clinical # noqa: E402 +from classes.hypertower import HyperTower # noqa: E402 + +try: # Allow checkpoints that stored pandas DataFrames in their args. + from torch.serialization import add_safe_globals # type: ignore + + add_safe_globals([pd.DataFrame]) +except (ImportError, AttributeError): + pass + + +def parse_args() -> argparse.Namespace: + ap = argparse.ArgumentParser(description="Rebuild ROC plots for an existing multifold run.") + ap.add_argument("--run-dir", required=True, type=Path, help="Path to the run directory under analysis_data.") + ap.add_argument("--head", default="image", choices=["image", "fused", "metadata"], help="Which prediction head to plot.") + ap.add_argument("--class-names", nargs="*", default=None, help="Optional class names to control plot labels.") + ap.add_argument("--overwrite", action="store_true", help="Overwrite existing .npy probability dumps if present.") + ap.add_argument( + "--use-holdout", + action="store_true", + help="Evaluate checkpoints on the saved holdout set instead of the fold validation splits.", + ) + return ap.parse_args() + + +def load_cli_args(run_dir: Path) -> dict: + cli_path = run_dir / "cli_args.json" + if not cli_path.exists(): + raise FileNotFoundError(f"Missing cli_args.json in {run_dir}") + with cli_path.open("r", encoding="utf-8") as fh: + return json.load(fh) + + +def load_summary(run_dir: Path) -> dict: + summary_path = run_dir / "summary.json" + if not summary_path.exists(): + raise FileNotFoundError(f"Missing summary.json in {run_dir}") + with summary_path.open("r", encoding="utf-8") as fh: + return json.load(fh) + + +def prepare_clinical(cli_args: dict, run_dir: Path) -> tuple: + clinical = build_papila_clinical( + cli_args["image_dir"], + cli_args["clinical_dir"], + cli_args["label_col"], + cli_args["cat_cols"], + n_splits=cli_args["n_splits"], + random_seed=cli_args["fold_seed"], + ) + + holdout_path = run_dir / "holdout.csv" + holdout_df = pd.read_csv(holdout_path) if holdout_path.exists() else None + if holdout_df is not None: + if cli_args["eval_mode"] == "binary": + holdout_df = holdout_df[holdout_df[cli_args["label_col"]].isin([0, 1])].reset_index(drop=True) + + join_cols = [c for c in holdout_df.columns if c in clinical.df.columns] + if not join_cols: + raise RuntimeError("Holdout CSV found but no overlapping columns with clinical dataframe.") + marker = holdout_df.assign(_holdout_marker=1) + merged = clinical.df.merge(marker, on=join_cols, how="left") + train_df = merged[merged["_holdout_marker"].isna()].drop(columns=["_holdout_marker"]).reset_index(drop=True) + clinical.frames = [train_df.copy()] + clinical.df = train_df.copy() + clinical._infer_or_validate_feature_types() + clinical._compute_numeric_stats() + clinical._build_cat_maps() + clinical._compute_feature_dim() + clinical._build_kfold_indices() + return clinical, holdout_df + + +def build_ht_args(cli_args: dict, fold: int, run_dir: Path, models_dir: Path, holdout_df): + # Copy of the training-time namespace so HyperTower can be re-instantiated. + return SimpleNamespace( + image_dir=cli_args["image_dir"], + clinical_dir=cli_args["clinical_dir"], + label_col=cli_args["label_col"], + cat_cols=cli_args["cat_cols"], + batch_size=cli_args["batch_size"], + epochs=cli_args["epochs"], + lr=cli_args["lr"], + num_classes=cli_args["num_classes"], + img_augment=cli_args.get("img_augment", True), + focal_gamma=cli_args.get("focal_gamma", 0.0), + eval_mode=cli_args["eval_mode"], + fold=fold, + run_dir=str(run_dir), + models_dir=str(models_dir), + backbone=cli_args["backbone"], + freeze_ratio=cli_args["freeze_ratio"], + fusion_mode=cli_args["fusion_mode"], + use_se=cli_args.get("use_se", True), + se_reduction=cli_args.get("se_reduction", 16), + se_pre_norm=cli_args.get("se_pre_norm", True), + se_where=cli_args.get("se_where", "bridge"), + se_reduction_tower=cli_args.get("se_reduction_tower", cli_args.get("se_reduction", 16)), + se_pre_norm_tower=cli_args.get("se_pre_norm_tower", cli_args.get("se_pre_norm", True)), + warmup_tower_epochs=cli_args.get("warmup_tower_epochs", 0), + warmup_fused_epochs=cli_args.get("warmup_fused_epochs", 0), + gradual_thaw=cli_args.get("gradual_thaw", False), + thaw_phase_duration=cli_args.get("thaw_phase_duration", 5), + thaw_ratio=cli_args.get("thaw_ratio", 0.33), + thaw_target=cli_args.get("thaw_target", "image"), + thaw_start_epoch=cli_args.get("thaw_start_epoch", -1), + initial_freeze=cli_args.get("initial_freeze", False), + bcd_prob=0.5, + bcd_p0=0.20, + bcd_min=0.05, + bcd_max=0.30, + bcd_k=0.4, + bcd_metric="auc", + bcd_alpha_batch=0.2, + bcd_alpha_tower=0.3, + bcd_explore_floor=0.15, + aux_img=0.05, + aux_md=0.05, + aux_detach=True, + ema_alpha=0.9, + entropy_ema=0.7, + early_stop=cli_args.get("early_stop", False), + early_metric=cli_args.get("early_metric"), + early_mode=cli_args.get("early_mode", "auto"), + early_patience=cli_args.get("early_patience", 7), + early_min_delta=cli_args.get("early_min_delta", 0.0), + checkpoint_best=cli_args.get("checkpoint_best", False), + holdout_df=holdout_df, + img_crop_manifest=cli_args.get("img_crop_manifest"), + img_crop_weights=cli_args.get("img_crop_weights"), + img_crop_normalize=cli_args.get("img_crop_normalize"), + img_crop_threshold=cli_args.get("img_crop_threshold"), + img_crop_scale=cli_args.get("img_crop_scale", 2.5), + img_crop_size=cli_args.get("img_crop_size", 224), + img_crop_cache=cli_args.get("img_crop_cache"), + img_crop_tta=cli_args.get("img_crop_tta", False), + img_crop_gt=cli_args.get("img_crop_gt", False), + img_geometry_features=cli_args.get("img_geometry_features", False), + balanced_sampler=cli_args.get("balanced_sampler", False), + ) + + +def collect_logits(ht, loader): + """Mirror Multifold.eval_collect_logits but for a provided loader.""" + device = ht.device + ht.img_tower.eval() + ht.md_tower.eval() + outputs = [] + with torch.no_grad(): + if ht.mode == "vote": + ht.head_img.eval() + ht.head_md.eval() + ht.vote.eval() + else: + ht.bridge.eval() + + for batch in loader: + if len(batch) == 4: + imgs, metas, geometry, labels = batch + else: + imgs, metas, labels = batch + geometry = None + imgs = imgs.to(device) + metas = metas.to(device) + labels = labels.to(device) + if geometry is not None and geometry.numel() > 0: + geometry = geometry.to(device) + else: + geometry = None + if ht.mode == "vote": + img_feats = ht.img_tower(imgs, geometry) + md_feats = ht.md_tower(metas) + out_img = ht.head_img(img_feats) + out_md = ht.head_md(md_feats) + out_fused = ht.vote(out_img, out_md) + else: + img_feats = ht.img_tower(imgs, geometry) + md_feats = ht.md_tower(metas) + result = ht.bridge(img_feats, md_feats) + if isinstance(result, tuple): + out_fused, out_img, out_md = result + else: + out_fused, out_img, out_md = result, None, None + + outputs.append( + ( + labels.detach().cpu().numpy(), + torch.softmax(out_fused, dim=1).detach().cpu().numpy() if out_fused is not None else None, + torch.softmax(out_img, dim=1).detach().cpu().numpy() if out_img is not None else None, + torch.softmax(out_md, dim=1).detach().cpu().numpy() if out_md is not None else None, + ) + ) + + if not outputs: + return np.array([]), None, None, None + + y_all, pf, pi, pm = zip(*outputs) + y_true = np.concatenate(y_all, axis=0) + probs_f = np.concatenate([p for p in pf if p is not None], axis=0) if any(p is not None for p in pf) else None + probs_i = np.concatenate([p for p in pi if p is not None], axis=0) if any(p is not None for p in pi) else None + probs_m = np.concatenate([p for p in pm if p is not None], axis=0) if any(p is not None for p in pm) else None + return y_true, probs_f, probs_i, probs_m + + +def compute_per_class_curves(y_true, probs): + if probs is None: + return {} + num_classes = probs.shape[1] + curves = {} + for k in range(num_classes): + y_bin = (y_true == k).astype(np.uint8) + fpr, tpr, _ = roc_curve(y_bin, probs[:, k]) + curves[k] = {"fpr": fpr, "tpr": tpr, "auc": auc(fpr, tpr) if len(fpr) > 1 else np.nan} + return curves + + +def choose_head_probs(head: str, probs_f, probs_i, probs_m): + if head == "fused": + return probs_f + if head == "metadata": + return probs_m + return probs_i + + +def ensure_binary_slice(y_true, *arrays): + mask = np.isin(y_true, [0, 1]) + filtered = [y_true[mask]] + for arr in arrays: + if arr is None: + filtered.append(None) + else: + filtered.append(arr[mask]) + return filtered + + +def plot_overlays(per_fold_curves, out_dir: Path, class_names: list[str], head: str, suffix: str = ""): + keys = sorted({k for _, curves in per_fold_curves for k in curves.keys()}) + if not keys: + return + name_map = {k: (class_names[k] if k < len(class_names) else f"class_{k}") for k in keys} + out_dir.mkdir(parents=True, exist_ok=True) + for k in keys: + fig = plt.figure(figsize=(10, 8)) + ax = fig.add_subplot(111) + ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1, color="grey") + for fold_idx, curves in per_fold_curves: + if k not in curves: + continue + fpr = curves[k]["fpr"] + tpr = curves[k]["tpr"] + auc_val = curves[k]["auc"] + label = f"Fold {fold_idx} (AUC {auc_val:.3f})" if auc_val == auc_val else f"Fold {fold_idx}" + ax.plot(fpr, tpr, linewidth=1.5, label=label) + ax.set_xlabel("False Positive Rate") + ax.set_ylabel("True Positive Rate") + ax.set_title(f"{head} head — {name_map[k]} ROC per fold") + ax.legend(loc="lower right") + fig.tight_layout() + safe_name = name_map[k].replace(" ", "_") + suffix_str = suffix if suffix else "" + fig.savefig(out_dir / f"roc_{head}_{safe_name}_perfold{suffix_str}.png", dpi=160) + plt.close(fig) + + +def plot_mean_sd(per_fold_curves, out_dir: Path, class_names: list[str], head: str, suffix: str = ""): + keys = sorted({k for _, curves in per_fold_curves for k in curves.keys()}) + if not keys: + return + grid = np.linspace(0, 1, 501) + fig = plt.figure(figsize=(10, 8)) + ax = fig.add_subplot(111) + ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1, color="grey") + for k in keys: + tprs = [] + aucs = [] + for _, curves in per_fold_curves: + if k not in curves: + continue + fpr = curves[k]["fpr"] + tpr = curves[k]["tpr"] + aucs.append(curves[k]["auc"]) + tprs.append(np.interp(grid, fpr, tpr)) + if not tprs: + continue + tprs = np.vstack(tprs) + mean = tprs.mean(axis=0) + std = tprs.std(axis=0) + label = class_names[k] if k < len(class_names) else f"class_{k}" + label = f"{label} (AUC {np.nanmean(aucs):.3f}±{np.nanstd(aucs):.3f})" + ax.plot(grid, mean, linewidth=2, label=label) + ax.fill_between(grid, np.maximum(mean - std, 0), np.minimum(mean + std, 1), alpha=0.15) + ax.set_xlabel("False Positive Rate") + ax.set_ylabel("True Positive Rate") + ax.set_title(f"Mean OVR ROC (±1 SD) — {head} head") + ax.legend(loc="lower right") + fig.tight_layout() + suffix_str = suffix if suffix else "" + out_dir.mkdir(parents=True, exist_ok=True) + fig.savefig(out_dir / f"roc_{head}_mean_ovr{suffix_str}.png", dpi=160) + plt.close(fig) + + +def main(): + args = parse_args() + run_dir = args.run_dir.resolve() + cli_args = load_cli_args(run_dir) + summary = load_summary(run_dir) + + class_names = ( + args.class_names + if args.class_names + else (cli_args.get("class_names") or (["Healthy", "Glaucoma"] if cli_args["eval_mode"] == "binary" else ["Healthy", "Glaucoma", "Suspect"])) + ) + + shortname = cli_args.get("shortname") or run_dir.parent.name + run_id = cli_args.get("run_id") or run_dir.name + base_models_dir = Path("models") / shortname / run_id + + clinical, holdout_df = prepare_clinical(cli_args, run_dir) + if args.use_holdout and holdout_df is None: + raise SystemExit("Holdout metrics requested but no holdout.csv found for this run.") + + per_fold_curves = [] + fold_aucs = [] + head = args.head + file_suffix = "_holdout" if args.use_holdout else "" + + for fold_entry in summary.get("fold_metrics", []): + fold_idx = int(fold_entry["fold"]) + best_epoch = fold_entry.get("best_epoch") + if not best_epoch: + print(f"[skip] Fold {fold_idx}: no best_epoch recorded.") + continue + + fold_models_dir = base_models_dir / f"fold{fold_idx}" + best_checkpoint = fold_models_dir / "model_best.pt" + if not best_checkpoint.exists(): + print(f"[warning] Fold {fold_idx}: missing model_best.pt at {best_checkpoint}") + continue + + ht_args = build_ht_args(cli_args, fold_idx, run_dir, fold_models_dir, holdout_df) + ht = HyperTower(clinical, ht_args) + for handler in list(ht.logger.handlers): + handler.close() + ht.logger.handlers = [logging.NullHandler()] + train_log_path = Path("train.log") + if train_log_path.exists() and train_log_path.stat().st_size == 0: + try: + train_log_path.unlink() + except OSError: + pass + try: + state = torch.load(best_checkpoint, map_location=ht.device, weights_only=False) + except TypeError: + state = torch.load(best_checkpoint, map_location=ht.device) + ht._restore_from_state(state) + if args.use_holdout: + eval_df = holdout_df.copy() + else: + _, eval_df = clinical.get_split_dfs(fold_idx) + if cli_args["eval_mode"] == "binary": + eval_df = eval_df[eval_df[cli_args["label_col"]].isin([0, 1])].reset_index(drop=True) + if eval_df.empty: + print(f"[warning] Fold {fold_idx}: evaluation dataframe is empty; skipping.") + continue + ht.test_loader = ht._make_loader_for_df(eval_df, is_train=False) + + y_true, probs_f, probs_i, probs_m = collect_logits(ht, ht.test_loader) + if cli_args["eval_mode"] == "binary": + y_true, probs_f, probs_i, probs_m = ensure_binary_slice(y_true, probs_f, probs_i, probs_m) + + head_probs = choose_head_probs(head, probs_f, probs_i, probs_m) + if head_probs is None: + print(f"[skip] Fold {fold_idx}: head '{head}' not available.") + continue + + if head_probs.shape[1] >= 2: + head_probs = head_probs[:, :2] + + if args.overwrite: + base = run_dir / f"fold{fold_idx}{file_suffix}" + np.save(f"{base}_y_true.npy", y_true) + if probs_f is not None: + np.save(f"{base}_probs_fused.npy", probs_f) + if probs_i is not None: + np.save(f"{base}_probs_img.npy", probs_i) + if probs_m is not None: + np.save(f"{base}_probs_md.npy", probs_m) + + curves = compute_per_class_curves(y_true, head_probs) + per_fold_curves.append((fold_idx, curves)) + try: + if head_probs.shape[1] > 2: + fold_auc = roc_auc_score(y_true, head_probs, multi_class="ovr", average="macro") + else: + target_scores = head_probs[:, 1] if head_probs.shape[1] > 1 else head_probs[:, 0] + fold_auc = roc_auc_score(y_true, target_scores) + fold_aucs.append(fold_auc) + print(f"[info] Fold {fold_idx}: best epoch {best_epoch}, AUC={fold_auc:.4f}") + except Exception: + print(f"[warning] Fold {fold_idx}: unable to compute AUC.") + + if not per_fold_curves: + raise SystemExit("No folds processed; nothing to plot.") + + plots_dir = run_dir / "plots" + plot_overlays(per_fold_curves, plots_dir, class_names, head, file_suffix) + plot_mean_sd(per_fold_curves, plots_dir, class_names, head, file_suffix) + + if fold_aucs: + print(f"[info] {head} head mean AUC across folds: {np.mean(fold_aucs):.4f} ± {np.std(fold_aucs):.4f}") + print(f"Plots regenerated under {plots_dir}") + + +if __name__ == "__main__": + main() diff --git a/scripts/testing/run_multifold_smoketest.sh b/scripts/testing/run_multifold_smoketest.sh new file mode 100755 index 0000000..5dcd1db --- /dev/null +++ b/scripts/testing/run_multifold_smoketest.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Quick smoke-test for run_multifold: runs two 1-epoch configs. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +MANIFEST="manifest.csv" +IMAGENET_WEIGHTS="models/unet_segmenter/norm_imagenet/best.pt" +if [[ ! -f "$MANIFEST" ]]; then + echo "Missing $MANIFEST; run scripts/main/refuge/build_manifest.py first." >&2 + exit 1 +fi +if [[ ! -f "$IMAGENET_WEIGHTS" ]]; then + echo "Missing $IMAGENET_WEIGHTS; train the imagenet-normalized UNet first." >&2 + exit 1 +fi + +COMMON_ARGS=( + --backbone resnet50 + --fusion-mode fused + --epochs 1 + --batch-size 4 + --img-crop-manifest "$MANIFEST" + --img-crop-weights "$IMAGENET_WEIGHTS" + --img-crop-normalize imagenet + --shortname smoketest + --holdout-per-class 12 +) + +echo "[smoketest] Binary eval, fused head" +python scripts/run_multifold.py \ + "${COMMON_ARGS[@]}" \ + --eval_mode binary \ + --run-id smoketest_binary +echo "→ Results under analysis_data/smoketest/smoketest_binary" + +echo "[smoketest] Multiclass eval, fused head" +python scripts/run_multifold.py \ + "${COMMON_ARGS[@]}" \ + --eval_mode multiclass \ + --run-id smoketest_multiclass +echo "→ Results under analysis_data/smoketest/smoketest_multiclass" diff --git a/scripts/testing/test_v1_v2_config_compare.py b/scripts/testing/test_v1_v2_config_compare.py new file mode 100644 index 0000000..6fd950b --- /dev/null +++ b/scripts/testing/test_v1_v2_config_compare.py @@ -0,0 +1,798 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Dict, List, Optional +import sys +import random + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import numpy as np +import torch +from torch import nn + +from classes.frontend import Multifold +from classes.bridge import Bridge, VoteBridge +from classes.dataset import ClinicalDataset +from classes.hypertower import _ClinicalView +from classes.image_tower import ImageTower +from classes.md_tower import MDTower +from classes.papila_builders import build_papila_clinical +from classes.v2 import ( + PatientSplit, + SlotLoaderFactory, + SlotDataset, + slot_collate, + assemble_config, + build_model_bundle, + build_papila_profile, + resolve_imports, +) +from classes.v2.split_manager import PatientFirstSplitManager + + +def build_v1_defaults() -> Dict[str, Any]: + parser = Multifold.build_parser() + args = parser.parse_args([]) + + clinical = build_papila_clinical( + image_dir=args.image_dir, + clinical_dir=args.clinical_dir, + label_col=args.label_col, + cat_cols=list(args.cat_cols), + n_splits=args.n_splits, + random_seed=args.fold_seed, + ) + + return { + "args": args, + "clinical": clinical, + "image_dir": args.image_dir, + "clinical_dir": args.clinical_dir, + "label_col": args.label_col, + "cat_cols": list(args.cat_cols), + "image_transform": { + "resize": 256, + "center_crop": 224, + "hflip": True, + "vflip": True, + "rotation": 15, + "color_jitter": (0.1, 0.1, 0.1, 0.05), + }, + "image_tower": { + "backbone": args.backbone, + "freeze_ratio": args.freeze_ratio, + "augment": args.img_augment, + "geometry_dim": 0, + "use_se": False, # se_where default is bridge + "se_reduction": args.se_reduction_tower, + "se_pre_norm": args.se_pre_norm_tower, + }, + "md_tower": { + "hidden_dim": 128, + "dropout": 0.1, + "use_se": False, + "se_reduction": args.se_reduction_tower, + "se_pre_norm": args.se_pre_norm_tower, + "freeze_ratio": 0.0, + }, + "bridge": { + "method": "fusion" if args.fusion_mode == "fused" else "consensus", + "fusion_dim": 256, + "use_se": args.use_se, + "se_reduction": args.se_reduction, + "se_pre_norm": args.se_pre_norm, + }, + } + + +def build_v2_from_config(path: Path) -> Dict[str, Any]: + assembly = assemble_config(path) + imports = resolve_imports(assembly) + if not imports: + raise ValueError("Config did not include any imports.") + clinical = next(iter(imports.values())) + + image_loader = _find_loader(assembly, input_type="image") + matrix_loader = _find_loader(assembly, input_type="matrix") + + return { + "assembly": assembly, + "clinical": clinical, + "image_loader": image_loader, + "matrix_loader": matrix_loader, + "image_transform_chain": [t.transform_type for t in image_loader.transforms], + } + + +def _find_loader(assembly, input_type: str): + matches = [ + loader for loader in assembly.loaders.values() if loader.input_type == input_type + ] + if not matches: + raise ValueError(f"No loader with input_type={input_type!r} found in config.") + if len(matches) > 1: + raise ValueError(f"Multiple loaders with input_type={input_type!r} found.") + return matches[0] + + +def compare_configs(v1: Dict[str, Any], v2: Dict[str, Any]) -> List[str]: + diffs: List[str] = [] + + # data sources + v1_rows, v1_cols = v1["clinical"].df.shape + v2_rows, v2_cols = v2["clinical"].df.shape + if v1_rows != v2_rows or v1_cols != v2_cols: + diffs.append( + f"Clinical DF shape mismatch: v1={v1_rows}x{v1_cols}, v2={v2_rows}x{v2_cols}" + ) + + # loader presence + if not v2.get("image_loader"): + diffs.append("Missing image loader in v2 config.") + if not v2.get("matrix_loader"): + diffs.append("Missing metadata loader in v2 config.") + + # transform chain expectations + expected_chain = ["resize", "center_crop", "jitter_bundle"] + if v2.get("image_transform_chain") != expected_chain: + diffs.append( + f"Image transform chain mismatch: v1 expects {expected_chain}, v2 has {v2.get('image_transform_chain')}" + ) + + # image tower settings + v1_img = v1["image_tower"] + v2_img = _extract_tower(assembly=v2["assembly"], tower_type="image") + _compare_dict(diffs, "ImageTower", v1_img, v2_img) + + # metadata tower settings + v1_md = v1["md_tower"] + v2_md = _extract_tower(assembly=v2["assembly"], tower_type="metadata") + _compare_dict(diffs, "MDTower", v1_md, v2_md) + + # bridge settings + v2_bridge = _extract_bridge(v2["assembly"]) + _compare_dict(diffs, "Bridge", v1["bridge"], v2_bridge) + + if not v2["assembly"].classifiers: + diffs.append("Missing classifier node in v2 config.") + + # splits + v1_train, v1_val = v1["clinical"].get_split_dfs(0) + sm = PatientFirstSplitManager() + args = SimpleNamespace( + n_splits=v1["args"].n_splits, + fold_seed=v1["args"].fold_seed, + holdout_per_class=v1["args"].holdout_per_class, + holdout_seed=v1["args"].holdout_seed, + eval_mode=v1["args"].eval_mode, + ) + splits = sm.build_plans(clinical=v2["clinical"], args=args, profile=None) + v2_train = splits[0].train + v2_val = splits[0].val + if len(v1_train) != len(v2_train) or len(v1_val) != len(v2_val): + diffs.append( + f"Split sizes mismatch: v1 train/val={len(v1_train)}/{len(v1_val)}, " + f"v2 train/val={len(v2_train)}/{len(v2_val)}" + ) + + return diffs + + +def _extract_tower(*, assembly, tower_type: str) -> Dict[str, Any]: + towers = [ + tower for tower in assembly.towers.values() if tower.tower_type == tower_type + ] + if not towers: + raise ValueError(f"No {tower_type} tower found in v2 config.") + if len(towers) > 1: + raise ValueError(f"Multiple {tower_type} towers found in v2 config.") + return towers[0].params + + +def _extract_bridge(assembly) -> Dict[str, Any]: + if not assembly.bridges: + raise ValueError("No bridge node found in v2 config.") + if len(assembly.bridges) > 1: + raise ValueError("Multiple bridge nodes found in v2 config.") + bridge = next(iter(assembly.bridges.values())) + payload = dict(bridge.params) + payload["method"] = bridge.method + return payload + + +def _compare_dict(diffs: List[str], label: str, v1: Dict[str, Any], v2: Dict[str, Any]) -> None: + for key, v1_val in v1.items(): + v2_val = v2.get(key) + if isinstance(v1_val, tuple): + v1_val = list(v1_val) + if isinstance(v2_val, tuple): + v2_val = list(v2_val) + if v1_val != v2_val: + diffs.append(f"{label} mismatch for {key}: v1={v1_val} v2={v2_val}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--config", + type=Path, + default=Path("hypertower_v2_config.json"), + help="Path to v2 config JSON", + ) + parser.add_argument("--samples", type=int, default=8, help="Number of samples to compare") + parser.add_argument("--seed", type=int, default=1234, help="Seed used for deterministic comparisons") + parser.add_argument( + "--image-compare", + choices=["shape", "value"], + default="value", + help="Compare image tensors by shape only or by value", + ) + parser.add_argument( + "--no-data-compare", + action="store_true", + help="Skip the data/loader comparison step", + ) + parser.add_argument( + "--sample-mode", + choices=["eye", "patient"], + default="eye", + help="Sample mode for V2 loaders (eye-level or patient-level).", + ) + parser.add_argument("--train-epochs", type=int, default=2, help="Epochs to run in train comparison.") + parser.add_argument("--train-folds", type=int, default=2, help="Folds to run in train comparison.") + parser.add_argument("--train-batch-size", type=int, default=8, help="Batch size for train comparison.") + parser.add_argument("--max-batches", type=int, default=10, help="Max batches per epoch (train/val).") + parser.add_argument("--loss-tol", type=float, default=0.5, help="Tolerance for loss diffs.") + parser.add_argument("--acc-tol", type=float, default=0.15, help="Tolerance for accuracy diffs.") + parser.add_argument( + "--device", + choices=["auto", "cpu", "cuda"], + default="cpu", + help="Device to use for training comparison.", + ) + parser.add_argument( + "--no-train-compare", + action="store_true", + help="Skip the training comparison step.", + ) + args = parser.parse_args() + v1 = build_v1_defaults() + v2 = build_v2_from_config(args.config) + diffs = compare_configs(v1, v2) + if diffs: + print("Differences detected:") + for diff in diffs: + print(f"- {diff}") + return 1 + + print("V1 vs V2 config comparison: OK (settings and loaders match).") + + if not args.no_data_compare: + data_diffs = compare_initial_data( + v1, + v2, + samples=args.samples, + seed=args.seed, + compare_mode=args.image_compare, + ) + if data_diffs: + print("Differences detected in initial data:") + for diff in data_diffs: + print(f"- {diff}") + return 1 + print("Initial data comparison: OK (image/meta/label inputs match).") + + if not args.no_train_compare: + train_diffs = compare_training_runs( + v1, + v2, + epochs=args.train_epochs, + folds=args.train_folds, + batch_size=args.train_batch_size, + max_batches=args.max_batches, + seed=args.seed, + loss_tol=args.loss_tol, + acc_tol=args.acc_tol, + sample_mode=args.sample_mode, + device=args.device, + ) + if train_diffs: + print("Differences detected in training comparison:") + for diff in train_diffs: + print(f"- {diff}") + return 1 + print("Training comparison: OK (metrics within tolerance).") + + return 0 + + +def _seed_all(seed: int) -> None: + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + + +def _build_v1_modules(v1: Dict[str, Any]) -> Dict[str, Any]: + args = v1["args"] + clinical = v1["clinical"] + + img_tower = ImageTower( + backbone=args.backbone, + freeze_ratio=args.freeze_ratio, + use_se=False, + se_reduction=args.se_reduction_tower, + se_pre_norm=args.se_pre_norm_tower, + augment=args.img_augment, + geometry_dim=0, + ) + md_tower = MDTower( + clinical, + hidden_dim=128, + dropout=0.1, + use_se=False, + se_reduction=args.se_reduction_tower, + se_pre_norm=args.se_pre_norm_tower, + ) + bridge = None + if args.fusion_mode == "vote": + bridge = VoteBridge(num_classes=args.num_classes) + else: + bridge = Bridge( + img_dim=img_tower.out_dim, + meta_dim=md_tower.out_dim, + num_classes=args.num_classes, + fusion_dim=256, + mode="fused", + use_se=args.use_se, + se_reduction=args.se_reduction, + se_pre_norm=args.se_pre_norm, + ) + + return {"image_tower": img_tower, "metadata_tower": md_tower, "bridge": bridge} + + +def compare_initial_data( + v1: Dict[str, Any], + v2: Dict[str, Any], + *, + samples: int = 8, + seed: int = 1234, + compare_mode: str = "value", +) -> List[str]: + diffs: List[str] = [] + + v1_modules = _build_v1_modules(v1) + v2_modules = build_model_bundle(v2["assembly"], v2["clinical"]) + + # Build consistent train split for both datasets + v1_train, _ = v1["clinical"].get_split_dfs(0) + split = PatientSplit(train=v1_train, val=v1_train.iloc[:0], holdout=None) + + # V1 dataset + v1_view = _ClinicalView(v1["clinical"], v1_train) + v1_ds = ClinicalDataset(v1_view, v1_modules["image_tower"].transform) + + # V2 dataset + if v2_modules.image_transform is None: + diffs.append("V2 image transform could not be built from config.") + return diffs + loader_factory = SlotLoaderFactory(image_transform=v2_modules.image_transform) + v2_loaders = loader_factory.build( + clinical=v2["clinical"], + split=split, + args=SimpleNamespace(batch_size=1), + fold=0, + profile=None, + ) + v2_ds = v2_loaders.train.dataset + + total = min(samples, len(v1_ds), len(v2_ds)) + for idx in range(total): + _seed_all(seed + idx) + v1_item = v1_ds[idx] + _seed_all(seed + idx) + v2_item = v2_ds[idx] + + if len(v1_item) == 4: + v1_img, v1_meta, _, v1_label = v1_item + else: + v1_img, v1_meta, v1_label = v1_item + + v2_img = v2_item.get("image_1") + v2_meta = v2_item.get("matrix_1") + v2_label = v2_item.get("label_1") + + if v2_label is None or int(v2_label) != int(v1_label): + diffs.append(f"Label mismatch at idx {idx}: v1={int(v1_label)} v2={v2_label}") + + if v2_meta is None: + diffs.append(f"Missing v2 metadata at idx {idx}") + else: + if not torch.allclose(v1_meta, v2_meta, atol=1e-6, rtol=0.0): + max_diff = float((v1_meta - v2_meta).abs().max().item()) + diffs.append(f"Metadata mismatch at idx {idx}: max_abs_diff={max_diff:.6f}") + + if v2_img is None: + diffs.append(f"Missing v2 image at idx {idx}") + else: + if tuple(v1_img.shape) != tuple(v2_img.shape): + diffs.append( + f"Image shape mismatch at idx {idx}: v1={tuple(v1_img.shape)} v2={tuple(v2_img.shape)}" + ) + elif compare_mode == "value": + max_diff = float((v1_img - v2_img).abs().max().item()) + if max_diff > 1e-5: + diffs.append(f"Image tensor mismatch at idx {idx}: max_abs_diff={max_diff:.6f}") + + return diffs + + +def compare_training_runs( + v1: Dict[str, Any], + v2: Dict[str, Any], + *, + epochs: int, + folds: int, + batch_size: int, + max_batches: int, + seed: int, + loss_tol: float, + acc_tol: float, + sample_mode: str, + device: str, +) -> List[str]: + diffs: List[str] = [] + if sample_mode != "eye": + diffs.append("Training compare only supports sample_mode='eye' for parity with v1.") + return diffs + + if device == "auto": + device = "cuda" if torch.cuda.is_available() else "cpu" + + _seed_all(seed) + + # Build profile for V2 dataset + profile = build_papila_profile( + patient_col="Patient ID", + label_col=v1["args"].label_col, + sample_mode=sample_mode, + ) + + # Build splits + sm = PatientFirstSplitManager() + split_args = SimpleNamespace( + n_splits=v1["args"].n_splits, + fold_seed=v1["args"].fold_seed, + holdout_per_class=v1["args"].holdout_per_class, + holdout_seed=v1["args"].holdout_seed, + eval_mode=v1["args"].eval_mode, + ) + plans = sm.build_plans(clinical=v2["clinical"], args=split_args, profile=profile) + + folds = min(folds, len(plans)) + + for fold in range(folds): + # Build V1 modules per fold (seeded) + _seed_all(seed + fold * 1000 + 1) + v1_modules = _build_v1_modules(v1) + _move_modules(v1_modules, device) + + # Build V2 modules per fold (seeded to match V1 init) + _seed_all(seed + fold * 1000 + 1) + v2_bundle = build_model_bundle(v2["assembly"], v2["clinical"]) + if v2_bundle.bridge is None: + diffs.append("V2 model bundle missing bridge.") + return diffs + if isinstance(v2_bundle.bridge, VoteBridge): + diffs.append("V2 bridge is VoteBridge; training compare only supports fusion bridge.") + return diffs + if v2_bundle.image_transform is None: + diffs.append("V2 image transform missing; cannot run training compare.") + return diffs + v2_modules = { + "image_tower": v2_bundle.image_tower, + "metadata_tower": v2_bundle.metadata_tower, + "bridge": v2_bundle.bridge, + } + _move_modules(v2_modules, device) + + split = plans[fold] + v1_train = split.train + v1_val = split.val + + v1_train_ds = _build_v1_dataset(v1, v1_train, v1_modules["image_tower"].transform) + v1_val_ds = _build_v1_dataset(v1, v1_val, v1_modules["image_tower"].transform) + + v2_train_ds = _build_v2_dataset(v2, v1_train, profile, v2_bundle.image_transform) + v2_val_ds = _build_v2_dataset(v2, v1_val, profile, v2_bundle.image_transform) + + # Optimizers + v1_opt = torch.optim.Adam( + list(v1_modules["image_tower"].parameters()) + + list(v1_modules["metadata_tower"].parameters()) + + list(v1_modules["bridge"].parameters()), + lr=float(v1["args"].lr), + ) + v2_opt = torch.optim.Adam( + list(v2_modules["image_tower"].parameters()) + + list(v2_modules["metadata_tower"].parameters()) + + list(v2_modules["bridge"].parameters()), + lr=float(v1["args"].lr), + ) + criterion = nn.CrossEntropyLoss() + + for epoch in range(epochs): + _seed_all(seed + fold * 100 + epoch) + v1_train_metrics = _run_epoch_v1( + v1_modules, + v1_train_ds, + v1_opt, + criterion, + device, + batch_size=batch_size, + max_batches=max_batches, + train=True, + seed=seed + fold * 100 + epoch, + ) + v2_train_metrics = _run_epoch_v2( + v2_modules, + v2_train_ds, + v2_opt, + criterion, + device, + batch_size=batch_size, + max_batches=max_batches, + train=True, + seed=seed + fold * 100 + epoch, + ) + + v1_val_metrics = _run_epoch_v1( + v1_modules, + v1_val_ds, + None, + criterion, + device, + batch_size=batch_size, + max_batches=max_batches, + train=False, + seed=seed + fold * 100 + epoch + 777, + ) + v2_val_metrics = _run_epoch_v2( + v2_modules, + v2_val_ds, + None, + criterion, + device, + batch_size=batch_size, + max_batches=max_batches, + train=False, + seed=seed + fold * 100 + epoch + 777, + ) + + print( + f"[fold {fold} epoch {epoch}] " + f"v1 train loss={v1_train_metrics['loss']:.4f} acc={v1_train_metrics['acc']:.4f} | " + f"v2 train loss={v2_train_metrics['loss']:.4f} acc={v2_train_metrics['acc']:.4f}" + ) + print( + f"[fold {fold} epoch {epoch}] " + f"v1 val loss={v1_val_metrics['loss']:.4f} acc={v1_val_metrics['acc']:.4f} | " + f"v2 val loss={v2_val_metrics['loss']:.4f} acc={v2_val_metrics['acc']:.4f}" + ) + + diffs.extend( + _compare_epoch_metrics( + fold, + epoch, + v1_train_metrics, + v2_train_metrics, + v1_val_metrics, + v2_val_metrics, + loss_tol, + acc_tol, + ) + ) + + return diffs + + +def _compare_epoch_metrics( + fold: int, + epoch: int, + v1_train: Dict[str, float], + v2_train: Dict[str, float], + v1_val: Dict[str, float], + v2_val: Dict[str, float], + loss_tol: float, + acc_tol: float, +) -> List[str]: + diffs: List[str] = [] + for split_name, a, b in ( + ("train", v1_train, v2_train), + ("val", v1_val, v2_val), + ): + loss_diff = abs(a["loss"] - b["loss"]) + acc_diff = abs(a["acc"] - b["acc"]) + if loss_diff > loss_tol: + diffs.append( + f"Fold {fold} epoch {epoch} {split_name} loss diff {loss_diff:.4f} (v1={a['loss']:.4f} v2={b['loss']:.4f})" + ) + if acc_diff > acc_tol: + diffs.append( + f"Fold {fold} epoch {epoch} {split_name} acc diff {acc_diff:.4f} (v1={a['acc']:.4f} v2={b['acc']:.4f})" + ) + return diffs + + +def _move_modules(modules: Dict[str, Any], device: str) -> None: + for module in modules.values(): + if module is not None and hasattr(module, "to"): + module.to(device) + + +def _build_v1_dataset(v1: Dict[str, Any], df, image_transform) -> ClinicalDataset: + view = _ClinicalView(v1["clinical"], df) + return ClinicalDataset(view, image_transform) + + +def _build_v2_dataset(v2: Dict[str, Any], df, profile, image_transform) -> SlotDataset: + samples = profile.build_samples(df=df, clinical=v2["clinical"]) + return SlotDataset( + samples, + profile.slot_descriptors(), + image_transform=image_transform, + ) + + +def _make_loader( + dataset, + *, + batch_size: int, + shuffle: bool, + seed: int, + collate_fn=None, +) -> torch.utils.data.DataLoader: + g = torch.Generator() + g.manual_seed(seed) + return torch.utils.data.DataLoader( + dataset, + batch_size=batch_size, + shuffle=shuffle, + generator=g, + collate_fn=collate_fn, + ) + + +def _run_epoch_v1( + modules: Dict[str, Any], + dataset: ClinicalDataset, + optimizer: Optional[torch.optim.Optimizer], + criterion: nn.Module, + device: str, + *, + batch_size: int, + max_batches: int, + train: bool, + seed: int, +) -> Dict[str, float]: + _seed_all(seed) + loader = _make_loader(dataset, batch_size=batch_size, shuffle=train, seed=seed) + image_tower = modules["image_tower"] + md_tower = modules["metadata_tower"] + bridge = modules["bridge"] + + image_tower.train(train) + md_tower.train(train) + bridge.train(train) + + total_loss = 0.0 + total_correct = 0 + total_count = 0 + context = torch.enable_grad() if train else torch.no_grad() + with context: + for step, batch in enumerate(loader): + if step >= max_batches: + break + if len(batch) == 4: + imgs, metas, _, labels = batch + else: + imgs, metas, labels = batch + imgs = imgs.to(device) + metas = metas.to(device) + labels = labels.to(device) + + if optimizer is not None: + optimizer.zero_grad() + + img_feats = image_tower(imgs) + md_feats = md_tower(metas) + out_fused, _, _ = bridge(img_feats, md_feats) + loss = criterion(out_fused, labels) + + if optimizer is not None: + loss.backward() + optimizer.step() + + total_loss += float(loss.detach().item()) * labels.size(0) + total_correct += (out_fused.argmax(dim=1) == labels).sum().item() + total_count += labels.size(0) + + if total_count == 0: + return {"loss": float("nan"), "acc": float("nan")} + return {"loss": total_loss / total_count, "acc": total_correct / total_count} + + +def _run_epoch_v2( + modules: Dict[str, Any], + dataset: SlotDataset, + optimizer: Optional[torch.optim.Optimizer], + criterion: nn.Module, + device: str, + *, + batch_size: int, + max_batches: int, + train: bool, + seed: int, +) -> Dict[str, float]: + _seed_all(seed) + loader = _make_loader( + dataset, + batch_size=batch_size, + shuffle=train, + seed=seed, + collate_fn=slot_collate, + ) + image_tower = modules["image_tower"] + md_tower = modules["metadata_tower"] + bridge = modules["bridge"] + + image_tower.train(train) + md_tower.train(train) + bridge.train(train) + + total_loss = 0.0 + total_correct = 0 + total_count = 0 + context = torch.enable_grad() if train else torch.no_grad() + with context: + for step, batch in enumerate(loader): + if step >= max_batches: + break + imgs = batch.get("image_1") + metas = batch.get("matrix_1") + labels = batch.get("label_1") + if imgs is None or metas is None or labels is None: + continue + if not torch.is_tensor(imgs) or not torch.is_tensor(metas): + continue + imgs = imgs.to(device) + metas = metas.to(device) + labels = torch.as_tensor(labels, device=device) + + if optimizer is not None: + optimizer.zero_grad() + + img_feats = image_tower(imgs) + md_feats = md_tower(metas) + out_fused, _, _ = bridge(img_feats, md_feats) + loss = criterion(out_fused, labels) + + if optimizer is not None: + loss.backward() + optimizer.step() + + total_loss += float(loss.detach().item()) * labels.size(0) + total_correct += (out_fused.argmax(dim=1) == labels).sum().item() + total_count += labels.size(0) + + if total_count == 0: + return {"loss": float("nan"), "acc": float("nan")} + return {"loss": total_loss / total_count, "acc": total_correct / total_count} + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/testing/test_v2_papila_loaders.py b/scripts/testing/test_v2_papila_loaders.py new file mode 100644 index 0000000..44db107 --- /dev/null +++ b/scripts/testing/test_v2_papila_loaders.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Describe PAPILA splits and V2 slot-based loaders.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from types import SimpleNamespace + +import torch + +# Ensure repo root is importable when running as: python3 scripts/test_v2_papila_loaders.py +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes import build_papila_clinical +from classes.v2 import build_papila_profile, PatientFirstSplitManager, SlotLoaderFactory + + +def _describe_split(name: str, df, label_col: str) -> str: + if df is None or df.empty: + return f"{name}: empty" + patient_ids = set(df["Patient ID"].tolist()) + class_counts = dict(df.groupby(label_col).size().to_dict()) + return ( + f"{name}: patients={len(patient_ids)} rows={len(df)} " + f"class_rows={class_counts}" + ) + + +def _describe_batch(batch: dict) -> list[str]: + lines = [] + for key, val in batch.items(): + if isinstance(val, torch.Tensor): + lines.append(f"{key}: tensor shape={tuple(val.shape)} dtype={val.dtype}") + elif isinstance(val, list): + non_none = next((v for v in val if v is not None), None) + lines.append( + f"{key}: list len={len(val)} sample_type={type(non_none).__name__ if non_none is not None else 'None'}" + ) + else: + lines.append(f"{key}: {type(val).__name__}") + return lines + + +def main() -> None: + ap = argparse.ArgumentParser(description="Describe PAPILA splits + V2 slot-based loaders.") + ap.add_argument("--image-dir", default="Papila/FundusImages") + ap.add_argument("--clinical-dir", default="Papila/ClinicalData") + ap.add_argument("--label-col", default="Diagnosis") + ap.add_argument( + "--cat-cols", + nargs="*", + default=["Gender", "Phakic/Pseudophakic"], + help="Categorical columns for PAPILA builder.", + ) + ap.add_argument("--n-splits", type=int, default=5) + ap.add_argument("--fold-seed", type=int, default=42) + ap.add_argument("--holdout-per-class", type=int, default=1) + ap.add_argument("--holdout-seed", type=int, default=123) + ap.add_argument("--batch-size", type=int, default=4) + ap.add_argument("--num-workers", type=int, default=0) + ap.add_argument("--fold", type=int, default=0, help="Which fold to inspect in detail.") + ap.add_argument( + "--sample-mode", + choices=["patient", "eye"], + default="patient", + help="Build samples per patient (multi-slot) or per eye (row-level).", + ) + args = ap.parse_args() + + clinical = build_papila_clinical( + image_dir=args.image_dir, + clinical_dir=args.clinical_dir, + label_col=args.label_col, + cat_cols=args.cat_cols, + n_splits=args.n_splits, + random_seed=args.fold_seed, + ) + profile = build_papila_profile( + patient_col="Patient ID", + label_col=args.label_col, + sample_mode=args.sample_mode, + ) + + print("=== PAPILA profile slots ===") + for key, desc in profile.slot_descriptors().items(): + print(f"{key}: kind={desc.kind} required={desc.required} desc={desc.description}") + print("aliases:", profile.semantic_aliases()) + + split_args = SimpleNamespace( + eval_mode="multiclass", + holdout_per_class=args.holdout_per_class, + holdout_seed=args.holdout_seed, + n_splits=args.n_splits, + fold_seed=args.fold_seed, + ) + split_manager = PatientFirstSplitManager(patient_col="Patient ID", label_col=args.label_col) + plans = split_manager.build_plans(clinical=clinical, args=split_args, profile=profile) + + print("\n=== Split summaries ===") + for i, split in enumerate(plans): + print(f"fold {i}:") + print(" " + _describe_split("train", split.train, args.label_col)) + print(" " + _describe_split("val", split.val, args.label_col)) + print(" " + _describe_split("holdout", split.holdout, args.label_col)) + + if args.fold < 0 or args.fold >= len(plans): + raise SystemExit(f"Requested fold {args.fold} but only {len(plans)} folds are available") + split = plans[args.fold] + + loader_factory = SlotLoaderFactory(num_workers=args.num_workers) + loaders = loader_factory.build( + clinical=clinical, + split=split, + args=SimpleNamespace(batch_size=args.batch_size), + fold=args.fold, + profile=profile, + ) + + print(f"\n=== Loader inspection (fold {args.fold}) ===") + for name, loader in (("train", loaders.train), ("val", loaders.val), ("holdout", loaders.holdout)): + if loader is None: + print(f"{name}: None") + continue + print(f"{name}: batches={len(loader)} batch_size={loader.batch_size}") + batch = next(iter(loader)) + for line in _describe_batch(batch): + print(f" {line}") + + +if __name__ == "__main__": + main() diff --git a/scripts/testing/test_v2_split_manager.py b/scripts/testing/test_v2_split_manager.py new file mode 100644 index 0000000..2b48425 --- /dev/null +++ b/scripts/testing/test_v2_split_manager.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Tiny smoke test for classes.v2.split_manager.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pandas as pd + +# Ensure repo root is importable when running as: python3 scripts/test_v2_split_manager.py +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from classes import build_papila_clinical +from classes.v2 import build_papila_profile +from classes.v2.split_manager import PatientFirstSplitManager, build_patient_split_plans + + +class _ClinicalStub: + def __init__(self, df: pd.DataFrame, label_col: str = "Diagnosis") -> None: + self.df = df + self.label_col = label_col + + +def _make_fake_df(n_patients: int, n_classes: int, label_col: str) -> pd.DataFrame: + rows = [] + for pid in range(1, n_patients + 1): + label = (pid - 1) % n_classes + for eye in ("OD", "OS"): + rows.append( + { + "Patient ID": pid, + "eyeID": eye, + label_col: label, + "dummy_feature": float(pid), + } + ) + return pd.DataFrame(rows) + + +def _summarize_fold( + fold: int, + split, + label_col: str, + expected_rows_per_patient: int | None = None, +) -> str: + train_ids = set(split.train["Patient ID"].tolist()) + val_ids = set(split.val["Patient ID"].tolist()) + holdout_ids = set(split.holdout["Patient ID"].tolist()) if split.holdout is not None else set() + + if train_ids & val_ids: + raise RuntimeError(f"Fold {fold}: train/val overlap detected") + if train_ids & holdout_ids: + raise RuntimeError(f"Fold {fold}: train/holdout overlap detected") + if val_ids & holdout_ids: + raise RuntimeError(f"Fold {fold}: val/holdout overlap detected") + + if expected_rows_per_patient is not None: + # Used only for synthetic data where we know OD+OS are both present. + for name, df in (("train", split.train), ("val", split.val), ("holdout", split.holdout)): + if df is None or df.empty: + continue + counts = df.groupby("Patient ID").size().unique().tolist() + if counts != [expected_rows_per_patient]: + raise RuntimeError(f"Fold {fold}: {name} has broken per-patient row grouping: {counts}") + + train_cls = dict(split.train.groupby(label_col).size().to_dict()) + val_cls = dict(split.val.groupby(label_col).size().to_dict()) + hold_cls = dict(split.holdout.groupby(label_col).size().to_dict()) if split.holdout is not None else {} + return ( + f"fold={fold} " + f"train_patients={len(train_ids)} val_patients={len(val_ids)} holdout_patients={len(holdout_ids)} " + f"train_rows={len(split.train)} val_rows={len(split.val)} holdout_rows={0 if split.holdout is None else len(split.holdout)} " + f"train_class_rows={train_cls} val_class_rows={val_cls} holdout_class_rows={hold_cls}" + ) + + +def _confirm_holdout_consistency_and_exclusion(splits) -> None: + holdout_sets: list[set] = [] + val_union: set = set() + for split in splits: + holdout_ids = set(split.holdout["Patient ID"].tolist()) if split.holdout is not None else set() + holdout_sets.append(holdout_ids) + val_union.update(split.val["Patient ID"].tolist()) + + # A) Holdout should be the same patients across all folds. + baseline = holdout_sets[0] if holdout_sets else set() + for i, holdout_ids in enumerate(holdout_sets): + if holdout_ids != baseline: + raise RuntimeError( + f"Holdout mismatch: fold 0 has {sorted(baseline)}, fold {i} has {sorted(holdout_ids)}" + ) + + # B) Holdout patients should never appear in any validation/test fold. + overlap = baseline & val_union + if overlap: + raise RuntimeError(f"Holdout patients found in val/test sets: {sorted(overlap)}") + + print( + "Holdout checks: OK " + f"(constant across folds, holdout_patients={len(baseline)}, overlap_with_any_val=0)" + ) + + +def main() -> None: + ap = argparse.ArgumentParser(description="Smoke test PatientFirstSplitManager with synthetic data.") + ap.add_argument("--dataset", choices=["papila", "synthetic"], default="papila") + ap.add_argument( + "--patients", + type=int, + default=30, + help="Synthetic mode only: number of fake patients to generate.", + ) + ap.add_argument( + "--synthetic-classes", + type=int, + default=3, + help="Synthetic mode only: number of classes to generate.", + ) + ap.add_argument("--n-splits", type=int, default=5) + ap.add_argument("--holdout-per-class", type=int, default=1) + ap.add_argument("--fold-seed", type=int, default=42) + ap.add_argument("--holdout-seed", type=int, default=123) + ap.add_argument("--image-dir", default="Papila/FundusImages") + ap.add_argument("--clinical-dir", default="Papila/ClinicalData") + ap.add_argument("--label-col", default="Diagnosis") + ap.add_argument( + "--sample-mode", + choices=["patient", "eye"], + default="patient", + help="Build samples per patient (multi-slot) or per eye (row-level).", + ) + ap.add_argument( + "--cat-cols", + nargs="*", + default=["Gender", "Phakic/Pseudophakic"], + help="Categorical columns for PAPILA builder.", + ) + args = ap.parse_args() + + if args.synthetic_classes < 2: + raise SystemExit("--synthetic-classes must be >= 2") + + expected_rows_per_patient: int | None = None + if args.dataset == "papila": + clinical = build_papila_clinical( + image_dir=args.image_dir, + clinical_dir=args.clinical_dir, + label_col=args.label_col, + cat_cols=args.cat_cols, + n_splits=args.n_splits, + random_seed=args.fold_seed, + ) + df = clinical.df.copy() + print( + f"Loaded PAPILA dataframe: rows={len(df)} patients={df['Patient ID'].nunique()} " + f"labels={dict(df.groupby(args.label_col).size().to_dict())}" + ) + else: + df = _make_fake_df(args.patients, args.synthetic_classes, args.label_col) + clinical = _ClinicalStub(df=df, label_col=args.label_col) + expected_rows_per_patient = 2 + print( + f"Loaded synthetic dataframe: rows={len(df)} patients={df['Patient ID'].nunique()} " + f"labels={dict(df.groupby(args.label_col).size().to_dict())}" + ) + n_classes = int(df[args.label_col].nunique()) + print(f"Detected classes from dataframe: n_classes={n_classes}") + + split_args = SimpleNamespace( + eval_mode="multiclass", + holdout_per_class=args.holdout_per_class, + holdout_seed=args.holdout_seed, + n_splits=args.n_splits, + fold_seed=args.fold_seed, + ) + + manager = PatientFirstSplitManager(patient_col="Patient ID", label_col=args.label_col) + profile = build_papila_profile( + patient_col="Patient ID", + label_col=args.label_col, + sample_mode=args.sample_mode, + ) + splits = manager.build_plans(clinical=clinical, args=split_args, profile=profile) + + print("=== Adapter split manager output ===") + for fold, split in enumerate(splits): + print( + _summarize_fold( + fold, + split, + label_col=args.label_col, + expected_rows_per_patient=expected_rows_per_patient, + ) + ) + _confirm_holdout_consistency_and_exclusion(splits) + + # Also smoke-test the pure vector API directly. + patient_labels = df.groupby("Patient ID")[args.label_col].first() + plans = build_patient_split_plans( + patient_ids=patient_labels.index.to_numpy(), + patient_labels=patient_labels.to_numpy(), + n_splits=args.n_splits, + seed=args.fold_seed, + holdout_per_class=args.holdout_per_class, + holdout_seed=args.holdout_seed, + ) + print(f"\nVector API produced {len(plans)} fold plans.") + print("OK: split manager smoke test passed.") + + +if __name__ == "__main__": + main() diff --git a/scripts/utility/check_gpu.py b/scripts/utility/check_gpu.py new file mode 100755 index 0000000..edbce11 --- /dev/null +++ b/scripts/utility/check_gpu.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +""" +Quick GPU sanity for PyTorch (CUDA or ROCm). +Prints device visibility, names, memory, and runs a tiny matmul on GPU:0 if available. + +Run: + python3 scripts/check_gpu.py +""" +from __future__ import annotations +import os, time, shutil, platform + +def _fmt_gb(b: int) -> str: + try: + return f"{b / (1024**3):.2f} GB" + except Exception: + return str(b) + +def main(): + try: + import torch + except Exception as e: + print(f"torch import failed: {e}") + return + + print(f"torch: {getattr(torch, '__version__', 'unknown')}") + print(f"python: {platform.python_version()} on {platform.platform()}") + print(f"CUDA_VISIBLE_DEVICES={os.environ.get('CUDA_VISIBLE_DEVICES', '')!r}") + print(f"HIP_VISIBLE_DEVICES={os.environ.get('HIP_VISIBLE_DEVICES', '')!r}") + print(f"torch.version.cuda={getattr(torch.version, 'cuda', None)}") + print(f"torch.version.hip={getattr(torch.version, 'hip', None)}") + + # Apple MPS check (macOS) + if hasattr(torch.backends, 'mps'): + print(f"mps available={torch.backends.mps.is_available()} built={torch.backends.mps.is_built()}") + + # CUDA/ROCm check + use_cuda = torch.cuda.is_available() + print(f"cuda available={use_cuda}") + if not use_cuda: + print("No CUDA/ROCm device visible to PyTorch.") + nvsmi = shutil.which('nvidia-smi') + rocmsmi = shutil.which('rocm-smi') or shutil.which('rocminfo') + if nvsmi: + print("nvidia-smi found; ensure your env uses a CUDA-enabled PyTorch build.") + if rocmsmi: + print("ROCm tools found; ensure your env uses a ROCm-enabled PyTorch build.") + print("Tip: activate your conda env and reinstall the GPU build if needed.") + return + + # List devices + try: + n = torch.cuda.device_count() + except Exception as e: + print(f"device_count error: {e}") + n = 0 + print(f"device_count={n}") + for i in range(n): + try: + name = torch.cuda.get_device_name(i) + except Exception: + name = "?" + try: + props = torch.cuda.get_device_properties(i) + mem = _fmt_gb(getattr(props, 'total_memory', 0)) + except Exception: + mem = "?" + print(f" cuda:{i} → {name} | total_memory={mem}") + + # Quick matmul on cuda:0 + import torch + try: + dev = torch.device('cuda:0') + torch.cuda.synchronize() + a = torch.randn(2048, 2048, device=dev) + b = torch.randn(2048, 2048, device=dev) + t0 = time.time() + c = a @ b + torch.cuda.synchronize() + dt = time.time() - t0 + print(f"matmul(2048x2048) on cuda:0 ok in {dt:.3f}s; c.mean={float(c.mean()):.5f}") + del a, b, c + torch.cuda.empty_cache() + except Exception as e: + print(f"matmul on cuda:0 failed: {e}") + + # cuDNN info (if CUDA build) + try: + print(f"cudnn available={torch.backends.cudnn.is_available()} version={torch.backends.cudnn.version()}") + except Exception: + pass + +if __name__ == "__main__": + main() + diff --git a/scripts/utility/cleanup_grid_runs.sh b/scripts/utility/cleanup_grid_runs.sh new file mode 100755 index 0000000..b504176 --- /dev/null +++ b/scripts/utility/cleanup_grid_runs.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Remove grid-search artifacts (analysis_data/grid_search and models/grid_search). +# Default is a dry run; pass --yes to actually delete. +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ANALYSIS_DIR="$ROOT_DIR/analysis_data/grid_search" +MODELS_DIR="$ROOT_DIR/models/grid_search" + +DRY_RUN=1 +for arg in "$@"; do + if [[ "$arg" == "--yes" ]]; then + DRY_RUN=0 + fi +done + +echo "[cleanup] Target analysis dir: $ANALYSIS_DIR" +echo "[cleanup] Target models dir: $MODELS_DIR" + +if [[ $DRY_RUN -eq 1 ]]; then + echo "[cleanup] Dry run only. Nothing deleted. Pass --yes to remove." + exit 0 +fi + +if [[ -d "$ANALYSIS_DIR" ]]; then + echo "[cleanup] Removing $ANALYSIS_DIR" + rm -rf "$ANALYSIS_DIR" +else + echo "[cleanup] Analysis dir not found; skipping." +fi + +if [[ -d "$MODELS_DIR" ]]; then + echo "[cleanup] Removing $MODELS_DIR" + rm -rf "$MODELS_DIR" +else + echo "[cleanup] Models dir not found; skipping." +fi + +echo "[cleanup] Done." diff --git a/webui/app.js b/webui/app.js new file mode 100644 index 0000000..3867407 --- /dev/null +++ b/webui/app.js @@ -0,0 +1,1973 @@ +const state = { + nodes: [], + edges: [], + selectedId: null, + connectMode: false, + connectFrom: null, + pan: { x: 0, y: 0, scale: 1 }, + clickTimer: null, + draggingNodeId: null, + draggingOffset: null, + draggingMoved: false, + draggingJustEnded: false, + meta: { imports: [] }, +}; + +const nodeTypeOrder = [ + "data", + "filter", + "transform", + "loader", + "image_tower", + "metadata_tower", + "bridge", + "classifier", +]; + +const typeMeta = { + data: { color: "#2f5d62", label: "Data" }, + filter: { color: "#8d6b94", label: "Filter" }, + transform: { color: "#f6c453", label: "Transform" }, + loader: { color: "#c84630", label: "Loader" }, + image_tower: { color: "#1f4e79", label: "Tower" }, + metadata_tower: { color: "#6b4226", label: "Tower" }, + bridge: { color: "#2a2a72", label: "Bridge" }, + classifier: { color: "#5a3e2b", label: "Classifier" }, +}; + +const svg = document.getElementById("graph"); +const jsonView = document.getElementById("json-view"); +const nodeCount = document.getElementById("node-count"); +const edgeCount = document.getElementById("edge-count"); +const selectedNode = document.getElementById("selected-node"); +const nodeLabelInput = document.getElementById("node-label"); +const nodeSlotInput = document.getElementById("node-slot"); +const nodeOutputInput = document.getElementById("node-output"); +const selectionGeneric = document.getElementById("selection-generic"); +const selectionLoader = document.getElementById("selection-loader"); +const selectionFilter = document.getElementById("selection-filter"); +const selectionTransform = document.getElementById("selection-transform"); +const selectionTower = document.getElementById("selection-tower"); +const selectionBridge = document.getElementById("selection-bridge"); +const selectionClassifier = document.getElementById("selection-classifier"); +const selectionData = document.getElementById("selection-data"); +const loaderInputType = document.getElementById("loader-input-type"); +const loaderInputIndex = document.getElementById("loader-input-index"); +const loaderOutputType = document.getElementById("loader-output-type"); +const dataOutputType = document.getElementById("data-output-type"); +const filterInputType = document.getElementById("filter-input-type"); +const filterOutputType = document.getElementById("filter-output-type"); +const filterType = document.getElementById("filter-type"); +const filterRegexFields = document.getElementById("filter-regex-fields"); +const filterRegexPattern = document.getElementById("filter-regex-pattern"); +const filterColumnFields = document.getElementById("filter-column-fields"); +const filterColumnName = document.getElementById("filter-column-name"); +const filterColumnOperator = document.getElementById("filter-column-operator"); +const filterColumnValue = document.getElementById("filter-column-value"); +const transformType = document.getElementById("transform-type"); +const transformRoiFields = document.getElementById("transform-roi-fields"); +const transformRoiMaskSource = document.getElementById("transform-roi-mask-source"); +const transformRoiScale = document.getElementById("transform-roi-scale"); +const transformRoiTarget = document.getElementById("transform-roi-target"); +const transformRoiFallback = document.getElementById("transform-roi-fallback"); +const transformCenterFields = document.getElementById("transform-center-fields"); +const transformCenterSize = document.getElementById("transform-center-size"); +const transformJitterFields = document.getElementById("transform-jitter-fields"); +const transformJitterHFlip = document.getElementById("transform-jitter-hflip"); +const transformJitterVFlip = document.getElementById("transform-jitter-vflip"); +const transformJitterRotation = document.getElementById("transform-jitter-rotation"); +const transformJitterColorEnabled = document.getElementById("transform-jitter-color-enabled"); +const transformJitterColor = document.getElementById("transform-jitter-color"); +const transformResizeFields = document.getElementById("transform-resize-fields"); +const transformResizeSize = document.getElementById("transform-resize-size"); +const towerType = document.getElementById("tower-type"); +const towerImageFields = document.getElementById("tower-image-fields"); +const towerBackbone = document.getElementById("tower-backbone"); +const towerFreezeRatio = document.getElementById("tower-freeze-ratio"); +const towerAugment = document.getElementById("tower-augment"); +const towerGeometryDim = document.getElementById("tower-geometry-dim"); +const towerUseSe = document.getElementById("tower-use-se"); +const towerSeReduction = document.getElementById("tower-se-reduction"); +const towerSePreNorm = document.getElementById("tower-se-pre-norm"); +const towerMdFields = document.getElementById("tower-md-fields"); +const towerMdHidden = document.getElementById("tower-md-hidden"); +const towerMdDropout = document.getElementById("tower-md-dropout"); +const towerMdUseSe = document.getElementById("tower-md-use-se"); +const towerMdSeReduction = document.getElementById("tower-md-se-reduction"); +const towerMdSePreNorm = document.getElementById("tower-md-se-pre-norm"); +const towerMdFreezeRatio = document.getElementById("tower-md-freeze-ratio"); +const bridgeMethod = document.getElementById("bridge-method"); +const bridgeFusionFields = document.getElementById("bridge-fusion-fields"); +const bridgeFusionDim = document.getElementById("bridge-fusion-dim"); +const bridgeUseSe = document.getElementById("bridge-use-se"); +const bridgeSeReduction = document.getElementById("bridge-se-reduction"); +const bridgeSePreNorm = document.getElementById("bridge-se-pre-norm"); +const dataBrowseButton = document.getElementById("data-browse"); +const dataSourceStatus = document.getElementById("data-source-status"); +const duplicateDataButton = document.getElementById("duplicate-data"); +const modalOverlay = document.getElementById("modal-overlay"); +const modalList = document.getElementById("modal-list"); +const modalClose = document.getElementById("modal-close"); +const modalSelectDir = document.getElementById("modal-select-dir"); +const modalSelectFile = document.getElementById("modal-select-file"); +const modalMode = document.getElementById("modal-mode"); +const breadcrumb = document.getElementById("breadcrumb"); +const previewOverlay = document.getElementById("preview-overlay"); +const previewClose = document.getElementById("preview-close"); +const previewBody = document.getElementById("preview-body"); +const previewSubtitle = document.getElementById("preview-subtitle"); +const importClassSelect = document.getElementById("import-class-select"); +const importClassBtn = document.getElementById("import-class-btn"); +const presetSelect = document.getElementById("preset-select"); +const presetApplyBtn = document.getElementById("preset-apply"); +const presetSaveBtn = document.getElementById("preset-save"); +const connectButton = document.getElementById("connect-mode"); +const toggleJsonButton = document.getElementById("toggle-json"); + +const PRESET_STORAGE_KEY = "hypertower_v2_presets"; +const selectionState = { nodeIds: new Set(), edgeIndices: new Set() }; +let selectionBox = null; + +function newNodeId() { + return `node_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; +} + +function createNodeTemplate(type) { + const node = { + id: newNodeId(), + type, + label: `${typeMeta[type]?.label || type}`, + inputKey: "", + outputKey: "", + inputType: "image", + inputIndex: "1", + outputType: type === "data" ? "" : "image", + source: null, + selectedPath: null, + filterType: type === "filter" ? "regex" : "", + regexPattern: "", + columnName: "", + columnOperator: ">", + columnValue: "", + transformType: type === "transform" ? "center_crop" : "", + roiMaskSource: "gt", + roiScale: 2.5, + roiTargetSize: 224, + roiFallback: true, + centerCropSize: 224, + jitterHFlip: true, + jitterVFlip: true, + jitterRotation: 15, + jitterColorEnabled: true, + jitterColor: "0.1,0.1,0.1,0.05", + resizeSize: 256, + }; + if (type === "image_tower" || type === "metadata_tower") { + node.towerType = type === "metadata_tower" ? "metadata" : "image"; + node.imageBackbone = "efficientnet_b0"; + node.imageFreezeRatio = 0.0; + node.imageAugment = true; + node.imageGeometryDim = 0; + node.imageUseSe = false; + node.imageSeReduction = 16; + node.imageSePreNorm = true; + node.mdHiddenDim = 128; + node.mdDropout = 0.1; + node.mdUseSe = false; + node.mdSeReduction = 16; + node.mdSePreNorm = true; + node.mdFreezeRatio = 0.0; + } + if (type === "bridge") { + node.bridgeMethod = "fusion"; + node.bridgeFusionDim = 256; + node.bridgeUseSe = true; + node.bridgeSeReduction = 16; + node.bridgeSePreNorm = true; + } + return node; +} + +function addNode(type) { + const count = state.nodes.filter((n) => n.type === type).length + 1; + const node = createNodeTemplate(type); + node.label = `${typeMeta[type]?.label || type} ${count}`; + state.nodes.push(node); + selectNode(node.id); + autoLayout(); + render(); + return node; +} + +function addDataSourceNode({ label, outputType }) { + const node = createNodeTemplate("data"); + node.label = label; + node.inputType = ""; + node.inputIndex = ""; + node.outputType = outputType; + node.sourceRef = null; + state.nodes.push(node); + return node; +} + +async function importClassDefinition() { + const selection = importClassSelect.value; + if (!selection) return; + if (selection === "PapilaData") { + let payload = null; + try { + const res = await fetch("/api/import/papila"); + const parsed = await parseJsonResponse(res); + payload = parsed.data; + if (!res.ok || !payload) { + throw new Error(payload?.error || parsed.text || "Import failed"); + } + } catch (err) { + alert(`Papila import failed: ${err.message}`); + return; + } + + const importId = `import_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + if (!state.meta) { + state.meta = { imports: [] }; + } + if (!Array.isArray(state.meta.imports)) { + state.meta.imports = []; + } + state.meta.imports.push({ + id: importId, + className: "PapilaData", + params: { + image_dir: "Papila/FundusImages", + clinical_dir: "Papila/ClinicalData", + label_col: payload.label_col || "Diagnosis", + cat_cols: ["Gender", "Phakic/Pseudophakic"], + }, + }); + + const imageNode = addDataSourceNode({ + label: "Papila Images", + outputType: "image", + }); + imageNode.source = { + mode: "directory", + path: payload.image_dir, + count: null, + }; + imageNode.sourceRef = { + importId, + slot: "image_dir", + mode: "directory", + }; + + const matrixNode = addDataSourceNode({ + label: "Papila Metadata", + outputType: "matrix", + }); + matrixNode.source = { + mode: "dataframe", + name: "clinical.df", + rows: payload.df_rows, + cols: payload.df_cols, + }; + matrixNode.sourceRef = { + importId, + slot: "clinical_df", + mode: "dataframe", + }; + const resizeTransform = addNode("transform"); + resizeTransform.label = "Resize"; + resizeTransform.transformType = "resize"; + resizeTransform.resizeSize = 256; + + const centerTransform = addNode("transform"); + centerTransform.label = "Center Crop"; + centerTransform.transformType = "center_crop"; + centerTransform.centerCropSize = 224; + + const jitterTransform = addNode("transform"); + jitterTransform.label = "Jitter"; + jitterTransform.transformType = "jitter_bundle"; + jitterTransform.jitterHFlip = true; + jitterTransform.jitterVFlip = true; + jitterTransform.jitterRotation = 15; + jitterTransform.jitterColorEnabled = true; + jitterTransform.jitterColor = "0.1,0.1,0.1,0.05"; + + const imageLoader = addNode("loader"); + imageLoader.label = "Image Loader"; + imageLoader.inputType = "image"; + imageLoader.inputIndex = "1"; + imageLoader.outputType = "image"; + imageLoader.inputKey = "image_1"; + imageLoader.outputKey = "image_1"; + + const matrixLoader = addNode("loader"); + matrixLoader.label = "Metadata Loader"; + matrixLoader.inputType = "matrix"; + matrixLoader.inputIndex = "1"; + matrixLoader.outputType = "matrix"; + matrixLoader.inputKey = "matrix_1"; + matrixLoader.outputKey = "matrix_1"; + + const imageTower = addNode("image_tower"); + imageTower.label = "Tower 1"; + imageTower.towerType = "image"; + imageTower.imageBackbone = "efficientnet_b0"; + imageTower.imageFreezeRatio = 0.0; + imageTower.imageAugment = true; + imageTower.imageGeometryDim = 0; + imageTower.imageUseSe = false; + imageTower.imageSeReduction = 16; + imageTower.imageSePreNorm = true; + + const metadataTower = addNode("metadata_tower"); + metadataTower.label = "Tower 2"; + metadataTower.towerType = "metadata"; + metadataTower.mdHiddenDim = 128; + metadataTower.mdDropout = 0.1; + metadataTower.mdUseSe = false; + metadataTower.mdSeReduction = 16; + metadataTower.mdSePreNorm = true; + metadataTower.mdFreezeRatio = 0.0; + + const bridgeNode = addNode("bridge"); + bridgeNode.label = "Bridge"; + bridgeNode.bridgeMethod = "fusion"; + bridgeNode.bridgeFusionDim = 256; + bridgeNode.bridgeUseSe = true; + bridgeNode.bridgeSeReduction = 16; + bridgeNode.bridgeSePreNorm = true; + + const classifierNode = addNode("classifier"); + classifierNode.label = "Classifier"; + + state.edges.push({ from: imageNode.id, to: resizeTransform.id }); + state.edges.push({ from: resizeTransform.id, to: centerTransform.id }); + state.edges.push({ from: centerTransform.id, to: jitterTransform.id }); + state.edges.push({ from: jitterTransform.id, to: imageLoader.id }); + state.edges.push({ from: matrixNode.id, to: matrixLoader.id }); + state.edges.push({ from: imageLoader.id, to: imageTower.id }); + state.edges.push({ from: matrixLoader.id, to: metadataTower.id }); + state.edges.push({ from: imageTower.id, to: bridgeNode.id }); + state.edges.push({ from: metadataTower.id, to: bridgeNode.id }); + state.edges.push({ from: bridgeNode.id, to: classifierNode.id }); + + autoLayout(); + selectNode(imageNode.id); + render(); + } +} + +function selectNode(id) { + state.selectedId = id; + if (id) { + setSelection([id]); + } else { + setSelection([]); + } + const node = state.nodes.find((n) => n.id === id); + if (node) { + selectedNode.textContent = node.label; + selectedNode.classList.remove("muted"); + nodeLabelInput.value = node.label; + nodeSlotInput.value = node.inputKey || ""; + nodeOutputInput.value = node.outputKey || ""; + selectionGeneric.classList.toggle("hidden", node.type === "loader" || node.type === "data"); + selectionLoader.classList.toggle("hidden", node.type !== "loader"); + selectionFilter.classList.toggle("hidden", node.type !== "filter"); + selectionTransform.classList.toggle("hidden", node.type !== "transform"); + const isTower = node.type === "image_tower" || node.type === "metadata_tower"; + selectionTower.classList.toggle("hidden", !isTower); + selectionBridge.classList.toggle("hidden", node.type !== "bridge"); + selectionClassifier.classList.toggle("hidden", node.type !== "classifier"); + selectionData.classList.toggle("hidden", node.type !== "data"); + + if (node.type === "loader") { + loaderInputType.value = node.inputType || "image"; + loaderInputIndex.value = node.inputIndex || "1"; + loaderOutputType.value = node.outputType || loaderInputType.value; + } + if (node.type === "filter") { + filterInputType.value = node.inputType || "image"; + filterOutputType.value = node.outputType || filterInputType.value; + filterType.value = node.filterType || "regex"; + filterRegexPattern.value = node.regexPattern || ""; + filterColumnName.value = node.columnName || ""; + filterColumnOperator.value = node.columnOperator || ">"; + filterColumnValue.value = node.columnValue || ""; + updateFilterFieldVisibility(filterType.value); + } + if (node.type === "transform") { + transformType.value = node.transformType || "center_crop"; + updateTransformFieldVisibility(transformType.value); + transformRoiMaskSource.value = node.roiMaskSource || "gt"; + transformRoiScale.value = + node.roiScale === null || node.roiScale === undefined ? "" : node.roiScale; + transformRoiTarget.value = + node.roiTargetSize === null || node.roiTargetSize === undefined ? "" : node.roiTargetSize; + transformRoiFallback.checked = node.roiFallback !== false; + transformCenterSize.value = + node.centerCropSize === null || node.centerCropSize === undefined + ? "" + : node.centerCropSize; + transformJitterHFlip.checked = node.jitterHFlip !== false; + transformJitterVFlip.checked = node.jitterVFlip !== false; + transformJitterRotation.value = + node.jitterRotation === null || node.jitterRotation === undefined + ? "" + : node.jitterRotation; + transformJitterColorEnabled.checked = node.jitterColorEnabled !== false; + transformJitterColor.value = node.jitterColor || ""; + transformResizeSize.value = + node.resizeSize === null || node.resizeSize === undefined ? "" : node.resizeSize; + } + if (node.type === "bridge") { + bridgeMethod.value = node.bridgeMethod || "fusion"; + updateBridgeFieldVisibility(bridgeMethod.value); + bridgeFusionDim.value = + node.bridgeFusionDim === null || node.bridgeFusionDim === undefined + ? "" + : node.bridgeFusionDim; + bridgeUseSe.checked = node.bridgeUseSe !== false; + bridgeSeReduction.value = + node.bridgeSeReduction === null || node.bridgeSeReduction === undefined + ? "" + : node.bridgeSeReduction; + bridgeSePreNorm.checked = node.bridgeSePreNorm !== false; + } + if (node.type === "image_tower" || node.type === "metadata_tower") { + const towerKind = node.type === "metadata_tower" ? "metadata" : "image"; + towerType.value = node.towerType || towerKind; + updateTowerFieldVisibility(towerType.value); + towerBackbone.value = node.imageBackbone || "efficientnet_b0"; + towerFreezeRatio.value = + node.imageFreezeRatio === null || node.imageFreezeRatio === undefined + ? "" + : node.imageFreezeRatio; + towerAugment.checked = node.imageAugment !== false; + towerGeometryDim.value = + node.imageGeometryDim === null || node.imageGeometryDim === undefined + ? "" + : node.imageGeometryDim; + towerUseSe.checked = node.imageUseSe === true; + towerSeReduction.value = + node.imageSeReduction === null || node.imageSeReduction === undefined + ? "" + : node.imageSeReduction; + towerSePreNorm.checked = node.imageSePreNorm !== false; + towerMdHidden.value = + node.mdHiddenDim === null || node.mdHiddenDim === undefined ? "" : node.mdHiddenDim; + towerMdDropout.value = + node.mdDropout === null || node.mdDropout === undefined ? "" : node.mdDropout; + towerMdUseSe.checked = node.mdUseSe === true; + towerMdSeReduction.value = + node.mdSeReduction === null || node.mdSeReduction === undefined + ? "" + : node.mdSeReduction; + towerMdSePreNorm.checked = node.mdSePreNorm !== false; + towerMdFreezeRatio.value = + node.mdFreezeRatio === null || node.mdFreezeRatio === undefined + ? "" + : node.mdFreezeRatio; + } + if (node.type === "data") { + dataOutputType.value = node.outputType || ""; + updateDataBrowseVisibility(node.outputType || ""); + updateDataSourceStatus(node); + } + } else { + clearSelectionUI(); + } +} + +function clearSelectionUI() { + selectedNode.textContent = "None"; + selectedNode.classList.add("muted"); + nodeLabelInput.value = ""; + nodeSlotInput.value = ""; + nodeOutputInput.value = ""; + selectionGeneric.classList.remove("hidden"); + selectionLoader.classList.add("hidden"); + selectionFilter.classList.add("hidden"); + selectionTransform.classList.add("hidden"); + selectionTower.classList.add("hidden"); + selectionBridge.classList.add("hidden"); + selectionClassifier.classList.add("hidden"); + selectionData.classList.add("hidden"); +} + +function applyNodeEdits() { + const node = state.nodes.find((n) => n.id === state.selectedId); + if (!node) return; + node.label = nodeLabelInput.value.trim() || node.label; + node.inputKey = nodeSlotInput.value.trim(); + node.outputKey = nodeOutputInput.value.trim(); + if (node.type === "loader") { + node.inputType = loaderInputType.value; + node.inputIndex = loaderInputIndex.value; + node.outputType = node.inputType; + } + if (node.type === "filter") { + node.inputType = filterInputType.value; + node.outputType = node.inputType; + node.filterType = filterType.value; + node.regexPattern = filterRegexPattern.value.trim(); + node.columnName = filterColumnName.value.trim(); + node.columnOperator = filterColumnOperator.value; + node.columnValue = filterColumnValue.value.trim(); + filterOutputType.value = node.outputType; + } + if (node.type === "transform") { + node.transformType = transformType.value; + node.roiMaskSource = transformRoiMaskSource.value; + node.roiScale = transformRoiScale.value === "" ? null : Number(transformRoiScale.value); + node.roiTargetSize = + transformRoiTarget.value === "" ? null : Number(transformRoiTarget.value); + node.roiFallback = transformRoiFallback.checked; + node.centerCropSize = + transformCenterSize.value === "" ? null : Number(transformCenterSize.value); + node.jitterHFlip = transformJitterHFlip.checked; + node.jitterVFlip = transformJitterVFlip.checked; + node.jitterRotation = + transformJitterRotation.value === "" ? null : Number(transformJitterRotation.value); + node.jitterColorEnabled = transformJitterColorEnabled.checked; + node.jitterColor = transformJitterColor.value.trim(); + node.resizeSize = + transformResizeSize.value === "" ? null : Number(transformResizeSize.value); + } + if (node.type === "bridge") { + node.bridgeMethod = bridgeMethod.value; + node.bridgeFusionDim = + bridgeFusionDim.value === "" ? null : Number(bridgeFusionDim.value); + node.bridgeUseSe = bridgeUseSe.checked; + node.bridgeSeReduction = + bridgeSeReduction.value === "" ? null : Number(bridgeSeReduction.value); + node.bridgeSePreNorm = bridgeSePreNorm.checked; + } + if (node.type === "image_tower" || node.type === "metadata_tower") { + const desiredType = towerType.value === "metadata" ? "metadata_tower" : "image_tower"; + if (node.type !== desiredType) { + const oldType = node.type; + node.type = desiredType; + const oldLabelBase = typeMeta[oldType]?.label; + const newLabelBase = typeMeta[desiredType]?.label; + if (oldLabelBase && newLabelBase && node.label.startsWith(oldLabelBase)) { + node.label = node.label.replace(oldLabelBase, newLabelBase); + } + } + node.towerType = towerType.value; + node.imageBackbone = towerBackbone.value; + node.imageFreezeRatio = + towerFreezeRatio.value === "" ? null : Number(towerFreezeRatio.value); + node.imageAugment = towerAugment.checked; + node.imageGeometryDim = + towerGeometryDim.value === "" ? null : Number(towerGeometryDim.value); + node.imageUseSe = towerUseSe.checked; + node.imageSeReduction = + towerSeReduction.value === "" ? null : Number(towerSeReduction.value); + node.imageSePreNorm = towerSePreNorm.checked; + node.mdHiddenDim = towerMdHidden.value === "" ? null : Number(towerMdHidden.value); + node.mdDropout = towerMdDropout.value === "" ? null : Number(towerMdDropout.value); + node.mdUseSe = towerMdUseSe.checked; + node.mdSeReduction = + towerMdSeReduction.value === "" ? null : Number(towerMdSeReduction.value); + node.mdSePreNorm = towerMdSePreNorm.checked; + node.mdFreezeRatio = + towerMdFreezeRatio.value === "" ? null : Number(towerMdFreezeRatio.value); + } + if (node.type === "data") { + node.outputType = dataOutputType.value; + updateDataSourceStatus(node); + } + render(); +} + +function deleteSelected() { + if (!state.selectedId) return; + state.nodes = state.nodes.filter((n) => n.id !== state.selectedId); + state.edges = state.edges.filter( + (e) => e.from !== state.selectedId && e.to !== state.selectedId + ); + state.selectedId = null; + selectNode(null); + render(); +} + +function toggleConnectMode() { + state.connectMode = !state.connectMode; + state.connectFrom = null; + connectButton.textContent = state.connectMode ? "Connecting..." : "Connect"; + connectButton.classList.toggle("danger", state.connectMode); +} + +function connectNodes(fromId, toId) { + if (!fromId || !toId || fromId === toId) return; + const exists = state.edges.some((e) => e.from === fromId && e.to === toId); + if (!exists) { + const toNode = state.nodes.find((n) => n.id === toId); + if (toNode && toNode.type === "bridge") { + const incoming = state.edges.filter((e) => e.to === toId); + if (incoming.length >= 2) { + alert("Bridge nodes require exactly two inputs. Remove an edge first."); + return; + } + } + state.edges.push({ from: fromId, to: toId }); + if (toNode && (toNode.type === "image_tower" || toNode.type === "metadata_tower")) { + autoSetTowerType(toNode); + if (state.selectedId === toNode.id) { + selectNode(toNode.id); + } + } + } +} + +function removeSelectedEdges() { + if (!state.selectedId) return; + state.edges = state.edges.filter( + (e) => e.from !== state.selectedId && e.to !== state.selectedId + ); + const node = state.nodes.find((n) => n.id === state.selectedId); + if (node && (node.type === "image_tower" || node.type === "metadata_tower")) { + autoSetTowerType(node); + } + render(); +} + +function autoLayout() { + const rows = {}; + nodeTypeOrder.forEach((type, idx) => { + rows[type] = { y: 760 - idx * 130, nodes: [] }; + }); + state.nodes.forEach((node) => { + if (!rows[node.type]) { + rows[node.type] = { y: 760 - nodeTypeOrder.length * 130, nodes: [] }; + } + rows[node.type].nodes.push(node); + }); + Object.values(rows).forEach((row) => { + row.nodes.forEach((node, index) => { + node.x = 160 + index * 180; + node.y = row.y; + }); + }); +} + +function updateDataBrowseVisibility(outputType) { + dataBrowseButton.disabled = outputType === ""; + modalSelectDir.disabled = outputType !== "image"; + modalSelectFile.disabled = outputType !== "matrix"; +} + +function updateFilterFieldVisibility(type) { + filterRegexFields.classList.toggle("hidden", type !== "regex"); + filterColumnFields.classList.toggle("hidden", type !== "column"); +} + +function updateTransformFieldVisibility(type) { + transformRoiFields.classList.toggle("hidden", type !== "roi_crop"); + transformCenterFields.classList.toggle("hidden", type !== "center_crop"); + transformJitterFields.classList.toggle("hidden", type !== "jitter_bundle"); + transformResizeFields.classList.toggle("hidden", type !== "resize"); +} + +function updateTowerFieldVisibility(type) { + towerImageFields.classList.toggle("hidden", type !== "image"); + towerMdFields.classList.toggle("hidden", type !== "metadata"); +} + +function updateBridgeFieldVisibility(method) { + bridgeFusionFields.classList.toggle("hidden", method !== "fusion"); +} + +function setSelection(nodeIds) { + selectionState.nodeIds = new Set(nodeIds); + selectionState.edgeIndices = new Set(); + state.edges.forEach((edge, idx) => { + if (selectionState.nodeIds.has(edge.from) && selectionState.nodeIds.has(edge.to)) { + selectionState.edgeIndices.add(idx); + } + }); +} + +function autoSetTowerType(towerNode) { + if (!towerNode) return; + const incoming = state.edges.filter((e) => e.to === towerNode.id); + const fromNodes = incoming + .map((edge) => state.nodes.find((n) => n.id === edge.from)) + .filter(Boolean); + const loader = fromNodes.find((n) => n.type === "loader"); + if (!loader) return; + const desired = loader.inputType === "matrix" ? "metadata" : "image"; + if (towerNode.towerType !== desired) { + towerNode.towerType = desired; + towerNode.type = desired === "metadata" ? "metadata_tower" : "image_tower"; + const labelBase = typeMeta[towerNode.type]?.label || "Tower"; + if (!towerNode.label || towerNode.label.startsWith("Tower")) { + const count = state.nodes.filter((n) => n.type === towerNode.type).length; + towerNode.label = `${labelBase} ${count + 1}`; + } else if (towerNode.label.startsWith("Tower")) { + towerNode.label = towerNode.label.replace(/^Tower/, labelBase); + } + } +} + +function resolveUpstreamData(startId) { + const visited = new Set(); + const filters = []; + let currentId = startId; + let dataNode = null; + let ambiguous = false; + while (currentId) { + if (visited.has(currentId)) break; + visited.add(currentId); + const incoming = state.edges.filter((e) => e.to === currentId); + if (!incoming.length) break; + if (incoming.length > 1) ambiguous = true; + const fromId = incoming[0].from; + const fromNode = state.nodes.find((n) => n.id === fromId); + if (!fromNode) break; + if (fromNode.type === "filter") { + filters.push(fromNode); + currentId = fromNode.id; + continue; + } + if (fromNode.type === "data") { + dataNode = fromNode; + break; + } + currentId = fromNode.id; + } + return { dataNode, filters, ambiguous }; +} + +function formatFilterLabel(filter) { + if (filter.filterType === "column") { + const name = filter.columnName || "?"; + const op = filter.columnOperator || "?"; + const value = filter.columnValue || "?"; + return `col:${name} ${op} ${value}`; + } + const pattern = filter.regexPattern || ""; + return `regex:${pattern || "?"}`; +} + +function summarizeFilters(filters) { + if (!filters.length) return ""; + return filters.map(formatFilterLabel).join(", "); +} + +function compileRegex(pattern) { + if (!pattern) return { regex: null, error: null }; + try { + return { regex: new RegExp(pattern), error: null }; + } catch (err) { + return { regex: null, error: err.message || "invalid regex" }; + } +} + +function applyRegexFilters(entries, filters) { + let filtered = entries; + const warnings = []; + filters.forEach((filter) => { + if (filter.filterType !== "regex") return; + const pattern = (filter.regexPattern || "").trim(); + if (!pattern) return; + const { regex, error } = compileRegex(pattern); + if (error || !regex) { + warnings.push(`Invalid regex "${pattern}": ${error || "invalid"}`); + return; + } + filtered = filtered.filter((entry) => regex.test(entry.name)); + }); + return { filtered, warnings }; +} + +function compareCell(cell, rawValue, operator) { + const cellStr = cell == null ? "" : String(cell).trim(); + const valueStr = rawValue == null ? "" : String(rawValue).trim(); + const cellNorm = cellStr.toLowerCase(); + const valueNorm = valueStr.toLowerCase(); + if (operator === "=") return cellNorm === valueNorm; + if (operator === "!=") return cellNorm !== valueNorm; + const cellNum = Number.parseFloat(cellStr); + const valueNum = Number.parseFloat(valueStr); + if (!Number.isFinite(cellNum) || !Number.isFinite(valueNum)) return false; + switch (operator) { + case ">": + return cellNum > valueNum; + case ">=": + return cellNum >= valueNum; + case "<": + return cellNum < valueNum; + case "<=": + return cellNum <= valueNum; + default: + return false; + } +} + +function applyColumnFilters(header, rows, filters) { + let filteredRows = rows; + const warnings = []; + filters.forEach((filter) => { + if (filter.filterType !== "column") return; + const columnName = (filter.columnName || "").trim(); + if (!columnName) { + warnings.push("Column filter missing column name."); + return; + } + let colIndex = header.indexOf(columnName); + if (colIndex === -1) { + const lower = columnName.toLowerCase(); + const matches = header + .map((col, idx) => ({ col, idx })) + .filter((item) => String(item.col).toLowerCase() === lower); + if (matches.length) { + colIndex = matches[0].idx; + if (matches.length > 1) { + warnings.push(`Column "${columnName}" matched multiple headers; using "${matches[0].col}".`); + } + } + } + if (colIndex === -1) { + warnings.push(`Column "${columnName}" not found.`); + return; + } + const rawValue = filter.columnValue; + if (rawValue === "" || rawValue == null) { + warnings.push(`Column filter "${columnName}" missing value.`); + return; + } + const op = filter.columnOperator || "="; + filteredRows = filteredRows.filter((row) => + compareCell(row[colIndex], rawValue, op) + ); + }); + return { filteredRows, warnings }; +} + +function renderPreviewWarnings(warnings) { + if (!warnings.length) return null; + const wrap = document.createElement("div"); + warnings.forEach((msg) => { + const line = document.createElement("div"); + line.className = "hint"; + line.textContent = msg; + wrap.appendChild(line); + }); + return wrap; +} + +function renderPreviewCount(label, shown, total, truncated, note = "") { + const line = document.createElement("div"); + line.className = "hint"; + const truncation = truncated ? " (showing first 200)" : ""; + const suffix = note ? ` ${note}` : ""; + line.textContent = `${label}: ${shown} / ${total}${truncation}${suffix}`; + return line; +} + +function updateDataSourceStatus(node) { + if (!node || !node.source) { + dataSourceStatus.textContent = "No source selected."; + return; + } + if (node.source.mode === "directory") { + const count = node.source.count === null ? "?" : node.source.count; + dataSourceStatus.textContent = `Directory: ${node.source.path} (${count} files)`; + } else if (node.source.mode === "file") { + dataSourceStatus.textContent = `File: ${node.source.path} (${node.source.size} bytes)`; + } else if (node.source.mode === "dataframe") { + dataSourceStatus.textContent = `DataFrame: ${node.source.name} (${node.source.rows}x${node.source.cols})`; + } else { + dataSourceStatus.textContent = "Source loaded."; + } +} + +async function parseJsonResponse(res) { + const text = await res.text(); + try { + return { data: JSON.parse(text), text }; + } catch (err) { + return { data: null, text }; + } +} + +async function fetchDirectory(pathValue = "") { + const res = await fetch(`/api/fs?path=${encodeURIComponent(pathValue)}`); + const { data, text } = await parseJsonResponse(res); + if (!res.ok || !data) { + const detail = data?.error || text || `HTTP ${res.status}`; + throw new Error(detail); + } + return data; +} + +async function fetchFile(pathValue = "", limit = null) { + const limitParam = limit == null ? "" : `&limit=${encodeURIComponent(limit)}`; + const res = await fetch( + `/api/file?path=${encodeURIComponent(pathValue)}${limitParam}` + ); + const { data, text } = await parseJsonResponse(res); + if (!res.ok || !data) { + const detail = data?.error || text || `HTTP ${res.status}`; + throw new Error(detail); + } + return data; +} + +function screenToWorld(clientX, clientY) { + const ctm = svg.getScreenCTM(); + if (!ctm) { + return { x: 0, y: 0 }; + } + const pt = svg.createSVGPoint(); + pt.x = clientX; + pt.y = clientY; + const svgPoint = pt.matrixTransform(ctm.inverse()); + const x = (svgPoint.x - state.pan.x) / state.pan.scale; + const y = (svgPoint.y - state.pan.y) / state.pan.scale; + return { x, y }; +} + +function render() { + nodeCount.textContent = `${state.nodes.length} nodes`; + edgeCount.textContent = `${state.edges.length} edges`; + jsonView.textContent = JSON.stringify( + { + nodes: state.nodes.map(serializeNode), + edges: state.edges, + meta: { + generated_at: new Date().toISOString(), + ...(state.meta || {}), + }, + }, + null, + 2 + ); + + svg.innerHTML = ""; + const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); + g.setAttribute( + "transform", + `translate(${state.pan.x}, ${state.pan.y}) scale(${state.pan.scale})` + ); + + state.edges.forEach((edge, idx) => { + const from = state.nodes.find((n) => n.id === edge.from); + const to = state.nodes.find((n) => n.id === edge.to); + if (!from || !to) return; + const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); + const startY = from.y - 30; + const endY = to.y + 30; + const midY = (startY + endY) / 2; + const d = `M ${from.x} ${startY} C ${from.x} ${midY}, ${to.x} ${midY}, ${to.x} ${endY}`; + path.setAttribute("d", d); + path.setAttribute("fill", "none"); + const isSelected = selectionState.edgeIndices.has(idx); + path.setAttribute("stroke", isSelected ? "#c84630" : "#1c1b1a"); + path.setAttribute("stroke-width", isSelected ? "3" : "2"); + path.setAttribute("opacity", isSelected ? "0.8" : "0.4"); + g.appendChild(path); + }); + + state.nodes.forEach((node) => { + const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); + group.setAttribute("class", "node"); + group.setAttribute("transform", `translate(${node.x}, ${node.y})`); + group.style.cursor = "pointer"; + + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("x", "-60"); + rect.setAttribute("y", "-30"); + rect.setAttribute("width", "120"); + rect.setAttribute("height", "60"); + rect.setAttribute("rx", "14"); + rect.setAttribute("fill", typeMeta[node.type]?.color || "#999"); + rect.setAttribute( + "opacity", + node.id === state.selectedId || selectionState.nodeIds.has(node.id) ? "0.9" : "0.75" + ); + rect.setAttribute( + "stroke", + selectionState.nodeIds.has(node.id) ? "#1c1b1a" : "none" + ); + rect.setAttribute( + "stroke-width", + selectionState.nodeIds.has(node.id) ? "2" : "0" + ); + + const text = document.createElementNS("http://www.w3.org/2000/svg", "text"); + text.setAttribute("text-anchor", "middle"); + text.setAttribute("y", "5"); + text.setAttribute("fill", "#fff"); + text.setAttribute("font-size", "12"); + text.setAttribute("font-family", "Space Grotesk, sans-serif"); + text.textContent = node.label; + + const tag = document.createElementNS("http://www.w3.org/2000/svg", "text"); + tag.setAttribute("text-anchor", "middle"); + tag.setAttribute("y", "24"); + tag.setAttribute("fill", "#fff"); + tag.setAttribute("font-size", "10"); + tag.textContent = node.inputKey ? `in: ${node.inputKey}` : ""; + + group.appendChild(rect); + group.appendChild(text); + group.appendChild(tag); + + group.addEventListener("click", () => { + if (state.draggingJustEnded) { + state.draggingJustEnded = false; + return; + } + if (state.connectMode) { + if (!state.connectFrom) { + state.connectFrom = node.id; + } else { + connectNodes(state.connectFrom, node.id); + state.connectFrom = null; + state.connectMode = false; + connectButton.textContent = "Connect"; + connectButton.classList.remove("danger"); + } + render(); + return; + } + if (state.clickTimer) { + clearTimeout(state.clickTimer); + } + state.clickTimer = setTimeout(() => { + selectNode(node.id); + render(); + state.clickTimer = null; + }, 250); + }); + + group.addEventListener("dblclick", (event) => { + event.preventDefault(); + if (state.clickTimer) { + clearTimeout(state.clickTimer); + state.clickTimer = null; + } + if (node.type === "data") { + openPreview(node); + } else if (node.type === "loader") { + openLoaderPreview(node); + } + }); + + group.addEventListener("mousedown", (event) => { + event.stopPropagation(); + state.draggingNodeId = node.id; + state.draggingMoved = false; + const world = screenToWorld(event.clientX, event.clientY); + state.draggingOffset = { x: world.x - node.x, y: world.y - node.y }; + }); + + g.appendChild(group); + }); + + svg.appendChild(g); + + if (selectionBox) { + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + const minX = Math.min(selectionBox.x1, selectionBox.x2); + const minY = Math.min(selectionBox.y1, selectionBox.y2); + const width = Math.abs(selectionBox.x2 - selectionBox.x1); + const height = Math.abs(selectionBox.y2 - selectionBox.y1); + rect.setAttribute("x", `${minX}`); + rect.setAttribute("y", `${minY}`); + rect.setAttribute("width", `${width}`); + rect.setAttribute("height", `${height}`); + rect.setAttribute("fill", "rgba(200,70,48,0.12)"); + rect.setAttribute("stroke", "#c84630"); + rect.setAttribute("stroke-width", "1"); + g.appendChild(rect); + } +} + +function serializeNode(node) { + if (!node) return node; + const base = { ...node }; + if (node.type !== "bridge") { + delete base.bridgeMethod; + delete base.bridgeFusionDim; + delete base.bridgeUseSe; + delete base.bridgeSeReduction; + delete base.bridgeSePreNorm; + } else if (base.bridgeMethod !== "fusion") { + delete base.bridgeFusionDim; + delete base.bridgeUseSe; + delete base.bridgeSeReduction; + delete base.bridgeSePreNorm; + } + if (node.type !== "filter") { + delete base.filterType; + delete base.regexPattern; + delete base.columnName; + delete base.columnOperator; + delete base.columnValue; + } + if (node.type !== "transform") { + delete base.transformType; + delete base.roiMaskSource; + delete base.roiScale; + delete base.roiTargetSize; + delete base.roiFallback; + delete base.centerCropSize; + delete base.jitterHFlip; + delete base.jitterVFlip; + delete base.jitterRotation; + delete base.jitterColorEnabled; + delete base.jitterColor; + delete base.resizeSize; + } + const isTower = node.type === "image_tower" || node.type === "metadata_tower"; + if (isTower) { + const towerType = node.towerType || (node.type === "metadata_tower" ? "metadata" : "image"); + base.towerType = towerType; + if (towerType === "image") { + delete base.mdHiddenDim; + delete base.mdDropout; + delete base.mdUseSe; + delete base.mdSeReduction; + delete base.mdSePreNorm; + delete base.mdFreezeRatio; + } else { + delete base.imageBackbone; + delete base.imageFreezeRatio; + delete base.imageAugment; + delete base.imageGeometryDim; + delete base.imageUseSe; + delete base.imageSeReduction; + delete base.imageSePreNorm; + } + } + return base; +} + +function resetGraph() { + state.nodes = []; + state.edges = []; + state.selectedId = null; + state.meta = { imports: [] }; + setSelection([]); + render(); +} + +function saveLocal() { + localStorage.setItem("hypertower_v2_builder", JSON.stringify(state)); +} + +function loadLocal() { + const raw = localStorage.getItem("hypertower_v2_builder"); + if (!raw) return; + const loaded = JSON.parse(raw); + state.nodes = loaded.nodes || []; + state.edges = loaded.edges || []; + state.meta = loaded.meta || { imports: [] }; + state.selectedId = null; + setSelection([]); + autoLayout(); + render(); +} + +function exportJson() { + const blob = new Blob([jsonView.textContent], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "hypertower_v2_config.json"; + link.click(); + URL.revokeObjectURL(url); +} + +function importJson(file) { + const reader = new FileReader(); + reader.onload = (event) => { + const data = JSON.parse(event.target.result); + state.nodes = data.nodes || []; + state.edges = data.edges || []; + state.meta = data.meta || { imports: [] }; + state.selectedId = null; + setSelection([]); + autoLayout(); + render(); + }; + reader.readAsText(file); +} + +let browseState = { path: "", entries: [], selected: null }; + +function openModal() { + modalOverlay.classList.remove("hidden"); + browseState = { path: "", entries: [], selected: null }; + loadDirectory(""); +} + +function closeModal() { + modalOverlay.classList.add("hidden"); +} + +function openPreview(node) { + previewOverlay.classList.remove("hidden"); + previewBody.innerHTML = ""; + if (!node.source) { + previewSubtitle.textContent = "No source selected."; + previewBody.innerHTML = "
Select a source first.
"; + return; + } + if (node.outputType === "image" && node.source.mode === "directory") { + previewSubtitle.textContent = `Images in ${node.source.path}`; + loadPreviewDirectory(node.source.path); + return; + } + if (node.outputType === "matrix" && node.source.mode === "file") { + previewSubtitle.textContent = `Matrix preview: ${node.source.path}`; + loadPreviewFile(node.source.path); + return; + } + if (node.outputType === "matrix" && node.source.mode === "dataframe") { + previewSubtitle.textContent = `Matrix preview: ${node.source.name}`; + loadPreviewPapilaDf(); + return; + } + previewSubtitle.textContent = "Unsupported source type."; + previewBody.innerHTML = "
Select a valid source.
"; +} + +function openLoaderPreview(node) { + previewOverlay.classList.remove("hidden"); + previewBody.innerHTML = ""; + const loaderType = node.inputType || node.outputType || ""; + const { dataNode, filters, ambiguous } = resolveUpstreamData(node.id); + const relevantFilters = filters + .filter((filter) => filter.inputType === loaderType) + .reverse(); + const useFullData = relevantFilters.length > 0 ? 0 : null; + const summary = summarizeFilters(relevantFilters); + const ambiguityNote = ambiguous ? " (multiple inputs: first path)" : ""; + previewSubtitle.textContent = `Loader: ${node.label}${ambiguityNote}`; + if (summary) { + previewSubtitle.textContent += ` | Filters: ${summary}`; + } else if (filters.length > 0) { + previewSubtitle.textContent += " | Filters: none applied (check input type)"; + } + if (!dataNode) { + previewBody.innerHTML = "
No upstream data source found.
"; + return; + } + if (!dataNode.source) { + previewBody.innerHTML = "
Upstream data source has no file selected.
"; + return; + } + if (loaderType === "image") { + if (dataNode.source.mode !== "directory") { + previewBody.innerHTML = "
Upstream source is not a directory.
"; + return; + } + loadPreviewDirectory(dataNode.source.path, relevantFilters); + return; + } + if (loaderType === "matrix") { + if (dataNode.source.mode === "file") { + loadPreviewFile(dataNode.source.path, relevantFilters, useFullData); + return; + } + if (dataNode.source.mode === "dataframe") { + loadPreviewPapilaDf(relevantFilters, useFullData); + return; + } + previewBody.innerHTML = "
Upstream source is not a matrix file.
"; + return; + } + previewBody.innerHTML = "
Loader input type is not set.
"; +} + +async function loadPreviewDirectory(pathValue, filters = []) { + previewBody.innerHTML = "
Loading…
"; + try { + const data = await fetchDirectory(pathValue); + const list = document.createElement("div"); + list.className = "preview-list"; + const allImages = data.entries.filter((entry) => + /\.(png|jpg|jpeg|tif|tiff)$/i.test(entry.name) + ); + let files = allImages; + const { filtered, warnings } = applyRegexFilters(allImages, filters); + files = filtered; + files.slice(0, 200).forEach((entry) => { + const item = document.createElement("div"); + item.className = "preview-item"; + item.textContent = entry.name; + list.appendChild(item); + }); + previewBody.innerHTML = ""; + const warningEl = renderPreviewWarnings(warnings); + if (warningEl) previewBody.appendChild(warningEl); + const countLine = renderPreviewCount( + "Files", + files.length, + allImages.length, + files.length > 200 + ); + previewBody.appendChild(countLine); + if (!files.length) { + const empty = document.createElement("div"); + empty.className = "hint"; + empty.textContent = "No image files matched the filter."; + previewBody.appendChild(empty); + return; + } + previewBody.appendChild(list); + } catch (err) { + previewBody.innerHTML = `
${err.message}
`; + } +} + +async function loadPreviewFile(pathValue, filters = [], limit = null) { + previewBody.innerHTML = "
Loading…
"; + try { + const data = await fetchFile(pathValue, limit); + if (!data || !data.header || !data.rows) { + previewBody.innerHTML = "
No table data.
"; + return; + } + const { filteredRows, warnings } = applyColumnFilters(data.header, data.rows, filters); + const totalRows = Number.isFinite(data.rows_total) ? data.rows_total : data.rows.length; + const isSampled = totalRows > data.rows.length; + const previewRows = filteredRows.slice(0, 200); + const table = document.createElement("table"); + table.className = "preview-table"; + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + data.header.forEach((col) => { + const th = document.createElement("th"); + th.textContent = col; + headRow.appendChild(th); + }); + thead.appendChild(headRow); + table.appendChild(thead); + const tbody = document.createElement("tbody"); + previewRows.forEach((row) => { + const tr = document.createElement("tr"); + row.forEach((cell) => { + const td = document.createElement("td"); + td.textContent = cell; + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + previewBody.innerHTML = ""; + const warningEl = renderPreviewWarnings(warnings); + if (warningEl) previewBody.appendChild(warningEl); + const countLine = renderPreviewCount( + "Rows", + filteredRows.length, + totalRows, + filteredRows.length > 200, + isSampled ? "(preview sample)" : "" + ); + previewBody.appendChild(countLine); + if (!filteredRows.length) { + const empty = document.createElement("div"); + empty.className = "hint"; + empty.textContent = "No rows matched the filter."; + previewBody.appendChild(empty); + return; + } + previewBody.appendChild(table); + } catch (err) { + previewBody.innerHTML = `
${err.message}
`; + } +} + +async function loadPreviewPapilaDf(filters = [], limit = null) { + previewBody.innerHTML = "
Loading…
"; + try { + const limitParam = limit == null ? "" : `?limit=${encodeURIComponent(limit)}`; + const res = await fetch(`/api/papila/df${limitParam}`); + const parsed = await parseJsonResponse(res); + const data = parsed.data; + if (!res.ok || !data) { + throw new Error(data?.error || parsed.text || "Failed to load dataframe preview"); + } + if (!data.header || !data.rows) { + previewBody.innerHTML = "
No table data.
"; + return; + } + const { filteredRows, warnings } = applyColumnFilters(data.header, data.rows, filters); + const totalRows = Number.isFinite(data.rows_total) ? data.rows_total : data.rows.length; + const isSampled = totalRows > data.rows.length; + const previewRows = filteredRows.slice(0, 200); + const table = document.createElement("table"); + table.className = "preview-table"; + const thead = document.createElement("thead"); + const headRow = document.createElement("tr"); + data.header.forEach((col) => { + const th = document.createElement("th"); + th.textContent = col; + headRow.appendChild(th); + }); + thead.appendChild(headRow); + table.appendChild(thead); + const tbody = document.createElement("tbody"); + previewRows.forEach((row) => { + const tr = document.createElement("tr"); + row.forEach((cell) => { + const td = document.createElement("td"); + td.textContent = cell; + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + table.appendChild(tbody); + previewBody.innerHTML = ""; + const warningEl = renderPreviewWarnings(warnings); + if (warningEl) previewBody.appendChild(warningEl); + const countLine = renderPreviewCount( + "Rows", + filteredRows.length, + totalRows, + filteredRows.length > 200, + isSampled ? "(preview sample)" : "" + ); + previewBody.appendChild(countLine); + if (!filteredRows.length) { + const empty = document.createElement("div"); + empty.className = "hint"; + empty.textContent = "No rows matched the filter."; + previewBody.appendChild(empty); + return; + } + previewBody.appendChild(table); + } catch (err) { + previewBody.innerHTML = `
${err.message}
`; + } +} + +async function loadDirectory(pathValue) { + modalList.innerHTML = "Loading…"; + try { + const data = await fetchDirectory(pathValue); + browseState.path = data.path || ""; + browseState.entries = data.entries || []; + browseState.selected = null; + renderBreadcrumb(); + renderModalList(); + } catch (err) { + modalList.innerHTML = `
${err.message}
`; + } +} + +function renderBreadcrumb() { + breadcrumb.innerHTML = ""; + const parts = browseState.path.split("/").filter(Boolean); + const root = document.createElement("span"); + root.textContent = "repo root"; + root.addEventListener("click", () => loadDirectory("")); + breadcrumb.appendChild(root); + let acc = ""; + parts.forEach((part) => { + acc = acc ? `${acc}/${part}` : part; + const sep = document.createElement("span"); + sep.textContent = " / "; + breadcrumb.appendChild(sep); + const crumb = document.createElement("span"); + crumb.textContent = part; + crumb.addEventListener("click", () => loadDirectory(acc)); + breadcrumb.appendChild(crumb); + }); +} + +function renderModalList() { + modalList.innerHTML = ""; + browseState.entries.forEach((entry) => { + const item = document.createElement("div"); + item.className = "modal-item"; + item.innerHTML = `${entry.name}${entry.type}`; + item.addEventListener("click", () => { + browseState.selected = entry; + document.querySelectorAll(".modal-item").forEach((el) => el.classList.remove("selected")); + item.classList.add("selected"); + }); + item.addEventListener("dblclick", () => { + if (entry.type === "dir") { + loadDirectory(entry.relPath); + } else { + browseState.selected = entry; + if (dataOutputType.value === "matrix") { + selectFileFromModal(); + } + } + }); + modalList.appendChild(item); + }); +} + +function selectDirectoryFromModal() { + const node = state.nodes.find((n) => n.id === state.selectedId); + if (!node) return; + if (!browseState.path) { + node.source = { mode: "directory", path: ".", count: countImages(browseState.entries) }; + } else { + node.source = { + mode: "directory", + path: browseState.path, + count: countImages(browseState.entries), + }; + } + updateDataSourceStatus(node); + closeModal(); + render(); +} + +function selectFileFromModal() { + const node = state.nodes.find((n) => n.id === state.selectedId); + if (!node || !browseState.selected || browseState.selected.type !== "file") return; + node.source = { + mode: "file", + path: browseState.selected.relPath, + size: browseState.selected.size, + }; + updateDataSourceStatus(node); + closeModal(); + render(); +} + +function countImages(entries) { + return entries.filter((entry) => entry.type === "file" && /\.(png|jpg|jpeg|tif|tiff)$/i.test(entry.name)) + .length; +} + +function duplicateSelectedDataSource() { + const node = state.nodes.find((n) => n.id === state.selectedId); + if (!node || node.type !== "data") return; + node.outputType = dataOutputType.value || node.outputType; + const copy = { + ...node, + id: `node_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, + label: `${node.label} Copy`, + source: node.source ? { ...node.source } : null, + x: (node.x || 140) + 40, + y: (node.y || 120) + 120, + }; + state.nodes.push(copy); + selectNode(copy.id); + render(); +} + +function loadCustomPresets() { + try { + const raw = localStorage.getItem(PRESET_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch (err) { + return {}; + } +} + +function saveCustomPresets(presets) { + localStorage.setItem(PRESET_STORAGE_KEY, JSON.stringify(presets, null, 2)); +} + +function buildBuiltinPresets() { + return { + roi_center_jitter: { + label: "ROI → CenterCrop → Jitter", + nodes: [ + { + id: "roi", + type: "transform", + label: "ROI Crop", + transformType: "roi_crop", + roiMaskSource: "gt", + roiScale: 2.5, + roiTargetSize: 224, + roiFallback: true, + x: 0, + y: 0, + }, + { + id: "center", + type: "transform", + label: "Center Crop", + transformType: "center_crop", + centerCropSize: 224, + x: 0, + y: -120, + }, + { + id: "jitter", + type: "transform", + label: "Jitter Bundle", + transformType: "jitter_bundle", + jitterHFlip: true, + jitterVFlip: true, + jitterRotation: 15, + jitterColorEnabled: true, + jitterColor: "0.1,0.1,0.1,0.05", + x: 0, + y: -240, + }, + ], + edges: [ + { from: "roi", to: "center" }, + { from: "center", to: "jitter" }, + ], + }, + }; +} + +function refreshPresetSelect() { + const custom = loadCustomPresets(); + const builtins = buildBuiltinPresets(); + presetSelect.innerHTML = ""; + const placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "Select…"; + presetSelect.appendChild(placeholder); + Object.entries(builtins).forEach(([key, preset]) => { + const option = document.createElement("option"); + option.value = `builtin:${key}`; + option.textContent = preset.label || key; + presetSelect.appendChild(option); + }); + Object.entries(custom).forEach(([key, preset]) => { + const option = document.createElement("option"); + option.value = `custom:${key}`; + option.textContent = `Custom: ${preset.label || key}`; + presetSelect.appendChild(option); + }); +} + +function buildPresetFromSelection(name) { + const selectedIds = Array.from(selectionState.nodeIds); + if (!selectedIds.length) return null; + const nodes = state.nodes.filter((n) => selectionState.nodeIds.has(n.id)); + const edges = state.edges.filter( + (e) => selectionState.nodeIds.has(e.from) && selectionState.nodeIds.has(e.to) + ); + const xs = nodes.map((n) => n.x ?? 0); + const ys = nodes.map((n) => n.y ?? 0); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + const normalizedNodes = nodes.map((node) => { + const { id, ...rest } = node; + return { + id, + ...rest, + x: (node.x ?? 0) - minX, + y: (node.y ?? 0) - minY, + }; + }); + return { + label: name, + nodes: normalizedNodes, + edges: edges.map((edge) => ({ ...edge })), + }; +} + +function applyPreset(preset) { + if (!preset || !preset.nodes || !preset.nodes.length) return; + const svgRect = svg.getBoundingClientRect(); + const center = screenToWorld( + svgRect.left + svgRect.width / 2, + svgRect.top + svgRect.height / 2 + ); + const xs = preset.nodes.map((n) => n.x ?? 0); + const ys = preset.nodes.map((n) => n.y ?? 0); + const minX = Math.min(...xs); + const minY = Math.min(...ys); + const maxX = Math.max(...xs); + const maxY = Math.max(...ys); + const width = maxX - minX; + const height = maxY - minY; + const offsetX = center.x - width / 2 - minX; + const offsetY = center.y - height / 2 - minY; + + const idMap = new Map(); + const newNodes = preset.nodes.map((node, idx) => { + const base = createNodeTemplate(node.type || "transform"); + const { id, x, y, ...rest } = node; + Object.assign(base, rest); + base.id = newNodeId(); + base.label = node.label || base.label; + base.x = (x ?? idx * 80) + offsetX; + base.y = (y ?? idx * -80) + offsetY; + idMap.set(id ?? `idx_${idx}`, base.id); + return base; + }); + const newEdges = (preset.edges || []) + .map((edge) => ({ + from: idMap.get(edge.from), + to: idMap.get(edge.to), + })) + .filter((edge) => edge.from && edge.to); + state.nodes.push(...newNodes); + state.edges.push(...newEdges); + setSelection(newNodes.map((n) => n.id)); + if (newNodes.length === 1) { + selectNode(newNodes[0].id); + } else { + state.selectedId = null; + clearSelectionUI(); + } + render(); +} + +function setupPanZoom() { + let dragging = false; + let last = { x: 0, y: 0 }; + svg.addEventListener("mousedown", (e) => { + if (e.target.closest(".node")) return; + if (e.shiftKey) { + const world = screenToWorld(e.clientX, e.clientY); + selectionBox = { x1: world.x, y1: world.y, x2: world.x, y2: world.y }; + render(); + return; + } + dragging = true; + last = { x: e.clientX, y: e.clientY }; + }); + window.addEventListener("mousemove", (e) => { + if (selectionBox) { + const world = screenToWorld(e.clientX, e.clientY); + selectionBox.x2 = world.x; + selectionBox.y2 = world.y; + render(); + return; + } + if (state.draggingNodeId) { + const node = state.nodes.find((n) => n.id === state.draggingNodeId); + if (!node || !state.draggingOffset) return; + const world = screenToWorld(e.clientX, e.clientY); + node.x = world.x - state.draggingOffset.x; + node.y = world.y - state.draggingOffset.y; + if (!state.draggingMoved) { + state.draggingMoved = true; + } + render(); + return; + } + if (!dragging) return; + const dx = e.clientX - last.x; + const dy = e.clientY - last.y; + state.pan.x += dx; + state.pan.y += dy; + last = { x: e.clientX, y: e.clientY }; + render(); + }); + window.addEventListener("mouseup", () => { + dragging = false; + if (state.draggingNodeId) { + state.draggingJustEnded = state.draggingMoved; + state.draggingNodeId = null; + state.draggingOffset = null; + state.draggingMoved = false; + } + if (selectionBox) { + const minX = Math.min(selectionBox.x1, selectionBox.x2); + const maxX = Math.max(selectionBox.x1, selectionBox.x2); + const minY = Math.min(selectionBox.y1, selectionBox.y2); + const maxY = Math.max(selectionBox.y1, selectionBox.y2); + const selected = state.nodes + .filter((node) => node.x >= minX && node.x <= maxX && node.y >= minY && node.y <= maxY) + .map((node) => node.id); + setSelection(selected); + if (selected.length === 1) { + selectNode(selected[0]); + } else { + state.selectedId = null; + clearSelectionUI(); + } + selectionBox = null; + render(); + } + }); + svg.addEventListener("wheel", (e) => { + e.preventDefault(); + const delta = e.deltaY < 0 ? 1.05 : 0.95; + state.pan.scale = Math.max(0.5, Math.min(2.0, state.pan.scale * delta)); + render(); + }); +} + +document.querySelectorAll("[data-add]").forEach((btn) => { + btn.addEventListener("click", () => addNode(btn.dataset.add)); +}); + +document.getElementById("apply-node").addEventListener("click", applyNodeEdits); +document.getElementById("delete-node").addEventListener("click", deleteSelected); +document.getElementById("connect-mode").addEventListener("click", toggleConnectMode); +document.getElementById("disconnect-selected").addEventListener("click", removeSelectedEdges); +document.getElementById("auto-layout").addEventListener("click", () => { + autoLayout(); + render(); +}); +document.getElementById("reset-graph").addEventListener("click", resetGraph); +document.getElementById("save-local").addEventListener("click", saveLocal); +document.getElementById("load-local").addEventListener("click", loadLocal); +document.getElementById("export-json").addEventListener("click", exportJson); +document.getElementById("import-json").addEventListener("change", (e) => { + if (e.target.files && e.target.files[0]) { + importJson(e.target.files[0]); + } +}); +importClassBtn.addEventListener("click", importClassDefinition); +presetApplyBtn.addEventListener("click", () => { + const value = presetSelect.value; + if (!value) return; + const [kind, key] = value.split(":"); + if (kind === "builtin") { + const preset = buildBuiltinPresets()[key]; + applyPreset(preset); + return; + } + if (kind === "custom") { + const custom = loadCustomPresets(); + const preset = custom[key]; + applyPreset(preset); + } +}); +presetSaveBtn.addEventListener("click", () => { + if (!selectionState.nodeIds.size) { + alert("Select nodes to save as a preset (Shift + drag on the graph)."); + return; + } + const name = window.prompt("Preset name:"); + if (!name) return; + const preset = buildPresetFromSelection(name); + if (!preset) return; + const custom = loadCustomPresets(); + custom[name] = preset; + saveCustomPresets(custom); + refreshPresetSelect(); + presetSelect.value = `custom:${name}`; +}); + +loaderInputType.addEventListener("change", () => { + loaderOutputType.value = loaderInputType.value; + applyNodeEdits(); +}); + +loaderInputIndex.addEventListener("change", applyNodeEdits); + +filterInputType.addEventListener("change", () => { + filterOutputType.value = filterInputType.value; + applyNodeEdits(); +}); + +filterType.addEventListener("change", () => { + updateFilterFieldVisibility(filterType.value); + applyNodeEdits(); +}); + +filterRegexPattern.addEventListener("change", applyNodeEdits); +filterColumnName.addEventListener("change", applyNodeEdits); +filterColumnOperator.addEventListener("change", applyNodeEdits); +filterColumnValue.addEventListener("change", applyNodeEdits); + +transformType.addEventListener("change", () => { + updateTransformFieldVisibility(transformType.value); + applyNodeEdits(); +}); +transformRoiMaskSource.addEventListener("change", applyNodeEdits); +transformRoiScale.addEventListener("change", applyNodeEdits); +transformRoiTarget.addEventListener("change", applyNodeEdits); +transformRoiFallback.addEventListener("change", applyNodeEdits); +transformCenterSize.addEventListener("change", applyNodeEdits); +transformJitterHFlip.addEventListener("change", applyNodeEdits); +transformJitterVFlip.addEventListener("change", applyNodeEdits); +transformJitterRotation.addEventListener("change", applyNodeEdits); +transformJitterColorEnabled.addEventListener("change", applyNodeEdits); +transformJitterColor.addEventListener("change", applyNodeEdits); +transformResizeSize.addEventListener("change", applyNodeEdits); + +towerType.addEventListener("change", () => { + updateTowerFieldVisibility(towerType.value); + applyNodeEdits(); +}); +towerBackbone.addEventListener("change", applyNodeEdits); +towerFreezeRatio.addEventListener("change", applyNodeEdits); +towerAugment.addEventListener("change", applyNodeEdits); +towerGeometryDim.addEventListener("change", applyNodeEdits); +towerUseSe.addEventListener("change", applyNodeEdits); +towerSeReduction.addEventListener("change", applyNodeEdits); +towerSePreNorm.addEventListener("change", applyNodeEdits); +towerMdHidden.addEventListener("change", applyNodeEdits); +towerMdDropout.addEventListener("change", applyNodeEdits); +towerMdUseSe.addEventListener("change", applyNodeEdits); +towerMdSeReduction.addEventListener("change", applyNodeEdits); +towerMdSePreNorm.addEventListener("change", applyNodeEdits); +towerMdFreezeRatio.addEventListener("change", applyNodeEdits); + +bridgeMethod.addEventListener("change", () => { + updateBridgeFieldVisibility(bridgeMethod.value); + applyNodeEdits(); +}); +bridgeFusionDim.addEventListener("change", applyNodeEdits); +bridgeUseSe.addEventListener("change", applyNodeEdits); +bridgeSeReduction.addEventListener("change", applyNodeEdits); +bridgeSePreNorm.addEventListener("change", applyNodeEdits); + +dataOutputType.addEventListener("change", () => { + const node = state.nodes.find((n) => n.id === state.selectedId); + if (node) { + node.outputType = dataOutputType.value; + node.source = null; + } + updateDataBrowseVisibility(dataOutputType.value); + updateDataSourceStatus(node); + render(); +}); + +dataBrowseButton.addEventListener("click", () => { + if (dataOutputType.value) { + modalMode.textContent = + dataOutputType.value === "image" + ? "Select an image directory." + : "Select a CSV/TSV/TXT file."; + modalSelectDir.disabled = dataOutputType.value !== "image"; + modalSelectFile.disabled = dataOutputType.value !== "matrix"; + openModal(); + } +}); + +duplicateDataButton.addEventListener("click", duplicateSelectedDataSource); + +modalClose.addEventListener("click", closeModal); +modalSelectDir.addEventListener("click", selectDirectoryFromModal); +modalSelectFile.addEventListener("click", selectFileFromModal); + +modalOverlay.addEventListener("click", (e) => { + if (e.target === modalOverlay) { + closeModal(); + } +}); + +previewClose.addEventListener("click", () => { + previewOverlay.classList.add("hidden"); +}); + +previewOverlay.addEventListener("click", (e) => { + if (e.target === previewOverlay) { + previewOverlay.classList.add("hidden"); + } +}); + +toggleJsonButton.addEventListener("click", () => { + jsonView.classList.toggle("collapsed"); + toggleJsonButton.textContent = jsonView.classList.contains("collapsed") + ? "Expand" + : "Collapse"; +}); + +setupPanZoom(); +resetGraph(); +refreshPresetSelect(); diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 0000000..5c9243f --- /dev/null +++ b/webui/index.html @@ -0,0 +1,481 @@ + + + + + + Hypertower V2 Builder + + + +
+
+
+
+

Hypertower V2 Builder

+

+ Draft your towers, bridges, and transforms, then inspect the generated JSON. +

+
+
+ 0 nodes + 0 edges +
+
+ +
+

Nodes

+
+ + + + + + + +
+
+ +
+

Presets

+
+ + +
+
+ + +
+

Use Shift + drag on the graph to select multiple nodes.

+
+ + +
+
+ +
+

Adds data sources for the selected dataset.

+
+ +
+

Selection

+
+ +
None
+
+
+ + +
+ +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + +
+ + +
+
+ +
+

State

+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+

Model Graph

+
Drag to pan. Scroll to zoom.
+
+
+ + +
+
+
+ +
+
+ +
+
+

Configuration JSON

+ +
+

+      
+
+ + + + + + + + diff --git a/webui/server.js b/webui/server.js new file mode 100644 index 0000000..178e91a --- /dev/null +++ b/webui/server.js @@ -0,0 +1,270 @@ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const url = require("url"); +const { spawnSync } = require("child_process"); + +const ROOT = path.resolve(__dirname, ".."); +const WEBROOT = path.resolve(__dirname); + +function sendJson(res, status, payload) { + const body = JSON.stringify(payload, null, 2); + res.writeHead(status, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }); + res.end(body); +} + +function sendFile(res, filePath) { + fs.readFile(filePath, (err, data) => { + if (err) { + res.writeHead(404); + res.end("Not found"); + return; + } + const ext = path.extname(filePath).toLowerCase(); + const mime = + ext === ".html" + ? "text/html" + : ext === ".css" + ? "text/css" + : ext === ".js" + ? "application/javascript" + : "application/octet-stream"; + res.writeHead(200, { "Content-Type": mime }); + res.end(data); + }); +} + +function listDir(requestedPath) { + const cleaned = (requestedPath || "").replace(/^\/+/, ""); + const safePath = path.resolve(ROOT, cleaned || "."); + if (!safePath.startsWith(ROOT)) { + return { error: "Path outside repo root." }; + } + if (!fs.existsSync(safePath)) { + return { error: "Path not found." }; + } + const stat = fs.statSync(safePath); + if (!stat.isDirectory()) { + return { error: "Path is not a directory." }; + } + const entries = fs.readdirSync(safePath, { withFileTypes: true }); + const formatted = entries.map((entry) => { + const entryPath = path.join(safePath, entry.name); + const relPath = path.relative(ROOT, entryPath); + const isDir = entry.isDirectory(); + const size = isDir ? 0 : fs.statSync(entryPath).size; + return { + name: entry.name, + type: isDir ? "dir" : "file", + relPath: relPath.replace(/\\/g, "/"), + size, + }; + }); + formatted.sort((a, b) => { + if (a.type !== b.type) return a.type === "dir" ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return { path: path.relative(ROOT, safePath).replace(/\\/g, "/"), entries: formatted }; +} + +function readTabularFile(requestedPath, limit = 50) { + const cleaned = (requestedPath || "").replace(/^\/+/, ""); + const safePath = path.resolve(ROOT, cleaned || "."); + if (!safePath.startsWith(ROOT)) { + return { error: "Path outside repo root." }; + } + if (!fs.existsSync(safePath)) { + return { error: "Path not found." }; + } + const stat = fs.statSync(safePath); + if (!stat.isFile()) { + return { error: "Path is not a file." }; + } + const ext = path.extname(safePath).toLowerCase(); + if ([".xlsx", ".xls"].includes(ext)) { + const script = ` +import json +import pandas as pd +import sys + +path = sys.argv[1] +limit = int(sys.argv[2]) if len(sys.argv) > 2 else 0 +df = pd.read_excel(path) +df = df.fillna("") +header = [str(c) for c in df.columns.tolist()] +total_rows = int(df.shape[0]) +if limit > 0: + df = df.head(limit) +rows = df.astype(str).values.tolist() +print(json.dumps({"header": header, "rows": rows, "rows_total": total_rows})) +`; + const proc = spawnSync("python3", ["-c", script, safePath, String(limit || 0)], { + encoding: "utf8", + }); + if (proc.status !== 0) { + const err = proc.stderr || proc.stdout || "python3 failed to read excel"; + return { error: err.trim() }; + } + try { + const parsed = JSON.parse(proc.stdout); + return { + path: path.relative(ROOT, safePath).replace(/\\/g, "/"), + delimiter: "excel", + header: parsed.header || [], + rows: parsed.rows || [], + rows_total: parsed.rows_total, + }; + } catch (err) { + return { error: "Failed to parse Excel preview output." }; + } + } + if (![".csv", ".tsv", ".txt"].includes(ext)) { + return { error: "Only .csv, .tsv, .txt, .xlsx, or .xls are supported for preview." }; + } + const content = fs.readFileSync(safePath, "utf8"); + const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0); + if (!lines.length) { + return { error: "File is empty." }; + } + const delimiter = ext === ".tsv" ? "\t" : ","; + const header = lines[0].split(delimiter).map((v) => v.trim()); + const totalRows = lines.length - 1; + const rowLimit = limit && limit > 0 ? limit : totalRows; + const rows = lines + .slice(1, rowLimit + 1) + .map((line) => line.split(delimiter).map((v) => v.trim())); + return { + path: path.relative(ROOT, safePath).replace(/\\/g, "/"), + delimiter, + header, + rows, + rows_total: totalRows, + }; +} + +function runPython(script, args = []) { + const proc = spawnSync("python3", ["-c", script, ...args], { + cwd: ROOT, + encoding: "utf8", + env: { ...process.env, PYTHONWARNINGS: "ignore" }, + }); + if (proc.status !== 0) { + const err = (proc.stderr || proc.stdout || "python3 failed").trim(); + return { error: err || "python3 failed" }; + } + try { + return JSON.parse(proc.stdout); + } catch (err) { + return { error: "Failed to parse python output." }; + } +} + +function importPapila() { + const script = ` +import json +from classes.v2 import PapilaData + +pd = PapilaData.from_dirs( + image_dir="Papila/FundusImages", + clinical_dir="Papila/ClinicalData", + label_col="Diagnosis", + cat_cols=["Gender", "Phakic/Pseudophakic"], +) +out = { + "image_dir": str(pd.clinical.image_dir), + "clinical_dir": str(pd.clinical.clinical_dir) if pd.clinical.clinical_dir else None, + "label_col": pd.label_col, + "df_rows": int(pd.df.shape[0]), + "df_cols": int(pd.df.shape[1]), + "df_columns": list(pd.df.columns), +} +print(json.dumps(out)) +`; + return runPython(script); +} + +function papilaDfPreview(limit = 50) { + const script = ` +import json +from classes.v2 import PapilaData + +pd = PapilaData.from_dirs( + image_dir="Papila/FundusImages", + clinical_dir="Papila/ClinicalData", + label_col="Diagnosis", + cat_cols=["Gender", "Phakic/Pseudophakic"], +) +df = pd.df.fillna("") +header = [str(c) for c in df.columns.tolist()] +limit = int(${Number(limit)}) if ${Number(limit)} else 0 +if limit > 0: + df = df.head(limit) +rows = df.astype(str).values.tolist() +print(json.dumps({"header": header, "rows": rows, "rows_total": int(pd.df.shape[0])})) +`; + return runPython(script); +} + +const server = http.createServer((req, res) => { + const parsed = url.parse(req.url, true); + if (parsed.pathname === "/api/fs" || parsed.pathname === "/api/fs/") { + const requestedPath = parsed.query.path || ""; + const payload = listDir(requestedPath); + if (payload.error) { + sendJson(res, 400, payload); + return; + } + sendJson(res, 200, payload); + return; + } + if (parsed.pathname === "/api/import/papila" || parsed.pathname === "/api/import/papila/") { + const payload = importPapila(); + if (payload.error) { + sendJson(res, 400, payload); + return; + } + sendJson(res, 200, payload); + return; + } + if (parsed.pathname === "/api/papila/df" || parsed.pathname === "/api/papila/df/") { + const rawLimit = parsed.query.limit; + const limit = rawLimit ? Number.parseInt(rawLimit, 10) : 50; + const payload = papilaDfPreview(Number.isFinite(limit) ? limit : 50); + if (payload.error) { + sendJson(res, 400, payload); + return; + } + sendJson(res, 200, payload); + return; + } + if (parsed.pathname === "/api/file" || parsed.pathname === "/api/file/") { + const requestedPath = parsed.query.path || ""; + const rawLimit = parsed.query.limit; + const limit = rawLimit ? Number.parseInt(rawLimit, 10) : 50; + const payload = readTabularFile(requestedPath, Number.isFinite(limit) ? limit : 50); + if (payload.error) { + sendJson(res, 400, payload); + return; + } + sendJson(res, 200, payload); + return; + } + + let filePath = parsed.pathname === "/" ? "index.html" : parsed.pathname.slice(1); + filePath = path.join(WEBROOT, filePath); + if (!filePath.startsWith(WEBROOT)) { + res.writeHead(403); + res.end("Forbidden"); + return; + } + sendFile(res, filePath); +}); + +const port = process.env.PORT || 5173; +server.listen(port, () => { + console.log(`Hypertower UI running at http://localhost:${port}`); + console.log(`Repo root: ${ROOT}`); +}); diff --git a/webui/styles.css b/webui/styles.css new file mode 100644 index 0000000..0fcbb71 --- /dev/null +++ b/webui/styles.css @@ -0,0 +1,363 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap"); + +:root { + --bg: #f3efe9; + --panel: #fff9f2; + --panel-border: #e0d7cc; + --ink: #1c1b1a; + --muted: #5b5a57; + --accent: #c84630; + --accent-2: #2f5d62; + --accent-3: #f6c453; + --shadow: 0 18px 40px rgba(28, 27, 26, 0.12); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "Space Grotesk", "Segoe UI", sans-serif; + color: var(--ink); + background: radial-gradient(circle at top left, #f9f2e7 0%, #f0e8dd 45%, #e8e0d5 100%); +} + +.app { + min-height: 100vh; + display: grid; + grid-template-columns: 38% 62%; + grid-template-rows: 60% 40%; + gap: 18px; + padding: 20px; +} + +.pane { + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: 18px; + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; +} + +.pane-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 18px 20px; + border-bottom: 1px solid var(--panel-border); + background: linear-gradient(120deg, #fef7ec, #f7efe3); + gap: 12px; +} + +.pane-header h1, +.pane-header h2 { + margin: 0; + font-size: 1.1rem; +} + +.graph-controls { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; +} + +.pane.left { + grid-column: 1; + grid-row: 1; + overflow-y: auto; +} + +.pane.right { + grid-column: 2; + grid-row: 1; +} + +.pane.bottom { + grid-column: 1 / span 2; + grid-row: 2; +} + +.subtitle { + margin: 4px 0 0; + font-size: 0.9rem; + color: var(--muted); +} + +.status { + display: flex; + gap: 10px; + font-size: 0.85rem; + color: var(--muted); +} + +.panel { + padding: 16px 20px; + border-bottom: 1px solid var(--panel-border); +} + +.panel h2 { + margin: 0 0 12px; + font-size: 0.95rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} + +.button-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.button-row { + display: flex; + gap: 10px; + margin-top: 12px; + flex-wrap: wrap; +} + +button, +.file-upload { + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--panel-border); + background: #fff; + font-weight: 600; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +button:hover, +.file-upload:hover { + transform: translateY(-1px); + box-shadow: 0 6px 14px rgba(28, 27, 26, 0.12); +} + +button.danger { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +button.ghost, +.file-upload { + background: transparent; +} + +.field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 10px; +} + +.field label { + font-size: 0.8rem; + color: var(--muted); +} + +input { + border-radius: 10px; + border: 1px solid var(--panel-border); + padding: 10px 12px; + font-family: inherit; + background: #fffefb; +} + +select { + border-radius: 10px; + border: 1px solid var(--panel-border); + padding: 10px 12px; + font-family: inherit; + background: #fffefb; +} + +.pill { + padding: 6px 10px; + border-radius: 999px; + background: #fff; + border: 1px solid var(--panel-border); + display: inline-flex; + align-items: center; + font-size: 0.85rem; +} + +.muted { + color: var(--muted); +} + +.hint { + font-size: 0.85rem; + color: var(--muted); +} + +#graph-container { + flex: 1; + position: relative; + overflow: hidden; +} + +#graph { + width: 100%; + height: 100%; + background: radial-gradient(circle at 20% 20%, rgba(200, 70, 48, 0.08), transparent 55%), + radial-gradient(circle at 80% 30%, rgba(47, 93, 98, 0.1), transparent 60%); +} + +.json-view { + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + font-size: 0.82rem; + margin: 0; + padding: 16px 20px; + overflow: auto; + white-space: pre; + flex: 1; + background: #11110f; + color: #f3e9dc; +} + +.json-view.collapsed { + display: none; +} + +.file-upload input { + display: none; +} + +.hidden { + display: none !important; +} + +.selection-block { + padding: 10px 0 0; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(15, 14, 13, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 40; +} + +.modal { + width: min(820px, 90vw); + background: #fffdf7; + border-radius: 18px; + border: 1px solid var(--panel-border); + box-shadow: var(--shadow); + display: flex; + flex-direction: column; + max-height: 80vh; +} + +.modal-header, +.modal-footer { + padding: 14px 18px; + border-bottom: 1px solid var(--panel-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-footer { + border-top: 1px solid var(--panel-border); + border-bottom: none; + gap: 10px; +} + +.modal-body { + padding: 12px 18px 18px; + overflow: auto; +} + +.modal-list { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 12px; +} + +.modal-item { + display: flex; + justify-content: space-between; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--panel-border); + cursor: pointer; + background: #fff; +} + +.modal-item.selected { + border-color: var(--accent); + box-shadow: 0 6px 14px rgba(200, 70, 48, 0.18); +} + +.breadcrumb { + display: flex; + flex-wrap: wrap; + gap: 6px; + font-size: 0.85rem; + color: var(--muted); +} + +.breadcrumb span { + cursor: pointer; +} + +.preview-body { + display: flex; + flex-direction: column; + gap: 10px; +} + +.preview-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.preview-item { + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--panel-border); + background: #fff; + font-size: 0.9rem; +} + +.preview-table { + width: 100%; + border-collapse: collapse; + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + font-size: 0.82rem; +} + +.preview-table th, +.preview-table td { + border: 1px solid var(--panel-border); + padding: 6px 8px; + text-align: left; +} + +.preview-table th { + background: #f1e7dc; +} + +@media (max-width: 1100px) { + .app { + grid-template-columns: 1fr; + grid-template-rows: auto auto auto; + } + .pane.left, + .pane.right, + .pane.bottom { + grid-column: 1; + } +}