From eda1c34fcab167ea35df330500bcea10397542f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Thu, 18 Jun 2026 00:18:57 +0100 Subject: [PATCH 1/7] Add partial-volume-aware label resampling mode Add a new opt-in `label_interpolation="label"` mode to the spatial transforms (Spatial/Resample/Affine/ElasticDeformation). For label maps, the mode one-hot encodes by unique label values (robust to non-contiguous labels), optionally antialiases the one-hot channels with the existing Cardoso et al. sigma when downsampling, resamples each channel linearly, takes the per-voxel argmax, and remaps back to the original label values. Out-of-bounds voxels use `default_pad_label`. This avoids the staircase artifacts and biased label volumes of nearest-neighbor interpolation. Defaults are unchanged: `label_interpolation` stays "nearest" everywhere and users opt in. `image_interpolation="label"` raises. --- src/torchio/transforms/spatial/spatial.py | 167 +++++++++++++++++++--- tests/test_spatial.py | 96 +++++++++++++ 2 files changed, 243 insertions(+), 20 deletions(-) diff --git a/src/torchio/transforms/spatial/spatial.py b/src/torchio/transforms/spatial/spatial.py index 2ab56f7ce..e3689a008 100644 --- a/src/torchio/transforms/spatial/spatial.py +++ b/src/torchio/transforms/spatial/spatial.py @@ -91,10 +91,16 @@ "fifth", "sixth", "seventh", + "label", ] TypeCenter: TypeAlias = Literal["image", "origin"] TypePadValue: TypeAlias = Literal["minimum", "mean", "otsu"] +#: Partial-volume label interpolation mode. Only valid for label maps: the map +#: is one-hot encoded, each channel is resampled linearly, and the per-voxel +#: argmax recovers the discrete labels (see `_resample_label_partial_volume`). +LABEL_INTERPOLATION = "label" + _SUPPORTED_INTERPOLATIONS = ( "nearest", "linear", @@ -104,6 +110,7 @@ "fifth", "sixth", "seventh", + LABEL_INTERPOLATION, ) _INTERPOLATION_TO_ORDER: dict[str, int] = { "nearest": 0, @@ -199,11 +206,22 @@ class Spatial(SpatialTransform): transforms. image_interpolation: `"linear"` (default) or `"nearest"`. Used for [`ScalarImage`][torchio.ScalarImage] instances. - label_interpolation: `"nearest"` (default) or `"linear"`. - Used for [`LabelMap`][torchio.LabelMap] instances. + label_interpolation: `"nearest"` (default), `"linear"`, or + `"label"`. Used for [`LabelMap`][torchio.LabelMap] instances. + The `"label"` mode performs partial-volume-aware resampling: + the label map is one-hot encoded, each channel is resampled + with linear interpolation, and the per-voxel argmax recovers + the discrete labels. Compared with `"nearest"`, this reduces + staircase artifacts and yields more accurate label volumes, + which is especially useful when downsampling. It never + produces label values that were absent from the input, and is + more memory- and compute-intensive because it processes one + channel per label. antialias: If `True`, apply Gaussian smoothing before - downsampling intensity images. Label maps are never - smoothed. The standard deviations follow + downsampling intensity images. Label maps are smoothed only + when `label_interpolation="label"` (the one-hot channels are + blurred before downsampling); otherwise they are left + untouched. The standard deviations follow [Cardoso et al., MICCAI 2015](https://link.springer.com/chapter/10.1007/978-3-319-24571-3_81). default_pad_value: Fill rule for out-of-bounds intensity voxels. `"minimum"` (default), `"mean"`, `"otsu"`, @@ -283,6 +301,12 @@ def __init__( raise ValueError(msg) self.affine_first = affine_first self.image_interpolation = _parse_interpolation(image_interpolation) + if self.image_interpolation == LABEL_INTERPOLATION: + msg = ( + f'image_interpolation cannot be "{LABEL_INTERPOLATION}"; that mode' + " is only valid for label_interpolation" + ) + raise ValueError(msg) self.label_interpolation = _parse_interpolation(label_interpolation) self.antialias = antialias self.default_pad_value = _parse_default_pad_value(default_pad_value) @@ -530,6 +554,12 @@ class Resample(Spatial): >>> transform = tio.Resample(2) # 2 mm isotropic >>> transform = tio.Resample("t1") # match "t1" space >>> transform = tio.Resample((1, 1, 3)) # anisotropic + >>> # Partial-volume-aware label resampling + >>> transform = tio.Resample( + ... 2, + ... label_interpolation="label", + ... antialias=True, + ... ) """ def __init__( @@ -714,26 +744,123 @@ def _apply_spatial_to_batch( image_interpolation=image_interpolation, label_interpolation=label_interpolation, ) - fill_value = _batch_fill_value( - img_batch, - default_pad_value=default_pad_value, - default_pad_label=default_pad_label, - ) - data = img_batch.data - # Antialias: blur ScalarImages before downsampling. - if antialias and not is_label: - data = _antialias_batch(data, input_affine, output_affine) - img_batch.data = _sample_batch( - data, - grid, - input_shape=input_shape, - interpolation=interpolation, - fill_value=fill_value, - ) + if is_label and interpolation == LABEL_INTERPOLATION: + img_batch.data = _resample_label_partial_volume( + img_batch.data, + grid, + input_shape=input_shape, + input_affine=input_affine, + output_affine=output_affine, + antialias=antialias, + default_pad_label=float(default_pad_label), + ) + else: + fill_value = _batch_fill_value( + img_batch, + default_pad_value=default_pad_value, + default_pad_label=default_pad_label, + ) + data = img_batch.data + # Antialias: blur ScalarImages before downsampling. + if antialias and not is_label: + data = _antialias_batch(data, input_affine, output_affine) + img_batch.data = _sample_batch( + data, + grid, + input_shape=input_shape, + interpolation=interpolation, + fill_value=fill_value, + ) new_affine = output_affine.clone() img_batch.affines[:] = [new_affine.clone() for _ in img_batch.affines] +def _resample_label_partial_volume( + data: Tensor, + grid: Tensor, + *, + input_shape: TypeThreeInts, + input_affine: AffineMatrix, + output_affine: AffineMatrix, + antialias: bool, + default_pad_label: float, +) -> Tensor: + r"""Resample a discrete label map in a partial-volume-aware way. + + Nearest-neighbor resampling of a label map produces staircase artifacts + and can drop thin structures, especially when downsampling. This helper + instead: + + 1. one-hot encodes the label map using the unique label values present + (robust to non-contiguous labels such as $\{0, 2, 5\}$), + 2. optionally Gaussian-smooths each channel before downsampling when + *antialias* is `True` (using the same sigma as + [`_antialias_sigmas`][] from Cardoso et al., MICCAI 2015), + 3. resamples every channel with linear interpolation, and + 4. takes the per-voxel argmax to recover the discrete labels. + + Out-of-bounds voxels are filled with *default_pad_label*. + + Args: + data: `(B, C, I, J, K)` label batch. When `C == 1` the full one-hot + pipeline is used. When `C > 1` (an already one-hot or + probabilistic map) the channels are resampled linearly without + re-encoding. + grid: `(I_out, J_out, K_out, 3)` sampling grid in input voxel + coordinates. + input_shape: Spatial shape of the input volume `(I, J, K)`. + input_affine: Affine of the input grid. + output_affine: Affine of the output grid. + antialias: Whether to Gaussian-smooth the one-hot channels before + downsampling. + default_pad_label: Value assigned to out-of-bounds voxels. + + Returns: + Resampled `(B, 1, I_out, J_out, K_out)` label batch (or + `(B, C, ...)` when the input had `C > 1`) with the input dtype. + """ + if data.shape[1] > 1: + smoothed = data.float() + if antialias: + smoothed = _antialias_batch(smoothed, input_affine, output_affine) + return _sample_batch( + smoothed, + grid, + input_shape=input_shape, + interpolation="linear", + fill_value=0.0, + ).to(data.dtype) + + labels = torch.unique(data) + values = rearrange(data[:, 0], "b i j k -> b 1 i j k") + targets = rearrange(labels, "n -> 1 n 1 1 1") + one_hot = (values == targets).float() + + if antialias: + one_hot = _antialias_batch(one_hot, input_affine, output_affine) + + sampled = _sample_batch( + one_hot, + grid, + input_shape=input_shape, + interpolation="linear", + fill_value=0.0, + ) + + winners = sampled.argmax(dim=1) + resampled = labels[winners] + # In-bounds voxels keep partition of unity (channels sum to ~1); voxels + # sampled entirely from outside the input have all-zero channels. + in_bounds = sampled.sum(dim=1) > 0.5 + resampled = torch.where( + in_bounds, + resampled, + torch.full_like(resampled, default_pad_label), + ) + out = rearrange(resampled, "b i j k -> b 1 i j k") + return out.to(data.dtype) + + def _resolve_target_space( target: TypeTarget, batch: SubjectsBatch, diff --git a/tests/test_spatial.py b/tests/test_spatial.py index e352066e4..6498429e3 100644 --- a/tests/test_spatial.py +++ b/tests/test_spatial.py @@ -827,3 +827,99 @@ def test_order_0_uses_fast_path(self) -> None: image_interpolation="nearest", )(subject) assert result.t1.data.shape == subject.t1.data.shape + + +def _sphere_label( + n: int = 64, + radius: float = 20.0, + value: float = 1.0, +) -> torch.Tensor: + center = (n - 1) / 2 + zz, yy, xx = torch.meshgrid( + torch.arange(n), + torch.arange(n), + torch.arange(n), + indexing="ij", + ) + distance = ((xx - center) ** 2 + (yy - center) ** 2 + (zz - center) ** 2).sqrt() + sphere = (distance <= radius).float() * value + return sphere[None] + + +def _dice(a: torch.Tensor, b: torch.Tensor) -> float: + mask_a = a > 0 + mask_b = b > 0 + intersection = (mask_a & mask_b).sum() + return (2 * intersection / (mask_a.sum() + mask_b.sum())).item() + + +class TestLabelInterpolation: + def test_parse_interpolation_accepts_label(self) -> None: + assert _parse_interpolation("label") == "label" + assert _parse_interpolation("LABEL") == "label" + + def test_image_interpolation_label_raises(self) -> None: + with pytest.raises(ValueError, match="image_interpolation"): + tio.Resample(2, image_interpolation="label") + + def test_no_invalid_labels_when_downsampling(self) -> None: + data = torch.zeros(1, 32, 32, 32) + data[0, 8:24, 8:24, 8:24] = 2 + data[0, 12:20, 12:20, 12:20] = 5 # non-contiguous label values + subject = tio.Subject(seg=tio.LabelMap(data, affine=np.eye(4))) + result = tio.Resample(4, label_interpolation="label")(subject) + unique = set(result.seg.data.unique().tolist()) + assert unique <= {0.0, 2.0, 5.0} + + def test_no_invalid_labels_when_upsampling(self) -> None: + data = torch.zeros(1, 16, 16, 16) + data[0, 4:12, 4:12, 4:12] = 3 + subject = tio.Subject(seg=tio.LabelMap(data, affine=np.eye(4))) + result = tio.Resample(0.5, label_interpolation="label")(subject) + unique = set(result.seg.data.unique().tolist()) + assert unique <= {0.0, 3.0} + + def test_roundtrip_dice_beats_nearest(self) -> None: + original = _sphere_label() + subject = tio.Subject(seg=tio.LabelMap(original, affine=np.eye(4))) + + def roundtrip(mode: str) -> torch.Tensor: + down = tio.Resample(4, label_interpolation=mode)(subject) + back = tio.Resample(subject.seg, label_interpolation=mode)(down) + return back.seg.data + + dice_label = _dice(roundtrip("label"), original) + dice_nearest = _dice(roundtrip("nearest"), original) + assert dice_label > dice_nearest + + def test_default_pad_label_fills_out_of_bounds(self) -> None: + data = torch.ones(1, 16, 16, 16) + subject = tio.Subject(seg=tio.LabelMap(data, affine=np.eye(4))) + # Shift the whole volume out of the field of view along one axis. + transformed = AffineTransform( + translation=(100.0, 0.0, 0.0), + label_interpolation="label", + default_pad_label=7.0, + )(subject) + assert (transformed.seg.data == 7.0).any() + + def test_antialias_label_runs_and_keeps_valid_labels(self) -> None: + original = _sphere_label(value=4.0) + subject = tio.Subject(seg=tio.LabelMap(original, affine=np.eye(4))) + result = tio.Resample( + 4, + label_interpolation="label", + antialias=True, + )(subject) + unique = set(result.seg.data.unique().tolist()) + assert unique <= {0.0, 4.0} + assert result.seg.data.shape[1:] == (16, 16, 16) + + def test_multichannel_label_resamples_without_argmax(self) -> None: + data = torch.zeros(2, 16, 16, 16) + data[0] = 1.0 + data[0, 4:12, 4:12, 4:12] = 0.0 + data[1, 4:12, 4:12, 4:12] = 1.0 + subject = tio.Subject(seg=tio.LabelMap(data, affine=np.eye(4))) + result = tio.Resample(2, label_interpolation="label")(subject) + assert result.seg.data.shape[0] == 2 From 2a45bcdb907ba7080af5427fed83e7692e65ee1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Thu, 18 Jun 2026 00:27:33 +0100 Subject: [PATCH 2/7] Address review: clarify label-mode docs and fix multi-channel dtype - Clarify in docstrings that the "label" mode can introduce `default_pad_label` for out-of-bounds voxels (single-channel path). - Document that multi-channel (C>1) inputs are resampled linearly, fill out-of-bounds with 0 (not `default_pad_label`), and return partial-volume floats. - Preserve floating-point output for non-floating multi-channel inputs so integer one-hot encodings are not truncated back to 0/1. - Add a test covering partial-volume preservation for integer one-hot multi-channel inputs. --- src/torchio/transforms/spatial/spatial.py | 37 +++++++++++++++++------ tests/test_spatial.py | 13 ++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/torchio/transforms/spatial/spatial.py b/src/torchio/transforms/spatial/spatial.py index e3689a008..aa6f51daa 100644 --- a/src/torchio/transforms/spatial/spatial.py +++ b/src/torchio/transforms/spatial/spatial.py @@ -214,7 +214,9 @@ class Spatial(SpatialTransform): the discrete labels. Compared with `"nearest"`, this reduces staircase artifacts and yields more accurate label volumes, which is especially useful when downsampling. It never - produces label values that were absent from the input, and is + invents intermediate label values that were absent from the + input (the only new value that can appear is + `default_pad_label`, used for out-of-bounds voxels), and is more memory- and compute-intensive because it processes one channel per label. antialias: If `True`, apply Gaussian smoothing before @@ -799,13 +801,20 @@ def _resample_label_partial_volume( 3. resamples every channel with linear interpolation, and 4. takes the per-voxel argmax to recover the discrete labels. - Out-of-bounds voxels are filled with *default_pad_label*. + This single-channel pipeline (`C == 1`) fills out-of-bounds voxels with + *default_pad_label*. + + Multi-channel inputs (`C > 1`, an already one-hot or probabilistic map) + take a different path: their channels are resampled linearly **without** + re-encoding or an argmax, out-of-bounds voxels are filled with `0` + (*default_pad_label* is **not** applied), and the partial-volume result + is returned as floating point so the interpolated fractions are not + truncated. Args: data: `(B, C, I, J, K)` label batch. When `C == 1` the full one-hot - pipeline is used. When `C > 1` (an already one-hot or - probabilistic map) the channels are resampled linearly without - re-encoding. + pipeline is used. When `C > 1` the channels are resampled + linearly without re-encoding (see above). grid: `(I_out, J_out, K_out, 3)` sampling grid in input voxel coordinates. input_shape: Spatial shape of the input volume `(I, J, K)`. @@ -813,23 +822,31 @@ def _resample_label_partial_volume( output_affine: Affine of the output grid. antialias: Whether to Gaussian-smooth the one-hot channels before downsampling. - default_pad_label: Value assigned to out-of-bounds voxels. + default_pad_label: Value assigned to out-of-bounds voxels. Only + applied for single-channel (`C == 1`) inputs; multi-channel + inputs use `0` for out-of-bounds. Returns: - Resampled `(B, 1, I_out, J_out, K_out)` label batch (or - `(B, C, ...)` when the input had `C > 1`) with the input dtype. + Resampled `(B, 1, I_out, J_out, K_out)` label batch with the input + dtype when `C == 1`. When `C > 1`, a `(B, C, ...)` partial-volume + batch is returned: the input dtype is preserved for floating-point + inputs, otherwise the output is `float32` to avoid truncating the + interpolated values. """ if data.shape[1] > 1: smoothed = data.float() if antialias: smoothed = _antialias_batch(smoothed, input_affine, output_affine) - return _sample_batch( + sampled = _sample_batch( smoothed, grid, input_shape=input_shape, interpolation="linear", fill_value=0.0, - ).to(data.dtype) + ) + if data.dtype.is_floating_point: + return sampled.to(data.dtype) + return sampled labels = torch.unique(data) values = rearrange(data[:, 0], "b i j k -> b 1 i j k") diff --git a/tests/test_spatial.py b/tests/test_spatial.py index 6498429e3..da40b8481 100644 --- a/tests/test_spatial.py +++ b/tests/test_spatial.py @@ -923,3 +923,16 @@ def test_multichannel_label_resamples_without_argmax(self) -> None: subject = tio.Subject(seg=tio.LabelMap(data, affine=np.eye(4))) result = tio.Resample(2, label_interpolation="label")(subject) assert result.seg.data.shape[0] == 2 + + def test_multichannel_integer_input_preserves_partial_volumes(self) -> None: + # An integer one-hot encoding must not be truncated back to 0/1: + # linear resampling should yield fractional partial volumes. + data = torch.zeros(2, 16, 16, 16, dtype=torch.uint8) + data[0] = 1 + data[0, :8] = 0 + data[1, :8] = 1 + subject = tio.Subject(seg=tio.LabelMap(data, affine=np.eye(4))) + result = tio.Resample((1.5, 1.0, 1.0), label_interpolation="label")(subject) + assert result.seg.data.dtype.is_floating_point + fractional = (result.seg.data > 0) & (result.seg.data < 1) + assert fractional.any() From 1a54dd404aacba5ea17868ffe1a4e4387b38b7a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Thu, 18 Jun 2026 00:34:02 +0100 Subject: [PATCH 3/7] Address review: fix docstring link and relax Dice assertion - Replace the unresolved mkdocstrings shortcut reference to `_antialias_sigmas` with plain inline code. - Relax the round-trip Dice test to assert "label" is not worse than "nearest" to avoid flakiness from grid alignment / library versions. --- src/torchio/transforms/spatial/spatial.py | 4 ++-- tests/test_spatial.py | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/torchio/transforms/spatial/spatial.py b/src/torchio/transforms/spatial/spatial.py index aa6f51daa..c4b1d0003 100644 --- a/src/torchio/transforms/spatial/spatial.py +++ b/src/torchio/transforms/spatial/spatial.py @@ -796,8 +796,8 @@ def _resample_label_partial_volume( 1. one-hot encodes the label map using the unique label values present (robust to non-contiguous labels such as $\{0, 2, 5\}$), 2. optionally Gaussian-smooths each channel before downsampling when - *antialias* is `True` (using the same sigma as - [`_antialias_sigmas`][] from Cardoso et al., MICCAI 2015), + *antialias* is `True` (using the same `_antialias_sigmas` as the + intensity antialiasing path, from Cardoso et al., MICCAI 2015), 3. resamples every channel with linear interpolation, and 4. takes the per-voxel argmax to recover the discrete labels. diff --git a/tests/test_spatial.py b/tests/test_spatial.py index da40b8481..373fa3ff5 100644 --- a/tests/test_spatial.py +++ b/tests/test_spatial.py @@ -890,7 +890,10 @@ def roundtrip(mode: str) -> torch.Tensor: dice_label = _dice(roundtrip("label"), original) dice_nearest = _dice(roundtrip("nearest"), original) - assert dice_label > dice_nearest + # "label" is reliably better for a compact sphere, but assert only + # "not worse" to stay robust across grid alignment and library + # versions (a tie should not fail the test). + assert dice_label >= dice_nearest def test_default_pad_label_fills_out_of_bounds(self) -> None: data = torch.ones(1, 16, 16, 16) From 5dccd88535c982a6dfb90d9430765979062d1b9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Thu, 18 Jun 2026 00:41:01 +0100 Subject: [PATCH 4/7] Address review: split interpolation type aliases Introduce `TypeImageInterpolation` (no "label") and `TypeLabelInterpolation` (adds "label"), and annotate `image_interpolation` parameters/attributes with the narrower type so that `image_interpolation="label"` is rejected by static type checkers, not only at runtime. `TypeInterpolation` remains as a broad alias for internal helpers. --- src/torchio/transforms/spatial/spatial.py | 46 +++++++++++++---------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/torchio/transforms/spatial/spatial.py b/src/torchio/transforms/spatial/spatial.py index c4b1d0003..b5be0a58a 100644 --- a/src/torchio/transforms/spatial/spatial.py +++ b/src/torchio/transforms/spatial/spatial.py @@ -82,7 +82,7 @@ ) TypeControlPoints: TypeAlias = Tensor | npt.ArrayLike TypeTargetSpace: TypeAlias = tuple[TypeThreeInts, AffineMatrix] -TypeInterpolation: TypeAlias = Literal[ +TypeImageInterpolation: TypeAlias = Literal[ "nearest", "linear", "quadratic", @@ -91,8 +91,12 @@ "fifth", "sixth", "seventh", - "label", ] +#: Label maps additionally accept the partial-volume-aware `"label"` mode. +TypeLabelInterpolation: TypeAlias = TypeImageInterpolation | Literal["label"] +#: Broad alias accepting any interpolation mode, including `"label"`. Used by +#: internal helpers that handle both image and label interpolation. +TypeInterpolation: TypeAlias = TypeLabelInterpolation TypeCenter: TypeAlias = Literal["image", "origin"] TypePadValue: TypeAlias = Literal["minimum", "mean", "otsu"] @@ -272,8 +276,8 @@ def __init__( max_displacement: TypeParameterValue = 0.0, locked_borders: int = 2, affine_first: bool = True, - image_interpolation: TypeInterpolation = "linear", - label_interpolation: TypeInterpolation = "nearest", + image_interpolation: TypeImageInterpolation = "linear", + label_interpolation: TypeLabelInterpolation = "nearest", antialias: bool = False, default_pad_value: TypePadValue | float = "minimum", default_pad_label: int | float = 0, @@ -302,13 +306,14 @@ def __init__( ) raise ValueError(msg) self.affine_first = affine_first - self.image_interpolation = _parse_interpolation(image_interpolation) - if self.image_interpolation == LABEL_INTERPOLATION: + parsed_image_interpolation = _parse_interpolation(image_interpolation) + if parsed_image_interpolation == LABEL_INTERPOLATION: msg = ( f'image_interpolation cannot be "{LABEL_INTERPOLATION}"; that mode' " is only valid for label_interpolation" ) raise ValueError(msg) + self.image_interpolation = parsed_image_interpolation self.label_interpolation = _parse_interpolation(label_interpolation) self.antialias = antialias self.default_pad_value = _parse_default_pad_value(default_pad_value) @@ -483,8 +488,8 @@ def __init__( affine_matrix: npt.ArrayLike | None, control_points: TypeControlPoints | None, affine_first: bool, - image_interpolation: TypeInterpolation, - label_interpolation: TypeInterpolation, + image_interpolation: TypeImageInterpolation, + label_interpolation: TypeLabelInterpolation, default_pad_value: TypePadValue | float, default_pad_label: float, **kwargs: Any, @@ -502,7 +507,10 @@ def __init__( else None ) self.affine_first = affine_first - self.image_interpolation = _parse_interpolation(image_interpolation) + self.image_interpolation = cast( + TypeImageInterpolation, + _parse_interpolation(image_interpolation), + ) self.label_interpolation = _parse_interpolation(label_interpolation) self.default_pad_value = _parse_default_pad_value(default_pad_value) self.default_pad_label = float(default_pad_label) @@ -567,8 +575,8 @@ class Resample(Spatial): def __init__( self, target: TypeTarget = 1, - image_interpolation: TypeInterpolation = "linear", - label_interpolation: TypeInterpolation = "nearest", + image_interpolation: TypeImageInterpolation = "linear", + label_interpolation: TypeLabelInterpolation = "nearest", antialias: bool = False, **kwargs: Any, ) -> None: @@ -618,8 +626,8 @@ def __init__( center: TypeCenter = "image", default_pad_value: TypePadValue | float = "minimum", default_pad_label: int | float = 0, - image_interpolation: TypeInterpolation = "linear", - label_interpolation: TypeInterpolation = "nearest", + image_interpolation: TypeImageInterpolation = "linear", + label_interpolation: TypeLabelInterpolation = "nearest", **kwargs: Any, ) -> None: super().__init__( @@ -679,8 +687,8 @@ def __init__( num_control_points: int | TypeThreeInts = 7, max_displacement: TypeParameterValue = 7.5, locked_borders: int = 2, - image_interpolation: TypeInterpolation = "linear", - label_interpolation: TypeInterpolation = "nearest", + image_interpolation: TypeImageInterpolation = "linear", + label_interpolation: TypeLabelInterpolation = "nearest", **kwargs: Any, ) -> None: super().__init__( @@ -703,8 +711,8 @@ def _apply_spatial_to_batch( control_points: Tensor | None, max_displacement: tuple[float, float, float] | None, affine_first: bool, - image_interpolation: TypeInterpolation, - label_interpolation: TypeInterpolation, + image_interpolation: TypeImageInterpolation, + label_interpolation: TypeLabelInterpolation, antialias: bool, default_pad_value: TypePadValue | float, default_pad_label: float, @@ -1747,8 +1755,8 @@ def _get_spatial_shape(img_batch: ImagesBatch) -> TypeThreeInts: def _interpolation_for_batch( img_batch: ImagesBatch, *, - image_interpolation: TypeInterpolation, - label_interpolation: TypeInterpolation, + image_interpolation: TypeImageInterpolation, + label_interpolation: TypeLabelInterpolation, ) -> str: """Choose the interpolation mode based on the image class.""" if issubclass(img_batch._image_class, LabelMap): From a6120b8f4e06ce2b29972ebcc45777833bc45a18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Thu, 18 Jun 2026 00:46:41 +0100 Subject: [PATCH 5/7] Address review: clarify out-of-bounds threshold and interpolation docs - Clarify the comment on the label-mode out-of-bounds test: the `sum > 0.5` threshold matches the `mask > 0.5` fill convention used for intensity images, treating voxels sampled mostly from outside as out-of-bounds (not only all-zero ones). - Document that image/label interpolation also accept higher-order B-spline modes ("quadratic".."seventh") and integer orders 0-7. - Note that the "label" mode only guarantees no invented labels for single-channel inputs; multi-channel maps yield partial volumes. --- src/torchio/transforms/spatial/spatial.py | 43 +++++++++++++++-------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/torchio/transforms/spatial/spatial.py b/src/torchio/transforms/spatial/spatial.py index b5be0a58a..d992aef26 100644 --- a/src/torchio/transforms/spatial/spatial.py +++ b/src/torchio/transforms/spatial/spatial.py @@ -208,19 +208,28 @@ class Spatial(SpatialTransform): before the elastic field. If `False`, apply the elastic field first. The difference is significant for large transforms. - image_interpolation: `"linear"` (default) or `"nearest"`. - Used for [`ScalarImage`][torchio.ScalarImage] instances. - label_interpolation: `"nearest"` (default), `"linear"`, or - `"label"`. Used for [`LabelMap`][torchio.LabelMap] instances. - The `"label"` mode performs partial-volume-aware resampling: - the label map is one-hot encoded, each channel is resampled - with linear interpolation, and the per-voxel argmax recovers - the discrete labels. Compared with `"nearest"`, this reduces - staircase artifacts and yields more accurate label volumes, - which is especially useful when downsampling. It never - invents intermediate label values that were absent from the - input (the only new value that can appear is - `default_pad_label`, used for out-of-bounds voxels), and is + image_interpolation: Interpolation for + [`ScalarImage`][torchio.ScalarImage] instances. `"linear"` + (default) or `"nearest"` use a fast path; higher-order + B-spline modes `"quadratic"`, `"cubic"`, `"fourth"`, + `"fifth"`, `"sixth"`, and `"seventh"` are also supported, as + are the equivalent integer orders `0`-`7`. + label_interpolation: Interpolation for + [`LabelMap`][torchio.LabelMap] instances. Accepts the same + values as *image_interpolation* (`"nearest"` is the default), + plus the special `"label"` mode. The `"label"` mode performs + partial-volume-aware resampling: the label map is one-hot + encoded, each channel is resampled with linear interpolation, + and the per-voxel argmax recovers the discrete labels. + Compared with `"nearest"`, this reduces staircase artifacts + and yields more accurate label volumes, which is especially + useful when downsampling. For single-channel label maps it + never invents intermediate label values that were absent from + the input (the only new value that can appear is + `default_pad_label`, used for out-of-bounds voxels). A + multi-channel (already one-hot or probabilistic) label map is + instead resampled linearly per channel, so its output can + contain fractional partial volumes. The `"label"` mode is more memory- and compute-intensive because it processes one channel per label. antialias: If `True`, apply Gaussian smoothing before @@ -874,8 +883,12 @@ def _resample_label_partial_volume( winners = sampled.argmax(dim=1) resampled = labels[winners] - # In-bounds voxels keep partition of unity (channels sum to ~1); voxels - # sampled entirely from outside the input have all-zero channels. + # In-bounds voxels keep partition of unity (channels sum to ~1). Near the + # border, the channel sum equals the in-bounds fraction of the sampling + # neighborhood. A voxel sampled mostly (>50%) from outside the input is + # treated as out-of-bounds and set to default_pad_label, matching the + # `mask > 0.5` fill convention used for intensity images in + # `_sample_batch_grid_sample`. in_bounds = sampled.sum(dim=1) > 0.5 resampled = torch.where( in_bounds, From 616e379286f091a5842f8421d11cb16d2c763d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Thu, 18 Jun 2026 00:51:46 +0100 Subject: [PATCH 6/7] Accept integer interpolation orders in public type annotations `_parse_interpolation` already accepts integer B-spline orders 0-7 at runtime (and the docs/tests use them), but the public parameter annotations were string-only, so `image_interpolation=3` failed static type checking. Widen the constructor annotations to `TypeImageInterpolation | int` / `TypeLabelInterpolation | int` to match the documented and tested behavior. --- src/torchio/transforms/spatial/spatial.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/torchio/transforms/spatial/spatial.py b/src/torchio/transforms/spatial/spatial.py index d992aef26..737d63948 100644 --- a/src/torchio/transforms/spatial/spatial.py +++ b/src/torchio/transforms/spatial/spatial.py @@ -285,8 +285,8 @@ def __init__( max_displacement: TypeParameterValue = 0.0, locked_borders: int = 2, affine_first: bool = True, - image_interpolation: TypeImageInterpolation = "linear", - label_interpolation: TypeLabelInterpolation = "nearest", + image_interpolation: TypeImageInterpolation | int = "linear", + label_interpolation: TypeLabelInterpolation | int = "nearest", antialias: bool = False, default_pad_value: TypePadValue | float = "minimum", default_pad_label: int | float = 0, @@ -584,8 +584,8 @@ class Resample(Spatial): def __init__( self, target: TypeTarget = 1, - image_interpolation: TypeImageInterpolation = "linear", - label_interpolation: TypeLabelInterpolation = "nearest", + image_interpolation: TypeImageInterpolation | int = "linear", + label_interpolation: TypeLabelInterpolation | int = "nearest", antialias: bool = False, **kwargs: Any, ) -> None: @@ -635,8 +635,8 @@ def __init__( center: TypeCenter = "image", default_pad_value: TypePadValue | float = "minimum", default_pad_label: int | float = 0, - image_interpolation: TypeImageInterpolation = "linear", - label_interpolation: TypeLabelInterpolation = "nearest", + image_interpolation: TypeImageInterpolation | int = "linear", + label_interpolation: TypeLabelInterpolation | int = "nearest", **kwargs: Any, ) -> None: super().__init__( @@ -696,8 +696,8 @@ def __init__( num_control_points: int | TypeThreeInts = 7, max_displacement: TypeParameterValue = 7.5, locked_borders: int = 2, - image_interpolation: TypeImageInterpolation = "linear", - label_interpolation: TypeLabelInterpolation = "nearest", + image_interpolation: TypeImageInterpolation | int = "linear", + label_interpolation: TypeLabelInterpolation | int = "nearest", **kwargs: Any, ) -> None: super().__init__( From e7f8e0f2b5f9b209a79a91493c1a3e73c8991c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20P=C3=A9rez-Garc=C3=ADa?= Date: Fri, 19 Jun 2026 10:44:02 +0100 Subject: [PATCH 7/7] Add one_hot_label_interpolation parameter to the "label" mode Let users choose the interpolation used to resample the one-hot channels of the partial-volume "label" mode, instead of hard-coding linear. It accepts the same modes as image interpolation (nearest, linear, B-spline orders 2-7 / integer orders 0-7) but not "label". Defaults to "linear", so behavior is unchanged. Higher-order modes give smoother label boundaries, which is useful when upsampling. Also document that the "label" mode's memory scales with the number of labels, and point to GridSampler + PatchAggregator for patch-based processing of many-label maps. Threaded through Spatial/Resample/Affine/ElasticDeformation, the inverse, history replay (with a "linear" fallback for old params), and the _resample_label_partial_volume helper. Adds tests covering the default, higher-order difference, integer orders, and rejection of "label". Addresses reviewer feedback on PR #1477. --- src/torchio/transforms/spatial/spatial.py | 101 +++++++++++++++++++--- tests/test_spatial.py | 59 +++++++++++++ 2 files changed, 147 insertions(+), 13 deletions(-) diff --git a/src/torchio/transforms/spatial/spatial.py b/src/torchio/transforms/spatial/spatial.py index 737d63948..d8b794abd 100644 --- a/src/torchio/transforms/spatial/spatial.py +++ b/src/torchio/transforms/spatial/spatial.py @@ -228,10 +228,22 @@ class Spatial(SpatialTransform): the input (the only new value that can appear is `default_pad_label`, used for out-of-bounds voxels). A multi-channel (already one-hot or probabilistic) label map is - instead resampled linearly per channel, so its output can - contain fractional partial volumes. The `"label"` mode is - more memory- and compute-intensive because it processes one - channel per label. + instead resampled per channel, so its output can contain + fractional partial volumes. + + The `"label"` mode is more memory- and compute-intensive + because it processes one channel per label, so memory scales + with the number of distinct labels. For maps with many labels + (e.g. hundreds of brain structures) prefer processing patch by + patch with [`GridSampler`][torchio.GridSampler] and + [`PatchAggregator`][torchio.PatchAggregator] to bound memory. + one_hot_label_interpolation: Interpolation used for the one-hot + channels of the `"label"` mode. Accepts the same values as + *image_interpolation* (`"nearest"`, `"linear"`, or the + higher-order B-spline modes / integer orders 0-7), but **not** + `"label"`. Defaults to `"linear"`. Higher-order modes give + smoother label boundaries, which is particularly useful when + upsampling. Ignored unless `label_interpolation="label"`. antialias: If `True`, apply Gaussian smoothing before downsampling intensity images. Label maps are smoothed only when `label_interpolation="label"` (the one-hot channels are @@ -287,6 +299,7 @@ def __init__( affine_first: bool = True, image_interpolation: TypeImageInterpolation | int = "linear", label_interpolation: TypeLabelInterpolation | int = "nearest", + one_hot_label_interpolation: TypeImageInterpolation | int = "linear", antialias: bool = False, default_pad_value: TypePadValue | float = "minimum", default_pad_label: int | float = 0, @@ -324,6 +337,9 @@ def __init__( raise ValueError(msg) self.image_interpolation = parsed_image_interpolation self.label_interpolation = _parse_interpolation(label_interpolation) + self.one_hot_label_interpolation = _parse_one_hot_label_interpolation( + one_hot_label_interpolation, + ) self.antialias = antialias self.default_pad_value = _parse_default_pad_value(default_pad_value) if not isinstance(default_pad_label, Number): @@ -396,6 +412,7 @@ def make_params(self, batch: SubjectsBatch) -> dict[str, Any]: "affine_first": self.affine_first, "image_interpolation": self.image_interpolation, "label_interpolation": self.label_interpolation, + "one_hot_label_interpolation": self.one_hot_label_interpolation, "antialias": self.antialias, "default_pad_value": self.default_pad_value, "default_pad_label": self.default_pad_label, @@ -430,6 +447,10 @@ def apply_transform( affine_first=params["affine_first"], image_interpolation=params["image_interpolation"], label_interpolation=params["label_interpolation"], + one_hot_label_interpolation=params.get( + "one_hot_label_interpolation", + "linear", + ), antialias=params.get("antialias", False), default_pad_value=params["default_pad_value"], default_pad_label=float(params["default_pad_label"]), @@ -476,6 +497,10 @@ def inverse(self, params: dict[str, Any]) -> _SpatialInverse: affine_first=not params["affine_first"], image_interpolation=params["image_interpolation"], label_interpolation=params["label_interpolation"], + one_hot_label_interpolation=params.get( + "one_hot_label_interpolation", + "linear", + ), default_pad_value=params["default_pad_value"], default_pad_label=float(params["default_pad_label"]), copy=False, @@ -499,6 +524,7 @@ def __init__( affine_first: bool, image_interpolation: TypeImageInterpolation, label_interpolation: TypeLabelInterpolation, + one_hot_label_interpolation: TypeImageInterpolation = "linear", default_pad_value: TypePadValue | float, default_pad_label: float, **kwargs: Any, @@ -521,6 +547,9 @@ def __init__( _parse_interpolation(image_interpolation), ) self.label_interpolation = _parse_interpolation(label_interpolation) + self.one_hot_label_interpolation = _parse_one_hot_label_interpolation( + one_hot_label_interpolation, + ) self.default_pad_value = _parse_default_pad_value(default_pad_value) self.default_pad_label = float(default_pad_label) @@ -547,6 +576,7 @@ def apply_transform( affine_first=self.affine_first, image_interpolation=self.image_interpolation, label_interpolation=self.label_interpolation, + one_hot_label_interpolation=self.one_hot_label_interpolation, antialias=False, default_pad_value=self.default_pad_value, default_pad_label=self.default_pad_label, @@ -565,6 +595,7 @@ class Resample(Spatial): Defaults to 1 mm isotropic. image_interpolation: See [`Spatial`][torchio.Spatial]. label_interpolation: See [`Spatial`][torchio.Spatial]. + one_hot_label_interpolation: See [`Spatial`][torchio.Spatial]. antialias: See [`Spatial`][torchio.Spatial]. **kwargs: See [`Transform`][torchio.Transform]. @@ -586,6 +617,7 @@ def __init__( target: TypeTarget = 1, image_interpolation: TypeImageInterpolation | int = "linear", label_interpolation: TypeLabelInterpolation | int = "nearest", + one_hot_label_interpolation: TypeImageInterpolation | int = "linear", antialias: bool = False, **kwargs: Any, ) -> None: @@ -593,6 +625,7 @@ def __init__( target=target, image_interpolation=image_interpolation, label_interpolation=label_interpolation, + one_hot_label_interpolation=one_hot_label_interpolation, antialias=antialias, **kwargs, ) @@ -617,6 +650,7 @@ class Affine(Spatial): default_pad_label: See [`Spatial`][torchio.Spatial]. image_interpolation: See [`Spatial`][torchio.Spatial]. label_interpolation: See [`Spatial`][torchio.Spatial]. + one_hot_label_interpolation: See [`Spatial`][torchio.Spatial]. **kwargs: See [`Transform`][torchio.Transform]. Examples: @@ -637,6 +671,7 @@ def __init__( default_pad_label: int | float = 0, image_interpolation: TypeImageInterpolation | int = "linear", label_interpolation: TypeLabelInterpolation | int = "nearest", + one_hot_label_interpolation: TypeImageInterpolation | int = "linear", **kwargs: Any, ) -> None: super().__init__( @@ -649,6 +684,7 @@ def __init__( default_pad_label=default_pad_label, image_interpolation=image_interpolation, label_interpolation=label_interpolation, + one_hot_label_interpolation=one_hot_label_interpolation, **kwargs, ) self._warn_if_noop( @@ -678,6 +714,7 @@ class ElasticDeformation(Spatial): locked_borders: See [`Spatial`][torchio.Spatial]. image_interpolation: See [`Spatial`][torchio.Spatial]. label_interpolation: See [`Spatial`][torchio.Spatial]. + one_hot_label_interpolation: See [`Spatial`][torchio.Spatial]. **kwargs: See [`Transform`][torchio.Transform]. Examples: @@ -698,6 +735,7 @@ def __init__( locked_borders: int = 2, image_interpolation: TypeImageInterpolation | int = "linear", label_interpolation: TypeLabelInterpolation | int = "nearest", + one_hot_label_interpolation: TypeImageInterpolation | int = "linear", **kwargs: Any, ) -> None: super().__init__( @@ -707,6 +745,7 @@ def __init__( locked_borders=locked_borders, image_interpolation=image_interpolation, label_interpolation=label_interpolation, + one_hot_label_interpolation=one_hot_label_interpolation, **kwargs, ) @@ -722,6 +761,7 @@ def _apply_spatial_to_batch( affine_first: bool, image_interpolation: TypeImageInterpolation, label_interpolation: TypeLabelInterpolation, + one_hot_label_interpolation: TypeImageInterpolation = "linear", antialias: bool, default_pad_value: TypePadValue | float, default_pad_label: float, @@ -771,6 +811,7 @@ def _apply_spatial_to_batch( input_affine=input_affine, output_affine=output_affine, antialias=antialias, + one_hot_label_interpolation=one_hot_label_interpolation, default_pad_label=float(default_pad_label), ) else: @@ -802,6 +843,7 @@ def _resample_label_partial_volume( input_affine: AffineMatrix, output_affine: AffineMatrix, antialias: bool, + one_hot_label_interpolation: TypeImageInterpolation = "linear", default_pad_label: float, ) -> Tensor: r"""Resample a discrete label map in a partial-volume-aware way. @@ -815,23 +857,25 @@ def _resample_label_partial_volume( 2. optionally Gaussian-smooths each channel before downsampling when *antialias* is `True` (using the same `_antialias_sigmas` as the intensity antialiasing path, from Cardoso et al., MICCAI 2015), - 3. resamples every channel with linear interpolation, and + 3. resamples every channel with *one_hot_label_interpolation* + (`"linear"` by default; higher-order B-spline modes give smoother + boundaries, which is useful when upsampling), and 4. takes the per-voxel argmax to recover the discrete labels. This single-channel pipeline (`C == 1`) fills out-of-bounds voxels with *default_pad_label*. Multi-channel inputs (`C > 1`, an already one-hot or probabilistic map) - take a different path: their channels are resampled linearly **without** - re-encoding or an argmax, out-of-bounds voxels are filled with `0` - (*default_pad_label* is **not** applied), and the partial-volume result - is returned as floating point so the interpolated fractions are not - truncated. + take a different path: their channels are resampled with + *one_hot_label_interpolation* **without** re-encoding or an argmax, + out-of-bounds voxels are filled with `0` (*default_pad_label* is **not** + applied), and the partial-volume result is returned as floating point so + the interpolated fractions are not truncated. Args: data: `(B, C, I, J, K)` label batch. When `C == 1` the full one-hot pipeline is used. When `C > 1` the channels are resampled - linearly without re-encoding (see above). + without re-encoding (see above). grid: `(I_out, J_out, K_out, 3)` sampling grid in input voxel coordinates. input_shape: Spatial shape of the input volume `(I, J, K)`. @@ -839,6 +883,9 @@ def _resample_label_partial_volume( output_affine: Affine of the output grid. antialias: Whether to Gaussian-smooth the one-hot channels before downsampling. + one_hot_label_interpolation: Interpolation used to resample the + one-hot channels (`"nearest"`, `"linear"`, or a higher-order + B-spline mode). Defaults to `"linear"`. default_pad_label: Value assigned to out-of-bounds voxels. Only applied for single-channel (`C == 1`) inputs; multi-channel inputs use `0` for out-of-bounds. @@ -858,7 +905,7 @@ def _resample_label_partial_volume( smoothed, grid, input_shape=input_shape, - interpolation="linear", + interpolation=one_hot_label_interpolation, fill_value=0.0, ) if data.dtype.is_floating_point: @@ -877,7 +924,7 @@ def _resample_label_partial_volume( one_hot, grid, input_shape=input_shape, - interpolation="linear", + interpolation=one_hot_label_interpolation, fill_value=0.0, ) @@ -1965,6 +2012,34 @@ def _parse_interpolation( return lowered +def _parse_one_hot_label_interpolation( + interpolation: TypeImageInterpolation | int, +) -> TypeImageInterpolation: + """Validate the per-channel interpolation used by the `"label"` mode. + + Accepts the same modes as image interpolation (`"nearest"`, `"linear"`, + or B-spline orders `"quadratic"`-`"seventh"` / integer orders 0-7) but + rejects `"label"`, which would recurse. + + Args: + interpolation: Interpolation mode (string or integer order). + + Returns: + The validated interpolation mode as a lowercase string. + + Raises: + ValueError: If `"label"` is passed. + """ + parsed = _parse_interpolation(interpolation) + if parsed == LABEL_INTERPOLATION: + msg = ( + f'one_hot_label_interpolation cannot be "{LABEL_INTERPOLATION}"; choose' + ' an interpolation for the one-hot channels (e.g. "linear")' + ) + raise ValueError(msg) + return parsed + + def _parse_default_pad_value(value: TypePadValue | float) -> TypePadValue | float: """Validate a pad-value specification (string keyword or number).""" if isinstance(value, Number): diff --git a/tests/test_spatial.py b/tests/test_spatial.py index 373fa3ff5..0bf582aeb 100644 --- a/tests/test_spatial.py +++ b/tests/test_spatial.py @@ -939,3 +939,62 @@ def test_multichannel_integer_input_preserves_partial_volumes(self) -> None: assert result.seg.data.dtype.is_floating_point fractional = (result.seg.data > 0) & (result.seg.data < 1) assert fractional.any() + + def _three_label_junction(self) -> tio.Subject: + # Three labels meeting at a wavy junction, where the per-channel + # interpolation order changes the argmax outcome. + n = 40 + yy, xx, zz = torch.meshgrid( + torch.arange(n), + torch.arange(n), + torch.arange(n), + indexing="ij", + ) + seg = torch.zeros(n, n, n) + boundary = n / 2 + 3 * torch.sin(xx.float() / 3) + seg[yy > boundary] = 1 + seg[(yy <= boundary) & (zz > n / 2)] = 2 + return tio.Subject(seg=tio.LabelMap(seg[None], affine=np.eye(4))) + + def test_one_hot_label_interpolation_label_raises(self) -> None: + with pytest.raises(ValueError, match="one_hot_label_interpolation"): + tio.Resample( + 2, + label_interpolation="label", + one_hot_label_interpolation="label", + ) + + def test_one_hot_label_interpolation_default_is_linear(self) -> None: + subject = self._three_label_junction() + default = tio.Resample(0.5, label_interpolation="label")(subject) + explicit = tio.Resample( + 0.5, + label_interpolation="label", + one_hot_label_interpolation="linear", + )(subject) + torch.testing.assert_close(default.seg.data, explicit.seg.data) + + def test_one_hot_label_interpolation_higher_order_differs(self) -> None: + subject = self._three_label_junction() + linear = tio.Resample( + 0.5, + label_interpolation="label", + one_hot_label_interpolation="linear", + )(subject) + cubic = tio.Resample( + 0.5, + label_interpolation="label", + one_hot_label_interpolation="cubic", + )(subject) + # The order changes the result, but never invents labels. + assert not torch.equal(linear.seg.data, cubic.seg.data) + assert set(cubic.seg.data.unique().tolist()) <= {0.0, 1.0, 2.0} + + def test_one_hot_label_interpolation_accepts_integer_order(self) -> None: + subject = self._three_label_junction() + result = tio.Resample( + 0.5, + label_interpolation="label", + one_hot_label_interpolation=3, + )(subject) + assert set(result.seg.data.unique().tolist()) <= {0.0, 1.0, 2.0}