Skip to content

Commit 4867cee

Browse files
Merge branch 'Project-MONAI:dev' into 4980-get-wsi-at-mpp
2 parents ed55c85 + 9ddd5e6 commit 4867cee

26 files changed

Lines changed: 1596 additions & 106 deletions

docs/source/handlers.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ Panoptic Quality metrics handler
8383
:members:
8484

8585

86+
Calibration Error metrics handler
87+
---------------------------------
88+
.. autoclass:: CalibrationError
89+
:members:
90+
91+
8692
Mean squared error metrics handler
8793
----------------------------------
8894
.. autoclass:: MeanSquaredError

docs/source/metrics.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,15 @@ Metrics
185185
.. autoclass:: MetricsReloadedCategorical
186186
:members:
187187

188+
`Calibration Error`
189+
-------------------
190+
.. autofunction:: calibration_binning
191+
192+
.. autoclass:: CalibrationReduction
193+
:members:
194+
195+
.. autoclass:: CalibrationErrorMetric
196+
:members:
188197

189198

190199
Utilities

monai/apps/detection/transforms/dictionary.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1137,11 +1137,14 @@ def generate_fg_center_boxes_np(self, boxes: NdarrayOrTensor, image_size: Sequen
11371137
extended_boxes[:, axis] = boxes_start[:, axis] - self.spatial_size[axis] // 2 + 1
11381138
extended_boxes[:, axis + spatial_dims] = boxes_stop[:, axis] + self.spatial_size[axis] // 2 - 1
11391139
else:
1140+
# the cropper will extend an additional pixel to the left side when the size is even
1141+
radius_left = self.spatial_size[axis] // 2
1142+
radius_right = self.spatial_size[axis] - radius_left - 1 # we subtract 1 for the center voxel
11401143
# extended box start
1141-
extended_boxes[:, axis] = boxes_stop[:, axis] - self.spatial_size[axis] // 2 - 1
1144+
extended_boxes[:, axis] = boxes_stop[:, axis] - radius_right
11421145
extended_boxes[:, axis] = np.minimum(extended_boxes[:, axis], boxes_start[:, axis])
11431146
# extended box stop
1144-
extended_boxes[:, axis + spatial_dims] = extended_boxes[:, axis] + self.spatial_size[axis] // 2
1147+
extended_boxes[:, axis + spatial_dims] = boxes_start[:, axis] + radius_left
11451148
extended_boxes[:, axis + spatial_dims] = np.maximum(
11461149
extended_boxes[:, axis + spatial_dims], boxes_stop[:, axis]
11471150
)

monai/apps/nnunet/nnunetv2_runner.py

Lines changed: 77 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
import glob
1616
import os
17+
import shlex
1718
import subprocess
1819
from typing import Any
1920

@@ -486,16 +487,16 @@ def plan_and_process(
486487
if not no_pp:
487488
self.preprocess(c, n_proc, overwrite_plans_name, verbose)
488489

489-
def train_single_model(self, config: Any, fold: int, gpu_id: tuple | list | int = 0, **kwargs: Any) -> None:
490+
def train_single_model(self, config: Any, fold: int, gpu_id: tuple | list | int | str = 0, **kwargs: Any) -> None:
490491
"""
491492
Run the training on a single GPU with one specified configuration provided.
492-
Note: this will override the environment variable `CUDA_VISIBLE_DEVICES`.
493+
Note: if CUDA_VISIBLE_DEVICES is already set and gpu_id resolves to 0, the existing value is preserved;
494+
otherwise it is set to gpu_id.
493495
494496
Args:
495497
config: configuration that should be trained. Examples: "2d", "3d_fullres", "3d_lowres".
496498
fold: fold of the 5-fold cross-validation. Should be an int between 0 and 4.
497-
gpu_id: an integer to select the device to use, or a tuple/list of GPU device indices used for multi-GPU
498-
training (e.g., (0,1)). Default: 0.
499+
gpu_id: an int, MIG UUID (str), or tuple/list of GPU indices for multi-GPU training (e.g., (0,1)). Default: 0.
499500
kwargs: this optional parameter allows you to specify additional arguments in
500501
``nnunetv2.run.run_training.run_training_entry``.
501502
@@ -525,35 +526,70 @@ def train_single_model(self, config: Any, fold: int, gpu_id: tuple | list | int
525526
kwargs.pop("npz")
526527
logger.warning("please specify the `export_validation_probabilities` in the __init__ of `nnUNetV2Runner`.")
527528

528-
cmd = self.train_single_model_command(config, fold, gpu_id, kwargs)
529-
run_cmd(cmd, shell=True)
529+
cmd, env = self.train_single_model_command(config, fold, gpu_id, kwargs)
530+
run_cmd(cmd, env=env)
530531

531-
def train_single_model_command(self, config, fold, gpu_id, kwargs):
532-
if isinstance(gpu_id, (tuple, list)):
532+
def train_single_model_command(
533+
self, config: str, fold: int, gpu_id: int | str | tuple | list, kwargs: dict[str, Any]
534+
) -> tuple[list[str], dict[str, str]]:
535+
"""
536+
Build the shell command string for training a single nnU-Net model.
537+
538+
Args:
539+
config: Configuration name (e.g., "3d_fullres").
540+
fold: Cross-validation fold index (0-4).
541+
gpu_id: Device selector—int, str (MIG UUID), or tuple/list for multi-GPU.
542+
kwargs: Additional CLI arguments forwarded to nnUNetv2_train.
543+
544+
Returns:
545+
Tuple of (cmd, env) where cmd is a list[str] of argv entries and env is a dict[str, str]
546+
passed to the subprocess.
547+
548+
Raises:
549+
ValueError: If gpu_id is an empty tuple or list.
550+
"""
551+
env = os.environ.copy()
552+
device_setting: str = "0"
553+
num_gpus = 1
554+
if isinstance(gpu_id, str):
555+
device_setting = gpu_id
556+
num_gpus = 1
557+
elif isinstance(gpu_id, (tuple, list)):
558+
if len(gpu_id) == 0:
559+
raise ValueError("gpu_id tuple/list cannot be empty")
533560
if len(gpu_id) > 1:
534-
gpu_ids_str = ""
535-
for _i in range(len(gpu_id)):
536-
gpu_ids_str += f"{gpu_id[_i]},"
537-
device_setting = f"CUDA_VISIBLE_DEVICES={gpu_ids_str[:-1]}"
538-
else:
539-
device_setting = f"CUDA_VISIBLE_DEVICES={gpu_id[0]}"
561+
device_setting = ",".join(str(x) for x in gpu_id)
562+
num_gpus = len(gpu_id)
563+
elif len(gpu_id) == 1:
564+
device_setting = str(gpu_id[0])
565+
num_gpus = 1
540566
else:
541-
device_setting = f"CUDA_VISIBLE_DEVICES={gpu_id}"
542-
num_gpus = 1 if isinstance(gpu_id, int) or len(gpu_id) == 1 else len(gpu_id)
543-
544-
cmd = (
545-
f"{device_setting} nnUNetv2_train "
546-
+ f"{self.dataset_name_or_id} {config} {fold} "
547-
+ f"-tr {self.trainer_class_name} -num_gpus {num_gpus}"
548-
)
567+
device_setting = str(gpu_id)
568+
num_gpus = 1
569+
env_cuda = env.get("CUDA_VISIBLE_DEVICES")
570+
if env_cuda is not None and device_setting == "0":
571+
logger.info(f"Using existing environment variable CUDA_VISIBLE_DEVICES='{env_cuda}'")
572+
else:
573+
env["CUDA_VISIBLE_DEVICES"] = device_setting
574+
575+
cmd = [
576+
"nnUNetv2_train",
577+
f"{self.dataset_name_or_id}",
578+
f"{config}",
579+
f"{fold}",
580+
"-tr",
581+
f"{self.trainer_class_name}",
582+
"-num_gpus",
583+
f"{num_gpus}",
584+
]
549585
if self.export_validation_probabilities:
550-
cmd += " --npz"
586+
cmd.append("--npz")
551587
for _key, _value in kwargs.items():
552588
if _key == "p" or _key == "pretrained_weights":
553-
cmd += f" -{_key} {_value}"
589+
cmd.extend([f"-{_key}", f"{_value}"])
554590
else:
555-
cmd += f" --{_key} {_value}"
556-
return cmd
591+
cmd.extend([f"--{_key}", f"{_value}"])
592+
return cmd, env
557593

558594
def train(
559595
self,
@@ -637,8 +673,8 @@ def train_parallel_cmd(
637673
if _config in ensure_tuple(configs):
638674
for _i in range(self.num_folds):
639675
the_device = gpu_id_for_all[_index % n_devices] # type: ignore
640-
cmd = self.train_single_model_command(_config, _i, the_device, kwargs)
641-
all_cmds[-1][the_device].append(cmd)
676+
cmd, env = self.train_single_model_command(_config, _i, the_device, kwargs)
677+
all_cmds[-1][the_device].append((cmd, env))
642678
_index += 1
643679
return all_cmds
644680

@@ -666,19 +702,21 @@ def train_parallel(
666702
for gpu_id, gpu_cmd in cmds.items():
667703
if not gpu_cmd:
668704
continue
705+
cmds_for_log = [shlex.join(cmd) for cmd, _ in gpu_cmd]
669706
logger.info(
670707
f"training - stage {s + 1}:\n"
671-
f"for gpu {gpu_id}, commands: {gpu_cmd}\n"
708+
f"for gpu {gpu_id}, commands: {cmds_for_log}\n"
672709
f"log '.txt' inside '{os.path.join(self.nnunet_results, self.dataset_name)}'"
673710
)
674711
for stage in all_cmds:
675712
processes = []
676713
for device_id in stage:
677714
if not stage[device_id]:
678715
continue
679-
cmd_str = "; ".join(stage[device_id])
716+
cmd_str = "; ".join(shlex.join(cmd) for cmd, _ in stage[device_id])
717+
env = stage[device_id][0][1]
680718
logger.info(f"Current running command on GPU device {device_id}:\n{cmd_str}\n")
681-
processes.append(subprocess.Popen(cmd_str, shell=True, stdout=subprocess.DEVNULL))
719+
processes.append(subprocess.Popen(cmd_str, shell=True, env=env, stdout=subprocess.DEVNULL))
682720
# finish this stage first
683721
for p in processes:
684722
p.wait()
@@ -779,7 +817,7 @@ def predict(
779817
part_id: int = 0,
780818
num_processes_preprocessing: int = -1,
781819
num_processes_segmentation_export: int = -1,
782-
gpu_id: int = 0,
820+
gpu_id: int | str = 0,
783821
) -> None:
784822
"""
785823
Use this to run inference with nnU-Net. This function is used when you want to manually specify a folder containing
@@ -813,9 +851,14 @@ def predict(
813851
num_processes_preprocessing: out-of-RAM issues.
814852
num_processes_segmentation_export: Number of processes used for segmentation export.
815853
More is not always better. Beware of out-of-RAM issues.
816-
gpu_id: which GPU to use for prediction.
854+
gpu_id: GPU device index (int) or MIG UUID (str) for prediction.
855+
If CUDA_VISIBLE_DEVICES is already set and gpu_id is 0, the existing
856+
environment variable is preserved.
817857
"""
818-
os.environ["CUDA_VISIBLE_DEVICES"] = f"{gpu_id}"
858+
if "CUDA_VISIBLE_DEVICES" in os.environ and gpu_id in {0, "0"}:
859+
logger.info(f"Predict: Using existing CUDA_VISIBLE_DEVICES={os.environ['CUDA_VISIBLE_DEVICES']}")
860+
else:
861+
os.environ["CUDA_VISIBLE_DEVICES"] = f"{gpu_id}"
819862

820863
from nnunetv2.inference.predict_from_raw_data import nnUNetPredictor
821864

monai/data/test_time_augmentation.py

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
from copy import deepcopy
1717
from typing import TYPE_CHECKING, Any
1818

19-
import numpy as np
2019
import torch
2120

2221
from monai.config.type_definitions import NdarrayOrTensor
@@ -68,7 +67,7 @@ class TestTimeAugmentation:
6867
Args:
6968
transform: transform (or composed) to be applied to each realization. At least one transform must be of type
7069
`RandomizableTrait` (i.e. `Randomizable`, `RandomizableTransform`, or `RandomizableTrait`).
71-
. All random transforms must be of type `InvertibleTransform`.
70+
When `apply_inverse_to_pred` is True, all random transforms must be of type `InvertibleTransform`.
7271
batch_size: number of realizations to infer at once.
7372
num_workers: how many subprocesses to use for data.
7473
inferrer_fn: function to use to perform inference.
@@ -92,6 +91,11 @@ class TestTimeAugmentation:
9291
will return the full data. Dimensions will be same size as when passing a single image through
9392
`inferrer_fn`, with a dimension appended equal in size to `num_examples` (N), i.e., `[N,C,H,W,[D]]`.
9493
progress: whether to display a progress bar.
94+
apply_inverse_to_pred: whether to apply inverse transformations to the predictions.
95+
If the model's prediction is spatial (e.g. segmentation), this should be `True` to map the predictions
96+
back to the original spatial reference.
97+
If the prediction is non-spatial (e.g. classification label or score), this should be `False` to
98+
aggregate the raw predictions directly. Defaults to `True`.
9599
96100
Example:
97101
.. code-block:: python
@@ -125,6 +129,7 @@ def __init__(
125129
post_func: Callable = _identity,
126130
return_full_data: bool = False,
127131
progress: bool = True,
132+
apply_inverse_to_pred: bool = True,
128133
) -> None:
129134
self.transform = transform
130135
self.batch_size = batch_size
@@ -134,6 +139,7 @@ def __init__(
134139
self.image_key = image_key
135140
self.return_full_data = return_full_data
136141
self.progress = progress
142+
self.apply_inverse_to_pred = apply_inverse_to_pred
137143
self._pred_key = CommonKeys.PRED
138144
self.inverter = Invertd(
139145
keys=self._pred_key,
@@ -152,20 +158,23 @@ def __init__(
152158

153159
def _check_transforms(self):
154160
"""Should be at least 1 random transform, and all random transforms should be invertible."""
155-
ts = [self.transform] if not isinstance(self.transform, Compose) else self.transform.transforms
156-
randoms = np.array([isinstance(t, Randomizable) for t in ts])
157-
invertibles = np.array([isinstance(t, InvertibleTransform) for t in ts])
158-
# check at least 1 random
159-
if sum(randoms) == 0:
161+
transforms = [self.transform] if not isinstance(self.transform, Compose) else self.transform.transforms
162+
warns = []
163+
randoms = []
164+
165+
for idx, t in enumerate(transforms):
166+
if isinstance(t, Randomizable):
167+
randoms.append(t)
168+
if self.apply_inverse_to_pred and not isinstance(t, InvertibleTransform):
169+
warns.append(f"Transform #{idx} (type {type(t).__name__}) is random but not invertible.")
170+
171+
if len(randoms) == 0:
172+
warns.append("TTA usually requires at least one `Randomizable` transform in the given transform sequence.")
173+
174+
if len(warns) > 0:
160175
warnings.warn(
161-
"TTA usually has at least a `Randomizable` transform or `Compose` contains `Randomizable` transforms."
176+
"TTA has encountered issues with the given transforms:\n " + "\n ".join(warns), stacklevel=2
162177
)
163-
# check that whenever randoms is True, invertibles is also true
164-
for r, i in zip(randoms, invertibles):
165-
if r and not i:
166-
warnings.warn(
167-
f"Not all applied random transform(s) are invertible. Problematic transform: {type(r).__name__}"
168-
)
169178

170179
def __call__(
171180
self, data: dict[str, Any], num_examples: int = 10
@@ -199,7 +208,10 @@ def __call__(
199208
for b in tqdm(dl) if has_tqdm and self.progress else dl:
200209
# do model forward pass
201210
b[self._pred_key] = self.inferrer_fn(b[self.image_key].to(self.device))
202-
outs.extend([self.inverter(PadListDataCollate.inverse(i))[self._pred_key] for i in decollate_batch(b)])
211+
if self.apply_inverse_to_pred:
212+
outs.extend([self.inverter(PadListDataCollate.inverse(i))[self._pred_key] for i in decollate_batch(b)])
213+
else:
214+
outs.extend([i[self._pred_key] for i in decollate_batch(b)])
203215

204216
output: NdarrayOrTensor = stack(outs, 0)
205217

monai/handlers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from __future__ import annotations
1313

1414
from .average_precision import AveragePrecision
15+
from .calibration import CalibrationError
1516
from .checkpoint_loader import CheckpointLoader
1617
from .checkpoint_saver import CheckpointSaver
1718
from .classification_saver import ClassificationSaver

0 commit comments

Comments
 (0)