From dd306b5171cb06aaadc59ad4cc833f2ba4822064 Mon Sep 17 00:00:00 2001 From: Sebastian Penhouet <7819068+Spenhouet@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:26:43 +0200 Subject: [PATCH 1/2] Fix spatial_ndim after EnsureChannelFirst adds a channel dim (#9184) When the input has no channel dim, the cached spatial_ndim was clamped to ndim - 1 at construction and was not updated after the channel dim was added. Crops then built a translation with too few dims and lost the offset on the last spatial axis. Recompute spatial_ndim from the affine once the channel dim exists. Signed-off-by: Sebastian Penhouet <7819068+Spenhouet@users.noreply.github.com> --- monai/transforms/utility/array.py | 7 ++++++- .../transforms/test_ensure_channel_firstd.py | 19 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 297b243ff91..0414138016f 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -231,7 +231,12 @@ def __call__(self, img: torch.Tensor, meta_dict: Mapping | None = None) -> torch else: result = moveaxis(img, int(channel_dim), 0) # type: ignore - return convert_to_tensor(result, track_meta=get_track_meta()) # type: ignore + result = convert_to_tensor(result, track_meta=get_track_meta()) + if is_no_channel(channel_dim) and isinstance(result, MetaTensor) and result.affine.ndim == 2: + # the cached spatial_ndim may have been clamped while the input had no channel dim, + # so derive it again from the affine now that the channel dim exists + result.spatial_ndim = _normalize_spatial_ndim(result.affine.shape[-1] - 1, result.ndim) + return result # type: ignore class RepeatChannel(Transform): diff --git a/tests/transforms/test_ensure_channel_firstd.py b/tests/transforms/test_ensure_channel_firstd.py index e9effad9516..60f39e2d1bd 100644 --- a/tests/transforms/test_ensure_channel_firstd.py +++ b/tests/transforms/test_ensure_channel_firstd.py @@ -22,7 +22,7 @@ from PIL import Image from monai.data.meta_tensor import MetaTensor -from monai.transforms import EnsureChannelFirstd, LoadImaged +from monai.transforms import EnsureChannelFirstd, LoadImaged, SpatialCropd TEST_CASE_1 = [{"keys": "img"}, ["test_image.nii.gz"], None] @@ -80,6 +80,23 @@ def test_default_channel_first(self): self.assertEqual(result["img"].shape, (1, 4, 4)) + @parameterized.expand([((8, 8, 8), 3), ((8, 8), 2)]) + def test_no_channel_spatial_ndim(self, shape, expected): + affine = np.eye(4) + affine[:3, 3] = [10.0, 20.0, 30.0] + im = MetaTensor(torch.zeros(shape), affine=affine) + result = EnsureChannelFirstd("img", channel_dim="no_channel")({"img": im}) + self.assertEqual(result["img"].shape, (1, *shape)) + self.assertEqual(result["img"].spatial_ndim, expected) + + def test_no_channel_crop_affine(self): + affine = np.eye(4) + affine[:3, 3] = [10.0, 20.0, 30.0] + im = MetaTensor(torch.zeros((8, 8, 8)), affine=affine) + data = EnsureChannelFirstd("img", channel_dim="no_channel")({"img": im}) + data = SpatialCropd("img", roi_start=[1, 2, 3], roi_end=[6, 6, 6])(data) + np.testing.assert_allclose(data["img"].affine[:3, 3], [11.0, 22.0, 33.0]) + if __name__ == "__main__": unittest.main() From 8159218b07ca7a3b18b47b05241f7f1cc0c21076 Mon Sep 17 00:00:00 2001 From: Sebastian Penhouet <7819068+Spenhouet@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:43:50 +0200 Subject: [PATCH 2/2] Add docstrings for EnsureChannelFirst.__call__ and its tests Signed-off-by: Sebastian Penhouet <7819068+Spenhouet@users.noreply.github.com> --- monai/transforms/utility/array.py | 10 ++++++++++ tests/transforms/test_ensure_channel_firstd.py | 3 +++ 2 files changed, 13 insertions(+) diff --git a/monai/transforms/utility/array.py b/monai/transforms/utility/array.py index 0414138016f..b12b6c92894 100644 --- a/monai/transforms/utility/array.py +++ b/monai/transforms/utility/array.py @@ -197,6 +197,16 @@ def __init__(self, strict_check: bool = True, channel_dim: None | str | int = No def __call__(self, img: torch.Tensor, meta_dict: Mapping | None = None) -> torch.Tensor: """ Apply the transform to `img`. + + If the input has no channel dim, a channel dim is added and the ``spatial_ndim`` of the + output ``MetaTensor`` is derived again from its affine. + + Args: + img: the input image. + meta_dict: metadata dictionary of `img`, used when `img` is not a ``MetaTensor``. + + Returns: + the image with the channel dim first. """ if not isinstance(img, MetaTensor) and not isinstance(meta_dict, Mapping): if self.input_channel_dim is None: diff --git a/tests/transforms/test_ensure_channel_firstd.py b/tests/transforms/test_ensure_channel_firstd.py index 60f39e2d1bd..ab066577975 100644 --- a/tests/transforms/test_ensure_channel_firstd.py +++ b/tests/transforms/test_ensure_channel_firstd.py @@ -75,6 +75,7 @@ def test_exceptions(self): EnsureChannelFirstd("img", strict_check=False, channel_dim=None)({"img": im_nodim}) def test_default_channel_first(self): + """A channel-less tensor gets a leading channel dim.""" im = torch.rand(4, 4) result = EnsureChannelFirstd("img", channel_dim="no_channel")({"img": im}) @@ -82,6 +83,7 @@ def test_default_channel_first(self): @parameterized.expand([((8, 8, 8), 3), ((8, 8), 2)]) def test_no_channel_spatial_ndim(self, shape, expected): + """spatial_ndim follows the affine after a channel dim is added to channel-less input.""" affine = np.eye(4) affine[:3, 3] = [10.0, 20.0, 30.0] im = MetaTensor(torch.zeros(shape), affine=affine) @@ -90,6 +92,7 @@ def test_no_channel_spatial_ndim(self, shape, expected): self.assertEqual(result["img"].spatial_ndim, expected) def test_no_channel_crop_affine(self): + """A crop after adding a channel dim shifts the affine origin on all spatial axes.""" affine = np.eye(4) affine[:3, 3] = [10.0, 20.0, 30.0] im = MetaTensor(torch.zeros((8, 8, 8)), affine=affine)