Skip to content

Commit 2cefafe

Browse files
authored
Merge branch 'dev' into improve/writer-error-messages
Signed-off-by: Eric Kerfoot <17726042+ericspod@users.noreply.github.com>
2 parents 58e774d + 342bd7a commit 2cefafe

29 files changed

Lines changed: 1633 additions & 55 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/data/image_reader.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,6 +1113,8 @@ def get_data(self, img) -> tuple[np.ndarray, dict]:
11131113

11141114
for i, filename in zip(ensure_tuple(img), self.filenames):
11151115
header = self._get_meta_dict(i)
1116+
if MetaKeys.PIXDIM in header:
1117+
header[MetaKeys.ORIGINAL_PIXDIM] = np.array(header[MetaKeys.PIXDIM], copy=True)
11161118
header[MetaKeys.AFFINE] = self._get_affine(i)
11171119
header[MetaKeys.ORIGINAL_AFFINE] = self._get_affine(i)
11181120
header["as_closest_canonical"] = self.as_closest_canonical

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/data/utils.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -597,11 +597,12 @@ def decollate_batch(batch, detach: bool = True, pad=True, fill_value=None):
597597
type(batch).__module__ == "numpy" and not isinstance(batch, Iterable)
598598
):
599599
return batch
600+
# if scalar tensor/array, return the item itself.
601+
if getattr(batch, "ndim", -1) == 0 and hasattr(batch, "item"):
602+
return batch.item() if detach else batch
600603
if isinstance(batch, torch.Tensor):
601604
if detach:
602605
batch = batch.detach()
603-
if batch.ndim == 0:
604-
return batch.item() if detach else batch
605606
out_list = torch.unbind(batch, dim=0)
606607
# if of type MetaObj, decollate the metadata
607608
if isinstance(batch, MetaObj):

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

monai/handlers/calibration.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Copyright (c) MONAI Consortium
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
# Unless required by applicable law or agreed to in writing, software
7+
# distributed under the License is distributed on an "AS IS" BASIS,
8+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
# See the License for the specific language governing permissions and
10+
# limitations under the License.
11+
12+
from __future__ import annotations
13+
14+
from collections.abc import Callable
15+
16+
from monai.handlers.ignite_metric import IgniteMetricHandler
17+
from monai.metrics import CalibrationErrorMetric, CalibrationReduction
18+
from monai.utils import MetricReduction
19+
20+
__all__ = ["CalibrationError"]
21+
22+
23+
class CalibrationError(IgniteMetricHandler):
24+
"""
25+
Ignite handler to compute Calibration Error during training or evaluation.
26+
27+
**Why Calibration Matters:**
28+
29+
A well-calibrated model produces probability estimates that match the true likelihood of correctness.
30+
For example, predictions with 80% confidence should be correct approximately 80% of the time.
31+
Modern neural networks often exhibit poor calibration (typically overconfident), which can be
32+
problematic in medical imaging where probability estimates may inform clinical decisions.
33+
34+
This handler wraps :py:class:`~monai.metrics.CalibrationErrorMetric` for use with PyTorch Ignite
35+
engines, automatically computing and aggregating calibration errors across iterations.
36+
37+
**Supported Calibration Metrics:**
38+
39+
- **Expected Calibration Error (ECE)**: Weighted average of per-bin errors (most common).
40+
- **Average Calibration Error (ACE)**: Unweighted average across bins.
41+
- **Maximum Calibration Error (MCE)**: Worst-case calibration error.
42+
43+
Args:
44+
num_bins: Number of equally-spaced bins for calibration computation. Defaults to 20.
45+
include_background: Whether to include the first channel (index 0) in computation.
46+
Set to ``False`` to exclude background in segmentation tasks. Defaults to ``True``.
47+
calibration_reduction: Calibration error reduction mode. Options: ``"expected"`` (ECE),
48+
``"average"`` (ACE), ``"maximum"`` (MCE). Defaults to ``"expected"``.
49+
metric_reduction: Reduction across batch/channel after computing per-sample errors.
50+
Options: ``"none"``, ``"mean"``, ``"sum"``, ``"mean_batch"``, ``"sum_batch"``,
51+
``"mean_channel"``, ``"sum_channel"``. Defaults to ``"mean"``.
52+
output_transform: Callable to extract ``(y_pred, y)`` from ``engine.state.output``.
53+
See `Ignite concepts <https://pytorch.org/ignite/concepts.html#state>`_ and
54+
the batch output transform tutorial in the MONAI tutorials repository.
55+
save_details: If ``True``, saves per-sample/per-channel metric values to
56+
``engine.state.metric_details[name]``. Defaults to ``True``.
57+
58+
References:
59+
- Guo, C., et al. "On Calibration of Modern Neural Networks." ICML 2017.
60+
https://proceedings.mlr.press/v70/guo17a.html
61+
- Barfoot, T., et al. "Average Calibration Losses for Reliable Uncertainty in
62+
Medical Image Segmentation." arXiv:2506.03942v3, 2025.
63+
https://arxiv.org/abs/2506.03942v3
64+
65+
See Also:
66+
- :py:class:`~monai.metrics.CalibrationErrorMetric`: The underlying metric class.
67+
- :py:func:`~monai.metrics.calibration_binning`: Low-level binning for reliability diagrams.
68+
69+
Example:
70+
>>> from monai.handlers import CalibrationError, from_engine
71+
>>> from ignite.engine import Engine
72+
>>>
73+
>>> def evaluation_step(engine, batch):
74+
... # Returns dict with "pred" (probabilities) and "label" (one-hot)
75+
... return {"pred": model(batch["image"]), "label": batch["label"]}
76+
>>>
77+
>>> evaluator = Engine(evaluation_step)
78+
>>>
79+
>>> # Attach calibration error handler
80+
>>> CalibrationError(
81+
... num_bins=15,
82+
... include_background=False,
83+
... calibration_reduction="expected",
84+
... output_transform=from_engine(["pred", "label"]),
85+
... ).attach(evaluator, name="ECE")
86+
>>>
87+
>>> # After evaluation, access results
88+
>>> evaluator.run(val_loader)
89+
>>> ece = evaluator.state.metrics["ECE"]
90+
>>> print(f"Expected Calibration Error: {ece:.4f}")
91+
"""
92+
93+
def __init__(
94+
self,
95+
num_bins: int = 20,
96+
include_background: bool = True,
97+
calibration_reduction: CalibrationReduction | str = CalibrationReduction.EXPECTED,
98+
metric_reduction: MetricReduction | str = MetricReduction.MEAN,
99+
output_transform: Callable = lambda x: x,
100+
save_details: bool = True,
101+
) -> None:
102+
metric_fn = CalibrationErrorMetric(
103+
num_bins=num_bins,
104+
include_background=include_background,
105+
calibration_reduction=calibration_reduction,
106+
metric_reduction=metric_reduction,
107+
)
108+
109+
super().__init__(metric_fn=metric_fn, output_transform=output_transform, save_details=save_details)

