From 9f2f59b4cf1508ba40d7faaa1262c1e588d15da1 Mon Sep 17 00:00:00 2001 From: moonbirdyang Date: Sun, 18 Oct 2026 19:13:39 +0800 Subject: [PATCH 1/2] Fix NibabelWriter spatial rank inference for channel-less arrays Assisted-by: OpenAI Codex Signed-off-by: moonbirdyang --- monai/data/image_writer.py | 10 ++++++---- monai/transforms/spatial/functional.py | 7 +++++-- tests/data/test_nifti_rw.py | 15 +++++++++++++++ tests/transforms/test_spatial_resample.py | 8 ++++++++ 4 files changed, 34 insertions(+), 6 deletions(-) diff --git a/monai/data/image_writer.py b/monai/data/image_writer.py index cc6cdcdead1..8d62b95994d 100644 --- a/monai/data/image_writer.py +++ b/monai/data/image_writer.py @@ -266,12 +266,14 @@ def resample_if_needed( The output data type of this method is always ``np.float32``. """ orig_type = type(data_array) - data_array = convert_to_tensor(data_array, track_meta=True) - if affine is not None: - data_array.affine = convert_to_tensor(affine, track_meta=False) # type: ignore + # Add the channel dimension before inferring the spatial rank from the affine. + data_array = convert_to_tensor(data_array[None], track_meta=True) + data_array.affine = ( + convert_to_tensor(affine, track_meta=False) if affine is not None else data_array.affine + ) # type: ignore resampler = SpatialResample(mode=mode, padding_mode=padding_mode, align_corners=align_corners, dtype=dtype) output_array = resampler( - data_array[None], dst_affine=target_affine, spatial_size=output_spatial_shape # type: ignore + data_array, dst_affine=target_affine, spatial_size=output_spatial_shape # type: ignore ) # convert back at the end if isinstance(output_array, MetaTensor): diff --git a/monai/transforms/spatial/functional.py b/monai/transforms/spatial/functional.py index c44d979927e..6dd4cfdf95c 100644 --- a/monai/transforms/spatial/functional.py +++ b/monai/transforms/spatial/functional.py @@ -153,8 +153,11 @@ def spatial_resample( in_spatial_size = torch.tensor(original_spatial_shape[:spatial_rank]) if isinstance(spatial_size, int) and (spatial_size == -1): # using the input spatial size spatial_size = in_spatial_size - elif spatial_size is None and spatial_rank > 1: # auto spatial size - spatial_size, _ = compute_shape_offset(in_spatial_size, src_affine, dst_affine) # type: ignore + elif spatial_size is None: + if spatial_rank > 1: # auto spatial size + spatial_size, _ = compute_shape_offset(in_spatial_size, src_affine, dst_affine) # type: ignore + else: + spatial_size = in_spatial_size spatial_size = torch.tensor( fall_back_tuple(ensure_tuple(spatial_size)[:spatial_rank], in_spatial_size, lambda x: x >= 0) ) diff --git a/tests/data/test_nifti_rw.py b/tests/data/test_nifti_rw.py index cd75bef93d7..4aa61ea3db1 100644 --- a/tests/data/test_nifti_rw.py +++ b/tests/data/test_nifti_rw.py @@ -249,6 +249,21 @@ def test_write_5d(self): ) np.testing.assert_allclose(out.affine, np.diag([1.4, 2, 2, 1]), atol=1e-4, rtol=1e-4) + @parameterized.expand([((64, 64),), ((8, 64, 64),)]) + def test_write_without_original_affine_and_spatial_shape(self, shape): + data = np.arange(np.prod(shape), dtype=np.float32).reshape(shape) + with tempfile.TemporaryDirectory() as out_dir: + image_name = os.path.join(out_dir, "test.nii") + writer_obj = NibabelWriter(output_dtype=np.float32) + writer_obj.set_data_array(data, channel_dim=None, spatial_ndim=None) + writer_obj.set_metadata({"affine": np.eye(4), "original_affine": None}) + writer_obj.write(image_name) + + saved = nib.load(image_name) + self.assertEqual(saved.shape, data.shape) + np.testing.assert_array_equal(saved.get_fdata(), data) + np.testing.assert_allclose(saved.affine, np.eye(4)) + if __name__ == "__main__": unittest.main() diff --git a/tests/transforms/test_spatial_resample.py b/tests/transforms/test_spatial_resample.py index becd909048d..b45aac59174 100644 --- a/tests/transforms/test_spatial_resample.py +++ b/tests/transforms/test_spatial_resample.py @@ -222,6 +222,14 @@ def test_inverse(self, img, device, data_param, expected_output): expected_affine = to_affine_nd(len(out.affine) - 1, torch.eye(4)) assert_allclose(out.affine, expected_affine) + @parameterized.expand([(False, None), (False, -1), (True, None), (True, -1)]) + def test_1d_unchanged(self, lazy, spatial_size): + img = MetaTensor(torch.arange(8, dtype=torch.float32).reshape(1, 8)) + result = SpatialResample(lazy=lazy)(img, spatial_size=spatial_size) + assert_allclose(result, img) + assert_allclose(result.peek_pending_affine(), img.affine) + self.assertEqual(tuple(result.peek_pending_shape()), (8,)) + def test_unchange(self): for i, p in enumerate(TEST_NDARRAYS_ALL): set_track_meta(i % 2) From d3138f7a6977459c0264f367d19b6f14d8ae21bc Mon Sep 17 00:00:00 2001 From: moonbirdyang Date: Sun, 18 Oct 2026 23:48:21 +0800 Subject: [PATCH 2/2] Document regression test parameters Assisted-by: OpenAI Codex Signed-off-by: moonbirdyang --- tests/data/test_nifti_rw.py | 5 +++++ tests/transforms/test_spatial_resample.py | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/tests/data/test_nifti_rw.py b/tests/data/test_nifti_rw.py index 4aa61ea3db1..47370b284eb 100644 --- a/tests/data/test_nifti_rw.py +++ b/tests/data/test_nifti_rw.py @@ -251,6 +251,11 @@ def test_write_5d(self): @parameterized.expand([((64, 64),), ((8, 64, 64),)]) def test_write_without_original_affine_and_spatial_shape(self, shape): + """Verify channel-less NIfTI writing preserves shape, voxel values, and affine without target metadata. + + Args: + shape: Shape of the channel-less 2D or 3D input array. + """ data = np.arange(np.prod(shape), dtype=np.float32).reshape(shape) with tempfile.TemporaryDirectory() as out_dir: image_name = os.path.join(out_dir, "test.nii") diff --git a/tests/transforms/test_spatial_resample.py b/tests/transforms/test_spatial_resample.py index ea0c091235b..5b70c92a6fa 100644 --- a/tests/transforms/test_spatial_resample.py +++ b/tests/transforms/test_spatial_resample.py @@ -224,6 +224,12 @@ def test_inverse(self, img, device, data_param, expected_output): @parameterized.expand([(False, None), (False, -1), (True, None), (True, -1)]) def test_1d_unchanged(self, lazy, spatial_size): + """Verify rank-one no-op resampling preserves values, pending shape, and affine. + + Args: + lazy: Whether to record the resampling lazily instead of executing eagerly. + spatial_size: Output size setting, either None or -1, expected to preserve the input size. + """ img = MetaTensor(torch.arange(8, dtype=torch.float32).reshape(1, 8)) result = SpatialResample(lazy=lazy)(img, spatial_size=spatial_size) assert_allclose(result, img)