From 236a88764eb7920e21431d208042d3f838e314d0 Mon Sep 17 00:00:00 2001 From: Sathiesh Date: Fri, 3 Jul 2026 15:52:41 +0200 Subject: [PATCH 1/8] feat: patch soft-vote ensembling, surface metrics, fold-learner discovery; bump 0.9.2 - vision_patch: PatchInferenceEngine/patch_inference accept a list of models and soft-vote per-patch probabilities in one sliding-window pass; AMP forward pass uses bfloat16; keep_largest_component skipped for multi-class; empty model-list guarded - vision_metrics: calculate_all_metrics and evaluate_segmentations per-case panel with spacing-aware ASSD/HD95/NSD read from a single ground-truth LabelMap - utils: find_fold_learners discovers per-fold best_learner.pkl from an MLflow experiment; log_metrics_table strips non-finite values before logging - vision_loss: DynUNetDSAdapter adapts DynUNet deep-supervision output to the list DeepSupervisionLoss expects - tutorials 12b/12c updated for cross-validation and soft-vote ensemble inference - bump version 0.9.1 -> 0.9.2 --- fastMONAI/__init__.py | 2 +- fastMONAI/_modidx.py | 11 + fastMONAI/utils.py | 91 +++- fastMONAI/vision_loss.py | 30 +- fastMONAI/vision_metrics.py | 96 +++- fastMONAI/vision_patch.py | 91 ++-- nbs/04_vision_loss_functions.ipynb | 22 + nbs/05_vision_metrics.ipynb | 42 +- nbs/07_utils.ipynb | 422 +++++++++++++++++- nbs/10_vision_patch.ipynb | 168 +++++-- nbs/12b_tutorial_patch_cross_validation.ipynb | 35 +- nbs/12c_tutorial_patch_inference.ipynb | 112 ++++- settings.ini | 2 +- 13 files changed, 999 insertions(+), 125 deletions(-) diff --git a/fastMONAI/__init__.py b/fastMONAI/__init__.py index d69d16e..a2fecb4 100644 --- a/fastMONAI/__init__.py +++ b/fastMONAI/__init__.py @@ -1 +1 @@ -__version__ = "0.9.1" +__version__ = "0.9.2" diff --git a/fastMONAI/_modidx.py b/fastMONAI/_modidx.py index b0d0069..9b5b9c8 100644 --- a/fastMONAI/_modidx.py +++ b/fastMONAI/_modidx.py @@ -125,6 +125,7 @@ 'fastMONAI/utils.py'), 'fastMONAI.utils._to_jsonable': ('utils.html#_to_jsonable', 'fastMONAI/utils.py'), 'fastMONAI.utils.create_mlflow_callback': ('utils.html#create_mlflow_callback', 'fastMONAI/utils.py'), + 'fastMONAI.utils.find_fold_learners': ('utils.html#find_fold_learners', 'fastMONAI/utils.py'), 'fastMONAI.utils.load_patch_variables': ('utils.html#load_patch_variables', 'fastMONAI/utils.py'), 'fastMONAI.utils.load_variables': ('utils.html#load_variables', 'fastMONAI/utils.py'), 'fastMONAI.utils.print_colab_gpu_info': ('utils.html#print_colab_gpu_info', 'fastMONAI/utils.py'), @@ -418,6 +419,12 @@ 'fastMONAI/vision_loss.py'), 'fastMONAI.vision_loss.CustomLoss.decodes': ( 'vision_loss_functions.html#customloss.decodes', 'fastMONAI/vision_loss.py'), + 'fastMONAI.vision_loss.DynUNetDSAdapter': ( 'vision_loss_functions.html#dynunetdsadapter', + 'fastMONAI/vision_loss.py'), + 'fastMONAI.vision_loss.DynUNetDSAdapter.__init__': ( 'vision_loss_functions.html#dynunetdsadapter.__init__', + 'fastMONAI/vision_loss.py'), + 'fastMONAI.vision_loss.DynUNetDSAdapter.forward': ( 'vision_loss_functions.html#dynunetdsadapter.forward', + 'fastMONAI/vision_loss.py'), 'fastMONAI.vision_loss.TverskyFocalLoss': ( 'vision_loss_functions.html#tverskyfocalloss', 'fastMONAI/vision_loss.py'), 'fastMONAI.vision_loss.TverskyFocalLoss.__init__': ( 'vision_loss_functions.html#tverskyfocalloss.__init__', @@ -464,6 +471,8 @@ 'fastMONAI/vision_metrics.py'), 'fastMONAI.vision_metrics.binary_signed_rve': ( 'vision_metrics.html#binary_signed_rve', 'fastMONAI/vision_metrics.py'), + 'fastMONAI.vision_metrics.calculate_all_metrics': ( 'vision_metrics.html#calculate_all_metrics', + 'fastMONAI/vision_metrics.py'), 'fastMONAI.vision_metrics.calculate_confusion_metrics': ( 'vision_metrics.html#calculate_confusion_metrics', 'fastMONAI/vision_metrics.py'), 'fastMONAI.vision_metrics.calculate_dsc': ( 'vision_metrics.html#calculate_dsc', @@ -478,6 +487,8 @@ 'fastMONAI/vision_metrics.py'), 'fastMONAI.vision_metrics.calculate_surface_metrics': ( 'vision_metrics.html#calculate_surface_metrics', 'fastMONAI/vision_metrics.py'), + 'fastMONAI.vision_metrics.evaluate_segmentations': ( 'vision_metrics.html#evaluate_segmentations', + 'fastMONAI/vision_metrics.py'), 'fastMONAI.vision_metrics.multi_dice_score': ( 'vision_metrics.html#multi_dice_score', 'fastMONAI/vision_metrics.py'), 'fastMONAI.vision_metrics.multi_lesion_detection_rate': ( 'vision_metrics.html#multi_lesion_detection_rate', diff --git a/fastMONAI/utils.py b/fastMONAI/utils.py index 12932d7..4b51862 100644 --- a/fastMONAI/utils.py +++ b/fastMONAI/utils.py @@ -2,7 +2,8 @@ # %% auto #0 __all__ = ['set_mlflow_tracking_uri', 'store_variables', 'load_variables', 'store_patch_variables', 'load_patch_variables', - 'print_colab_gpu_info', 'ModelTrackingCallback', 'create_mlflow_callback', 'MLflowUIManager'] + 'print_colab_gpu_info', 'ModelTrackingCallback', 'create_mlflow_callback', 'find_fold_learners', + 'MLflowUIManager'] # %% ../nbs/07_utils.ipynb #ac941446-cf7e-4f5c-ace0-a36fb078022f import pickle @@ -731,10 +732,13 @@ def log_metrics_table(self, df, metrics=None, key='validation_metrics', display= summary_data = [] for metric in metrics: - mean = df[metric].mean() - std = df[metric].std() + # Drop +/-inf (e.g. surface distances on one_empty cases) so they don't poison + # the summary; pandas already skips NaN. Show 'N/A' if nothing finite remains. + col = df[metric].replace([float('inf'), float('-inf')], float('nan')) + mean, std = col.mean(), col.std() + mean_str = f'{mean:.4f}' if not math.isnan(mean) else 'N/A' std_str = f'{std:.4f}' if not math.isnan(std) else 'N/A' - summary_data.append([metric, f'{mean:.4f}', std_str]) + summary_data.append([metric, mean_str, std_str]) fig, ax = plt.subplots(figsize=(5, len(metrics) * 0.8 + 0.5)) ax.axis('off') @@ -881,6 +885,85 @@ def create_mlflow_callback( log_split=log_split, ) +# %% ../nbs/07_utils.ipynb #6e7ed65b +def find_fold_learners(experiment_name, artifact_path="model/best_learner.pkl", + n_folds=None, fold_tag="fold"): + """Discover one trained learner checkpoint per cross-validation fold from an MLflow experiment. + + Reads back what ``ModelTrackingCallback`` logs per run: takes the most recent run for each + fold in ``experiment_name`` and downloads ``artifact_path`` from it, returning + ``{fold: local_path}`` sorted by fold. Folds are identified by each run's ``tags.``, + falling back to a ``_`` run name. Warns if the selected folds span more than one + ``dataset_version`` tag (a sign of a partial retrain). + + Pair it with the soft-vote ensembling in ``patch_inference`` -- but the returned values are + PATHS, so load them first:: + + from fastai.learner import load_learner + paths = find_fold_learners("vs5f_unet") + learners = [load_learner(p) for p in paths.values()] + preds = patch_inference(learner=learners, config=config, file_paths=cases) + + Args: + experiment_name: MLflow experiment to search (e.g. ``"vs5f_unet"``). + artifact_path: run-relative artifact to download per fold (default + ``"model/best_learner.pkl"``, what ``ModelTrackingCallback`` logs). + n_folds: optional expected folds for a missing-fold warning -- an int ``n`` (labels + ``1..n``) or an explicit iterable of labels (use this for 0-indexed folds). ``None`` + makes no assumption and just returns what was found. + fold_tag: run tag naming the fold; also the ``_`` run-name prefix fallback. + + Returns: + ``{fold_int: local_path}`` sorted by fold, or ``{}`` if the experiment is missing or + MLflow is unavailable. + """ + from mlflow.exceptions import MlflowException + try: + _ensure_fastmonai_tracking_uri() # respect a user-configured tracking server + exp = mlflow.get_experiment_by_name(experiment_name) + if exp is None: + print(f"No MLflow experiment {experiment_name!r}. Nothing to load.") + return {} + runs = mlflow.search_runs(experiment_ids=[exp.experiment_id], + order_by=["attributes.start_time DESC"]) + except (ImportError, MlflowException) as e: + print(f"MLflow lookup for {experiment_name!r} skipped: {e}") + return {} + + out, fold_dsv = {}, {} + prefix = f"{fold_tag}_" + for _, row in runs.iterrows(): + fold = row.get(f"tags.{fold_tag}") + # pandas yields NaN (a float, != itself), not None, when the column exists but this + # row lacks the tag -- fall back to the run name instead of dropping the run. + if fold is None or (isinstance(fold, float) and fold != fold): + name = row.get("tags.mlflow.runName") or "" + fold = name[len(prefix):] if name.startswith(prefix) else None + try: + fold = int(fold) + except (TypeError, ValueError): + continue + if fold in out: + continue # newest run already kept for this fold (runs are start_time DESC) + try: + out[fold] = mlflow.artifacts.download_artifacts( + run_id=row["run_id"], artifact_path=artifact_path) + except Exception: + continue # fall through to the next-newest run for this fold + fold_dsv[fold] = row.get("tags.dataset_version") + + print(f"Found fold learners for {sorted(out)} in {experiment_name!r}.") + if n_folds is not None: + expected = range(1, n_folds + 1) if isinstance(n_folds, int) else n_folds + missing = [f for f in expected if f not in out] + if missing: + print(f"WARNING: no checkpoint for folds {missing}; ensemble will use {len(out)} model(s).") + distinct_dsv = {v for v in fold_dsv.values() if isinstance(v, str) and v} + if len(distinct_dsv) > 1: + shown = {f: v[:8] for f, v in sorted(fold_dsv.items()) if isinstance(v, str) and v} + print(f"WARNING: selected folds span multiple dataset_versions {shown} - possible partial retrain.") + return dict(sorted(out.items())) + # %% ../nbs/07_utils.ipynb #a3d2d585 import subprocess import threading diff --git a/fastMONAI/vision_loss.py b/fastMONAI/vision_loss.py index d90f707..0d26721 100644 --- a/fastMONAI/vision_loss.py +++ b/fastMONAI/vision_loss.py @@ -1,7 +1,7 @@ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/04_vision_loss_functions.ipynb. # %% auto #0 -__all__ = ['CustomLoss', 'TverskyFocalLoss'] +__all__ = ['CustomLoss', 'TverskyFocalLoss', 'DynUNetDSAdapter'] # %% ../nbs/04_vision_loss_functions.ipynb #e8eee6b3-75c3-4468-8645-8f6a42a76bfe import torch @@ -105,3 +105,31 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor: total_loss: torch.Tensor = tversky_loss ** self.gamma return total_loss + +# %% ../nbs/04_vision_loss_functions.ipynb #acf08940 +class DynUNetDSAdapter(torch.nn.Module): + """Wrap a MONAI ``DynUNet`` with deep supervision so its output matches what + ``monai.losses.DeepSupervisionLoss`` (used via ``CustomLoss``) expects. + + A ``DynUNet(deep_supervision=True)`` returns a *stacked* ``[B, N, C, ...]`` tensor in + training (a TorchScript workaround), but ``DeepSupervisionLoss`` only unbinds a *list* of + tensors -- a stacked tensor would fall through to the base loss and be scored incorrectly. + This adapter unbinds the stack into that list in training, and passes the plain prediction + through in eval mode (MONAI's ``DynUNet`` docstring prescribes exactly this ``torch.unbind``). + + Note: wrapping registers the network as ``self.model``, so it adds a ``model.`` prefix to + state-dict keys -- relevant when loading raw ``.pth`` checkpoints of a wrapped model. + """ + + def __init__(self, model): + super().__init__() + self.model = model + + def forward(self, x): + out = self.model(x) + # Unbind only in training, and only when the wrapped net actually stacks its + # deep-supervision heads. Gating on `deep_supervision` (not output rank) keeps this + # correct for 2D and 3D alike, and fails loudly if wrapped around a non-DynUNet in training. + if self.model.training and self.model.deep_supervision: + return list(torch.unbind(out, dim=1)) + return out diff --git a/fastMONAI/vision_metrics.py b/fastMONAI/vision_metrics.py index ea3aa2e..5e55645 100644 --- a/fastMONAI/vision_metrics.py +++ b/fastMONAI/vision_metrics.py @@ -5,14 +5,17 @@ 'calculate_confusion_metrics', 'binary_sensitivity', 'multi_sensitivity', 'binary_precision', 'multi_precision', 'calculate_lesion_detection_rate', 'binary_lesion_detection_rate', 'multi_lesion_detection_rate', 'calculate_signed_rve', 'binary_signed_rve', 'multi_signed_rve', - 'calculate_surface_metrics', 'AccumulatedDice', 'AccumulatedMultiDice', 'EMACheckpoint'] + 'calculate_surface_metrics', 'calculate_all_metrics', 'evaluate_segmentations', 'AccumulatedDice', + 'AccumulatedMultiDice', 'EMACheckpoint'] # %% ../nbs/05_vision_metrics.ipynb #8b6a83ac import warnings import torch import numpy as np +import pandas as pd from monai.metrics import compute_hausdorff_distance, compute_dice, get_confusion_matrix, compute_confusion_matrix_metric, compute_average_surface_distance, compute_surface_dice from scipy.ndimage import label as scipy_label +from pathlib import Path from .vision_data import pred_to_binary_mask, batch_pred_to_multiclass_mask from fastai.learner import Metric from fastai.callback.tracker import TrackerCallback @@ -506,6 +509,97 @@ def calculate_surface_metrics(pred, targ, spacing_mm, **nsd, "status": "ok", **base} +# %% ../nbs/05_vision_metrics.ipynb #ed336bbf +def calculate_all_metrics(pred, targ, spacing_mm, *, nsd_tolerances_mm=(0.5, 1.0, 2.0), + binary: bool = True) -> dict: + """Full per-case metric panel for one segmentation vs its ground truth. + + Orchestrates the individual ``calculate_*`` functions into one dict: Dice, sensitivity, + precision, lesion detection rate, signed RVE, and the spacing-aware surface metrics + (ASSD / HD95 / NSD in mm). For a whole dataset, use ``evaluate_segmentations``. + + Args: + pred: predicted mask (binary / argmax label). torch.Tensor or np.ndarray, shaped + [W,H,D], [C,W,H,D] or [B,C,W,H,D] with singleton batch/channel dims. + targ: ground-truth mask, same shape convention, on the SAME voxel grid as ``pred``. + spacing_mm: voxel spacing in mm in array-axis (i,j,k) order matching ``targ``. Pass it + explicitly (read from the GT file); a permuted spacing silently corrupts the + surface distances. + nsd_tolerances_mm: NSD tolerance(s) in mm, forwarded to ``calculate_surface_metrics``. + binary: single-foreground-class segmentation (the only mode supported). The confusion + metrics run on the single label channel -- MONAI keeps a size-1 channel when + ``include_background=False``, so a single-channel input is scored correctly. + + Returns: + dict: ``dsc``, ``sensitivity``, ``precision``, ``ldr``, ``rve``, the ``nsd_tau*_mm`` + values, ``assd_mm``, ``hd95_mm``, ``surface_status`` and ``spacing_mm``. + """ + if not binary: + raise NotImplementedError("calculate_all_metrics supports binary (single foreground " + "class) segmentation only.") + + pred_t = pred if isinstance(pred, torch.Tensor) else torch.as_tensor(np.asarray(pred)) + targ_t = targ if isinstance(targ, torch.Tensor) else torch.as_tensor(np.asarray(targ)) + while pred_t.ndim < 5: pred_t = pred_t.unsqueeze(0) # -> [B,C,W,H,D] + while targ_t.ndim < 5: targ_t = targ_t.unsqueeze(0) + if pred_t.shape[-3:] != targ_t.shape[-3:]: + raise ValueError("pred and targ must share a voxel grid; got spatial shapes " + f"{tuple(pred_t.shape[-3:])} vs {tuple(targ_t.shape[-3:])}.") + pred_t, targ_t = pred_t.float(), targ_t.float() + + sm = calculate_surface_metrics(pred_t, targ_t, spacing_mm, nsd_tolerances_mm=nsd_tolerances_mm) + row = { + "dsc": calculate_dsc(pred_t, targ_t).nanmean().item(), + "sensitivity": calculate_confusion_metrics(pred_t, targ_t, "sensitivity").nanmean().item(), + "precision": calculate_confusion_metrics(pred_t, targ_t, "precision").nanmean().item(), + "ldr": calculate_lesion_detection_rate(pred_t, targ_t).nanmean().item(), + "rve": calculate_signed_rve(pred_t, targ_t).nanmean().item(), + } + row.update({k: sm[k] for k in sm if k.startswith("nsd_tau")}) + row["assd_mm"], row["hd95_mm"] = sm["assd_mm"], sm["hd95_mm"] + row["surface_status"], row["spacing_mm"] = sm["status"], sm["spacing_mm"] + return row + +# %% ../nbs/05_vision_metrics.ipynb #2e526be3 +def evaluate_segmentations(preds, gt_paths, case_ids=None, *, + nsd_tolerances_mm=(0.5, 1.0, 2.0), binary: bool = True): + """Score a batch of predictions against ground-truth masks into a per-case DataFrame. + + For each (prediction, ground-truth) pair, the GT mask AND its voxel spacing are read from a + SINGLE object, so the array and its spacing can never disagree -- reading spacing from a + separately-loaded file is a silent source of axis-permutation errors that corrupt the surface + metrics. Predictions may be tensors (as returned by ``patch_inference``) or paths to saved + masks, so predictions written with ``patch_inference(save_dir=...)`` can be re-scored without + re-running inference. + + Args: + preds: sequence of predicted masks -- torch.Tensor / np.ndarray, or paths to mask files. + gt_paths: sequence of ground-truth mask file paths (one per prediction, same voxel grid). + case_ids: optional row labels; default is each GT file's name stem. + nsd_tolerances_mm: NSD tolerance(s) in mm, forwarded to the surface metrics. + binary: binary (single foreground class) segmentation only. + + Returns: + pandas.DataFrame with one row per case: ``case_id`` plus the columns from + ``calculate_all_metrics``. + """ + import torchio as tio # lazy: only this benchmark helper needs torchio I/O + preds, gt_paths = list(preds), list(gt_paths) + if len(preds) != len(gt_paths): + raise ValueError(f"preds and gt_paths must have equal length; got {len(preds)} and " + f"{len(gt_paths)}.") + + rows = [] + for i, (pred, gt_path) in enumerate(zip(preds, gt_paths)): + gt = tio.LabelMap(gt_path) # single source: .data and .spacing agree + pred_data = tio.LabelMap(pred).data if isinstance(pred, (str, Path)) else pred + case_id = case_ids[i] if case_ids is not None else Path(str(gt_path)).name.split(".")[0] + row = {"case_id": case_id} + row.update(calculate_all_metrics(pred_data, gt.data, gt.spacing, + nsd_tolerances_mm=nsd_tolerances_mm, binary=binary)) + rows.append(row) + return pd.DataFrame(rows) + # %% ../nbs/05_vision_metrics.ipynb #js7clwse6n class AccumulatedDice(Metric): """nnU-Net-style accumulated Dice metric for reliable pseudo dice during training. diff --git a/fastMONAI/vision_patch.py b/fastMONAI/vision_patch.py index f4239db..c55ac90 100644 --- a/fastMONAI/vision_patch.py +++ b/fastMONAI/vision_patch.py @@ -98,7 +98,9 @@ class PatchConfig: Can be int, float, or string (e.g., 'minimum', 'mean'). keep_largest_component: If True, keep only the largest connected component in binary segmentation predictions. Only applies during inference when - return_probabilities=False. Defaults to False. + return_probabilities=False. Defaults to False. Binary-only: for multi-class + output (more than 2 classes) it is skipped with a warning, since it would + otherwise merge all classes into one blob and collapse the labels. binary_threshold: Decision boundary for single-channel (sigmoid) masks; a voxel is foreground when probability >= binary_threshold (matches MONAI AsDiscrete). Only applies when return_probabilities=False and n_classes == 1. Defaults to 0.5. @@ -1009,29 +1011,34 @@ def _normalize_patch_overlap(patch_overlap: 'int | float | list', patch_size: li def _logits_to_probs(logits: torch.Tensor) -> torch.Tensor: """Logits -> probabilities: sigmoid for 1 channel (binary), else softmax over the channel dim. - Upcasts to float32 (logits may be float16 under AMP). Does NOT move to CPU; callers decide + Upcasts to float32 (logits may be bfloat16 under AMP). Does NOT move to CPU; callers decide placement (TTA keeps tensors on-device to flip back; the non-TTA path moves to CPU immediately). """ return torch.sigmoid(logits.float()) if logits.shape[1] == 1 else torch.softmax(logits.float(), dim=1) -def _predict_patch_tta(model, patch_input, amp_context=None) -> torch.Tensor: - """Mirror TTA: average probabilities over the 8 flip combinations. +def _predict_patch_tta(models, patch_input, amp_context=None) -> torch.Tensor: + """Mirror TTA (with optional model ensemble): average probabilities over the 8 flips and all models. - Uses a running sum (2x memory, not 9x). Each pass: flip -> forward -> activate -> flip back -> accumulate. - Returns the averaged probability tensor [B, C, D, H, W] on CPU. amp_context, if given, wraps the forward pass. + models is a single model or a list of models; a list soft-votes (averages probabilities). + Uses a running sum (2x memory, not 9x per model). Each pass: flip -> forward -> activate -> + flip back -> accumulate. Returns the averaged probability tensor [B, C, D, H, W] on CPU. + amp_context, if given, wraps each forward pass. """ if amp_context is None: amp_context = nullcontext() + if not isinstance(models, (list, tuple)): + models = [models] summed_probs = None for axes in _TTA_FLIP_AXES: flipped = torch.flip(patch_input, list(axes)) if axes else patch_input - with amp_context: - logits = model(flipped) - probs = _logits_to_probs(logits) - if axes: - probs = torch.flip(probs, list(axes)) - summed_probs = probs if summed_probs is None else summed_probs + probs - return (summed_probs / len(_TTA_FLIP_AXES)).cpu() + for model in models: + with amp_context: + logits = model(flipped) + probs = _logits_to_probs(logits) + if axes: + probs = torch.flip(probs, list(axes)) + summed_probs = probs if summed_probs is None else summed_probs + probs + return (summed_probs / (len(_TTA_FLIP_AXES) * len(models))).cpu() @dataclass @@ -1053,8 +1060,10 @@ class PatchInferenceEngine: GridAggregator to reconstruct the full volume from predictions. Args: - learner: fastai Learner or PyTorch model (nn.Module). When passing a raw - PyTorch model, load weights first with model.load_state_dict(). + learner: fastai Learner or PyTorch model (nn.Module), or a list of them for + soft-vote ensembling (per-patch probabilities are averaged before argmax/ + threshold). When passing a raw PyTorch model, load weights first with + model.load_state_dict(). config: PatchConfig with inference settings. Preprocessing params (apply_reorder, target_spacing, padding_mode) can be set here for DRY usage. apply_reorder: Whether to reorder to RAS+ orientation. If None, uses config value. @@ -1064,7 +1073,7 @@ class PatchInferenceEngine: read from config.normalization (the default source of truth, set at training time). Provide this only to override the config (e.g. for non-serializable transforms). Accepts both fastMONAI wrappers and raw TorchIO transforms. - amp: If True, use automatic mixed precision (float16) for the forward pass. + amp: If True, use automatic mixed precision (bfloat16) for the forward pass. Only supported on CUDA devices; ignored with a warning on CPU/MPS. Defaults to False. @@ -1090,10 +1099,11 @@ def __init__( # We check for Learner explicitly because some models (e.g., MONAI UNet) have a # .model attribute that is NOT the full model but an internal Sequential. - if isinstance(learner, Learner): - self.model = learner.model - else: - self.model = learner # already a PyTorch model + _learners = list(learner) if isinstance(learner, (list, tuple)) else [learner] + if len(_learners) == 0: + raise ValueError("learner must be a model/Learner or a non-empty list of them.") + self.models = [l.model if isinstance(l, Learner) else l for l in _learners] + self.model = self.models[0] # first model; alias kept for single-model back-compat self.config = config self.sw_batch_size = sw_batch_size @@ -1123,11 +1133,12 @@ def __init__( self._device = _get_default_device() # Set eval mode once at construction (this class is inference-only) - self.model.eval() + for _m in self.models: + _m.eval() # AMP: CUDA-only, matching nnU-Net pattern if amp and self._device.type == 'cuda': - self._amp_context = torch.amp.autocast('cuda', dtype=torch.float16) + self._amp_context = torch.amp.autocast('cuda', dtype=torch.bfloat16) else: if amp and self._device.type != 'cuda': warnings.warn("AMP is only supported on CUDA devices. Ignoring amp=True.") @@ -1182,9 +1193,10 @@ def _prepare_subject(self, img_path: Path | str) -> _PreparedSubject: ) def _run_inference(self, prepared: _PreparedSubject, tta: bool = False) -> torch.Tensor: - """Run the model over all patches and aggregate; returns the raw probability tensor. + """Run the model(s) over all patches and aggregate; returns the raw probability tensor. Must run on the main thread (model forward pass). tta enables mirror test-time augmentation. + With multiple models the per-patch probabilities are averaged (soft voting). """ # inference_mode is slightly faster than no_grad (disables autograd tracking # and view tracking). Safe here since we don't do in-place ops on outputs. @@ -1194,11 +1206,15 @@ def _run_inference(self, prepared: _PreparedSubject, tta: bool = False) -> torch locations = patches_batch[tio.LOCATION] if tta: - probs = _predict_patch_tta(self.model, patch_input, self._amp_context) + probs = _predict_patch_tta(self.models, patch_input, self._amp_context) else: - with self._amp_context: - logits = self.model(patch_input) - probs = _logits_to_probs(logits).cpu() + summed = None + for _m in self.models: + with self._amp_context: + logits = _m(patch_input) + p = _logits_to_probs(logits) + summed = p if summed is None else summed + p + probs = (summed / len(self.models)).cpu() prepared.aggregator.add_batch(probs, locations) @@ -1224,8 +1240,15 @@ def _postprocess( result = output.argmax(dim=0, keepdim=True).float() if not return_probabilities and self.config.keep_largest_component: - from fastMONAI.vision_inference import keep_largest - result = keep_largest(result.squeeze(0)).unsqueeze(0) + if n_classes > 2: + warnings.warn( + f"keep_largest_component is binary-only; skipping it for multi-class output " + f"(n_classes={n_classes}). Set keep_largest_component=False for multi-class " + f"models, or post-process each class separately." + ) + else: + from fastMONAI.vision_inference import keep_largest + result = keep_largest(result.squeeze(0)).unsqueeze(0) if return_probabilities: pred_img = tio.ScalarImage(tensor=result.float(), affine=prepared.input_img.affine) @@ -1281,9 +1304,10 @@ def predict( return result def to(self, device): - """Move engine to device.""" + """Move engine (all models) to device.""" self._device = device - self.model.to(device) + for _m in self.models: + _m.to(device) return self # %% ../nbs/10_vision_patch.ipynb #cell-18 @@ -1347,7 +1371,8 @@ def patch_inference( GPU compute use different hardware. Args: - learner: PyTorch model or fastai Learner. + learner: PyTorch model or fastai Learner, or a list of them to soft-vote + ensemble (per-patch probabilities are averaged before argmax/threshold). config: PatchConfig with inference settings. Preprocessing params (apply_reorder, target_spacing) can be set here for DRY usage. file_paths: List of image paths. @@ -1365,7 +1390,7 @@ def patch_inference( thread for preparation and saving. Holds two subjects in memory simultaneously (current + next). Set to False for memory-constrained environments processing very large volumes. - amp: If True, use automatic mixed precision (float16) for the forward pass. + amp: If True, use automatic mixed precision (bfloat16) for the forward pass. Only supported on CUDA devices; ignored with a warning on CPU/MPS. Returns: diff --git a/nbs/04_vision_loss_functions.ipynb b/nbs/04_vision_loss_functions.ipynb index 1186990..8a1b519 100644 --- a/nbs/04_vision_loss_functions.ipynb +++ b/nbs/04_vision_loss_functions.ipynb @@ -50,6 +50,28 @@ "outputs": [], "source": "#| export\nclass TverskyFocalLoss(_Loss):\n \"\"\"Focal Tversky loss: ``(1 - TI)**gamma`` (TI = Tversky index; see ``monai.losses.TverskyLoss``).\n ``gamma=1`` reduces to the plain Tversky loss.\n \"\"\"\n\n def __init__(\n self,\n include_background: bool = True,\n to_onehot_y: bool = False,\n sigmoid: bool = False,\n softmax: bool = False,\n gamma: float = 2,\n alpha: float = 0.5, \n beta: float = 0.99):\n \"\"\"\n Args:\n include_background: if to calculate loss for the background class.\n to_onehot_y: whether to convert `y` into one-hot format.\n sigmoid: if True, apply a sigmoid function to the prediction.\n softmax: if True, apply a softmax function to the prediction.\n gamma: focal exponent in ``(1 - TI)**gamma``.\n alpha: the weight of false positive in Tversky loss calculation.\n beta: the weight of false negative in Tversky loss calculation.\n \"\"\"\n \n super().__init__()\n self.tversky = TverskyLoss(\n to_onehot_y=to_onehot_y, \n include_background=include_background, \n sigmoid=sigmoid, \n softmax=softmax, \n alpha=alpha, \n beta=beta\n )\n self.gamma = gamma\n\n def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor:\n \"\"\"\n Args:\n input: the shape should be [B, C, W, H, D]. The input should be the original logits.\n target: the shape should be[B, C, W, H, D].\n\n Raises:\n ValueError: When number of dimensions for input and target are different.\n \"\"\"\n if len(input.shape) != len(target.shape):\n raise ValueError(\"The number of dimensions for input and target should be the same.\")\n\n # (1 - TI)**gamma; MONAI's TverskyLoss already returns tversky_loss = 1 - TI\n tversky_loss = self.tversky(input, target)\n total_loss: torch.Tensor = tversky_loss ** self.gamma\n\n return total_loss" }, + { + "cell_type": "markdown", + "id": "2eedc7ca", + "metadata": {}, + "source": "## DynUNet deep-supervision adapter" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "acf08940", + "metadata": {}, + "outputs": [], + "source": "#| export\nclass DynUNetDSAdapter(torch.nn.Module):\n \"\"\"Wrap a MONAI ``DynUNet`` with deep supervision so its output matches what\n ``monai.losses.DeepSupervisionLoss`` (used via ``CustomLoss``) expects.\n\n A ``DynUNet(deep_supervision=True)`` returns a *stacked* ``[B, N, C, ...]`` tensor in\n training (a TorchScript workaround), but ``DeepSupervisionLoss`` only unbinds a *list* of\n tensors -- a stacked tensor would fall through to the base loss and be scored incorrectly.\n This adapter unbinds the stack into that list in training, and passes the plain prediction\n through in eval mode (MONAI's ``DynUNet`` docstring prescribes exactly this ``torch.unbind``).\n\n Note: wrapping registers the network as ``self.model``, so it adds a ``model.`` prefix to\n state-dict keys -- relevant when loading raw ``.pth`` checkpoints of a wrapped model.\n \"\"\"\n\n def __init__(self, model):\n super().__init__()\n self.model = model\n\n def forward(self, x):\n out = self.model(x)\n # Unbind only in training, and only when the wrapped net actually stacks its\n # deep-supervision heads. Gating on `deep_supervision` (not output rank) keeps this\n # correct for 2D and 3D alike, and fails loudly if wrapped around a non-DynUNet in training.\n if self.model.training and self.model.deep_supervision:\n return list(torch.unbind(out, dim=1))\n return out" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "61b58857", + "metadata": {}, + "outputs": [], + "source": "#| hide\n# DynUNetDSAdapter: train -> list (for DeepSupervisionLoss), eval -> plain tensor.\n# Uses a minimal stand-in that stacks heads on dim=1 like DynUNet(deep_supervision=True),\n# so the check is dimension-agnostic (the reason we gate on `deep_supervision`, not ndim).\nfrom fastcore.test import test_eq\n\nclass _StubDS(torch.nn.Module):\n def __init__(self, n_heads=3):\n super().__init__()\n self.deep_supervision = True\n self.n_heads = n_heads\n def forward(self, x):\n return torch.stack([x] * self.n_heads, dim=1) if self.training else x\n\n# 3D\n_ad = DynUNetDSAdapter(_StubDS(n_heads=3))\n_x = torch.rand(2, 2, 8, 8, 8)\n_ad.train()\n_out = _ad(_x)\ntest_eq(isinstance(_out, list), True)\ntest_eq(len(_out), 3)\ntest_eq(_out[0].shape, _x.shape)\n_ad.eval()\ntest_eq(isinstance(_ad(_x), torch.Tensor), True)\ntest_eq(_ad(_x).shape, _x.shape)\n\n# 2D: the deep-supervision stack is 5-D, not 6-D; gating on the flag still unbinds correctly\n# (an `out.ndim == 6` check would silently fail here).\n_ad2 = DynUNetDSAdapter(_StubDS(n_heads=2)); _ad2.train()\ntest_eq(len(_ad2(torch.rand(2, 2, 8, 8))), 2)" + }, { "cell_type": "code", "execution_count": null, diff --git a/nbs/05_vision_metrics.ipynb b/nbs/05_vision_metrics.ipynb index 0354cd3..2d39073 100644 --- a/nbs/05_vision_metrics.ipynb +++ b/nbs/05_vision_metrics.ipynb @@ -16,17 +16,7 @@ "id": "8b6a83ac", "metadata": {}, "outputs": [], - "source": [ - "#| export\n", - "import warnings\n", - "import torch\n", - "import numpy as np\n", - "from monai.metrics import compute_hausdorff_distance, compute_dice, get_confusion_matrix, compute_confusion_matrix_metric, compute_average_surface_distance, compute_surface_dice\n", - "from scipy.ndimage import label as scipy_label\n", - "from fastMONAI.vision_data import pred_to_binary_mask, batch_pred_to_multiclass_mask\n", - "from fastai.learner import Metric\n", - "from fastai.callback.tracker import TrackerCallback" - ] + "source": "#| export\nimport warnings\nimport torch\nimport numpy as np\nimport pandas as pd\nfrom monai.metrics import compute_hausdorff_distance, compute_dice, get_confusion_matrix, compute_confusion_matrix_metric, compute_average_surface_distance, compute_surface_dice\nfrom scipy.ndimage import label as scipy_label\nfrom pathlib import Path\nfrom fastMONAI.vision_data import pred_to_binary_mask, batch_pred_to_multiclass_mask\nfrom fastai.learner import Metric\nfrom fastai.callback.tracker import TrackerCallback" }, { "cell_type": "markdown", @@ -688,6 +678,36 @@ " \"status\": \"ok\", **base}" ] }, + { + "cell_type": "markdown", + "id": "383333ca", + "metadata": {}, + "source": "## Full metric panel (per-case and batch)" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed336bbf", + "metadata": {}, + "outputs": [], + "source": "#| export\ndef calculate_all_metrics(pred, targ, spacing_mm, *, nsd_tolerances_mm=(0.5, 1.0, 2.0),\n binary: bool = True) -> dict:\n \"\"\"Full per-case metric panel for one segmentation vs its ground truth.\n\n Orchestrates the individual ``calculate_*`` functions into one dict: Dice, sensitivity,\n precision, lesion detection rate, signed RVE, and the spacing-aware surface metrics\n (ASSD / HD95 / NSD in mm). For a whole dataset, use ``evaluate_segmentations``.\n\n Args:\n pred: predicted mask (binary / argmax label). torch.Tensor or np.ndarray, shaped\n [W,H,D], [C,W,H,D] or [B,C,W,H,D] with singleton batch/channel dims.\n targ: ground-truth mask, same shape convention, on the SAME voxel grid as ``pred``.\n spacing_mm: voxel spacing in mm in array-axis (i,j,k) order matching ``targ``. Pass it\n explicitly (read from the GT file); a permuted spacing silently corrupts the\n surface distances.\n nsd_tolerances_mm: NSD tolerance(s) in mm, forwarded to ``calculate_surface_metrics``.\n binary: single-foreground-class segmentation (the only mode supported). The confusion\n metrics run on the single label channel -- MONAI keeps a size-1 channel when\n ``include_background=False``, so a single-channel input is scored correctly.\n\n Returns:\n dict: ``dsc``, ``sensitivity``, ``precision``, ``ldr``, ``rve``, the ``nsd_tau*_mm``\n values, ``assd_mm``, ``hd95_mm``, ``surface_status`` and ``spacing_mm``.\n \"\"\"\n if not binary:\n raise NotImplementedError(\"calculate_all_metrics supports binary (single foreground \"\n \"class) segmentation only.\")\n\n pred_t = pred if isinstance(pred, torch.Tensor) else torch.as_tensor(np.asarray(pred))\n targ_t = targ if isinstance(targ, torch.Tensor) else torch.as_tensor(np.asarray(targ))\n while pred_t.ndim < 5: pred_t = pred_t.unsqueeze(0) # -> [B,C,W,H,D]\n while targ_t.ndim < 5: targ_t = targ_t.unsqueeze(0)\n if pred_t.shape[-3:] != targ_t.shape[-3:]:\n raise ValueError(\"pred and targ must share a voxel grid; got spatial shapes \"\n f\"{tuple(pred_t.shape[-3:])} vs {tuple(targ_t.shape[-3:])}.\")\n pred_t, targ_t = pred_t.float(), targ_t.float()\n\n sm = calculate_surface_metrics(pred_t, targ_t, spacing_mm, nsd_tolerances_mm=nsd_tolerances_mm)\n row = {\n \"dsc\": calculate_dsc(pred_t, targ_t).nanmean().item(),\n \"sensitivity\": calculate_confusion_metrics(pred_t, targ_t, \"sensitivity\").nanmean().item(),\n \"precision\": calculate_confusion_metrics(pred_t, targ_t, \"precision\").nanmean().item(),\n \"ldr\": calculate_lesion_detection_rate(pred_t, targ_t).nanmean().item(),\n \"rve\": calculate_signed_rve(pred_t, targ_t).nanmean().item(),\n }\n row.update({k: sm[k] for k in sm if k.startswith(\"nsd_tau\")})\n row[\"assd_mm\"], row[\"hd95_mm\"] = sm[\"assd_mm\"], sm[\"hd95_mm\"]\n row[\"surface_status\"], row[\"spacing_mm\"] = sm[\"status\"], sm[\"spacing_mm\"]\n return row" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2e526be3", + "metadata": {}, + "outputs": [], + "source": "#| export\ndef evaluate_segmentations(preds, gt_paths, case_ids=None, *,\n nsd_tolerances_mm=(0.5, 1.0, 2.0), binary: bool = True):\n \"\"\"Score a batch of predictions against ground-truth masks into a per-case DataFrame.\n\n For each (prediction, ground-truth) pair, the GT mask AND its voxel spacing are read from a\n SINGLE object, so the array and its spacing can never disagree -- reading spacing from a\n separately-loaded file is a silent source of axis-permutation errors that corrupt the surface\n metrics. Predictions may be tensors (as returned by ``patch_inference``) or paths to saved\n masks, so predictions written with ``patch_inference(save_dir=...)`` can be re-scored without\n re-running inference.\n\n Args:\n preds: sequence of predicted masks -- torch.Tensor / np.ndarray, or paths to mask files.\n gt_paths: sequence of ground-truth mask file paths (one per prediction, same voxel grid).\n case_ids: optional row labels; default is each GT file's name stem.\n nsd_tolerances_mm: NSD tolerance(s) in mm, forwarded to the surface metrics.\n binary: binary (single foreground class) segmentation only.\n\n Returns:\n pandas.DataFrame with one row per case: ``case_id`` plus the columns from\n ``calculate_all_metrics``.\n \"\"\"\n import torchio as tio # lazy: only this benchmark helper needs torchio I/O\n preds, gt_paths = list(preds), list(gt_paths)\n if len(preds) != len(gt_paths):\n raise ValueError(f\"preds and gt_paths must have equal length; got {len(preds)} and \"\n f\"{len(gt_paths)}.\")\n\n rows = []\n for i, (pred, gt_path) in enumerate(zip(preds, gt_paths)):\n gt = tio.LabelMap(gt_path) # single source: .data and .spacing agree\n pred_data = tio.LabelMap(pred).data if isinstance(pred, (str, Path)) else pred\n case_id = case_ids[i] if case_ids is not None else Path(str(gt_path)).name.split(\".\")[0]\n row = {\"case_id\": case_id}\n row.update(calculate_all_metrics(pred_data, gt.data, gt.spacing,\n nsd_tolerances_mm=nsd_tolerances_mm, binary=binary))\n rows.append(row)\n return pd.DataFrame(rows)" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2fcc148b", + "metadata": {}, + "outputs": [], + "source": "#| hide\nimport tempfile\nfrom pathlib import Path as _Path\nimport torchio as tio\nfrom fastcore.test import test_eq, test_close, test_fail\n\n# calculate_all_metrics: a perfect prediction scores DSC ~1 and ASSD 0.\n_cube = np.zeros((16, 16, 16), np.float32); _cube[4:12, 4:12, 4:12] = 1.0\n_m = calculate_all_metrics(_cube, _cube, spacing_mm=(1.0, 1.0, 1.0))\ntest_close(_m[\"dsc\"], 1.0, eps=1e-4)\ntest_close(_m[\"assd_mm\"], 0.0, eps=1e-6)\ntest_eq(_m[\"sensitivity\"] > 0.99, True)\ntest_eq(_m[\"precision\"] > 0.99, True)\n\n# guards: multiclass not supported; mismatched grids raise loudly.\ntest_fail(lambda: calculate_all_metrics(_cube, _cube, (1, 1, 1), binary=False))\ntest_fail(lambda: calculate_all_metrics(_cube, np.zeros((8, 8, 8), np.float32), (1, 1, 1)))\n\n# evaluate_segmentations: pred as tensor AND as path; spacing read from the GT object.\nwith tempfile.TemporaryDirectory() as _d:\n _gt = _Path(_d) / \"case_seg.nii.gz\"\n tio.LabelMap(tensor=torch.from_numpy(_cube)[None], affine=np.diag([1., 1., 2., 1.])).save(_gt)\n _df = evaluate_segmentations([torch.from_numpy(_cube)[None], str(_gt)], [str(_gt), str(_gt)])\n test_eq(len(_df), 2)\n test_eq(\"case_id\" in _df.columns, True)\n test_close(_df[\"dsc\"].iloc[0], 1.0, eps=1e-4)\n test_eq(tuple(_df[\"spacing_mm\"].iloc[0]), (1.0, 1.0, 2.0)) # anisotropic spacing from GT" + }, { "cell_type": "markdown", "id": "1jvtp79rnxf", diff --git a/nbs/07_utils.ipynb b/nbs/07_utils.ipynb index 71661f0..5387a52 100644 --- a/nbs/07_utils.ipynb +++ b/nbs/07_utils.ipynb @@ -288,7 +288,405 @@ "id": "030876d2", "metadata": {}, "outputs": [], - "source": "#| export\nclass ModelTrackingCallback(Callback):\n \"\"\"\n A FastAI callback for comprehensive MLflow experiment tracking.\n \n This callback automatically logs hyperparameters, metrics, model artifacts,\n and configuration to MLflow during training. If a checkpoint callback\n (SaveModelCallback, EMACheckpoint, or any TrackerCallback with fname) is\n present, the best model checkpoint will also be logged as an artifact.\n \n Supports auto-managed runs when created via `create_mlflow_callback()`.\n \"\"\"\n \n def __init__(\n self, \n model_name: str, \n loss_function: str, \n item_tfms: list[Any],\n size: list[int], \n target_spacing: list[float], \n apply_reorder: bool,\n experiment_name: str = None,\n run_name: str = None,\n auto_start: bool = False,\n patch_config: dict = None,\n extra_params: dict = None,\n extra_tags: dict = None,\n dataset_version: str = None,\n log_split: bool = True\n ):\n \"\"\"\n Initialize the MLflow tracking callback.\n \n Args:\n model_name: Name/identifier for the model being tracked\n loss_function: Name of the loss function being used\n item_tfms: List of item transforms\n size: Model input dimensions\n target_spacing: Resampling dimensions\n apply_reorder: Whether reordering augmentation is applied\n experiment_name: MLflow experiment name (used with auto_start)\n run_name: MLflow run name (auto-generated if None)\n auto_start: If True, auto-starts/stops MLflow run\n patch_config: Patch configuration dict for logging (from MedPatchDataLoaders)\n extra_params: Additional parameters to log\n extra_tags: MLflow tags to set on the run\n dataset_version: Dataset version hash string for tracking\n log_split: If True, auto-logs train/val split CSV when dls has split_df\n \"\"\"\n _ensure_fastmonai_tracking_uri()\n self.model_name = model_name\n self.loss_function = loss_function\n self.item_tfms = item_tfms\n self.size = size\n self.target_spacing = target_spacing\n self.apply_reorder = apply_reorder\n \n self.experiment_name = experiment_name\n self.run_name = run_name\n self.auto_start = auto_start\n self.patch_config = patch_config\n self.extra_params = extra_params or {}\n self.extra_tags = extra_tags or {}\n self.dataset_version = dataset_version\n self.log_split = log_split\n self._auto_started = False\n self.run_id = None\n \n self.config = self._build_config()\n \n def extract_all_params(self, tfm):\n \"\"\"\n Extract all parameters from a transform object for detailed logging.\n \n Args:\n tfm: Transform object to extract parameters from\n \n Returns:\n dict: Dictionary with 'name' and 'params' keys containing transform details\n \"\"\"\n class_name = tfm.__class__.__name__\n params = {}\n \n for key, value in tfm.__dict__.items():\n if not key.startswith('_') and key != '__signature__':\n if hasattr(value, '__dict__') and hasattr(value, 'target_shape'):\n params['target_shape'] = value.target_shape\n elif hasattr(value, '__dict__') and not key.startswith('_'):\n nested_params = {k: v for k, v in value.__dict__.items() \n if not k.startswith('_') and isinstance(v, (int, float, str, bool, tuple, list))}\n params.update(nested_params)\n elif isinstance(value, (int, float, str, bool, tuple, list)):\n params[key] = value\n \n return {\n 'name': class_name,\n 'params': params\n }\n \n def _build_config(self) -> dict[str, Any]:\n \"\"\"Build configuration dictionary from initialization parameters.\"\"\"\n transform_details = [self.extract_all_params(tfm) for tfm in self.item_tfms]\n \n return {\n \"model_name\": self.model_name,\n \"loss_function\": self.loss_function,\n \"transform_details\": transform_details,\n \"size\": self.size,\n \"target_spacing\": self.target_spacing,\n \"apply_reorder\": self.apply_reorder,\n }\n \n def _extract_training_params(self) -> dict[str, Any]:\n \"\"\"Extract training hyperparameters from the learner.\"\"\"\n params = {}\n \n params[\"epochs\"] = self.learn.n_epoch\n params[\"learning_rate\"] = float(self.learn.lr)\n params[\"optimizer\"] = self.learn.opt_func.__name__\n params[\"batch_size\"] = self.learn.dls.bs\n \n params[\"loss_function\"] = self.config[\"loss_function\"]\n params[\"size\"] = self.config[\"size\"]\n params[\"target_spacing\"] = self.config[\"target_spacing\"]\n params[\"apply_reorder\"] = self.config[\"apply_reorder\"]\n \n params[\"transformations\"] = json.dumps(\n self.config[\"transform_details\"], \n indent=2, \n separators=(',', ': ')\n )\n \n if self.patch_config:\n for key, value in self.patch_config.items():\n if value is not None:\n params[f\"patch_{key}\"] = value if isinstance(value, (int, float, str, bool)) else str(value)\n \n return params\n \n def _extract_epoch_metrics(self) -> dict[str, float]:\n \"\"\"Extract metrics from the current epoch.\"\"\"\n recorder = self.learn.recorder\n \n # Skip 'epoch' and 'time'\n metric_names = recorder.metric_names[2:]\n raw_metric_values = recorder.log[2:]\n \n metrics = {}\n for name, val in zip(metric_names, raw_metric_values):\n if val is None:\n continue # Skip None values during inference\n if isinstance(val, torch.Tensor):\n if val.numel() == 1:\n metrics[name] = float(val)\n else:\n val_list = val.tolist() if hasattr(val, 'tolist') else list(val)\n for i, class_score in enumerate(val_list):\n metrics[f\"{name}_class_{i+1}\"] = float(class_score)\n metrics[f\"{name}_mean\"] = float(torch.mean(val))\n else:\n metrics[name] = float(val)\n \n if len(recorder.log) >= 2:\n if recorder.log[1] is not None:\n metrics['train_loss'] = float(recorder.log[1])\n if len(recorder.log) >= 3 and recorder.log[2] is not None:\n metrics['valid_loss'] = float(recorder.log[2])\n \n return metrics\n \n def _save_model_artifacts(self, temp_dir: Path) -> None:\n \"\"\"Save model weights, learner, and configuration as artifacts.\"\"\"\n import shutil\n\n # Save final epoch weights (without optimizer state to reduce file size)\n weights_path = temp_dir / \"final_weights\"\n self.learn.save(str(weights_path), with_opt=False)\n weights_file = f\"{weights_path}.pth\"\n if os.path.exists(weights_file):\n mlflow.log_artifact(weights_file, \"model\")\n\n # Auto-detect checkpoint callback (SaveModelCallback, EMACheckpoint, etc.)\n from fastai.callback.tracker import TrackerCallback\n best_model_cb = None\n for cb in self.learn.cbs:\n if isinstance(cb, TrackerCallback) and hasattr(cb, 'fname'):\n best_model_path = self.learn.path / self.learn.model_dir / f'{cb.fname}.pth'\n if best_model_path.exists():\n best_weights_dest = temp_dir / \"best_weights.pth\"\n shutil.copy2(str(best_model_path), str(best_weights_dest))\n mlflow.log_artifact(str(best_weights_dest), \"model\")\n print(f\"Logged best model weights: best_weights.pth\")\n best_model_cb = cb\n break\n\n # Remove ModelTrackingCallback before exporting learners\n original_cbs = self.learn.cbs.copy()\n filtered_cbs = L([cb for cb in self.learn.cbs\n if not isinstance(cb, ModelTrackingCallback)])\n self.learn.cbs = filtered_cbs\n\n # Export before loading best weights\n final_learner_path = temp_dir / \"final_learner.pkl\"\n self.learn.export(str(final_learner_path))\n mlflow.log_artifact(str(final_learner_path), \"model\")\n print(\"Logged final epoch learner: final_learner.pkl\")\n\n if best_model_cb is not None:\n self.learn.load(best_model_cb.fname)\n print(f\"Loaded best model weights ({best_model_cb.fname}) for best learner export\")\n\n best_learner_path = temp_dir / \"best_learner.pkl\"\n self.learn.export(str(best_learner_path))\n mlflow.log_artifact(str(best_learner_path), \"model\")\n print(\"Logged best epoch learner: best_learner.pkl\")\n\n self.learn.cbs = original_cbs\n\n config_path = temp_dir / \"inference_settings.json\"\n store_variables(config_path, self.size, self.apply_reorder, self.target_spacing)\n mlflow.log_artifact(str(config_path), \"config\")\n \n def _log_split_df(self) -> None:\n \"\"\"Log train/validation split as CSV artifact if available.\"\"\"\n dls = self.learn.dls\n if not hasattr(dls, 'split_df'):\n return\n try:\n split_df = dls.split_df\n cols = []\n img_col = getattr(dls, '_img_col', None)\n mask_col = getattr(dls, '_mask_col', None)\n if img_col and img_col in split_df.columns:\n cols.append(img_col)\n if mask_col and mask_col in split_df.columns:\n cols.append(mask_col)\n cols.append('is_valid')\n split_df = split_df[cols]\n\n mlflow.log_text(split_df.to_csv(index=False), \"data/train_val_split.csv\")\n print(f\"Logged train/val split ({len(split_df)} rows) to MLflow artifacts\")\n except Exception as e:\n print(f\"Warning: Could not log train/val split: {e}\")\n\n def before_fit(self) -> None:\n \"\"\"Log hyperparameters before training starts. Auto-start run if configured.\"\"\"\n if self.auto_start:\n if self.experiment_name:\n mlflow.set_experiment(self.experiment_name)\n self.run_name = self.run_name or f\"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}\"\n mlflow.start_run(run_name=self.run_name)\n self._auto_started = True\n \n if self.dataset_version is not None:\n mlflow.set_tag(\"dataset_version\", self.dataset_version)\n \n if self.extra_tags:\n mlflow.set_tags(self.extra_tags)\n \n params = self._extract_training_params()\n params.update(self.extra_params)\n mlflow.log_params(params)\n\n if self.log_split:\n self._log_split_df()\n \n def after_epoch(self) -> None:\n \"\"\"Log metrics after each epoch.\"\"\"\n metrics = self._extract_epoch_metrics()\n if metrics:\n mlflow.log_metrics(metrics, step=self.learn.epoch)\n \n def after_fit(self) -> None:\n \"\"\"Log model artifacts after training completion.\"\"\"\n print(\"\\nTraining finished. Logging model artifacts to MLflow...\")\n \n with tempfile.TemporaryDirectory() as temp_dir:\n temp_path = Path(temp_dir)\n \n self._save_model_artifacts(temp_path)\n \n self.run_id = mlflow.active_run().info.run_id\n print(f\"MLflow run completed. Run ID: {self.run_id}\")\n \n if self._auto_started:\n mlflow.end_run()\n self._auto_started = False\n\n def log_metrics(self, metrics: dict) -> None:\n \"\"\"Log additional metrics to the completed MLflow run.\n\n Reopens the run to log metrics, then closes it. Useful for\n post-training evaluation (e.g., validation set patch inference results).\n NaN values are silently skipped (MLflow rejects them).\n\n Args:\n metrics: Dictionary of metric name -> value pairs to log.\n \"\"\"\n if self.run_id is None:\n raise RuntimeError(\"No MLflow run found. Train the model first.\")\n\n import math\n valid = {k: v for k, v in metrics.items()\n if not (isinstance(v, float) and math.isnan(v))}\n\n if valid:\n with mlflow.start_run(run_id=self.run_id):\n mlflow.log_metrics(valid)\n print(f\"Logged {len(valid)} metric(s) to MLflow run {self.run_id}\")\n\n def log_metrics_table(self, df, metrics=None, key='validation_metrics', display=False):\n \"\"\"Log a summary table of metrics as an image to the MLflow Images tab.\n\n Creates a styled matplotlib table with Metric, Mean, and Std columns\n from the given DataFrame and logs it via mlflow.log_image() so it\n appears in the MLflow UI Images tab.\n\n Args:\n df: DataFrame containing per-sample metric values.\n metrics: List of column names in df to summarize. If None,\n all numeric columns are used automatically.\n key: Image key for MLflow Images tab.\n display: If True, display the table inline in the notebook.\n \"\"\"\n import math\n import io\n import matplotlib.pyplot as plt\n from PIL import Image\n\n if self.run_id is None:\n raise RuntimeError(\"No MLflow run found. Train the model first.\")\n\n if metrics is None:\n metrics = df.select_dtypes(include='number').columns.tolist()\n\n summary_data = []\n for metric in metrics:\n mean = df[metric].mean()\n std = df[metric].std()\n std_str = f'{std:.4f}' if not math.isnan(std) else 'N/A'\n summary_data.append([metric, f'{mean:.4f}', std_str])\n\n fig, ax = plt.subplots(figsize=(5, len(metrics) * 0.8 + 0.5))\n ax.axis('off')\n\n table = ax.table(\n cellText=summary_data,\n colLabels=['Metric', 'Mean', 'Std'],\n cellLoc='center',\n loc='center'\n )\n table.auto_set_font_size(False)\n table.set_fontsize(14)\n table.scale(1.5, 2.2)\n\n for col in range(3):\n table[0, col].set_facecolor('#4472C4')\n table[0, col].set_text_props(color='white', fontweight='bold')\n\n fig.tight_layout()\n\n buf = io.BytesIO()\n fig.savefig(buf, format='png', bbox_inches='tight', dpi=250)\n buf.seek(0)\n pil_image = Image.open(buf)\n\n with mlflow.start_run(run_id=self.run_id):\n mlflow.log_image(pil_image, key=key)\n\n buf.close()\n if display: plt.show()\n plt.close(fig)\n print(f\"Logged metrics table to MLflow run {self.run_id}\")\n\n def log_dataframe(self, df, artifact_name='validation_results.csv', artifact_path='results'):\n \"\"\"Log a DataFrame as a CSV artifact to the MLflow run.\n\n Args:\n df: DataFrame to save.\n artifact_name: Filename for the CSV.\n artifact_path: Artifact subdirectory in MLflow.\n \"\"\"\n if self.run_id is None:\n raise RuntimeError(\"No MLflow run found. Train the model first.\")\n\n with tempfile.NamedTemporaryFile(suffix='.csv', delete=False, mode='w') as f:\n df.to_csv(f, index=False)\n temp_path = f.name\n\n with mlflow.start_run(run_id=self.run_id):\n mlflow.log_artifact(temp_path, artifact_path)\n\n os.remove(temp_path)\n print(f\"Logged DataFrame ({len(df)} rows) to MLflow run {self.run_id}\")" + "source": [ + "#| export\n", + "class ModelTrackingCallback(Callback):\n", + " \"\"\"\n", + " A FastAI callback for comprehensive MLflow experiment tracking.\n", + " \n", + " This callback automatically logs hyperparameters, metrics, model artifacts,\n", + " and configuration to MLflow during training. If a checkpoint callback\n", + " (SaveModelCallback, EMACheckpoint, or any TrackerCallback with fname) is\n", + " present, the best model checkpoint will also be logged as an artifact.\n", + " \n", + " Supports auto-managed runs when created via `create_mlflow_callback()`.\n", + " \"\"\"\n", + " \n", + " def __init__(\n", + " self, \n", + " model_name: str, \n", + " loss_function: str, \n", + " item_tfms: list[Any],\n", + " size: list[int], \n", + " target_spacing: list[float], \n", + " apply_reorder: bool,\n", + " experiment_name: str = None,\n", + " run_name: str = None,\n", + " auto_start: bool = False,\n", + " patch_config: dict = None,\n", + " extra_params: dict = None,\n", + " extra_tags: dict = None,\n", + " dataset_version: str = None,\n", + " log_split: bool = True\n", + " ):\n", + " \"\"\"\n", + " Initialize the MLflow tracking callback.\n", + " \n", + " Args:\n", + " model_name: Name/identifier for the model being tracked\n", + " loss_function: Name of the loss function being used\n", + " item_tfms: List of item transforms\n", + " size: Model input dimensions\n", + " target_spacing: Resampling dimensions\n", + " apply_reorder: Whether reordering augmentation is applied\n", + " experiment_name: MLflow experiment name (used with auto_start)\n", + " run_name: MLflow run name (auto-generated if None)\n", + " auto_start: If True, auto-starts/stops MLflow run\n", + " patch_config: Patch configuration dict for logging (from MedPatchDataLoaders)\n", + " extra_params: Additional parameters to log\n", + " extra_tags: MLflow tags to set on the run\n", + " dataset_version: Dataset version hash string for tracking\n", + " log_split: If True, auto-logs train/val split CSV when dls has split_df\n", + " \"\"\"\n", + " _ensure_fastmonai_tracking_uri()\n", + " self.model_name = model_name\n", + " self.loss_function = loss_function\n", + " self.item_tfms = item_tfms\n", + " self.size = size\n", + " self.target_spacing = target_spacing\n", + " self.apply_reorder = apply_reorder\n", + " \n", + " self.experiment_name = experiment_name\n", + " self.run_name = run_name\n", + " self.auto_start = auto_start\n", + " self.patch_config = patch_config\n", + " self.extra_params = extra_params or {}\n", + " self.extra_tags = extra_tags or {}\n", + " self.dataset_version = dataset_version\n", + " self.log_split = log_split\n", + " self._auto_started = False\n", + " self.run_id = None\n", + " \n", + " self.config = self._build_config()\n", + " \n", + " def extract_all_params(self, tfm):\n", + " \"\"\"\n", + " Extract all parameters from a transform object for detailed logging.\n", + " \n", + " Args:\n", + " tfm: Transform object to extract parameters from\n", + " \n", + " Returns:\n", + " dict: Dictionary with 'name' and 'params' keys containing transform details\n", + " \"\"\"\n", + " class_name = tfm.__class__.__name__\n", + " params = {}\n", + " \n", + " for key, value in tfm.__dict__.items():\n", + " if not key.startswith('_') and key != '__signature__':\n", + " if hasattr(value, '__dict__') and hasattr(value, 'target_shape'):\n", + " params['target_shape'] = value.target_shape\n", + " elif hasattr(value, '__dict__') and not key.startswith('_'):\n", + " nested_params = {k: v for k, v in value.__dict__.items() \n", + " if not k.startswith('_') and isinstance(v, (int, float, str, bool, tuple, list))}\n", + " params.update(nested_params)\n", + " elif isinstance(value, (int, float, str, bool, tuple, list)):\n", + " params[key] = value\n", + " \n", + " return {\n", + " 'name': class_name,\n", + " 'params': params\n", + " }\n", + " \n", + " def _build_config(self) -> dict[str, Any]:\n", + " \"\"\"Build configuration dictionary from initialization parameters.\"\"\"\n", + " transform_details = [self.extract_all_params(tfm) for tfm in self.item_tfms]\n", + " \n", + " return {\n", + " \"model_name\": self.model_name,\n", + " \"loss_function\": self.loss_function,\n", + " \"transform_details\": transform_details,\n", + " \"size\": self.size,\n", + " \"target_spacing\": self.target_spacing,\n", + " \"apply_reorder\": self.apply_reorder,\n", + " }\n", + " \n", + " def _extract_training_params(self) -> dict[str, Any]:\n", + " \"\"\"Extract training hyperparameters from the learner.\"\"\"\n", + " params = {}\n", + " \n", + " params[\"epochs\"] = self.learn.n_epoch\n", + " params[\"learning_rate\"] = float(self.learn.lr)\n", + " params[\"optimizer\"] = self.learn.opt_func.__name__\n", + " params[\"batch_size\"] = self.learn.dls.bs\n", + " \n", + " params[\"loss_function\"] = self.config[\"loss_function\"]\n", + " params[\"size\"] = self.config[\"size\"]\n", + " params[\"target_spacing\"] = self.config[\"target_spacing\"]\n", + " params[\"apply_reorder\"] = self.config[\"apply_reorder\"]\n", + " \n", + " params[\"transformations\"] = json.dumps(\n", + " self.config[\"transform_details\"], \n", + " indent=2, \n", + " separators=(',', ': ')\n", + " )\n", + " \n", + " if self.patch_config:\n", + " for key, value in self.patch_config.items():\n", + " if value is not None:\n", + " params[f\"patch_{key}\"] = value if isinstance(value, (int, float, str, bool)) else str(value)\n", + " \n", + " return params\n", + " \n", + " def _extract_epoch_metrics(self) -> dict[str, float]:\n", + " \"\"\"Extract metrics from the current epoch.\"\"\"\n", + " recorder = self.learn.recorder\n", + " \n", + " # Skip 'epoch' and 'time'\n", + " metric_names = recorder.metric_names[2:]\n", + " raw_metric_values = recorder.log[2:]\n", + " \n", + " metrics = {}\n", + " for name, val in zip(metric_names, raw_metric_values):\n", + " if val is None:\n", + " continue # Skip None values during inference\n", + " if isinstance(val, torch.Tensor):\n", + " if val.numel() == 1:\n", + " metrics[name] = float(val)\n", + " else:\n", + " val_list = val.tolist() if hasattr(val, 'tolist') else list(val)\n", + " for i, class_score in enumerate(val_list):\n", + " metrics[f\"{name}_class_{i+1}\"] = float(class_score)\n", + " metrics[f\"{name}_mean\"] = float(torch.mean(val))\n", + " else:\n", + " metrics[name] = float(val)\n", + " \n", + " if len(recorder.log) >= 2:\n", + " if recorder.log[1] is not None:\n", + " metrics['train_loss'] = float(recorder.log[1])\n", + " if len(recorder.log) >= 3 and recorder.log[2] is not None:\n", + " metrics['valid_loss'] = float(recorder.log[2])\n", + " \n", + " return metrics\n", + " \n", + " def _save_model_artifacts(self, temp_dir: Path) -> None:\n", + " \"\"\"Save model weights, learner, and configuration as artifacts.\"\"\"\n", + " import shutil\n", + "\n", + " # Save final epoch weights (without optimizer state to reduce file size)\n", + " weights_path = temp_dir / \"final_weights\"\n", + " self.learn.save(str(weights_path), with_opt=False)\n", + " weights_file = f\"{weights_path}.pth\"\n", + " if os.path.exists(weights_file):\n", + " mlflow.log_artifact(weights_file, \"model\")\n", + "\n", + " # Auto-detect checkpoint callback (SaveModelCallback, EMACheckpoint, etc.)\n", + " from fastai.callback.tracker import TrackerCallback\n", + " best_model_cb = None\n", + " for cb in self.learn.cbs:\n", + " if isinstance(cb, TrackerCallback) and hasattr(cb, 'fname'):\n", + " best_model_path = self.learn.path / self.learn.model_dir / f'{cb.fname}.pth'\n", + " if best_model_path.exists():\n", + " best_weights_dest = temp_dir / \"best_weights.pth\"\n", + " shutil.copy2(str(best_model_path), str(best_weights_dest))\n", + " mlflow.log_artifact(str(best_weights_dest), \"model\")\n", + " print(f\"Logged best model weights: best_weights.pth\")\n", + " best_model_cb = cb\n", + " break\n", + "\n", + " # Remove ModelTrackingCallback before exporting learners\n", + " original_cbs = self.learn.cbs.copy()\n", + " filtered_cbs = L([cb for cb in self.learn.cbs\n", + " if not isinstance(cb, ModelTrackingCallback)])\n", + " self.learn.cbs = filtered_cbs\n", + "\n", + " # Export before loading best weights\n", + " final_learner_path = temp_dir / \"final_learner.pkl\"\n", + " self.learn.export(str(final_learner_path))\n", + " mlflow.log_artifact(str(final_learner_path), \"model\")\n", + " print(\"Logged final epoch learner: final_learner.pkl\")\n", + "\n", + " if best_model_cb is not None:\n", + " self.learn.load(best_model_cb.fname)\n", + " print(f\"Loaded best model weights ({best_model_cb.fname}) for best learner export\")\n", + "\n", + " best_learner_path = temp_dir / \"best_learner.pkl\"\n", + " self.learn.export(str(best_learner_path))\n", + " mlflow.log_artifact(str(best_learner_path), \"model\")\n", + " print(\"Logged best epoch learner: best_learner.pkl\")\n", + "\n", + " self.learn.cbs = original_cbs\n", + "\n", + " config_path = temp_dir / \"inference_settings.json\"\n", + " store_variables(config_path, self.size, self.apply_reorder, self.target_spacing)\n", + " mlflow.log_artifact(str(config_path), \"config\")\n", + " \n", + " def _log_split_df(self) -> None:\n", + " \"\"\"Log train/validation split as CSV artifact if available.\"\"\"\n", + " dls = self.learn.dls\n", + " if not hasattr(dls, 'split_df'):\n", + " return\n", + " try:\n", + " split_df = dls.split_df\n", + " cols = []\n", + " img_col = getattr(dls, '_img_col', None)\n", + " mask_col = getattr(dls, '_mask_col', None)\n", + " if img_col and img_col in split_df.columns:\n", + " cols.append(img_col)\n", + " if mask_col and mask_col in split_df.columns:\n", + " cols.append(mask_col)\n", + " cols.append('is_valid')\n", + " split_df = split_df[cols]\n", + "\n", + " mlflow.log_text(split_df.to_csv(index=False), \"data/train_val_split.csv\")\n", + " print(f\"Logged train/val split ({len(split_df)} rows) to MLflow artifacts\")\n", + " except Exception as e:\n", + " print(f\"Warning: Could not log train/val split: {e}\")\n", + "\n", + " def before_fit(self) -> None:\n", + " \"\"\"Log hyperparameters before training starts. Auto-start run if configured.\"\"\"\n", + " if self.auto_start:\n", + " if self.experiment_name:\n", + " mlflow.set_experiment(self.experiment_name)\n", + " self.run_name = self.run_name or f\"run_{datetime.now().strftime('%Y%m%d_%H%M%S')}\"\n", + " mlflow.start_run(run_name=self.run_name)\n", + " self._auto_started = True\n", + " \n", + " if self.dataset_version is not None:\n", + " mlflow.set_tag(\"dataset_version\", self.dataset_version)\n", + " \n", + " if self.extra_tags:\n", + " mlflow.set_tags(self.extra_tags)\n", + " \n", + " params = self._extract_training_params()\n", + " params.update(self.extra_params)\n", + " mlflow.log_params(params)\n", + "\n", + " if self.log_split:\n", + " self._log_split_df()\n", + " \n", + " def after_epoch(self) -> None:\n", + " \"\"\"Log metrics after each epoch.\"\"\"\n", + " metrics = self._extract_epoch_metrics()\n", + " if metrics:\n", + " mlflow.log_metrics(metrics, step=self.learn.epoch)\n", + " \n", + " def after_fit(self) -> None:\n", + " \"\"\"Log model artifacts after training completion.\"\"\"\n", + " print(\"\\nTraining finished. Logging model artifacts to MLflow...\")\n", + " \n", + " with tempfile.TemporaryDirectory() as temp_dir:\n", + " temp_path = Path(temp_dir)\n", + " \n", + " self._save_model_artifacts(temp_path)\n", + " \n", + " self.run_id = mlflow.active_run().info.run_id\n", + " print(f\"MLflow run completed. Run ID: {self.run_id}\")\n", + " \n", + " if self._auto_started:\n", + " mlflow.end_run()\n", + " self._auto_started = False\n", + "\n", + " def log_metrics(self, metrics: dict) -> None:\n", + " \"\"\"Log additional metrics to the completed MLflow run.\n", + "\n", + " Reopens the run to log metrics, then closes it. Useful for\n", + " post-training evaluation (e.g., validation set patch inference results).\n", + " NaN values are silently skipped (MLflow rejects them).\n", + "\n", + " Args:\n", + " metrics: Dictionary of metric name -> value pairs to log.\n", + " \"\"\"\n", + " if self.run_id is None:\n", + " raise RuntimeError(\"No MLflow run found. Train the model first.\")\n", + "\n", + " import math\n", + " valid = {k: v for k, v in metrics.items()\n", + " if not (isinstance(v, float) and math.isnan(v))}\n", + "\n", + " if valid:\n", + " with mlflow.start_run(run_id=self.run_id):\n", + " mlflow.log_metrics(valid)\n", + " print(f\"Logged {len(valid)} metric(s) to MLflow run {self.run_id}\")\n", + "\n", + " def log_metrics_table(self, df, metrics=None, key='validation_metrics', display=False):\n", + " \"\"\"Log a summary table of metrics as an image to the MLflow Images tab.\n", + "\n", + " Creates a styled matplotlib table with Metric, Mean, and Std columns\n", + " from the given DataFrame and logs it via mlflow.log_image() so it\n", + " appears in the MLflow UI Images tab.\n", + "\n", + " Args:\n", + " df: DataFrame containing per-sample metric values.\n", + " metrics: List of column names in df to summarize. If None,\n", + " all numeric columns are used automatically.\n", + " key: Image key for MLflow Images tab.\n", + " display: If True, display the table inline in the notebook.\n", + " \"\"\"\n", + " import math\n", + " import io\n", + " import matplotlib.pyplot as plt\n", + " from PIL import Image\n", + "\n", + " if self.run_id is None:\n", + " raise RuntimeError(\"No MLflow run found. Train the model first.\")\n", + "\n", + " if metrics is None:\n", + " metrics = df.select_dtypes(include='number').columns.tolist()\n", + "\n", + " summary_data = []\n", + " for metric in metrics:\n", + " # Drop +/-inf (e.g. surface distances on one_empty cases) so they don't poison\n", + " # the summary; pandas already skips NaN. Show 'N/A' if nothing finite remains.\n", + " col = df[metric].replace([float('inf'), float('-inf')], float('nan'))\n", + " mean, std = col.mean(), col.std()\n", + " mean_str = f'{mean:.4f}' if not math.isnan(mean) else 'N/A'\n", + " std_str = f'{std:.4f}' if not math.isnan(std) else 'N/A'\n", + " summary_data.append([metric, mean_str, std_str])\n", + "\n", + " fig, ax = plt.subplots(figsize=(5, len(metrics) * 0.8 + 0.5))\n", + " ax.axis('off')\n", + "\n", + " table = ax.table(\n", + " cellText=summary_data,\n", + " colLabels=['Metric', 'Mean', 'Std'],\n", + " cellLoc='center',\n", + " loc='center'\n", + " )\n", + " table.auto_set_font_size(False)\n", + " table.set_fontsize(14)\n", + " table.scale(1.5, 2.2)\n", + "\n", + " for col in range(3):\n", + " table[0, col].set_facecolor('#4472C4')\n", + " table[0, col].set_text_props(color='white', fontweight='bold')\n", + "\n", + " fig.tight_layout()\n", + "\n", + " buf = io.BytesIO()\n", + " fig.savefig(buf, format='png', bbox_inches='tight', dpi=250)\n", + " buf.seek(0)\n", + " pil_image = Image.open(buf)\n", + "\n", + " with mlflow.start_run(run_id=self.run_id):\n", + " mlflow.log_image(pil_image, key=key)\n", + "\n", + " buf.close()\n", + " if display: plt.show()\n", + " plt.close(fig)\n", + " print(f\"Logged metrics table to MLflow run {self.run_id}\")\n", + "\n", + " def log_dataframe(self, df, artifact_name='validation_results.csv', artifact_path='results'):\n", + " \"\"\"Log a DataFrame as a CSV artifact to the MLflow run.\n", + "\n", + " Args:\n", + " df: DataFrame to save.\n", + " artifact_name: Filename for the CSV.\n", + " artifact_path: Artifact subdirectory in MLflow.\n", + " \"\"\"\n", + " if self.run_id is None:\n", + " raise RuntimeError(\"No MLflow run found. Train the model first.\")\n", + "\n", + " with tempfile.NamedTemporaryFile(suffix='.csv', delete=False, mode='w') as f:\n", + " df.to_csv(f, index=False)\n", + " temp_path = f.name\n", + "\n", + " with mlflow.start_run(run_id=self.run_id):\n", + " mlflow.log_artifact(temp_path, artifact_path)\n", + "\n", + " os.remove(temp_path)\n", + " print(f\"Logged DataFrame ({len(df)} rows) to MLflow run {self.run_id}\")" + ] }, { "cell_type": "code", @@ -298,6 +696,28 @@ "outputs": [], "source": "#| export\ndef create_mlflow_callback(\n learn,\n experiment_name: str = None,\n run_name: str = None,\n auto_start: bool = True,\n model_name: str = None,\n extra_params: dict = None,\n extra_tags: dict = None,\n dataset_version: str = None,\n log_split: bool = True,\n) -> ModelTrackingCallback:\n \"\"\"Create MLflow tracking callback with auto-extracted configuration.\n \n This factory function automatically extracts configuration from the Learner,\n eliminating the need to manually specify parameters like size, transforms,\n loss function, etc.\n \n Auto-extracts from Learner:\n - Preprocessing: apply_reorder, target_spacing, size/patch_size\n - Transforms: item_tfms or pre_patch_tfms\n - Training: loss_func, model architecture\n \n Args:\n learn: fastai Learner instance\n experiment_name: MLflow experiment name. If None, uses model name.\n run_name: MLflow run name. If None, auto-generates with timestamp.\n auto_start: If True, auto-starts/stops MLflow run in before_fit/after_fit.\n model_name: Override the auto-extracted model name (used as the experiment name when experiment_name is None).\n extra_params: Additional parameters to log (e.g., {'dropout': 0.5}).\n extra_tags: MLflow tags to set on the run.\n dataset_version: Dataset version hash string for tracking.\n log_split: If True, auto-logs train/val split CSV when dls has split_df.\n \n Returns:\n ModelTrackingCallback ready to use with learn.fit()\n \n Example:\n >>> # Instead of this (6 manual params):\n >>> # mlflow_callback = ModelTrackingCallback(\n >>> # model_name=f\"{task}_{model._get_name()}\",\n >>> # loss_function=loss_func.loss_func._get_name(),\n >>> # item_tfms=item_tfms,\n >>> # size=size,\n >>> # target_spacing=target_spacing,\n >>> # apply_reorder=True,\n >>> # )\n >>> # with mlflow.start_run(run_name=\"training\"):\n >>> # learn.fit_one_cycle(30, lr, cbs=[mlflow_callback])\n >>> \n >>> # Do this (zero manual params):\n >>> callback = create_mlflow_callback(learn, experiment_name=\"Task02_Heart\")\n >>> learn.fit_one_cycle(30, lr, cbs=[callback, save_best])\n \"\"\"\n _ensure_fastmonai_tracking_uri()\n\n if _detect_patch_workflow(learn.dls):\n config = _extract_patch_config(learn)\n else:\n config = _extract_standard_config(learn)\n \n _model_name = model_name or _extract_model_name(learn)\n _loss_name = _extract_loss_name(learn)\n \n if experiment_name is None:\n experiment_name = _model_name\n \n if config['size'] is None:\n raise ValueError(\n \"Could not auto-extract 'size'. Either:\\n\"\n \"1. Add PadOrCrop to item_tfms, or\\n\"\n \"2. Use MedPatchDataLoaders with PatchConfig, or\\n\"\n \"3. Use ModelTrackingCallback directly with manual params\"\n )\n \n return ModelTrackingCallback(\n model_name=_model_name,\n loss_function=_loss_name,\n item_tfms=config['item_tfms'],\n size=config['size'],\n target_spacing=config['target_spacing'],\n apply_reorder=config['apply_reorder'],\n experiment_name=experiment_name,\n run_name=run_name,\n auto_start=auto_start,\n patch_config=config['patch_config'],\n extra_params=extra_params,\n extra_tags=extra_tags,\n dataset_version=dataset_version,\n log_split=log_split,\n )" }, + { + "cell_type": "markdown", + "id": "c5023d19", + "metadata": {}, + "source": "## Locating cross-validation fold checkpoints" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6e7ed65b", + "metadata": {}, + "outputs": [], + "source": "#| export\ndef find_fold_learners(experiment_name, artifact_path=\"model/best_learner.pkl\",\n n_folds=None, fold_tag=\"fold\"):\n \"\"\"Discover one trained learner checkpoint per cross-validation fold from an MLflow experiment.\n\n Reads back what ``ModelTrackingCallback`` logs per run: takes the most recent run for each\n fold in ``experiment_name`` and downloads ``artifact_path`` from it, returning\n ``{fold: local_path}`` sorted by fold. Folds are identified by each run's ``tags.``,\n falling back to a ``_`` run name. Warns if the selected folds span more than one\n ``dataset_version`` tag (a sign of a partial retrain).\n\n Pair it with the soft-vote ensembling in ``patch_inference`` -- but the returned values are\n PATHS, so load them first::\n\n from fastai.learner import load_learner\n paths = find_fold_learners(\"vs5f_unet\")\n learners = [load_learner(p) for p in paths.values()]\n preds = patch_inference(learner=learners, config=config, file_paths=cases)\n\n Args:\n experiment_name: MLflow experiment to search (e.g. ``\"vs5f_unet\"``).\n artifact_path: run-relative artifact to download per fold (default\n ``\"model/best_learner.pkl\"``, what ``ModelTrackingCallback`` logs).\n n_folds: optional expected folds for a missing-fold warning -- an int ``n`` (labels\n ``1..n``) or an explicit iterable of labels (use this for 0-indexed folds). ``None``\n makes no assumption and just returns what was found.\n fold_tag: run tag naming the fold; also the ``_`` run-name prefix fallback.\n\n Returns:\n ``{fold_int: local_path}`` sorted by fold, or ``{}`` if the experiment is missing or\n MLflow is unavailable.\n \"\"\"\n from mlflow.exceptions import MlflowException\n try:\n _ensure_fastmonai_tracking_uri() # respect a user-configured tracking server\n exp = mlflow.get_experiment_by_name(experiment_name)\n if exp is None:\n print(f\"No MLflow experiment {experiment_name!r}. Nothing to load.\")\n return {}\n runs = mlflow.search_runs(experiment_ids=[exp.experiment_id],\n order_by=[\"attributes.start_time DESC\"])\n except (ImportError, MlflowException) as e:\n print(f\"MLflow lookup for {experiment_name!r} skipped: {e}\")\n return {}\n\n out, fold_dsv = {}, {}\n prefix = f\"{fold_tag}_\"\n for _, row in runs.iterrows():\n fold = row.get(f\"tags.{fold_tag}\")\n # pandas yields NaN (a float, != itself), not None, when the column exists but this\n # row lacks the tag -- fall back to the run name instead of dropping the run.\n if fold is None or (isinstance(fold, float) and fold != fold):\n name = row.get(\"tags.mlflow.runName\") or \"\"\n fold = name[len(prefix):] if name.startswith(prefix) else None\n try:\n fold = int(fold)\n except (TypeError, ValueError):\n continue\n if fold in out:\n continue # newest run already kept for this fold (runs are start_time DESC)\n try:\n out[fold] = mlflow.artifacts.download_artifacts(\n run_id=row[\"run_id\"], artifact_path=artifact_path)\n except Exception:\n continue # fall through to the next-newest run for this fold\n fold_dsv[fold] = row.get(\"tags.dataset_version\")\n\n print(f\"Found fold learners for {sorted(out)} in {experiment_name!r}.\")\n if n_folds is not None:\n expected = range(1, n_folds + 1) if isinstance(n_folds, int) else n_folds\n missing = [f for f in expected if f not in out]\n if missing:\n print(f\"WARNING: no checkpoint for folds {missing}; ensemble will use {len(out)} model(s).\")\n distinct_dsv = {v for v in fold_dsv.values() if isinstance(v, str) and v}\n if len(distinct_dsv) > 1:\n shown = {f: v[:8] for f, v in sorted(fold_dsv.items()) if isinstance(v, str) and v}\n print(f\"WARNING: selected folds span multiple dataset_versions {shown} - possible partial retrain.\")\n return dict(sorted(out.items()))" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "175c00b4", + "metadata": {}, + "outputs": [], + "source": "#| hide\n# find_fold_learners: graceful {} on a missing experiment, and correct fold parsing --\n# NaN tag falls back to the run name (not dropped), 0-indexed labels, newest-run-per-fold dedup.\nfrom unittest.mock import patch, MagicMock\nimport pandas as pd\nfrom fastcore.test import test_eq\n\nwith patch.object(mlflow, \"get_experiment_by_name\", return_value=None):\n test_eq(find_fold_learners(\"no_such_experiment\"), {})\n\n_runs = pd.DataFrame([\n {\"run_id\": \"r_new0\", \"tags.fold\": \"0\", \"tags.mlflow.runName\": \"fold_0\", \"tags.dataset_version\": \"dv\"},\n {\"run_id\": \"r_old0\", \"tags.fold\": \"0\", \"tags.mlflow.runName\": \"fold_0\", \"tags.dataset_version\": \"dv\"},\n {\"run_id\": \"r_nan1\", \"tags.fold\": float(\"nan\"), \"tags.mlflow.runName\": \"fold_1\", \"tags.dataset_version\": \"dv\"},\n])\n_exp = MagicMock(); _exp.experiment_id = \"1\"\nwith patch.object(mlflow, \"get_experiment_by_name\", return_value=_exp), \\\n patch.object(mlflow, \"search_runs\", return_value=_runs), \\\n patch.object(mlflow.artifacts, \"download_artifacts\",\n side_effect=lambda run_id, artifact_path: f\"/dl/{run_id}\"):\n _got = find_fold_learners(\"exp\", n_folds=[0, 1])\ntest_eq(_got, {0: \"/dl/r_new0\", 1: \"/dl/r_nan1\"}) # dedup to newest; NaN-tag fold recovered via name" + }, { "cell_type": "code", "execution_count": null, diff --git a/nbs/10_vision_patch.ipynb b/nbs/10_vision_patch.ipynb index c8ed385..4e2fecb 100644 --- a/nbs/10_vision_patch.ipynb +++ b/nbs/10_vision_patch.ipynb @@ -197,7 +197,9 @@ " Can be int, float, or string (e.g., 'minimum', 'mean').\n", " keep_largest_component: If True, keep only the largest connected component\n", " in binary segmentation predictions. Only applies during inference when\n", - " return_probabilities=False. Defaults to False.\n", + " return_probabilities=False. Defaults to False. Binary-only: for multi-class\n", + " output (more than 2 classes) it is skipped with a warning, since it would\n", + " otherwise merge all classes into one blob and collapse the labels.\n", " binary_threshold: Decision boundary for single-channel (sigmoid) masks; a voxel is\n", " foreground when probability >= binary_threshold (matches MONAI AsDiscrete). Only\n", " applies when return_probabilities=False and n_classes == 1. Defaults to 0.5.\n", @@ -1327,29 +1329,34 @@ "def _logits_to_probs(logits: torch.Tensor) -> torch.Tensor:\n", " \"\"\"Logits -> probabilities: sigmoid for 1 channel (binary), else softmax over the channel dim.\n", "\n", - " Upcasts to float32 (logits may be float16 under AMP). Does NOT move to CPU; callers decide\n", + " Upcasts to float32 (logits may be bfloat16 under AMP). Does NOT move to CPU; callers decide\n", " placement (TTA keeps tensors on-device to flip back; the non-TTA path moves to CPU immediately).\n", " \"\"\"\n", " return torch.sigmoid(logits.float()) if logits.shape[1] == 1 else torch.softmax(logits.float(), dim=1)\n", "\n", "\n", - "def _predict_patch_tta(model, patch_input, amp_context=None) -> torch.Tensor:\n", - " \"\"\"Mirror TTA: average probabilities over the 8 flip combinations.\n", + "def _predict_patch_tta(models, patch_input, amp_context=None) -> torch.Tensor:\n", + " \"\"\"Mirror TTA (with optional model ensemble): average probabilities over the 8 flips and all models.\n", "\n", - " Uses a running sum (2x memory, not 9x). Each pass: flip -> forward -> activate -> flip back -> accumulate.\n", - " Returns the averaged probability tensor [B, C, D, H, W] on CPU. amp_context, if given, wraps the forward pass.\n", + " models is a single model or a list of models; a list soft-votes (averages probabilities).\n", + " Uses a running sum (2x memory, not 9x per model). Each pass: flip -> forward -> activate ->\n", + " flip back -> accumulate. Returns the averaged probability tensor [B, C, D, H, W] on CPU.\n", + " amp_context, if given, wraps each forward pass.\n", " \"\"\"\n", " if amp_context is None: amp_context = nullcontext()\n", + " if not isinstance(models, (list, tuple)):\n", + " models = [models]\n", " summed_probs = None\n", " for axes in _TTA_FLIP_AXES:\n", " flipped = torch.flip(patch_input, list(axes)) if axes else patch_input\n", - " with amp_context:\n", - " logits = model(flipped)\n", - " probs = _logits_to_probs(logits)\n", - " if axes:\n", - " probs = torch.flip(probs, list(axes))\n", - " summed_probs = probs if summed_probs is None else summed_probs + probs\n", - " return (summed_probs / len(_TTA_FLIP_AXES)).cpu()\n", + " for model in models:\n", + " with amp_context:\n", + " logits = model(flipped)\n", + " probs = _logits_to_probs(logits)\n", + " if axes:\n", + " probs = torch.flip(probs, list(axes))\n", + " summed_probs = probs if summed_probs is None else summed_probs + probs\n", + " return (summed_probs / (len(_TTA_FLIP_AXES) * len(models))).cpu()\n", "\n", "\n", "@dataclass\n", @@ -1371,8 +1378,10 @@ " GridAggregator to reconstruct the full volume from predictions.\n", " \n", " Args:\n", - " learner: fastai Learner or PyTorch model (nn.Module). When passing a raw\n", - " PyTorch model, load weights first with model.load_state_dict().\n", + " learner: fastai Learner or PyTorch model (nn.Module), or a list of them for\n", + " soft-vote ensembling (per-patch probabilities are averaged before argmax/\n", + " threshold). When passing a raw PyTorch model, load weights first with\n", + " model.load_state_dict().\n", " config: PatchConfig with inference settings. Preprocessing params (apply_reorder,\n", " target_spacing, padding_mode) can be set here for DRY usage.\n", " apply_reorder: Whether to reorder to RAS+ orientation. If None, uses config value.\n", @@ -1382,7 +1391,7 @@ " read from config.normalization (the default source of truth, set at training time).\n", " Provide this only to override the config (e.g. for non-serializable transforms).\n", " Accepts both fastMONAI wrappers and raw TorchIO transforms.\n", - " amp: If True, use automatic mixed precision (float16) for the forward pass.\n", + " amp: If True, use automatic mixed precision (bfloat16) for the forward pass.\n", " Only supported on CUDA devices; ignored with a warning on CPU/MPS.\n", " Defaults to False.\n", " \n", @@ -1408,10 +1417,11 @@ " \n", " # We check for Learner explicitly because some models (e.g., MONAI UNet) have a\n", " # .model attribute that is NOT the full model but an internal Sequential.\n", - " if isinstance(learner, Learner):\n", - " self.model = learner.model\n", - " else:\n", - " self.model = learner # already a PyTorch model\n", + " _learners = list(learner) if isinstance(learner, (list, tuple)) else [learner]\n", + " if len(_learners) == 0:\n", + " raise ValueError(\"learner must be a model/Learner or a non-empty list of them.\")\n", + " self.models = [l.model if isinstance(l, Learner) else l for l in _learners]\n", + " self.model = self.models[0] # first model; alias kept for single-model back-compat\n", " \n", " self.config = config\n", " self.sw_batch_size = sw_batch_size\n", @@ -1441,11 +1451,12 @@ " self._device = _get_default_device()\n", "\n", " # Set eval mode once at construction (this class is inference-only)\n", - " self.model.eval()\n", + " for _m in self.models:\n", + " _m.eval()\n", "\n", " # AMP: CUDA-only, matching nnU-Net pattern\n", " if amp and self._device.type == 'cuda':\n", - " self._amp_context = torch.amp.autocast('cuda', dtype=torch.float16)\n", + " self._amp_context = torch.amp.autocast('cuda', dtype=torch.bfloat16)\n", " else:\n", " if amp and self._device.type != 'cuda':\n", " warnings.warn(\"AMP is only supported on CUDA devices. Ignoring amp=True.\")\n", @@ -1500,9 +1511,10 @@ " )\n", "\n", " def _run_inference(self, prepared: _PreparedSubject, tta: bool = False) -> torch.Tensor:\n", - " \"\"\"Run the model over all patches and aggregate; returns the raw probability tensor.\n", + " \"\"\"Run the model(s) over all patches and aggregate; returns the raw probability tensor.\n", "\n", " Must run on the main thread (model forward pass). tta enables mirror test-time augmentation.\n", + " With multiple models the per-patch probabilities are averaged (soft voting).\n", " \"\"\"\n", " # inference_mode is slightly faster than no_grad (disables autograd tracking\n", " # and view tracking). Safe here since we don't do in-place ops on outputs.\n", @@ -1512,11 +1524,15 @@ " locations = patches_batch[tio.LOCATION]\n", "\n", " if tta:\n", - " probs = _predict_patch_tta(self.model, patch_input, self._amp_context)\n", + " probs = _predict_patch_tta(self.models, patch_input, self._amp_context)\n", " else:\n", - " with self._amp_context:\n", - " logits = self.model(patch_input)\n", - " probs = _logits_to_probs(logits).cpu()\n", + " summed = None\n", + " for _m in self.models:\n", + " with self._amp_context:\n", + " logits = _m(patch_input)\n", + " p = _logits_to_probs(logits)\n", + " summed = p if summed is None else summed + p\n", + " probs = (summed / len(self.models)).cpu()\n", "\n", " prepared.aggregator.add_batch(probs, locations)\n", "\n", @@ -1542,8 +1558,15 @@ " result = output.argmax(dim=0, keepdim=True).float()\n", "\n", " if not return_probabilities and self.config.keep_largest_component:\n", - " from fastMONAI.vision_inference import keep_largest\n", - " result = keep_largest(result.squeeze(0)).unsqueeze(0)\n", + " if n_classes > 2:\n", + " warnings.warn(\n", + " f\"keep_largest_component is binary-only; skipping it for multi-class output \"\n", + " f\"(n_classes={n_classes}). Set keep_largest_component=False for multi-class \"\n", + " f\"models, or post-process each class separately.\"\n", + " )\n", + " else:\n", + " from fastMONAI.vision_inference import keep_largest\n", + " result = keep_largest(result.squeeze(0)).unsqueeze(0)\n", "\n", " if return_probabilities:\n", " pred_img = tio.ScalarImage(tensor=result.float(), affine=prepared.input_img.affine)\n", @@ -1599,9 +1622,10 @@ " return result\n", " \n", " def to(self, device):\n", - " \"\"\"Move engine to device.\"\"\"\n", + " \"\"\"Move engine (all models) to device.\"\"\"\n", " self._device = device\n", - " self.model.to(device)\n", + " for _m in self.models:\n", + " _m.to(device)\n", " return self" ] }, @@ -1702,7 +1726,8 @@ " GPU compute use different hardware.\n", "\n", " Args:\n", - " learner: PyTorch model or fastai Learner.\n", + " learner: PyTorch model or fastai Learner, or a list of them to soft-vote\n", + " ensemble (per-patch probabilities are averaged before argmax/threshold).\n", " config: PatchConfig with inference settings. Preprocessing params (apply_reorder,\n", " target_spacing) can be set here for DRY usage.\n", " file_paths: List of image paths.\n", @@ -1720,7 +1745,7 @@ " thread for preparation and saving. Holds two subjects in memory\n", " simultaneously (current + next). Set to False for memory-constrained\n", " environments processing very large volumes.\n", - " amp: If True, use automatic mixed precision (float16) for the forward pass.\n", + " amp: If True, use automatic mixed precision (bfloat16) for the forward pass.\n", " Only supported on CUDA devices; ignored with a warning on CPU/MPS.\n", "\n", " Returns:\n", @@ -2127,6 +2152,83 @@ "test_fail(lambda: PatchConfig(patch_size=[96, 96, 96], normalization=[tio.ZNormalization()]))" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "ensemble-soft-voting-tests", + "metadata": {}, + "outputs": [], + "source": [ + "# Ensemble (soft-voting): PatchInferenceEngine and patch_inference accept a list of models.\n", + "# Per-patch probabilities are averaged, then a single argmax/threshold is applied.\n", + "import tempfile, os, nibabel as nib\n", + "from monai.networks.nets import UNet\n", + "from monai.networks.layers import Norm\n", + "\n", + "_ens_data = np.random.randn(32, 32, 32).astype(np.float32)\n", + "_ens_nii = nib.Nifti1Image(_ens_data, np.eye(4))\n", + "\n", + "def _mk_ens_unet(out_channels=2):\n", + " return UNet(spatial_dims=3, in_channels=1, out_channels=out_channels,\n", + " channels=(16, 32), strides=(2,), num_res_units=1, norm=Norm.INSTANCE).eval()\n", + "\n", + "with tempfile.TemporaryDirectory() as tmpdir:\n", + " img_path = os.path.join(tmpdir, 'ens_img.nii.gz')\n", + " nib.save(_ens_nii, img_path)\n", + "\n", + " _cfg = PatchConfig(patch_size=[32, 32, 32])\n", + " _m1 = _mk_ens_unet()\n", + "\n", + " # A single-element list is stored as .models; .model aliases the first model (back-compat)\n", + " _eng_list = PatchInferenceEngine([_m1], _cfg, apply_reorder=False)\n", + " test_eq(len(_eng_list.models), 1)\n", + " assert _eng_list.model is _m1\n", + "\n", + " # Ensembling a model with itself must equal single-model inference (identical probs -> same argmax)\n", + " _pred_single = PatchInferenceEngine(_m1, _cfg, apply_reorder=False).predict(img_path)\n", + " _pred_dup = PatchInferenceEngine([_m1, _m1], _cfg, apply_reorder=False).predict(img_path)\n", + " assert torch.equal(_pred_single, _pred_dup), \"Ensembling identical models should equal single-model output\"\n", + "\n", + " # A list of two different models runs and returns a valid label mask (labels subset of {0, 1})\n", + " _m2 = _mk_ens_unet()\n", + " _eng_ens = PatchInferenceEngine([_m1, _m2], _cfg, apply_reorder=False)\n", + " test_eq(len(_eng_ens.models), 2)\n", + " _pred_ens = _eng_ens.predict(img_path)\n", + " test_eq(_pred_ens.shape, _pred_single.shape)\n", + " assert set(torch.unique(_pred_ens).tolist()).issubset({0, 1})\n", + "\n", + " # Ensemble + TTA runs and returns the same shape\n", + " _pred_ens_tta = _eng_ens.predict(img_path, tta=True)\n", + " test_eq(_pred_ens_tta.shape, _pred_single.shape)\n", + "\n", + " # patch_inference accepts a list of models (batch ensemble)\n", + " _batch = patch_inference([_m1, _m2], _cfg, [img_path], apply_reorder=False, progress=False)\n", + " test_eq(len(_batch), 1)\n", + " test_eq(_batch[0].shape, _pred_single.shape)\n", + "\n", + " # Ensembling identical models via patch_inference equals the single-model result\n", + " _batch_dup = patch_inference([_m1, _m1], _cfg, [img_path], apply_reorder=False, progress=False)\n", + " assert torch.equal(_batch_dup[0], _pred_single)\n", + "\n", + " # Multi-class: keep_largest_component is binary-only, so it is skipped (with a warning) for >2 classes\n", + " import warnings as _warnings\n", + " _mc = _mk_ens_unet(out_channels=3)\n", + " _mc_klc = PatchConfig(patch_size=[32, 32, 32], keep_largest_component=True)\n", + " _mc_off = PatchConfig(patch_size=[32, 32, 32], keep_largest_component=False)\n", + " _p_off = PatchInferenceEngine([_mc, _mc], _mc_off, apply_reorder=False).predict(img_path)\n", + " with _warnings.catch_warnings(record=True) as _w:\n", + " _warnings.simplefilter(\"always\")\n", + " _p_klc = PatchInferenceEngine([_mc, _mc], _mc_klc, apply_reorder=False).predict(img_path)\n", + " assert torch.equal(_p_klc, _p_off), \"keep_largest_component must be skipped for multi-class output\"\n", + " assert any(\"binary-only\" in str(_wi.message) for _wi in _w), \"expected a binary-only warning for multi-class\"\n", + " assert set(torch.unique(_p_klc).tolist()).issubset({0, 1, 2})\n", + "\n", + "# An empty model list is rejected\n", + "test_fail(lambda: PatchInferenceEngine([], PatchConfig(patch_size=[32, 32, 32])))\n", + "\n", + "print(\"Ensemble (soft-voting) tests passed!\")" + ] + }, { "cell_type": "markdown", "id": "cell-19", diff --git a/nbs/12b_tutorial_patch_cross_validation.ipynb b/nbs/12b_tutorial_patch_cross_validation.ipynb index 1dca431..9d79fa9 100644 --- a/nbs/12b_tutorial_patch_cross_validation.ipynb +++ b/nbs/12b_tutorial_patch_cross_validation.ipynb @@ -440,8 +440,8 @@ "> **Choosing a fold strategy.** Plain `KFold` is fine when subjects are independent and\n", "> roughly homogeneous. Real datasets often need:\n", ">\n", - "> - **`StratifiedKFold`** to balance a property across folds, for example binning tumour volume\n", - "> into quartiles so each fold has a similar tumour-size distribution (as the VS dataset does).\n", + "> - **`StratifiedKFold`** to balance a property across folds, for example binning tumor volume\n", + "> into quartiles so each fold has a similar tumor-size distribution (as the VS dataset does).\n", "> - **`GroupKFold`** to keep all scans from the same patient in the same fold, preventing\n", "> leakage when a subject contributes multiple volumes." ] @@ -451,21 +451,7 @@ "id": "c24-fn-header", "metadata": {}, "source": [ - "### Define the per-fold train-and-evaluate function\n", - "\n", - "`train_one_fold` mirrors `research/vs_seg/patch_based_dev/train_cv.py`. For one fold it:\n", - "\n", - "1. Splits the data with `valid_col='is_val'` (the held-out fold is validation).\n", - "2. Builds a fresh UNet, loss, and `Learner` (no weights carried over between folds).\n", - "3. Trains with `EMACheckpoint` saving the best smoothed-Dice weights, logging to a per-fold MLflow run.\n", - "4. Runs full-volume `patch_inference` (with TTA) on the held-out fold and computes the metric suite.\n", - "5. Frees GPU memory before the next fold.\n", - "\n", - "`patch_inference` returns each prediction in its volume's original voxel space (it resizes back from the resampled\n", - "grid), and the ground truth is loaded natively too, so surface metrics use the per-case voxel\n", - "spacing read from the GT file (`tio.LabelMap(path).spacing`),\n", - "mirroring `train_cv.py`. The cohort spacing is non-uniform, so a single fixed spacing would give\n", - "wrong HD95/ASSD millimetres." + "### Define the per-fold train-and-evaluate function" ] }, { @@ -2865,10 +2851,7 @@ "**Part 2, final model (deploy)**\n", "\n", "5. **Train on all data**: one model on every subject (a small duplicated nominal-val subset for monitoring only).\n", - "6. **Export**: `learn.export(...)` + `store_patch_variables(...)` for inference (see [12c](12c_tutorial_patch_inference.ipynb)).\n", - "\n", - "In short: cross-validate to estimate, retrain on all data to deploy. The CV mean and std is your\n", - "reported performance; the all-data model is what you ship." + "6. **Export**: `learn.export(...)` + `store_patch_variables(...)` for inference (see [12c](12c_tutorial_patch_inference.ipynb))." ] }, { @@ -2897,6 +2880,16 @@ "source": [ "mlflow_ui.stop()" ] + }, + { + "cell_type": "markdown", + "id": "references-12b", + "metadata": {}, + "source": [ + "## References\n", + "\n", + "- Isensee, F., et al. (2021). nnU-Net: a self-configuring method for deep learning-based biomedical image segmentation. *Nature Methods*. https://doi.org/10.1038/s41592-020-01008-z" + ] } ], "metadata": { diff --git a/nbs/12c_tutorial_patch_inference.ipynb b/nbs/12c_tutorial_patch_inference.ipynb index 76bfa88..fa0a386 100644 --- a/nbs/12c_tutorial_patch_inference.ipynb +++ b/nbs/12c_tutorial_patch_inference.ipynb @@ -431,6 +431,99 @@ "print(f\"Saved to: predictions/patch_heart/\")" ] }, + { + "cell_type": "markdown", + "id": "cell-ensemble-header", + "metadata": {}, + "source": [ + "## Optional: ensemble the 5 cross-validation models\n", + "\n", + "The cross-validation notebook ([12b](12b_tutorial_patch_cross_validation.ipynb)) trains one model per fold. Instead of deploying only the single final model, you can run all five fold models together as an **ensemble** and average their predictions. This is the standard nnU-Net-style approach: it usually gives a small accuracy gain and more robust, better-calibrated masks, at the cost of running inference once per model.\n", + "\n", + "fastMONAI uses **soft voting**. Each model runs the full sliding-window pass, the per-patch class probabilities are averaged across the models, and the final label map comes from a single `argmax` on the averaged probabilities (never a vote on discrete masks). Averaging happens inside `PatchInferenceEngine`, so you just pass a **list of models** instead of one. Because each volume is preprocessed once and every model runs on the shared patches, memory and preprocessing stay close to single-model inference.\n", + "\n", + "> **Prerequisite**: this section uses the per-fold checkpoints `models/best_fold_1.pth ... best_fold_5.pth` saved by 12b's cross-validation part. Run that first if you skipped it." + ] + }, + { + "cell_type": "markdown", + "id": "cell-ensemble-load-header", + "metadata": {}, + "source": [ + "### Load the five fold models\n", + "\n", + "12b's `EMACheckpoint` saved each fold's best weights as `models/best_fold_{i}.pth` (weights only, not an exported learner). We rebuild each model from the final learner's architecture (the folds use the identical UNet) and load each fold's weights into it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-ensemble-load", + "metadata": {}, + "outputs": [], + "source": [ + "from copy import deepcopy\n", + "\n", + "# The 5 CV fold checkpoints from 12b (Part 1). EMACheckpoint saved each fold's best weights\n", + "# as models/best_fold_{i}.pth (weights only), so we rebuild each model from the final learner's\n", + "# architecture (identical UNet) and load each fold's weights into it.\n", + "fold_paths = [Path(f'models/best_fold_{i}.pth') for i in range(1, 6)]\n", + "\n", + "if all(p.exists() for p in fold_paths):\n", + " fold_models = []\n", + " for p in fold_paths:\n", + " m = deepcopy(learn.model)\n", + " m.load_state_dict(torch.load(p, map_location=device))\n", + " m.to(device).eval()\n", + " fold_models.append(m)\n", + " print(f\"Loaded {len(fold_models)} fold models for ensembling\")\n", + "else:\n", + " fold_models = None\n", + " missing = [str(p) for p in fold_paths if not p.exists()]\n", + " print(\"Fold checkpoints not found:\", missing)\n", + " print(\"Run 12b's cross-validation section first. Skipping the ensemble demo.\")\n", + "\n", + "# Alternative: load each fold's exported learner from its MLflow run instead of local weights\n", + "# (each CV fold run logs model/best_learner.pkl), mirroring the single-model MLflow option above:\n", + "# fold_models = [load_learner(fold_pkl).model for fold_pkl in fold_learner_pkls]" + ] + }, + { + "cell_type": "markdown", + "id": "cell-ensemble-run-header", + "metadata": {}, + "source": [ + "### Run the ensemble\n", + "\n", + "Pass the list of fold models to `patch_inference` (or `PatchInferenceEngine`) exactly like a single model. Predictions are written to a separate folder so they sit alongside the single-model outputs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-ensemble-run", + "metadata": {}, + "outputs": [], + "source": [ + "if fold_models is not None:\n", + " # Pass the list of models: the engine averages their softmax probabilities per patch\n", + " # (soft voting), then applies a single argmax + keep_largest_component, as in training.\n", + " ensemble_predictions = patch_inference(\n", + " fold_models,\n", + " config=patch_config,\n", + " file_paths=test_imgs,\n", + " save_dir='predictions/patch_heart_ensemble',\n", + " progress=True\n", + " )\n", + " print(f\"\\nEnsemble: generated {len(ensemble_predictions)} predictions\")\n", + " print(\"Saved to: predictions/patch_heart_ensemble/\")\n", + "\n", + " # Single-image ensemble: same call, just pass the list to PatchInferenceEngine\n", + " ensemble_engine = PatchInferenceEngine(fold_models, config=patch_config, sw_batch_size=4)\n", + " pred_ens = ensemble_engine.predict(test_imgs[0])\n", + " print(f\"Ensemble prediction shape: {pred_ens.shape}, unique values: {torch.unique(pred_ens).tolist()}\")" + ] + }, { "cell_type": "markdown", "id": "cell-visualize-header", @@ -482,24 +575,7 @@ "1. **`load_patch_variables()`**: Load the configuration saved during training so preprocessing matches exactly\n", "2. **Load the exported learner**: `load_learner('models/final_learner.pkl')` loads the all-data model exported by the cross-validation notebook, and we pass its `learn.model` to `PatchInferenceEngine`\n", "3. **`PatchInferenceEngine`**: Sliding-window inference with GridSampler and GridAggregator\n", - "4. **`patch_inference()`**: Batch inference with NIfTI output, then qualitative visualization with TorchIO's `Subject.plot`\n", - "\n", - "**Key points**:\n", - "- This is the deployment step. The final model was trained on all labeled data, so there is no held-out ground truth here and we report no metric. The generalization estimate comes from the cross-validation in the [prerequisite notebook](12b_tutorial_patch_cross_validation.ipynb).\n", - "- Preprocessing parameters (`normalization`, `apply_reorder`, `target_spacing`) are carried in the saved config and applied automatically, so they match the values used in training\n", - "- `PatchInferenceEngine` also accepts a raw PyTorch model, not only a Learner (here we pass `learn.model`)\n", - "- Hann windowing (`aggregation_mode='hann'`) produces smooth boundaries between patches\n", - "- `keep_largest_component=True` can clean up small spurious predictions\n", - "\n", - "**When to use patch-based inference**:\n", - "- Large images that don't fit in GPU memory\n", - "- Variable-sized inputs (no resizing needed)\n", - "- Memory-constrained environments\n", - "\n", - "**Tradeoffs**:\n", - "- Slower than full-image inference (multiple forward passes)\n", - "- Overlap and aggregation add computational overhead\n", - "- Enables processing of arbitrarily large volumes" + "4. **`patch_inference()`**: Batch inference with NIfTI output, then qualitative visualization with TorchIO's `Subject.plot`" ] } ], diff --git a/settings.ini b/settings.ini index 756474d..dc8fd3c 100644 --- a/settings.ini +++ b/settings.ini @@ -5,7 +5,7 @@ ### Python Library ### lib_name = fastMONAI min_python = 3.10 -version = 0.9.1 +version = 0.9.2 ### OPTIONAL ### requirements = fastai==2.8.7 monai==1.6.0 torchio==1.2.1 xlrd>=1.2.0 scikit-image==0.26.0 imagedata==3.9.6 mlflow==3.14.0 huggingface-hub gdown plum-dispatch From 3cd3a7602499655224d8bc5334d5cdcc9752ea2d Mon Sep 17 00:00:00 2001 From: Sathiesh Date: Fri, 3 Jul 2026 15:52:41 +0200 Subject: [PATCH 2/8] research: add vestibular_schwannoma 5-fold CV and ensemble inference workflow - 01_five_fold_cross_validation.ipynb: 5-fold cross-validation over UNet, DynUNet and a SegMamba fork on the patch-based workflow - 02_inference_new_cases.ipynb: 5-fold soft-vote ensemble inference on new cases - ml_dataset.csv (346 CE-T1w cases with pre-assigned folds), README, inference config JSON - gitignore: preprocessed/ and cv_results_*/ notebook run byproducts --- .gitignore | 4 + .../01_five_fold_cross_validation.ipynb | 771 ++++++++++++++++++ .../02_inference_new_cases.ipynb | 299 +++++++ research/vestibular_schwannoma/README.md | 29 + .../inference_patch_config.json | 1 + research/vestibular_schwannoma/ml_dataset.csv | 347 ++++++++ 6 files changed, 1451 insertions(+) create mode 100644 research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb create mode 100644 research/vestibular_schwannoma/02_inference_new_cases.ipynb create mode 100644 research/vestibular_schwannoma/README.md create mode 100644 research/vestibular_schwannoma/inference_patch_config.json create mode 100644 research/vestibular_schwannoma/ml_dataset.csv diff --git a/.gitignore b/.gitignore index 254d9f1..0fbd8a8 100644 --- a/.gitignore +++ b/.gitignore @@ -161,6 +161,10 @@ models/ cv_summary.csv patch_config.json +# Patch-workflow run byproducts (regenerated by the notebooks; e.g. research/*) +preprocessed/ +cv_results_*/ + # Claude - share skills/settings, keep local config private .claude/settings.local.json .claude/*.local.* diff --git a/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb b/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb new file mode 100644 index 0000000..c7433a3 --- /dev/null +++ b/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb @@ -0,0 +1,771 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "b1e2871e", + "metadata": {}, + "source": [ + "# Five-Fold Cross-Validation for Vestibular Schwannoma Segmentation\n", + "\n", + "This notebook runs 5-fold cross-validation for three 3D segmentation models on a\n", + "vestibular schwannoma (VS) MRI segmentation task, using the patch-based workflow in\n", + "fastMONAI, built on fastai, MONAI, and TorchIO.\n", + "\n", + "## The task\n", + "\n", + "Vestibular Schwannoma is a benign tumor arising from the vestibulocochlear nerve that can\n", + "cause hearing loss and brainstem compression ([Kujawa et al., 2024](https://doi.org/10.3389/fncom.2024.1365727)).\n", + "For small- to medium-sized lesions, guidelines recommend either upfront radiosurgery or\n", + "observation until radiographic tumor growth is evident ([Dhayalan et al., 2023](https://doi.org/10.1001/jama.2023.12222)).\n", + "Accurate, reproducible delineation of the tumor on Contrast-Enhanced T1-weighted (CE-T1w)\n", + "MRI supports treatment planning and volumetric follow-up. We treat it as a binary 3D\n", + "segmentation problem: for every voxel, decide tumor (1) or background (0).\n", + "\n", + "## The dataset\n", + "\n", + "We use 346 CE-T1w cases pooled from the Queen Square cohort\n", + "([Shapey et al., 2021](https://doi.org/10.1038/s41597-021-01064-w)) and the public crossMoDA\n", + "challenge data ([Dorent et al., 2023](https://doi.org/10.1016/j.media.2022.102628)). Each\n", + "case has a paired ground-truth tumor mask. The tumors are small relative to the field of\n", + "view, which is why we train on patches sampled around the lesion rather than on whole\n", + "volumes.\n", + "\n", + "## What five-fold cross-validation buys us\n", + "\n", + "Cross-validation gives a more honest estimate of how a model generalizes than a single\n", + "train/validation split. The 346 cases are partitioned into five folds. We train five times\n", + "per model; each run holds out one fold for validation and trains on the other four.\n", + "Averaging the held-out scores across the five folds uses every case for validation exactly\n", + "once and reports a mean and a spread rather than a single lucky (or unlucky) number.\n", + "\n", + "The fold assignment is fixed ahead of time in the `fold` column of the dataset CSV\n", + "(values 1..5). Using a pre-assigned column, rather than reshuffling here, means every model\n", + "sees the exact same folds, so their scores are directly comparable, and the split is\n", + "reproducible across notebooks and machines. The CSV also carries a `split` column, but the\n", + "cross-validation intentionally ignores it: all 346 cases participate in CV.\n", + "\n", + "## The three models\n", + "\n", + "- **UNet** (MONAI built-in): the classic encoder-decoder with skip connections. A strong,\n", + " well-understood convolutional baseline.\n", + "- **DynUNet** (MONAI built-in): a dynamic, nnU-Net-style UNet\n", + " ([Isensee et al., 2024](https://doi.org/10.1007/978-3-031-72114-4_47)) with residual blocks\n", + " and deep supervision (auxiliary losses at several decoder resolutions), which tends to\n", + " train more stably on medical data.\n", + "- **SegMamba** (our fork): a state-space (Mamba) backbone for 3D segmentation. It replaces\n", + " the quadratic-cost attention of a transformer with a linear-time selective scan, aiming to\n", + " capture long-range 3D context efficiently.\n", + "\n", + "All three are trained through an identical data, loss, metric, and evaluation pipeline, so\n", + "any difference in scores reflects the model rather than the plumbing.\n", + "\n", + "Despite the high accuracy reported for automated VS segmentation, its real-world utility and\n", + "computational cost remain underexplored, and an apparent performance plateau raises the\n", + "question of whether newer architectures offer practical gains over a standard CNN\n", + "([Connor et al., 2025](https://doi.org/10.5152/iao.2025.241693); [Häußler et al., 2025](https://doi.org/10.1002/lary.31979)).\n", + "Comparing a standard convolutional baseline (UNet) against an nnU-Net-style model (DynUNet)\n", + "and a state-space model (SegMamba) on the same data is a controlled way to probe that\n", + "question. References cited throughout are listed at the end of this notebook." + ] + }, + { + "cell_type": "markdown", + "id": "65895c58", + "metadata": {}, + "source": [ + "## 1. Environment setup\n", + "\n", + "We import the fastMONAI patch API, MONAI networks and losses, and the exact metric\n", + "functions used during evaluation. Everything runs in a conda environment with fastMONAI and\n", + "its dependencies installed. We `chdir` into the project folder early so that the relative\n", + "image and mask paths in the CSV (`../nii_data/...`) resolve correctly." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af9ad685", + "metadata": {}, + "outputs": [], + "source": "import os\nimport gc\nimport json\nimport traceback\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torchio as tio\n\nfrom monai.losses import DiceCELoss, DeepSupervisionLoss\nfrom monai.networks.layers import Norm\nfrom monai.networks.nets import UNet, DynUNet\n\nfrom fastMONAI.vision_all import *\n\n# SegMamba is an optional fork; UNet and DynUNet need none of it. Import it once here so a\n# missing fork is reported now, with the install command, instead of mid-sweep. (A fork that\n# imports but whose CUDA kernels are broken surfaces later, when the model is built, and is\n# caught by the driver's try/except.)\n_SEGMAMBA_INSTALL_HINT = (\n \" GPU (training): pip install 'segmamba-v2[gpu] @ git+https://github.com/skaliy/SegMamba-V2.git'\\n\"\n \" CPU (inference): pip install 'segmamba-v2[cpu] @ git+https://github.com/skaliy/SegMamba-V2.git'\"\n)\ntry:\n from models_segmamba.segmambav2 import SegMamba\n SEGMAMBA_AVAILABLE = True\nexcept ImportError as exc:\n SegMamba, SEGMAMBA_AVAILABLE = None, False\n print(f\"[segmamba] fork not available ({exc}); UNet and DynUNet still run. To enable it:\\n\"\n + _SEGMAMBA_INSTALL_HINT)\n\n# Resolve data paths against the repo root so they work wherever the kernel started\n# (VS Code launches at the workspace root; plain Jupyter, in the notebook folder).\nimport fastMONAI\nREPO_ROOT = Path(fastMONAI.__file__).resolve().parent.parent\nos.chdir(REPO_ROOT / \"research\" / \"vestibular_schwannoma\")" + }, + { + "cell_type": "markdown", + "id": "ba1a63fd", + "metadata": {}, + "source": [ + "## 2. Run configuration\n", + "\n", + "The knobs below control the size of the run. A full paper run is\n", + "`3 models x 5 folds x 500 epochs`, which takes on the order of days on a single modern GPU.\n", + "\n", + "To smoke-test the pipeline end to end in a few minutes instead, shrink the sweep, for\n", + "example:\n", + "\n", + "- `MODELS_TO_RUN = [\"unet\"]`\n", + "- `FOLDS_TO_RUN = [1]`\n", + "- `EPOCHS = 2`\n", + "\n", + "The demo exercises every stage (preprocessing, patch loading, training, sliding-window\n", + "evaluation, metric computation, aggregation) without producing publication-quality models.\n", + "`TARGET_SPACING` and `PATCH_SIZE` are part of the shared preprocessing contract and must\n", + "match the inference notebook, so they are not casual knobs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dc9dd6a5", + "metadata": {}, + "outputs": [], + "source": [ + "# =============================== RUN KNOBS ===============================\n", + "# Which models and folds to run, and for how long. Shrink these for a quick\n", + "# pipeline smoke test (see the note above); keep the defaults for a full run.\n", + "MODELS_TO_RUN = [\"unet\", \"dynunet\", \"segmamba\"]\n", + "FOLDS_TO_RUN = [1, 2, 3, 4, 5]\n", + "\n", + "EPOCHS = 500 # paper setting; set to e.g. 2 for a quick pipeline demo\n", + "BS = 4 # patches per training batch\n", + "LR = 1e-3 # maximum learning rate for fit_one_cycle\n", + "USE_TTA = True # 8-flip test-time augmentation during evaluation\n", + "# ========================================================================\n", + "\n", + "# Shared preprocessing contract. These MUST match 02_inference_new_cases.ipynb.\n", + "TARGET_SPACING = [0.4102, 0.4102, 1.5] # resample target, voxel size in mm\n", + "PATCH_SIZE = [192, 192, 48] # patch shape in voxels\n", + "\n", + "DATA_CSV = \"ml_dataset.csv\"\n", + "\n", + "# cuDNN autotuner: a fixed patch size means the fastest kernels are found once and reused.\n", + "torch.backends.cudnn.benchmark = True\n", + "\n", + "print(f\"Models: {MODELS_TO_RUN}\")\n", + "print(f\"Folds : {FOLDS_TO_RUN}\")\n", + "print(f\"Epochs: {EPOCHS} | Batch size: {BS} | LR: {LR} | TTA: {USE_TTA}\")\n", + "print(f\"Target spacing: {TARGET_SPACING} | Patch size: {PATCH_SIZE}\")" + ] + }, + { + "cell_type": "markdown", + "id": "615d402a", + "metadata": {}, + "source": [ + "## 3. Dataset and folds\n", + "\n", + "The dataset CSV lists one row per case, with the raw image and mask paths, the pre-assigned\n", + "`fold` (1..5), and a `split` column. Cross-validation defines the validation set for each\n", + "fold inside `train_one_fold` as `is_val = (fold == fold_num)`; the held-out fold is the\n", + "validation set and the other four folds are training. All 346 cases take part in every\n", + "sweep, so the `split` column is deliberately not used here." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "73034f5e", + "metadata": {}, + "outputs": [], + "source": [ + "train_df = pd.read_csv(DATA_CSV)\n", + "print(f\"Total cases: {len(train_df)}\")\n", + "\n", + "print(\"\\nCases per fold (each fold is the validation set when held out):\")\n", + "print(train_df[\"fold\"].value_counts().sort_index().to_string())\n", + "\n", + "print(\"\\n'split' column (present in the CSV but IGNORED by cross-validation):\")\n", + "print(train_df[\"split\"].value_counts().to_string())\n", + "\n", + "train_df[[\"case_id\", \"t1_img_path\", \"t1_seg_path\", \"fold\", \"split\"]].head()" + ] + }, + { + "cell_type": "markdown", + "id": "e3e3534f", + "metadata": {}, + "source": "## 4. Preprocess once to disk\n\nPreprocessing (RAS+ reorientation, resampling to `TARGET_SPACING`, and foreground-masked\nZ-normalization) is **fold-independent**: it depends only on each raw volume, not on which\nfold that volume lands in. The original per-model scripts repeated it inside every fold; we\nhoist it out and run it a single time over all 346 cases. This is faithful to the pipeline\nbut avoids four redundant passes per model.\n\n`preprocess_dataset` writes the processed volumes under `preprocessed/` and adds the columns\n`t1_img_path_preprocessed` and `t1_seg_path_preprocessed` to `train_df` in place. With\n`skip_existing=True` it is effectively idempotent: re-running reuses the cache instead of\nrecomputing, so you can safely point it at an existing `preprocessed/` folder. During\ntraining, `PatchConfig(preprocessed=True)` then skips reorder, resample, and normalization\nbecause the inputs are already prepared." + }, + { + "cell_type": "markdown", + "id": "abd10b73", + "metadata": {}, + "source": [ + "### Intensity normalization\n", + "\n", + "`ZNormalization(masking_method=\"foreground\")` applies a Z-score: it subtracts a mean and\n", + "divides by a standard deviation. These are computed from foreground voxels, meaning voxels\n", + "with intensity above zero (read from the image, not the tumor mask), and then applied to the\n", + "whole volume. Restricting the statistics to the foreground stops the large zero-valued\n", + "background from dominating them and washing out tissue contrast." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2285c0be", + "metadata": {}, + "outputs": [], + "source": [ + "# Single source of truth for pre-patch / pre-inference intensity normalization. The same\n", + "# list is reused when building the training DataLoaders and when running sliding-window\n", + "# inference, guaranteeing identical preprocessing on both sides.\n", + "pre_patch_tfms = [ZNormalization(masking_method=\"foreground\")]\n", + "\n", + "preprocess_dataset(\n", + " train_df,\n", + " img_col=\"t1_img_path\",\n", + " mask_col=\"t1_seg_path\",\n", + " output_dir=\"preprocessed\",\n", + " target_spacing=TARGET_SPACING,\n", + " transforms=pre_patch_tfms,\n", + " max_workers=32,\n", + ")\n", + "\n", + "print(\"Added columns:\", [c for c in train_df.columns if c.endswith(\"_preprocessed\")])\n", + "train_df[[\"t1_img_path\", \"t1_img_path_preprocessed\"]].head()" + ] + }, + { + "cell_type": "markdown", + "id": "23c25507", + "metadata": {}, + "source": "## 5. Shared pipeline building blocks\n\nEvery model is trained through the same two helpers, so differences in results come from the\nnetwork, not the data pipeline.\n\n`create_patch_config()` returns a `PatchConfig`, the single source of truth for how patches\nare sampled and stitched back together:\n\n- The volumes are large and the tumor is tiny, so we train on **192 x 192 x 48** patches\n rather than whole scans.\n- A **label sampler** with `label_probabilities={0: 0.2, 1: 0.8}` draws 80% of patches\n centered on tumor voxels. Without this bias the network would rarely see foreground.\n- `samples_per_volume=4`: four patches are drawn each time a volume is visited.\n- At evaluation time patches overlap by 50% and are blended with a **Hann window**\n (`aggregation_mode=\"hann\"`) for seamless reconstruction.\n- `preprocessed=True` tells the loader the volumes on disk are already reoriented, resampled,\n and normalized, so it does not repeat that work.\n- `normalization=pre_patch_tfms` records the intensity normalization (foreground\n Z-normalization) on the config as the single source of truth. The volumes on disk are\n already normalized, so training does not re-apply it under `preprocessed=True`; keeping it\n on the config documents the choice, logs it to the MLflow run, and lets inference rebuild the\n identical transform.\n- `keep_largest_component=True` post-processes predictions to the single largest connected\n region, which suits a solitary VS tumor." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "42d6caca", + "metadata": {}, + "outputs": [], + "source": "def create_patch_config():\n \"\"\"Patch sampling and aggregation settings shared by all three models.\"\"\"\n return PatchConfig(\n patch_size=PATCH_SIZE,\n samples_per_volume=4,\n sampler_type=\"label\",\n label_probabilities={0: 0.2, 1: 0.8},\n patch_overlap=0.5,\n keep_largest_component=True,\n target_spacing=TARGET_SPACING,\n preprocessed=True,\n normalization=pre_patch_tfms,\n aggregation_mode=\"hann\",\n queue_num_workers=16,\n queue_length=1200,\n )\n\n\npatch_config = create_patch_config()\npatch_config" + }, + { + "cell_type": "markdown", + "id": "318eea6e", + "metadata": {}, + "source": [ + "### GPU augmentation\n", + "\n", + "`create_gpu_augmentation()` returns a `GpuPatchAugmentation` that applies spatial and\n", + "intensity augmentation to each batch directly on the GPU, which keeps the input pipeline\n", + "from becoming the bottleneck. The transforms and probabilities follow nnU-Net conventions:\n", + "moderate random affine, anisotropy, flips, gamma, intensity scaling, noise, and blur.\n", + "Affine translations are specified in voxels, so the requested millimeter shifts are divided\n", + "by the target spacing. Augmentation is applied to the training set only." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d9aa931a", + "metadata": {}, + "outputs": [], + "source": [ + "def create_gpu_augmentation():\n", + " \"\"\"nnU-Net-inspired GPU-batched augmentation applied to training patches.\"\"\"\n", + " ts = TARGET_SPACING\n", + " return GpuPatchAugmentation(\n", + " affine={\n", + " \"scales\": (0.7, 1.4),\n", + " \"degrees\": (5, 5, 30),\n", + " \"translation\": (25 / ts[0], 25 / ts[1], 5 / ts[2]),\n", + " \"default_pad_value\": 0.0,\n", + " \"p\": 0.2,\n", + " },\n", + " anisotropy={\"axes\": (0, 1, 2), \"downsampling\": (2, 4), \"p\": 0.25},\n", + " flip={\"axes\": (0, 1, 2), \"p\": 0.5},\n", + " gamma={\"log_gamma\": (-0.3, 0.3), \"p\": 0.3},\n", + " intensity_scale={\"scale_range\": (0.75, 1.25), \"p\": 0.1},\n", + " noise={\"std\": 0.1, \"p\": 0.1},\n", + " blur={\"std\": (0.5, 1.0), \"p\": 0.2},\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "6e13dc69", + "metadata": {}, + "source": "## 6. Model definitions\n\nThe three models are registered in a single `MODELS` dictionary. Each entry provides a\n`make_model` factory, a `make_loss` factory, and the exact experiment name, checkpoint\nfilename, and results directory that the sibling inference notebook expects:\n\n| key | experiment | checkpoint | results dir |\n|-----|------------|-----------|-------------|\n| `unet` | `vs5f_unet` | `best_unet` | `cv_results_unet` |\n| `dynunet` | `vs5f_dynunet` | `best_dynunet` | `cv_results_dynunet` |\n| `segmamba` | `vs5f_segmamba` | `best_segmamba` | `cv_results_segmamba` |\n\nUNet and SegMamba use a Dice + cross-entropy loss. DynUNet emits predictions at several\ndecoder depths, so it is wrapped in `DynUNetDSAdapter` (which turns the stacked output into\nthe list that `DeepSupervisionLoss` expects) and trained with a deep-supervision loss. UNet\nand DynUNet are compiled with `torch.compile`; SegMamba is not, because it relies on custom\nCUDA kernels.\n\n### Install SegMamba\n\n**UNet and DynUNet need nothing extra** beyond fastMONAI: they are MONAI built-ins.\n\n**SegMamba requires our fork** (`models_segmamba`, imported as `from\nmodels_segmamba.segmambav2 import SegMamba`; default backend `mamba_ssm`, 138.77M\nparameters). Install it from GitHub with the `[gpu]` extra, which is the one command you\nrun for this notebook: it brings the base package plus the CUDA Mamba kernels that the\ndefault `mamba_ssm` backend needs:\n\n pip install \"segmamba-v2[gpu] @ git+https://github.com/skaliy/SegMamba-V2.git\"\n\nThe kernels (`mamba-ssm`, `causal-conv1d`) sit behind the `[gpu]` extra rather than in the\nbase install because they compile CUDA extensions and only build where the CUDA toolchain is\npresent; keeping them optional lets the package install on machines without a GPU. For a\nCPU-only box (inference/dev; CPU training is not practical) use `[cpu]` instead, which pulls\nin transformers for the `mamba_backend=\"mambamixer\"` path:\n\n pip install \"segmamba-v2[cpu] @ git+https://github.com/skaliy/SegMamba-V2.git\"\n\nCaveat: `mamba-ssm` and `causal-conv1d` are CUDA-only and are compiled against the active\ntorch build. A torch version bump can break `causal-conv1d` with an \"undefined symbol\" ABI\nerror; reinstall it against the current torch with `--no-build-isolation --force-reinstall`.\n\nSegMamba is imported once, up front, in the setup cell (section 1) inside a `try/except`. If\nthe fork is not importable, `SEGMAMBA_AVAILABLE` is set to `False`, the install command is\nprinted, and the next cell skips SegMamba while UNet and DynUNet still run. A fork that imports\nbut whose CUDA kernels are broken (the \"undefined symbol\" case above) is not caught here,\nbecause the import does not load the kernels; it surfaces later, when the model is built, where\nthe driver's per-(model, fold) `try/except` skips just that run." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1cea942f", + "metadata": {}, + "outputs": [], + "source": [ + "MODELS = {}\n", + "\n", + "def make_unet():\n", + " model = UNet(\n", + " spatial_dims=3,\n", + " in_channels=1,\n", + " out_channels=2,\n", + " channels=(64, 128, 256, 512, 1024),\n", + " strides=(2, 2, 2, 2),\n", + " num_res_units=4,\n", + " norm=Norm.INSTANCE,\n", + " act=(\"LEAKYRELU\", {\"negative_slope\": 0.01, \"inplace\": True}),\n", + " )\n", + " return torch.compile(model)\n", + "\n", + "\n", + "def make_unet_loss():\n", + " return CustomLoss(loss_func=DiceCELoss(\n", + " to_onehot_y=True, softmax=True, include_background=False, batch=True\n", + " ))\n", + "\n", + "\n", + "MODELS[\"unet\"] = dict(\n", + " make_model=make_unet, make_loss=make_unet_loss,\n", + " experiment=\"vs5f_unet\", best_fname=\"best_unet\", results_dir=\"cv_results_unet\",\n", + ")\n", + "\n", + "def make_dynunet():\n", + " dynunet = DynUNet(\n", + " spatial_dims=3,\n", + " in_channels=1,\n", + " out_channels=2,\n", + " kernel_size=[[3, 3, 3], [3, 3, 3], [3, 3, 3], [3, 3, 3], [3, 3, 3]],\n", + " strides=[[1, 1, 1], [2, 2, 2], [2, 2, 2], [2, 2, 2], [2, 2, 2]],\n", + " upsample_kernel_size=[[2, 2, 2], [2, 2, 2], [2, 2, 2], [2, 2, 2]],\n", + " filters=[64, 128, 256, 512, 1024],\n", + " res_block=True,\n", + " deep_supervision=True,\n", + " deep_supr_num=3,\n", + " )\n", + " model = DynUNetDSAdapter(dynunet)\n", + " return torch.compile(model)\n", + "\n", + "\n", + "def make_dynunet_loss():\n", + " base_loss = DiceCELoss(\n", + " to_onehot_y=True, softmax=True, include_background=False, batch=True\n", + " )\n", + " return CustomLoss(loss_func=DeepSupervisionLoss(base_loss, weight_mode=\"exp\"))\n", + "\n", + "\n", + "MODELS[\"dynunet\"] = dict(\n", + " make_model=make_dynunet, make_loss=make_dynunet_loss,\n", + " experiment=\"vs5f_dynunet\", best_fname=\"best_dynunet\", results_dir=\"cv_results_dynunet\",\n", + ")\n", + "\n", + "# SegMamba (optional fork): registered only if it imported cleanly in the setup cell. Not\n", + "# wrapped in torch.compile because it relies on custom CUDA kernels.\n", + "if SEGMAMBA_AVAILABLE:\n", + "\n", + " def make_segmamba():\n", + " return SegMamba(\n", + " in_chans=1,\n", + " out_chans=2,\n", + " depths=[2, 2, 2, 2],\n", + " feat_size=[48, 96, 192, 384],\n", + " hidden_size=768,\n", + " )\n", + "\n", + " def make_segmamba_loss():\n", + " return CustomLoss(loss_func=DiceCELoss(\n", + " to_onehot_y=True, softmax=True, include_background=False, batch=True\n", + " ))\n", + "\n", + " MODELS[\"segmamba\"] = dict(\n", + " make_model=make_segmamba, make_loss=make_segmamba_loss,\n", + " experiment=\"vs5f_segmamba\", best_fname=\"best_segmamba\",\n", + " results_dir=\"cv_results_segmamba\",\n", + " )\n", + "\n", + "print(\"Registered models:\", list(MODELS))\n", + "\n", + "# If segmamba was requested but is unavailable, note it (the setup cell already printed the\n", + "# install/fix command). A UNet/DynUNet-only run stays quiet.\n", + "if \"segmamba\" in MODELS_TO_RUN and \"segmamba\" not in MODELS:\n", + " print(\"\\n[segmamba] requested but its fork is not importable, so it is skipped \"\n", + " \"(see the install hint from the setup cell above).\")" + ] + }, + { + "cell_type": "markdown", + "id": "c0a9ce0a", + "metadata": {}, + "source": [ + "## 7. Train one fold\n", + "\n", + "`train_one_fold(model_key, fold_num, train_df, patch_config)` runs a single training job. It\n", + "marks the held-out fold as validation (`is_val = fold == fold_num`), builds the patch\n", + "DataLoaders from the **preprocessed** columns, constructs the model and loss from the\n", + "registry, and trains with `fit_one_cycle`. Training uses:\n", + "\n", + "- `AccumulatedDice(n_classes=2)` as the monitored metric (nnU-Net-style pseudo-Dice\n", + " accumulated over the whole validation set, more stable than per-batch Dice).\n", + "- `EMACheckpoint` to save the best weights by the exponential moving average of that metric,\n", + " under the per-model checkpoint name so models do not overwrite each other.\n", + "- `create_mlflow_callback` to log parameters, metrics, the split, and artifacts to the\n", + " per-model MLflow experiment, tagged with the dataset fingerprint.\n", + "- `.to_bf16()` for bfloat16 mixed-precision training.\n", + "\n", + "At the end it calls `evaluate_fold` (defined in the next section) and frees GPU memory. The\n", + "function is defined here but only executed later by the driver loop, so it can safely refer\n", + "to `evaluate_fold` from the following cell." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5384dd04", + "metadata": {}, + "outputs": [], + "source": "def train_one_fold(model_key, fold_num, train_df, patch_config):\n \"\"\"Train one model on one fold, then evaluate the held-out fold.\"\"\"\n entry = MODELS[model_key]\n print(f\"\\n{'='*60}\")\n print(f\" {model_key.upper()} | FOLD {fold_num}\")\n print(f\"{'='*60}\\n\")\n\n # Fold membership: the held-out fold becomes the validation set. All 346 cases\n # participate; the CSV 'split' column is not used here.\n train_df = train_df.copy()\n train_df[\"is_val\"] = train_df[\"fold\"] == fold_num\n\n # Content fingerprint of the label set, logged to MLflow for reproducibility.\n med_dataset = MedDataset(\n img_list=train_df.t1_seg_path.tolist(), dtype=MedMask, max_workers=32\n )\n\n gpu_aug = create_gpu_augmentation()\n\n # Normalization lives on patch_config (single source of truth). The preprocessed columns\n # are already reoriented, resampled, and Z-normalized, so preprocessed=True skips re-applying\n # it here; the config still carries the spec for logging and inference parity.\n dls = MedPatchDataLoaders.from_df(\n df=train_df,\n img_col=\"t1_img_path_preprocessed\",\n mask_col=\"t1_seg_path_preprocessed\",\n valid_col=\"is_val\",\n patch_config=patch_config,\n gpu_augmentation=gpu_aug,\n bs=BS,\n )\n\n print(f\"[{model_key} fold {fold_num}] Train: {len(dls.train.subjects_dataset)}, \"\n f\"Val: {len(dls.valid.subjects_dataset)}\")\n\n model = entry[\"make_model\"]()\n loss_func = entry[\"make_loss\"]()\n\n learn = Learner(dls, model, loss_func=loss_func,\n metrics=[AccumulatedDice(n_classes=2)]).to_bf16()\n\n save_best = EMACheckpoint(\n monitor=\"accumulated_dice\", momentum=0.9,\n comp=np.greater, fname=entry[\"best_fname\"], with_opt=False,\n )\n\n mlflow_cb = create_mlflow_callback(\n learn,\n experiment_name=entry[\"experiment\"],\n run_name=f\"fold_{fold_num}\",\n extra_tags={\"fold\": str(fold_num)},\n dataset_version=med_dataset.fingerprint,\n )\n\n learn.fit_one_cycle(EPOCHS, LR, cbs=[mlflow_cb, save_best])\n\n results_df = evaluate_fold(\n learn, patch_config, dls, pre_patch_tfms, fold_num,\n tta=USE_TTA, mlflow_cb=mlflow_cb, results_dir=Path(entry[\"results_dir\"]),\n )\n\n # Release GPU memory before the next (model, fold).\n del learn, model, dls, gpu_aug\n torch.cuda.empty_cache()\n gc.collect()\n\n return results_df" + }, + { + "cell_type": "markdown", + "id": "26832b09", + "metadata": {}, + "source": [ + "## 8. Evaluate a fold\n", + "\n", + "After training, we score the held-out fold on the **raw** validation images (not the cached\n", + "preprocessed copies), re-applying the identical `ZNormalization` through\n", + "`pre_inference_tfms`. This mirrors real inference on unseen data end to end.\n", + "\n", + "For each case we compute Dice, sensitivity, precision, lesion-detection rate, and signed\n", + "relative volume error, plus spacing-aware surface metrics (ASSD, HD95, and Normalized Surface\n", + "Dice at 0.5/1.0/2.0 mm) via `calculate_surface_metrics`. Voxel spacing is read **per case**\n", + "from the ground-truth file, because the cohort spacing is not uniform and a single latched\n", + "affine would corrupt every distance. Per-case scores go to `results.csv`, provenance to\n", + "`benchmark_meta.json`, and finite-mean aggregates to MLflow." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "02f51121", + "metadata": {}, + "outputs": [], + "source": [ + "def evaluate_fold(learn, patch_config, dls, pre_patch_tfms, fold_num, tta, mlflow_cb, results_dir):\n", + " results_dir = Path(results_dir)\n", + " fold_dir = results_dir / f\"fold_{fold_num}\"\n", + " pred_dir = fold_dir / \"predictions\"\n", + " pred_dir.mkdir(parents=True, exist_ok=True)\n", + "\n", + " learn.cuda()\n", + "\n", + " val_df = dls._valid_source_df\n", + " val_img_paths = val_df[\"t1_img_path\"].tolist()\n", + " val_mask_paths = val_df[\"t1_seg_path\"].tolist()\n", + "\n", + " print(f\"[Fold {fold_num}] Running inference on {len(val_img_paths)} validation images...\")\n", + " predictions = patch_inference(\n", + " learner=learn,\n", + " config=patch_config,\n", + " file_paths=val_img_paths,\n", + " pre_inference_tfms=pre_patch_tfms,\n", + " save_dir=str(pred_dir),\n", + " progress=True,\n", + " tta=tta,\n", + " )\n", + "\n", + " # Per-case metric table via the library helper: it reads each GT mask's data AND spacing\n", + " # from one object (so the array and its spacing can't disagree) and runs the full panel.\n", + " results_df = evaluate_segmentations(predictions, val_mask_paths,\n", + " case_ids=val_df[\"case_id\"].tolist())\n", + " results_df.insert(1, \"image\", [Path(p).name for p in val_img_paths])\n", + " if results_df[\"spacing_mm\"].astype(str).nunique() == 1:\n", + " print(f\"[Fold {fold_num}] WARNING: spacing_mm is constant across the fold \"\n", + " f\"({results_df['spacing_mm'].iloc[0]}); verify per-case derivation if unexpected.\")\n", + " results_df.to_csv(fold_dir / \"results.csv\", index=False)\n", + "\n", + " # Benchmark provenance (arxiv:2410.02630 transparency): metric implementation,\n", + " # NSD tolerances, and per-case spacing/empty-status used for the surface metrics.\n", + " from fastMONAI.vision_metrics import _SURFACE_DISTANCE_SOURCE\n", + " with open(fold_dir / \"benchmark_meta.json\", \"w\") as f:\n", + " json.dump({\n", + " \"surface_distance_source\": _SURFACE_DISTANCE_SOURCE,\n", + " \"nsd_tolerances_mm\": [0.5, 1.0, 2.0], # evaluate_segmentations defaults\n", + " \"nsd_headline_tau_mm\": 1.0,\n", + " \"status_counts\": results_df[\"surface_status\"].value_counts().to_dict(),\n", + " \"per_case\": [{\"case_id\": r[\"case_id\"], \"spacing_mm\": list(r[\"spacing_mm\"]),\n", + " \"surface_status\": r[\"surface_status\"]}\n", + " for r in results_df.to_dict(\"records\")],\n", + " }, f, indent=2)\n", + "\n", + " # inf-safe means: one_empty cases score assd_mm/hd95_mm = inf, which would poison a plain\n", + " # mean (and MLflow rejects non-finite values). Replacing inf with NaN lets pandas skip it.\n", + " numeric = results_df.select_dtypes(include=\"number\").replace([np.inf, -np.inf], np.nan)\n", + " mlflow_cb.log_metrics_table(results_df, display=False)\n", + " mlflow_cb.log_metrics({f\"val_{m}\": numeric[m].mean() for m in numeric.columns})\n", + " mlflow_cb.log_dataframe(results_df)\n", + "\n", + " print(f\"[Fold {fold_num}] Results saved to {fold_dir / 'results.csv'}\")\n", + " print(f\"[Fold {fold_num}] DSC: {results_df['dsc'].mean():.4f} +/- {results_df['dsc'].std():.4f}\")\n", + " return results_df" + ] + }, + { + "cell_type": "markdown", + "id": "f7261d2c", + "metadata": {}, + "source": [ + "## 9. Cross-validation driver\n", + "\n", + "The driver sweeps `MODELS_TO_RUN x FOLDS_TO_RUN`, calling `train_one_fold` for each pair.\n", + "Each pair is wrapped in try/except: if one job fails (for example an out-of-memory error on\n", + "one fold) the error is printed and the sweep continues with the next pair rather than\n", + "aborting the whole run. Unregistered model keys (for example `segmamba` without the fork)\n", + "are skipped with a message.\n", + "\n", + "This is the long-running cell. For the full paper configuration it trains up to 15 models\n", + "and can take days. Reduce the knobs in the configuration cell for a quick demo." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d7593854", + "metadata": {}, + "outputs": [], + "source": [ + "# Resilient sweep: a failure on one (model, fold) is logged and skipped so it does not abort\n", + "# the rest of the run. Re-running is safe; a completed fold simply overwrites its outputs.\n", + "for model_key in MODELS_TO_RUN:\n", + " if model_key not in MODELS:\n", + " print(f\"[skip] '{model_key}' is not registered (see the model definitions cell above).\")\n", + " continue\n", + "\n", + " Path(MODELS[model_key][\"results_dir\"]).mkdir(parents=True, exist_ok=True)\n", + "\n", + " for fold_num in FOLDS_TO_RUN:\n", + " try:\n", + " train_one_fold(model_key, fold_num, train_df, patch_config)\n", + " except Exception as exc:\n", + " print(f\"[FAILED] {model_key} fold {fold_num}: {type(exc).__name__}: {exc}\")\n", + " traceback.print_exc()\n", + " torch.cuda.empty_cache()\n", + " gc.collect()\n", + " continue\n", + "\n", + "print(\"\\nSweep complete.\")" + ] + }, + { + "cell_type": "markdown", + "id": "69415c7b", + "metadata": {}, + "source": [ + "## 10. Aggregate per-model results\n", + "\n", + "For each model, `aggregate_results` concatenates the per-fold `results.csv` files into a\n", + "single `cv_summary.csv` inside that model's results directory, then prints a per-metric\n", + "mean/standard-deviation summary and the per-fold DSC breakdown. Surface distances in\n", + "millimeters (ASSD, HD95) are averaged over **finite values only**: a case where exactly one\n", + "of prediction or ground truth is empty scores `+inf` by design, and a plain mean would be\n", + "dominated by it." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a9529920", + "metadata": {}, + "outputs": [], + "source": [ + "def aggregate_results(results_dir):\n", + " \"\"\"Concatenate a model's per-fold results.csv into cv_summary.csv and print a summary.\n", + "\n", + " Returns the combined DataFrame, or None if no fold results exist yet (notebook-safe:\n", + " it does not exit the process).\n", + " \"\"\"\n", + " results_dir = Path(results_dir)\n", + " all_results = []\n", + " for fold_dir in sorted(results_dir.glob(\"fold_*\")):\n", + " csv_path = fold_dir / \"results.csv\"\n", + " if csv_path.exists():\n", + " df = pd.read_csv(csv_path)\n", + " df[\"fold\"] = int(fold_dir.name.split(\"_\")[1])\n", + " all_results.append(df)\n", + "\n", + " if not all_results:\n", + " print(f\"No fold results found in {results_dir}. Run training first.\")\n", + " return None\n", + "\n", + " combined = pd.concat(all_results, ignore_index=True)\n", + " combined.to_csv(results_dir / \"cv_summary.csv\", index=False)\n", + "\n", + " metrics = [\"dsc\", \"sensitivity\", \"precision\", \"ldr\", \"rve\",\n", + " \"assd_mm\", \"hd95_mm\", \"nsd_tau0.5_mm\", \"nsd_tau1.0_mm\", \"nsd_tau2.0_mm\"]\n", + " inf_excluded = {\"assd_mm\", \"hd95_mm\"} # one_empty -> inf must not poison the mean\n", + " print(f\"\\n{'='*60}\")\n", + " print(f\" CROSS-VALIDATION SUMMARY: {results_dir.name}\")\n", + " print(f\"{'='*60}\")\n", + " print(f\" Folds completed: {sorted(combined['fold'].unique())}\")\n", + " print(f\" Total subjects: {len(combined)}\\n\")\n", + " print(f\" {'Metric':<15} {'Mean':>10} {'Std':>10}\")\n", + " print(f\" {'-'*35}\")\n", + " for m in metrics:\n", + " if m not in combined.columns:\n", + " continue\n", + " col = combined[m]\n", + " note = \"\"\n", + " if m in inf_excluded:\n", + " mask = np.isfinite(col)\n", + " n_skip = int((~mask).sum())\n", + " col = col[mask]\n", + " if n_skip:\n", + " note = f\" ({n_skip} non-finite excl.)\"\n", + " print(f\" {m:<15} {col.mean():>10.4f} {col.std():>10.4f}{note}\")\n", + "\n", + " if \"surface_status\" in combined.columns:\n", + " print(f\"\\n Surface-metric status counts:\")\n", + " for status, n in combined[\"surface_status\"].value_counts().items():\n", + " print(f\" {status:<12} {n}\")\n", + "\n", + " print(f\"\\n Per-fold DSC:\")\n", + " for fold_num, group in combined.groupby(\"fold\"):\n", + " print(f\" Fold {fold_num}: {group['dsc'].mean():.4f} +/- {group['dsc'].std():.4f}\")\n", + "\n", + " print(f\"\\n Results saved to {results_dir / 'cv_summary.csv'}\")\n", + " return combined" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f3f1952b", + "metadata": {}, + "outputs": [], + "source": [ + "# Aggregate each model that ran; collect the combined per-case tables for the comparison.\n", + "cv_combined = {}\n", + "for model_key in MODELS_TO_RUN:\n", + " entry = MODELS.get(model_key)\n", + " if entry is None:\n", + " print(f\"[skip] '{model_key}' is not registered.\")\n", + " continue\n", + " combined = aggregate_results(Path(entry[\"results_dir\"]))\n", + " if combined is not None:\n", + " cv_combined[model_key] = combined" + ] + }, + { + "cell_type": "markdown", + "id": "42ffb314", + "metadata": {}, + "source": [ + "## 11. Cross-model comparison\n", + "\n", + "This is the headline table and it is net-new relative to the per-model scripts. For every\n", + "model that produced results, we pool all held-out cases across folds and report the mean and\n", + "standard deviation of each key metric, save the numbers to `cv_model_comparison.csv`, and\n", + "display a compact `mean +/- std` view. The same inf-safe averaging is applied here, so the\n", + "millimeter surface metrics ignore non-finite per-case scores (and NaN, e.g. RVE on an empty\n", + "ground truth, is skipped the same way)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "483d623d", + "metadata": {}, + "outputs": [], + "source": [ + "# Headline metrics for the cross-model table. Surface distances in mm (assd/hd95) can be\n", + "# +inf for 'one_empty' cases (exactly one of prediction/ground truth empty). pandas .mean()/\n", + "# .std() already skip NaN but not inf, so we replace inf with NaN first; the finite-only mean\n", + "# and sample std (ddof=1, the pandas default) then match the per-model summary above.\n", + "KEY_METRICS = [\"dsc\", \"sensitivity\", \"precision\", \"ldr\", \"rve\",\n", + " \"assd_mm\", \"hd95_mm\", \"nsd_tau1.0_mm\"]\n", + "\n", + "rows = []\n", + "for model_key, combined in cv_combined.items():\n", + " row = {\"model\": model_key,\n", + " \"folds\": sorted(combined[\"fold\"].unique().tolist()),\n", + " \"n_cases\": int(len(combined))}\n", + " for m in KEY_METRICS:\n", + " if m in combined.columns:\n", + " finite = combined[m].replace([np.inf, -np.inf], np.nan) # drop inf; pandas skips NaN\n", + " row[f\"{m}_mean\"] = float(finite.mean())\n", + " row[f\"{m}_std\"] = float(finite.std())\n", + " rows.append(row)\n", + "\n", + "if rows:\n", + " comparison_df = pd.DataFrame(rows).set_index(\"model\")\n", + " comparison_df.to_csv(\"cv_model_comparison.csv\")\n", + " print(\"Saved cross-model comparison to cv_model_comparison.csv\\n\")\n", + "\n", + " # Compact 'mean +/- std' view of the headline metrics.\n", + " pretty = pd.DataFrame(index=comparison_df.index)\n", + " for m in KEY_METRICS:\n", + " mc, sc = f\"{m}_mean\", f\"{m}_std\"\n", + " if mc in comparison_df.columns:\n", + " pretty[m] = [f\"{mu:.4f} +/- {sd:.4f}\"\n", + " for mu, sd in zip(comparison_df[mc], comparison_df[sc])]\n", + " display(pretty)\n", + "else:\n", + " print(\"No per-model results available yet. Run the driver loop first.\")" + ] + }, + { + "cell_type": "markdown", + "id": "4b709ef9", + "metadata": {}, + "source": [ + "## 12. Viewing runs and next steps\n", + "\n", + "Every fold logs to MLflow (metrics, parameters, the train/validation split, and model\n", + "artifacts) under the per-model experiment names `vs5f_unet`, `vs5f_dynunet`, and\n", + "`vs5f_segmamba`. To browse them, launch the fastMONAI MLflow UI from a notebook cell:\n", + "\n", + " mlflow_ui = MLflowUIManager()\n", + " mlflow_ui.start_ui() # opens http://localhost:5001\n", + "\n", + "The UI is tied to the kernel and is reaped when the interpreter exits, so restarting the\n", + "notebook leaves no orphaned server holding the port.\n", + "\n", + "Artifacts written to disk:\n", + "\n", + "- `preprocessed/` - reoriented, resampled, normalized volumes (shared by all folds).\n", + "- `cv_results_/fold_N/` - per-fold `results.csv`, `benchmark_meta.json`, and NIfTI\n", + " predictions.\n", + "- `cv_results_/cv_summary.csv` - all folds concatenated for one model.\n", + "- `cv_model_comparison.csv` - the headline mean +/- std table across models.\n", + "\n", + "The best checkpoint for each model is saved by `EMACheckpoint` as `best_unet`,\n", + "`best_dynunet`, and `best_segmamba`. To run any trained model on brand-new cases, see the\n", + "sibling notebook `02_inference_new_cases.ipynb`, which reuses the same `TARGET_SPACING`,\n", + "`PATCH_SIZE`, and `ZNormalization(masking_method=\"foreground\")` contract so that inference\n", + "preprocessing matches training exactly.\n", + "\n", + "To deploy the five folds together as a soft-vote ensemble, pass the list of fold learners to `patch_inference` (or `PatchInferenceEngine`): it averages their per-patch probabilities in a single sliding-window pass, which usually beats any single fold. Notebook 02 does exactly this." + ] + }, + { + "cell_type": "markdown", + "id": "6099e9bd", + "metadata": {}, + "source": [ + "## References\n", + "\n", + "- Connor, S., et al. (2025). The Real-World Impact of Vestibular Schwannoma Fully Automated Volume Measures on the Evaluation of Size Change and Clinical Management Outcomes in a Multidisciplinary Meeting Setting. *Journal of International Advanced Otology*. https://doi.org/10.5152/iao.2025.241693\n", + "- Dhayalan, D., et al. (2023). Upfront Radiosurgery vs a Wait-and-Scan Approach for Small- or Medium-Sized Vestibular Schwannoma: The V-REX Randomized Clinical Trial. *JAMA*. https://doi.org/10.1001/jama.2023.12222\n", + "- Dorent, R., et al. (2023). CrossMoDA 2021 challenge: Benchmark of cross-modality domain adaptation techniques for vestibular schwannoma and cochlea segmentation. *Medical Image Analysis*. https://doi.org/10.1016/j.media.2022.102628\n", + "- Häußler, S. M., et al. (2025). Automatic Segmentation of Vestibular Schwannoma From MRI Using Two Cascaded Deep Learning Networks. *The Laryngoscope*. https://doi.org/10.1002/lary.31979\n", + "- Isensee, F., et al. (2024). nnU-Net Revisited: A Call for Rigorous Validation in 3D Medical Image Segmentation. *MICCAI 2024*. https://doi.org/10.1007/978-3-031-72114-4_47\n", + "- Kujawa, A., et al. (2024). Deep learning for automatic segmentation of vestibular schwannoma: a retrospective study from multi-center routine MRI. *Frontiers in Computational Neuroscience*. https://doi.org/10.3389/fncom.2024.1365727\n", + "- Shapey, J., et al. (2021). Segmentation of vestibular schwannoma from MRI, an open annotated dataset and baseline algorithm. *Scientific Data*. https://doi.org/10.1038/s41597-021-01064-w" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/research/vestibular_schwannoma/02_inference_new_cases.ipynb b/research/vestibular_schwannoma/02_inference_new_cases.ipynb new file mode 100644 index 0000000..3791831 --- /dev/null +++ b/research/vestibular_schwannoma/02_inference_new_cases.ipynb @@ -0,0 +1,299 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cfd2f9a8", + "metadata": {}, + "source": "# Inference on New Vestibular Schwannoma Cases\n\nThis notebook segments new vestibular schwannoma (VS) MRI scans using the models trained in `01_five_fold_cross_validation.ipynb` and saves the predicted tumor masks as NIfTI files. The audience is students and researchers, so every step is explained.\n\nIt runs a **5-fold soft-voting ensemble**: all five cross-validation models for the chosen architecture are run on the scan, their class probabilities are averaged, and the averaged map is decoded into a mask. This is the standard way to deploy a cross-validation and usually beats any single fold.\n\n## What this notebook does\n\n1. Locates the five fold checkpoints for a model (UNet, DynUNet, or SegMamba) trained in notebook 01.\n2. Rebuilds the exact preprocessing used at training time.\n3. Runs sliding-window patch inference with each fold and averages the probabilities (soft voting).\n4. Saves and visualizes the predicted mask, and optionally scores it against ground truth.\n\n## Sliding-window patch inference\n\nVS scans are large 3D volumes, too big to feed to a 3D network in one piece. Instead we use **patch-based inference**:\n\n- A `GridSampler` slides a fixed-size window (the patch) across the volume with overlap, producing many small patches.\n- The network predicts on each patch.\n- A `GridAggregator` stitches the patch predictions back into a full-volume prediction. We use **Hann-window** aggregation, which weights each patch by a smooth cosine taper so overlapping patches blend without visible seams at the patch borders.\n\nfastMONAI wraps this in `PatchInferenceEngine` and the `patch_inference` helper.\n\n## Soft-voting ensemble\n\nA 5-fold cross-validation produces five models, each trained on a different 4/5 of the data. To turn them into one predictor we combine them by **soft voting**: run all five, apply softmax to each output, average the five probability maps, and only then take the argmax. Averaging probabilities (not the decoded masks) is the correct way to combine multi-class segmentation models, and it gives a small, reliable accuracy gain over any single fold. The engine returns each fold's probability map already resampled and reoriented into the input's original space, so the five maps are voxel-aligned and can be averaged directly.\n\n## The one rule that matters most: preprocessing parity\n\nA segmentation model produces correct output only when the data it sees at inference is preprocessed **identically** to the data it saw during training. Three settings must match exactly:\n\n- **`apply_reorder`**: reorientation to RAS+ canonical orientation.\n- **`target_spacing`**: the voxel spacing the volume is resampled to.\n- **`normalization`**: the intensity normalization (here, foreground Z-normalization).\n\nA mismatch does not raise an error. It silently produces wrong predictions, for example masks that are shifted, mirrored, or rotated. This notebook reuses the same `PatchConfig` and normalization as training so the two pipelines stay in lockstep." + }, + { + "cell_type": "markdown", + "id": "106beda7", + "metadata": {}, + "source": [ + "## 1. Environment setup\n", + "\n", + "We import the fastMONAI public API and pin the working directory to the project folder so that the relative dataset paths used here (for example `../nii_data/...`) resolve the same way they do during training.\n", + "\n", + "`from fastMONAI.vision_all import *` brings in everything we need for inference: `PatchConfig`, `patch_inference`, `ZNormalization`, `store_patch_variables` / `load_patch_variables`, `MedImage`, `MedMask`, and the metric functions. `load_learner` comes from fastai and is used to load an exported model." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "44c21482", + "metadata": {}, + "outputs": [], + "source": "import os\nfrom pathlib import Path\n\nimport numpy as np\nimport torch\nimport torchio as tio\n\nfrom fastMONAI.vision_all import *\nfrom fastai.learner import load_learner\n\n# Resolve data paths against the repo root so they work wherever the kernel started\n# (VS Code launches at the workspace root; plain Jupyter, in the notebook folder).\nimport fastMONAI\nREPO_ROOT = Path(fastMONAI.__file__).resolve().parent.parent\nos.chdir(REPO_ROOT / \"research\" / \"vestibular_schwannoma\")\n\nimport fastai, monai # fastMONAI already imported above\nprint(\"fastMONAI:\", fastMONAI.__version__)\nprint(\"fastai: \", fastai.__version__)\nprint(\"MONAI: \", monai.__version__)\nprint(\"TorchIO: \", tio.__version__)\nprint(\"PyTorch: \", torch.__version__)\nprint(\"CUDA available:\", torch.cuda.is_available())\nif torch.cuda.is_available():\n print(\"GPU:\", torch.cuda.get_device_name(0))" + }, + { + "cell_type": "markdown", + "id": "a29cda31", + "metadata": {}, + "source": "## 2. Configuration\n\nEverything you would normally change lives in the cell below. Treat this notebook as a **template**: pick the architecture with `MODEL_KEY`, list the scans in `NEW_CASES`, and run top to bottom. The notebook always soft-votes the five cross-validation folds for the chosen model.\n\n| Knob | Meaning |\n| --- | --- |\n| `MODEL_KEY` | Which architecture to load: `\"unet\"`, `\"dynunet\"`, or `\"segmamba\"`. Must match a trained CV experiment. |\n| `FOLD_LEARNER_PATHS` | Optional `{fold: path/to/best_learner.pkl}`. Leave empty to auto-discover the five folds from MLflow. |\n| `NEW_CASES` | List of NIfTI image paths to segment. These are **raw** scans; the pipeline preprocesses them for you. |\n| `OUTPUT_DIR` | Where predicted masks are written as NIfTI. |\n| `USE_TTA` | 8-flip test-time augmentation. More robust, roughly 8x slower. |\n| `USE_AMP` | Automatic mixed precision (float16) forward pass. Faster on CUDA, ignored on CPU. |\n\n`TARGET_SPACING` and `PATCH_SIZE` are part of the shared contract with training and must not be changed independently of notebook 01." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e2b4255", + "metadata": {}, + "outputs": [], + "source": "# --- Which model ---------------------------------------------------------------\nMODEL_KEY = \"unet\" # \"unet\" | \"dynunet\" | \"segmamba\" (must match a trained CV run)\n\n# Ensemble checkpoints: {fold_number: path/to/best_learner.pkl}. Leave empty to\n# auto-discover the five folds from the MLflow experiment \"vs5f_\" below.\nFOLD_LEARNER_PATHS = {}\n\n# --- What to segment and where to write it -------------------------------------\nNEW_CASES = [\n \"../nii_data/queen_square_data/vs_gk_1/vs_gk_1_t1_refT1.nii.gz\",\n]\nOUTPUT_DIR = \"inference_predictions\"\n\n# --- Inference options ---------------------------------------------------------\nUSE_TTA = True # 8-flip test-time augmentation (more robust, ~8x slower)\nUSE_AMP = True # mixed precision on CUDA (ignored on CPU)\n\n# --- Shared contract with training notebook 01 (do not change in isolation) ----\nTARGET_SPACING = [0.4102, 0.4102, 1.5]\nPATCH_SIZE = [192, 192, 48]\n\nprint(f\"Model: {MODEL_KEY} | mode: 5-fold soft-voting ensemble\")\nprint(f\"Cases to segment: {len(NEW_CASES)}\")" + }, + { + "cell_type": "markdown", + "id": "e4287608", + "metadata": {}, + "source": "### Locate the fold checkpoints\n\nNotebook 01 logs `best_learner.pkl` as an MLflow artifact for every fold, under experiments named `vs5f_unet`, `vs5f_dynunet`, and `vs5f_segmamba` (one run per fold, tagged with its fold number). The helper below finds one checkpoint per fold for `MODEL_KEY` and returns `{fold: local_path}`. It is best-effort and never raises, so it is safe to run before any training exists.\n\nIf you already have the paths, set `FOLD_LEARNER_PATHS` in the configuration cell and this lookup is skipped." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "712e4005", + "metadata": {}, + "outputs": [], + "source": "# find_fold_learners now lives in the library (fastMONAI.utils) and is imported via\n# `from fastMONAI.vision_all import *` above; the experiment name is passed explicitly\n# (it also respects a user-configured MLflow tracking server, unlike the old inline copy).\n\nif not FOLD_LEARNER_PATHS:\n FOLD_LEARNER_PATHS = find_fold_learners(f\"vs5f_{MODEL_KEY}\")\n\nprint(\"Fold checkpoints:\", {k: str(v) for k, v in sorted(FOLD_LEARNER_PATHS.items())} or \"(none)\")" + }, + { + "cell_type": "markdown", + "id": "b59bde21", + "metadata": {}, + "source": [ + "## 3. Build the inference configuration\n", + "\n", + "`PatchConfig` is the single object that describes how patches are sampled, how they are aggregated, and (critically) how the raw input is preprocessed. We build the **exact same** config that training used. The values below are the shared contract with notebook 01.\n", + "\n", + "A few points worth understanding:\n", + "\n", + "- **`preprocessed=True`** only affects *training* (it tells the training pipeline that the data on disk was already reordered, resampled, and normalized, so it should not do it again). At *inference* the engine always reorders, resamples, and normalizes the raw input for you, so we point it at the original scans.\n", + "- **`patch_overlap=0.5`** means neighboring windows overlap by half a patch. More overlap gives smoother, more accurate borders at the cost of speed.\n", + "- **`aggregation_mode=\"hann\"`** blends overlapping patches with a Hann taper (smoothest transitions).\n", + "- **`keep_largest_component=True`** keeps only the largest connected foreground blob, a simple and effective post-processing step for a single-tumor task like VS.\n", + "\n", + "The pre-inference normalization is defined separately as `pre_inference_tfms` and passed to `patch_inference`. It must be the same transform training used: foreground `ZNormalization`. Passing it explicitly makes the parity obvious and overrides whatever is stored on the config." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "25f30026", + "metadata": {}, + "outputs": [], + "source": [ + "# Pre-inference intensity normalization. MUST match training exactly.\n", + "pre_inference_tfms = [ZNormalization(masking_method=\"foreground\")]\n", + "\n", + "# The same PatchConfig used for training in notebook 01.\n", + "patch_config = PatchConfig(\n", + " patch_size=PATCH_SIZE,\n", + " samples_per_volume=4,\n", + " sampler_type=\"label\",\n", + " label_probabilities={0: 0.2, 1: 0.8},\n", + " patch_overlap=0.5,\n", + " keep_largest_component=True,\n", + " target_spacing=TARGET_SPACING,\n", + " preprocessed=True, # only affects training; inference always preprocesses raw input\n", + " aggregation_mode=\"hann\",\n", + " queue_num_workers=16,\n", + " queue_length=1200,\n", + ")\n", + "print(patch_config)" + ] + }, + { + "cell_type": "markdown", + "id": "aec89045", + "metadata": {}, + "source": [ + "### Persisting and reloading the config\n", + "\n", + "To guarantee parity across machines and over time, the patch config can be written to a small JSON file with `store_patch_variables(...)` and read back with `load_patch_variables(...)`. The JSON captures `patch_size`, `target_spacing`, `apply_reorder`, the sampler settings, and the `normalization` spec, so an inference run can reconstruct the training config without re-typing it. This is the recommended way to ship a config alongside a checkpoint.\n", + "\n", + "The cell below writes the current config out and reads it back to confirm the round-trip. An inference run on another machine would simply start from `load_patch_variables(...)`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "39b66065", + "metadata": {}, + "outputs": [], + "source": [ + "CONFIG_JSON = \"inference_patch_config.json\"\n", + "\n", + "# Coerce the normalization transforms to a JSON-serializable spec. PatchConfig does this\n", + "# coercion in __post_init__, so we build a throwaway config to obtain the spec list.\n", + "norm_specs = PatchConfig(patch_size=PATCH_SIZE, normalization=pre_inference_tfms).normalization\n", + "\n", + "# Persist the config so any inference run can reconstruct it verbatim.\n", + "store_patch_variables(\n", + " CONFIG_JSON,\n", + " patch_size=patch_config.patch_size,\n", + " patch_overlap=patch_config.patch_overlap,\n", + " aggregation_mode=patch_config.aggregation_mode,\n", + " apply_reorder=patch_config.apply_reorder,\n", + " target_spacing=patch_config.target_spacing,\n", + " sampler_type=patch_config.sampler_type,\n", + " label_probabilities=patch_config.label_probabilities,\n", + " samples_per_volume=patch_config.samples_per_volume,\n", + " queue_length=patch_config.queue_length,\n", + " queue_num_workers=patch_config.queue_num_workers,\n", + " keep_largest_component=patch_config.keep_largest_component,\n", + " normalization=norm_specs,\n", + ")\n", + "\n", + "# Read it back to confirm the round-trip (inference elsewhere would start here).\n", + "reloaded = load_patch_variables(CONFIG_JSON)\n", + "print(\"Persisted config to\", CONFIG_JSON)\n", + "print(\"target_spacing:\", reloaded[\"target_spacing\"])\n", + "print(\"patch_size: \", reloaded[\"patch_size\"])\n", + "print(\"normalization: \", reloaded[\"normalization\"])" + ] + }, + { + "cell_type": "markdown", + "id": "20f7b65d", + "metadata": {}, + "source": "## 4. Load the models\n\nWe load the five fold learners for `MODEL_KEY` into a list. Each exported learner carries its own trained weights and preprocessing, so no architecture code is needed. `load_learner` uses Python `pickle`, which can execute arbitrary code, so only load checkpoints you trust.\n\nThe list of learners is what the inference engine soft-votes in the next section." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4ce981f2", + "metadata": {}, + "outputs": [], + "source": "def _load_learner(pkl_path):\n \"\"\"Load an exported fastai learner onto GPU if available, else CPU; set eval mode.\"\"\"\n learn = load_learner(pkl_path, cpu=not torch.cuda.is_available())\n learn.model.eval()\n return learn\n\n\n# Load the five fold learners for MODEL_KEY into a list. Each exported learner carries its\n# own trained weights and preprocessing, so no architecture code is needed here. load_learner\n# uses Python pickle, which can execute arbitrary code, so only load checkpoints you trust.\nassert FOLD_LEARNER_PATHS, (\n \"No fold checkpoints found. Train the folds with notebook 01, or set \"\n \"FOLD_LEARNER_PATHS = {1: '.../best_learner.pkl', ...} in the config cell.\")\n\npredictors = []\nfor fold in sorted(FOLD_LEARNER_PATHS):\n predictors.append(_load_learner(FOLD_LEARNER_PATHS[fold]))\n print(f\"Loaded fold {fold}: {FOLD_LEARNER_PATHS[fold]}\")\nprint(f\"Ensemble ready: {len(predictors)} fold model(s) for '{MODEL_KEY}'.\")" + }, + { + "cell_type": "markdown", + "id": "ceaa05d1", + "metadata": {}, + "source": "### A note on SegMamba\n\nSegMamba is not part of MONAI; it comes from our fork **skaliy/SegMamba-V2**. Install it into this environment before selecting `MODEL_KEY = \"segmamba\"`:\n\n```bash\npip install -e /home/sathiesh/ml_projects/SegMamba-V2 # base (import path: models_segmamba)\npip install -e \"/home/sathiesh/ml_projects/SegMamba-V2[gpu]\" # adds mamba-ssm + causal-conv1d for CUDA\n```\n\nThe model is imported as `from models_segmamba.segmambav2 import SegMamba`. It supports two Mamba backends:\n\n- **`mamba_backend=\"mamba_ssm\"`** (default): the CUDA kernel used for training and fast GPU inference.\n- **`mamba_backend=\"mambamixer\"`**: a pure-PyTorch backend (needs `transformers`) that runs on CPU. It is weight-compatible with a `mamba_ssm`-trained checkpoint, so the same `.pth` loads with `strict=True` and no retraining.\n\nUNet and DynUNet need no fork and run on CPU or GPU as-is. One environment caveat: `causal-conv1d` and `mamba-ssm` must be compiled against the active PyTorch build, otherwise the import fails with an \"undefined symbol\" ABI error. On CPU we sidestep this with a small `_force_cpu_mamba_backend()` helper (defined in the section 8 cell below, mirroring `infer_segmamba_cpu.py`) that tells `transformers` not to probe the CUDA kernels. Section 8 shows the CPU path end to end." + }, + { + "cell_type": "markdown", + "id": "37ea9e87", + "metadata": {}, + "source": "## 5. Run inference\n\nEnsembling is built into fastMONAI: pass a **list of learners** to `patch_inference` and it\nsoft-votes them, averaging each patch's class probabilities across the models before taking the\nargmax, all in a single sliding-window pass. We pass the list of fold learners as\n`learner = predictors`.\n\nFor each scan `patch_inference` reorders, resamples, and normalizes the raw input exactly as in\ntraining, slides the patch grid, aggregates with the Hann window (soft-voting per patch across the\nfolds), resamples the prediction back to the input's original grid, and applies the training-time\npost-processing (argmax and keep-largest-component). Predictions are written to `OUTPUT_DIR` as\n`_pred.nii.gz` and returned as in-memory masks in `predictions` for the sections below.\n\nTwo options:\n\n- **`tta=USE_TTA`**: each patch is run through 8 axis-flip combinations and the probabilities\n averaged (combined with the fold averaging). Tighter borders, roughly 8x compute. Impractical on CPU.\n- **`amp=USE_AMP`**: float16 forward pass on CUDA. Faster and lighter; ignored on CPU.\n\nTo obtain the averaged probability map instead of a mask, pass `return_probabilities=True`." + }, + { + "cell_type": "code", + "execution_count": null, + "id": "65abc50b", + "metadata": {}, + "outputs": [], + "source": "# Ensembling is a fastMONAI feature: a list of learners makes patch_inference soft-vote them\n# (per-patch probabilities averaged before argmax) in one sliding-window pass. Post-processing\n# (argmax, keep-largest-component) is applied to the averaged map.\nlearners = predictors\n\nPath(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)\npredictions = patch_inference(\n learner=learners,\n config=patch_config,\n file_paths=NEW_CASES,\n pre_inference_tfms=pre_inference_tfms,\n save_dir=OUTPUT_DIR,\n progress=True,\n tta=USE_TTA,\n amp=USE_AMP,\n)\n\n\ndef _pred_filename(input_path):\n \"\"\"Name patch_inference writes for a prediction: '_pred.nii[.gz]'. Mirrors the\n engine's saver, stripping only the trailing extension so '.nii' and '.nii.gz' inputs\n both map to the right output name (used again when we reload the mask to visualize).\"\"\"\n p = Path(input_path)\n if p.suffix == \".gz\" and p.stem.endswith(\".nii\"):\n return f\"{p.stem[:-4]}_pred.nii.gz\"\n if p.suffix == \".nii\":\n return f\"{p.stem}_pred.nii\"\n return f\"{p.stem}_pred.nii.gz\"\n\n\nprint(f\"\\nWrote {len(predictions)} prediction(s) to {OUTPUT_DIR}/ \"\n f\"(soft-vote ensemble of {len(predictors)} folds).\")\nfor p in NEW_CASES:\n print(f\" {p} -> {OUTPUT_DIR}/{_pred_filename(p)}\")" + }, + { + "cell_type": "markdown", + "id": "691d43d1", + "metadata": {}, + "source": [ + "## 6. Visualize a prediction\n", + "\n", + "Now we inspect a result qualitatively. `vision_plot` is imported separately (it is not part of `vision_all`). We load the input scan as a `MedImage` and its predicted mask as a `MedMask`.\n", + "\n", + "Both are read with fastMONAI's default loader, which does not reorder or resample, so they share the input's original voxel grid and line up slice for slice. We pick the axial slice where the predicted tumor is largest with `find_max_slice`, then show the input, the mask, and an overlay. `voxel_size=TARGET_SPACING` sets only the display aspect ratio; it does not alter the data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ff90e7e6", + "metadata": {}, + "outputs": [], + "source": "from fastMONAI.vision_plot import * # fastMONAI plotting; imported separately from vision_all\nfrom torchio.visualization import rotate\nimport matplotlib.pyplot as plt\n\nidx = 0\nimg_fn = NEW_CASES[idx]\n# Same name patch_inference used when saving (see _pred_filename in the run-inference cell),\n# so the mask reloads correctly for both .nii and .nii.gz inputs.\npred_fn = Path(OUTPUT_DIR) / _pred_filename(img_fn)\n\n# Default fastMONAI loader: no reorder, no resample, so input and mask share the\n# input's original voxel grid and align slice for slice.\nimg = MedImage.create(img_fn)\npred_mask = MedMask.create(pred_fn)\n\nplane = 2 # 0 = sagittal, 1 = coronal, 2 = axial\nsl = int(find_max_slice(pred_mask.data[0].cpu().numpy(), plane))\nprint(f\"Predicted foreground voxels: {int(pred_mask.data.sum())}\")\nprint(f\"Showing plane={plane} (axial), slice={sl}\")\n\n\ndef _disp_slice(vol3d, i, plane, vsize):\n \"\"\"Replicate fastMONAI's show slicing so the overlay lines up with .show().\"\"\"\n sr, sa, ss = vsize\n ops = {0: (vol3d[i, :, :], ss / sa),\n 1: (vol3d[:, i, :], ss / sr),\n 2: (vol3d[:, :, i], sa / sr)}\n sl2d, aspect = ops[plane]\n return rotate(sl2d, radiological=True, n=1), aspect\n\n\nfig, axes = plt.subplots(1, 3, figsize=(15, 5))\nimg.show(ctx=axes[0], anatomical_plane=plane, slice_index=sl, voxel_size=TARGET_SPACING)\naxes[0].set_title(\"Input T1\")\npred_mask.show(ctx=axes[1], anatomical_plane=plane, slice_index=sl, voxel_size=TARGET_SPACING)\naxes[1].set_title(\"Predicted mask\")\n\nimg_slice, aspect = _disp_slice(img.data[0].cpu().numpy(), sl, plane, TARGET_SPACING)\nmsk_slice, _ = _disp_slice(pred_mask.data[0].cpu().numpy(), sl, plane, TARGET_SPACING)\naxes[2].imshow(img_slice, cmap=\"gray\", aspect=aspect)\naxes[2].imshow(np.ma.masked_where(msk_slice == 0, msk_slice),\n cmap=\"autumn\", alpha=0.5, aspect=aspect)\naxes[2].set_title(\"Overlay\")\naxes[2].axis(\"off\")\nplt.tight_layout()\nplt.show()" + }, + { + "cell_type": "markdown", + "id": "95c532c4", + "metadata": {}, + "source": [ + "## 7. Optional: validate against ground truth\n", + "\n", + "If a case has an expert segmentation, we can score the prediction with the **same metric functions** notebook 01 uses in its cross-validation, so the numbers here are directly comparable. This cell is optional: it only runs when you set `GT_PATH` to a mask file.\n", + "\n", + "The metrics:\n", + "\n", + "- **DSC** (Dice) and **sensitivity** / **precision**: overlap-based agreement.\n", + "- **LDR** (lesion detection rate) and **signed RVE** (relative volume error).\n", + "- **Surface metrics in millimeters** via `calculate_surface_metrics`: ASSD, HD95, and NSD. These are spacing-aware, so we read the per-case voxel spacing straight from the ground-truth file with `tio.LabelMap(GT_PATH).spacing`. This matters because the VS cohort has non-uniform spacing, and using one global spacing would bias the surface distances.\n", + "\n", + "The prediction and ground truth are compared as 5D tensors `[batch, channel, X, Y, Z]`, matching the metric API." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ed2780b4", + "metadata": {}, + "outputs": [], + "source": [ + "# Set to a ground-truth mask to score the first case, e.g.\n", + "# GT_PATH = \"../nii_data/queen_square_data/vs_gk_1/vs_gk_1_seg_refT1.nii.gz\"\n", + "GT_PATH = None\n", + "\n", + "if GT_PATH:\n", + " # Score the first prediction with the library's per-case panel (loads the GT mask's\n", + " # data and spacing from one object).\n", + " row = evaluate_segmentations([predictions[0]], [GT_PATH]).iloc[0]\n", + " print(f\"DSC: {row['dsc']:.4f}\")\n", + " print(f\"Sensitivity: {row['sensitivity']:.4f}\")\n", + " print(f\"Precision: {row['precision']:.4f}\")\n", + " print(f\"LDR: {row['ldr']:.4f}\")\n", + " print(f\"Signed RVE: {row['rve']:.4f}\")\n", + " print(f\"ASSD (mm): {row['assd_mm']}\")\n", + " print(f\"HD95 (mm): {row['hd95_mm']}\")\n", + " print(f\"NSD tau=1mm: {row['nsd_tau1.0_mm']}\")\n", + " print(f\"Spacing (mm): {row['spacing_mm']} | status: {row['surface_status']}\")\n", + "else:\n", + " print(\"GT_PATH is None; skipping ground-truth evaluation.\")" + ] + }, + { + "cell_type": "markdown", + "id": "df20f031", + "metadata": {}, + "source": [ + "## 8. Running SegMamba on CPU\n", + "\n", + "When no CUDA GPU is available, SegMamba can still run through the exact same `patch_inference` pipeline by switching to the `mambamixer` backend. This mirrors `research/vs_seg/infer_segmamba_cpu.py` and `research/vs_seg/SEGMAMBA_CPU.md`.\n", + "\n", + "The key points:\n", + "\n", + "- Construct with `mamba_backend=\"mambamixer\"` (pure PyTorch, needs `transformers`). No CUDA and no `mamba_ssm` required.\n", + "- A checkpoint trained with the default `mamba_ssm` backend loads into the `mambamixer` model unchanged (`strict=True`), so no retraining is needed. Correctness was verified decision-identical to GPU on a real case (Dice 1.0).\n", + "- It is slow: roughly 11 minutes per volume on CPU, and TTA is impractical there, so keep `tta=False`.\n", + "\n", + "The cell below is a self-contained CPU inference path. It is guarded so it does nothing unless `RUN_SEGMAMBA_CPU` is set to `True` and a checkpoint is present." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1eff224c", + "metadata": {}, + "outputs": [], + "source": "def _force_cpu_mamba_backend():\n \"\"\"Make transformers' MambaMixer take its pure-PyTorch path instead of probing CUDA\n kernels. A no-op on a clean CPU-only box; needed only when the environment has a\n broken or version-mismatched causal_conv1d / mamba_ssm build (the \"undefined symbol\"\n ABI error). Mirrors research/vs_seg/infer_segmamba_cpu.py.\"\"\"\n import sys\n import transformers # noqa: F401\n for modname in (\"transformers.utils.import_utils\", \"transformers.utils\"):\n mod = sys.modules.get(modname)\n if mod is not None:\n for fn in (\"is_causal_conv1d_available\", \"is_mamba_ssm_available\"):\n if hasattr(mod, fn):\n setattr(mod, fn, (lambda *a, **k: False))\n\n\nRUN_SEGMAMBA_CPU = False\nSEGMAMBA_WEIGHTS = \"models/best_segmamba.pth\"\n\nif RUN_SEGMAMBA_CPU:\n _force_cpu_mamba_backend() # skips the mismatched CUDA kernel probe (defined just above)\n from models_segmamba.segmambav2 import SegMamba\n\n # Pure-PyTorch Mamba backend: runs on CPU, no mamba_ssm / causal_conv1d needed.\n seg_model = SegMamba(\n in_chans=1, out_chans=2, depths=[2, 2, 2, 2],\n feat_size=[48, 96, 192, 384], hidden_size=768,\n mamba_backend=\"mambamixer\",\n )\n # A mamba_ssm-trained checkpoint loads strict into the mambamixer model unchanged.\n from torch.nn.modules.utils import consume_prefix_in_state_dict_if_present\n sd = torch.load(SEGMAMBA_WEIGHTS, map_location=\"cpu\")\n sd = sd[\"model\"] if isinstance(sd, dict) and \"model\" in sd else sd\n consume_prefix_in_state_dict_if_present(sd, \"_orig_mod.\") # official torch helper\n seg_model.load_state_dict(sd, strict=True)\n seg_model.eval() # stays on CPU\n\n cpu_preds = patch_inference(\n learner=seg_model,\n config=patch_config,\n file_paths=NEW_CASES,\n pre_inference_tfms=pre_inference_tfms,\n save_dir=OUTPUT_DIR,\n progress=True,\n tta=False, # TTA is impractical on CPU\n )\n print(f\"SegMamba CPU: {len(cpu_preds)} prediction(s) written to {OUTPUT_DIR}/\")\nelse:\n print(\"Set RUN_SEGMAMBA_CPU = True (with a SegMamba checkpoint) to run this path.\")" + }, + { + "cell_type": "markdown", + "id": "53b15733", + "metadata": {}, + "source": "## Recap and pointers\n\n- **This notebook runs a 5-fold soft-voting ensemble.** Each fold's probability map is produced in the input's original space, the five are averaged, and only then decoded (argmax + keep-largest-component). This usually beats any single fold.\n- **Preprocessing parity is the rule that governs correctness.** The prediction is valid only because inference reused the training `apply_reorder`, `target_spacing`, and foreground `ZNormalization`. When in doubt, load an exported learner (which carries its own preprocessing) or ship a `PatchConfig` JSON via `store_patch_variables` / `load_patch_variables`.\n- **Patch inference** slides an overlapping grid, predicts per patch, and blends with a Hann window, so arbitrarily large volumes fit in memory.\n- **TTA** trades roughly 8x compute for tighter borders; **AMP** speeds up the GPU forward pass. Neither changes the required preprocessing.\n- **Model choice is orthogonal to this notebook.** UNet and DynUNet run anywhere; SegMamba needs the fork and prefers a GPU, with a documented CPU fallback.\n\nPointers:\n\n- Training and the checkpoints this notebook consumes: `01_five_fold_cross_validation.ipynb`.\n- An earlier whole-volume ensemble reference: `research/vs_seg/10_ensemble_inference_verification_unet.ipynb`.\n- SegMamba CPU details: `research/vs_seg/infer_segmamba_cpu.py` and `research/vs_seg/SEGMAMBA_CPU.md`.\n- The inference engine and config: `fastMONAI/vision_patch.py` (`patch_inference`, `PatchInferenceEngine`, `PatchConfig`)." + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/research/vestibular_schwannoma/README.md b/research/vestibular_schwannoma/README.md new file mode 100644 index 0000000..e9407a7 --- /dev/null +++ b/research/vestibular_schwannoma/README.md @@ -0,0 +1,29 @@ +# Vestibular Schwannoma Segmentation (CE-T1w) + +This folder reproduces our fastMONAI results for segmenting vestibular schwannoma (VS) in +contrast-enhanced T1-weighted (CE-T1w) MRI. The task is binary 3D segmentation: for every +voxel, tumor (1) or background (0), trained and evaluated with the patch-based workflow. + +**Status: under development.** Downloading the data and preprocessing it into the layout the +notebooks expect are not part of this folder yet and will be added. + +## Notebooks + +- `01_five_fold_cross_validation.ipynb` - five-fold cross-validation comparing three models + (UNet, DynUNet, and a SegMamba fork) on the same fixed folds. Each fold is trained and then + evaluated with sliding-window inference, and the notebook ends with a cross-model summary. +- `02_inference_new_cases.ipynb` - runs a soft-vote ensemble of the five + folds, on new cases, reusing the exact preprocessing contract from training. + +## Data + +`ml_dataset.csv` has one row per case: image and mask paths (relative to this folder, under +`../nii_data/...`), a pre-assigned `fold` (1..5), and a `split` column. The scans themselves +are not included here yet. The steps that download the data and build `../nii_data/` and this +CSV will be added (see Status). + +## Requirements + +fastMONAI and its dependencies. UNet and DynUNet are MONAI built-ins and need nothing extra. +SegMamba needs our fork; see "Install SegMamba" in +`01_five_fold_cross_validation.ipynb` for the one-line GitHub install. diff --git a/research/vestibular_schwannoma/inference_patch_config.json b/research/vestibular_schwannoma/inference_patch_config.json new file mode 100644 index 0000000..366f441 --- /dev/null +++ b/research/vestibular_schwannoma/inference_patch_config.json @@ -0,0 +1 @@ +{"patch_size": [192, 192, 48], "patch_overlap": 0.5, "aggregation_mode": "hann", "apply_reorder": true, "target_spacing": [0.4102, 0.4102, 1.5], "sampler_type": "label", "label_probabilities": {"0": 0.2, "1": 0.8}, "samples_per_volume": 4, "queue_length": 1200, "queue_num_workers": 16, "keep_largest_component": true, "normalization": [{"name": "ZNormalization", "masking_method": "foreground", "channel_wise": true}]} \ No newline at end of file diff --git a/research/vestibular_schwannoma/ml_dataset.csv b/research/vestibular_schwannoma/ml_dataset.csv new file mode 100644 index 0000000..b208ca6 --- /dev/null +++ b/research/vestibular_schwannoma/ml_dataset.csv @@ -0,0 +1,347 @@ +case_id,volume_mm3,volume_category_quartile,equiv_sphere_diameter_mm,voxel_count,t1_img_path,t1_seg_path,fold,quartile_label,volume_range,is_small,is_large,split +vs_gk_193,4362.997055053711,Q4_Large,20.273501607947463,17290,../nii_data/queen_square_data/vs_gk_193/vs_gk_193_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_193/vs_gk_193_seg_refT1.nii.gz,1,3,L,0,1,train +vs_gk_115,1491.090202331543,Q3_Medium_Large,14.174304711317058,5909,../nii_data/queen_square_data/vs_gk_115/vs_gk_115_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_115/vs_gk_115_seg_refT1.nii.gz,5,2,M,0,0,train +vs_gk_43,329.05426025390625,Q1_Small,8.565570590758327,1304,../nii_data/queen_square_data/vs_gk_43/vs_gk_43_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_43/vs_gk_43_seg_refT1.nii.gz,3,0,XS,1,0,train +vs_gk_213,488.7868881225586,Q1_Small,9.77327925827696,1937,../nii_data/queen_square_data/vs_gk_213/vs_gk_213_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_213/vs_gk_213_seg_refT1.nii.gz,1,0,XS,1,0,train +vs_gk_87,1049.995994567871,Q2_Medium_Small,12.610423542945606,4161,../nii_data/queen_square_data/vs_gk_87/vs_gk_87_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_87/vs_gk_87_seg_refT1.nii.gz,1,1,M,0,0,train +vs_gk_53,472.3846435546875,Q1_Small,9.662712313674025,2808,../nii_data/queen_square_data/vs_gk_53/vs_gk_53_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_53/vs_gk_53_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_183,416.6170120239258,Q1_Small,9.266437321597284,1651,../nii_data/queen_square_data/vs_gk_183/vs_gk_183_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_183/vs_gk_183_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_201,945.5263137817384,Q2_Medium_Small,12.177505445538696,3747,../nii_data/queen_square_data/vs_gk_201/vs_gk_201_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_201/vs_gk_201_seg_refT1.nii.gz,1,1,S,0,0,train +vs_gk_148,461.2815856933594,Q1_Small,9.58640630369382,1828,../nii_data/queen_square_data/vs_gk_148/vs_gk_148_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_148/vs_gk_148_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_5,2076.2718200683594,Q3_Medium_Large,15.828090658010616,8228,../nii_data/queen_square_data/vs_gk_5/vs_gk_5_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_5/vs_gk_5_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_109,4616.348648071289,Q4_Large,20.658557486415383,18294,../nii_data/queen_square_data/vs_gk_109/vs_gk_109_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_109/vs_gk_109_seg_refT1.nii.gz,1,3,L,0,1,train +vs_gk_161,1103.744888305664,Q2_Medium_Small,12.822027214229196,4374,../nii_data/queen_square_data/vs_gk_161/vs_gk_161_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_161/vs_gk_161_seg_refT1.nii.gz,3,1,M,0,0,train +vs_gk_129,852.6643753051758,Q2_Medium_Small,11.7650334244872,3379,../nii_data/queen_square_data/vs_gk_129/vs_gk_129_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_129/vs_gk_129_seg_refT1.nii.gz,4,1,S,0,0,train +vs_gk_78,4024.101448059082,Q4_Large,19.734377556379627,15947,../nii_data/queen_square_data/vs_gk_78/vs_gk_78_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_78/vs_gk_78_seg_refT1.nii.gz,4,3,L,0,1,train +vs_gk_199,3923.921585083008,Q4_Large,19.5692372821121,15550,../nii_data/queen_square_data/vs_gk_199/vs_gk_199_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_199/vs_gk_199_seg_refT1.nii.gz,1,3,L,0,1,train +vs_gk_164,4833.36296081543,Q4_Large,20.97733252077008,19154,../nii_data/queen_square_data/vs_gk_164/vs_gk_164_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_164/vs_gk_164_seg_refT1.nii.gz,2,3,L,0,1,train +vs_gk_91,1748.7316131591797,Q3_Medium_Large,14.94771629043457,6930,../nii_data/queen_square_data/vs_gk_91/vs_gk_91_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_91/vs_gk_91_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_18,49.62730407714844,Q1_Small,4.559396418303071,295,../nii_data/queen_square_data/vs_gk_18/vs_gk_18_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_18/vs_gk_18_seg_refT1.nii.gz,4,0,XS,1,0,train +vs_gk_61,714.6331787109375,Q2_Medium_Small,11.09246799760526,2832,../nii_data/queen_square_data/vs_gk_61/vs_gk_61_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_61/vs_gk_61_seg_refT1.nii.gz,3,1,S,0,0,train +crossmoda2022_etz_82,9914.017788648604,Q4_Large,26.6532613104477,9822,../nii_data/tilburg_data/crossmoda2022_etz_82/crossmoda2022_etz_82_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_82/crossmoda2022_etz_82_Label.nii.gz,1,3,XL,0,1,train +vs_gk_177,3506.5475463867188,Q4_Large,18.84923388296519,13896,../nii_data/queen_square_data/vs_gk_177/vs_gk_177_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_177/vs_gk_177_seg_refT1.nii.gz,2,3,L,0,1,train +vs_gk_125,2606.190490722656,Q3_Medium_Large,17.074022307792454,10328,../nii_data/queen_square_data/vs_gk_125/vs_gk_125_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_125/vs_gk_125_seg_refT1.nii.gz,1,2,L,0,0,train +vs_gk_221,623.0329513549805,Q1_Small,10.59670387312672,2469,../nii_data/queen_square_data/vs_gk_221/vs_gk_221_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_221/vs_gk_221_seg_refT1.nii.gz,5,0,S,1,0,train +vs_gk_71,2094.188117980957,Q3_Medium_Large,15.873487571396822,8299,../nii_data/queen_square_data/vs_gk_71/vs_gk_71_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_71/vs_gk_71_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_33,1216.541862487793,Q2_Medium_Small,13.244720926711796,4821,../nii_data/queen_square_data/vs_gk_33/vs_gk_33_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_33/vs_gk_33_seg_refT1.nii.gz,4,1,M,0,0,train +crossmoda2022_etz_86,2706.1180114746094,Q3_Medium_Large,17.28951071786434,2681,../nii_data/tilburg_data/crossmoda2022_etz_86/crossmoda2022_etz_86_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_86/crossmoda2022_etz_86_Label.nii.gz,5,2,L,0,0,train +vs_gk_218,2993.283462524414,Q3_Medium_Large,17.880640628067994,11862,../nii_data/queen_square_data/vs_gk_218/vs_gk_218_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_218/vs_gk_218_seg_refT1.nii.gz,4,2,L,0,0,train +vs_gk_86,570.4616546630859,Q1_Small,10.28985555452946,3391,../nii_data/queen_square_data/vs_gk_86/vs_gk_86_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_86/vs_gk_86_seg_refT1.nii.gz,1,0,S,1,0,train +vs_gk_25,998.2658386230468,Q2_Medium_Small,12.39983374915763,3956,../nii_data/queen_square_data/vs_gk_25/vs_gk_25_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_25/vs_gk_25_seg_refT1.nii.gz,2,1,S,0,0,train +crossmoda2022_etz_56,564.2372131347656,Q1_Small,10.252293592820104,559,../nii_data/tilburg_data/crossmoda2022_etz_56/crossmoda2022_etz_56_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_56/crossmoda2022_etz_56_Label.nii.gz,1,0,S,1,0,train +vs_gk_171,1517.838478088379,Q3_Medium_Large,14.25855927229834,6015,../nii_data/queen_square_data/vs_gk_171/vs_gk_171_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_171/vs_gk_171_seg_refT1.nii.gz,4,2,M,0,0,train +crossmoda2022_etz_51,1179.9522399902344,Q2_Medium_Small,13.11058092618194,1169,../nii_data/tilburg_data/crossmoda2022_etz_51/crossmoda2022_etz_51_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_51/crossmoda2022_etz_51_Label.nii.gz,1,1,M,0,0,train +vs_gk_95,1458.790397644043,Q2_Medium_Small,14.071209352272732,5781,../nii_data/queen_square_data/vs_gk_95/vs_gk_95_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_95/vs_gk_95_seg_refT1.nii.gz,5,1,M,0,0,train +crossmoda2022_etz_104,723.7174987792969,Q2_Medium_Small,11.139272214395223,717,../nii_data/tilburg_data/crossmoda2022_etz_104/crossmoda2022_etz_104_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_104/crossmoda2022_etz_104_Label.nii.gz,4,1,S,0,0,train +vs_gk_238,1182.7280044555664,Q2_Medium_Small,13.120853484711036,4687,../nii_data/queen_square_data/vs_gk_238/vs_gk_238_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_238/vs_gk_238_seg_refT1.nii.gz,2,1,M,0,0,train +vs_gk_248,2822.4477767944336,Q3_Medium_Large,17.53378852526643,11185,../nii_data/queen_square_data/vs_gk_248/vs_gk_248_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_248/vs_gk_248_seg_refT1.nii.gz,5,2,L,0,0,train +vs_gk_136,287.1654510498047,Q1_Small,8.185486981224278,1707,../nii_data/queen_square_data/vs_gk_136/vs_gk_136_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_136/vs_gk_136_seg_refT1.nii.gz,3,0,XS,1,0,train +vs_gk_187,4140.935897827148,Q4_Large,19.923545613747105,16410,../nii_data/queen_square_data/vs_gk_187/vs_gk_187_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_187/vs_gk_187_seg_refT1.nii.gz,3,3,L,0,1,train +vs_gk_19,6827.875900268555,Q4_Large,23.537608351648924,27058,../nii_data/queen_square_data/vs_gk_19/vs_gk_19_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_19/vs_gk_19_seg_refT1.nii.gz,2,3,XL,0,1,train +vs_gk_185,712.6144409179688,Q2_Medium_Small,11.082013261912692,2824,../nii_data/queen_square_data/vs_gk_185/vs_gk_185_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_185/vs_gk_185_seg_refT1.nii.gz,3,1,S,0,0,train +vs_gk_123,1320.2545166015625,Q2_Medium_Small,13.610884433708874,5232,../nii_data/queen_square_data/vs_gk_123/vs_gk_123_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_123/vs_gk_123_seg_refT1.nii.gz,3,1,M,0,0,train +crossmoda2022_etz_76,8315.180969238281,Q4_Large,25.13570251233125,8238,../nii_data/tilburg_data/crossmoda2022_etz_76/crossmoda2022_etz_76_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_76/crossmoda2022_etz_76_Label.nii.gz,4,3,XL,0,1,train +vs_gk_103,585.1816177368164,Q1_Small,10.377610130983658,2319,../nii_data/queen_square_data/vs_gk_103/vs_gk_103_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_103/vs_gk_103_seg_refT1.nii.gz,3,0,S,1,0,train +vs_gk_56,2793.428421020508,Q3_Medium_Large,17.47348943417387,11070,../nii_data/queen_square_data/vs_gk_56/vs_gk_56_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_56/vs_gk_56_seg_refT1.nii.gz,2,2,L,0,0,train +crossmoda2022_etz_70,456.2333402633667,Q1_Small,9.551306878431458,452,../nii_data/tilburg_data/crossmoda2022_etz_70/crossmoda2022_etz_70_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_70/crossmoda2022_etz_70_Label.nii.gz,3,0,XS,1,0,train +crossmoda2022_etz_78,1496.8915984630585,Q3_Medium_Large,14.19266361431514,1483,../nii_data/tilburg_data/crossmoda2022_etz_78/crossmoda2022_etz_78_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_78/crossmoda2022_etz_78_Label.nii.gz,5,2,M,0,0,train +crossmoda2022_etz_97,6199.543762207031,Q4_Large,22.7922413942113,6142,../nii_data/tilburg_data/crossmoda2022_etz_97/crossmoda2022_etz_97_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_97/crossmoda2022_etz_97_Label.nii.gz,1,3,XL,0,1,train +vs_gk_172,3261.5232467651367,Q3_Medium_Large,18.39955355011357,12925,../nii_data/queen_square_data/vs_gk_172/vs_gk_172_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_172/vs_gk_172_seg_refT1.nii.gz,2,2,L,0,0,train +vs_gk_154,1964.736557006836,Q3_Medium_Large,15.539435370546078,7786,../nii_data/queen_square_data/vs_gk_154/vs_gk_154_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_154/vs_gk_154_seg_refT1.nii.gz,4,2,M,0,0,train +crossmoda2022_etz_102,1323.2826232910156,Q2_Medium_Small,13.621282363217889,1311,../nii_data/tilburg_data/crossmoda2022_etz_102/crossmoda2022_etz_102_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_102/crossmoda2022_etz_102_Label.nii.gz,2,1,M,0,0,train +vs_gk_50,4519.196891784668,Q4_Large,20.512607980551536,17909,../nii_data/queen_square_data/vs_gk_50/vs_gk_50_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_50/vs_gk_50_seg_refT1.nii.gz,5,3,L,0,1,train +vs_gk_24,3657.448196411133,Q4_Large,19.11583120878609,14494,../nii_data/queen_square_data/vs_gk_24/vs_gk_24_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_24/vs_gk_24_seg_refT1.nii.gz,4,3,L,0,1,train +crossmoda2022_etz_49,2532.503570318222,Q3_Medium_Large,16.91156560347764,2509,../nii_data/tilburg_data/crossmoda2022_etz_49/crossmoda2022_etz_49_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_49/crossmoda2022_etz_49_Label.nii.gz,3,2,L,0,0,train +vs_gk_100,1474.4356155395508,Q3_Medium_Large,14.121334163937185,5843,../nii_data/queen_square_data/vs_gk_100/vs_gk_100_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_100/vs_gk_100_seg_refT1.nii.gz,4,2,M,0,0,train +crossmoda2022_etz_46,1907.7072143554688,Q3_Medium_Large,15.387605135373947,1890,../nii_data/tilburg_data/crossmoda2022_etz_46/crossmoda2022_etz_46_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_46/crossmoda2022_etz_46_Label.nii.gz,4,2,M,0,0,train +vs_gk_241,4279.471778869629,Q4_Large,20.143295081650127,16959,../nii_data/queen_square_data/vs_gk_241/vs_gk_241_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_241/vs_gk_241_seg_refT1.nii.gz,3,3,L,0,1,train +vs_gk_44,1623.065185546875,Q3_Medium_Large,14.58072480906109,6432,../nii_data/queen_square_data/vs_gk_44/vs_gk_44_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_44/vs_gk_44_seg_refT1.nii.gz,4,2,M,0,0,train +crossmoda2022_etz_66,4598.6846923828125,Q4_Large,20.632174566663952,4556,../nii_data/tilburg_data/crossmoda2022_etz_66/crossmoda2022_etz_66_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_66/crossmoda2022_etz_66_Label.nii.gz,4,3,L,0,1,train +vs_gk_166,2253.9207458496094,Q3_Medium_Large,16.267219977145203,8932,../nii_data/queen_square_data/vs_gk_166/vs_gk_166_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_166/vs_gk_166_seg_refT1.nii.gz,3,2,M,0,0,train +crossmoda2022_etz_100,373.4664916992188,Q1_Small,8.9347898029244,370,../nii_data/tilburg_data/crossmoda2022_etz_100/crossmoda2022_etz_100_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_100/crossmoda2022_etz_100_Label.nii.gz,5,0,XS,1,0,train +crossmoda2022_etz_41,6595.216369628906,Q4_Large,23.267165298570287,6534,../nii_data/tilburg_data/crossmoda2022_etz_41/crossmoda2022_etz_41_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_41/crossmoda2022_etz_41_Label.nii.gz,2,3,XL,0,1,train +vs_gk_250,6295.181465148926,Q4_Large,22.908845876084687,24947,../nii_data/queen_square_data/vs_gk_250/vs_gk_250_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_250/vs_gk_250_seg_refT1.nii.gz,5,3,XL,0,1,train +crossmoda2022_etz_59,7236.165618896484,Q4_Large,23.99771872260159,7169,../nii_data/tilburg_data/crossmoda2022_etz_59/crossmoda2022_etz_59_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_59/crossmoda2022_etz_59_Label.nii.gz,5,3,XL,0,1,train +vs_gk_116,989.1815185546876,Q2_Medium_Small,12.362105827068188,3920,../nii_data/queen_square_data/vs_gk_116/vs_gk_116_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_116/vs_gk_116_seg_refT1.nii.gz,5,1,S,0,0,train +vs_gk_157,6971.963310241699,Q4_Large,23.70202709347249,27629,../nii_data/queen_square_data/vs_gk_157/vs_gk_157_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_157/vs_gk_157_seg_refT1.nii.gz,4,3,XL,0,1,train +vs_gk_46,1432.294464111328,Q2_Medium_Small,13.98549676143428,5676,../nii_data/queen_square_data/vs_gk_46/vs_gk_46_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_46/vs_gk_46_seg_refT1.nii.gz,5,1,M,0,0,train +crossmoda2022_etz_16,1466.6116209030151,Q3_Medium_Large,14.096311878054504,1453,../nii_data/tilburg_data/crossmoda2022_etz_16/crossmoda2022_etz_16_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_16/crossmoda2022_etz_16_Label.nii.gz,4,2,M,0,0,train +vs_gk_146,864.8609161376953,Q2_Medium_Small,11.820863879940877,5141,../nii_data/queen_square_data/vs_gk_146/vs_gk_146_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_146/vs_gk_146_seg_refT1.nii.gz,2,1,S,0,0,train +vs_gk_152,1029.3039321899414,Q2_Medium_Small,12.527036318223042,4079,../nii_data/queen_square_data/vs_gk_152/vs_gk_152_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_152/vs_gk_152_seg_refT1.nii.gz,1,1,M,0,0,train +vs_gk_107,3661.738014221192,Q4_Large,19.123301936012748,14511,../nii_data/queen_square_data/vs_gk_107/vs_gk_107_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_107/vs_gk_107_seg_refT1.nii.gz,1,3,L,0,1,train +vs_gk_246,5956.285858154297,Q4_Large,22.490147483202502,23604,../nii_data/queen_square_data/vs_gk_246/vs_gk_246_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_246/vs_gk_246_seg_refT1.nii.gz,3,3,XL,0,1,train +vs_gk_211,1261.4587783813477,Q2_Medium_Small,13.405761243272607,4999,../nii_data/queen_square_data/vs_gk_211/vs_gk_211_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_211/vs_gk_211_seg_refT1.nii.gz,3,1,M,0,0,train +crossmoda2022_etz_33,232.1554219722748,Q1_Small,7.625355042400001,230,../nii_data/tilburg_data/crossmoda2022_etz_33/crossmoda2022_etz_33_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_33/crossmoda2022_etz_33_Label.nii.gz,2,0,XS,1,0,train +crossmoda2022_etz_42,1285.9359741210938,Q2_Medium_Small,13.491914319310446,1274,../nii_data/tilburg_data/crossmoda2022_etz_42/crossmoda2022_etz_42_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_42/crossmoda2022_etz_42_Label.nii.gz,4,1,M,0,0,train +vs_gk_184,496.3571548461914,Q1_Small,9.823476745181974,1967,../nii_data/queen_square_data/vs_gk_184/vs_gk_184_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_184/vs_gk_184_seg_refT1.nii.gz,3,0,XS,1,0,train +vs_gk_70,2534.0206146240234,Q3_Medium_Large,16.914941771562404,10042,../nii_data/queen_square_data/vs_gk_70/vs_gk_70_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_70/vs_gk_70_seg_refT1.nii.gz,2,2,L,0,0,train +vs_gk_215,840.2996063232422,Q2_Medium_Small,11.70788674253976,3330,../nii_data/queen_square_data/vs_gk_215/vs_gk_215_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_215/vs_gk_215_seg_refT1.nii.gz,4,1,S,0,0,train +crossmoda2022_etz_63,944.77006816864,Q2_Medium_Small,12.174257997973584,936,../nii_data/tilburg_data/crossmoda2022_etz_63/crossmoda2022_etz_63_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_63/crossmoda2022_etz_63_Label.nii.gz,2,1,S,0,0,train +vs_gk_204,883.1977844238281,Q2_Medium_Small,11.90382269539906,3500,../nii_data/queen_square_data/vs_gk_204/vs_gk_204_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_204/vs_gk_204_seg_refT1.nii.gz,1,1,S,0,0,train +crossmoda2022_etz_32,941.741180419922,Q2_Medium_Small,12.16123403994057,933,../nii_data/tilburg_data/crossmoda2022_etz_32/crossmoda2022_etz_32_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_32/crossmoda2022_etz_32_Label.nii.gz,1,1,S,0,0,train +vs_gk_21,4263.57421875,Q4_Large,20.118321080559785,16896,../nii_data/queen_square_data/vs_gk_21/vs_gk_21_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_21/vs_gk_21_seg_refT1.nii.gz,1,3,L,0,1,train +crossmoda2022_etz_35,2664.7367191314697,Q3_Medium_Large,17.200928662067007,2640,../nii_data/tilburg_data/crossmoda2022_etz_35/crossmoda2022_etz_35_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_35/crossmoda2022_etz_35_Label.nii.gz,1,2,L,0,0,train +vs_gk_83,4065.485572814941,Q4_Large,19.801796847259794,16111,../nii_data/queen_square_data/vs_gk_83/vs_gk_83_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_83/vs_gk_83_seg_refT1.nii.gz,2,3,L,0,1,train +crossmoda2022_etz_37,1298.0484008789062,Q2_Medium_Small,13.534142808499274,1286,../nii_data/tilburg_data/crossmoda2022_etz_37/crossmoda2022_etz_37_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_37/crossmoda2022_etz_37_Label.nii.gz,3,1,M,0,0,train +crossmoda2022_etz_60,181.6864013671875,Q1_Small,7.027075185139094,180,../nii_data/tilburg_data/crossmoda2022_etz_60/crossmoda2022_etz_60_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_60/crossmoda2022_etz_60_Label.nii.gz,4,0,XS,1,0,train +vs_gk_181,2562.282943725586,Q3_Medium_Large,16.977594419681754,10154,../nii_data/queen_square_data/vs_gk_181/vs_gk_181_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_181/vs_gk_181_seg_refT1.nii.gz,4,2,L,0,0,train +crossmoda2022_etz_7,7760.030825614929,Q4_Large,24.56338836008172,7688,../nii_data/tilburg_data/crossmoda2022_etz_7/crossmoda2022_etz_7_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_7/crossmoda2022_etz_7_Label.nii.gz,5,3,XL,0,1,train +vs_gk_223,2321.2961196899414,Q3_Medium_Large,16.427720595154465,9199,../nii_data/queen_square_data/vs_gk_223/vs_gk_223_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_223/vs_gk_223_seg_refT1.nii.gz,2,2,M,0,0,train +crossmoda2022_etz_6,7900.330352783203,Q4_Large,24.71053850073288,7827,../nii_data/tilburg_data/crossmoda2022_etz_6/crossmoda2022_etz_6_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_6/crossmoda2022_etz_6_Label.nii.gz,2,3,XL,0,1,train +vs_gk_99,3122.735023498535,Q3_Medium_Large,18.13477495858989,12375,../nii_data/queen_square_data/vs_gk_99/vs_gk_99_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_99/vs_gk_99_seg_refT1.nii.gz,5,2,L,0,0,train +vs_gk_106,1033.341407775879,Q2_Medium_Small,12.5433941741591,4095,../nii_data/queen_square_data/vs_gk_106/vs_gk_106_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_106/vs_gk_106_seg_refT1.nii.gz,2,1,M,0,0,train +crossmoda2022_etz_62,3503.5194396972656,Q4_Large,18.843806519036328,3471,../nii_data/tilburg_data/crossmoda2022_etz_62/crossmoda2022_etz_62_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_62/crossmoda2022_etz_62_Label.nii.gz,5,3,L,0,1,train +vs_gk_110,4954.487228393555,Q4_Large,21.1511197290988,19634,../nii_data/queen_square_data/vs_gk_110/vs_gk_110_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_110/vs_gk_110_seg_refT1.nii.gz,3,3,L,0,1,train +vs_gk_229,301.29661560058594,Q1_Small,8.317609819626888,1194,../nii_data/queen_square_data/vs_gk_229/vs_gk_229_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_229/vs_gk_229_seg_refT1.nii.gz,1,0,XS,1,0,train +vs_gk_121,1453.743553161621,Q2_Medium_Small,14.054963642102832,5761,../nii_data/queen_square_data/vs_gk_121/vs_gk_121_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_121/vs_gk_121_seg_refT1.nii.gz,5,1,M,0,0,train +crossmoda2022_etz_87,682.3333740234375,Q2_Medium_Small,10.922766856228415,676,../nii_data/tilburg_data/crossmoda2022_etz_87/crossmoda2022_etz_87_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_87/crossmoda2022_etz_87_Label.nii.gz,3,1,S,0,0,train +vs_gk_15,1294.2632675170898,Q2_Medium_Small,13.520974729734988,5129,../nii_data/queen_square_data/vs_gk_15/vs_gk_15_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_15/vs_gk_15_seg_refT1.nii.gz,1,1,M,0,0,train +vs_gk_149,6185.160255432129,Q4_Large,22.774601052059094,24511,../nii_data/queen_square_data/vs_gk_149/vs_gk_149_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_149/vs_gk_149_seg_refT1.nii.gz,1,3,XL,0,1,train +vs_gk_212,2681.640815734864,Q3_Medium_Large,17.237224115199634,10627,../nii_data/queen_square_data/vs_gk_212/vs_gk_212_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_212/vs_gk_212_seg_refT1.nii.gz,2,2,L,0,0,train +vs_gk_92,1284.6742630004885,Q2_Medium_Small,13.48750029219463,5091,../nii_data/queen_square_data/vs_gk_92/vs_gk_92_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_92/vs_gk_92_seg_refT1.nii.gz,4,1,M,0,0,train +vs_gk_102,1277.8610229492188,Q2_Medium_Small,13.463614468373368,5064,../nii_data/queen_square_data/vs_gk_102/vs_gk_102_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_102/vs_gk_102_seg_refT1.nii.gz,1,1,M,0,0,train +vs_gk_210,5464.723205566406,Q4_Large,21.853609696080664,21656,../nii_data/queen_square_data/vs_gk_210/vs_gk_210_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_210/vs_gk_210_seg_refT1.nii.gz,4,3,XL,0,1,train +vs_gk_122,941.741180419922,Q2_Medium_Small,12.16123403994057,3732,../nii_data/queen_square_data/vs_gk_122/vs_gk_122_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_122/vs_gk_122_seg_refT1.nii.gz,3,1,S,0,0,train +vs_gk_128,511.497688293457,Q1_Small,9.92236086195688,2027,../nii_data/queen_square_data/vs_gk_128/vs_gk_128_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_128/vs_gk_128_seg_refT1.nii.gz,4,0,S,1,0,train +crossmoda2022_etz_4,1107.27781021595,Q2_Medium_Small,12.835693106024005,1097,../nii_data/tilburg_data/crossmoda2022_etz_4/crossmoda2022_etz_4_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_4/crossmoda2022_etz_4_Label.nii.gz,1,1,M,0,0,train +vs_gk_159,2462.607765197754,Q3_Medium_Large,16.754528378053248,9759,../nii_data/queen_square_data/vs_gk_159/vs_gk_159_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_159/vs_gk_159_seg_refT1.nii.gz,1,2,M,0,0,train +vs_gk_242,658.6132049560547,Q1_Small,10.794700663331165,2610,../nii_data/queen_square_data/vs_gk_242/vs_gk_242_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_242/vs_gk_242_seg_refT1.nii.gz,5,0,S,1,0,train +crossmoda2022_etz_77,324.0076071023941,Q1_Small,8.521555198356914,321,../nii_data/tilburg_data/crossmoda2022_etz_77/crossmoda2022_etz_77_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_77/crossmoda2022_etz_77_Label.nii.gz,5,0,XS,1,0,train +crossmoda2022_etz_68,220.0422894954681,Q1_Small,7.490356891149099,218,../nii_data/tilburg_data/crossmoda2022_etz_68/crossmoda2022_etz_68_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_68/crossmoda2022_etz_68_Label.nii.gz,5,0,XS,1,0,train +vs_gk_224,538.245964050293,Q1_Small,10.092390665442052,2133,../nii_data/queen_square_data/vs_gk_224/vs_gk_224_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_224/vs_gk_224_seg_refT1.nii.gz,2,0,S,1,0,train +vs_gk_236,691.4176940917969,Q2_Medium_Small,10.97102713028818,2740,../nii_data/queen_square_data/vs_gk_236/vs_gk_236_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_236/vs_gk_236_seg_refT1.nii.gz,4,1,S,0,0,train +vs_gk_10,7925.05989074707,Q4_Large,24.7362945421496,31406,../nii_data/queen_square_data/vs_gk_10/vs_gk_10_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_10/vs_gk_10_seg_refT1.nii.gz,1,3,XL,0,1,train +vs_gk_36,3297.860527038574,Q3_Medium_Large,18.46763237660716,13069,../nii_data/queen_square_data/vs_gk_36/vs_gk_36_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_36/vs_gk_36_seg_refT1.nii.gz,1,2,L,0,0,train +vs_gk_153,716.9042587280273,Q2_Medium_Small,11.104206067736538,2841,../nii_data/queen_square_data/vs_gk_153/vs_gk_153_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_153/vs_gk_153_seg_refT1.nii.gz,4,1,S,0,0,train +vs_gk_249,1920.8290100097656,Q3_Medium_Large,15.422804777078156,7612,../nii_data/queen_square_data/vs_gk_249/vs_gk_249_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_249/vs_gk_249_seg_refT1.nii.gz,1,2,M,0,0,train +vs_gk_150,2940.2915954589844,Q3_Medium_Large,17.774494568271958,11652,../nii_data/queen_square_data/vs_gk_150/vs_gk_150_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_150/vs_gk_150_seg_refT1.nii.gz,5,2,L,0,0,train +vs_gk_75,2706.1180114746094,Q3_Medium_Large,17.28951071786434,10724,../nii_data/queen_square_data/vs_gk_75/vs_gk_75_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_75/vs_gk_75_seg_refT1.nii.gz,3,2,L,0,0,train +crossmoda2022_etz_95,1431.283742904663,Q2_Medium_Small,13.982206291775054,1418,../nii_data/tilburg_data/crossmoda2022_etz_95/crossmoda2022_etz_95_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_95/crossmoda2022_etz_95_Label.nii.gz,4,1,M,0,0,train +vs_gk_82,6807.688522338867,Q4_Large,23.51438826273441,26978,../nii_data/queen_square_data/vs_gk_82/vs_gk_82_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_82/vs_gk_82_seg_refT1.nii.gz,1,3,XL,0,1,train +crossmoda2022_etz_22,6279.283905029297,Q4_Large,22.88954530675377,6221,../nii_data/tilburg_data/crossmoda2022_etz_22/crossmoda2022_etz_22_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_22/crossmoda2022_etz_22_Label.nii.gz,3,3,XL,0,1,train +vs_gk_20,408.45794677734375,Q1_Small,9.205546495160444,2428,../nii_data/queen_square_data/vs_gk_20/vs_gk_20_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_20/vs_gk_20_seg_refT1.nii.gz,4,0,XS,1,0,train +crossmoda2022_etz_69,5811.944046735764,Q4_Large,22.306988022351508,5758,../nii_data/tilburg_data/crossmoda2022_etz_69/crossmoda2022_etz_69_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_69/crossmoda2022_etz_69_Label.nii.gz,2,3,XL,0,1,train +crossmoda2022_etz_90,1040.6593322753906,Q2_Medium_Small,12.572934516409338,1031,../nii_data/tilburg_data/crossmoda2022_etz_90/crossmoda2022_etz_90_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_90/crossmoda2022_etz_90_Label.nii.gz,3,1,M,0,0,train +vs_gk_233,7948.780059814453,Q4_Large,24.7609490211777,31500,../nii_data/queen_square_data/vs_gk_233/vs_gk_233_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_233/vs_gk_233_seg_refT1.nii.gz,5,3,XL,0,1,train +crossmoda2022_etz_0,2327.6046752929688,Q3_Medium_Large,16.442588933401527,2306,../nii_data/tilburg_data/crossmoda2022_etz_0/crossmoda2022_etz_0_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_0/crossmoda2022_etz_0_Label.nii.gz,4,2,M,0,0,train +vs_gk_165,588.4620666503906,Q1_Small,10.396965833176978,2332,../nii_data/queen_square_data/vs_gk_165/vs_gk_165_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_165/vs_gk_165_seg_refT1.nii.gz,2,0,S,1,0,train +crossmoda2022_etz_54,608.6507384777069,Q1_Small,10.51452949608094,603,../nii_data/tilburg_data/crossmoda2022_etz_54/crossmoda2022_etz_54_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_54/crossmoda2022_etz_54_Label.nii.gz,1,0,S,1,0,train +vs_gk_190,437.81375885009766,Q1_Small,9.420998351400852,1735,../nii_data/queen_square_data/vs_gk_190/vs_gk_190_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_190/vs_gk_190_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_155,275.22125244140625,Q1_Small,8.070388620345998,1636,../nii_data/queen_square_data/vs_gk_155/vs_gk_155_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_155/vs_gk_155_seg_refT1.nii.gz,4,0,XS,1,0,train +vs_gk_47,4593.890190124512,Q4_Large,20.62500183418161,18205,../nii_data/queen_square_data/vs_gk_47/vs_gk_47_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_47/vs_gk_47_seg_refT1.nii.gz,5,3,L,0,1,train +crossmoda2022_etz_79,542.0310974121094,Q1_Small,10.115993161749314,537,../nii_data/tilburg_data/crossmoda2022_etz_79/crossmoda2022_etz_79_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_79/crossmoda2022_etz_79_Label.nii.gz,1,0,S,1,0,train +vs_gk_57,807.9998016357422,Q2_Medium_Small,11.55591159149698,3202,../nii_data/queen_square_data/vs_gk_57/vs_gk_57_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_57/vs_gk_57_seg_refT1.nii.gz,3,1,S,0,0,train +vs_gk_194,5694.1022872924805,Q4_Large,22.155193496333908,22565,../nii_data/queen_square_data/vs_gk_194/vs_gk_194_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_194/vs_gk_194_seg_refT1.nii.gz,2,3,XL,0,1,train +vs_gk_176,1000.5369186401368,Q2_Medium_Small,12.409229938973828,3965,../nii_data/queen_square_data/vs_gk_176/vs_gk_176_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_176/vs_gk_176_seg_refT1.nii.gz,5,1,M,0,0,train +vs_gk_140,6910.8964920043945,Q4_Large,23.632622732743336,27387,../nii_data/queen_square_data/vs_gk_140/vs_gk_140_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_140/vs_gk_140_seg_refT1.nii.gz,3,3,XL,0,1,train +vs_gk_237,1390.9103393554688,Q2_Medium_Small,13.849481177804895,5512,../nii_data/queen_square_data/vs_gk_237/vs_gk_237_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_237/vs_gk_237_seg_refT1.nii.gz,2,1,M,0,0,train +vs_gk_31,352.0174026489258,Q1_Small,8.76035765548068,1395,../nii_data/queen_square_data/vs_gk_31/vs_gk_31_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_31/vs_gk_31_seg_refT1.nii.gz,1,0,XS,1,0,train +crossmoda2022_etz_98,1044.696807861328,Q2_Medium_Small,12.58917339079081,1035,../nii_data/tilburg_data/crossmoda2022_etz_98/crossmoda2022_etz_98_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_98/crossmoda2022_etz_98_Label.nii.gz,5,1,M,0,0,train +vs_gk_203,2079.2999267578125,Q3_Medium_Large,15.83578166537156,8240,../nii_data/queen_square_data/vs_gk_203/vs_gk_203_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_203/vs_gk_203_seg_refT1.nii.gz,5,2,M,0,0,train +vs_gk_23,594.6865081787109,Q1_Small,10.433495212733405,3535,../nii_data/queen_square_data/vs_gk_23/vs_gk_23_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_23/vs_gk_23_seg_refT1.nii.gz,1,0,S,1,0,train +vs_gk_98,2166.862678527832,Q3_Medium_Large,16.055022642333196,8587,../nii_data/queen_square_data/vs_gk_98/vs_gk_98_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_98/vs_gk_98_seg_refT1.nii.gz,3,2,M,0,0,train +crossmoda2022_etz_9,1066.9039316177368,Q2_Medium_Small,12.677751391374624,1057,../nii_data/tilburg_data/crossmoda2022_etz_9/crossmoda2022_etz_9_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_9/crossmoda2022_etz_9_Label.nii.gz,1,1,M,0,0,train +vs_gk_11,6224.273300170898,Q4_Large,22.822506729575544,24666,../nii_data/queen_square_data/vs_gk_11/vs_gk_11_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_11/vs_gk_11_seg_refT1.nii.gz,4,3,XL,0,1,train +crossmoda2022_etz_21,434.0287792682648,Q1_Small,9.39377099004822,430,../nii_data/tilburg_data/crossmoda2022_etz_21/crossmoda2022_etz_21_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_21/crossmoda2022_etz_21_Label.nii.gz,2,0,XS,1,0,train +crossmoda2022_etz_40,1098.193359375,Q2_Medium_Small,12.800493998233376,1088,../nii_data/tilburg_data/crossmoda2022_etz_40/crossmoda2022_etz_40_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_40/crossmoda2022_etz_40_Label.nii.gz,4,1,M,0,0,train +vs_gk_9,3585.2783203125,Q4_Large,18.98926189584605,14208,../nii_data/queen_square_data/vs_gk_9/vs_gk_9_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_9/vs_gk_9_seg_refT1.nii.gz,2,3,L,0,1,train +crossmoda2022_etz_67,3602.4337624311447,Q4_Large,19.01950138566732,3569,../nii_data/tilburg_data/crossmoda2022_etz_67/crossmoda2022_etz_67_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_67/crossmoda2022_etz_67_Label.nii.gz,3,3,L,0,1,train +vs_gk_188,3117.183494567871,Q3_Medium_Large,18.124022050667904,12353,../nii_data/queen_square_data/vs_gk_188/vs_gk_188_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_188/vs_gk_188_seg_refT1.nii.gz,1,2,L,0,0,train +vs_gk_225,5597.2028732299805,Q4_Large,22.028798413402235,22181,../nii_data/queen_square_data/vs_gk_225/vs_gk_225_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_225/vs_gk_225_seg_refT1.nii.gz,3,3,XL,0,1,train +vs_gk_158,3383.909225463867,Q3_Medium_Large,18.626876167903696,13410,../nii_data/queen_square_data/vs_gk_158/vs_gk_158_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_158/vs_gk_158_seg_refT1.nii.gz,1,2,L,0,0,train +vs_gk_226,1957.9233169555664,Q3_Medium_Large,15.52145220952328,7759,../nii_data/queen_square_data/vs_gk_226/vs_gk_226_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_226/vs_gk_226_seg_refT1.nii.gz,4,2,M,0,0,train +vs_gk_118,768.1297302246094,Q2_Medium_Small,11.3626243947309,3044,../nii_data/queen_square_data/vs_gk_118/vs_gk_118_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_118/vs_gk_118_seg_refT1.nii.gz,4,1,S,0,0,train +vs_gk_3,3212.0641708374023,Q3_Medium_Large,18.306073296305403,12729,../nii_data/queen_square_data/vs_gk_3/vs_gk_3_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_3/vs_gk_3_seg_refT1.nii.gz,4,2,L,0,0,train +vs_gk_239,517.5539016723633,Q1_Small,9.961368087087342,2051,../nii_data/queen_square_data/vs_gk_239/vs_gk_239_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_239/vs_gk_239_seg_refT1.nii.gz,5,0,S,1,0,train +vs_gk_88,3466.425132751465,Q4_Large,18.777066059704747,13737,../nii_data/queen_square_data/vs_gk_88/vs_gk_88_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_88/vs_gk_88_seg_refT1.nii.gz,3,3,L,0,1,train +vs_gk_119,5183.361625671387,Q4_Large,21.47192404840324,20541,../nii_data/queen_square_data/vs_gk_119/vs_gk_119_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_119/vs_gk_119_seg_refT1.nii.gz,2,3,XL,0,1,train +vs_gk_144,1342.4606323242188,Q2_Medium_Small,13.686770304371368,5320,../nii_data/queen_square_data/vs_gk_144/vs_gk_144_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_144/vs_gk_144_seg_refT1.nii.gz,1,1,M,0,0,train +crossmoda2022_etz_61,742.8945426940918,Q2_Medium_Small,11.23680511009344,736,../nii_data/tilburg_data/crossmoda2022_etz_61/crossmoda2022_etz_61_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_61/crossmoda2022_etz_61_Label.nii.gz,3,1,S,0,0,train +crossmoda2022_etz_58,1211.2436771392822,Q2_Medium_Small,13.22546549832516,1200,../nii_data/tilburg_data/crossmoda2022_etz_58/crossmoda2022_etz_58_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_58/crossmoda2022_etz_58_Label.nii.gz,1,1,M,0,0,train +vs_gk_58,2772.231674194336,Q3_Medium_Large,17.429180449916725,10986,../nii_data/queen_square_data/vs_gk_58/vs_gk_58_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_58/vs_gk_58_seg_refT1.nii.gz,1,2,L,0,0,train +vs_gk_189,1072.2021102905271,Q2_Medium_Small,12.69870239114932,4249,../nii_data/queen_square_data/vs_gk_189/vs_gk_189_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_189/vs_gk_189_seg_refT1.nii.gz,5,1,M,0,0,train +crossmoda2022_etz_36,1555.438387989998,Q3_Medium_Large,14.375337959007098,1541,../nii_data/tilburg_data/crossmoda2022_etz_36/crossmoda2022_etz_36_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_36/crossmoda2022_etz_36_Label.nii.gz,1,2,M,0,0,train +vs_gk_147,1983.914566040039,Q3_Medium_Large,15.58983245836877,7862,../nii_data/queen_square_data/vs_gk_147/vs_gk_147_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_147/vs_gk_147_seg_refT1.nii.gz,1,2,M,0,0,train +vs_gk_230,199.3503570556641,Q1_Small,7.247798851660776,1185,../nii_data/queen_square_data/vs_gk_230/vs_gk_230_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_230/vs_gk_230_seg_refT1.nii.gz,4,0,XS,1,0,train +crossmoda2022_etz_43,1315.206740140915,Q2_Medium_Small,13.59351595209355,1303,../nii_data/tilburg_data/crossmoda2022_etz_43/crossmoda2022_etz_43_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_43/crossmoda2022_etz_43_Label.nii.gz,1,1,M,0,0,train +vs_gk_85,174.78904724121094,Q1_Small,6.937002711498812,1039,../nii_data/queen_square_data/vs_gk_85/vs_gk_85_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_85/vs_gk_85_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_34,497.3665237426758,Q1_Small,9.830131092175527,1971,../nii_data/queen_square_data/vs_gk_34/vs_gk_34_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_34/vs_gk_34_seg_refT1.nii.gz,3,0,XS,1,0,train +crossmoda2022_etz_30,261.4265441894531,Q1_Small,7.933235620745557,259,../nii_data/tilburg_data/crossmoda2022_etz_30/crossmoda2022_etz_30_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_30/crossmoda2022_etz_30_Label.nii.gz,1,0,XS,1,0,train +vs_gk_49,1952.1194458007808,Q3_Medium_Large,15.506100285821578,7736,../nii_data/queen_square_data/vs_gk_49/vs_gk_49_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_49/vs_gk_49_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_191,661.6413116455078,Q1_Small,10.811218976263978,2622,../nii_data/queen_square_data/vs_gk_191/vs_gk_191_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_191/vs_gk_191_seg_refT1.nii.gz,5,0,S,1,0,train +vs_gk_77,199.6026992797852,Q1_Small,7.250855705187132,791,../nii_data/queen_square_data/vs_gk_77/vs_gk_77_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_77/vs_gk_77_seg_refT1.nii.gz,4,0,XS,1,0,train +vs_gk_143,1578.1482696533203,Q3_Medium_Large,14.44496166834592,6254,../nii_data/queen_square_data/vs_gk_143/vs_gk_143_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_143/vs_gk_143_seg_refT1.nii.gz,2,2,M,0,0,train +vs_gk_54,1592.2794342041016,Q3_Medium_Large,14.487948309973207,6310,../nii_data/queen_square_data/vs_gk_54/vs_gk_54_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_54/vs_gk_54_seg_refT1.nii.gz,2,2,M,0,0,train +crossmoda2022_etz_85,248.3048658370972,Q1_Small,7.79822094808764,246,../nii_data/tilburg_data/crossmoda2022_etz_85/crossmoda2022_etz_85_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_85/crossmoda2022_etz_85_Label.nii.gz,5,0,XS,1,0,train +vs_gk_72,3547.931671142578,Q4_Large,18.92309668379365,14060,../nii_data/queen_square_data/vs_gk_72/vs_gk_72_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_72/vs_gk_72_seg_refT1.nii.gz,2,3,L,0,1,train +vs_gk_175,672.7443695068359,Q1_Small,10.871358473206469,2666,../nii_data/queen_square_data/vs_gk_175/vs_gk_175_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_175/vs_gk_175_seg_refT1.nii.gz,3,0,S,1,0,train +vs_gk_131,1472.6692199707031,Q3_Medium_Large,14.115692721769218,5836,../nii_data/queen_square_data/vs_gk_131/vs_gk_131_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_131/vs_gk_131_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_65,434.5333099365234,Q1_Small,9.397409466367773,1722,../nii_data/queen_square_data/vs_gk_65/vs_gk_65_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_65/vs_gk_65_seg_refT1.nii.gz,5,0,XS,1,0,train +vs_gk_137,1936.978912353516,Q3_Medium_Large,15.46590803639474,7676,../nii_data/queen_square_data/vs_gk_137/vs_gk_137_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_137/vs_gk_137_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_200,626.0610580444336,Q1_Small,10.61384373942733,2481,../nii_data/queen_square_data/vs_gk_200/vs_gk_200_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_200/vs_gk_200_seg_refT1.nii.gz,1,0,S,1,0,train +vs_gk_120,3235.027313232422,Q3_Medium_Large,18.34959330621269,12820,../nii_data/queen_square_data/vs_gk_120/vs_gk_120_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_120/vs_gk_120_seg_refT1.nii.gz,5,2,L,0,0,train +crossmoda2022_etz_23,3700.340693235397,Q4_Large,19.19026755502384,3666,../nii_data/tilburg_data/crossmoda2022_etz_23/crossmoda2022_etz_23_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_23/crossmoda2022_etz_23_Label.nii.gz,3,3,L,0,1,train +crossmoda2022_etz_80,6295.433807373047,Q4_Large,22.909151972205564,6237,../nii_data/tilburg_data/crossmoda2022_etz_80/crossmoda2022_etz_80_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_80/crossmoda2022_etz_80_Label.nii.gz,2,3,XL,0,1,train +vs_gk_104,5808.665657043457,Q4_Large,22.30279293984266,23019,../nii_data/queen_square_data/vs_gk_104/vs_gk_104_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_104/vs_gk_104_seg_refT1.nii.gz,2,3,XL,0,1,train +crossmoda2022_etz_20,2566.8214659690857,Q3_Medium_Large,16.98761253530319,2543,../nii_data/tilburg_data/crossmoda2022_etz_20/crossmoda2022_etz_20_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_20/crossmoda2022_etz_20_Label.nii.gz,3,2,L,0,0,train +vs_gk_29,3349.7589111328125,Q3_Medium_Large,18.564003555904936,19912,../nii_data/queen_square_data/vs_gk_29/vs_gk_29_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_29/vs_gk_29_seg_refT1.nii.gz,1,2,L,0,0,train +crossmoda2022_etz_15,2222.6303100585938,Q3_Medium_Large,16.191591437438603,2202,../nii_data/tilburg_data/crossmoda2022_etz_15/crossmoda2022_etz_15_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_15/crossmoda2022_etz_15_Label.nii.gz,2,2,M,0,0,train +vs_gk_37,2148.441696166992,Q3_Medium_Large,16.009397331997317,8514,../nii_data/queen_square_data/vs_gk_37/vs_gk_37_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_37/vs_gk_37_seg_refT1.nii.gz,1,2,M,0,0,train +vs_gk_244,767.8773880004883,Q2_Medium_Small,11.36137999384539,3043,../nii_data/queen_square_data/vs_gk_244/vs_gk_244_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_244/vs_gk_244_seg_refT1.nii.gz,3,1,S,0,0,train +vs_gk_151,1531.9696426391602,Q3_Medium_Large,14.302671995707556,6071,../nii_data/queen_square_data/vs_gk_151/vs_gk_151_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_151/vs_gk_151_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_96,571.5551376342773,Q1_Small,10.296426022556291,2265,../nii_data/queen_square_data/vs_gk_96/vs_gk_96_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_96/vs_gk_96_seg_refT1.nii.gz,3,0,S,1,0,train +crossmoda2022_etz_93,1266.7612564563751,Q2_Medium_Small,13.424518465529584,1255,../nii_data/tilburg_data/crossmoda2022_etz_93/crossmoda2022_etz_93_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_93/crossmoda2022_etz_93_Label.nii.gz,5,1,M,0,0,train +vs_gk_81,10030.351066589355,Q4_Large,26.757107931886537,39749,../nii_data/queen_square_data/vs_gk_81/vs_gk_81_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_81/vs_gk_81_seg_refT1.nii.gz,5,3,XL,0,1,train +vs_gk_167,2245.341110229492,Q3_Medium_Large,16.246553133308943,8898,../nii_data/queen_square_data/vs_gk_167/vs_gk_167_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_167/vs_gk_167_seg_refT1.nii.gz,1,2,M,0,0,train +vs_gk_133,1221.0840225219729,Q2_Medium_Small,13.261184238782567,4839,../nii_data/queen_square_data/vs_gk_133/vs_gk_133_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_133/vs_gk_133_seg_refT1.nii.gz,5,1,M,0,0,train +vs_gk_220,5447.05924987793,Q4_Large,21.830038031840537,21586,../nii_data/queen_square_data/vs_gk_220/vs_gk_220_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_220/vs_gk_220_seg_refT1.nii.gz,4,3,XL,0,1,train +vs_gk_195,150.64830780029297,Q1_Small,6.601691288768374,597,../nii_data/queen_square_data/vs_gk_195/vs_gk_195_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_195/vs_gk_195_seg_refT1.nii.gz,4,0,XS,1,0,train +vs_gk_163,272.52960205078125,Q1_Small,8.043993047921813,1080,../nii_data/queen_square_data/vs_gk_163/vs_gk_163_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_163/vs_gk_163_seg_refT1.nii.gz,4,0,XS,1,0,train +crossmoda2022_etz_18,591.4901733398438,Q1_Small,10.41476889163409,586,../nii_data/tilburg_data/crossmoda2022_etz_18/crossmoda2022_etz_18_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_18/crossmoda2022_etz_18_Label.nii.gz,4,0,S,1,0,train +crossmoda2022_etz_94,376.4949096441269,Q1_Small,8.958875379621936,373,../nii_data/tilburg_data/crossmoda2022_etz_94/crossmoda2022_etz_94_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_94/crossmoda2022_etz_94_Label.nii.gz,2,0,XS,1,0,train +crossmoda2022_etz_89,3231.9992065429688,Q3_Medium_Large,18.343866216793817,3202,../nii_data/tilburg_data/crossmoda2022_etz_89/crossmoda2022_etz_89_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_89/crossmoda2022_etz_89_Label.nii.gz,5,2,L,0,0,train +vs_gk_192,1182.2233200073242,Q2_Medium_Small,13.118986943184364,4685,../nii_data/queen_square_data/vs_gk_192/vs_gk_192_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_192/vs_gk_192_seg_refT1.nii.gz,2,1,M,0,0,train +vs_gk_59,2304.8938751220703,Q3_Medium_Large,16.388936423601013,9134,../nii_data/queen_square_data/vs_gk_59/vs_gk_59_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_59/vs_gk_59_seg_refT1.nii.gz,4,2,M,0,0,train +vs_gk_26,2307.16495513916,Q3_Medium_Large,16.394317490997732,9143,../nii_data/queen_square_data/vs_gk_26/vs_gk_26_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_26/vs_gk_26_seg_refT1.nii.gz,3,2,M,0,0,train +vs_gk_245,3645.5881118774414,Q4_Large,19.095146397723283,14447,../nii_data/queen_square_data/vs_gk_245/vs_gk_245_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_245/vs_gk_245_seg_refT1.nii.gz,3,3,L,0,1,train +vs_gk_13,741.1291122436523,Q2_Medium_Small,11.22789692197804,2937,../nii_data/queen_square_data/vs_gk_13/vs_gk_13_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_13/vs_gk_13_seg_refT1.nii.gz,3,1,S,0,0,train +crossmoda2022_etz_19,11454.318237304688,Q4_Large,27.967707842875583,11348,../nii_data/tilburg_data/crossmoda2022_etz_19/crossmoda2022_etz_19_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_19/crossmoda2022_etz_19_Label.nii.gz,3,3,XL,0,1,train +vs_gk_234,10988.494491577148,Q4_Large,27.583319134244416,43546,../nii_data/queen_square_data/vs_gk_234/vs_gk_234_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_234/vs_gk_234_seg_refT1.nii.gz,4,3,XL,0,1,train +vs_gk_169,854.4307708740234,Q2_Medium_Small,11.77315204239993,3386,../nii_data/queen_square_data/vs_gk_169/vs_gk_169_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_169/vs_gk_169_seg_refT1.nii.gz,5,1,S,0,0,train +vs_gk_67,2708.389091491699,Q3_Medium_Large,17.294346043198335,10733,../nii_data/queen_square_data/vs_gk_67/vs_gk_67_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_67/vs_gk_67_seg_refT1.nii.gz,1,2,L,0,0,train +crossmoda2022_etz_24,3614.550018310547,Q4_Large,19.04080062262961,3581,../nii_data/tilburg_data/crossmoda2022_etz_24/crossmoda2022_etz_24_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_24/crossmoda2022_etz_24_Label.nii.gz,4,3,L,0,1,train +crossmoda2022_etz_27,1038.640103816986,Q2_Medium_Small,12.564797346607545,1029,../nii_data/tilburg_data/crossmoda2022_etz_27/crossmoda2022_etz_27_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_27/crossmoda2022_etz_27_Label.nii.gz,5,1,M,0,0,train +crossmoda2022_etz_3,423.9347863197327,Q1_Small,9.320376954523017,420,../nii_data/tilburg_data/crossmoda2022_etz_3/crossmoda2022_etz_3_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_3/crossmoda2022_etz_3_Label.nii.gz,1,0,XS,1,0,train +vs_gk_60,255.87501525878903,Q1_Small,7.876677902481717,1521,../nii_data/queen_square_data/vs_gk_60/vs_gk_60_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_60/vs_gk_60_seg_refT1.nii.gz,5,0,XS,1,0,train +vs_gk_35,1832.0045471191409,Q3_Medium_Large,15.181312088183317,7260,../nii_data/queen_square_data/vs_gk_35/vs_gk_35_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_35/vs_gk_35_seg_refT1.nii.gz,2,2,M,0,0,train +vs_gk_198,1377.788543701172,Q2_Medium_Small,13.805791672427349,5460,../nii_data/queen_square_data/vs_gk_198/vs_gk_198_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_198/vs_gk_198_seg_refT1.nii.gz,3,1,M,0,0,train +vs_gk_93,121.79718017578124,Q1_Small,6.150062610731983,724,../nii_data/queen_square_data/vs_gk_93/vs_gk_93_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_93/vs_gk_93_seg_refT1.nii.gz,4,0,XS,1,0,train +vs_gk_135,1079.520034790039,Q2_Medium_Small,12.72752703367157,4278,../nii_data/queen_square_data/vs_gk_135/vs_gk_135_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_135/vs_gk_135_seg_refT1.nii.gz,2,1,M,0,0,train +vs_gk_170,255.622673034668,Q1_Small,7.874087741853501,1013,../nii_data/queen_square_data/vs_gk_170/vs_gk_170_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_170/vs_gk_170_seg_refT1.nii.gz,3,0,XS,1,0,train +vs_gk_69,2212.53662109375,Q3_Medium_Large,16.16704380816796,8768,../nii_data/queen_square_data/vs_gk_69/vs_gk_69_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_69/vs_gk_69_seg_refT1.nii.gz,2,2,M,0,0,train +vs_gk_142,9984.92946624756,Q4_Large,26.716657708303224,39569,../nii_data/queen_square_data/vs_gk_142/vs_gk_142_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_142/vs_gk_142_seg_refT1.nii.gz,2,3,XL,0,1,train +vs_gk_52,2546.890068054199,Q3_Medium_Large,16.943528565199635,10093,../nii_data/queen_square_data/vs_gk_52/vs_gk_52_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_52/vs_gk_52_seg_refT1.nii.gz,5,2,L,0,0,train +vs_gk_222,346.9705581665039,Q1_Small,8.718290463882607,1375,../nii_data/queen_square_data/vs_gk_222/vs_gk_222_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_222/vs_gk_222_seg_refT1.nii.gz,5,0,XS,1,0,train +vs_gk_41,540.2647018432617,Q1_Small,10.104992383748735,2141,../nii_data/queen_square_data/vs_gk_41/vs_gk_41_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_41/vs_gk_41_seg_refT1.nii.gz,5,0,S,1,0,train +crossmoda2022_etz_92,3745.766647696495,Q4_Large,19.2684759538078,3711,../nii_data/tilburg_data/crossmoda2022_etz_92/crossmoda2022_etz_92_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_92/crossmoda2022_etz_92_Label.nii.gz,4,3,L,0,1,train +vs_gk_79,290.0253295898437,Q1_Small,8.212570339066223,1724,../nii_data/queen_square_data/vs_gk_79/vs_gk_79_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_79/vs_gk_79_seg_refT1.nii.gz,5,0,XS,1,0,train +vs_gk_28,457.74879455566406,Q1_Small,9.561870604404854,1814,../nii_data/queen_square_data/vs_gk_28/vs_gk_28_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_28/vs_gk_28_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_48,2029.0838241577148,Q3_Medium_Large,15.707260826367737,8041,../nii_data/queen_square_data/vs_gk_48/vs_gk_48_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_48/vs_gk_48_seg_refT1.nii.gz,2,2,M,0,0,train +vs_gk_162,624.547004699707,Q1_Small,10.605280731483068,2475,../nii_data/queen_square_data/vs_gk_162/vs_gk_162_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_162/vs_gk_162_seg_refT1.nii.gz,3,0,S,1,0,train +vs_gk_141,77.72140502929688,Q1_Small,5.294772935246936,462,../nii_data/queen_square_data/vs_gk_141/vs_gk_141_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_141/vs_gk_141_seg_refT1.nii.gz,4,0,XS,1,0,train +vs_gk_228,7100.405502319336,Q4_Large,23.84669387009796,28138,../nii_data/queen_square_data/vs_gk_228/vs_gk_228_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_228/vs_gk_228_seg_refT1.nii.gz,5,3,XL,0,1,train +vs_gk_17,5914.9017333984375,Q4_Large,22.4379393888014,23440,../nii_data/queen_square_data/vs_gk_17/vs_gk_17_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_17/vs_gk_17_seg_refT1.nii.gz,1,3,XL,0,1,train +crossmoda2022_etz_11,5564.650726318359,Q4_Large,21.98601041464094,5513,../nii_data/tilburg_data/crossmoda2022_etz_11/crossmoda2022_etz_11_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_11/crossmoda2022_etz_11_Label.nii.gz,3,3,XL,0,1,train +vs_gk_232,1148.4094619750977,Q2_Medium_Small,12.992699077625286,4551,../nii_data/queen_square_data/vs_gk_232/vs_gk_232_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_232/vs_gk_232_seg_refT1.nii.gz,5,1,M,0,0,train +vs_gk_139,638.1734848022461,Q1_Small,10.681855807361211,2529,../nii_data/queen_square_data/vs_gk_139/vs_gk_139_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_139/vs_gk_139_seg_refT1.nii.gz,1,0,S,1,0,train +vs_gk_247,1275.0852584838867,Q2_Medium_Small,13.45385886525046,5053,../nii_data/queen_square_data/vs_gk_247/vs_gk_247_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_247/vs_gk_247_seg_refT1.nii.gz,5,1,M,0,0,train +vs_gk_132,428.9817810058594,Q1_Small,9.357217877180432,1700,../nii_data/queen_square_data/vs_gk_132/vs_gk_132_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_132/vs_gk_132_seg_refT1.nii.gz,2,0,XS,1,0,train +vs_gk_111,96.8994140625,Q1_Small,5.698678761879643,576,../nii_data/queen_square_data/vs_gk_111/vs_gk_111_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_111/vs_gk_111_seg_refT1.nii.gz,3,0,XS,1,0,train +crossmoda2022_etz_99,3913.325984716416,Q4_Large,19.55160740814026,3877,../nii_data/tilburg_data/crossmoda2022_etz_99/crossmoda2022_etz_99_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_99/crossmoda2022_etz_99_Label.nii.gz,5,3,L,0,1,train +crossmoda2022_etz_39,6452.895355224609,Q4_Large,23.09858272100477,6393,../nii_data/tilburg_data/crossmoda2022_etz_39/crossmoda2022_etz_39_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_39/crossmoda2022_etz_39_Label.nii.gz,1,3,XL,0,1,train +vs_gk_84,575.5926132202148,Q1_Small,10.320613868942852,2281,../nii_data/queen_square_data/vs_gk_84/vs_gk_84_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_84/vs_gk_84_seg_refT1.nii.gz,5,0,S,1,0,train +crossmoda2022_etz_71,5034.732055664063,Q4_Large,21.26469934795613,4988,../nii_data/tilburg_data/crossmoda2022_etz_71/crossmoda2022_etz_71_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_71/crossmoda2022_etz_71_Label.nii.gz,4,3,XL,0,1,val +vs_gk_4,765.6063079833984,Q2_Medium_Small,11.35016809841078,3034,../nii_data/queen_square_data/vs_gk_4/vs_gk_4_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_4/vs_gk_4_seg_refT1.nii.gz,2,1,S,0,0,val +vs_gk_76,7529.134941101074,Q4_Large,24.3173074761068,29837,../nii_data/queen_square_data/vs_gk_76/vs_gk_76_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_76/vs_gk_76_seg_refT1.nii.gz,5,3,XL,0,1,val +vs_gk_174,302.5583267211914,Q1_Small,8.329203938091256,1199,../nii_data/queen_square_data/vs_gk_174/vs_gk_174_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_174/vs_gk_174_seg_refT1.nii.gz,2,0,XS,1,0,val +vs_gk_16,869.318962097168,Q2_Medium_Small,11.841139843999011,3445,../nii_data/queen_square_data/vs_gk_16/vs_gk_16_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_16/vs_gk_16_seg_refT1.nii.gz,3,1,S,0,0,val +crossmoda2022_etz_101,325.01678466796875,Q1_Small,8.530393313140182,322,../nii_data/tilburg_data/crossmoda2022_etz_101/crossmoda2022_etz_101_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_101/crossmoda2022_etz_101_Label.nii.gz,2,0,XS,1,0,val +vs_gk_64,2473.963165283203,Q3_Medium_Large,16.78024132259808,9804,../nii_data/queen_square_data/vs_gk_64/vs_gk_64_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_64/vs_gk_64_seg_refT1.nii.gz,4,2,M,0,0,val +vs_gk_243,750.2134323120117,Q2_Medium_Small,11.273585713902063,2973,../nii_data/queen_square_data/vs_gk_243/vs_gk_243_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_243/vs_gk_243_seg_refT1.nii.gz,3,1,S,0,0,val +vs_gk_173,6871.783447265625,Q4_Large,23.5879544736398,27232,../nii_data/queen_square_data/vs_gk_173/vs_gk_173_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_173/vs_gk_173_seg_refT1.nii.gz,5,3,XL,0,1,val +vs_gk_180,540.5170440673828,Q1_Small,10.106565390060531,2142,../nii_data/queen_square_data/vs_gk_180/vs_gk_180_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_180/vs_gk_180_seg_refT1.nii.gz,3,0,S,1,0,val +vs_gk_112,99.25460815429688,Q1_Small,5.744479522235329,590,../nii_data/queen_square_data/vs_gk_112/vs_gk_112_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_112/vs_gk_112_seg_refT1.nii.gz,4,0,XS,1,0,val +crossmoda2022_etz_83,704.5394065380096,Q2_Medium_Small,11.039995311958291,698,../nii_data/tilburg_data/crossmoda2022_etz_83/crossmoda2022_etz_83_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_83/crossmoda2022_etz_83_Label.nii.gz,2,1,S,0,0,val +crossmoda2022_etz_38,3650.886005043984,Q4_Large,19.10439182690344,3617,../nii_data/tilburg_data/crossmoda2022_etz_38/crossmoda2022_etz_38_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_38/crossmoda2022_etz_38_Label.nii.gz,4,3,L,0,1,val +vs_gk_138,4113.68293762207,Q4_Large,19.87974141464836,16302,../nii_data/queen_square_data/vs_gk_138/vs_gk_138_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_138/vs_gk_138_seg_refT1.nii.gz,1,3,L,0,1,val +vs_gk_202,3160.081672668457,Q3_Medium_Large,18.206783513024845,12523,../nii_data/queen_square_data/vs_gk_202/vs_gk_202_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_202/vs_gk_202_seg_refT1.nii.gz,5,2,L,0,0,val +crossmoda2022_etz_14,1582.6904296875,Q3_Medium_Large,14.458806688004806,1568,../nii_data/tilburg_data/crossmoda2022_etz_14/crossmoda2022_etz_14_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_14/crossmoda2022_etz_14_Label.nii.gz,3,2,M,0,0,val +vs_gk_182,197.07927703857425,Q1_Small,7.22017037949382,781,../nii_data/queen_square_data/vs_gk_182/vs_gk_182_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_182/vs_gk_182_seg_refT1.nii.gz,4,0,XS,1,0,val +crossmoda2022_etz_28,2344.767823457718,Q3_Medium_Large,16.48290445339813,2323,../nii_data/tilburg_data/crossmoda2022_etz_28/crossmoda2022_etz_28_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_28/crossmoda2022_etz_28_Label.nii.gz,2,2,M,0,0,val +vs_gk_51,456.4870834350586,Q1_Small,9.553077266168753,1809,../nii_data/queen_square_data/vs_gk_51/vs_gk_51_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_51/vs_gk_51_seg_refT1.nii.gz,3,0,XS,1,0,val +crossmoda2022_etz_57,270.5108642578125,Q1_Small,8.02408209261212,268,../nii_data/tilburg_data/crossmoda2022_etz_57/crossmoda2022_etz_57_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_57/crossmoda2022_etz_57_Label.nii.gz,2,0,XS,1,0,val +vs_gk_217,5387.254142761231,Q4_Large,21.749850718596942,21349,../nii_data/queen_square_data/vs_gk_217/vs_gk_217_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_217/vs_gk_217_seg_refT1.nii.gz,1,3,XL,0,1,val +vs_gk_179,2641.7707443237305,Q3_Medium_Large,17.151370741808194,10469,../nii_data/queen_square_data/vs_gk_179/vs_gk_179_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_179/vs_gk_179_seg_refT1.nii.gz,5,2,L,0,0,val +vs_gk_214,955.3676605224608,Q2_Medium_Small,12.219608857834675,3786,../nii_data/queen_square_data/vs_gk_214/vs_gk_214_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_214/vs_gk_214_seg_refT1.nii.gz,4,1,S,0,0,val +crossmoda2022_etz_29,317.9515027999878,Q1_Small,8.468128115577644,315,../nii_data/tilburg_data/crossmoda2022_etz_29/crossmoda2022_etz_29_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_29/crossmoda2022_etz_29_Label.nii.gz,3,0,XS,1,0,val +crossmoda2022_etz_64,2595.090804219246,Q3_Medium_Large,17.04974856646933,2571,../nii_data/tilburg_data/crossmoda2022_etz_64/crossmoda2022_etz_64_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_64/crossmoda2022_etz_64_Label.nii.gz,4,2,L,0,0,val +vs_gk_74,1677.5711059570312,Q3_Medium_Large,14.742148163135614,6648,../nii_data/queen_square_data/vs_gk_74/vs_gk_74_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_74/vs_gk_74_seg_refT1.nii.gz,5,2,M,0,0,val +crossmoda2022_etz_13,2696.0243225097656,Q3_Medium_Large,17.267987590027587,2671,../nii_data/tilburg_data/crossmoda2022_etz_13/crossmoda2022_etz_13_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_13/crossmoda2022_etz_13_Label.nii.gz,2,2,L,0,0,val +crossmoda2022_etz_47,1370.7229614257812,Q2_Medium_Small,13.782151571944448,1358,../nii_data/tilburg_data/crossmoda2022_etz_47/crossmoda2022_etz_47_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_47/crossmoda2022_etz_47_Label.nii.gz,3,1,M,0,0,val +vs_gk_45,2212.53662109375,Q3_Medium_Large,16.16704380816796,8768,../nii_data/queen_square_data/vs_gk_45/vs_gk_45_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_45/vs_gk_45_seg_refT1.nii.gz,3,2,M,0,0,val +vs_gk_108,7490.526580810547,Q4_Large,24.27567096542248,29684,../nii_data/queen_square_data/vs_gk_108/vs_gk_108_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_108/vs_gk_108_seg_refT1.nii.gz,4,3,XL,0,1,val +vs_gk_117,999.0228652954102,Q2_Medium_Small,12.402967394413388,3959,../nii_data/queen_square_data/vs_gk_117/vs_gk_117_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_117/vs_gk_117_seg_refT1.nii.gz,4,1,S,0,0,val +vs_gk_42,444.122314453125,Q1_Small,9.466032488701732,1760,../nii_data/queen_square_data/vs_gk_42/vs_gk_42_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_42/vs_gk_42_seg_refT1.nii.gz,1,0,XS,1,0,val +crossmoda2022_etz_26,916.5054426193236,Q2_Medium_Small,12.05162127313325,908,../nii_data/tilburg_data/crossmoda2022_etz_26/crossmoda2022_etz_26_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_26/crossmoda2022_etz_26_Label.nii.gz,1,1,S,0,0,val +crossmoda2022_etz_52,5108.415985107422,Q4_Large,21.36793452679421,5061,../nii_data/tilburg_data/crossmoda2022_etz_52/crossmoda2022_etz_52_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_52/crossmoda2022_etz_52_Label.nii.gz,5,3,XL,0,1,val +crossmoda2022_etz_55,3197.6799087524414,Q3_Medium_Large,18.27870632516928,3168,../nii_data/tilburg_data/crossmoda2022_etz_55/crossmoda2022_etz_55_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_55/crossmoda2022_etz_55_Label.nii.gz,1,2,L,0,0,val +vs_gk_7,5528.313446044922,Q4_Large,21.938049505192545,21908,../nii_data/queen_square_data/vs_gk_7/vs_gk_7_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_7/vs_gk_7_seg_refT1.nii.gz,2,3,XL,0,1,val +vs_gk_113,606.6307067871094,Q1_Small,10.502884490657486,2404,../nii_data/queen_square_data/vs_gk_113/vs_gk_113_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_113/vs_gk_113_seg_refT1.nii.gz,5,0,S,1,0,val +crossmoda2022_etz_1,357.31658935546875,Q1_Small,8.804097684161087,354,../nii_data/tilburg_data/crossmoda2022_etz_1/crossmoda2022_etz_1_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_1/crossmoda2022_etz_1_Label.nii.gz,2,0,XS,1,0,val +vs_gk_231,2665.4909133911133,Q3_Medium_Large,17.202551288655922,10563,../nii_data/queen_square_data/vs_gk_231/vs_gk_231_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_231/vs_gk_231_seg_refT1.nii.gz,4,2,L,0,0,val +vs_gk_14,1294.0109252929688,Q2_Medium_Small,13.520095945465664,5128,../nii_data/queen_square_data/vs_gk_14/vs_gk_14_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_14/vs_gk_14_seg_refT1.nii.gz,5,1,M,0,0,val +crossmoda2022_etz_84,334.1001182794571,Q1_Small,8.609131385069263,331,../nii_data/tilburg_data/crossmoda2022_etz_84/crossmoda2022_etz_84_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_84/crossmoda2022_etz_84_Label.nii.gz,2,0,XS,1,0,val +vs_gk_145,1082.295799255371,Q2_Medium_Small,12.738426439338324,4289,../nii_data/queen_square_data/vs_gk_145/vs_gk_145_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_145/vs_gk_145_seg_refT1.nii.gz,3,1,M,0,0,val +vs_gk_205,499.3852615356445,Q1_Small,9.843412825730828,1979,../nii_data/queen_square_data/vs_gk_205/vs_gk_205_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_205/vs_gk_205_seg_refT1.nii.gz,5,0,XS,1,0,val +vs_gk_235,576.6019821166992,Q1_Small,10.326643146709143,2285,../nii_data/queen_square_data/vs_gk_235/vs_gk_235_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_235/vs_gk_235_seg_refT1.nii.gz,1,0,S,1,0,val +vs_gk_114,4888.625907897949,Q4_Large,21.05697884952108,19373,../nii_data/queen_square_data/vs_gk_114/vs_gk_114_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_114/vs_gk_114_seg_refT1.nii.gz,2,3,L,0,1,val +vs_gk_62,3326.8798828125,Q3_Medium_Large,18.52164259520501,13184,../nii_data/queen_square_data/vs_gk_62/vs_gk_62_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_62/vs_gk_62_seg_refT1.nii.gz,4,2,L,0,0,val +vs_gk_38,6724.920272827148,Q4_Large,23.418703020138874,26650,../nii_data/queen_square_data/vs_gk_38/vs_gk_38_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_38/vs_gk_38_seg_refT1.nii.gz,4,3,XL,0,1,val +vs_gk_207,3874.7148513793945,Q4_Large,19.48709227747513,15355,../nii_data/queen_square_data/vs_gk_207/vs_gk_207_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_207/vs_gk_207_seg_refT1.nii.gz,3,3,L,0,1,val +vs_gk_186,2341.231155395508,Q3_Medium_Large,16.47461310008943,9278,../nii_data/queen_square_data/vs_gk_186/vs_gk_186_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_186/vs_gk_186_seg_refT1.nii.gz,2,2,M,0,0,val +vs_gk_206,4790.717124938965,Q4_Large,20.915454284493272,18985,../nii_data/queen_square_data/vs_gk_206/vs_gk_206_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_206/vs_gk_206_seg_refT1.nii.gz,1,3,L,0,1,val +vs_gk_105,907.6749801635742,Q2_Medium_Small,12.012790800714887,3597,../nii_data/queen_square_data/vs_gk_105/vs_gk_105_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_105/vs_gk_105_seg_refT1.nii.gz,1,1,S,0,0,val +crossmoda2022_etz_103,8508.979797363281,Q4_Large,25.329480933820925,8430,../nii_data/tilburg_data/crossmoda2022_etz_103/crossmoda2022_etz_103_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_103/crossmoda2022_etz_103_Label.nii.gz,5,3,XL,0,1,val +vs_gk_8,1063.8748168945312,Q2_Medium_Small,12.665741948843069,4216,../nii_data/queen_square_data/vs_gk_8/vs_gk_8_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_8/vs_gk_8_seg_refT1.nii.gz,2,1,M,0,0,val +vs_gk_30,7943.228530883789,Q4_Large,24.755183224927148,31478,../nii_data/queen_square_data/vs_gk_30/vs_gk_30_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_30/vs_gk_30_seg_refT1.nii.gz,5,3,XL,0,1,val +crossmoda2022_etz_44,2093.4310913085938,Q3_Medium_Large,15.871574642049378,2074,../nii_data/tilburg_data/crossmoda2022_etz_44/crossmoda2022_etz_44_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_44/crossmoda2022_etz_44_Label.nii.gz,4,2,M,0,0,val +crossmoda2022_etz_12,802.4482727050781,Q2_Medium_Small,11.5293849899612,795,../nii_data/tilburg_data/crossmoda2022_etz_12/crossmoda2022_etz_12_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_12/crossmoda2022_etz_12_Label.nii.gz,5,1,S,0,0,val +vs_gk_89,3377.3483276367188,Q3_Medium_Large,18.614830129811985,13384,../nii_data/queen_square_data/vs_gk_89/vs_gk_89_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_89/vs_gk_89_seg_refT1.nii.gz,4,2,L,0,0,val +crossmoda2022_etz_48,296.75445556640625,Q1_Small,8.275600973042236,294,../nii_data/tilburg_data/crossmoda2022_etz_48/crossmoda2022_etz_48_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_48/crossmoda2022_etz_48_Label.nii.gz,3,0,XS,1,0,val +vs_gk_94,683.0904006958008,Q2_Medium_Small,10.926804848040332,2707,../nii_data/queen_square_data/vs_gk_94/vs_gk_94_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_94/vs_gk_94_seg_refT1.nii.gz,1,1,S,0,0,val +crossmoda2022_etz_45,2895.879364013672,Q3_Medium_Large,17.684547340366127,2869,../nii_data/tilburg_data/crossmoda2022_etz_45/crossmoda2022_etz_45_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_45/crossmoda2022_etz_45_Label.nii.gz,5,2,L,0,0,val +crossmoda2022_etz_96,1702.8069372177124,Q3_Medium_Large,14.815702979805238,1687,../nii_data/tilburg_data/crossmoda2022_etz_96/crossmoda2022_etz_96_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_96/crossmoda2022_etz_96_Label.nii.gz,1,2,M,0,0,val +crossmoda2022_etz_88,1021.4813232421876,Q2_Medium_Small,12.495220834205282,1012,../nii_data/tilburg_data/crossmoda2022_etz_88/crossmoda2022_etz_88_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_88/crossmoda2022_etz_88_Label.nii.gz,1,1,M,0,0,val +crossmoda2022_etz_65,3536.82861328125,Q4_Large,18.903336458340867,3504,../nii_data/tilburg_data/crossmoda2022_etz_65/crossmoda2022_etz_65_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_65/crossmoda2022_etz_65_Label.nii.gz,2,3,L,0,1,val +crossmoda2022_etz_53,461.2815856933594,Q1_Small,9.58640630369382,457,../nii_data/tilburg_data/crossmoda2022_etz_53/crossmoda2022_etz_53_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_53/crossmoda2022_etz_53_Label.nii.gz,4,0,XS,1,0,val +crossmoda2022_etz_74,3713.468170166016,Q4_Large,19.21293415920296,3679,../nii_data/tilburg_data/crossmoda2022_etz_74/crossmoda2022_etz_74_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_74/crossmoda2022_etz_74_Label.nii.gz,5,3,L,0,1,val +crossmoda2022_etz_8,520.8343505859375,Q1_Small,9.98237006056808,516,../nii_data/tilburg_data/crossmoda2022_etz_8/crossmoda2022_etz_8_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_8/crossmoda2022_etz_8_Label.nii.gz,3,0,S,1,0,val +vs_gk_197,1938.240623474121,Q3_Medium_Large,15.4692653733726,7681,../nii_data/queen_square_data/vs_gk_197/vs_gk_197_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_197/vs_gk_197_seg_refT1.nii.gz,2,2,M,0,0,val +vs_gk_126,4501.28059387207,Q4_Large,20.485464755789657,17838,../nii_data/queen_square_data/vs_gk_126/vs_gk_126_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_126/vs_gk_126_seg_refT1.nii.gz,3,3,L,0,1,val +crossmoda2022_etz_50,960.919189453125,Q2_Medium_Small,12.24323206205572,952,../nii_data/tilburg_data/crossmoda2022_etz_50/crossmoda2022_etz_50_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_50/crossmoda2022_etz_50_Label.nii.gz,5,1,S,0,0,val +vs_gk_55,1395.2001571655271,Q2_Medium_Small,13.863704662701313,5529,../nii_data/queen_square_data/vs_gk_55/vs_gk_55_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_55/vs_gk_55_seg_refT1.nii.gz,2,1,M,0,0,val +vs_gk_12,1706.842803955078,Q3_Medium_Large,14.827398771366973,6764,../nii_data/queen_square_data/vs_gk_12/vs_gk_12_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_12/vs_gk_12_seg_refT1.nii.gz,5,2,M,0,0,test +vs_gk_2,1181.466293334961,Q2_Medium_Small,13.116186134614368,4682,../nii_data/queen_square_data/vs_gk_2/vs_gk_2_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_2/vs_gk_2_seg_refT1.nii.gz,4,1,M,0,0,test +vs_gk_134,3417.218399047852,Q3_Medium_Large,18.68779399054685,13542,../nii_data/queen_square_data/vs_gk_134/vs_gk_134_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_134/vs_gk_134_seg_refT1.nii.gz,2,2,L,0,0,test +vs_gk_32,3636.503791809082,Q4_Large,19.07927235114296,14411,../nii_data/queen_square_data/vs_gk_32/vs_gk_32_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_32/vs_gk_32_seg_refT1.nii.gz,4,3,L,0,1,test +vs_gk_66,7473.619651794434,Q4_Large,24.25739293463497,29617,../nii_data/queen_square_data/vs_gk_66/vs_gk_66_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_66/vs_gk_66_seg_refT1.nii.gz,4,3,XL,0,1,test +vs_gk_27,1367.694854736328,Q2_Medium_Small,13.771995229598543,5420,../nii_data/queen_square_data/vs_gk_27/vs_gk_27_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_27/vs_gk_27_seg_refT1.nii.gz,4,1,M,0,0,test +vs_gk_1,3669.055938720703,Q4_Large,19.13603266279162,14540,../nii_data/queen_square_data/vs_gk_1/vs_gk_1_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_1/vs_gk_1_seg_refT1.nii.gz,5,3,L,0,1,test +crossmoda2022_etz_5,333.0917358398437,Q1_Small,8.600461283818992,330,../nii_data/tilburg_data/crossmoda2022_etz_5/crossmoda2022_etz_5_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_5/crossmoda2022_etz_5_Label.nii.gz,4,0,XS,1,0,test +crossmoda2022_etz_81,2027.8242684602733,Q3_Medium_Large,15.70401005464464,2009,../nii_data/tilburg_data/crossmoda2022_etz_81/crossmoda2022_etz_81_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_81/crossmoda2022_etz_81_Label.nii.gz,5,2,M,0,0,test +vs_gk_196,792.3545837402344,Q2_Medium_Small,11.480839636081102,3140,../nii_data/queen_square_data/vs_gk_196/vs_gk_196_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_196/vs_gk_196_seg_refT1.nii.gz,5,1,S,0,0,test +vs_gk_101,218.023681640625,Q1_Small,7.467381661778542,864,../nii_data/queen_square_data/vs_gk_101/vs_gk_101_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_101/vs_gk_101_seg_refT1.nii.gz,4,0,XS,1,0,test +crossmoda2022_etz_34,63.590240478515625,Q1_Small,4.952189804235541,63,../nii_data/tilburg_data/crossmoda2022_etz_34/crossmoda2022_etz_34_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_34/crossmoda2022_etz_34_Label.nii.gz,3,0,XS,1,0,test +crossmoda2022_etz_31,472.3846435546875,Q1_Small,9.662712313674025,468,../nii_data/tilburg_data/crossmoda2022_etz_31/crossmoda2022_etz_31_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_31/crossmoda2022_etz_31_Label.nii.gz,1,0,XS,1,0,test +vs_gk_63,711.8574142456055,Q2_Medium_Small,11.07808764583436,2821,../nii_data/queen_square_data/vs_gk_63/vs_gk_63_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_63/vs_gk_63_seg_refT1.nii.gz,2,1,S,0,0,test +vs_gk_240,3031.387138366699,Q3_Medium_Large,17.956192823367203,12013,../nii_data/queen_square_data/vs_gk_240/vs_gk_240_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_240/vs_gk_240_seg_refT1.nii.gz,3,2,L,0,0,test +vs_gk_6,3484.0890884399414,Q4_Large,18.80890630440923,13807,../nii_data/queen_square_data/vs_gk_6/vs_gk_6_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_6/vs_gk_6_seg_refT1.nii.gz,1,3,L,0,1,test +crossmoda2022_etz_25,913.4774488210678,Q2_Medium_Small,12.038334391988109,905,../nii_data/tilburg_data/crossmoda2022_etz_25/crossmoda2022_etz_25_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_25/crossmoda2022_etz_25_Label.nii.gz,2,1,S,0,0,test +vs_gk_22,362.6998901367188,Q1_Small,8.848091407459945,2156,../nii_data/queen_square_data/vs_gk_22/vs_gk_22_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_22/vs_gk_22_seg_refT1.nii.gz,5,0,XS,1,0,test +vs_gk_80,1767.6572799682615,Q3_Medium_Large,15.00144686131186,7005,../nii_data/queen_square_data/vs_gk_80/vs_gk_80_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_80/vs_gk_80_seg_refT1.nii.gz,2,2,M,0,0,test +vs_gk_178,741.6337966918945,Q2_Medium_Small,11.230444952684367,2939,../nii_data/queen_square_data/vs_gk_178/vs_gk_178_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_178/vs_gk_178_seg_refT1.nii.gz,4,1,S,0,0,test +vs_gk_127,4874.494743347168,Q4_Large,21.036670020366824,19317,../nii_data/queen_square_data/vs_gk_127/vs_gk_127_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_127/vs_gk_127_seg_refT1.nii.gz,3,3,L,0,1,test +vs_gk_216,575.3402709960938,Q1_Small,10.319105448303285,2280,../nii_data/queen_square_data/vs_gk_216/vs_gk_216_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_216/vs_gk_216_seg_refT1.nii.gz,1,0,S,1,0,test +vs_gk_90,349.2416381835937,Q1_Small,8.737270836847156,1384,../nii_data/queen_square_data/vs_gk_90/vs_gk_90_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_90/vs_gk_90_seg_refT1.nii.gz,5,0,XS,1,0,test +crossmoda2022_etz_72,700.5026760101318,Q2_Medium_Small,11.018869986757547,694,../nii_data/tilburg_data/crossmoda2022_etz_72/crossmoda2022_etz_72_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_72/crossmoda2022_etz_72_Label.nii.gz,2,1,S,0,0,test +crossmoda2022_etz_91,3913.323211669922,Q4_Large,19.551602789943413,3877,../nii_data/tilburg_data/crossmoda2022_etz_91/crossmoda2022_etz_91_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_91/crossmoda2022_etz_91_Label.nii.gz,2,3,L,0,1,test +vs_gk_209,6967.168807983398,Q4_Large,23.696592685370923,27610,../nii_data/queen_square_data/vs_gk_209/vs_gk_209_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_209/vs_gk_209_seg_refT1.nii.gz,1,3,XL,0,1,test +crossmoda2022_etz_17,369.4291906356812,Q1_Small,8.902477035335542,366,../nii_data/tilburg_data/crossmoda2022_etz_17/crossmoda2022_etz_17_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_17/crossmoda2022_etz_17_Label.nii.gz,1,0,XS,1,0,test +crossmoda2022_etz_75,2407.3448181152344,Q3_Medium_Large,16.628250547680228,2385,../nii_data/tilburg_data/crossmoda2022_etz_75/crossmoda2022_etz_75_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_75/crossmoda2022_etz_75_Label.nii.gz,3,2,M,0,0,test +crossmoda2022_etz_2,3446.989489197731,Q4_Large,18.741907018201733,3415,../nii_data/tilburg_data/crossmoda2022_etz_2/crossmoda2022_etz_2_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_2/crossmoda2022_etz_2_Label.nii.gz,1,3,L,0,1,test +vs_gk_40,1892.061996459961,Q3_Medium_Large,15.345424734626713,7498,../nii_data/queen_square_data/vs_gk_40/vs_gk_40_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_40/vs_gk_40_seg_refT1.nii.gz,1,2,M,0,0,test +vs_gk_156,6305.2751541137695,Q4_Large,22.921083346328373,24987,../nii_data/queen_square_data/vs_gk_156/vs_gk_156_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_156/vs_gk_156_seg_refT1.nii.gz,3,3,XL,0,1,test +crossmoda2022_etz_73,1797.6834568977356,Q3_Medium_Large,15.08591071992531,1781,../nii_data/tilburg_data/crossmoda2022_etz_73/crossmoda2022_etz_73_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_73/crossmoda2022_etz_73_Label.nii.gz,5,2,M,0,0,test +crossmoda2022_etz_10,1030.565278172493,Q2_Medium_Small,12.532151256091517,1021,../nii_data/tilburg_data/crossmoda2022_etz_10/crossmoda2022_etz_10_ceT1.nii.gz,../nii_data/tilburg_data/crossmoda2022_etz_10/crossmoda2022_etz_10_Label.nii.gz,2,1,M,0,0,test +vs_gk_68,601.8362045288086,Q1_Small,10.475141443974136,2385,../nii_data/queen_square_data/vs_gk_68/vs_gk_68_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_68/vs_gk_68_seg_refT1.nii.gz,1,0,S,1,0,test +vs_gk_124,1387.3775482177734,Q2_Medium_Small,13.83774574448326,5498,../nii_data/queen_square_data/vs_gk_124/vs_gk_124_t1_refT1.nii.gz,../nii_data/queen_square_data/vs_gk_124/vs_gk_124_seg_refT1.nii.gz,4,1,M,0,0,test From 9dd12f8bff1cbd530556e6802acfe0d4de353b9d Mon Sep 17 00:00:00 2001 From: Sathiesh Date: Mon, 6 Jul 2026 08:36:05 +0200 Subject: [PATCH 3/8] research: stop tracking regenerated inference_patch_config.json --- .gitignore | 1 + research/vestibular_schwannoma/inference_patch_config.json | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 research/vestibular_schwannoma/inference_patch_config.json diff --git a/.gitignore b/.gitignore index 0fbd8a8..3251437 100644 --- a/.gitignore +++ b/.gitignore @@ -164,6 +164,7 @@ patch_config.json # Patch-workflow run byproducts (regenerated by the notebooks; e.g. research/*) preprocessed/ cv_results_*/ +inference_patch_config.json # Claude - share skills/settings, keep local config private .claude/settings.local.json diff --git a/research/vestibular_schwannoma/inference_patch_config.json b/research/vestibular_schwannoma/inference_patch_config.json deleted file mode 100644 index 366f441..0000000 --- a/research/vestibular_schwannoma/inference_patch_config.json +++ /dev/null @@ -1 +0,0 @@ -{"patch_size": [192, 192, 48], "patch_overlap": 0.5, "aggregation_mode": "hann", "apply_reorder": true, "target_spacing": [0.4102, 0.4102, 1.5], "sampler_type": "label", "label_probabilities": {"0": 0.2, "1": 0.8}, "samples_per_volume": 4, "queue_length": 1200, "queue_num_workers": 16, "keep_largest_component": true, "normalization": [{"name": "ZNormalization", "masking_method": "foreground", "channel_wise": true}]} \ No newline at end of file From ee0d14c82faa9c6c73ffc50e984034f78449e770 Mon Sep 17 00:00:00 2001 From: Sathiesh Date: Mon, 6 Jul 2026 11:00:40 +0200 Subject: [PATCH 4/8] research: add vestibular_schwannoma 5-fold ensemble ROR/PACS container Soft-vote ensemble deployment container (research_pacs_integration/) running the vs5f UNet cross-validation folds as one model, mirroring the vs_seg single-model container. The full patch/preprocessing config is loaded from one file (store_patch_variables/load_patch_variables -> PatchConfig) as the single source of truth, so training and inference stay in sync. Fold weights are generated on the build host by prepare_fold_models.py and are gitignored. --- .../research_pacs_integration/.dockerignore | 15 + .../research_pacs_integration/.gitignore | 13 + .../.ror/virt/Dockerfile | 47 +++ .../research_pacs_integration/README.md | 120 +++++++ .../research_pacs_integration/entrypoint.sh | 127 +++++++ .../prepare_fold_models.py | 155 +++++++++ .../requirements.yml | 16 + .../stub_inference.py | 317 ++++++++++++++++++ 8 files changed, 810 insertions(+) create mode 100644 research/vestibular_schwannoma/research_pacs_integration/.dockerignore create mode 100644 research/vestibular_schwannoma/research_pacs_integration/.gitignore create mode 100644 research/vestibular_schwannoma/research_pacs_integration/.ror/virt/Dockerfile create mode 100644 research/vestibular_schwannoma/research_pacs_integration/README.md create mode 100755 research/vestibular_schwannoma/research_pacs_integration/entrypoint.sh create mode 100644 research/vestibular_schwannoma/research_pacs_integration/prepare_fold_models.py create mode 100644 research/vestibular_schwannoma/research_pacs_integration/requirements.yml create mode 100644 research/vestibular_schwannoma/research_pacs_integration/stub_inference.py diff --git a/research/vestibular_schwannoma/research_pacs_integration/.dockerignore b/research/vestibular_schwannoma/research_pacs_integration/.dockerignore new file mode 100644 index 0000000..0aafdd1 --- /dev/null +++ b/research/vestibular_schwannoma/research_pacs_integration/.dockerignore @@ -0,0 +1,15 @@ +# Keep large/irrelevant files out of the Docker build context and image. +# NOTE: do NOT ignore the model directories or *.pkl weights - they must ship. + +# Saved docker image export(s) - e.g. vs-seg_docker_image_Sathiesh.tar.gz (~11.8 GB) +*.tar.gz +*.tar + +# Stale editor backup of the Dockerfile +.ror/virt/Dockerfile~ + +# Local tooling / caches +.claude/ +**/__pycache__/ +*.pyc +*.pyo diff --git a/research/vestibular_schwannoma/research_pacs_integration/.gitignore b/research/vestibular_schwannoma/research_pacs_integration/.gitignore new file mode 100644 index 0000000..4295205 --- /dev/null +++ b/research/vestibular_schwannoma/research_pacs_integration/.gitignore @@ -0,0 +1,13 @@ +# Fold weights + config + run-ids are generated on the build host by +# prepare_fold_models.py and are not committed (large binaries, and this folder +# is tracked). Regenerate before `docker build` - see README section 3. +vs5f_unet_models/ + +# Exported docker images (README section 4 writes these here) +*.tar.gz +*.tar + +# Python caches +__pycache__/ +*.pyc +*.pyo diff --git a/research/vestibular_schwannoma/research_pacs_integration/.ror/virt/Dockerfile b/research/vestibular_schwannoma/research_pacs_integration/.ror/virt/Dockerfile new file mode 100644 index 0000000..5b80838 --- /dev/null +++ b/research/vestibular_schwannoma/research_pacs_integration/.ror/virt/Dockerfile @@ -0,0 +1,47 @@ +FROM haukebartsch/fiona-component-python:latest +# +# build from ror-enabled directory as (conda_env must match the name in requirements.yml): +# docker build --no-cache --build-arg conda_env="fastmonai" -f .ror/virt/Dockerfile -t vs-seg-5fold:latest . +# + +WORKDIR /app + +# Set value to the name of your conda environment in requirements.yml (currently "fastmonai"). +# Optional: provide it to docker build as a variable +# --build-arg conda_env="fastmonai" +ARG conda_env=fastmonai + +LABEL "com.ror.vendor"="MMIV.no" \ + version="1.0" \ + com.ror.conda.env.name="${conda_env}" \ + description="Example docker container for workflow AI on the research information system at MMIV.no." + +ENV REPORT_FONT_PATH=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf +ENV DCMDICTPATH=/dcmtk/dcmdata/data/dicom.dic:/dcmtk/dcmdata/data/private.dic + +# In case we need a proxy to connect to the internet on the build machine +#ENV HTTP_PROXY="http://proxy.ihelse.net:3128" +#ENV HTTPS_PROXY="http://proxy.ihelse.net:3128" + +# Create the environment: +COPY requirements.yml . +RUN conda env create -f requirements.yml + +# Make RUN commands use the new environment: +RUN echo "conda activate ${conda_env}" >> ~/.bashrc +SHELL ["/bin/bash", "--login", "-c"] + +# Demonstrate the environment is activated: +#RUN echo "Make sure fastMONAI is installed:" +#RUN python -c "import fastMONAI" + +# VERSION is a cheap runtime stamp (entrypoint.sh uses it for DICOM provenance). +# Declared after the conda layer so bumping it never invalidates that cache. +ARG VERSION=jul032026 +ENV VERSION=$VERSION + +COPY . /app + +# The code to run when container is started: +RUN ["chmod", "+x", "/app/entrypoint.sh"] +ENTRYPOINT [ "/app/entrypoint.sh" ] diff --git a/research/vestibular_schwannoma/research_pacs_integration/README.md b/research/vestibular_schwannoma/research_pacs_integration/README.md new file mode 100644 index 0000000..720dc0d --- /dev/null +++ b/research/vestibular_schwannoma/research_pacs_integration/README.md @@ -0,0 +1,120 @@ +# UNet 5-Fold Ensemble ROR Container (Vestibular Schwannoma) + +## 1. What this is + +A ROR/PACS inference container for vestibular schwannoma segmentation that runs the five cross-validation UNet folds as a single soft-vote ensemble. `PatchInferenceEngine` receives the folds as a LIST and averages their per-patch softmax probabilities before the final argmax, so the folds behave as one logical model: the 5 folds averaged per patch. + +This container mirrors the production `vs_seg` container at `research/vs_seg/fastmonai_inference/integration`. Beyond model loading (a list of fold learners instead of one) and the provenance (ensemble run-ids), it also loads the full patch config from one file (section 3) rather than vs_seg's 3-key settings plus hardcoded values. Preprocessing, sliding-window inference and the output contract are otherwise the same. + +## 2. How it differs from vs_seg + +| Aspect | vs_seg (`research/vs_seg/fastmonai_inference/integration`) | This container | +|--------|-----------------------------------------------------------|----------------| +| Model loading | one exported learner (`unet_models/final_unet_learner.pkl`) | a LIST of fold learners (`vs5f_unet_models/fold_*.pkl`) | +| Averaging | none (single model) | per-patch softmax averaged by `PatchInferenceEngine` | +| SoftwareVersions | single-model tag plus one run-id | ensemble tag plus every fold run-id | +| Entrypoint INFO | `unet: ` | `unet N-fold ensemble` (N = shipped folds) | +| DICOM UID suffixes | `UNS1` / `UNP1` | `U5S1` / `U5P1` | +| display_name | `UNet` | `UNet 5-fold ensemble` | + +The distinct UID pair (`U5S1` segmentation, `U5P1` probability) means the ensemble series do not overwrite the vs_seg single-model series in PACS. + +Ensemble provenance is read from `vs5f_unet_models/mlflow_run_ids.txt` (one fold run-id per line, read with a guard for a missing file). `SoftwareVersions` becomes `[model_type + "-" + str(n) + "fold"]` (for this container, `unet-fold`) followed by the first 8 characters of each fold run-id and `"fastMONAI " + fastMONAI.__version__`, where `n` is the number of folds actually loaded. Apart from the documented changes (model loading, provenance, and the patch-config loading described in section 3), the container follows vs_seg. + +## 3. Prepare weights (before building) + +The fold weights are not committed to the repo (they are large, and unlike the untracked `vs_seg` tree this folder is tracked - see `.gitignore`). Generate `vs5f_unet_models/` on the build host before you build. In the `fastmonai-dev` conda env: + +```bash +source ~/miniconda3/etc/profile.d/conda.sh && conda activate fastmonai-dev +python prepare_fold_models.py +``` + +This writes three things into `vs5f_unet_models/`: + +- `fold_1.pkl .. fold_5.pkl` - the exported fold learners, copied AS-IS with no cleaning and no re-export, exactly like the vs_seg `unet_models/final_unet_learner.pkl`. +- `inference_patch_config.json` - the full patch-inference config, sourced from the canonical training config `../inference_patch_config.json`. +- `mlflow_run_ids.txt` - one fold run-id per line, used for DICOM provenance. + +Equivalent manual step (copy each exported fold `best_learner.pkl`, plus the config): + +```bash +mkdir -p vs5f_unet_models +cp /path/to/fold_1/best_learner.pkl vs5f_unet_models/fold_1.pkl +cp /path/to/fold_2/best_learner.pkl vs5f_unet_models/fold_2.pkl +# ... repeat for fold_3, fold_4, fold_5 +cp ../inference_patch_config.json vs5f_unet_models/inference_patch_config.json +``` + +`inference_patch_config.json` is read at inference with `load_patch_variables` and rebuilt into a full `PatchConfig(**config)`, so `patch_size`, `patch_overlap`, `aggregation_mode`, `apply_reorder`, `target_spacing`, `keep_largest_component` and `normalization` all come from the one training config - the single source of truth. A retrain that changes any of them then propagates to inference automatically (unlike vs_seg, which persists only `[patch_size, apply_reorder, target_spacing]` and hardcodes the rest in the stub). + +Note: only four UNet fold runs currently exist on disk, not a full five. The stub ensembles whatever N folds are present, so the container runs a 4-fold ensemble until the fifth fold is trained and prepared. + +## 4. Build, run, export + +These steps mirror `../../ROR_DOCKER_WORKFLOW.md` sections 3-7 with this container's values. Run every command from this integration folder. + +Pull the base image (unchanged from vs_seg): + +```bash +docker pull haukebartsch/fiona-component-python:latest +``` + +Build (Dockerfile `ARG VERSION=jul032026`): + +```bash +docker build --build-arg conda_env="fastmonai" -f .ror/virt/Dockerfile -t vs-seg-5fold:latest . +``` + +Run through ror trigger: + +```bash +ror trigger -cont vs-seg-5fold:latest -each -keep +``` + +Test-time augmentation (8-flip mirror) is on by default and improves accuracy, but it is much slower on CPU (~7-9x). Because this is an N-fold ensemble the cost stacks across folds: expect roughly 8-10 min/case without TTA and ~60-80 min/case with it for a 4-5 fold ensemble. Disable it with the `tta` key in `ROR_CONT_OPTIONS` (accepts true/false): + +```bash +ror trigger -cont vs-seg-5fold:latest -each -keep -envs '{"tta":false}' +``` + +Manual docker run (mount input read-only at `/data`, output at `/output`): + +```bash +docker run --rm \ + -v :/data:ro \ + -v :/output \ + vs-seg-5fold:latest +``` + +Export for distribution: + +```bash +docker save vs-seg-5fold:latest | gzip > vs-seg-5fold_docker_image_jul032026.tar.gz +``` + +See `../../ROR_DOCKER_WORKFLOW.md` for ror install, project init, and the full ror trigger option tables. + +## 5. Research PACS deployment + +Deployment mirrors vs_seg. The select statement is the same VS MR study selection; only the `docker_image` changes to `vs-seg-5fold:latest`. + +Select-statement entry: + +```json +"MMIVVestSchAI": { + "select": "SELECT study FROM study WHERE series named \"everything\" has Modality regexp \"MR\"", + "ROR_CONT_OPTIONS": "{}", + "docker_image": "vs-seg-5fold:latest" +} +``` + +Set `"ROR_CONT_OPTIONS": "{\"tta\":false}"` to disable TTA. The `config.json` trigger entry is unchanged from vs_seg. See `../../ROR_DOCKER_WORKFLOW.md` section 8 for the full fiona registration steps and the submission token. + +## 6. Output + +The container returns four series to PACS: `fused`, `fused_vote_map`, `reports`, and `mask`. The stub writes `/output/mask` (segmentation) and `/output/vote_map` (foreground probability); `entrypoint.sh` runs pr2mask, which produces `fused`, `fused_vote_map`, and `reports`. This is the same output contract as vs_seg. + +## 7. Caveat + +The exported `.pkl` fold learners are version-bound. The container's fastMONAI and fastai must match the versions used to train the folds, or the pickled learners will fail to load. diff --git a/research/vestibular_schwannoma/research_pacs_integration/entrypoint.sh b/research/vestibular_schwannoma/research_pacs_integration/entrypoint.sh new file mode 100755 index 0000000..b479c3f --- /dev/null +++ b/research/vestibular_schwannoma/research_pacs_integration/entrypoint.sh @@ -0,0 +1,127 @@ +#!/bin/bash --login +# The --login ensures the bash configuration is loaded. + +output="/output" + +if [ ! -z "$CONDA_DEFAULT_ENV" ]; then + conda_env="$CONDA_DEFAULT_ENV" +fi + +if [ -z "$conda_env" ]; then + echo "Usage: " + exit -1 +fi + + +MODELTOUSE="unet" +USE_TTA=1 +# Handle environment variables provided to ror as --envs "ROR_CONT_OPTIONS={\"model-type\":\"unet\",\"tta\":false}" +if [ ! -z "$ROR_CONT_OPTIONS" ]; then + for key in $(echo "$ROR_CONT_OPTIONS" | jq -r "keys[]"); do + value=$(echo "$ROR_CONT_OPTIONS" | jq -r '."'$key'"') + if [ $key == "model-type" ]; then + MODELTOUSE=$value + fi + # 8-flip test-time augmentation is on by default; tta false/0/no/off + # disables it (true/1/yes/on re-enables). + if [ $key == "tta" ]; then + case "$(echo "$value" | tr '[:upper:]' '[:lower:]')" in + true|1|yes|on) USE_TTA=1 ;; + false|0|no|off) USE_TTA=0 ;; + esac + fi + done +fi + + +# This container ships a single unet 5-fold ensemble (model-type unet). TTA is on +# by default; set the tta key in ROR_CONT_OPTIONS to false to disable it. +SCRIPT_DIR="$(dirname "$0")" +STUB_SCRIPT="${SCRIPT_DIR}/stub_inference.py" +MODEL_ARG="--model-type ${MODELTOUSE}" +TTA_ARG="" +if [ "$USE_TTA" -eq 1 ]; then + TTA_ARG="--tta" + echo "Using ${MODELTOUSE} model (TTA: on)" +else + echo "Using ${MODELTOUSE} model (TTA: off)" +fi + +# where is pr2mask? +export PATH="/pr2mask:$PATH" + +# Fold count for the DICOM info field. Count the fold_*.pkl actually shipped in +# vs5f_unet_models/ (the same files the stub globs and loads), so this label and +# the stub's SoftwareVersions tag (len(models)) always agree. nullglob makes a +# missing directory / no matches yield 0 rather than a literal glob pattern. +shopt -s nullglob +fold_pkls=("${SCRIPT_DIR}"/vs5f_unet_models/fold_*.pkl) +shopt -u nullglob +n_folds=${#fold_pkls[@]} +INFO="${MODELTOUSE} ${n_folds}-fold ensemble, Predicted $(date '+%b%d%Y')" + +# if we find imageAndMask2Report and json2SR in this container +auto_report_mode=0 +output2="/output_tmp" +if [ -f /pr2mask/imageAndMask2Report ]; then + auto_report_mode=1 +else + output2="${output}" +fi + +# relax strict mode for conda activate (its hooks aren't 'set -euo pipefail' safe), then restore +set +euo pipefail +conda activate "${conda_env}" +if [ $? -ne 0 ]; then + echo "Error: activating conda environment \"$1\" failed." + exit -1 +fi +set -euo pipefail + +log_file="${output}"/stub_command.log +# prefer the bundled inference script; otherwise run the command passed to the container +if [ -n "$STUB_SCRIPT" ] && [ -f "$STUB_SCRIPT" ]; then + cmd="python ${STUB_SCRIPT} /data ${output2} ${MODEL_ARG} ${TTA_ARG}" +else + cmd="$@ ${output2}" +fi +echo "run now: $cmd" +# eval splits the assembled $cmd into command + arguments +eval $cmd + +if [ "$auto_report_mode" -eq 1 ]; then + echo "imageAndMask2Report:" + /pr2mask/imageAndMask2Report /data/input "${output2}/mask" "${output2}" -u "$VERSION" -i "$VERSION" --reporttype mosaic -t "${INFO} " >> "${log_file}" 2>&1 + echo "imageAndMask2Fused:" + /pr2mask/imageAndMask2Fused /data/input "${output2}/mask" "${output2}" -u "${VERSION}_fused" -i "$VERSION" >> "${log_file}" 2>&1 + echo "imageAndMask2Fused (vote map):" + /pr2mask/imageAndMask2Fused /data/input "${output2}/vote_map" "${output2}" --votemapmax 65535 --votemapagree 0.5 -u "${VERSION}_votemap" -s "peak agreement {peak_agreement}" -i "$VERSION" >> "${log_file}" 2>&1 + + # Four DICOM series are sent back to PACS: the report (reports/), the raw + # segmentation mask (mask/), the fused mask overlay (fused/) and the fused + # vote-map / agreement overlay (fused_vote_map/). The raw probability map + # (vote_map/), the B&W labels, the redcap JSON and the structured report + # (*.dcm) are intentionally not copied. + if [ ! -d "${output2}"/fused ]; then + echo "Error: no /fused folder found in ${output2}" + fi + cp -R "${output2}"/fused "${output}" + + if [ ! -d "${output2}"/fused_vote_map ]; then + echo "Error: no /fused_vote_map folder found in ${output2}" + fi + cp -R "${output2}"/fused_vote_map "${output}" + + if [ ! -d "${output2}"/reports ]; then + echo "Error: no /report folder found in ${output2}" + fi + cp -R "${output2}"/reports "${output}" + + if [ ! -d "${output2}"/mask ]; then + echo "Error: no /mask folder found in ${output2}" + fi + cp -R "${output2}"/mask "${output}" + + chmod -R 777 /output +fi +echo "$(date): processing done" >> "${log_file}" diff --git a/research/vestibular_schwannoma/research_pacs_integration/prepare_fold_models.py b/research/vestibular_schwannoma/research_pacs_integration/prepare_fold_models.py new file mode 100644 index 0000000..04af3f4 --- /dev/null +++ b/research/vestibular_schwannoma/research_pacs_integration/prepare_fold_models.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +''' +Bundle exported cross-validation fold learners into vs5f_unet_models/ so the +UNet 5-fold ensemble container can be built. + +Each exported best_learner.pkl is copied as-is: no loading, no re-export, and no +torch.compile unwrap. The raw exported learners load and run in the container, +matching the single-model vs_seg build that already runs in production. + +Run once in the fastmonai-dev env, for example: + python prepare_fold_models.py --experiment vs5f_unet +''' + +import argparse +import glob +import shutil +from pathlib import Path + +from fastMONAI.vision_all import * + + +def run_id_from_path(path): + """Parse the MLflow run-dir hash from an mlruns-style artifact path. + + Expects a path of the form ...//artifacts/... and returns , + or None when the path does not follow that layout. + """ + parts = Path(path).parts + if "artifacts" in parts: + idx = parts.index("artifacts") + if idx > 0: + return parts[idx - 1] + return None + + +def discover_learners(args): + """Return an ordered list of (src_path, run_id) for the fold learners. + + Explicit --pkl-paths win and are used in the given order. Otherwise try + find_fold_learners against a live MLflow store, then fall back to the on-disk + mlruns glob. The MLflow attempt is guarded so a missing store degrades to the + glob instead of crashing. + """ + if args.pkl_paths: + return [(p, run_id_from_path(p) or "unknown") for p in args.pkl_paths] + + try: + found = find_fold_learners(args.experiment) + except Exception as err: + print(f"WARNING: MLflow discovery failed ({err}); using on-disk mlruns glob.") + found = {} + + if found: + return [(src, run_id_from_path(src) or str(fold)) + for fold, src in sorted(found.items())] + + matches = sorted(glob.glob(args.mlruns_glob)) + if matches: + print("WARNING: no MLflow fold learners found; falling back to on-disk " + "mlruns glob. On-disk mlruns is tag-stripped, so fold order follows " + "path order and may not match the training fold numbers. Prefer " + "--pkl-paths (explicit fold order) or a live MLflow store.") + return [(src, run_id_from_path(src) or "unknown") for src in matches] + + +def main(): + parser = argparse.ArgumentParser( + prog="PrepareFoldModels", + description="Copy exported cross-validation fold learners into the " + "ensemble models directory (no cleaning or re-export)." + ) + parser.add_argument("--experiment", type=str, default="vs5f_unet", + help="MLflow experiment to discover fold learners from " + "(default: vs5f_unet).") + parser.add_argument("--out", type=Path, + default=Path(__file__).parent / "vs5f_unet_models", + help="Output models directory (default: ./vs5f_unet_models).") + parser.add_argument("--pkl-paths", nargs="*", default=None, + help="Explicit exported best_learner.pkl paths in fold " + "order; overrides discovery.") + parser.add_argument("--mlruns-glob", type=str, + default="mlruns/1/*/artifacts/model/best_learner.pkl", + help="Fallback glob for on-disk mlruns learners.") + parser.add_argument("--patch-size", nargs=3, type=int, default=[192, 192, 48], + help="Fallback patch size if --patch-config is missing " + "(default: 192 192 48).") + parser.add_argument("--target-spacing", nargs=3, type=float, + default=[0.4102, 0.4102, 1.5], + help="Fallback voxel spacing if --patch-config is missing " + "(default: 0.4102 0.4102 1.5).") + parser.add_argument("--reorder", default=True, action=argparse.BooleanOptionalAction, + help="Reorder to RAS+ canonical orientation (default: True).") + parser.add_argument("--patch-config", type=Path, + default=Path(__file__).parent.parent / "inference_patch_config.json", + help="Canonical training patch config to bundle for inference " + "(default: ../inference_patch_config.json). When present it is " + "the single source of truth; the --patch-size / --target-spacing " + "/ --reorder args are only the fallback used if it is missing.") + args = parser.parse_args() + + out = args.out + out.mkdir(parents=True, exist_ok=True) + + folds = discover_learners(args) + if not folds: + raise SystemExit( + "No fold learners found. Pass --pkl-paths, run against a live MLflow " + "store, or point --mlruns-glob at exported best_learner.pkl files.") + + # Copy each exported learner as-is (no loading, no re-export, no cleaning). + run_ids = [] + for i, (src, run_id) in enumerate(folds, start=1): + dst = out / ("fold_" + str(i) + ".pkl") + shutil.copy(src, dst) + run_ids.append(run_id) + print(f"Copied fold {i}: {src} -> {dst} (run {run_id})") + + # Ensemble provenance, one run id per line in fold_i.pkl order. + run_ids_path = out / "mlflow_run_ids.txt" + run_ids_path.write_text("\n".join(run_ids) + "\n") + + # Full patch-inference config, read back at inference with load_patch_variables + # into PatchConfig(**config). Prefer the canonical training config so every + # patch/preprocessing/postprocessing parameter (overlap, aggregation, + # normalization, keep_largest_component, ...) stays coupled to training; fall + # back to the CLI args + known-training defaults only if it is missing. + settings_path = out / "inference_patch_config.json" + if args.patch_config and args.patch_config.is_file(): + config = load_patch_variables(args.patch_config) + print(f"Sourced patch config from {args.patch_config}") + else: + print(f"WARNING: {args.patch_config} not found; falling back to CLI/default " + "patch config. Verify these match training.") + config = dict( + patch_size=args.patch_size, + patch_overlap=0.5, + aggregation_mode="hann", + apply_reorder=args.reorder, + target_spacing=args.target_spacing, + keep_largest_component=True, + normalization=[{"name": "ZNormalization", + "masking_method": "foreground", + "channel_wise": True}], + ) + store_patch_variables(str(settings_path), **config) + + print("\n" + "=" * 60) + print(f"Bundled {len(folds)} fold learners into {out}") + print(f"Run ids: {run_ids}") + print(f"Settings: {settings_path}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/research/vestibular_schwannoma/research_pacs_integration/requirements.yml b/research/vestibular_schwannoma/research_pacs_integration/requirements.yml new file mode 100644 index 0000000..3a5a0a7 --- /dev/null +++ b/research/vestibular_schwannoma/research_pacs_integration/requirements.yml @@ -0,0 +1,16 @@ +name: fastmonai +channels: + - pytorch + - conda-forge + - nodefaults +dependencies: + - python=3.11 + - pytorch=2.6.0 + - torchvision + - torchaudio + - cpuonly + - pip + - pip: + - fastMONAI==0.9.2 + - SimpleITK + - ipython \ No newline at end of file diff --git a/research/vestibular_schwannoma/research_pacs_integration/stub_inference.py b/research/vestibular_schwannoma/research_pacs_integration/stub_inference.py new file mode 100644 index 0000000..6219d67 --- /dev/null +++ b/research/vestibular_schwannoma/research_pacs_integration/stub_inference.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +''' +5-fold soft-vote ensemble patch-based inference for vestibular schwanoma segmentation. +./stub_inference.py --model-type [unet] + +Loads the five cross-validation fold learners (fold_*.pkl) and runs patch-based +sliding-window inference as one ensemble. PatchInferenceEngine averages the +per-patch class probabilities across the folds before argmax, so the folds +soft-vote into a single segmentation. Each fold is an exported fastai learner, +loaded as-is with load_learner. +''' + +import argparse +import glob +import json +from pathlib import Path + +import numpy as np +from fastai.learner import load_learner +import fastMONAI +from fastMONAI.vision_all import * +from fastMONAI.vision_patch import PatchConfig, PatchInferenceEngine +from fastMONAI.vision_inference import keep_largest +from imagedata.series import Series + + +# Model configuration (single model type; five folds soft-voted as one ensemble) +MODEL_CONFIGS = { + "unet": { + "models_dir": "vs5f_unet_models", + "weights_glob": "fold_*.pkl", + "seg_uid": "U5S1", + "prob_uid": "U5P1", + "display_name": "UNet 5-fold ensemble", + }, +} + +# Path setup - models are relative to script location +SCRIPT_DIR = Path(__file__).parent + +# Use CPU for inference +USE_CPU = True + +# Runtime batching knob (not a PatchConfig field). Every patch/preprocessing +# parameter comes from the persisted patch config; see run_inference. +SW_BATCH_SIZE = 2 + + +def save_series_pred(series_obj, save_dir, val='1234'): + """Save series prediction with updated DICOM UIDs. + + Makes sure we get derived UIDs to allow for overwrite of image objects in PACS. + """ + # Update series instance UID + my_seriesInstanceUID = series_obj.seriesInstanceUID[:-4] + val + series_obj.seriesInstanceUID = my_seriesInstanceUID + + # Update study ID from patient ID + if hasattr(series_obj, 'patientID') and series_obj.patientID: + my_studyID = series_obj.patientID[3:] if len(series_obj.patientID) > 3 else series_obj.patientID + series_obj.studyID = my_studyID + + base_sop_uid = series_obj.getDicomAttribute('SOPInstanceUID') + + for slice_idx in range(series_obj.slices): + # Create unique UID per slice by modifying the base UID + my_SOPInstanceUID = base_sop_uid[:-8] + val + str(slice_idx).zfill(4) + series_obj.setDicomAttribute('SOPInstanceUID', my_SOPInstanceUID, slice=slice_idx) + + series_obj.write(save_dir, opts={'keep_uid': True}, formats=['dicom']) + + +def create_dicom_mask(pred, dicom_input_path, output_dir, uid_suffix, software_versions): + """ + Create DICOM series from segmentation mask using template series metadata. + + `pred` is the prediction in original image space (a tensor/array, already + resized and reoriented by PatchInferenceEngine.predict()). + """ + # Create a copy of the template series to hold the mask + mask_obj = Series(str(dicom_input_path), opts={'slice_tolerance': 1e-2}) + + # Overwrite SoftwareVersions (0018,1020) with the ensemble provenance passed in + # (an ensemble tag, the per-fold run ids, and the fastMONAI version); any value + # carried over from the source series is replaced. + mask_obj.setDicomAttribute('SoftwareVersions', software_versions) + + # Mark the mask as a derived/secondary object in ImageType (0008,0008): + # value 1 -> DERIVED, value 2 -> SECONDARY, keep any trailing values, and + # append a MASK marker (e.g. ORIGINAL\PRIMARY\M\ND -> DERIVED\SECONDARY\M\ND\MASK). + image_type = mask_obj.getDicomAttribute('ImageType') + image_type = [] if image_type is None else ( + [image_type] if isinstance(image_type, str) else list(image_type)) + mask_obj.setDicomAttribute('ImageType', ['DERIVED', 'SECONDARY'] + image_type[2:] + ['MASK']) + + # Get mask data and transform to DICOM orientation + new_mask = pred.numpy() + new_mask = new_mask.squeeze() + + # Transform to DICOM orientation + new_mask = np.transpose(new_mask, (-1, 1, 0)) + new_mask = new_mask.copy() # Make contiguous + new_mask = new_mask.astype(np.uint16) + + # Assign mask data to series using slice assignment + mask_obj[:] = new_mask + + # Create output directory + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + # Save with proper UID handling + save_series_pred(mask_obj, str(output_path), val=uid_suffix) + + return output_path + + +def create_dicom_prob_mask(pred, dicom_input_path, output_dir, uid_suffix): + """ + Create DICOM series from probability mask. + Scales probabilities from [0,1] to uint16 [0,65535]. + + `pred` is the foreground probability in original image space (a tensor/array). + """ + # Create a copy of the template series to hold the probability mask + mask_obj = Series(str(dicom_input_path), opts={'slice_tolerance': 1e-2}) + + # Get probability data and transform to DICOM orientation + prob_data = pred.numpy().squeeze() + + # Transform to DICOM orientation + prob_data = np.transpose(prob_data, (-1, 1, 0)) + prob_data = prob_data.copy() # Make contiguous + + # Scale from [0,1] to [0,65535] for uint16 + prob_scaled = (prob_data * 65535).astype(np.uint16) + + # Assign scaled probability data to series + mask_obj[:] = prob_scaled + + # Create output directory + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + # Save with proper UID handling + save_series_pred(mask_obj, str(output_path), val=uid_suffix) + + return output_path + + +def load_models(config): + """Load the exported fold learners and return their eval-mode networks as a list. + + Each fold learner (fold_*.pkl) is an exported fastai learner, loaded as-is with + load_learner. The returned list is handed to PatchInferenceEngine, which + soft-votes the folds (per-patch probability averaging) at inference time. + """ + paths = sorted(glob.glob(str(SCRIPT_DIR / config["models_dir"] / config["weights_glob"]))) + assert paths, ( + f"No fold_*.pkl found in {config['models_dir']}; " + "copy the exported fold learners there " + "(see README / prepare_fold_models.py)") + + print(f"\nLoading {config['display_name']} folds from: " + f"{SCRIPT_DIR / config['models_dir']}") + models = [] + for path in paths: + learn = load_learner(path, cpu=True) + model = learn.model + model.eval() + models.append(model) + print(f" Loaded fold: {path}") + + if len(paths) != 5: + print(f" WARNING: expected 5 folds, loaded {len(paths)}") + + return models + + +def build_software_versions(models_dir, model_type, n_folds): + """Build the SoftwareVersions values recording the ensemble provenance. + + Reads one MLflow run id per line from mlflow_run_ids.txt (guarded so a + missing or unreadable file never aborts inference) and returns the model + tag, the per-fold run-id prefixes, and the fastMONAI version. + """ + try: + run_ids = [line.strip() for line + in (models_dir / "mlflow_run_ids.txt").read_text().splitlines() + if line.strip()] + except OSError: + run_ids = [] + return ([f"{model_type}-{n_folds}fold"] + + [rid[:8] for rid in run_ids] + + [f"fastMONAI {fastMONAI.__version__}"]) + + +def run_inference(datafolder, output, model_type, use_tta=False): + """Run 5-fold soft-vote ensemble patch-based inference for the model type. + + use_tta enables 8-flip mirror test-time augmentation (default off). + """ + config = MODEL_CONFIGS[model_type] + display_name = config["display_name"] + + print("=" * 60) + print(f"{display_name} Inference - Vestibular Schwanoma Segmentation") + print("=" * 60) + + models_dir = SCRIPT_DIR / config["models_dir"] + + # Load the fold models first so a missing or empty models directory fails with an + # actionable message (see load_models) before anything else runs. Passing the list + # of models makes PatchInferenceEngine soft-vote them (per-patch probability + # averaging before argmax). The engine also handles preprocessing (reorder/resample + # via med_img_reader + Z-normalization), sliding-window patch sampling, + # resize-to-original, and reorientation. + models = load_models(config) + + # Load the full patch-inference config saved next to the models. This is the + # single source of truth for preprocessing and patch settings (patch_size, + # patch_overlap, aggregation_mode, apply_reorder, target_spacing, + # keep_largest_component, normalization), so training and inference stay in + # sync. PatchConfig(**config) reconstructs it; the engine reads apply_reorder, + # target_spacing and normalization from the config. + settings_path = models_dir / "inference_patch_config.json" + print(f"\nLoading inference settings from: {settings_path}") + patch_config = PatchConfig(**load_patch_variables(settings_path)) + print(f" Patch size: {patch_config.patch_size}") + print(f" Reorder: {patch_config.apply_reorder}") + print(f" Resample: {patch_config.target_spacing}") + + # Ensemble provenance for the mask DICOM (SoftwareVersions tag). + software_versions = build_software_versions(models_dir, model_type, len(models)) + + engine = PatchInferenceEngine(models, patch_config, sw_batch_size=SW_BATCH_SIZE) + + # predict() reads the DICOM folder directly and returns class probabilities + # in the original image space (resized + reoriented). The result is + # [n_classes, *spatial], though the released reorientation path adds a leading + # singleton ([1, n_classes, *spatial]); squeeze(0) normalizes both (it only + # drops dim 0 when its size is 1, and n_classes here is 2). The binary mask is + # derived via argmax + largest-connected-component, matching the validated CV path. + print(f"\nRunning patch-based inference " + f"(patch={patch_config.patch_size}, overlap={patch_config.patch_overlap}, " + f"agg={patch_config.aggregation_mode}, " + f"TTA={'on' if use_tta else 'off'})...") + prob = engine.predict(datafolder, return_probabilities=True, tta=use_tta).squeeze(0) + tumor_prob = prob[1] + segmentation = keep_largest(prob.argmax(0).float()) + + # Save outputs + print(f"\nSaving outputs to: {output}") + + # Save binary segmentation mask + mask_output_dir = output + '/mask' + create_dicom_mask(segmentation, datafolder, mask_output_dir, + uid_suffix=config["seg_uid"], software_versions=software_versions) + print(f" Saved segmentation mask to: {mask_output_dir}") + + # Save probability mask + prob_output_dir = output + '/vote_map' + create_dicom_prob_mask(tumor_prob, datafolder, prob_output_dir, uid_suffix=config["prob_uid"]) + print(f" Saved probability mask to: {prob_output_dir}") + + # Optional description passthrough (guarded so a missing/foreign descr.json + # never aborts inference after the masks have been written) + description = {} + input_description_path = Path(datafolder + "/descr.json") + if input_description_path.is_file(): + try: + with open(input_description_path, "r") as file: + description = json.load(file) + if isinstance(description, list) and description and isinstance(description[0], dict): + description[0]["ProbabilityMask"] = prob_output_dir + except (json.JSONDecodeError, OSError) as err: + print(f" Warning: could not read descr.json ({err})") + + # Summary + seg_array = segmentation.cpu().numpy().squeeze() + tumor_voxels = np.sum(seg_array > 0) + total_voxels = seg_array.size + + print("\n" + "=" * 60) + print(f"{display_name.upper()} INFERENCE COMPLETE") + print("=" * 60) + print(f"Folds ensembled: {len(models)}") + print(f"TTA: {'on' if use_tta else 'off'}") + print(f"Tumor voxels: {int(tumor_voxels)}") + print(f"Total voxels: {total_voxels}") + print(f"Tumor percentage: {100 * tumor_voxels / total_voxels:.4f}%") + print("=" * 60) + + +def main(): + # Argument parsing + parser = argparse.ArgumentParser( + prog='VestibularSchwanomaSegmentation', + description='Vestibular schwanoma 5-fold ensemble segmentation script.' + ) + parser.add_argument('fn', type=str, help='Directory name of the input folder') + parser.add_argument('on', type=str, help='Directory name for the output') + parser.add_argument('--model-type', type=str, choices=['unet'], + default='unet', help='Model type to use (default: unet)') + parser.add_argument('--tta', action='store_true', + help='Enable 8-flip mirror test-time augmentation ' + '(default: off; much slower on CPU, ~7-9x).') + args = parser.parse_args() + + datafolder = args.fn + '/input' + output = args.on + + run_inference(datafolder, output, args.model_type, use_tta=args.tta) + + +if __name__ == "__main__": + main() From 5851958c43a1f718b670a0101c47592b80c23571 Mon Sep 17 00:00:00 2001 From: Sathiesh Date: Mon, 6 Jul 2026 11:44:46 +0200 Subject: [PATCH 5/8] feat: add show_mask_overlay overlay helper; expose show_segmentation_comparison via vision_all --- fastMONAI/_modidx.py | 2 ++ fastMONAI/vision_all.py | 1 + fastMONAI/vision_plot.py | 56 +++++++++++++++++++++++++++++++++++++++- nbs/00_vision_plot.ipynb | 8 ++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/fastMONAI/_modidx.py b/fastMONAI/_modidx.py index 9b5b9c8..35fc24b 100644 --- a/fastMONAI/_modidx.py +++ b/fastMONAI/_modidx.py @@ -633,6 +633,8 @@ 'fastMONAI.vision_plot': { 'fastMONAI.vision_plot._get_slice': ('vision_plot.html#_get_slice', 'fastMONAI/vision_plot.py'), 'fastMONAI.vision_plot.find_max_slice': ( 'vision_plot.html#find_max_slice', 'fastMONAI/vision_plot.py'), + 'fastMONAI.vision_plot.show_mask_overlay': ( 'vision_plot.html#show_mask_overlay', + 'fastMONAI/vision_plot.py'), 'fastMONAI.vision_plot.show_med_img': ('vision_plot.html#show_med_img', 'fastMONAI/vision_plot.py'), 'fastMONAI.vision_plot.show_segmentation_comparison': ( 'vision_plot.html#show_segmentation_comparison', 'fastMONAI/vision_plot.py'), diff --git a/fastMONAI/vision_all.py b/fastMONAI/vision_all.py index 62ab1fe..9c78fcc 100644 --- a/fastMONAI/vision_all.py +++ b/fastMONAI/vision_all.py @@ -1,4 +1,5 @@ #imports all of the functions and classes from the fastMONAI library +from .vision_plot import show_segmentation_comparison from .vision_core import * from .vision_data import * from .vision_augmentation import * diff --git a/fastMONAI/vision_plot.py b/fastMONAI/vision_plot.py index 4f887c8..75e50cc 100644 --- a/fastMONAI/vision_plot.py +++ b/fastMONAI/vision_plot.py @@ -1,7 +1,7 @@ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_vision_plot.ipynb. # %% auto #0 -__all__ = ['validate_anatomical_plane', 'show_med_img', 'find_max_slice', 'show_segmentation_comparison'] +__all__ = ['validate_anatomical_plane', 'show_med_img', 'find_max_slice', 'show_segmentation_comparison', 'show_mask_overlay'] # %% ../nbs/00_vision_plot.ipynb #7ea97ee7-8a55-485d-a75c-ae1a8055f489 import warnings @@ -196,3 +196,57 @@ def show_segmentation_comparison( axes[2].axis('off') plt.tight_layout() + +# %% ../nbs/00_vision_plot.ipynb #72d68ebd +def show_mask_overlay(image, mask, ctx=None, channel: int = 0, slice_index: int = None, + anatomical_plane: int = 2, voxel_size=None, alpha: float = 0.5, + cmap_img: str = 'gray', cmap_mask: str = 'autumn', + ax=None, figsize=None, title=None, **kwargs): + """Overlay `mask` on `image` on a single axis, using the same slicing and orientation as `.show()`. + + Draws the image, then the mask on top with zero voxels transparent, so it lines up with + `MedImage.show()` / `MedMask.show()`. Reuses the same `_get_slice` those methods use. + + Args: + image: Background image (MedImage or object with `.data` [C, H, W, D]). + mask: Overlay mask (MedMask or object with `.data` [C, H, W, D]); zero voxels are transparent. + ctx: Axis to draw on (fastai-style context); same role as `ax`. + channel: Channel to display. + slice_index: Slice to show; if None, uses `find_max_slice(mask, anatomical_plane)`. + anatomical_plane: 0=sagittal, 1=coronal, 2=axial (default). + voxel_size: Voxel spacing for aspect ratio; if None, aspect defaults to 1. + alpha: Overlay opacity. + cmap_img: Colormap for the background image. + cmap_mask: Colormap for the mask overlay. + ax: Axis to draw on (overrides `ctx`). + figsize: Figure size when a new axis is created. + title: Optional axis title. + kwargs: Extra args forwarded to the background image `imshow`. + + Returns: + The matplotlib axis with the overlay. + """ + validate_anatomical_plane(anatomical_plane) + + img_data = image.data if hasattr(image, 'data') else image + mask_data = mask.data if hasattr(mask, 'data') else mask + if hasattr(img_data, 'cpu'): img_data = img_data.cpu() + if hasattr(mask_data, 'cpu'): mask_data = mask_data.cpu() + + if slice_index is None: + slice_index = find_max_slice(mask_data[channel].numpy(), anatomical_plane) + + img_slice, aspect = _get_slice(img_data, channel, slice_index, anatomical_plane, voxel_size) + mask_slice, _ = _get_slice(mask_data, channel, slice_index, anatomical_plane, voxel_size) + + ax = ax if ax is not None else ctx + if ax is None: + _, ax = plt.subplots(figsize=figsize) + + ax.imshow(img_slice, cmap=cmap_img, aspect=aspect, **kwargs) + ax.imshow(np.ma.masked_where(mask_slice == 0, mask_slice), cmap=cmap_mask, alpha=alpha, aspect=aspect) + + if title is not None: + ax.set_title(title) + ax.axis('off') + return ax diff --git a/nbs/00_vision_plot.ipynb b/nbs/00_vision_plot.ipynb index 07b7968..4b23f40 100644 --- a/nbs/00_vision_plot.ipynb +++ b/nbs/00_vision_plot.ipynb @@ -174,6 +174,14 @@ "metadata": {}, "outputs": [], "source": "#| export\ndef show_segmentation_comparison(\n image, ground_truth, prediction,\n slice_index: int = None,\n anatomical_plane: int = 2,\n metric_value: float = None,\n metric_name: str = 'DSC',\n channel: int = 0,\n figsize: tuple = (15, 5),\n cmap_img: str = 'gray',\n cmap_mask: str = 'gray',\n voxel_size = None\n):\n \"\"\"Display 3-panel comparison: Image | Ground Truth | Prediction.\n \n Useful for validating segmentation results, especially after patch-based \n inference where results are not in standard fastai batch format.\n \n Args:\n image: Input image (MedImage, MedMask, or tensor [C, H, W, D])\n ground_truth: Ground truth mask (MedMask or tensor [C, H, W, D])\n prediction: Predicted mask (tensor [C, H, W, D])\n slice_index: Slice to display. If None, uses find_max_slice on ground_truth.\n anatomical_plane: 0=sagittal, 1=coronal, 2=axial (default)\n metric_value: Optional metric value to display in prediction title\n metric_name: Name of metric for title (default 'DSC')\n channel: Channel to display for multi-channel data (default 0)\n figsize: Figure size (default (15, 5))\n cmap_img: Colormap for image (default 'gray')\n cmap_mask: Colormap for masks (default 'gray')\n voxel_size: Voxel spacing for aspect ratio. If None, aspect=1.\n\n Example::\n \n # After patch_inference()\n show_segmentation_comparison(\n image=val_img,\n ground_truth=val_gt,\n prediction=predictions[0],\n metric_value=results_df.iloc[0]['DSC'],\n anatomical_plane=2\n )\n \"\"\"\n validate_anatomical_plane(anatomical_plane)\n \n img_data = image.data if hasattr(image, 'data') else image\n gt_data = ground_truth.data if hasattr(ground_truth, 'data') else ground_truth\n pred_data = prediction.data if hasattr(prediction, 'data') else prediction\n \n if hasattr(img_data, 'cpu'): img_data = img_data.cpu()\n if hasattr(gt_data, 'cpu'): gt_data = gt_data.cpu()\n if hasattr(pred_data, 'cpu'): pred_data = pred_data.cpu()\n \n if slice_index is None:\n gt_np = gt_data[channel].numpy()\n slice_index = find_max_slice(gt_np, anatomical_plane)\n \n fig, axes = plt.subplots(1, 3, figsize=figsize)\n \n img_slice, img_aspect = _get_slice(img_data, channel, slice_index, anatomical_plane, voxel_size)\n gt_slice, gt_aspect = _get_slice(gt_data, channel, slice_index, anatomical_plane, voxel_size)\n pred_slice, pred_aspect = _get_slice(pred_data, channel, slice_index, anatomical_plane, voxel_size)\n \n axes[0].imshow(img_slice, cmap=cmap_img, aspect=img_aspect)\n axes[0].set_title('Input Image')\n axes[0].axis('off')\n \n axes[1].imshow(gt_slice, cmap=cmap_mask, aspect=gt_aspect)\n axes[1].set_title('Ground Truth')\n axes[1].axis('off')\n \n pred_title = 'Prediction'\n if metric_value is not None:\n pred_title = f'Prediction ({metric_name}: {metric_value:.4f})'\n \n axes[2].imshow(pred_slice, cmap=cmap_mask, aspect=pred_aspect)\n axes[2].set_title(pred_title)\n axes[2].axis('off')\n \n plt.tight_layout()" + }, + { + "cell_type": "code", + "execution_count": null, + "id": "72d68ebd", + "metadata": {}, + "outputs": [], + "source": "#| export\ndef show_mask_overlay(image, mask, ctx=None, channel: int = 0, slice_index: int = None,\n anatomical_plane: int = 2, voxel_size=None, alpha: float = 0.5,\n cmap_img: str = 'gray', cmap_mask: str = 'autumn',\n ax=None, figsize=None, title=None, **kwargs):\n \"\"\"Overlay `mask` on `image` on a single axis, using the same slicing and orientation as `.show()`.\n\n Draws the image, then the mask on top with zero voxels transparent, so it lines up with\n `MedImage.show()` / `MedMask.show()`. Reuses the same `_get_slice` those methods use.\n\n Args:\n image: Background image (MedImage or object with `.data` [C, H, W, D]).\n mask: Overlay mask (MedMask or object with `.data` [C, H, W, D]); zero voxels are transparent.\n ctx: Axis to draw on (fastai-style context); same role as `ax`.\n channel: Channel to display.\n slice_index: Slice to show; if None, uses `find_max_slice(mask, anatomical_plane)`.\n anatomical_plane: 0=sagittal, 1=coronal, 2=axial (default).\n voxel_size: Voxel spacing for aspect ratio; if None, aspect defaults to 1.\n alpha: Overlay opacity.\n cmap_img: Colormap for the background image.\n cmap_mask: Colormap for the mask overlay.\n ax: Axis to draw on (overrides `ctx`).\n figsize: Figure size when a new axis is created.\n title: Optional axis title.\n kwargs: Extra args forwarded to the background image `imshow`.\n\n Returns:\n The matplotlib axis with the overlay.\n \"\"\"\n validate_anatomical_plane(anatomical_plane)\n\n img_data = image.data if hasattr(image, 'data') else image\n mask_data = mask.data if hasattr(mask, 'data') else mask\n if hasattr(img_data, 'cpu'): img_data = img_data.cpu()\n if hasattr(mask_data, 'cpu'): mask_data = mask_data.cpu()\n\n if slice_index is None:\n slice_index = find_max_slice(mask_data[channel].numpy(), anatomical_plane)\n\n img_slice, aspect = _get_slice(img_data, channel, slice_index, anatomical_plane, voxel_size)\n mask_slice, _ = _get_slice(mask_data, channel, slice_index, anatomical_plane, voxel_size)\n\n ax = ax if ax is not None else ctx\n if ax is None:\n _, ax = plt.subplots(figsize=figsize)\n\n ax.imshow(img_slice, cmap=cmap_img, aspect=aspect, **kwargs)\n ax.imshow(np.ma.masked_where(mask_slice == 0, mask_slice), cmap=cmap_mask, alpha=alpha, aspect=aspect)\n\n if title is not None:\n ax.set_title(title)\n ax.axis('off')\n return ax" } ], "metadata": { From 86627a233e6f8ae37ebd3325608118bb324c70c7 Mon Sep 17 00:00:00 2001 From: Sathiesh Date: Mon, 6 Jul 2026 11:44:47 +0200 Subject: [PATCH 6/8] research: trim vestibular_schwannoma CV/inference notebooks; use show_mask_overlay for overlay viz --- .../01_five_fold_cross_validation.ipynb | 570 ++++++++++++------ .../02_inference_new_cases.ipynb | 361 +++++++---- 2 files changed, 611 insertions(+), 320 deletions(-) diff --git a/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb b/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb index c7433a3..6d3a8db 100644 --- a/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb +++ b/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb @@ -7,65 +7,21 @@ "source": [ "# Five-Fold Cross-Validation for Vestibular Schwannoma Segmentation\n", "\n", - "This notebook runs 5-fold cross-validation for three 3D segmentation models on a\n", - "vestibular schwannoma (VS) MRI segmentation task, using the patch-based workflow in\n", - "fastMONAI, built on fastai, MONAI, and TorchIO.\n", - "\n", - "## The task\n", - "\n", - "Vestibular Schwannoma is a benign tumor arising from the vestibulocochlear nerve that can\n", - "cause hearing loss and brainstem compression ([Kujawa et al., 2024](https://doi.org/10.3389/fncom.2024.1365727)).\n", - "For small- to medium-sized lesions, guidelines recommend either upfront radiosurgery or\n", - "observation until radiographic tumor growth is evident ([Dhayalan et al., 2023](https://doi.org/10.1001/jama.2023.12222)).\n", - "Accurate, reproducible delineation of the tumor on Contrast-Enhanced T1-weighted (CE-T1w)\n", - "MRI supports treatment planning and volumetric follow-up. We treat it as a binary 3D\n", - "segmentation problem: for every voxel, decide tumor (1) or background (0).\n", - "\n", - "## The dataset\n", - "\n", - "We use 346 CE-T1w cases pooled from the Queen Square cohort\n", - "([Shapey et al., 2021](https://doi.org/10.1038/s41597-021-01064-w)) and the public crossMoDA\n", - "challenge data ([Dorent et al., 2023](https://doi.org/10.1016/j.media.2022.102628)). Each\n", - "case has a paired ground-truth tumor mask. The tumors are small relative to the field of\n", - "view, which is why we train on patches sampled around the lesion rather than on whole\n", - "volumes.\n", - "\n", - "## What five-fold cross-validation buys us\n", - "\n", - "Cross-validation gives a more honest estimate of how a model generalizes than a single\n", - "train/validation split. The 346 cases are partitioned into five folds. We train five times\n", - "per model; each run holds out one fold for validation and trains on the other four.\n", - "Averaging the held-out scores across the five folds uses every case for validation exactly\n", - "once and reports a mean and a spread rather than a single lucky (or unlucky) number.\n", - "\n", - "The fold assignment is fixed ahead of time in the `fold` column of the dataset CSV\n", - "(values 1..5). Using a pre-assigned column, rather than reshuffling here, means every model\n", - "sees the exact same folds, so their scores are directly comparable, and the split is\n", - "reproducible across notebooks and machines. The CSV also carries a `split` column, but the\n", - "cross-validation intentionally ignores it: all 346 cases participate in CV.\n", - "\n", - "## The three models\n", - "\n", - "- **UNet** (MONAI built-in): the classic encoder-decoder with skip connections. A strong,\n", - " well-understood convolutional baseline.\n", - "- **DynUNet** (MONAI built-in): a dynamic, nnU-Net-style UNet\n", - " ([Isensee et al., 2024](https://doi.org/10.1007/978-3-031-72114-4_47)) with residual blocks\n", - " and deep supervision (auxiliary losses at several decoder resolutions), which tends to\n", - " train more stably on medical data.\n", - "- **SegMamba** (our fork): a state-space (Mamba) backbone for 3D segmentation. It replaces\n", - " the quadratic-cost attention of a transformer with a linear-time selective scan, aiming to\n", - " capture long-range 3D context efficiently.\n", - "\n", - "All three are trained through an identical data, loss, metric, and evaluation pipeline, so\n", - "any difference in scores reflects the model rather than the plumbing.\n", - "\n", - "Despite the high accuracy reported for automated VS segmentation, its real-world utility and\n", - "computational cost remain underexplored, and an apparent performance plateau raises the\n", - "question of whether newer architectures offer practical gains over a standard CNN\n", - "([Connor et al., 2025](https://doi.org/10.5152/iao.2025.241693); [Häußler et al., 2025](https://doi.org/10.1002/lary.31979)).\n", - "Comparing a standard convolutional baseline (UNet) against an nnU-Net-style model (DynUNet)\n", - "and a state-space model (SegMamba) on the same data is a controlled way to probe that\n", - "question. References cited throughout are listed at the end of this notebook." + "Trains and evaluates three 3D segmentation models (UNet, DynUNet, SegMamba) on vestibular schwannoma (VS) MRI with 5-fold cross-validation, using the fastMONAI patch workflow. VS is a benign tumor of the vestibulocochlear nerve; accurate delineation on Contrast-Enhanced T1-weighted (CE-T1w) MRI supports treatment planning and volumetric follow-up ([Kujawa et al., 2024](https://doi.org/10.3389/fncom.2024.1365727); [Dhayalan et al., 2023](https://doi.org/10.1001/jama.2023.12222)).\n", + "\n", + "## Task and data\n", + "\n", + "Binary segmentation: label every voxel tumor (1) or background (0). We use 346 CE-T1w cases pooled from the Queen Square cohort ([Shapey et al., 2021](https://doi.org/10.1038/s41597-021-01064-w)) and the crossMoDA challenge ([Dorent et al., 2023](https://doi.org/10.1016/j.media.2022.102628)), each with a ground-truth mask. Tumors are small relative to the field of view, so we train on patches sampled around the lesion.\n", + "\n", + "## Cross-validation and models\n", + "\n", + "The 346 cases are split into five folds via the `fold` column (1..5) of the dataset CSV; each run holds out one fold for validation and trains on the other four, so every case is validated once. A fixed column keeps the folds identical across models and reproducible; the `split` column is ignored.\n", + "\n", + "- **UNet** (MONAI): convolutional encoder-decoder baseline.\n", + "- **DynUNet** (MONAI): nnU-Net-style UNet ([Isensee et al., 2024](https://doi.org/10.1007/978-3-031-72114-4_47)) with residual blocks and deep supervision.\n", + "- **SegMamba** (our fork): a state-space backbone with linear-time selective scan for long-range 3D context.\n", + "\n", + "All three run through an identical data, loss, and evaluation pipeline, so score differences reflect the model, not the plumbing. Comparing a CNN baseline, an nnU-Net-style model, and a state-space model probes whether newer architectures beat a standard CNN on VS ([Connor et al., 2025](https://doi.org/10.5152/iao.2025.241693); [Häußler et al., 2025](https://doi.org/10.1002/lary.31979)). References are listed at the end." ] }, { @@ -75,10 +31,7 @@ "source": [ "## 1. Environment setup\n", "\n", - "We import the fastMONAI patch API, MONAI networks and losses, and the exact metric\n", - "functions used during evaluation. Everything runs in a conda environment with fastMONAI and\n", - "its dependencies installed. We `chdir` into the project folder early so that the relative\n", - "image and mask paths in the CSV (`../nii_data/...`) resolve correctly." + "Import the fastMONAI patch API, MONAI nets and losses, and the metric helpers, then `chdir` into the project folder so the CSV's relative image and mask paths resolve." ] }, { @@ -87,7 +40,42 @@ "id": "af9ad685", "metadata": {}, "outputs": [], - "source": "import os\nimport gc\nimport json\nimport traceback\nfrom pathlib import Path\n\nimport numpy as np\nimport pandas as pd\nimport torch\nimport torchio as tio\n\nfrom monai.losses import DiceCELoss, DeepSupervisionLoss\nfrom monai.networks.layers import Norm\nfrom monai.networks.nets import UNet, DynUNet\n\nfrom fastMONAI.vision_all import *\n\n# SegMamba is an optional fork; UNet and DynUNet need none of it. Import it once here so a\n# missing fork is reported now, with the install command, instead of mid-sweep. (A fork that\n# imports but whose CUDA kernels are broken surfaces later, when the model is built, and is\n# caught by the driver's try/except.)\n_SEGMAMBA_INSTALL_HINT = (\n \" GPU (training): pip install 'segmamba-v2[gpu] @ git+https://github.com/skaliy/SegMamba-V2.git'\\n\"\n \" CPU (inference): pip install 'segmamba-v2[cpu] @ git+https://github.com/skaliy/SegMamba-V2.git'\"\n)\ntry:\n from models_segmamba.segmambav2 import SegMamba\n SEGMAMBA_AVAILABLE = True\nexcept ImportError as exc:\n SegMamba, SEGMAMBA_AVAILABLE = None, False\n print(f\"[segmamba] fork not available ({exc}); UNet and DynUNet still run. To enable it:\\n\"\n + _SEGMAMBA_INSTALL_HINT)\n\n# Resolve data paths against the repo root so they work wherever the kernel started\n# (VS Code launches at the workspace root; plain Jupyter, in the notebook folder).\nimport fastMONAI\nREPO_ROOT = Path(fastMONAI.__file__).resolve().parent.parent\nos.chdir(REPO_ROOT / \"research\" / \"vestibular_schwannoma\")" + "source": [ + "import os\n", + "import gc\n", + "import json\n", + "import traceback\n", + "from pathlib import Path\n", + "\n", + "import numpy as np\n", + "import pandas as pd\n", + "import torch\n", + "import torchio as tio\n", + "\n", + "from monai.losses import DiceCELoss, DeepSupervisionLoss\n", + "from monai.networks.layers import Norm\n", + "from monai.networks.nets import UNet, DynUNet\n", + "\n", + "from fastMONAI.vision_all import *\n", + "\n", + "# Optional fork; import here so a missing fork is reported now with the install hint, not mid-sweep.\n", + "_SEGMAMBA_INSTALL_HINT = (\n", + " \" GPU (training): pip install 'segmamba-v2[gpu] @ git+https://github.com/skaliy/SegMamba-V2.git'\\n\"\n", + " \" CPU (inference): pip install 'segmamba-v2[cpu] @ git+https://github.com/skaliy/SegMamba-V2.git'\"\n", + ")\n", + "try:\n", + " from models_segmamba.segmambav2 import SegMamba\n", + " SEGMAMBA_AVAILABLE = True\n", + "except ImportError as exc:\n", + " SegMamba, SEGMAMBA_AVAILABLE = None, False\n", + " print(f\"[segmamba] fork not available ({exc}); UNet and DynUNet still run. To enable it:\\n\"\n", + " + _SEGMAMBA_INSTALL_HINT)\n", + "\n", + "# Resolve paths against the repo root so they work from any launch dir.\n", + "import fastMONAI\n", + "REPO_ROOT = Path(fastMONAI.__file__).resolve().parent.parent\n", + "os.chdir(REPO_ROOT / \"research\" / \"vestibular_schwannoma\")" + ] }, { "cell_type": "markdown", @@ -96,20 +84,7 @@ "source": [ "## 2. Run configuration\n", "\n", - "The knobs below control the size of the run. A full paper run is\n", - "`3 models x 5 folds x 500 epochs`, which takes on the order of days on a single modern GPU.\n", - "\n", - "To smoke-test the pipeline end to end in a few minutes instead, shrink the sweep, for\n", - "example:\n", - "\n", - "- `MODELS_TO_RUN = [\"unet\"]`\n", - "- `FOLDS_TO_RUN = [1]`\n", - "- `EPOCHS = 2`\n", - "\n", - "The demo exercises every stage (preprocessing, patch loading, training, sliding-window\n", - "evaluation, metric computation, aggregation) without producing publication-quality models.\n", - "`TARGET_SPACING` and `PATCH_SIZE` are part of the shared preprocessing contract and must\n", - "match the inference notebook, so they are not casual knobs." + "A full run is `3 models x 5 folds x 500 epochs`, on the order of days on one modern GPU. Shrink the knobs (e.g. `MODELS_TO_RUN = [\"unet\"]`, `FOLDS_TO_RUN = [1]`, `EPOCHS = 2`) for a quick smoke test that still exercises every stage. `TARGET_SPACING` and `PATCH_SIZE` are the shared preprocessing contract and must match `02_inference_new_cases.ipynb`." ] }, { @@ -119,25 +94,21 @@ "metadata": {}, "outputs": [], "source": [ - "# =============================== RUN KNOBS ===============================\n", - "# Which models and folds to run, and for how long. Shrink these for a quick\n", - "# pipeline smoke test (see the note above); keep the defaults for a full run.\n", "MODELS_TO_RUN = [\"unet\", \"dynunet\", \"segmamba\"]\n", "FOLDS_TO_RUN = [1, 2, 3, 4, 5]\n", "\n", - "EPOCHS = 500 # paper setting; set to e.g. 2 for a quick pipeline demo\n", - "BS = 4 # patches per training batch\n", - "LR = 1e-3 # maximum learning rate for fit_one_cycle\n", - "USE_TTA = True # 8-flip test-time augmentation during evaluation\n", - "# ========================================================================\n", + "EPOCHS = 500 # paper setting; 2 for a quick demo\n", + "BS = 4\n", + "LR = 1e-3 # max LR for fit_one_cycle\n", + "USE_TTA = True # 8-flip TTA at evaluation\n", "\n", - "# Shared preprocessing contract. These MUST match 02_inference_new_cases.ipynb.\n", + "# Shared preprocessing contract. MUST match 02_inference_new_cases.ipynb.\n", "TARGET_SPACING = [0.4102, 0.4102, 1.5] # resample target, voxel size in mm\n", "PATCH_SIZE = [192, 192, 48] # patch shape in voxels\n", "\n", "DATA_CSV = \"ml_dataset.csv\"\n", "\n", - "# cuDNN autotuner: a fixed patch size means the fastest kernels are found once and reused.\n", + "# cuDNN autotuner: fixed patch size means the fastest kernels are found once and reused.\n", "torch.backends.cudnn.benchmark = True\n", "\n", "print(f\"Models: {MODELS_TO_RUN}\")\n", @@ -153,11 +124,7 @@ "source": [ "## 3. Dataset and folds\n", "\n", - "The dataset CSV lists one row per case, with the raw image and mask paths, the pre-assigned\n", - "`fold` (1..5), and a `split` column. Cross-validation defines the validation set for each\n", - "fold inside `train_one_fold` as `is_val = (fold == fold_num)`; the held-out fold is the\n", - "validation set and the other four folds are training. All 346 cases take part in every\n", - "sweep, so the `split` column is deliberately not used here." + "The CSV lists one row per case with raw image and mask paths, the pre-assigned `fold` (1..5), and a `split` column. `train_one_fold` sets the validation set as `is_val = (fold == fold_num)`; all 346 cases take part, so the `split` column is not used here." ] }, { @@ -183,7 +150,11 @@ "cell_type": "markdown", "id": "e3e3534f", "metadata": {}, - "source": "## 4. Preprocess once to disk\n\nPreprocessing (RAS+ reorientation, resampling to `TARGET_SPACING`, and foreground-masked\nZ-normalization) is **fold-independent**: it depends only on each raw volume, not on which\nfold that volume lands in. The original per-model scripts repeated it inside every fold; we\nhoist it out and run it a single time over all 346 cases. This is faithful to the pipeline\nbut avoids four redundant passes per model.\n\n`preprocess_dataset` writes the processed volumes under `preprocessed/` and adds the columns\n`t1_img_path_preprocessed` and `t1_seg_path_preprocessed` to `train_df` in place. With\n`skip_existing=True` it is effectively idempotent: re-running reuses the cache instead of\nrecomputing, so you can safely point it at an existing `preprocessed/` folder. During\ntraining, `PatchConfig(preprocessed=True)` then skips reorder, resample, and normalization\nbecause the inputs are already prepared." + "source": [ + "## 4. Preprocess once to disk\n", + "\n", + "Preprocessing (RAS+ reorientation, resampling to `TARGET_SPACING`, foreground-masked Z-normalization) is fold-independent, so we run it once over all 346 cases. `preprocess_dataset` writes processed volumes under `preprocessed/` and adds `t1_img_path_preprocessed` / `t1_seg_path_preprocessed` to `train_df` in place; `skip_existing=True` makes it idempotent, reusing the cache on re-run. During training, `PatchConfig(preprocessed=True)` then skips reorder, resample, and normalization." + ] }, { "cell_type": "markdown", @@ -192,11 +163,7 @@ "source": [ "### Intensity normalization\n", "\n", - "`ZNormalization(masking_method=\"foreground\")` applies a Z-score: it subtracts a mean and\n", - "divides by a standard deviation. These are computed from foreground voxels, meaning voxels\n", - "with intensity above zero (read from the image, not the tumor mask), and then applied to the\n", - "whole volume. Restricting the statistics to the foreground stops the large zero-valued\n", - "background from dominating them and washing out tissue contrast." + "`ZNormalization(masking_method=\"foreground\")` computes mean and std from foreground voxels (intensity above zero, read from the image not the mask) and applies them to the whole volume. Restricting the statistics to the foreground stops the large zero-valued background from washing out tissue contrast." ] }, { @@ -206,9 +173,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Single source of truth for pre-patch / pre-inference intensity normalization. The same\n", - "# list is reused when building the training DataLoaders and when running sliding-window\n", - "# inference, guaranteeing identical preprocessing on both sides.\n", + "# Single source of truth for pre-patch / pre-inference intensity normalization.\n", "pre_patch_tfms = [ZNormalization(masking_method=\"foreground\")]\n", "\n", "preprocess_dataset(\n", @@ -229,7 +194,16 @@ "cell_type": "markdown", "id": "23c25507", "metadata": {}, - "source": "## 5. Shared pipeline building blocks\n\nEvery model is trained through the same two helpers, so differences in results come from the\nnetwork, not the data pipeline.\n\n`create_patch_config()` returns a `PatchConfig`, the single source of truth for how patches\nare sampled and stitched back together:\n\n- The volumes are large and the tumor is tiny, so we train on **192 x 192 x 48** patches\n rather than whole scans.\n- A **label sampler** with `label_probabilities={0: 0.2, 1: 0.8}` draws 80% of patches\n centered on tumor voxels. Without this bias the network would rarely see foreground.\n- `samples_per_volume=4`: four patches are drawn each time a volume is visited.\n- At evaluation time patches overlap by 50% and are blended with a **Hann window**\n (`aggregation_mode=\"hann\"`) for seamless reconstruction.\n- `preprocessed=True` tells the loader the volumes on disk are already reoriented, resampled,\n and normalized, so it does not repeat that work.\n- `normalization=pre_patch_tfms` records the intensity normalization (foreground\n Z-normalization) on the config as the single source of truth. The volumes on disk are\n already normalized, so training does not re-apply it under `preprocessed=True`; keeping it\n on the config documents the choice, logs it to the MLflow run, and lets inference rebuild the\n identical transform.\n- `keep_largest_component=True` post-processes predictions to the single largest connected\n region, which suits a solitary VS tumor." + "source": [ + "## 5. Shared pipeline building blocks\n", + "\n", + "Every model trains through the same helpers, so result differences come from the network, not the pipeline. `create_patch_config()` returns the `PatchConfig` that governs how patches are sampled and stitched:\n", + "\n", + "- A **label sampler** with `label_probabilities={0: 0.2, 1: 0.8}` draws 80% of patches centered on tumor voxels; without this bias the network would rarely see foreground.\n", + "- `preprocessed=True` tells the loader the volumes on disk are already reoriented, resampled, and normalized, so it does not repeat that work.\n", + "- `normalization=pre_patch_tfms` records the intensity normalization on the config as the single source of truth: not re-applied under `preprocessed=True`, but logged to MLflow and used to rebuild the identical transform at inference.\n", + "- `keep_largest_component=True` post-processes each prediction to its single largest connected region, which suits a solitary VS tumor." + ] }, { "cell_type": "code", @@ -237,7 +211,46 @@ "id": "42d6caca", "metadata": {}, "outputs": [], - "source": "def create_patch_config():\n \"\"\"Patch sampling and aggregation settings shared by all three models.\"\"\"\n return PatchConfig(\n patch_size=PATCH_SIZE,\n samples_per_volume=4,\n sampler_type=\"label\",\n label_probabilities={0: 0.2, 1: 0.8},\n patch_overlap=0.5,\n keep_largest_component=True,\n target_spacing=TARGET_SPACING,\n preprocessed=True,\n normalization=pre_patch_tfms,\n aggregation_mode=\"hann\",\n queue_num_workers=16,\n queue_length=1200,\n )\n\n\npatch_config = create_patch_config()\npatch_config" + "source": [ + "def create_patch_config():\n", + " \"\"\"Patch sampling and aggregation settings shared by all three models.\"\"\"\n", + " return PatchConfig(\n", + " patch_size=PATCH_SIZE,\n", + " samples_per_volume=4,\n", + " sampler_type=\"label\",\n", + " label_probabilities={0: 0.2, 1: 0.8},\n", + " patch_overlap=0.5,\n", + " keep_largest_component=True,\n", + " target_spacing=TARGET_SPACING,\n", + " preprocessed=True,\n", + " normalization=pre_patch_tfms,\n", + " aggregation_mode=\"hann\",\n", + " queue_num_workers=16,\n", + " queue_length=1200,\n", + " )\n", + "\n", + "\n", + "patch_config = create_patch_config()\n", + "\n", + "# Persist the config so 02_inference_new_cases.ipynb rebuilds identical preprocessing and patch\n", + "# settings. Local copy here; train_one_fold also logs it as a per-run MLflow artifact.\n", + "store_patch_variables(\n", + " \"inference_patch_config.json\",\n", + " patch_size=patch_config.patch_size,\n", + " patch_overlap=patch_config.patch_overlap,\n", + " aggregation_mode=patch_config.aggregation_mode,\n", + " apply_reorder=patch_config.apply_reorder,\n", + " target_spacing=patch_config.target_spacing,\n", + " sampler_type=patch_config.sampler_type,\n", + " label_probabilities=patch_config.label_probabilities,\n", + " samples_per_volume=patch_config.samples_per_volume,\n", + " queue_length=patch_config.queue_length,\n", + " queue_num_workers=patch_config.queue_num_workers,\n", + " keep_largest_component=patch_config.keep_largest_component,\n", + " normalization=patch_config.normalization,\n", + ")\n", + "patch_config" + ] }, { "cell_type": "markdown", @@ -246,12 +259,7 @@ "source": [ "### GPU augmentation\n", "\n", - "`create_gpu_augmentation()` returns a `GpuPatchAugmentation` that applies spatial and\n", - "intensity augmentation to each batch directly on the GPU, which keeps the input pipeline\n", - "from becoming the bottleneck. The transforms and probabilities follow nnU-Net conventions:\n", - "moderate random affine, anisotropy, flips, gamma, intensity scaling, noise, and blur.\n", - "Affine translations are specified in voxels, so the requested millimeter shifts are divided\n", - "by the target spacing. Augmentation is applied to the training set only." + "`create_gpu_augmentation()` returns a `GpuPatchAugmentation` that augments each batch on the GPU so the input pipeline is not the bottleneck. Transforms and probabilities follow nnU-Net conventions; affine translations are given in voxels, so millimeter shifts are divided by the target spacing, and augmentation is applied to the training set only." ] }, { @@ -281,11 +289,71 @@ " )" ] }, + { + "cell_type": "markdown", + "id": "525b796f", + "metadata": {}, + "source": [ + "### Sanity check: inspect a training batch\n", + "\n", + "Draw one batch of patches from the first fold and overlay the ground-truth mask, a quick check that the label sampler centers patches on the tumor, the intensities look normalized, and the mask lines up with the lesion. The cell is standalone and does not touch `train_one_fold` or the driver." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8f22431b", + "metadata": {}, + "outputs": [], + "source": [ + "# Pre-flight sanity check (standalone, runs once).\n", + "sanity_fold = FOLDS_TO_RUN[0]\n", + "sanity_df = train_df.copy()\n", + "sanity_df[\"is_val\"] = sanity_df[\"fold\"] == sanity_fold\n", + "\n", + "# Trim the queue so the first batch loads a few volumes, not the whole fold (the training\n", + "# queue would load all ~277 cases and buffer ~16 GB before batch 1). One patch per volume\n", + "# still gives BS distinct cases.\n", + "sanity_config = create_patch_config() # fresh config leaves patch_config alone\n", + "sanity_config.samples_per_volume = 1\n", + "sanity_config.queue_length = BS\n", + "sanity_config.queue_num_workers = 2\n", + "\n", + "# No gpu_augmentation: show clean patches. preprocessed=True means they carry the on-disk\n", + "# foreground Z-norm, exactly what the model trains on.\n", + "sanity_dls = MedPatchDataLoaders.from_df(\n", + " df=sanity_df,\n", + " img_col=\"t1_img_path_preprocessed\",\n", + " mask_col=\"t1_seg_path_preprocessed\",\n", + " valid_col=\"is_val\",\n", + " patch_config=sanity_config,\n", + " bs=BS,\n", + ")\n", + "\n", + "# Axial slice per patch, mask overlaid. Look for patches centered on the lesion, near\n", + "# zero-mean contrast, and the mask hugging the tumor.\n", + "sanity_dls.show_batch(dl_idx=0, max_n=4, overlay=True, anatomical_plane=2)\n", + "\n", + "sanity_dls.close()" + ] + }, { "cell_type": "markdown", "id": "6e13dc69", "metadata": {}, - "source": "## 6. Model definitions\n\nThe three models are registered in a single `MODELS` dictionary. Each entry provides a\n`make_model` factory, a `make_loss` factory, and the exact experiment name, checkpoint\nfilename, and results directory that the sibling inference notebook expects:\n\n| key | experiment | checkpoint | results dir |\n|-----|------------|-----------|-------------|\n| `unet` | `vs5f_unet` | `best_unet` | `cv_results_unet` |\n| `dynunet` | `vs5f_dynunet` | `best_dynunet` | `cv_results_dynunet` |\n| `segmamba` | `vs5f_segmamba` | `best_segmamba` | `cv_results_segmamba` |\n\nUNet and SegMamba use a Dice + cross-entropy loss. DynUNet emits predictions at several\ndecoder depths, so it is wrapped in `DynUNetDSAdapter` (which turns the stacked output into\nthe list that `DeepSupervisionLoss` expects) and trained with a deep-supervision loss. UNet\nand DynUNet are compiled with `torch.compile`; SegMamba is not, because it relies on custom\nCUDA kernels.\n\n### Install SegMamba\n\n**UNet and DynUNet need nothing extra** beyond fastMONAI: they are MONAI built-ins.\n\n**SegMamba requires our fork** (`models_segmamba`, imported as `from\nmodels_segmamba.segmambav2 import SegMamba`; default backend `mamba_ssm`, 138.77M\nparameters). Install it from GitHub with the `[gpu]` extra, which is the one command you\nrun for this notebook: it brings the base package plus the CUDA Mamba kernels that the\ndefault `mamba_ssm` backend needs:\n\n pip install \"segmamba-v2[gpu] @ git+https://github.com/skaliy/SegMamba-V2.git\"\n\nThe kernels (`mamba-ssm`, `causal-conv1d`) sit behind the `[gpu]` extra rather than in the\nbase install because they compile CUDA extensions and only build where the CUDA toolchain is\npresent; keeping them optional lets the package install on machines without a GPU. For a\nCPU-only box (inference/dev; CPU training is not practical) use `[cpu]` instead, which pulls\nin transformers for the `mamba_backend=\"mambamixer\"` path:\n\n pip install \"segmamba-v2[cpu] @ git+https://github.com/skaliy/SegMamba-V2.git\"\n\nCaveat: `mamba-ssm` and `causal-conv1d` are CUDA-only and are compiled against the active\ntorch build. A torch version bump can break `causal-conv1d` with an \"undefined symbol\" ABI\nerror; reinstall it against the current torch with `--no-build-isolation --force-reinstall`.\n\nSegMamba is imported once, up front, in the setup cell (section 1) inside a `try/except`. If\nthe fork is not importable, `SEGMAMBA_AVAILABLE` is set to `False`, the install command is\nprinted, and the next cell skips SegMamba while UNet and DynUNet still run. A fork that imports\nbut whose CUDA kernels are broken (the \"undefined symbol\" case above) is not caught here,\nbecause the import does not load the kernels; it surfaces later, when the model is built, where\nthe driver's per-(model, fold) `try/except` skips just that run." + "source": [ + "## 6. Model definitions\n", + "\n", + "The three models are registered in a single `MODELS` dictionary. Each entry provides a `make_model` factory, a `make_loss` factory, and the experiment name, checkpoint filename, and results directory the inference notebook expects:\n", + "\n", + "| key | experiment | checkpoint | results dir |\n", + "|-----|------------|-----------|-------------|\n", + "| `unet` | `vs5f_unet` | `best_unet` | `cv_results_unet` |\n", + "| `dynunet` | `vs5f_dynunet` | `best_dynunet` | `cv_results_dynunet` |\n", + "| `segmamba` | `vs5f_segmamba` | `best_segmamba` | `cv_results_segmamba` |\n", + "\n", + "UNet and SegMamba use a Dice + cross-entropy loss. DynUNet emits predictions at several decoder depths, so it is wrapped in `DynUNetDSAdapter` and trained with `DeepSupervisionLoss`. UNet and DynUNet are compiled with `torch.compile`; SegMamba is not, because it relies on custom CUDA kernels." + ] }, { "cell_type": "code", @@ -350,8 +418,7 @@ " experiment=\"vs5f_dynunet\", best_fname=\"best_dynunet\", results_dir=\"cv_results_dynunet\",\n", ")\n", "\n", - "# SegMamba (optional fork): registered only if it imported cleanly in the setup cell. Not\n", - "# wrapped in torch.compile because it relies on custom CUDA kernels.\n", + "# SegMamba (optional fork): registered only if it imported cleanly. No torch.compile (custom CUDA kernels).\n", "if SEGMAMBA_AVAILABLE:\n", "\n", " def make_segmamba():\n", @@ -376,8 +443,7 @@ "\n", "print(\"Registered models:\", list(MODELS))\n", "\n", - "# If segmamba was requested but is unavailable, note it (the setup cell already printed the\n", - "# install/fix command). A UNet/DynUNet-only run stays quiet.\n", + "# Note a requested-but-unavailable segmamba (setup cell already printed the install hint).\n", "if \"segmamba\" in MODELS_TO_RUN and \"segmamba\" not in MODELS:\n", " print(\"\\n[segmamba] requested but its fork is not importable, so it is skipped \"\n", " \"(see the install hint from the setup cell above).\")" @@ -390,22 +456,14 @@ "source": [ "## 7. Train one fold\n", "\n", - "`train_one_fold(model_key, fold_num, train_df, patch_config)` runs a single training job. It\n", - "marks the held-out fold as validation (`is_val = fold == fold_num`), builds the patch\n", - "DataLoaders from the **preprocessed** columns, constructs the model and loss from the\n", - "registry, and trains with `fit_one_cycle`. Training uses:\n", - "\n", - "- `AccumulatedDice(n_classes=2)` as the monitored metric (nnU-Net-style pseudo-Dice\n", - " accumulated over the whole validation set, more stable than per-batch Dice).\n", - "- `EMACheckpoint` to save the best weights by the exponential moving average of that metric,\n", - " under the per-model checkpoint name so models do not overwrite each other.\n", - "- `create_mlflow_callback` to log parameters, metrics, the split, and artifacts to the\n", - " per-model MLflow experiment, tagged with the dataset fingerprint.\n", + "`train_one_fold(model_key, fold_num, train_df, patch_config)` runs one training job: it marks the held-out fold as validation, builds the patch DataLoaders from the preprocessed columns, constructs the model and loss from the registry, and trains with `fit_one_cycle`. Training uses:\n", + "\n", + "- `AccumulatedDice(n_classes=2)` as the monitored metric (nnU-Net-style pseudo-Dice accumulated over the whole validation set).\n", + "- `EMACheckpoint` to save the best weights by the EMA of that metric, under the per-model checkpoint name.\n", + "- `create_mlflow_callback` to log parameters, metrics, the split, and artifacts to the per-model MLflow experiment.\n", "- `.to_bf16()` for bfloat16 mixed-precision training.\n", "\n", - "At the end it calls `evaluate_fold` (defined in the next section) and frees GPU memory. The\n", - "function is defined here but only executed later by the driver loop, so it can safely refer\n", - "to `evaluate_fold` from the following cell." + "At the end it calls `evaluate_fold` (next section) and frees GPU memory." ] }, { @@ -414,7 +472,80 @@ "id": "5384dd04", "metadata": {}, "outputs": [], - "source": "def train_one_fold(model_key, fold_num, train_df, patch_config):\n \"\"\"Train one model on one fold, then evaluate the held-out fold.\"\"\"\n entry = MODELS[model_key]\n print(f\"\\n{'='*60}\")\n print(f\" {model_key.upper()} | FOLD {fold_num}\")\n print(f\"{'='*60}\\n\")\n\n # Fold membership: the held-out fold becomes the validation set. All 346 cases\n # participate; the CSV 'split' column is not used here.\n train_df = train_df.copy()\n train_df[\"is_val\"] = train_df[\"fold\"] == fold_num\n\n # Content fingerprint of the label set, logged to MLflow for reproducibility.\n med_dataset = MedDataset(\n img_list=train_df.t1_seg_path.tolist(), dtype=MedMask, max_workers=32\n )\n\n gpu_aug = create_gpu_augmentation()\n\n # Normalization lives on patch_config (single source of truth). The preprocessed columns\n # are already reoriented, resampled, and Z-normalized, so preprocessed=True skips re-applying\n # it here; the config still carries the spec for logging and inference parity.\n dls = MedPatchDataLoaders.from_df(\n df=train_df,\n img_col=\"t1_img_path_preprocessed\",\n mask_col=\"t1_seg_path_preprocessed\",\n valid_col=\"is_val\",\n patch_config=patch_config,\n gpu_augmentation=gpu_aug,\n bs=BS,\n )\n\n print(f\"[{model_key} fold {fold_num}] Train: {len(dls.train.subjects_dataset)}, \"\n f\"Val: {len(dls.valid.subjects_dataset)}\")\n\n model = entry[\"make_model\"]()\n loss_func = entry[\"make_loss\"]()\n\n learn = Learner(dls, model, loss_func=loss_func,\n metrics=[AccumulatedDice(n_classes=2)]).to_bf16()\n\n save_best = EMACheckpoint(\n monitor=\"accumulated_dice\", momentum=0.9,\n comp=np.greater, fname=entry[\"best_fname\"], with_opt=False,\n )\n\n mlflow_cb = create_mlflow_callback(\n learn,\n experiment_name=entry[\"experiment\"],\n run_name=f\"fold_{fold_num}\",\n extra_tags={\"fold\": str(fold_num)},\n dataset_version=med_dataset.fingerprint,\n )\n\n learn.fit_one_cycle(EPOCHS, LR, cbs=[mlflow_cb, save_best])\n\n results_df = evaluate_fold(\n learn, patch_config, dls, pre_patch_tfms, fold_num,\n tta=USE_TTA, mlflow_cb=mlflow_cb, results_dir=Path(entry[\"results_dir\"]),\n )\n\n # Release GPU memory before the next (model, fold).\n del learn, model, dls, gpu_aug\n torch.cuda.empty_cache()\n gc.collect()\n\n return results_df" + "source": [ + "def train_one_fold(model_key, fold_num, train_df, patch_config):\n", + " \"\"\"Train one model on one fold, then evaluate the held-out fold.\"\"\"\n", + " entry = MODELS[model_key]\n", + " print(f\"\\n{'='*60}\")\n", + " print(f\" {model_key.upper()} | FOLD {fold_num}\")\n", + " print(f\"{'='*60}\\n\")\n", + "\n", + " # Held-out fold becomes the validation set.\n", + " train_df = train_df.copy()\n", + " train_df[\"is_val\"] = train_df[\"fold\"] == fold_num\n", + "\n", + " # Content fingerprint of the label set, logged to MLflow for reproducibility.\n", + " med_dataset = MedDataset(\n", + " img_list=train_df.t1_seg_path.tolist(), dtype=MedMask, max_workers=32\n", + " )\n", + "\n", + " gpu_aug = create_gpu_augmentation()\n", + "\n", + " # Normalization lives on patch_config; preprocessed columns are already normalized, so\n", + " # preprocessed=True skips re-applying it here.\n", + " dls = MedPatchDataLoaders.from_df(\n", + " df=train_df,\n", + " img_col=\"t1_img_path_preprocessed\",\n", + " mask_col=\"t1_seg_path_preprocessed\",\n", + " valid_col=\"is_val\",\n", + " patch_config=patch_config,\n", + " gpu_augmentation=gpu_aug,\n", + " bs=BS,\n", + " )\n", + "\n", + " print(f\"[{model_key} fold {fold_num}] Train: {len(dls.train.subjects_dataset)}, \"\n", + " f\"Val: {len(dls.valid.subjects_dataset)}\")\n", + "\n", + " model = entry[\"make_model\"]()\n", + " loss_func = entry[\"make_loss\"]()\n", + "\n", + " learn = Learner(dls, model, loss_func=loss_func,\n", + " metrics=[AccumulatedDice(n_classes=2)]).to_bf16()\n", + "\n", + " save_best = EMACheckpoint(\n", + " monitor=\"accumulated_dice\", momentum=0.9,\n", + " comp=np.greater, fname=entry[\"best_fname\"], with_opt=False,\n", + " )\n", + "\n", + " mlflow_cb = create_mlflow_callback(\n", + " learn,\n", + " experiment_name=entry[\"experiment\"],\n", + " run_name=f\"fold_{fold_num}\",\n", + " extra_tags={\"fold\": str(fold_num)},\n", + " dataset_version=med_dataset.fingerprint,\n", + " )\n", + "\n", + " learn.fit_one_cycle(EPOCHS, LR, cbs=[mlflow_cb, save_best])\n", + "\n", + " # Reopen this fold's MLflow run to attach the patch config so 02 can pull it bound to these\n", + " # checkpoints (the callback closed the run but keeps its id).\n", + " import mlflow\n", + " if mlflow_cb.run_id is not None:\n", + " with mlflow.start_run(run_id=mlflow_cb.run_id):\n", + " mlflow.log_artifact(\"inference_patch_config.json\", artifact_path=\"config\")\n", + "\n", + " results_df = evaluate_fold(\n", + " learn, patch_config, dls, pre_patch_tfms, fold_num,\n", + " tta=USE_TTA, mlflow_cb=mlflow_cb, results_dir=Path(entry[\"results_dir\"]),\n", + " )\n", + "\n", + " # Release GPU memory before the next (model, fold).\n", + " del learn, model, dls, gpu_aug\n", + " torch.cuda.empty_cache()\n", + " gc.collect()\n", + "\n", + " return results_df" + ] }, { "cell_type": "markdown", @@ -423,16 +554,7 @@ "source": [ "## 8. Evaluate a fold\n", "\n", - "After training, we score the held-out fold on the **raw** validation images (not the cached\n", - "preprocessed copies), re-applying the identical `ZNormalization` through\n", - "`pre_inference_tfms`. This mirrors real inference on unseen data end to end.\n", - "\n", - "For each case we compute Dice, sensitivity, precision, lesion-detection rate, and signed\n", - "relative volume error, plus spacing-aware surface metrics (ASSD, HD95, and Normalized Surface\n", - "Dice at 0.5/1.0/2.0 mm) via `calculate_surface_metrics`. Voxel spacing is read **per case**\n", - "from the ground-truth file, because the cohort spacing is not uniform and a single latched\n", - "affine would corrupt every distance. Per-case scores go to `results.csv`, provenance to\n", - "`benchmark_meta.json`, and finite-mean aggregates to MLflow." + "After training, we score the held-out fold on the raw validation images (not the preprocessed copies), re-applying the identical `ZNormalization` through `pre_inference_tfms`, which mirrors real inference end to end. For each case we compute Dice, sensitivity, precision, lesion-detection rate, and signed relative volume error, plus spacing-aware surface metrics (ASSD, HD95, and Normalized Surface Dice at 0.5/1.0/2.0 mm) via `calculate_surface_metrics`. Voxel spacing is read per case from the ground-truth file, because the cohort spacing is not uniform." ] }, { @@ -465,8 +587,7 @@ " tta=tta,\n", " )\n", "\n", - " # Per-case metric table via the library helper: it reads each GT mask's data AND spacing\n", - " # from one object (so the array and its spacing can't disagree) and runs the full panel.\n", + " # Per-case metric table: reads each GT mask's data and spacing from one object so they can't disagree.\n", " results_df = evaluate_segmentations(predictions, val_mask_paths,\n", " case_ids=val_df[\"case_id\"].tolist())\n", " results_df.insert(1, \"image\", [Path(p).name for p in val_img_paths])\n", @@ -475,8 +596,7 @@ " f\"({results_df['spacing_mm'].iloc[0]}); verify per-case derivation if unexpected.\")\n", " results_df.to_csv(fold_dir / \"results.csv\", index=False)\n", "\n", - " # Benchmark provenance (arxiv:2410.02630 transparency): metric implementation,\n", - " # NSD tolerances, and per-case spacing/empty-status used for the surface metrics.\n", + " # Benchmark provenance (arxiv:2410.02630 transparency): metric impl, NSD tolerances, per-case spacing.\n", " from fastMONAI.vision_metrics import _SURFACE_DISTANCE_SOURCE\n", " with open(fold_dir / \"benchmark_meta.json\", \"w\") as f:\n", " json.dump({\n", @@ -489,8 +609,7 @@ " for r in results_df.to_dict(\"records\")],\n", " }, f, indent=2)\n", "\n", - " # inf-safe means: one_empty cases score assd_mm/hd95_mm = inf, which would poison a plain\n", - " # mean (and MLflow rejects non-finite values). Replacing inf with NaN lets pandas skip it.\n", + " # inf-safe means: one_empty cases score inf; replace with NaN so pandas skips it (MLflow rejects non-finite).\n", " numeric = results_df.select_dtypes(include=\"number\").replace([np.inf, -np.inf], np.nan)\n", " mlflow_cb.log_metrics_table(results_df, display=False)\n", " mlflow_cb.log_metrics({f\"val_{m}\": numeric[m].mean() for m in numeric.columns})\n", @@ -508,14 +627,7 @@ "source": [ "## 9. Cross-validation driver\n", "\n", - "The driver sweeps `MODELS_TO_RUN x FOLDS_TO_RUN`, calling `train_one_fold` for each pair.\n", - "Each pair is wrapped in try/except: if one job fails (for example an out-of-memory error on\n", - "one fold) the error is printed and the sweep continues with the next pair rather than\n", - "aborting the whole run. Unregistered model keys (for example `segmamba` without the fork)\n", - "are skipped with a message.\n", - "\n", - "This is the long-running cell. For the full paper configuration it trains up to 15 models\n", - "and can take days. Reduce the knobs in the configuration cell for a quick demo." + "The driver sweeps `MODELS_TO_RUN x FOLDS_TO_RUN`, calling `train_one_fold` for each pair. Each pair is wrapped in try/except, so one failure (for example an out-of-memory error on one fold) is logged and the sweep continues rather than aborting." ] }, { @@ -525,8 +637,7 @@ "metadata": {}, "outputs": [], "source": [ - "# Resilient sweep: a failure on one (model, fold) is logged and skipped so it does not abort\n", - "# the rest of the run. Re-running is safe; a completed fold simply overwrites its outputs.\n", + "# Resilient sweep: a failure on one (model, fold) is logged and skipped, not fatal.\n", "for model_key in MODELS_TO_RUN:\n", " if model_key not in MODELS:\n", " print(f\"[skip] '{model_key}' is not registered (see the model definitions cell above).\")\n", @@ -554,12 +665,7 @@ "source": [ "## 10. Aggregate per-model results\n", "\n", - "For each model, `aggregate_results` concatenates the per-fold `results.csv` files into a\n", - "single `cv_summary.csv` inside that model's results directory, then prints a per-metric\n", - "mean/standard-deviation summary and the per-fold DSC breakdown. Surface distances in\n", - "millimeters (ASSD, HD95) are averaged over **finite values only**: a case where exactly one\n", - "of prediction or ground truth is empty scores `+inf` by design, and a plain mean would be\n", - "dominated by it." + "For each model, `aggregate_results` concatenates the per-fold `results.csv` files into `cv_summary.csv` and prints a per-metric mean/std summary plus the per-fold DSC breakdown. Millimeter surface distances (ASSD, HD95) are averaged over finite values only, since a case where exactly one of prediction or ground truth is empty scores `+inf` by design." ] }, { @@ -570,11 +676,7 @@ "outputs": [], "source": [ "def aggregate_results(results_dir):\n", - " \"\"\"Concatenate a model's per-fold results.csv into cv_summary.csv and print a summary.\n", - "\n", - " Returns the combined DataFrame, or None if no fold results exist yet (notebook-safe:\n", - " it does not exit the process).\n", - " \"\"\"\n", + " \"\"\"Concatenate a model's per-fold results.csv into cv_summary.csv and print a summary; None if no results yet.\"\"\"\n", " results_dir = Path(results_dir)\n", " all_results = []\n", " for fold_dir in sorted(results_dir.glob(\"fold_*\")):\n", @@ -653,12 +755,7 @@ "source": [ "## 11. Cross-model comparison\n", "\n", - "This is the headline table and it is net-new relative to the per-model scripts. For every\n", - "model that produced results, we pool all held-out cases across folds and report the mean and\n", - "standard deviation of each key metric, save the numbers to `cv_model_comparison.csv`, and\n", - "display a compact `mean +/- std` view. The same inf-safe averaging is applied here, so the\n", - "millimeter surface metrics ignore non-finite per-case scores (and NaN, e.g. RVE on an empty\n", - "ground truth, is skipped the same way)." + "For every model that produced results, we pool all held-out cases across folds, report the mean and standard deviation of each key metric, save the numbers to `cv_model_comparison.csv`, and display a compact `mean +/- std` view. The same inf-safe averaging is applied, so the millimeter surface metrics ignore non-finite per-case scores (and NaN, e.g. RVE on an empty ground truth, is skipped the same way)." ] }, { @@ -668,10 +765,6 @@ "metadata": {}, "outputs": [], "source": [ - "# Headline metrics for the cross-model table. Surface distances in mm (assd/hd95) can be\n", - "# +inf for 'one_empty' cases (exactly one of prediction/ground truth empty). pandas .mean()/\n", - "# .std() already skip NaN but not inf, so we replace inf with NaN first; the finite-only mean\n", - "# and sample std (ddof=1, the pandas default) then match the per-model summary above.\n", "KEY_METRICS = [\"dsc\", \"sensitivity\", \"precision\", \"ldr\", \"rve\",\n", " \"assd_mm\", \"hd95_mm\", \"nsd_tau1.0_mm\"]\n", "\n", @@ -704,38 +797,127 @@ " print(\"No per-model results available yet. Run the driver loop first.\")" ] }, + { + "cell_type": "markdown", + "id": "cf1a1c7a", + "metadata": {}, + "source": [ + "## 12. Qualitative check: prediction vs. ground truth\n", + "\n", + "A visual sanity check on the saved validation masks. We reload one model's predictions from disk and compare the median-DSC (representative) and worst-DSC (failure) cases against their ground truth; input, ground truth, and prediction share each case's original voxel grid, so they line up slice for slice." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c39cd8cc", + "metadata": {}, + "outputs": [], + "source": [ + "from fastMONAI.vision_plot import *\n", + "import matplotlib.pyplot as plt\n", + "\n", + "\n", + "def _pred_filename(input_path):\n", + " \"\"\"Prediction filename patch_inference wrote: '_pred.nii[.gz]'. Mirrors vision_patch._save_prediction.\"\"\"\n", + " p = Path(input_path)\n", + " if p.suffix == \".gz\" and p.stem.endswith(\".nii\"):\n", + " return f\"{p.stem[:-4]}_pred.nii.gz\"\n", + " if p.suffix == \".nii\":\n", + " return f\"{p.stem}_pred.nii\"\n", + " return f\"{p.stem}_pred.nii.gz\"\n", + "\n", + "\n", + "def show_qualitative_examples(plane=2):\n", + " \"\"\"Show the median-DSC and worst-DSC validation cases for the first model with results on disk.\"\"\"\n", + " # First registered model that actually produced fold results.\n", + " picked = None\n", + " for model_key, entry in MODELS.items():\n", + " results_dir = Path(entry[\"results_dir\"])\n", + " fold_dirs = [d for d in sorted(results_dir.glob(\"fold_*\"))\n", + " if (d / \"results.csv\").exists()]\n", + " if fold_dirs:\n", + " picked = (model_key, results_dir, fold_dirs)\n", + " break\n", + " if picked is None:\n", + " print(\"No fold_*/results.csv found for any model. Run the sweep first.\")\n", + " return\n", + " model_key, results_dir, fold_dirs = picked\n", + "\n", + " # Pool the per-fold tables, tagging each row with its validation fold (the predictions/ subdir).\n", + " frames = []\n", + " for fold_dir in fold_dirs:\n", + " df = pd.read_csv(fold_dir / \"results.csv\")\n", + " df[\"fold\"] = int(fold_dir.name.split(\"_\")[1])\n", + " frames.append(df)\n", + " pooled = pd.concat(frames, ignore_index=True)\n", + " print(f\"Model '{model_key}': pooled {len(pooled)} cases from folds \"\n", + " f\"{sorted(pooled['fold'].unique().tolist())}.\")\n", + "\n", + " # Sort by dsc: middle row is the median case, first is the worst (real cases, no interpolation).\n", + " ordered = pooled.sort_values(\"dsc\").reset_index(drop=True)\n", + " picks = [(\"median DSC\", ordered.iloc[len(ordered) // 2]),\n", + " (\"worst DSC\", ordered.iloc[0])]\n", + "\n", + " shown = set()\n", + " for label, row in picks:\n", + " case_id = row[\"case_id\"]\n", + " if case_id in shown: # median == worst when only one case\n", + " continue\n", + " shown.add(case_id)\n", + "\n", + " # case_id -> raw t1/seg paths (original grid) via train_df.\n", + " sub = train_df[train_df[\"case_id\"] == case_id]\n", + " if sub.empty:\n", + " print(f\"[skip] {case_id}: not found in train_df.\")\n", + " continue\n", + " img_path = str(sub.iloc[0][\"t1_img_path\"])\n", + " gt_path = str(sub.iloc[0][\"t1_seg_path\"])\n", + "\n", + " fold = int(row[\"fold\"])\n", + " pred_path = results_dir / f\"fold_{fold}\" / \"predictions\" / _pred_filename(img_path)\n", + " if not pred_path.exists():\n", + " print(f\"[skip] {case_id}: prediction not found at {pred_path}.\")\n", + " continue\n", + "\n", + " # Default loader (no reorder/resample): input, GT and prediction share the case's original\n", + " # grid. voxel_size is the native spacing, so the display aspect is correct.\n", + " img = MedImage.create(img_path)\n", + " gt = MedMask.create(gt_path)\n", + " pred = MedMask.create(str(pred_path))\n", + " vsize = tio.ScalarImage(img_path).spacing\n", + "\n", + " dsc = float(row[\"dsc\"])\n", + " print(f\"[{label}] {case_id} (fold {fold}): DSC = {dsc:.4f}\")\n", + " show_segmentation_comparison(img, gt, pred, metric_value=dsc, metric_name=\"DSC\",\n", + " anatomical_plane=plane, voxel_size=vsize)\n", + " plt.show()\n", + "\n", + "\n", + "show_qualitative_examples()" + ] + }, { "cell_type": "markdown", "id": "4b709ef9", "metadata": {}, "source": [ - "## 12. Viewing runs and next steps\n", + "## 13. Viewing runs and next steps\n", "\n", - "Every fold logs to MLflow (metrics, parameters, the train/validation split, and model\n", - "artifacts) under the per-model experiment names `vs5f_unet`, `vs5f_dynunet`, and\n", - "`vs5f_segmamba`. To browse them, launch the fastMONAI MLflow UI from a notebook cell:\n", + "Every fold logs metrics, parameters, the train/validation split, and model artifacts to MLflow under the per-model experiments `vs5f_unet`, `vs5f_dynunet`, and `vs5f_segmamba`. To browse them, launch the fastMONAI MLflow UI from a notebook cell:\n", "\n", " mlflow_ui = MLflowUIManager()\n", " mlflow_ui.start_ui() # opens http://localhost:5001\n", "\n", - "The UI is tied to the kernel and is reaped when the interpreter exits, so restarting the\n", - "notebook leaves no orphaned server holding the port.\n", - "\n", "Artifacts written to disk:\n", "\n", "- `preprocessed/` - reoriented, resampled, normalized volumes (shared by all folds).\n", - "- `cv_results_/fold_N/` - per-fold `results.csv`, `benchmark_meta.json`, and NIfTI\n", - " predictions.\n", + "- `inference_patch_config.json` - patch/preprocessing config, also logged to each fold's MLflow run under `config/`; notebook 02 loads it for inference parity.\n", + "- `cv_results_/fold_N/` - per-fold `results.csv`, `benchmark_meta.json`, and NIfTI predictions.\n", "- `cv_results_/cv_summary.csv` - all folds concatenated for one model.\n", "- `cv_model_comparison.csv` - the headline mean +/- std table across models.\n", "\n", - "The best checkpoint for each model is saved by `EMACheckpoint` as `best_unet`,\n", - "`best_dynunet`, and `best_segmamba`. To run any trained model on brand-new cases, see the\n", - "sibling notebook `02_inference_new_cases.ipynb`, which reuses the same `TARGET_SPACING`,\n", - "`PATCH_SIZE`, and `ZNormalization(masking_method=\"foreground\")` contract so that inference\n", - "preprocessing matches training exactly.\n", - "\n", - "To deploy the five folds together as a soft-vote ensemble, pass the list of fold learners to `patch_inference` (or `PatchInferenceEngine`): it averages their per-patch probabilities in a single sliding-window pass, which usually beats any single fold. Notebook 02 does exactly this." + "The best checkpoint per model is saved by `EMACheckpoint` as `best_unet`, `best_dynunet`, and `best_segmamba`. To run any trained model on new cases, see `02_inference_new_cases.ipynb`, which loads `inference_patch_config.json` so inference reuses the exact preprocessing contract. To deploy the five folds together as a soft-vote ensemble, pass the list of fold learners to `patch_inference`; notebook 02 does exactly this." ] }, { @@ -768,4 +950,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/research/vestibular_schwannoma/02_inference_new_cases.ipynb b/research/vestibular_schwannoma/02_inference_new_cases.ipynb index 3791831..4b1e15f 100644 --- a/research/vestibular_schwannoma/02_inference_new_cases.ipynb +++ b/research/vestibular_schwannoma/02_inference_new_cases.ipynb @@ -4,18 +4,22 @@ "cell_type": "markdown", "id": "cfd2f9a8", "metadata": {}, - "source": "# Inference on New Vestibular Schwannoma Cases\n\nThis notebook segments new vestibular schwannoma (VS) MRI scans using the models trained in `01_five_fold_cross_validation.ipynb` and saves the predicted tumor masks as NIfTI files. The audience is students and researchers, so every step is explained.\n\nIt runs a **5-fold soft-voting ensemble**: all five cross-validation models for the chosen architecture are run on the scan, their class probabilities are averaged, and the averaged map is decoded into a mask. This is the standard way to deploy a cross-validation and usually beats any single fold.\n\n## What this notebook does\n\n1. Locates the five fold checkpoints for a model (UNet, DynUNet, or SegMamba) trained in notebook 01.\n2. Rebuilds the exact preprocessing used at training time.\n3. Runs sliding-window patch inference with each fold and averages the probabilities (soft voting).\n4. Saves and visualizes the predicted mask, and optionally scores it against ground truth.\n\n## Sliding-window patch inference\n\nVS scans are large 3D volumes, too big to feed to a 3D network in one piece. Instead we use **patch-based inference**:\n\n- A `GridSampler` slides a fixed-size window (the patch) across the volume with overlap, producing many small patches.\n- The network predicts on each patch.\n- A `GridAggregator` stitches the patch predictions back into a full-volume prediction. We use **Hann-window** aggregation, which weights each patch by a smooth cosine taper so overlapping patches blend without visible seams at the patch borders.\n\nfastMONAI wraps this in `PatchInferenceEngine` and the `patch_inference` helper.\n\n## Soft-voting ensemble\n\nA 5-fold cross-validation produces five models, each trained on a different 4/5 of the data. To turn them into one predictor we combine them by **soft voting**: run all five, apply softmax to each output, average the five probability maps, and only then take the argmax. Averaging probabilities (not the decoded masks) is the correct way to combine multi-class segmentation models, and it gives a small, reliable accuracy gain over any single fold. The engine returns each fold's probability map already resampled and reoriented into the input's original space, so the five maps are voxel-aligned and can be averaged directly.\n\n## The one rule that matters most: preprocessing parity\n\nA segmentation model produces correct output only when the data it sees at inference is preprocessed **identically** to the data it saw during training. Three settings must match exactly:\n\n- **`apply_reorder`**: reorientation to RAS+ canonical orientation.\n- **`target_spacing`**: the voxel spacing the volume is resampled to.\n- **`normalization`**: the intensity normalization (here, foreground Z-normalization).\n\nA mismatch does not raise an error. It silently produces wrong predictions, for example masks that are shifted, mirrored, or rotated. This notebook reuses the same `PatchConfig` and normalization as training so the two pipelines stay in lockstep." + "source": [ + "# Inference on New Vestibular Schwannoma Cases\n", + "\n", + "Segments new VS MRI scans with the models trained in `01_five_fold_cross_validation.ipynb` and saves the predicted tumor masks as NIfTI. It runs a **5-fold soft-voting ensemble**: all five fold models for the chosen architecture (UNet, DynUNet, or SegMamba) are run on the scan, their softmax probabilities averaged, and the average decoded into a mask (argmax + keep-largest-component). This usually beats any single fold.\n", + "\n", + "## Preprocessing parity\n", + "\n", + "A model gives correct output only when inference preprocesses the input exactly as training did. Three settings must match: `apply_reorder` (RAS+ reorientation), `target_spacing` (resample spacing), and `normalization` (foreground Z-normalization). A mismatch does not raise an error; it silently degrades the prediction. We get parity for free by loading the `PatchConfig` that notebook 01 saved at training time." + ] }, { "cell_type": "markdown", "id": "106beda7", "metadata": {}, "source": [ - "## 1. Environment setup\n", - "\n", - "We import the fastMONAI public API and pin the working directory to the project folder so that the relative dataset paths used here (for example `../nii_data/...`) resolve the same way they do during training.\n", - "\n", - "`from fastMONAI.vision_all import *` brings in everything we need for inference: `PatchConfig`, `patch_inference`, `ZNormalization`, `store_patch_variables` / `load_patch_variables`, `MedImage`, `MedMask`, and the metric functions. `load_learner` comes from fastai and is used to load an exported model." + "## 1. Environment setup" ] }, { @@ -24,13 +28,30 @@ "id": "44c21482", "metadata": {}, "outputs": [], - "source": "import os\nfrom pathlib import Path\n\nimport numpy as np\nimport torch\nimport torchio as tio\n\nfrom fastMONAI.vision_all import *\nfrom fastai.learner import load_learner\n\n# Resolve data paths against the repo root so they work wherever the kernel started\n# (VS Code launches at the workspace root; plain Jupyter, in the notebook folder).\nimport fastMONAI\nREPO_ROOT = Path(fastMONAI.__file__).resolve().parent.parent\nos.chdir(REPO_ROOT / \"research\" / \"vestibular_schwannoma\")\n\nimport fastai, monai # fastMONAI already imported above\nprint(\"fastMONAI:\", fastMONAI.__version__)\nprint(\"fastai: \", fastai.__version__)\nprint(\"MONAI: \", monai.__version__)\nprint(\"TorchIO: \", tio.__version__)\nprint(\"PyTorch: \", torch.__version__)\nprint(\"CUDA available:\", torch.cuda.is_available())\nif torch.cuda.is_available():\n print(\"GPU:\", torch.cuda.get_device_name(0))" + "source": [ + "import os\n", + "from pathlib import Path\n", + "\n", + "import numpy as np\n", + "import torch\n", + "import torchio as tio\n", + "\n", + "from fastMONAI.vision_all import *\n", + "from fastai.learner import load_learner\n", + "\n", + "# Resolve paths against the repo root so they work from any launch dir.\n", + "import fastMONAI\n", + "REPO_ROOT = Path(fastMONAI.__file__).resolve().parent.parent\n", + "os.chdir(REPO_ROOT / \"research\" / \"vestibular_schwannoma\")" + ] }, { "cell_type": "markdown", "id": "a29cda31", "metadata": {}, - "source": "## 2. Configuration\n\nEverything you would normally change lives in the cell below. Treat this notebook as a **template**: pick the architecture with `MODEL_KEY`, list the scans in `NEW_CASES`, and run top to bottom. The notebook always soft-votes the five cross-validation folds for the chosen model.\n\n| Knob | Meaning |\n| --- | --- |\n| `MODEL_KEY` | Which architecture to load: `\"unet\"`, `\"dynunet\"`, or `\"segmamba\"`. Must match a trained CV experiment. |\n| `FOLD_LEARNER_PATHS` | Optional `{fold: path/to/best_learner.pkl}`. Leave empty to auto-discover the five folds from MLflow. |\n| `NEW_CASES` | List of NIfTI image paths to segment. These are **raw** scans; the pipeline preprocesses them for you. |\n| `OUTPUT_DIR` | Where predicted masks are written as NIfTI. |\n| `USE_TTA` | 8-flip test-time augmentation. More robust, roughly 8x slower. |\n| `USE_AMP` | Automatic mixed precision (float16) forward pass. Faster on CUDA, ignored on CPU. |\n\n`TARGET_SPACING` and `PATCH_SIZE` are part of the shared contract with training and must not be changed independently of notebook 01." + "source": [ + "## 2. Configuration" + ] }, { "cell_type": "code", @@ -38,13 +59,36 @@ "id": "5e2b4255", "metadata": {}, "outputs": [], - "source": "# --- Which model ---------------------------------------------------------------\nMODEL_KEY = \"unet\" # \"unet\" | \"dynunet\" | \"segmamba\" (must match a trained CV run)\n\n# Ensemble checkpoints: {fold_number: path/to/best_learner.pkl}. Leave empty to\n# auto-discover the five folds from the MLflow experiment \"vs5f_\" below.\nFOLD_LEARNER_PATHS = {}\n\n# --- What to segment and where to write it -------------------------------------\nNEW_CASES = [\n \"../nii_data/queen_square_data/vs_gk_1/vs_gk_1_t1_refT1.nii.gz\",\n]\nOUTPUT_DIR = \"inference_predictions\"\n\n# --- Inference options ---------------------------------------------------------\nUSE_TTA = True # 8-flip test-time augmentation (more robust, ~8x slower)\nUSE_AMP = True # mixed precision on CUDA (ignored on CPU)\n\n# --- Shared contract with training notebook 01 (do not change in isolation) ----\nTARGET_SPACING = [0.4102, 0.4102, 1.5]\nPATCH_SIZE = [192, 192, 48]\n\nprint(f\"Model: {MODEL_KEY} | mode: 5-fold soft-voting ensemble\")\nprint(f\"Cases to segment: {len(NEW_CASES)}\")" + "source": [ + "MODEL_KEY = \"unet\" # \"unet\" | \"dynunet\" | \"segmamba\"\n", + "\n", + "# leave empty to auto-discover from MLflow.\n", + "FOLD_LEARNER_PATHS = {}\n", + "\n", + "NEW_CASES = [\n", + " \"../nii_data/CASE_ID\", \n", + "]\n", + "OUTPUT_DIR = \"inference_predictions\"\n", + "\n", + "USE_TTA = True # 8-flip test-time augmentation (more robust, ~8x slower)\n", + "USE_AMP = True # mixed precision on CUDA (ignored on CPU)\n", + "\n", + "# Patch config: pulled from the model's MLflow run by default; set CONFIG_JSON to a local inference_patch_config.json to override.\n", + "CONFIG_JSON = None\n", + "\n", + "print(f\"Model: {MODEL_KEY} | mode: 5-fold soft-voting ensemble\")\n", + "print(f\"Cases to segment: {len(NEW_CASES)}\")" + ] }, { "cell_type": "markdown", "id": "e4287608", "metadata": {}, - "source": "### Locate the fold checkpoints\n\nNotebook 01 logs `best_learner.pkl` as an MLflow artifact for every fold, under experiments named `vs5f_unet`, `vs5f_dynunet`, and `vs5f_segmamba` (one run per fold, tagged with its fold number). The helper below finds one checkpoint per fold for `MODEL_KEY` and returns `{fold: local_path}`. It is best-effort and never raises, so it is safe to run before any training exists.\n\nIf you already have the paths, set `FOLD_LEARNER_PATHS` in the configuration cell and this lookup is skipped." + "source": [ + "### Locate the fold checkpoints\n", + "\n", + "Notebook 01 logs `best_learner.pkl` per fold under the MLflow experiment `vs5f_`. The helper below returns `{fold: path}` for `MODEL_KEY` and is best-effort, so it is safe to run before any training exists. Set `FOLD_LEARNER_PATHS` in the config cell to skip the lookup." + ] }, { "cell_type": "code", @@ -52,25 +96,26 @@ "id": "712e4005", "metadata": {}, "outputs": [], - "source": "# find_fold_learners now lives in the library (fastMONAI.utils) and is imported via\n# `from fastMONAI.vision_all import *` above; the experiment name is passed explicitly\n# (it also respects a user-configured MLflow tracking server, unlike the old inline copy).\n\nif not FOLD_LEARNER_PATHS:\n FOLD_LEARNER_PATHS = find_fold_learners(f\"vs5f_{MODEL_KEY}\")\n\nprint(\"Fold checkpoints:\", {k: str(v) for k, v in sorted(FOLD_LEARNER_PATHS.items())} or \"(none)\")" + "source": [ + "# find_fold_learners (fastMONAI.utils) discovers one best_learner.pkl per fold from MLflow.\n", + "if not FOLD_LEARNER_PATHS:\n", + " FOLD_LEARNER_PATHS = find_fold_learners(f\"vs5f_{MODEL_KEY}\")\n", + "\n", + "print(\"Fold checkpoints:\", {k: str(v) for k, v in sorted(FOLD_LEARNER_PATHS.items())} or \"(none)\")" + ] }, { "cell_type": "markdown", "id": "b59bde21", "metadata": {}, "source": [ - "## 3. Build the inference configuration\n", - "\n", - "`PatchConfig` is the single object that describes how patches are sampled, how they are aggregated, and (critically) how the raw input is preprocessed. We build the **exact same** config that training used. The values below are the shared contract with notebook 01.\n", - "\n", - "A few points worth understanding:\n", + "## 3. Load the inference configuration\n", "\n", - "- **`preprocessed=True`** only affects *training* (it tells the training pipeline that the data on disk was already reordered, resampled, and normalized, so it should not do it again). At *inference* the engine always reorders, resamples, and normalizes the raw input for you, so we point it at the original scans.\n", - "- **`patch_overlap=0.5`** means neighboring windows overlap by half a patch. More overlap gives smoother, more accurate borders at the cost of speed.\n", - "- **`aggregation_mode=\"hann\"`** blends overlapping patches with a Hann taper (smoothest transitions).\n", - "- **`keep_largest_component=True`** keeps only the largest connected foreground blob, a simple and effective post-processing step for a single-tumor task like VS.\n", + "`PatchConfig` describes how patches are sampled and aggregated and how the raw input is preprocessed. We load the exact config notebook 01 saved at training time (from the model's MLflow run by default, or a local `inference_patch_config.json`) and rebuild `PatchConfig` from it.\n", "\n", - "The pre-inference normalization is defined separately as `pre_inference_tfms` and passed to `patch_inference`. It must be the same transform training used: foreground `ZNormalization`. Passing it explicitly makes the parity obvious and overrides whatever is stored on the config." + "- The config is loaded from what notebook 01 saved, so preprocessing parity is free.\n", + "- `normalization` (foreground `ZNormalization`) travels inside the config; the engine reads it from `config.normalization`.\n", + "- `preprocessed` is training-only and not stored; at inference the engine always reorders, resamples, and normalizes the raw input." ] }, { @@ -80,101 +125,79 @@ "metadata": {}, "outputs": [], "source": [ - "# Pre-inference intensity normalization. MUST match training exactly.\n", - "pre_inference_tfms = [ZNormalization(masking_method=\"foreground\")]\n", - "\n", - "# The same PatchConfig used for training in notebook 01.\n", - "patch_config = PatchConfig(\n", - " patch_size=PATCH_SIZE,\n", - " samples_per_volume=4,\n", - " sampler_type=\"label\",\n", - " label_probabilities={0: 0.2, 1: 0.8},\n", - " patch_overlap=0.5,\n", - " keep_largest_component=True,\n", - " target_spacing=TARGET_SPACING,\n", - " preprocessed=True, # only affects training; inference always preprocesses raw input\n", - " aggregation_mode=\"hann\",\n", - " queue_num_workers=16,\n", - " queue_length=1200,\n", - ")\n", + "# Load the patch config notebook 01 saved: the source of truth for preprocessing and patch sampling.\n", + "if CONFIG_JSON and Path(CONFIG_JSON).exists():\n", + " config_path = CONFIG_JSON\n", + " print(f\"Patch config: local file {config_path}\")\n", + "else:\n", + " _cfg = find_fold_learners(f\"vs5f_{MODEL_KEY}\",\n", + " artifact_path=\"config/inference_patch_config.json\")\n", + " if _cfg:\n", + " config_path = next(iter(_cfg.values())) # identical across folds; take any\n", + " print(f\"Patch config: MLflow run for 'vs5f_{MODEL_KEY}'\")\n", + " elif Path(\"inference_patch_config.json\").exists():\n", + " config_path = \"inference_patch_config.json\"\n", + " print(\"Patch config: local inference_patch_config.json (no MLflow artifact found)\")\n", + " else:\n", + " raise FileNotFoundError(\n", + " \"No patch config found. Run notebook 01 (it logs the config to MLflow and writes a \"\n", + " \"local copy), or set CONFIG_JSON to a local inference_patch_config.json.\")\n", + "\n", + "# normalization travels inside the config; patch_inference applies the same ZNormalization.\n", + "patch_config = PatchConfig(**load_patch_variables(config_path))\n", "print(patch_config)" ] }, { "cell_type": "markdown", - "id": "aec89045", + "id": "20f7b65d", "metadata": {}, "source": [ - "### Persisting and reloading the config\n", - "\n", - "To guarantee parity across machines and over time, the patch config can be written to a small JSON file with `store_patch_variables(...)` and read back with `load_patch_variables(...)`. The JSON captures `patch_size`, `target_spacing`, `apply_reorder`, the sampler settings, and the `normalization` spec, so an inference run can reconstruct the training config without re-typing it. This is the recommended way to ship a config alongside a checkpoint.\n", + "## 4. Load the models\n", "\n", - "The cell below writes the current config out and reads it back to confirm the round-trip. An inference run on another machine would simply start from `load_patch_variables(...)`." + "We load the five fold learners for `MODEL_KEY` into a list; each carries its own weights and preprocessing. `load_learner` uses Python `pickle`, which can execute arbitrary code, so only load checkpoints you trust." ] }, { "cell_type": "code", "execution_count": null, - "id": "39b66065", + "id": "4ce981f2", "metadata": {}, "outputs": [], "source": [ - "CONFIG_JSON = \"inference_patch_config.json\"\n", - "\n", - "# Coerce the normalization transforms to a JSON-serializable spec. PatchConfig does this\n", - "# coercion in __post_init__, so we build a throwaway config to obtain the spec list.\n", - "norm_specs = PatchConfig(patch_size=PATCH_SIZE, normalization=pre_inference_tfms).normalization\n", - "\n", - "# Persist the config so any inference run can reconstruct it verbatim.\n", - "store_patch_variables(\n", - " CONFIG_JSON,\n", - " patch_size=patch_config.patch_size,\n", - " patch_overlap=patch_config.patch_overlap,\n", - " aggregation_mode=patch_config.aggregation_mode,\n", - " apply_reorder=patch_config.apply_reorder,\n", - " target_spacing=patch_config.target_spacing,\n", - " sampler_type=patch_config.sampler_type,\n", - " label_probabilities=patch_config.label_probabilities,\n", - " samples_per_volume=patch_config.samples_per_volume,\n", - " queue_length=patch_config.queue_length,\n", - " queue_num_workers=patch_config.queue_num_workers,\n", - " keep_largest_component=patch_config.keep_largest_component,\n", - " normalization=norm_specs,\n", - ")\n", + "def _load_learner(pkl_path):\n", + " \"\"\"Load an exported fastai learner onto GPU if available, else CPU; set eval mode.\"\"\"\n", + " learn = load_learner(pkl_path, cpu=not torch.cuda.is_available())\n", + " learn.model.eval()\n", + " return learn\n", + "\n", + "\n", + "# Load the fold learners; load_learner uses pickle, so only load checkpoints you trust.\n", + "assert FOLD_LEARNER_PATHS, (\n", + " \"No fold checkpoints found. Train the folds with notebook 01, or set \"\n", + " \"FOLD_LEARNER_PATHS = {1: '.../best_learner.pkl', ...} in the config cell.\")\n", "\n", - "# Read it back to confirm the round-trip (inference elsewhere would start here).\n", - "reloaded = load_patch_variables(CONFIG_JSON)\n", - "print(\"Persisted config to\", CONFIG_JSON)\n", - "print(\"target_spacing:\", reloaded[\"target_spacing\"])\n", - "print(\"patch_size: \", reloaded[\"patch_size\"])\n", - "print(\"normalization: \", reloaded[\"normalization\"])" + "predictors = []\n", + "for fold in sorted(FOLD_LEARNER_PATHS):\n", + " predictors.append(_load_learner(FOLD_LEARNER_PATHS[fold]))\n", + " print(f\"Loaded fold {fold}: {FOLD_LEARNER_PATHS[fold]}\")\n", + "print(f\"Ensemble ready: {len(predictors)} fold model(s) for '{MODEL_KEY}'.\")" ] }, - { - "cell_type": "markdown", - "id": "20f7b65d", - "metadata": {}, - "source": "## 4. Load the models\n\nWe load the five fold learners for `MODEL_KEY` into a list. Each exported learner carries its own trained weights and preprocessing, so no architecture code is needed. `load_learner` uses Python `pickle`, which can execute arbitrary code, so only load checkpoints you trust.\n\nThe list of learners is what the inference engine soft-votes in the next section." - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4ce981f2", - "metadata": {}, - "outputs": [], - "source": "def _load_learner(pkl_path):\n \"\"\"Load an exported fastai learner onto GPU if available, else CPU; set eval mode.\"\"\"\n learn = load_learner(pkl_path, cpu=not torch.cuda.is_available())\n learn.model.eval()\n return learn\n\n\n# Load the five fold learners for MODEL_KEY into a list. Each exported learner carries its\n# own trained weights and preprocessing, so no architecture code is needed here. load_learner\n# uses Python pickle, which can execute arbitrary code, so only load checkpoints you trust.\nassert FOLD_LEARNER_PATHS, (\n \"No fold checkpoints found. Train the folds with notebook 01, or set \"\n \"FOLD_LEARNER_PATHS = {1: '.../best_learner.pkl', ...} in the config cell.\")\n\npredictors = []\nfor fold in sorted(FOLD_LEARNER_PATHS):\n predictors.append(_load_learner(FOLD_LEARNER_PATHS[fold]))\n print(f\"Loaded fold {fold}: {FOLD_LEARNER_PATHS[fold]}\")\nprint(f\"Ensemble ready: {len(predictors)} fold model(s) for '{MODEL_KEY}'.\")" - }, - { - "cell_type": "markdown", - "id": "ceaa05d1", - "metadata": {}, - "source": "### A note on SegMamba\n\nSegMamba is not part of MONAI; it comes from our fork **skaliy/SegMamba-V2**. Install it into this environment before selecting `MODEL_KEY = \"segmamba\"`:\n\n```bash\npip install -e /home/sathiesh/ml_projects/SegMamba-V2 # base (import path: models_segmamba)\npip install -e \"/home/sathiesh/ml_projects/SegMamba-V2[gpu]\" # adds mamba-ssm + causal-conv1d for CUDA\n```\n\nThe model is imported as `from models_segmamba.segmambav2 import SegMamba`. It supports two Mamba backends:\n\n- **`mamba_backend=\"mamba_ssm\"`** (default): the CUDA kernel used for training and fast GPU inference.\n- **`mamba_backend=\"mambamixer\"`**: a pure-PyTorch backend (needs `transformers`) that runs on CPU. It is weight-compatible with a `mamba_ssm`-trained checkpoint, so the same `.pth` loads with `strict=True` and no retraining.\n\nUNet and DynUNet need no fork and run on CPU or GPU as-is. One environment caveat: `causal-conv1d` and `mamba-ssm` must be compiled against the active PyTorch build, otherwise the import fails with an \"undefined symbol\" ABI error. On CPU we sidestep this with a small `_force_cpu_mamba_backend()` helper (defined in the section 8 cell below, mirroring `infer_segmamba_cpu.py`) that tells `transformers` not to probe the CUDA kernels. Section 8 shows the CPU path end to end." - }, { "cell_type": "markdown", "id": "37ea9e87", "metadata": {}, - "source": "## 5. Run inference\n\nEnsembling is built into fastMONAI: pass a **list of learners** to `patch_inference` and it\nsoft-votes them, averaging each patch's class probabilities across the models before taking the\nargmax, all in a single sliding-window pass. We pass the list of fold learners as\n`learner = predictors`.\n\nFor each scan `patch_inference` reorders, resamples, and normalizes the raw input exactly as in\ntraining, slides the patch grid, aggregates with the Hann window (soft-voting per patch across the\nfolds), resamples the prediction back to the input's original grid, and applies the training-time\npost-processing (argmax and keep-largest-component). Predictions are written to `OUTPUT_DIR` as\n`_pred.nii.gz` and returned as in-memory masks in `predictions` for the sections below.\n\nTwo options:\n\n- **`tta=USE_TTA`**: each patch is run through 8 axis-flip combinations and the probabilities\n averaged (combined with the fold averaging). Tighter borders, roughly 8x compute. Impractical on CPU.\n- **`amp=USE_AMP`**: float16 forward pass on CUDA. Faster and lighter; ignored on CPU.\n\nTo obtain the averaged probability map instead of a mask, pass `return_probabilities=True`." + "source": [ + "## 5. Run inference\n", + "\n", + "Pass a **list of learners** to `patch_inference` to soft-vote them: it averages each patch's class probabilities across the folds before the argmax, in one sliding-window pass. For each scan it reorders, resamples, and normalizes the input as in training, slides the Hann-blended patch grid, resamples the prediction back to the original grid, and post-processes (argmax + keep-largest-component). Predictions are written to `OUTPUT_DIR` as `_pred.nii.gz` and returned in `predictions`.\n", + "\n", + "- **`tta=USE_TTA`**: 8 axis-flip combinations averaged per patch. Tighter borders, ~8x compute, impractical on CPU.\n", + "- **`amp=USE_AMP`**: float16 forward pass; CUDA-only.\n", + "\n", + "Pass `return_probabilities=True` for the averaged probability map instead of a mask." + ] }, { "cell_type": "code", @@ -182,18 +205,44 @@ "id": "65abc50b", "metadata": {}, "outputs": [], - "source": "# Ensembling is a fastMONAI feature: a list of learners makes patch_inference soft-vote them\n# (per-patch probabilities averaged before argmax) in one sliding-window pass. Post-processing\n# (argmax, keep-largest-component) is applied to the averaged map.\nlearners = predictors\n\nPath(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)\npredictions = patch_inference(\n learner=learners,\n config=patch_config,\n file_paths=NEW_CASES,\n pre_inference_tfms=pre_inference_tfms,\n save_dir=OUTPUT_DIR,\n progress=True,\n tta=USE_TTA,\n amp=USE_AMP,\n)\n\n\ndef _pred_filename(input_path):\n \"\"\"Name patch_inference writes for a prediction: '_pred.nii[.gz]'. Mirrors the\n engine's saver, stripping only the trailing extension so '.nii' and '.nii.gz' inputs\n both map to the right output name (used again when we reload the mask to visualize).\"\"\"\n p = Path(input_path)\n if p.suffix == \".gz\" and p.stem.endswith(\".nii\"):\n return f\"{p.stem[:-4]}_pred.nii.gz\"\n if p.suffix == \".nii\":\n return f\"{p.stem}_pred.nii\"\n return f\"{p.stem}_pred.nii.gz\"\n\n\nprint(f\"\\nWrote {len(predictions)} prediction(s) to {OUTPUT_DIR}/ \"\n f\"(soft-vote ensemble of {len(predictors)} folds).\")\nfor p in NEW_CASES:\n print(f\" {p} -> {OUTPUT_DIR}/{_pred_filename(p)}\")" + "source": [ + "# A list of learners makes patch_inference soft-vote the folds; normalization comes from patch_config.\n", + "learners = predictors\n", + "\n", + "Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)\n", + "predictions = patch_inference(\n", + " learner=learners,\n", + " config=patch_config,\n", + " file_paths=NEW_CASES,\n", + " save_dir=OUTPUT_DIR,\n", + " progress=True,\n", + " tta=USE_TTA,\n", + " amp=USE_AMP,\n", + ")\n", + "\n", + "\n", + "def _pred_filename(input_path):\n", + " \"\"\"Output name patch_inference writes for a prediction: '_pred.nii[.gz]'.\"\"\"\n", + " p = Path(input_path)\n", + " if p.suffix == \".gz\" and p.stem.endswith(\".nii\"):\n", + " return f\"{p.stem[:-4]}_pred.nii.gz\"\n", + " if p.suffix == \".nii\":\n", + " return f\"{p.stem}_pred.nii\"\n", + " return f\"{p.stem}_pred.nii.gz\"\n", + "\n", + "\n", + "print(f\"\\nWrote {len(predictions)} prediction(s) to {OUTPUT_DIR}/ \"\n", + " f\"(soft-vote ensemble of {len(predictors)} folds).\")\n", + "for p in NEW_CASES:\n", + " print(f\" {p} -> {OUTPUT_DIR}/{_pred_filename(p)}\")" + ] }, { "cell_type": "markdown", "id": "691d43d1", "metadata": {}, "source": [ - "## 6. Visualize a prediction\n", - "\n", - "Now we inspect a result qualitatively. `vision_plot` is imported separately (it is not part of `vision_all`). We load the input scan as a `MedImage` and its predicted mask as a `MedMask`.\n", - "\n", - "Both are read with fastMONAI's default loader, which does not reorder or resample, so they share the input's original voxel grid and line up slice for slice. We pick the axial slice where the predicted tumor is largest with `find_max_slice`, then show the input, the mask, and an overlay. `voxel_size=TARGET_SPACING` sets only the display aspect ratio; it does not alter the data." + "## 6. Visualize a prediction" ] }, { @@ -202,7 +251,37 @@ "id": "ff90e7e6", "metadata": {}, "outputs": [], - "source": "from fastMONAI.vision_plot import * # fastMONAI plotting; imported separately from vision_all\nfrom torchio.visualization import rotate\nimport matplotlib.pyplot as plt\n\nidx = 0\nimg_fn = NEW_CASES[idx]\n# Same name patch_inference used when saving (see _pred_filename in the run-inference cell),\n# so the mask reloads correctly for both .nii and .nii.gz inputs.\npred_fn = Path(OUTPUT_DIR) / _pred_filename(img_fn)\n\n# Default fastMONAI loader: no reorder, no resample, so input and mask share the\n# input's original voxel grid and align slice for slice.\nimg = MedImage.create(img_fn)\npred_mask = MedMask.create(pred_fn)\n\nplane = 2 # 0 = sagittal, 1 = coronal, 2 = axial\nsl = int(find_max_slice(pred_mask.data[0].cpu().numpy(), plane))\nprint(f\"Predicted foreground voxels: {int(pred_mask.data.sum())}\")\nprint(f\"Showing plane={plane} (axial), slice={sl}\")\n\n\ndef _disp_slice(vol3d, i, plane, vsize):\n \"\"\"Replicate fastMONAI's show slicing so the overlay lines up with .show().\"\"\"\n sr, sa, ss = vsize\n ops = {0: (vol3d[i, :, :], ss / sa),\n 1: (vol3d[:, i, :], ss / sr),\n 2: (vol3d[:, :, i], sa / sr)}\n sl2d, aspect = ops[plane]\n return rotate(sl2d, radiological=True, n=1), aspect\n\n\nfig, axes = plt.subplots(1, 3, figsize=(15, 5))\nimg.show(ctx=axes[0], anatomical_plane=plane, slice_index=sl, voxel_size=TARGET_SPACING)\naxes[0].set_title(\"Input T1\")\npred_mask.show(ctx=axes[1], anatomical_plane=plane, slice_index=sl, voxel_size=TARGET_SPACING)\naxes[1].set_title(\"Predicted mask\")\n\nimg_slice, aspect = _disp_slice(img.data[0].cpu().numpy(), sl, plane, TARGET_SPACING)\nmsk_slice, _ = _disp_slice(pred_mask.data[0].cpu().numpy(), sl, plane, TARGET_SPACING)\naxes[2].imshow(img_slice, cmap=\"gray\", aspect=aspect)\naxes[2].imshow(np.ma.masked_where(msk_slice == 0, msk_slice),\n cmap=\"autumn\", alpha=0.5, aspect=aspect)\naxes[2].set_title(\"Overlay\")\naxes[2].axis(\"off\")\nplt.tight_layout()\nplt.show()" + "source": [ + "from fastMONAI.vision_plot import *\n", + "import matplotlib.pyplot as plt\n", + "\n", + "idx = 0\n", + "img_fn = NEW_CASES[idx]\n", + "# Same name patch_inference saved under, for both .nii and .nii.gz inputs.\n", + "pred_fn = Path(OUTPUT_DIR) / _pred_filename(img_fn)\n", + "\n", + "# Default loader: no reorder/resample, so input and mask share the original grid.\n", + "img = MedImage.create(img_fn)\n", + "pred_mask = MedMask.create(pred_fn)\n", + "\n", + "# Display aspect ratio only; does not alter data.\n", + "disp_spacing = patch_config.target_spacing\n", + "\n", + "plane = 2 # 0 = sagittal, 1 = coronal, 2 = axial\n", + "sl = int(find_max_slice(pred_mask.data[0].cpu().numpy(), plane))\n", + "print(f\"Predicted foreground voxels: {int(pred_mask.data.sum())}\")\n", + "print(f\"Showing plane={plane} (axial), slice={sl}\")\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(15, 5))\n", + "img.show(ctx=axes[0], anatomical_plane=plane, slice_index=sl, voxel_size=disp_spacing)\n", + "axes[0].set_title(\"Input T1\")\n", + "pred_mask.show(ctx=axes[1], anatomical_plane=plane, slice_index=sl, voxel_size=disp_spacing)\n", + "axes[1].set_title(\"Predicted mask\")\n", + "show_mask_overlay(img, pred_mask, ctx=axes[2], anatomical_plane=plane,\n", + " slice_index=sl, voxel_size=disp_spacing, title=\"Overlay\")\n", + "plt.tight_layout()\n", + "plt.show()" + ] }, { "cell_type": "markdown", @@ -211,15 +290,11 @@ "source": [ "## 7. Optional: validate against ground truth\n", "\n", - "If a case has an expert segmentation, we can score the prediction with the **same metric functions** notebook 01 uses in its cross-validation, so the numbers here are directly comparable. This cell is optional: it only runs when you set `GT_PATH` to a mask file.\n", - "\n", - "The metrics:\n", - "\n", - "- **DSC** (Dice) and **sensitivity** / **precision**: overlap-based agreement.\n", - "- **LDR** (lesion detection rate) and **signed RVE** (relative volume error).\n", - "- **Surface metrics in millimeters** via `calculate_surface_metrics`: ASSD, HD95, and NSD. These are spacing-aware, so we read the per-case voxel spacing straight from the ground-truth file with `tio.LabelMap(GT_PATH).spacing`. This matters because the VS cohort has non-uniform spacing, and using one global spacing would bias the surface distances.\n", + "If a case has an expert segmentation, we score the prediction with the same metric functions notebook 01 uses, so the numbers are comparable. Runs only when `GT_PATH` points to a mask file.\n", "\n", - "The prediction and ground truth are compared as 5D tensors `[batch, channel, X, Y, Z]`, matching the metric API." + "- **DSC**, **sensitivity**, **precision**: overlap agreement.\n", + "- **LDR** (lesion detection rate) and signed **RVE** (relative volume error).\n", + "- **Surface metrics (mm)** via `calculate_surface_metrics`: ASSD, HD95, NSD. Spacing-aware, so we read per-case spacing from the GT file (`tio.LabelMap(GT_PATH).spacing`) because the VS cohort spacing is non-uniform." ] }, { @@ -234,8 +309,7 @@ "GT_PATH = None\n", "\n", "if GT_PATH:\n", - " # Score the first prediction with the library's per-case panel (loads the GT mask's\n", - " # data and spacing from one object).\n", + " # Score the first prediction with the library's per-case panel.\n", " row = evaluate_segmentations([predictions[0]], [GT_PATH]).iloc[0]\n", " print(f\"DSC: {row['dsc']:.4f}\")\n", " print(f\"Sensitivity: {row['sensitivity']:.4f}\")\n", @@ -255,17 +329,13 @@ "id": "df20f031", "metadata": {}, "source": [ - "## 8. Running SegMamba on CPU\n", + "## 8. Running SegMamba on CPU (single-checkpoint demo)\n", "\n", - "When no CUDA GPU is available, SegMamba can still run through the exact same `patch_inference` pipeline by switching to the `mambamixer` backend. This mirrors `research/vs_seg/infer_segmamba_cpu.py` and `research/vs_seg/SEGMAMBA_CPU.md`.\n", + "This is a standalone demonstration of the CPU backend swap, not the ensemble path above. Only SegMamba needs it. Its Mamba blocks default to the `mamba_ssm`/`causal-conv1d` CUDA kernels, so on a CPU-only box you switch to `mamba_backend=\"mambamixer\"` (pure PyTorch, needs `transformers`). A checkpoint trained with the default `mamba_ssm` backend loads into it with `strict=True`, no retraining.\n", "\n", - "The key points:\n", + "It loads a single checkpoint (`models/best_segmamba.pth`) on purpose, to show the mechanism. `patch_inference` still soft-votes if you pass it a list, so you could CPU-ensemble several SegMamba folds like the UNet path above, but at roughly 11 min/volume per fold that is rarely worth it. Keep `tta=False` and one model here.\n", "\n", - "- Construct with `mamba_backend=\"mambamixer\"` (pure PyTorch, needs `transformers`). No CUDA and no `mamba_ssm` required.\n", - "- A checkpoint trained with the default `mamba_ssm` backend loads into the `mambamixer` model unchanged (`strict=True`), so no retraining is needed. Correctness was verified decision-identical to GPU on a real case (Dice 1.0).\n", - "- It is slow: roughly 11 minutes per volume on CPU, and TTA is impractical there, so keep `tta=False`.\n", - "\n", - "The cell below is a self-contained CPU inference path. It is guarded so it does nothing unless `RUN_SEGMAMBA_CPU` is set to `True` and a checkpoint is present." + "The cell is guarded and does nothing unless `RUN_SEGMAMBA_CPU = True` and a checkpoint is present." ] }, { @@ -274,13 +344,52 @@ "id": "1eff224c", "metadata": {}, "outputs": [], - "source": "def _force_cpu_mamba_backend():\n \"\"\"Make transformers' MambaMixer take its pure-PyTorch path instead of probing CUDA\n kernels. A no-op on a clean CPU-only box; needed only when the environment has a\n broken or version-mismatched causal_conv1d / mamba_ssm build (the \"undefined symbol\"\n ABI error). Mirrors research/vs_seg/infer_segmamba_cpu.py.\"\"\"\n import sys\n import transformers # noqa: F401\n for modname in (\"transformers.utils.import_utils\", \"transformers.utils\"):\n mod = sys.modules.get(modname)\n if mod is not None:\n for fn in (\"is_causal_conv1d_available\", \"is_mamba_ssm_available\"):\n if hasattr(mod, fn):\n setattr(mod, fn, (lambda *a, **k: False))\n\n\nRUN_SEGMAMBA_CPU = False\nSEGMAMBA_WEIGHTS = \"models/best_segmamba.pth\"\n\nif RUN_SEGMAMBA_CPU:\n _force_cpu_mamba_backend() # skips the mismatched CUDA kernel probe (defined just above)\n from models_segmamba.segmambav2 import SegMamba\n\n # Pure-PyTorch Mamba backend: runs on CPU, no mamba_ssm / causal_conv1d needed.\n seg_model = SegMamba(\n in_chans=1, out_chans=2, depths=[2, 2, 2, 2],\n feat_size=[48, 96, 192, 384], hidden_size=768,\n mamba_backend=\"mambamixer\",\n )\n # A mamba_ssm-trained checkpoint loads strict into the mambamixer model unchanged.\n from torch.nn.modules.utils import consume_prefix_in_state_dict_if_present\n sd = torch.load(SEGMAMBA_WEIGHTS, map_location=\"cpu\")\n sd = sd[\"model\"] if isinstance(sd, dict) and \"model\" in sd else sd\n consume_prefix_in_state_dict_if_present(sd, \"_orig_mod.\") # official torch helper\n seg_model.load_state_dict(sd, strict=True)\n seg_model.eval() # stays on CPU\n\n cpu_preds = patch_inference(\n learner=seg_model,\n config=patch_config,\n file_paths=NEW_CASES,\n pre_inference_tfms=pre_inference_tfms,\n save_dir=OUTPUT_DIR,\n progress=True,\n tta=False, # TTA is impractical on CPU\n )\n print(f\"SegMamba CPU: {len(cpu_preds)} prediction(s) written to {OUTPUT_DIR}/\")\nelse:\n print(\"Set RUN_SEGMAMBA_CPU = True (with a SegMamba checkpoint) to run this path.\")" - }, - { - "cell_type": "markdown", - "id": "53b15733", - "metadata": {}, - "source": "## Recap and pointers\n\n- **This notebook runs a 5-fold soft-voting ensemble.** Each fold's probability map is produced in the input's original space, the five are averaged, and only then decoded (argmax + keep-largest-component). This usually beats any single fold.\n- **Preprocessing parity is the rule that governs correctness.** The prediction is valid only because inference reused the training `apply_reorder`, `target_spacing`, and foreground `ZNormalization`. When in doubt, load an exported learner (which carries its own preprocessing) or ship a `PatchConfig` JSON via `store_patch_variables` / `load_patch_variables`.\n- **Patch inference** slides an overlapping grid, predicts per patch, and blends with a Hann window, so arbitrarily large volumes fit in memory.\n- **TTA** trades roughly 8x compute for tighter borders; **AMP** speeds up the GPU forward pass. Neither changes the required preprocessing.\n- **Model choice is orthogonal to this notebook.** UNet and DynUNet run anywhere; SegMamba needs the fork and prefers a GPU, with a documented CPU fallback.\n\nPointers:\n\n- Training and the checkpoints this notebook consumes: `01_five_fold_cross_validation.ipynb`.\n- An earlier whole-volume ensemble reference: `research/vs_seg/10_ensemble_inference_verification_unet.ipynb`.\n- SegMamba CPU details: `research/vs_seg/infer_segmamba_cpu.py` and `research/vs_seg/SEGMAMBA_CPU.md`.\n- The inference engine and config: `fastMONAI/vision_patch.py` (`patch_inference`, `PatchInferenceEngine`, `PatchConfig`)." + "source": [ + "def _force_cpu_mamba_backend():\n", + " \"\"\"Make transformers' MambaMixer take its pure-PyTorch path instead of probing CUDA kernels (which crash on CPU or on an ABI-mismatched mamba_ssm build).\"\"\"\n", + " import sys\n", + " import transformers # noqa: F401\n", + " for modname in (\"transformers.utils.import_utils\", \"transformers.utils\"):\n", + " mod = sys.modules.get(modname)\n", + " if mod is not None:\n", + " for fn in (\"is_causal_conv1d_available\", \"is_mamba_ssm_available\"):\n", + " if hasattr(mod, fn):\n", + " setattr(mod, fn, (lambda *a, **k: False))\n", + "\n", + "\n", + "RUN_SEGMAMBA_CPU = False\n", + "SEGMAMBA_WEIGHTS = \"models/best_segmamba.pth\" # single checkpoint on purpose: demos the CPU swap, not the fold ensemble\n", + "\n", + "if RUN_SEGMAMBA_CPU:\n", + " _force_cpu_mamba_backend() # skips the mismatched CUDA kernel probe\n", + " from models_segmamba.segmambav2 import SegMamba\n", + "\n", + " # Pure-PyTorch backend, runs on CPU.\n", + " seg_model = SegMamba(\n", + " in_chans=1, out_chans=2, depths=[2, 2, 2, 2],\n", + " feat_size=[48, 96, 192, 384], hidden_size=768,\n", + " mamba_backend=\"mambamixer\",\n", + " )\n", + " # A mamba_ssm-trained checkpoint loads strict into the mambamixer model unchanged.\n", + " from torch.nn.modules.utils import consume_prefix_in_state_dict_if_present\n", + " sd = torch.load(SEGMAMBA_WEIGHTS, map_location=\"cpu\")\n", + " sd = sd[\"model\"] if isinstance(sd, dict) and \"model\" in sd else sd\n", + " consume_prefix_in_state_dict_if_present(sd, \"_orig_mod.\") # official torch helper\n", + " seg_model.load_state_dict(sd, strict=True)\n", + " seg_model.eval() # stays on CPU\n", + "\n", + " cpu_preds = patch_inference(\n", + " learner=seg_model,\n", + " config=patch_config,\n", + " file_paths=NEW_CASES,\n", + " save_dir=OUTPUT_DIR,\n", + " progress=True,\n", + " tta=False, # TTA is impractical on CPU\n", + " )\n", + " print(f\"SegMamba CPU: {len(cpu_preds)} prediction(s) written to {OUTPUT_DIR}/\")\n", + "else:\n", + " print(\"Set RUN_SEGMAMBA_CPU = True (with a SegMamba checkpoint) to run this path.\")" + ] } ], "metadata": { @@ -296,4 +405,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} From 2ea3dafdf3d0a39f8f1f461dfba5ed604b302d6c Mon Sep 17 00:00:00 2001 From: Sathiesh Date: Mon, 6 Jul 2026 11:56:36 +0200 Subject: [PATCH 7/8] research: further trim vestibular_schwannoma notebook prose and comments --- .../01_five_fold_cross_validation.ipynb | 8 ++----- .../02_inference_new_cases.ipynb | 22 ++++--------------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb b/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb index 6d3a8db..f4d4489 100644 --- a/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb +++ b/research/vestibular_schwannoma/01_five_fold_cross_validation.ipynb @@ -29,9 +29,7 @@ "id": "65895c58", "metadata": {}, "source": [ - "## 1. Environment setup\n", - "\n", - "Import the fastMONAI patch API, MONAI nets and losses, and the metric helpers, then `chdir` into the project folder so the CSV's relative image and mask paths resolve." + "## 1. Environment setup" ] }, { @@ -311,9 +309,7 @@ "sanity_df = train_df.copy()\n", "sanity_df[\"is_val\"] = sanity_df[\"fold\"] == sanity_fold\n", "\n", - "# Trim the queue so the first batch loads a few volumes, not the whole fold (the training\n", - "# queue would load all ~277 cases and buffer ~16 GB before batch 1). One patch per volume\n", - "# still gives BS distinct cases.\n", + "\n", "sanity_config = create_patch_config() # fresh config leaves patch_config alone\n", "sanity_config.samples_per_volume = 1\n", "sanity_config.queue_length = BS\n", diff --git a/research/vestibular_schwannoma/02_inference_new_cases.ipynb b/research/vestibular_schwannoma/02_inference_new_cases.ipynb index 4b1e15f..71578e3 100644 --- a/research/vestibular_schwannoma/02_inference_new_cases.ipynb +++ b/research/vestibular_schwannoma/02_inference_new_cases.ipynb @@ -85,9 +85,7 @@ "id": "e4287608", "metadata": {}, "source": [ - "### Locate the fold checkpoints\n", - "\n", - "Notebook 01 logs `best_learner.pkl` per fold under the MLflow experiment `vs5f_`. The helper below returns `{fold: path}` for `MODEL_KEY` and is best-effort, so it is safe to run before any training exists. Set `FOLD_LEARNER_PATHS` in the config cell to skip the lookup." + "### Locate the fold checkpoints" ] }, { @@ -111,11 +109,7 @@ "source": [ "## 3. Load the inference configuration\n", "\n", - "`PatchConfig` describes how patches are sampled and aggregated and how the raw input is preprocessed. We load the exact config notebook 01 saved at training time (from the model's MLflow run by default, or a local `inference_patch_config.json`) and rebuild `PatchConfig` from it.\n", - "\n", - "- The config is loaded from what notebook 01 saved, so preprocessing parity is free.\n", - "- `normalization` (foreground `ZNormalization`) travels inside the config; the engine reads it from `config.normalization`.\n", - "- `preprocessed` is training-only and not stored; at inference the engine always reorders, resamples, and normalizes the raw input." + "`PatchConfig` describes how patches are sampled and aggregated and how the raw input is preprocessed. We load the exact config notebook 01 saved at training time (from the model's MLflow run by default, or a local `inference_patch_config.json`) and rebuild `PatchConfig` from it." ] }, { @@ -290,11 +284,7 @@ "source": [ "## 7. Optional: validate against ground truth\n", "\n", - "If a case has an expert segmentation, we score the prediction with the same metric functions notebook 01 uses, so the numbers are comparable. Runs only when `GT_PATH` points to a mask file.\n", - "\n", - "- **DSC**, **sensitivity**, **precision**: overlap agreement.\n", - "- **LDR** (lesion detection rate) and signed **RVE** (relative volume error).\n", - "- **Surface metrics (mm)** via `calculate_surface_metrics`: ASSD, HD95, NSD. Spacing-aware, so we read per-case spacing from the GT file (`tio.LabelMap(GT_PATH).spacing`) because the VS cohort spacing is non-uniform." + "If a case has an expert segmentation, we score the prediction with the same metric functions notebook 01 uses, so the numbers are comparable. Runs only when `GT_PATH` points to a mask file." ] }, { @@ -331,11 +321,7 @@ "source": [ "## 8. Running SegMamba on CPU (single-checkpoint demo)\n", "\n", - "This is a standalone demonstration of the CPU backend swap, not the ensemble path above. Only SegMamba needs it. Its Mamba blocks default to the `mamba_ssm`/`causal-conv1d` CUDA kernels, so on a CPU-only box you switch to `mamba_backend=\"mambamixer\"` (pure PyTorch, needs `transformers`). A checkpoint trained with the default `mamba_ssm` backend loads into it with `strict=True`, no retraining.\n", - "\n", - "It loads a single checkpoint (`models/best_segmamba.pth`) on purpose, to show the mechanism. `patch_inference` still soft-votes if you pass it a list, so you could CPU-ensemble several SegMamba folds like the UNet path above, but at roughly 11 min/volume per fold that is rarely worth it. Keep `tta=False` and one model here.\n", - "\n", - "The cell is guarded and does nothing unless `RUN_SEGMAMBA_CPU = True` and a checkpoint is present." + "This is a standalone demonstration of the CPU backend swap, not the ensemble path above. Only SegMamba needs it. Its Mamba blocks default to the `mamba_ssm`/`causal-conv1d` CUDA kernels, so on a CPU-only box you switch to `mamba_backend=\"mambamixer\"` (pure PyTorch, needs `transformers`). A checkpoint trained with the default `mamba_ssm` backend loads into it with `strict=True`, no retraining." ] }, { From 678056a7c90167707f1c50ff0c136fdf7c7861d0 Mon Sep 17 00:00:00 2001 From: Sathiesh Date: Mon, 6 Jul 2026 11:58:16 +0200 Subject: [PATCH 8/8] research: tweak nb02 inference intro wording --- research/vestibular_schwannoma/02_inference_new_cases.ipynb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/research/vestibular_schwannoma/02_inference_new_cases.ipynb b/research/vestibular_schwannoma/02_inference_new_cases.ipynb index 71578e3..967ed3f 100644 --- a/research/vestibular_schwannoma/02_inference_new_cases.ipynb +++ b/research/vestibular_schwannoma/02_inference_new_cases.ipynb @@ -11,7 +11,7 @@ "\n", "## Preprocessing parity\n", "\n", - "A model gives correct output only when inference preprocesses the input exactly as training did. Three settings must match: `apply_reorder` (RAS+ reorientation), `target_spacing` (resample spacing), and `normalization` (foreground Z-normalization). A mismatch does not raise an error; it silently degrades the prediction. We get parity for free by loading the `PatchConfig` that notebook 01 saved at training time." + "A model gives correct output only when inference preprocesses the input exactly as training did. Three settings must match: `apply_reorder` (RAS+ reorientation), `target_spacing` (resample spacing), and `normalization` (foreground Z-normalization). A mismatch does not raise an error, it silently degrades the prediction." ] }, {