monai/inferers/utils.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,8 @@ def sliding_window_inference(
7676
7777
Args:
7878
inputs: input image to be processed (assuming NCHW[D])
79-
roi_size: the spatial window size for inferences.
79+
roi_size: the spatial window size for inferences, this must be a single value or a tuple with values
80+
for each spatial dimension (eg. 2 for 2D, 3 for 3D).
8081
When its components have None or non-positives, the corresponding inputs dimension will be used.
8182
if the components of the `roi_size` are non-positive values, the transform will use the
8283
corresponding components of img size. For example, `roi_size=(32, -1)` will be adapted
@@ -131,11 +132,30 @@ def sliding_window_inference(
131132
kwargs: optional keyword args to be passed to ``predictor``.
132133
133134
Note:
134-
- input must be channel-first and have a batch dim, supports N-D sliding window.
135+
- Inputs must be channel-first and have a batch dim (NCHW / NCDHW).
136+
- If your data is NHWC/NDHWC, please apply `EnsureChannelFirst` / `EnsureChannelFirstd` upstream.
137+
138+
Raises:
139+
ValueError: When the input dimensions do not match the expected dimensions based on ``roi_size``.
135140
136141
"""
137-
buffered = buffer_steps is not None and buffer_steps > 0
138142
num_spatial_dims = len(inputs.shape) - 2
143+
144+
# Only perform strict shape validation if roi_size is a sequence (explicit dimensions).
145+
# If roi_size is an integer, it is broadcast to all dimensions, so we cannot
146+
# infer the expected dimensionality to enforce a strict check here.
147+
if isinstance(roi_size, Sequence):
148+
roi_dims = len(roi_size)
149+
if num_spatial_dims != roi_dims:
150+
raise ValueError(
151+
f"Inputs must have {roi_dims + 2} dimensions for {roi_dims}D roi_size "
152+
f"(Batch, Channel, {', '.join(['Spatial'] * roi_dims)}), "
153+
f"but got inputs shape {inputs.shape}.\n"
154+
"If you have channel-last data (e.g. B, D, H, W, C), please use "
155+
"monai.transforms.EnsureChannelFirst or EnsureChannelFirstd upstream."
156+
)
157+
# -----------------------------------------------------------------
158+
buffered = buffer_steps is not None and buffer_steps > 0
139159
if buffered:
140160
if buffer_dim < -num_spatial_dims or buffer_dim > num_spatial_dims:
141161
raise ValueError(f"buffer_dim must be in [{-num_spatial_dims}, {num_spatial_dims}], got {buffer_dim}.")

monai/losses/cldice.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,8 @@ class SoftclDiceLoss(_Loss):
126126
def __init__(self, iter_: int = 3, smooth: float = 1.0) -> None:
127127
"""
128128
Args:
129-
iter_: Number of iterations for skeletonization
130-
smooth: Smoothing parameter
129+
iter_: Number of iterations for skeletonization. Defaults to 3.
130+
smooth: Smoothing parameter. Defaults to 1.0.
131131
"""
132132
super().__init__()
133133
self.iter = iter_
@@ -160,9 +160,9 @@ class SoftDiceclDiceLoss(_Loss):
160160
def __init__(self, iter_: int = 3, alpha: float = 0.5, smooth: float = 1.0) -> None:
161161
"""
162162
Args:
163-
iter_: Number of iterations for skeletonization
164-
smooth: Smoothing parameter
165-
alpha: Weighing factor for cldice
163+
iter_: Number of iterations for skeletonization. Defaults to 3.
164+
alpha: Weighing factor for cldice. Defaults to 0.5.
165+
smooth: Smoothing parameter. Defaults to 1.0.
166166
"""
167167
super().__init__()
168168
self.iter = iter_

monai/losses/dice.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -548,10 +548,8 @@ def forward(self, input: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
548548
elif self.reduction == LossReduction.SUM.value:
549549
wass_dice_loss = torch.sum(wass_dice_loss) # sum over the batch and channel dims
550550
elif self.reduction == LossReduction.NONE.value:
551-
# If we are not computing voxelwise loss components at least
552-
# make sure a none reduction maintains a broadcastable shape
553-
broadcast_shape = input.shape[0:2] + (1,) * (len(input.shape) - 2)
554-
wass_dice_loss = wass_dice_loss.view(broadcast_shape)
551+
# GWDL aggregates over classes internally, so wass_dice_loss has shape (B,)
552+
pass
555553
else:
556554
raise ValueError(f'Unsupported reduction: {self.reduction}, available options are ["mean", "sum", "none"].')
557555

@@ -609,8 +607,9 @@ def _compute_generalized_true_positive(
609607
alpha_extended = alpha_extended.expand((flat_target.size(0), self.num_classes, flat_target.size(1)))
610608
flat_target_extended = torch.unsqueeze(flat_target, dim=1)
611609
alpha_extended = torch.gather(alpha_extended, index=flat_target_extended, dim=1)
610+
alpha_extended = torch.squeeze(alpha_extended, dim=1)
612611

613-
return torch.sum(alpha_extended * (1.0 - wasserstein_distance_map), dim=[1, 2])
612+
return torch.sum(alpha_extended * (1.0 - wasserstein_distance_map), dim=1)
614613

615614
def _compute_denominator(
616615
self, alpha: torch.Tensor, flat_target: torch.Tensor, wasserstein_distance_map: torch.Tensor
@@ -626,8 +625,9 @@ def _compute_denominator(
626625
alpha_extended = alpha_extended.expand((flat_target.size(0), self.num_classes, flat_target.size(1)))
627626
flat_target_extended = torch.unsqueeze(flat_target, dim=1)
628627
alpha_extended = torch.gather(alpha_extended, index=flat_target_extended, dim=1)
628+
alpha_extended = torch.squeeze(alpha_extended, dim=1)
629629

630-
return torch.sum(alpha_extended * (2.0 - wasserstein_distance_map), dim=[1, 2])
630+
return torch.sum(alpha_extended * (2.0 - wasserstein_distance_map), dim=1)
631631

632632
def _compute_alpha_generalized_true_positives(self, flat_target: torch.Tensor) -> torch.Tensor:
633633
"""

0 commit comments

Comments
 (0)