From 354732366431ea36f21538ae95a8dfd8719d437d Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Thu, 6 Aug 2026 15:06:22 -0400 Subject: [PATCH 01/13] Add to_monai and from_monai methods --- pyproject.toml | 1 + src/highdicom/volume.py | 180 ++++++++++++++++++++++++++++++++++++++++ tests/utils.py | 1 + 3 files changed, 182 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9e45200f..7de06911 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ libjpeg = [ itk = ["itk>=5.4.0"] simpleitk = ["SimpleITK>=2.2.1"] nibabel = ["nibabel>=4.0.0"] +monai = ["monai>=1.3.0"] [dependency-groups] test = [ diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index ac8f294f..b18189ec 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -4241,6 +4241,186 @@ def from_nibabel( from_reference_convention='RAS' ) + def to_monai( + self, + space: str = 'RAS', + ensure_channel_first: bool = False, + ) -> 'monai.data.MetaTensor': # noqa: F821 + """Convert the volume to a ``monai.data.MetaTensor``. + + This method requires an optional dependency to be installed + separately from highdicom, specifically ``monai``. + + The Volume is converted to a ``monai.data.Metatensor``. Spatial metadata + is preserved through the affine array. By default, metatensors use "RAS" + convention, however they can support "LPS" similar to highdicom if + specified. + + Parameters + ---------- + space: str + Space convention used by the metatensor. Defaults to 'RAS'. + ensure_channel_first: bool + Whether to convert to a channel first metatensor. Defaults to False. + + Returns + ------- + monai.data.MetaTensor: + MetaTensor constructed from the volume. + + Raises + ------ + ValueError + When the volume has more than one channel dimension. + + """ + func = self.to_monai + monai = import_optional_dependency( + module_name='monai', + feature=f'{func.__module__}.{func.__qualname__}' + ) + + ImageStatsKeys = monai.utils.enums.ImageStatsKeys + MetaKeys = monai.utils.enums.MetaKeys + + if self.number_of_channel_dimensions > 1: + raise ValueError( + 'Monai conversion does not currently support' + ' volumes with multiple channel dimensions.' + ) + + meta = {} + space = monai.utils.enums.SpaceKeys(space) + affine = self.get_affine(space.value) + + meta[MetaKeys.SPACE] = space + meta[ImageStatsKeys.SPACING] = self.spacing + meta[MetaKeys.SPATIAL_SHAPE] = self.shape[:3] + meta[MetaKeys.ORIGINAL_AFFINE] = affine.copy() + meta[MetaKeys.AFFINE] = affine.copy() + meta[MetaKeys.ORIGINAL_CHANNEL_DIM] = ( + -1 if self.ndim > 3 else float("nan") + ) + + metatensor = monai.data.MetaTensor( + self.array.copy(), + meta=meta + ) + + if ensure_channel_first: + metatensor = monai.transforms.EnsureChannelFirst()(metatensor) + + return metatensor + + @classmethod + def from_monai( + cls, + metatensor: 'monai.data.MetaTensor', # noqa: F821 + coordinate_system: CoordinateSystemNames | str = 'PATIENT', + frame_of_reference_uid: str | None = None, + channels: dict[ + BaseTag | int | str | ChannelDescriptor, + Sequence[int | str | float | Enum] + ] | None = None, + channel_dim: int = 0 + ) -> Self: + """Construct a Volume from a ``monai.data.MetaTensor``. + + This method requires an optional dependency to be installed + separately from highdicom, specifically ``monai``. + + The ``monai.data.MetaTensor`` is converted to a 3D Volume. + Spatial metadata is preserved through the affine array. By default, + metatensors use "RAS" convention, however they can support "LPS" similar + to highdicom. For correct conversions, ensure that the metatensor's + metadata specifies the appropriate convention through the space key + (``monai.utils.enums.SPACE``). + + Parameters + ---------- + metatensor: monai.data.MetaTensor + A ``monai.data.MetaTensor`` to convert to a volume. + coordinate_system: highdicom.CoordinateSystemNames | str + Coordinate system (``"PATIENT"`` or ``"SLIDE"``) in which the volume + is defined. + frame_of_reference_uid: Union[str, None], optional + Frame of reference UID for the frame of reference, if known. + channels: dict[int | str | ChannelDescriptor, Sequence[int | str | float | Enum]] | None, optional + Specification of channels of the array. Channels are additional + dimensions of the array beyond the three spatial dimensions. For + each such additional dimension (if any), an item in this dictionary + is required to specify the meaning. The dictionary key specifies + the meaning of the dimension, which must be either an instance of + highdicom.ChannelDescriptor, specifying a DICOM tag whose attribute + describes the channel, a a DICOM keyword describing a DICOM + attribute, or an integer representing the tag of a DICOM attribute. + The corresponding item of the dictionary is a sequence giving the + value of the relevant attribute at each index in the array. The + insertion order of the dictionary is significant as it is used to + match items to the corresponding dimensions of the array (the first + item in the dictionary corresponds to axis 3 of the array and so + on). + channel_dim: int + Channel dimension index of the metatensor. Defaults to 0. + + Returns + ------- + highdicom.Volume: + Volume constructed from the Nibabel image. + + Raises + ------ + ValueError + When the metatensor has more than 4 dimensions (multiple channel + dimensions are unsupported). + ValueError + When there are multiple channels and the `channels` argument is + not provided. + + """ + func = cls.from_monai + monai = import_optional_dependency( + module_name='monai', + feature=f'{func.__module__}.{func.__qualname__}' + ) + + array = metatensor.cpu().detach().numpy() + + if array.ndim > 3: + if array.ndim > 4: + raise ValueError( + 'Monai conversion does not currently support' + ' volumes with multiple channel dimensions.' + ) + + channel_dim = channel_dim % array.ndim + channel_last_perm = [ + i for i in range(array.ndim) if i != channel_dim + ] + [channel_dim] + array = array.transpose(channel_last_perm) + + if channels is None: + if array.shape[-1] == 1: + array = array.squeeze(-1) + + else: + raise ValueError( + 'Monai conversion requires `channels` be specified' + ' for volumes with >=2 channels.' + ) + + return cls( + array=array, + affine=metatensor.affine.cpu().detach().numpy(), + coordinate_system=coordinate_system, + frame_of_reference_uid=frame_of_reference_uid, + channels=channels, + from_reference_convention=metatensor.meta.get( + monai.utils.enums.MetaKeys.SPACE, + monai.utils.enums.SpaceKeys.RAS + ).value + ) + class VolumeToVolumeTransformer: diff --git a/tests/utils.py b/tests/utils.py index 31a4f2c0..dfdb55c5 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -19,6 +19,7 @@ DCM_QA_MPRAGE = 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main' # noqa: E501 DCM_QA_ME = 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master' DCM_QA_PDT2 = 'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main' +TEST_DATA = Path(__file__).parent.parent / "data" / "test_files" def write_and_read_dataset(dataset: Dataset): From 1c2ecb006898921243e073cedc514b55bd777c79 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Thu, 6 Aug 2026 15:32:51 -0400 Subject: [PATCH 02/13] Add monai tests --- tests/test_monai.py | 689 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 689 insertions(+) create mode 100644 tests/test_monai.py diff --git a/tests/test_monai.py b/tests/test_monai.py new file mode 100644 index 00000000..6074bee9 --- /dev/null +++ b/tests/test_monai.py @@ -0,0 +1,689 @@ +import numpy as np +import tempfile +import pytest +import pydicom +import zipfile + + +from pathlib import Path +from typing import Sequence +from highdicom import Volume, ChannelDescriptor, get_volume_from_series +from highdicom.spatial import ( + get_closest_patient_orientation, + convert_affine_to_convention +) +from highdicom._dependency_utils import import_optional_dependency +from highdicom.seg import segread +from .utils import ( + DCM_QA_MPRAGE, + DCM_QA_ME, + DCM_QA_PDT2, + TEST_DATA, + read_multiframe_ct_volume, + read_ct_series_volume, + urldownload_with_retry +) + + +try: + monai = import_optional_dependency('monai', feature='monai tests') + ImageStatsKeys = monai.utils.enums.ImageStatsKeys + MetaKeys = monai.utils.enums.MetaKeys + +except Exception: + pytest.skip("Optional dependency not available", allow_module_level=True) + + +def read_github_zip_volume_and_metatensor(url: str): + with tempfile.TemporaryDirectory() as temp_dir: + zipfilename = Path(temp_dir) / Path(url).name + urldownload_with_retry(url, zipfilename) + + with zipfile.ZipFile(zipfilename, 'r') as zf: + zf.extractall(temp_dir) + + series = [pydicom.dcmread(f) for f in Path(temp_dir).glob('**/*.dcm')] + + metatensor = monai.transforms.LoadImage(reader="ITKReader")( + Path(str(zipfilename)[:-4]) + ) + + return get_volume_from_series(series), series, metatensor + + +def read_github_series_volume_and_metatensor(urls: Sequence[str]): + series = [] + with tempfile.TemporaryDirectory() as temp_dir: + for url in urls: + filename = Path(temp_dir) / Path(url).name + urldownload_with_retry(url, filename) + + series.append(pydicom.dcmread(filename)) + + metatensor = monai.transforms.LoadImage(reader="ITKReader")(temp_dir) + + return get_volume_from_series(series), series, metatensor + + +@pytest.mark.parametrize( + 'vol', + [ + # testdata_files + read_multiframe_ct_volume(), + read_ct_series_volume(), + # different orientations + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ).to_patient_orientation('RAF'), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ).to_patient_orientation('RAH'), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ).to_patient_orientation('RPF'), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ).to_patient_orientation('RPH'), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ).to_patient_orientation('LAF'), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ).to_patient_orientation('LAH'), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ).to_patient_orientation('LPF'), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ).to_patient_orientation('LPH'), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ).to_patient_orientation('HLP'), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ).to_patient_orientation('FPR'), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ).to_patient_orientation('HRP'), + # isotropic + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[0.5, 0.5], + spacing_between_slices=0.5, + coordinate_system='PATIENT' + ), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[2.0, 2.0], + spacing_between_slices=2.0, + coordinate_system='PATIENT' + ), + # anisotropic + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[0.5, 0.5], + spacing_between_slices=2.0, + coordinate_system='PATIENT' + ), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[2.0, 0.5], + spacing_between_slices=0.5, + coordinate_system='PATIENT' + ), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[0.5, 2.0], + spacing_between_slices=0.5, + coordinate_system='PATIENT' + ), + # non-square + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 32, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ), + Volume.from_attributes( + array=np.random.randint(0, 100, (64, 128, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ), + # single-slice + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 1)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 1, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ), + Volume.from_attributes( + array=np.random.randint(0, 100, (1, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ), + # random position offset + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[84.40363858, 105.04467386, 143.73326388], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[-21.03512292, 35.19549233, -184.42393696], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[-197.36060235, 86.22231644, -14.79874245], + image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ), + # random orientation + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[ + -0.9267662161157189, + 0.32283606313442387, + -0.1920449348627007, + -0.3751482085550474, + -0.7693372329674889, + 0.5170919101937937 + ], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[ + -0.16411694786392106, + -0.7887835415036736, + 0.5923564400567902, + 0.9859501024515502, + -0.11222667947664411, + 0.12372375636644917 + ], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ), + Volume.from_attributes( + array=np.random.randint(0, 100, (16, 16, 16)), + image_position=[0.0, 0.0, 0.0], + image_orientation=[ + -0.4161787354389772, + 0.440351232633788, + 0.7955413578729377, + -0.2771687765466385, + -0.8947097563255662, + 0.350245515665062 + ], + pixel_spacing=[1.0, 1.0], + spacing_between_slices=1.0, + coordinate_system='PATIENT' + ), + # entirely random + Volume.from_attributes( + array=np.random.randint(113, 257, (192, 249, 84)), + image_position=[156.03935104, -57.61106994, -108.37601079], + image_orientation=[ + -0.3056572521325831, + -0.9434667295206645, + -0.12823484123411946, + 0.9440777770580763, + -0.3177984972478506, + 0.08787073467366081 + ], + pixel_spacing=[3.34201481, 2.35548103], + spacing_between_slices=2.82618053, + coordinate_system='PATIENT' + ), + Volume.from_attributes( + array=np.random.randint(180, 214, (96, 121, 50)), + image_position=[93.43769804, -184.44672839, -153.64700033], + image_orientation=[ + -0.7034764816836532, + 0.05445328347346446, + -0.7086294374614612, + -0.24703727011677554, + -0.9536256538386763, + 0.1719613314498601 + ], + pixel_spacing=[2.84370598, 0.69898499], + spacing_between_slices=0.57265037, + coordinate_system='PATIENT' + ), + Volume.from_attributes( + array=np.random.randint(81, 214, (34, 123, 59)), + image_position=[-252.23051789, 146.90528128, 84.40363858], + image_orientation=[ + -0.7374895773326877, + 0.5716640328411087, + 0.35959610242811746, + -0.35709313761636013, + -0.7820074118766409, + 0.510831575802926 + ], + pixel_spacing=[3.52582689, 3.90364516], + spacing_between_slices=2.29457888, + coordinate_system='PATIENT' + ), + + ] +) +def test_roundtrip(vol: Volume): + metatensor = vol.to_monai() + + assert np.allclose(vol.get_affine('RAS'), metatensor.affine, atol=1e-4) + assert (vol.array == metatensor.numpy()).all() + + monai_roundtrip = Volume.from_monai(metatensor) + + assert np.allclose(vol.affine, monai_roundtrip.affine, atol=1e-4) + assert (vol.array == monai_roundtrip.array).all() + + +@pytest.mark.parametrize( + 'zip_url', + [ + f'{DCM_QA_MPRAGE}/In/2_t1_mp2rage_sag_p3_32.zip', + f'{DCM_QA_MPRAGE}/In/5_HCP_T1.zip', + ] +) +def test_metatensor_equivalence_zip(zip_url: str): + vol, series, metatensor = read_github_zip_volume_and_metatensor(zip_url) + + orientation = get_closest_patient_orientation( + convert_affine_to_convention( + metatensor.affine.numpy(), + from_reference_convention='RAS', + to_reference_convention='LPS' + ) + ) + oriented_vol = vol.to_patient_orientation(orientation) + + assert np.allclose( + oriented_vol.get_affine('RAS'), + metatensor.affine.numpy(), + atol=1e-4 + ) + assert (oriented_vol.array == metatensor.numpy()).all() + + +@pytest.mark.parametrize( + 'dcm_urls', + [ + [ + f'{DCM_QA_ME}/In/2_me_FieldMap_GRE/{i:04d}.dcm' + for i in range(1, 37) + ], + [ + f'{DCM_QA_PDT2}/In/Siemens/VE11/{i:04d}.dcm' + for i in range(1, 36) + ], + ] +) +def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): + vol, series, metatensor = read_github_series_volume_and_metatensor(dcm_urls) + + orientation = get_closest_patient_orientation( + convert_affine_to_convention( + metatensor.affine.numpy(), + from_reference_convention='RAS', + to_reference_convention='LPS' + ) + ) + oriented_vol = vol.to_patient_orientation(orientation) + + assert np.allclose( + oriented_vol.get_affine('RAS'), + metatensor.affine.numpy(), + atol=1e-4 + ) + assert (oriented_vol.array == metatensor.numpy()).all() + + +@pytest.mark.parametrize( + 'segfile,channel_first,space,spacing,spatial_shape,affine,sum', + [ + [ + 'seg_image_sm_control.dcm', + True, + 'RAS', + (1.0, 0.000499, 0.000499), + (1, 50, 50), + np.array([[0., 4.99e-04, 0., -23.449374], + [0., 0., 4.99e-04, -25.691075], + [1., 0., 0., 1.01], + [0., 0., 0., 1.]]), + 523 + ], + [ + 'seg_image_sm_dots_tiled_full.dcm', + False, + 'RAS', + (1.0, 0.000499, 0.000499), + (1, 50, 50), + np.array([[0., 4.99e-04, 0., -23.449873], + [0., 0., 4.99e-04, -25.691574], + [1., 0., 0., 0.], + [0., 0., 0., 1.]]), + 200 + ], + [ + 'seg_image_ct_true_fractional.dcm', + True, + 'LPS', + (1.25, 0.488281, 0.488281), + (3, 16, 16), + np.array([[0., 0., 0.488281, -125.], + [0., 0.488281, 0., -128.100006], + [-1.25, 0., 0., 105.519997], + [0., 0., 0., 1.]]), + 326.02353 + ], + [ + 'seg_image_ct_binary_overlap.dcm', + False, + 'LPS', + (1.25, 0.488281, 0.488281), + (165, 16, 16), + np.array([[0., 0., 0.488281, -125.], + [0., 0.488281, 0., -128.100006], + [-1.25, 0., 0., 105.519997], + [0., 0., 0., 1.]]), + 80 + ], + [ + 'seg_image_sm_numbers.dcm', + True, + 'RAS', + (1.0, 0.000499, 0.000499), + (1, 50, 50), + np.array([[0., 4.99e-04, 0., -23.449374], + [0., 0., 4.99e-04, -25.691075], + [1., 0., 0., 1.01], + [0., 0., 0., 1.]]), + 523 + ], + [ + 'seg_image_ct_binary_fractional.dcm', + True, + 'RAS', + (1.25, 0.488281, 0.488281), + (3, 16, 16), + np.array([[0., 0., -0.488281, 125.], + [0., -0.488281, 0., 128.100006], + [-1.25, 0., 0., 105.519997], + [0., 0., 0., 1.]]), + 638.0 + ], + [ + 'seg_image_ct_binary_single_frame.dcm', + True, + 'RAS', + (5.0, 0.661468, 0.661468), + (1, 128, 128), + np.array([[0., 0., -0.661468, 158.135803], + [0., -0.661468, 0., 179.035797], + [-5., 0., 0., -75.699997], + [0., 0., 0., 1.]]), + 1832 + ], + [ + 'seg_image_sm_dots.dcm', + True, + 'RAS', + (1.0, 0.000499, 0.000499), + (1, 50, 50), + np.array([[0., 4.99e-04, 0., -23.449374], + [0., 0., 4.99e-04, -25.691075], + [1., 0., 0., 1.01], + [0., 0., 0., 1.]]), + 200 + ], + [ + 'seg_image_sm_control_labelmap.dcm', + True, + 'RAS', + (1.0, 0.000499, 0.000499), + (1, 50, 50), + np.array([[0., 4.99e-04, 0., -23.449873], + [0., 0., 4.99e-04, -25.691574], + [1., 0., 0., 0.], + [0., 0., 0., 1.]]), + 523 + ], + [ + 'seg_image_sm_control_labelmap_palette_color.dcm', + True, + 'RAS', + (1.0, 0.000499, 0.000499), + (1, 50, 50), + np.array([[0., 4.99e-04, 0., -23.449873], + [0., 0., 4.99e-04, -25.691574], + [1., 0., 0., 0.], + [0., 0., 0., 1.]]), + 523 + ], + [ + 'seg_image_ct_binary.dcm', + True, + 'RAS', + (1.25, 0.488281, 0.488281), + (3, 16, 16), + np.array([[0., 0., -0.488281, 125.], + [0., -0.488281, 0., 128.100006], + [-1.25, 0., 0., 105.519997], + [0., 0., 0., 1.]]), + 638 + ] + ] +) +def test_segmentation( + segfile, + channel_first, + space, + spacing, + spatial_shape, + affine, + sum +): + seg = segread(TEST_DATA / segfile) + vol = seg.get_volume() + metatensor = vol.to_monai( + ensure_channel_first=channel_first, + space=space + ) + meta = metatensor.meta + + assert meta[MetaKeys.SPACE] == space + assert vol.spacing == meta[ImageStatsKeys.SPACING] == spacing + assert vol.shape[:3] == meta[MetaKeys.SPATIAL_SHAPE] == spatial_shape + assert ( + metatensor.shape[1:] if channel_first else metatensor.shape[:3] == + spatial_shape + ) + assert np.allclose(vol.get_affine(space), affine, atol=1e-4) + assert np.allclose(metatensor.affine, affine, atol=1e-4) + assert np.allclose(meta[MetaKeys.ORIGINAL_AFFINE], affine, atol=1e-4) + assert np.allclose(meta[MetaKeys.AFFINE], affine, atol=1e-4) + assert meta[MetaKeys.ORIGINAL_CHANNEL_DIM] == -1 + assert vol.array.sum() == metatensor.numpy().sum() == sum + + +def test_multichannel_volume(): + array = np.zeros((10, 10, 10, 2)) + volume = Volume.from_attributes( + array=array, + image_position=(0.0, 0.0, 0.0), + image_orientation=(1.0, 0.0, 0.0, 0.0, 1.0, 0.0), + pixel_spacing=(1.0, 1.0), + spacing_between_slices=2.0, + channels={'OpticalPathIdentifier': ['path1', 'path2']}, + coordinate_system="PATIENT", + ) + + volume.to_monai() + + array = np.zeros((10, 10, 10, 1, 1)) + volume = Volume.from_attributes( + array=array, + image_position=(0.0, 0.0, 0.0), + image_orientation=(1.0, 0.0, 0.0, 0.0, 1.0, 0.0), + pixel_spacing=(1.0, 1.0), + spacing_between_slices=2.0, + channels={ + ChannelDescriptor('Channel0', True, str): ['class0'], + ChannelDescriptor('Channel1', True, str): ['class1'] + }, + coordinate_system="PATIENT", + ) + + with pytest.raises( + ValueError, + match=( + 'Monai conversion does not currently support' + ' volumes with multiple channel dimensions.' + ) + ): + volume.to_monai() + + array = np.zeros((1, 10, 10, 10)) + metatensor = monai.data.MetaTensor(array) + Volume.from_monai(metatensor) + + array = np.zeros((10, 10, 10, 1)) + metatensor = monai.data.MetaTensor(array) + Volume.from_monai(metatensor, channel_dim=-1) + + array = np.zeros((1, 10, 10, 10)) + metatensor = monai.data.MetaTensor(array) + Volume.from_monai( + metatensor, + channels={'OpticalPathIdentifier': ['path1']} + ) + + array = np.zeros((2, 10, 10, 10)) + metatensor = monai.data.MetaTensor(array) + Volume.from_monai( + metatensor, + channels={'OpticalPathIdentifier': ['path1', 'path2']} + ) + + array = np.zeros((1, 1, 10, 10, 10)) + metatensor = monai.data.MetaTensor(array) + + with pytest.raises( + ValueError, + match=( + 'Monai conversion does not currently support' + ' volumes with multiple channel dimensions.' + ) + ): + Volume.from_monai(metatensor) + + array = np.zeros((2, 10, 10, 10)) + metatensor = monai.data.MetaTensor(array) + + with pytest.raises( + ValueError, + match=( + 'Monai conversion requires `channels` be specified' + ' for volumes with >=2 channels.' + ) + ): + Volume.from_monai(metatensor) From 37c641705b1595e72ddf02d3a84a74cb7fef6d23 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 28 Aug 2026 09:53:00 -0400 Subject: [PATCH 03/13] Change monai conversion spacing argument, update documentation --- docs/monai_lib.rst | 50 ++++++++++++++++++++++++++++++++++++++++ docs/other_libraries.rst | 1 + src/highdicom/volume.py | 15 ++++++++---- tests/test_monai.py | 34 ++++++++++++++++----------- 4 files changed, 81 insertions(+), 19 deletions(-) create mode 100644 docs/monai_lib.rst diff --git a/docs/monai_lib.rst b/docs/monai_lib.rst new file mode 100644 index 00000000..32b5dd43 --- /dev/null +++ b/docs/monai_lib.rst @@ -0,0 +1,50 @@ +.. _monai_lib: + +MONAI +===== + +`MONAI`_ is a widely-used library for training and evaluating models on medical imaging +data. In order to use MONAI with highdicom, the ``monai`` python package must be +installed separately. Version 1.3.0 or later is required. + +.. _monai_vol: + +Volume Conversions +------------------ + +Highdicom supports conversions with the ``monai.data.MetaTensor`` class through the +:meth:`highdicom.Volume.to_monai` and :meth:`highdicom.Volume.from_monai` methods. +Unlike highdicom, which exclusively uses the "LPS" convention, MONAI supports both +"RAS" and "LPS" MetaTensors. By default, highdicom converts to the "RAS" convention. + +Creating an MONAI MetaTensor from a Volume: + +.. code-block:: python + + import highdicom as hd + + + vol = hd.Volume(...) + + metatensor = vol.to_monai() + +Creating a volume from a MONAI MetaTensor: + +.. code-block:: python + + import monai + import highdicom as hd + + + metatensor = monai.data.MetaTensor(...) + + vol = hd.Volume.from_monai( + metatensor=metatensor, + coordinate_system='PATIENT', + frame_of_reference_uid=None, + channels=None, + channel_dim=None + ) + + +.. _`MONAI`: https://project-monai.github.io/ diff --git a/docs/other_libraries.rst b/docs/other_libraries.rst index 566dabd8..5a6ddb70 100644 --- a/docs/other_libraries.rst +++ b/docs/other_libraries.rst @@ -23,3 +23,4 @@ library enables. itk_lib sitk_lib nib_lib + monai_lib diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index b18189ec..93ce4e7f 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -4243,7 +4243,7 @@ def from_nibabel( def to_monai( self, - space: str = 'RAS', + convert_to_ras: bool = True, ensure_channel_first: bool = False, ) -> 'monai.data.MetaTensor': # noqa: F821 """Convert the volume to a ``monai.data.MetaTensor``. @@ -4258,8 +4258,8 @@ def to_monai( Parameters ---------- - space: str - Space convention used by the metatensor. Defaults to 'RAS'. + convert_to_ras: bool + Whether to conver from 'LPS' to 'RAS' convention. Defaults to True. ensure_channel_first: bool Whether to convert to a channel first metatensor. Defaults to False. @@ -4290,7 +4290,12 @@ def to_monai( ) meta = {} - space = monai.utils.enums.SpaceKeys(space) + if convert_to_ras: + space = monai.utils.enums.SpaceKeys.RAS + + else: + space = monai.utils.enums.SpaceKeys.LPS + affine = self.get_affine(space.value) meta[MetaKeys.SPACE] = space @@ -4366,7 +4371,7 @@ def from_monai( Returns ------- highdicom.Volume: - Volume constructed from the Nibabel image. + Volume constructed from the monai image. Raises ------ diff --git a/tests/test_monai.py b/tests/test_monai.py index 6074bee9..8e29d5d8 100644 --- a/tests/test_monai.py +++ b/tests/test_monai.py @@ -437,12 +437,12 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): @pytest.mark.parametrize( - 'segfile,channel_first,space,spacing,spatial_shape,affine,sum', + 'segfile,channel_first,convert_to_ras,spacing,spatial_shape,affine,sum', [ [ 'seg_image_sm_control.dcm', True, - 'RAS', + True, (1.0, 0.000499, 0.000499), (1, 50, 50), np.array([[0., 4.99e-04, 0., -23.449374], @@ -454,7 +454,7 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): [ 'seg_image_sm_dots_tiled_full.dcm', False, - 'RAS', + True, (1.0, 0.000499, 0.000499), (1, 50, 50), np.array([[0., 4.99e-04, 0., -23.449873], @@ -466,7 +466,7 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): [ 'seg_image_ct_true_fractional.dcm', True, - 'LPS', + False, (1.25, 0.488281, 0.488281), (3, 16, 16), np.array([[0., 0., 0.488281, -125.], @@ -478,7 +478,7 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): [ 'seg_image_ct_binary_overlap.dcm', False, - 'LPS', + False, (1.25, 0.488281, 0.488281), (165, 16, 16), np.array([[0., 0., 0.488281, -125.], @@ -490,7 +490,7 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): [ 'seg_image_sm_numbers.dcm', True, - 'RAS', + True, (1.0, 0.000499, 0.000499), (1, 50, 50), np.array([[0., 4.99e-04, 0., -23.449374], @@ -502,7 +502,7 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): [ 'seg_image_ct_binary_fractional.dcm', True, - 'RAS', + True, (1.25, 0.488281, 0.488281), (3, 16, 16), np.array([[0., 0., -0.488281, 125.], @@ -514,7 +514,7 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): [ 'seg_image_ct_binary_single_frame.dcm', True, - 'RAS', + True, (5.0, 0.661468, 0.661468), (1, 128, 128), np.array([[0., 0., -0.661468, 158.135803], @@ -526,7 +526,7 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): [ 'seg_image_sm_dots.dcm', True, - 'RAS', + True, (1.0, 0.000499, 0.000499), (1, 50, 50), np.array([[0., 4.99e-04, 0., -23.449374], @@ -538,7 +538,7 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): [ 'seg_image_sm_control_labelmap.dcm', True, - 'RAS', + True, (1.0, 0.000499, 0.000499), (1, 50, 50), np.array([[0., 4.99e-04, 0., -23.449873], @@ -550,7 +550,7 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): [ 'seg_image_sm_control_labelmap_palette_color.dcm', True, - 'RAS', + True, (1.0, 0.000499, 0.000499), (1, 50, 50), np.array([[0., 4.99e-04, 0., -23.449873], @@ -562,7 +562,7 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): [ 'seg_image_ct_binary.dcm', True, - 'RAS', + True, (1.25, 0.488281, 0.488281), (3, 16, 16), np.array([[0., 0., -0.488281, 125.], @@ -576,7 +576,7 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): def test_segmentation( segfile, channel_first, - space, + convert_to_ras, spacing, spatial_shape, affine, @@ -585,11 +585,17 @@ def test_segmentation( seg = segread(TEST_DATA / segfile) vol = seg.get_volume() metatensor = vol.to_monai( + convert_to_ras=convert_to_ras, ensure_channel_first=channel_first, - space=space ) meta = metatensor.meta + if convert_to_ras: + space = monai.utils.enums.SpaceKeys.RAS + + else: + space = monai.utils.enums.SpaceKeys.LPS + assert meta[MetaKeys.SPACE] == space assert vol.spacing == meta[ImageStatsKeys.SPACING] == spacing assert vol.shape[:3] == meta[MetaKeys.SPATIAL_SHAPE] == spatial_shape From 5aafe6131cc33c9e90891f3dfa0dc5b95459d245 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Thu, 3 Sep 2026 15:22:05 -0400 Subject: [PATCH 04/13] Fix flake8 --- src/highdicom/volume.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 93ce4e7f..eaa2838f 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -4382,7 +4382,7 @@ def from_monai( When there are multiple channels and the `channels` argument is not provided. - """ + """ # noqa: E501 func = cls.from_monai monai = import_optional_dependency( module_name='monai', From faca0ba137c2afe3c6d383e4c5199330afa5f5e6 Mon Sep 17 00:00:00 2001 From: "Mason C. Cleveland" <104479423+mccle@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:17:01 -0400 Subject: [PATCH 05/13] Update src/highdicom/volume.py Co-authored-by: Chris Bridge --- src/highdicom/volume.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index eaa2838f..b308c55c 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -4259,7 +4259,7 @@ def to_monai( Parameters ---------- convert_to_ras: bool - Whether to conver from 'LPS' to 'RAS' convention. Defaults to True. + Whether to convert the affine matrix from 'LPS' to 'RAS' convention. ensure_channel_first: bool Whether to convert to a channel first metatensor. Defaults to False. From c1fadcf68d5d777444e51b9ce566544339503717 Mon Sep 17 00:00:00 2001 From: "Mason C. Cleveland" <104479423+mccle@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:46:33 -0400 Subject: [PATCH 06/13] Update src/highdicom/volume.py --- src/highdicom/volume.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index b308c55c..46fa2889 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -4300,7 +4300,7 @@ def to_monai( meta[MetaKeys.SPACE] = space meta[ImageStatsKeys.SPACING] = self.spacing - meta[MetaKeys.SPATIAL_SHAPE] = self.shape[:3] + meta[MetaKeys.SPATIAL_SHAPE] = np.array(self.spatial_shape) meta[MetaKeys.ORIGINAL_AFFINE] = affine.copy() meta[MetaKeys.AFFINE] = affine.copy() meta[MetaKeys.ORIGINAL_CHANNEL_DIM] = ( From f474bcf82eb9ff59da66bd72c9c82c23d64f5ff1 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Thu, 10 Sep 2026 15:07:16 -0400 Subject: [PATCH 07/13] replace copy operation with contiguous array --- src/highdicom/volume.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index eaa2838f..0ed60803 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -4308,7 +4308,7 @@ def to_monai( ) metatensor = monai.data.MetaTensor( - self.array.copy(), + np.ascontiguousarray(self.array), meta=meta ) From 53b779946f16bfe1a009f0c1f0647ce5a4abed03 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Thu, 10 Sep 2026 16:33:29 -0400 Subject: [PATCH 08/13] Fix tests for spatial shape --- tests/test_monai.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_monai.py b/tests/test_monai.py index 8e29d5d8..e3f949e0 100644 --- a/tests/test_monai.py +++ b/tests/test_monai.py @@ -598,7 +598,7 @@ def test_segmentation( assert meta[MetaKeys.SPACE] == space assert vol.spacing == meta[ImageStatsKeys.SPACING] == spacing - assert vol.shape[:3] == meta[MetaKeys.SPATIAL_SHAPE] == spatial_shape + assert (np.array(vol.spatial_shape) == meta[MetaKeys.SPATIAL_SHAPE]).all() assert ( metatensor.shape[1:] if channel_first else metatensor.shape[:3] == spatial_shape From adcb46819993e688341c6f80ab513c87e3c677ac Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 11 Sep 2026 00:38:24 -0400 Subject: [PATCH 09/13] reorder channel permutation --- src/highdicom/volume.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 3fc9ce04..6c40c877 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -4389,24 +4389,22 @@ def from_monai( feature=f'{func.__module__}.{func.__qualname__}' ) - array = metatensor.cpu().detach().numpy() - - if array.ndim > 3: - if array.ndim > 4: + if metatensor.ndim > 3: + if metatensor.ndim > 4: raise ValueError( 'Monai conversion does not currently support' ' volumes with multiple channel dimensions.' ) - channel_dim = channel_dim % array.ndim + channel_dim = channel_dim % metatensor.ndim channel_last_perm = [ - i for i in range(array.ndim) if i != channel_dim + i for i in range(metatensor.ndim) if i != channel_dim ] + [channel_dim] - array = array.transpose(channel_last_perm) + metatensor = metatensor.permute(channel_last_perm) if channels is None: - if array.shape[-1] == 1: - array = array.squeeze(-1) + if metatensor.shape[-1] == 1: + metatensor = metatensor.squeeze(-1) else: raise ValueError( @@ -4414,6 +4412,8 @@ def from_monai( ' for volumes with >=2 channels.' ) + array = metatensor.cpu().detach().numpy() + return cls( array=array, affine=metatensor.affine.cpu().detach().numpy(), From 47f111aeb72c1d63a134b242a4dda1926ae2c9d5 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 18 Sep 2026 08:52:26 -0400 Subject: [PATCH 10/13] sync repo --- src/highdicom/volume.py | 15 +++++++++++++++ tests/test_monai.py | 18 ++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 6c40c877..35c14ba4 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -4244,6 +4244,7 @@ def from_nibabel( def to_monai( self, convert_to_ras: bool = True, + squeeze_dim: None | int = None, ensure_channel_first: bool = False, ) -> 'monai.data.MetaTensor': # noqa: F821 """Convert the volume to a ``monai.data.MetaTensor``. @@ -4260,6 +4261,9 @@ def to_monai( ---------- convert_to_ras: bool Whether to convert the affine matrix from 'LPS' to 'RAS' convention. + squeeze_dim: None | int + Index of a singleton dimension to squeeze, must be a spatial dimension. + Defaults to None. ensure_channel_first: bool Whether to convert to a channel first metatensor. Defaults to False. @@ -4298,6 +4302,17 @@ def to_monai( affine = self.get_affine(space.value) + keep_dims = [0, 1, 2, 3] + if squeeze_dim is not None: + if squeeze_dim not in [0, 1, 2]: + raise ValueError( + 'If provided, `squeeze_dim` must be a spatial' + ' dimension (0, 1, 2).' + ) + keep_dims.pop(squeeze_dim) + + affine = affine[np.ix_(keep_dims, keep_dims)] + meta[MetaKeys.SPACE] = space meta[ImageStatsKeys.SPACING] = self.spacing meta[MetaKeys.SPATIAL_SHAPE] = np.array(self.spatial_shape) diff --git a/tests/test_monai.py b/tests/test_monai.py index e3f949e0..16c8e9e6 100644 --- a/tests/test_monai.py +++ b/tests/test_monai.py @@ -44,9 +44,14 @@ def read_github_zip_volume_and_metatensor(url: str): series = [pydicom.dcmread(f) for f in Path(temp_dir).glob('**/*.dcm')] - metatensor = monai.transforms.LoadImage(reader="ITKReader")( - Path(str(zipfilename)[:-4]) - ) + try: + pydicom.config.enforce_valid_values = False + metatensor = monai.transforms.LoadImage(reader="PydicomReader")( + Path(str(zipfilename)[:-4]) + ) + + finally: + pydicom.config.enforce_valid_values = True return get_volume_from_series(series), series, metatensor @@ -60,7 +65,12 @@ def read_github_series_volume_and_metatensor(urls: Sequence[str]): series.append(pydicom.dcmread(filename)) - metatensor = monai.transforms.LoadImage(reader="ITKReader")(temp_dir) + try: + pydicom.config.enforce_valid_values = False + metatensor = monai.transforms.LoadImage(reader="PydicomReader")(temp_dir) + + finally: + pydicom.config.enforce_valid_values = True return get_volume_from_series(series), series, metatensor From 47a06b468b94aea812fd0ae7e72d5daecce685b1 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Tue, 22 Sep 2026 21:24:30 -0400 Subject: [PATCH 11/13] Fix monai dimension squeeze --- src/highdicom/volume.py | 41 ++++++--- tests/test_monai.py | 181 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 196 insertions(+), 26 deletions(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 35c14ba4..4a3c6ec1 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -4262,8 +4262,8 @@ def to_monai( convert_to_ras: bool Whether to convert the affine matrix from 'LPS' to 'RAS' convention. squeeze_dim: None | int - Index of a singleton dimension to squeeze, must be a spatial dimension. - Defaults to None. + Index of a singleton dimension to squeeze (must be a spatial + dimension). Defaults to None. ensure_channel_first: bool Whether to convert to a channel first metatensor. Defaults to False. @@ -4276,6 +4276,8 @@ def to_monai( ------ ValueError When the volume has more than one channel dimension. + ValueError + When squeeze_dim does not correspond to a spatial dimension. """ func = self.to_monai @@ -4301,31 +4303,48 @@ def to_monai( space = monai.utils.enums.SpaceKeys.LPS affine = self.get_affine(space.value) + array = np.ascontiguousarray(self.array) - keep_dims = [0, 1, 2, 3] + keep_cols = [i for i in range(3) if i != squeeze_dim] if squeeze_dim is not None: if squeeze_dim not in [0, 1, 2]: raise ValueError( 'If provided, `squeeze_dim` must be a spatial' ' dimension (0, 1, 2).' ) - keep_dims.pop(squeeze_dim) - affine = affine[np.ix_(keep_dims, keep_dims)] + if array.shape[squeeze_dim] != 1: + raise ValueError( + f'`squeeze_dim={squeeze_dim}` does not correspond' + ' to a singleton dimension. Array has shape:' + f' {array.shape}.' + ) + + u = affine[:, keep_cols[0]][:3] + v = affine[:, keep_cols[1]][:3] + + norm = np.cross(u, v) + drop_row = int(np.argmax(np.abs(norm))) + + affine = np.delete( + np.delete(affine, drop_row, axis=0), + squeeze_dim, + axis=1 + ) + array = array.squeeze(squeeze_dim) meta[MetaKeys.SPACE] = space - meta[ImageStatsKeys.SPACING] = self.spacing - meta[MetaKeys.SPATIAL_SHAPE] = np.array(self.spatial_shape) + meta[ImageStatsKeys.SPACING] = tuple([ + self.spacing[i] for i in keep_cols + ]) + meta[MetaKeys.SPATIAL_SHAPE] = np.array(self.spatial_shape)[keep_cols] meta[MetaKeys.ORIGINAL_AFFINE] = affine.copy() meta[MetaKeys.AFFINE] = affine.copy() meta[MetaKeys.ORIGINAL_CHANNEL_DIM] = ( -1 if self.ndim > 3 else float("nan") ) - metatensor = monai.data.MetaTensor( - np.ascontiguousarray(self.array), - meta=meta - ) + metatensor = monai.data.MetaTensor(array, meta=meta) if ensure_channel_first: metatensor = monai.transforms.EnsureChannelFirst()(metatensor) diff --git a/tests/test_monai.py b/tests/test_monai.py index 16c8e9e6..04fb9810 100644 --- a/tests/test_monai.py +++ b/tests/test_monai.py @@ -3,7 +3,7 @@ import pytest import pydicom import zipfile - +import re from pathlib import Path from typing import Sequence @@ -67,7 +67,9 @@ def read_github_series_volume_and_metatensor(urls: Sequence[str]): try: pydicom.config.enforce_valid_values = False - metatensor = monai.transforms.LoadImage(reader="PydicomReader")(temp_dir) + metatensor = monai.transforms.LoadImage( + reader="PydicomReader" + )(temp_dir) finally: pydicom.config.enforce_valid_values = True @@ -455,8 +457,8 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): True, (1.0, 0.000499, 0.000499), (1, 50, 50), - np.array([[0., 4.99e-04, 0., -23.449374], - [0., 0., 4.99e-04, -25.691075], + np.array([[0., 4.99e-4, 0., -23.449374], + [0., 0., 4.99e-4, -25.691075], [1., 0., 0., 1.01], [0., 0., 0., 1.]]), 523 @@ -467,8 +469,8 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): True, (1.0, 0.000499, 0.000499), (1, 50, 50), - np.array([[0., 4.99e-04, 0., -23.449873], - [0., 0., 4.99e-04, -25.691574], + np.array([[0., 4.99e-4, 0., -23.449873], + [0., 0., 4.99e-4, -25.691574], [1., 0., 0., 0.], [0., 0., 0., 1.]]), 200 @@ -503,8 +505,8 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): True, (1.0, 0.000499, 0.000499), (1, 50, 50), - np.array([[0., 4.99e-04, 0., -23.449374], - [0., 0., 4.99e-04, -25.691075], + np.array([[0., 4.99e-4, 0., -23.449374], + [0., 0., 4.99e-4, -25.691075], [1., 0., 0., 1.01], [0., 0., 0., 1.]]), 523 @@ -539,8 +541,8 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): True, (1.0, 0.000499, 0.000499), (1, 50, 50), - np.array([[0., 4.99e-04, 0., -23.449374], - [0., 0., 4.99e-04, -25.691075], + np.array([[0., 4.99e-4, 0., -23.449374], + [0., 0., 4.99e-4, -25.691075], [1., 0., 0., 1.01], [0., 0., 0., 1.]]), 200 @@ -551,8 +553,8 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): True, (1.0, 0.000499, 0.000499), (1, 50, 50), - np.array([[0., 4.99e-04, 0., -23.449873], - [0., 0., 4.99e-04, -25.691574], + np.array([[0., 4.99e-4, 0., -23.449873], + [0., 0., 4.99e-4, -25.691574], [1., 0., 0., 0.], [0., 0., 0., 1.]]), 523 @@ -563,8 +565,8 @@ def test_metatensor_equivalence_series(dcm_urls: Sequence[str]): True, (1.0, 0.000499, 0.000499), (1, 50, 50), - np.array([[0., 4.99e-04, 0., -23.449873], - [0., 0., 4.99e-04, -25.691574], + np.array([[0., 4.99e-4, 0., -23.449873], + [0., 0., 4.99e-4, -25.691574], [1., 0., 0., 0.], [0., 0., 0., 1.]]), 523 @@ -621,7 +623,61 @@ def test_segmentation( assert vol.array.sum() == metatensor.numpy().sum() == sum -def test_multichannel_volume(): +def test_channels(): + array = np.zeros((10, 10, 10, 1)) + volume = Volume.from_attributes( + array=array, + image_position=(0.0, 0.0, 0.0), + image_orientation=(1.0, 0.0, 0.0, 0.0, 1.0, 0.0), + pixel_spacing=(1.0, 1.0), + spacing_between_slices=2.0, + channels={'OpticalPathIdentifier': ['path1']}, + coordinate_system="PATIENT", + ) + + metatensor = volume.to_monai(ensure_channel_first=False) + assert metatensor.shape == (10, 10, 10, 1) + + array = np.zeros((10, 10, 10, 1)) + volume = Volume.from_attributes( + array=array, + image_position=(0.0, 0.0, 0.0), + image_orientation=(1.0, 0.0, 0.0, 0.0, 1.0, 0.0), + pixel_spacing=(1.0, 1.0), + spacing_between_slices=2.0, + channels={'OpticalPathIdentifier': ['path1']}, + coordinate_system="PATIENT", + ) + + metatensor = volume.to_monai(ensure_channel_first=True) + assert metatensor.shape == (1, 10, 10, 10) + + array = np.zeros((10, 10, 10)) + volume = Volume.from_attributes( + array=array, + image_position=(0.0, 0.0, 0.0), + image_orientation=(1.0, 0.0, 0.0, 0.0, 1.0, 0.0), + pixel_spacing=(1.0, 1.0), + spacing_between_slices=2.0, + coordinate_system="PATIENT", + ) + + metatensor = volume.to_monai(ensure_channel_first=False) + assert metatensor.shape == (10, 10, 10) + + array = np.zeros((10, 10, 10)) + volume = Volume.from_attributes( + array=array, + image_position=(0.0, 0.0, 0.0), + image_orientation=(1.0, 0.0, 0.0, 0.0, 1.0, 0.0), + pixel_spacing=(1.0, 1.0), + spacing_between_slices=2.0, + coordinate_system="PATIENT", + ) + + metatensor = volume.to_monai(ensure_channel_first=True) + assert metatensor.shape == (1, 10, 10, 10) + array = np.zeros((10, 10, 10, 2)) volume = Volume.from_attributes( array=array, @@ -703,3 +759,98 @@ def test_multichannel_volume(): ) ): Volume.from_monai(metatensor) + + +def test_squeeze(): + volume = Volume( + array=np.random.rand(1, 10, 10), + affine=np.array([[0., 4.99e-4, 0., -23.449374], + [0., 0., 4.99e-4, -25.691075], + [1., 0., 0., 1.01], + [0., 0., 0., 1.]]), + coordinate_system="PATIENT", + ) + squeeze_dim = 0 + + metatensor = volume.to_monai(squeeze_dim=squeeze_dim, convert_to_ras=False) + assert ( + volume.array.squeeze(squeeze_dim) == metatensor.numpy() + ).all() + assert (metatensor.affine == np.array([[4.99e-4, 0., -23.449374], + [0., 4.99e-4, -25.691075], + [0., 0., 1.]])).all() + + volume = Volume( + array=np.random.rand(10, 1, 10), + affine=np.array([[0., 4.99e-4, 0., -23.449374], + [0., 0., 4.99e-4, -25.691075], + [1., 0., 0., 1.01], + [0., 0., 0., 1.]]), + coordinate_system="PATIENT", + ) + squeeze_dim = 1 + + metatensor = volume.to_monai(squeeze_dim=squeeze_dim, convert_to_ras=False) + assert ( + volume.array.squeeze(squeeze_dim) == metatensor.numpy() + ).all() + assert (metatensor.affine == np.array([[0., 4.99e-4, -25.691075], + [1., 0., 1.01], + [0., 0., 1.]])).all() + + volume = Volume( + array=np.random.rand(10, 10, 1), + affine=np.array([[0., 4.99e-4, 0., -23.449374], + [0., 0., 4.99e-4, -25.691075], + [1., 0., 0., 1.01], + [0., 0., 0., 1.]]), + coordinate_system="PATIENT", + ) + squeeze_dim = 2 + + metatensor = volume.to_monai(squeeze_dim=squeeze_dim, convert_to_ras=False) + assert ( + volume.array.squeeze(squeeze_dim) == metatensor.numpy() + ).all() + assert (metatensor.affine == np.array([[0., 4.99e-4, -23.449374], + [1., 0., 1.01], + [0., 0., 1.]])).all() + + volume = Volume( + array=np.random.rand(1, 10, 10), + affine=np.array([[0., 4.99e-4, 0., -23.449374], + [0., 0., 4.99e-4, -25.691075], + [1., 0., 0., 1.01], + [0., 0., 0., 1.]]), + coordinate_system="PATIENT", + ) + squeeze_dim = 3 + + with pytest.raises( + ValueError, + match=re.escape( + 'If provided, `squeeze_dim` must be a spatial' + ' dimension (0, 1, 2).' + ) + ): + metatensor = volume.to_monai(squeeze_dim=squeeze_dim) + + volume = Volume( + array=np.random.rand(10, 10, 10), + affine=np.array([[0., 4.99e-4, 0., -23.449374], + [0., 0., 4.99e-4, -25.691075], + [1., 0., 0., 1.01], + [0., 0., 0., 1.]]), + coordinate_system="PATIENT", + ) + squeeze_dim = 0 + + with pytest.raises( + ValueError, + match=re.escape( + f'`squeeze_dim={squeeze_dim}` does not correspond' + ' to a singleton dimension. Array has shape:' + f' {volume.array.shape}.' + ) + ): + metatensor = volume.to_monai(squeeze_dim=squeeze_dim) From eaf604396d4ba0727c7d07d46ec908ff1ceac314 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Thu, 24 Sep 2026 07:22:56 -0400 Subject: [PATCH 12/13] Change method and test for dimension squeezing --- src/highdicom/volume.py | 23 ++++++++------- tests/test_monai.py | 65 +++++++++++++++++++++++++++++++++++------ 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 4a3c6ec1..fb9c7bd6 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -4320,16 +4320,19 @@ def to_monai( f' {array.shape}.' ) - u = affine[:, keep_cols[0]][:3] - v = affine[:, keep_cols[1]][:3] - - norm = np.cross(u, v) - drop_row = int(np.argmax(np.abs(norm))) - - affine = np.delete( - np.delete(affine, drop_row, axis=0), - squeeze_dim, - axis=1 + u = affine[:3, keep_cols[0]] + v = affine[:3, keep_cols[1]] + origin = affine[:3, 3] + + # define new orthonormal basis (e_0, e_1) + e_0 = u / np.linalg.norm(u) + v_perp = v - (np.dot(v, e_0) * e_0) + e_1 = v_perp / np.linalg.norm(v_perp) + + affine = np.array( + [[np.dot(u, e_0), np.dot(v, e_0), np.dot(origin, e_0)], + [np.dot(u, e_1), np.dot(v, e_1), np.dot(origin, e_1)], + [0, 0, 1]] ) array = array.squeeze(squeeze_dim) diff --git a/tests/test_monai.py b/tests/test_monai.py index 04fb9810..e79b89b1 100644 --- a/tests/test_monai.py +++ b/tests/test_monai.py @@ -77,6 +77,43 @@ def read_github_series_volume_and_metatensor(urls: Sequence[str]): return get_volume_from_series(series), series, metatensor +def validate_affine( + affine_3d: np.ndarray, + affine_2d: np.ndarray, + squeeze_dim: int, + shape: Sequence[int] +): + keep_cols = [i for i in range(3) if i != squeeze_dim] + u = affine_3d[:3, keep_cols[0]] + v = affine_3d[:3, keep_cols[1]] + origin = affine_3d[:3, 3] + + e_0 = u / np.linalg.norm(u) + v_perp = v - (np.dot(v, e_0) * e_0) + e_1 = v_perp / np.linalg.norm(v_perp) + + basis = np.column_stack([e_0, e_1]) + + origin_2d = basis.T @ origin + origin_offset = origin - basis @ origin_2d + + for x in range(shape[keep_cols[0]]): + for y in range(shape[keep_cols[1]]): + vox_3d = np.zeros(3) + vox_3d[keep_cols[0]] = x + vox_3d[keep_cols[1]] = y + world_3d = affine_3d[:3, :3] @ vox_3d + affine_3d[:3, 3] + + vox_2d = np.array([x, y]) + world_2d = affine_2d[:2, :2] @ vox_2d + affine_2d[:2, 2] + world_3d_recon = basis @ world_2d + origin_offset + + assert np.allclose( + world_3d, + world_3d_recon + ) + + @pytest.mark.parametrize( 'vol', [ @@ -773,12 +810,16 @@ def test_squeeze(): squeeze_dim = 0 metatensor = volume.to_monai(squeeze_dim=squeeze_dim, convert_to_ras=False) + assert ( volume.array.squeeze(squeeze_dim) == metatensor.numpy() ).all() - assert (metatensor.affine == np.array([[4.99e-4, 0., -23.449374], - [0., 4.99e-4, -25.691075], - [0., 0., 1.]])).all() + validate_affine( + affine_3d=volume.affine, + affine_2d=metatensor.affine.numpy(), + squeeze_dim=squeeze_dim, + shape=volume.shape + ) volume = Volume( array=np.random.rand(10, 1, 10), @@ -794,9 +835,12 @@ def test_squeeze(): assert ( volume.array.squeeze(squeeze_dim) == metatensor.numpy() ).all() - assert (metatensor.affine == np.array([[0., 4.99e-4, -25.691075], - [1., 0., 1.01], - [0., 0., 1.]])).all() + validate_affine( + affine_3d=volume.affine, + affine_2d=metatensor.affine.numpy(), + squeeze_dim=squeeze_dim, + shape=volume.shape + ) volume = Volume( array=np.random.rand(10, 10, 1), @@ -812,9 +856,12 @@ def test_squeeze(): assert ( volume.array.squeeze(squeeze_dim) == metatensor.numpy() ).all() - assert (metatensor.affine == np.array([[0., 4.99e-4, -23.449374], - [1., 0., 1.01], - [0., 0., 1.]])).all() + validate_affine( + affine_3d=volume.affine, + affine_2d=metatensor.affine.numpy(), + squeeze_dim=squeeze_dim, + shape=volume.shape + ) volume = Volume( array=np.random.rand(1, 10, 10), From 4524bb0b0c8b3141f6f5b26d047b8da43c69e61e Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Thu, 24 Sep 2026 18:56:31 -0400 Subject: [PATCH 13/13] Separate tests and add randomly generated affines --- tests/test_monai.py | 182 +++++++++++++++++++++++++++++++------------- 1 file changed, 127 insertions(+), 55 deletions(-) diff --git a/tests/test_monai.py b/tests/test_monai.py index e79b89b1..b385bfab 100644 --- a/tests/test_monai.py +++ b/tests/test_monai.py @@ -798,40 +798,131 @@ def test_channels(): Volume.from_monai(metatensor) -def test_squeeze(): - volume = Volume( - array=np.random.rand(1, 10, 10), - affine=np.array([[0., 4.99e-4, 0., -23.449374], - [0., 0., 4.99e-4, -25.691075], - [1., 0., 0., 1.01], - [0., 0., 0., 1.]]), - coordinate_system="PATIENT", - ) - squeeze_dim = 0 - - metatensor = volume.to_monai(squeeze_dim=squeeze_dim, convert_to_ras=False) - - assert ( - volume.array.squeeze(squeeze_dim) == metatensor.numpy() - ).all() - validate_affine( - affine_3d=volume.affine, - affine_2d=metatensor.affine.numpy(), - squeeze_dim=squeeze_dim, - shape=volume.shape - ) - - volume = Volume( - array=np.random.rand(10, 1, 10), - affine=np.array([[0., 4.99e-4, 0., -23.449374], - [0., 0., 4.99e-4, -25.691075], - [1., 0., 0., 1.01], - [0., 0., 0., 1.]]), - coordinate_system="PATIENT", - ) - squeeze_dim = 1 +@pytest.mark.parametrize( + 'volume,squeeze_dim', + [ + [ + Volume( + array=np.random.rand(1, 10, 10), + affine=np.array( + [[0., 4.99e-4, 0., -23.449374], + [0., 0., 4.99e-4, -25.691075], + [1., 0., 0., 1.01], + [0., 0., 0., 1.]] + ), + coordinate_system="PATIENT", + ), + 0 + ], + [ + Volume( + array=np.random.rand(10, 1, 10), + affine=np.array( + [[0., 4.99e-04, 0., -23.449873], + [0., 0., 4.99e-04, -25.691574], + [1., 0., 0., 0.], + [0., 0., 0., 1.]] + ), + coordinate_system="PATIENT", + ), + 1 + ], + [ + Volume( + array=np.random.rand(10, 10, 1), + affine=np.array( + [[0., 4.99e-04, 0., -23.449873], + [0., 0., 4.99e-04, -25.691574], + [1., 0., 0., 0.], + [0., 0., 0., 1.]] + ), + coordinate_system="PATIENT", + ), + 2 + ], + [ + Volume( + array=np.random.rand(1, 10, 10), + affine=np.array( + [[0.51484336, -2.45034467, -0.29838402, 31.96125748], + [0.84430051, 1.01480423, -1.0149242, 19.85095778], + [1.16995367, 0.34594871, 0.86372826, -39.22734667], + [0., 0., 0., 1.]] + ), + coordinate_system="PATIENT", + ), + 0 + ], + [ + Volume( + array=np.random.rand(10, 1, 10), + affine=np.array( + [[-6.62073016e-03, -0.648063598, 6.39172474e-02, -25.55167], + [-0.786401952, -1.4152762e-02, -1.50316788, -12.367773], + [0.950988641, -1.621514e-02, -1.24257108, -11.5527702], + [0., 0., 0., 1.]] + ), + coordinate_system="PATIENT", + ), + 1 + ], + [ + Volume( + array=np.random.rand(10, 10, 1), + affine=np.array( + [[0.783406238, 0.312050746, 2.29152341e-02, 45.850208], + [7.69377955e-02, -0.998526806, -0.712275579, -49.9411473], + [-9.27305439e-02, 1.80779755, -0.397376895, 18.0307572], + [0., 0., 0., 1.]] + ), + coordinate_system="PATIENT", + ), + 2 + ], + [ + Volume( + array=np.random.rand(1, 10, 10), + affine=np.array( + [[-1.8249537, 0.357257741, -0.365592144, 35.5553115], + [1.06765446, 0.586750214, -0.637285403, 18.5404252], + [-2.58571459e-02, -0.987438965, -0.510956107, 27.5584146], + [0., 0., 0., 1.]] + ), + coordinate_system="PATIENT", + ), + 0 + ], + [ + Volume( + array=np.random.rand(10, 1, 10), + affine=np.array( + [[0.40659061, -0.14035702, 2.52546278, -22.24126432], + [-2.01663853, -0.32789655, 0.12001713, -27.24361216], + [1.09727089, -0.55062153, -0.71522747, -25.00163784], + [0., 0., 0., 1.]] + ), + coordinate_system="PATIENT", + ), + 1 + ], + [ + Volume( + array=np.random.rand(10, 10, 1), + affine=np.array( + [[-0.692324440, 1.81254936, 0.770028884, 19.9835788], + [-1.01187701, -1.29172094, 1.06442359, 4.6090612], + [2.01112784, -2.59508652e-02, 0.800633129, -38.2126518], + [0., 0., 0., 1.]] + ), + coordinate_system="PATIENT", + ), + 2 + ], + ] +) +def test_squeeze_affine(volume, squeeze_dim): + metatensor = volume.to_monai(squeeze_dim=squeeze_dim) - metatensor = volume.to_monai(squeeze_dim=squeeze_dim, convert_to_ras=False) assert ( volume.array.squeeze(squeeze_dim) == metatensor.numpy() ).all() @@ -842,27 +933,8 @@ def test_squeeze(): shape=volume.shape ) - volume = Volume( - array=np.random.rand(10, 10, 1), - affine=np.array([[0., 4.99e-4, 0., -23.449374], - [0., 0., 4.99e-4, -25.691075], - [1., 0., 0., 1.01], - [0., 0., 0., 1.]]), - coordinate_system="PATIENT", - ) - squeeze_dim = 2 - - metatensor = volume.to_monai(squeeze_dim=squeeze_dim, convert_to_ras=False) - assert ( - volume.array.squeeze(squeeze_dim) == metatensor.numpy() - ).all() - validate_affine( - affine_3d=volume.affine, - affine_2d=metatensor.affine.numpy(), - squeeze_dim=squeeze_dim, - shape=volume.shape - ) +def test_squeeze_errors(): volume = Volume( array=np.random.rand(1, 10, 10), affine=np.array([[0., 4.99e-4, 0., -23.449374], @@ -880,7 +952,7 @@ def test_squeeze(): ' dimension (0, 1, 2).' ) ): - metatensor = volume.to_monai(squeeze_dim=squeeze_dim) + volume.to_monai(squeeze_dim=squeeze_dim) volume = Volume( array=np.random.rand(10, 10, 10), @@ -900,4 +972,4 @@ def test_squeeze(): f' {volume.array.shape}.' ) ): - metatensor = volume.to_monai(squeeze_dim=squeeze_dim) + volume.to_monai(squeeze_dim=squeeze_dim)