From 5023720ee6804b63186f72083010d0442a66aeef Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Wed, 7 Jan 2026 22:12:33 -0500 Subject: [PATCH 01/33] Add import_optional_dependency util function --- src/highdicom/utils.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/highdicom/utils.py b/src/highdicom/utils.py index 4fdfaa248..357e4f625 100644 --- a/src/highdicom/utils.py +++ b/src/highdicom/utils.py @@ -14,6 +14,9 @@ is_tiled_image, ) +from importlib import import_module +from types import ModuleType +from typing import Optional # Several functions that were initially defined in this module were moved to # highdicom.spatial to consolidate similar functionality and prevent circular @@ -243,3 +246,41 @@ def are_plane_positions_tiled_full( return False return True + + +def import_optional_dependency( + module_name: str, + feature: Optional[str] = None +) -> ModuleType: + """Import an optional dependency. + + This function is designed to support interaction with other common + libraries that are not required for `highdicom`. + + Parameters + ---------- + module_name: str + Name of the module to be imported. + feature: Optional[str] + Optional description of the feature that requires this dependency. + This is used only for improving the error message when the module + is not installed. + + Returns + ------- + ModuleType: + Imported module. + + Raises + ------ + ImportError + When the specified module cannot be imported. + """ + try: + return import_module(name=module_name) + + except ImportError as error: + raise ImportError( + f"Optional dependency `{module_name}` could not be imported" + + (f" but is required for {feature}." if feature else ".") + ) from error From c5f7398e34c21ba3f3132c3904f37988ae90d9e9 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 23 Jan 2026 08:41:09 -0500 Subject: [PATCH 02/33] Update optional import function. --- src/highdicom/utils.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/highdicom/utils.py b/src/highdicom/utils.py index 357e4f625..779cac296 100644 --- a/src/highdicom/utils.py +++ b/src/highdicom/utils.py @@ -250,21 +250,20 @@ def are_plane_positions_tiled_full( def import_optional_dependency( module_name: str, - feature: Optional[str] = None + feature: str ) -> ModuleType: """Import an optional dependency. This function is designed to support interaction with other common - libraries that are not required for `highdicom`. + libraries that are not required for `highdicom` by default. Parameters ---------- module_name: str Name of the module to be imported. - feature: Optional[str] - Optional description of the feature that requires this dependency. - This is used only for improving the error message when the module - is not installed. + feature: str + Name or description of the feature that requires this dependency. + This is used for improving the clarity of error messages. Returns ------- @@ -276,11 +275,19 @@ def import_optional_dependency( ImportError When the specified module cannot be imported. """ + + #Update as new optional dependencies are supported + versions = {} + try: return import_module(name=module_name) except ImportError as error: + module_version = versions[module_name] if module_name in versions.keys() else "Unknown" + raise ImportError( f"Optional dependency `{module_name}` could not be imported" + (f" but is required for {feature}." if feature else ".") + + f" The minimum required version for `{module_name}` is {module_version}." + ) from error From feef10be6c79e954153c41bf75c76a49b0dc733b Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 23 Jan 2026 08:56:56 -0500 Subject: [PATCH 03/33] Reformat code --- src/highdicom/utils.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/highdicom/utils.py b/src/highdicom/utils.py index 779cac296..7a7eeef99 100644 --- a/src/highdicom/utils.py +++ b/src/highdicom/utils.py @@ -283,11 +283,13 @@ def import_optional_dependency( return import_module(name=module_name) except ImportError as error: - module_version = versions[module_name] if module_name in versions.keys() else "Unknown" + module_version = ( + versions[module_name] if module_name in versions.keys() else 'Unknown' + ) raise ImportError( - f"Optional dependency `{module_name}` could not be imported" - + (f" but is required for {feature}." if feature else ".") - + f" The minimum required version for `{module_name}` is {module_version}." + f'Optional dependency `{module_name}` could not be imported' + + (f' but is required for {feature}.' if feature else '.') + + f' The minimum required version for `{module_name}` is {module_version}.' ) from error From bc41ecfc95a5f48452f412560fabe41efebafc64 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Wed, 4 Feb 2026 23:52:23 -0500 Subject: [PATCH 04/33] Add module version compatibility check. --- src/highdicom/utils.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/highdicom/utils.py b/src/highdicom/utils.py index 7a7eeef99..cafb6f7fc 100644 --- a/src/highdicom/utils.py +++ b/src/highdicom/utils.py @@ -14,9 +14,9 @@ is_tiled_image, ) -from importlib import import_module +from importlib import import_module, metadata +from packaging.requirements import Requirement from types import ModuleType -from typing import Optional # Several functions that were initially defined in this module were moved to # highdicom.spatial to consolidate similar functionality and prevent circular @@ -275,21 +275,33 @@ def import_optional_dependency( ImportError When the specified module cannot be imported. """ + for req_str in metadata.requires('highdicom'): + req = Requirement(req_str) + if req.name == module_name: + break - #Update as new optional dependencies are supported - versions = {} + else: + raise ValueError( + f'`{module_name}` is not a requirement of highdicom' + f' but is required for {feature}.' + ) try: - return import_module(name=module_name) + module = import_module(name=module_name) + installed_version = metadata.version(module_name) - except ImportError as error: - module_version = ( - versions[module_name] if module_name in versions.keys() else 'Unknown' - ) + if installed_version not in req.specifier: + raise ImportError( + f'Optional dependency `{module_name}` has an unsuitable' + f' version. Found {installed_version}, but highdicom requires' + f' {module_name}{req.specifier}.' + ) + return module + + except ImportError as error: raise ImportError( f'Optional dependency `{module_name}` could not be imported' - + (f' but is required for {feature}.' if feature else '.') - + f' The minimum required version for `{module_name}` is {module_version}.' - + f' but is required for {feature}.' + f' highdicom requires {module_name}{req.specifier}.' ) from error From ce4998ae51ace41eca025a4bf32cb1b98a90aa62 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Thu, 5 Feb 2026 00:16:56 -0500 Subject: [PATCH 05/33] Move version incompatibility error raise location and update error message --- src/highdicom/utils.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/highdicom/utils.py b/src/highdicom/utils.py index cafb6f7fc..f5c88214b 100644 --- a/src/highdicom/utils.py +++ b/src/highdicom/utils.py @@ -290,18 +290,18 @@ def import_optional_dependency( module = import_module(name=module_name) installed_version = metadata.version(module_name) - if installed_version not in req.specifier: - raise ImportError( - f'Optional dependency `{module_name}` has an unsuitable' - f' version. Found {installed_version}, but highdicom requires' - f' {module_name}{req.specifier}.' - ) - - return module - except ImportError as error: raise ImportError( f'Optional dependency `{module_name}` could not be imported' f' but is required for {feature}.' f' highdicom requires {module_name}{req.specifier}.' ) from error + + if installed_version not in req.specifier: + raise ImportError( + f'Optional dependency `{module_name}` has an unsuitable' + f' version. Found {module_name}=={installed_version}, but' + f' highdicom requires {module_name}{req.specifier}.' + ) + + return module From 5c5e9527723b71cb46c24f3813531b27f0147fdd Mon Sep 17 00:00:00 2001 From: "Mason C. Cleveland" <104479423+mccle@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:47:47 -0500 Subject: [PATCH 06/33] Update src/highdicom/utils.py Co-authored-by: Chris Bridge --- src/highdicom/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/highdicom/utils.py b/src/highdicom/utils.py index f5c88214b..2fe9e8975 100644 --- a/src/highdicom/utils.py +++ b/src/highdicom/utils.py @@ -274,6 +274,7 @@ def import_optional_dependency( ------ ImportError When the specified module cannot be imported. + """ for req_str in metadata.requires('highdicom'): req = Requirement(req_str) From 1ce1bc7495aa227151a3d243bc7f743f43292d66 Mon Sep 17 00:00:00 2001 From: "Mason C. Cleveland" <104479423+mccle@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:48:40 -0500 Subject: [PATCH 07/33] Update src/highdicom/utils.py Co-authored-by: Chris Bridge --- src/highdicom/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/highdicom/utils.py b/src/highdicom/utils.py index 2fe9e8975..5225ae56d 100644 --- a/src/highdicom/utils.py +++ b/src/highdicom/utils.py @@ -272,7 +272,7 @@ def import_optional_dependency( Raises ------ - ImportError + ImportError: When the specified module cannot be imported. """ From bdd363955c6c769107ad5ccf04959e6bb9996a0b Mon Sep 17 00:00:00 2001 From: "Mason C. Cleveland" <104479423+mccle@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:49:10 -0500 Subject: [PATCH 08/33] Update src/highdicom/utils.py Co-authored-by: Chris Bridge --- src/highdicom/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/highdicom/utils.py b/src/highdicom/utils.py index 5225ae56d..9f07c005a 100644 --- a/src/highdicom/utils.py +++ b/src/highdicom/utils.py @@ -300,9 +300,9 @@ def import_optional_dependency( if installed_version not in req.specifier: raise ImportError( - f'Optional dependency `{module_name}` has an unsuitable' - f' version. Found {module_name}=={installed_version}, but' - f' highdicom requires {module_name}{req.specifier}.' + f'Optional dependency `{module_name}` has an unsuitable ' + f'version. Found {module_name}=={installed_version}, but ' + f'highdicom requires {module_name}{req.specifier}.' ) return module From b4cd2403732fc46c9672e18b5bbbd3c72cc23c99 Mon Sep 17 00:00:00 2001 From: "Mason C. Cleveland" <104479423+mccle@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:49:35 -0500 Subject: [PATCH 09/33] Update src/highdicom/utils.py Co-authored-by: Chris Bridge --- src/highdicom/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/highdicom/utils.py b/src/highdicom/utils.py index 9f07c005a..5f6e275a2 100644 --- a/src/highdicom/utils.py +++ b/src/highdicom/utils.py @@ -283,8 +283,8 @@ def import_optional_dependency( else: raise ValueError( - f'`{module_name}` is not a requirement of highdicom' - f' but is required for {feature}.' + f'`{module_name}` is not a requirement of highdicom ' + f'but is required for {feature}.' ) try: From 36a8fb5a8c882154b514ef57283cb204ebef3499 Mon Sep 17 00:00:00 2001 From: "Mason C. Cleveland" <104479423+mccle@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:50:33 -0500 Subject: [PATCH 10/33] Update src/highdicom/utils.py Co-authored-by: Chris Bridge --- src/highdicom/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/highdicom/utils.py b/src/highdicom/utils.py index 5f6e275a2..fdf34fd66 100644 --- a/src/highdicom/utils.py +++ b/src/highdicom/utils.py @@ -289,7 +289,6 @@ def import_optional_dependency( try: module = import_module(name=module_name) - installed_version = metadata.version(module_name) except ImportError as error: raise ImportError( @@ -297,6 +296,8 @@ def import_optional_dependency( f' but is required for {feature}.' f' highdicom requires {module_name}{req.specifier}.' ) from error + + installed_version = metadata.version(module_name) if installed_version not in req.specifier: raise ImportError( From 7ff4a135fcf72c04d6b99a87965313ccf8437c59 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 20 Feb 2026 09:49:04 -0500 Subject: [PATCH 11/33] Move to new utils file --- src/highdicom/_dependency_utils.py | 64 ++++++++++++++++++++++++++++++ src/highdicom/utils.py | 64 ------------------------------ 2 files changed, 64 insertions(+), 64 deletions(-) create mode 100644 src/highdicom/_dependency_utils.py diff --git a/src/highdicom/_dependency_utils.py b/src/highdicom/_dependency_utils.py new file mode 100644 index 000000000..9c7643c6e --- /dev/null +++ b/src/highdicom/_dependency_utils.py @@ -0,0 +1,64 @@ +from importlib import import_module, metadata +from packaging.requirements import Requirement +from types import ModuleType + + +def import_optional_dependency( + module_name: str, + feature: str +) -> ModuleType: + """Import an optional dependency. + + This function is designed to support interaction with other common + libraries that are not required for `highdicom` by default. + + Parameters + ---------- + module_name: str + Name of the module to be imported. + feature: str + Name or description of the feature that requires this dependency. + This is used for improving the clarity of error messages. + + Returns + ------- + ModuleType: + Imported module. + + Raises + ------ + ImportError: + When the specified module cannot be imported. + + """ + for req_str in metadata.requires('highdicom'): + req = Requirement(req_str) + if req.name == module_name: + break + + else: + raise ValueError( + f'`{module_name}` is not a requirement of highdicom ' + f'but is required for {feature}.' + ) + + try: + module = import_module(name=module_name) + + except ImportError as error: + raise ImportError( + f'Optional dependency `{module_name}` could not be imported' + f' but is required for {feature}.' + f' highdicom requires {module_name}{req.specifier}.' + ) from error + + installed_version = metadata.version(module_name) + + if installed_version not in req.specifier: + raise ImportError( + f'Optional dependency `{module_name}` has an unsuitable ' + f'version. Found {module_name}=={installed_version}, but ' + f'highdicom requires {module_name}{req.specifier}.' + ) + + return module diff --git a/src/highdicom/utils.py b/src/highdicom/utils.py index fdf34fd66..4fdfaa248 100644 --- a/src/highdicom/utils.py +++ b/src/highdicom/utils.py @@ -14,9 +14,6 @@ is_tiled_image, ) -from importlib import import_module, metadata -from packaging.requirements import Requirement -from types import ModuleType # Several functions that were initially defined in this module were moved to # highdicom.spatial to consolidate similar functionality and prevent circular @@ -246,64 +243,3 @@ def are_plane_positions_tiled_full( return False return True - - -def import_optional_dependency( - module_name: str, - feature: str -) -> ModuleType: - """Import an optional dependency. - - This function is designed to support interaction with other common - libraries that are not required for `highdicom` by default. - - Parameters - ---------- - module_name: str - Name of the module to be imported. - feature: str - Name or description of the feature that requires this dependency. - This is used for improving the clarity of error messages. - - Returns - ------- - ModuleType: - Imported module. - - Raises - ------ - ImportError: - When the specified module cannot be imported. - - """ - for req_str in metadata.requires('highdicom'): - req = Requirement(req_str) - if req.name == module_name: - break - - else: - raise ValueError( - f'`{module_name}` is not a requirement of highdicom ' - f'but is required for {feature}.' - ) - - try: - module = import_module(name=module_name) - - except ImportError as error: - raise ImportError( - f'Optional dependency `{module_name}` could not be imported' - f' but is required for {feature}.' - f' highdicom requires {module_name}{req.specifier}.' - ) from error - - installed_version = metadata.version(module_name) - - if installed_version not in req.specifier: - raise ImportError( - f'Optional dependency `{module_name}` has an unsuitable ' - f'version. Found {module_name}=={installed_version}, but ' - f'highdicom requires {module_name}{req.specifier}.' - ) - - return module From efb70aafb7074bedc953398ba5b7c881ce0c5244 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 20 Feb 2026 09:49:25 -0500 Subject: [PATCH 12/33] Add packaging to requirements --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 276f28ddf..6fce1a04d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ "pydicom>=3.0.1", "pyjpegls>=1.0.0", "typing-extensions>=4.0.0", + "packaging>=25.0" ] [project.optional-dependencies] From 12322049af58e0e11a4c8f623575d0e6c9f6a0c3 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 20 Feb 2026 10:57:34 -0500 Subject: [PATCH 13/33] add itk and sitk conversions --- pyproject.toml | 4 ++ src/highdicom/volume.py | 133 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 6fce1a04d..4725659dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,10 @@ docs = [ "sphinxcontrib-autoprogram==0.1.7", "sphinxcontrib-websupport==1.2.4", ] +itk = [ + "itk>=5.4.0", + "SimpleITK>=2.5.0" +] [project.urls] homepage = "https://github.com/imagingdatacommons/highdicom" diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index d5c54d6a5..30d173ac4 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -1,4 +1,5 @@ """Representations of multidimensional arrays with spatial metadata.""" +from __future__ import annotations from abc import ABC, abstractmethod from enum import Enum import itertools @@ -44,6 +45,7 @@ keyword_for_tag, ) +from highdicom._dependency_utils import import_optional_dependency _DCM_PYTHON_TYPE_MAP = { 'CS': str, @@ -3583,6 +3585,137 @@ def pad_array(array: np.ndarray, cval: float) -> float: channels=self._channels, ) + def to_sitk(self) -> SimpleITK.Image: + """Convert the Volume to `SimpleITK.Image` format. + + Returns + ------- + SimpleITK.Image: + Image constructed from the Volume. + + """ + func = self.to_sitk + sitk = import_optional_dependency( + module_name="SimpleITK", + feature=f"{func.__module__}.{func.__qualname__}" + ) + + array = self.array.transpose(2, 1, 0) + + if self.dtype == np.bool_: + array = array.astype(int) + + sitk_im = sitk.GetImageFromArray(array) + sitk_im.SetSpacing(self.spacing) + sitk_im.SetDirection(self.direction.flatten()) + sitk_im.SetOrigin(self.position) + + return sitk_im + + + @classmethod + def from_sitk( + cls, + sitk_im: SimpleITK.Image + ): + """Construct a Volume from a `SimpleITK.Image`. + + Parameters + ---------- + sitk_im: SimpleITK.Image + A `SimpleITK.Image` to convert to a volume. + + Returns + ------- + highdicom.Volume: + Volume constructed from the `SimpleITK.Image`. + + """ + func = cls.from_sitk + sitk = import_optional_dependency( + module_name="SimpleITK", + feature=f"{func.__module__}.{func.__qualname__}" + ) + + array = sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0) + + if array.dtype == np.bool_: + array = array.astype(int) + + return cls.from_components( + array=array, + spacing=sitk_im.GetSpacing(), + coordinate_system="PATIENT", + direction=np.reshape(sitk_im.GetDirection(), (3, 3)), + position=sitk_im.GetOrigin() + ) + + + def to_itk(self) -> itk.Image: + """Convert the volume to `itk.Image` format. + + Returns + ------- + itk.Image: + Image constructed from the volume. + + """ + func = self.to_itk + itk = import_optional_dependency( + module_name="itk", + feature=f"{func.__module__}.{func.__qualname__}" + ) + + array = self.array + + if self.dtype == np.bool_: + array = array.astype(int) + + itk_im = itk.GetImageFromArray(array) + itk_im.SetSpacing(self.spacing) + itk_im.SetDirection(self.direction) + itk_im.SetOrigin(self.position) + + return itk_im + + + @classmethod + def from_itk( + cls, + itk_im: itk.Image + ): + """Construct a Volume from an `itk.Image`. + + Parameters + ---------- + itk_im: itk.Image + A `itk.Image` to convert to a volume. + + Returns + ------- + highdicom.Volume: + Volume constructed from the `itk.Image`. + + """ + func = cls.from_itk + itk = import_optional_dependency( + module_name="itk", + feature=f"{func.__module__}.{func.__qualname__}" + ) + + array = itk.GetArrayFromImage(itk_im) + + if array.dtype == np.bool_: + array = array.astype(int) + + return cls.from_components( + array=array, + spacing=np.array(itk_im.GetSpacing()), + coordinate_system="PATIENT", + direction=np.reshape(itk_im.GetDirection(), (3, 3)), + position=np.array(itk_im.GetOrigin()) + ) + class VolumeToVolumeTransformer: From 9f9a7bb577a5351ebe42cf7f0e84a7f442ba649f Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Thu, 16 Apr 2026 22:33:23 -0400 Subject: [PATCH 14/33] Add frame_of_reference_uid and coordinate_system arguments; update docstrings; reformat strings --- src/highdicom/volume.py | 57 +++++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 30d173ac4..5d0c56cbd 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -3596,8 +3596,8 @@ def to_sitk(self) -> SimpleITK.Image: """ func = self.to_sitk sitk = import_optional_dependency( - module_name="SimpleITK", - feature=f"{func.__module__}.{func.__qualname__}" + module_name='SimpleITK', + feature=f'{func.__module__}.{func.__qualname__}' ) array = self.array.transpose(2, 1, 0) @@ -3612,18 +3612,28 @@ def to_sitk(self) -> SimpleITK.Image: return sitk_im - @classmethod def from_sitk( cls, - sitk_im: SimpleITK.Image - ): + sitk_im: SimpleITK.Image, + 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, + ) -> Self: """Construct a Volume from a `SimpleITK.Image`. Parameters ---------- sitk_im: SimpleITK.Image A `SimpleITK.Image` 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. Returns ------- @@ -3633,8 +3643,8 @@ def from_sitk( """ func = cls.from_sitk sitk = import_optional_dependency( - module_name="SimpleITK", - feature=f"{func.__module__}.{func.__qualname__}" + module_name='SimpleITK', + feature=f'{func.__module__}.{func.__qualname__}' ) array = sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0) @@ -3645,12 +3655,13 @@ def from_sitk( return cls.from_components( array=array, spacing=sitk_im.GetSpacing(), - coordinate_system="PATIENT", + coordinate_system=coordinate_system, direction=np.reshape(sitk_im.GetDirection(), (3, 3)), - position=sitk_im.GetOrigin() + position=sitk_im.GetOrigin(), + frame_of_reference_uid=frame_of_reference_uid, + channels=channels ) - def to_itk(self) -> itk.Image: """Convert the volume to `itk.Image` format. @@ -3662,8 +3673,8 @@ def to_itk(self) -> itk.Image: """ func = self.to_itk itk = import_optional_dependency( - module_name="itk", - feature=f"{func.__module__}.{func.__qualname__}" + module_name='itk', + feature=f'{func.__module__}.{func.__qualname__}' ) array = self.array @@ -3678,18 +3689,24 @@ def to_itk(self) -> itk.Image: return itk_im - @classmethod def from_itk( cls, - itk_im: itk.Image - ): + itk_im: itk.Image, + coordinate_system: CoordinateSystemNames | str = 'PATIENT', + frame_of_reference_uid: str | None = None, + ) -> Self: """Construct a Volume from an `itk.Image`. Parameters ---------- itk_im: itk.Image A `itk.Image` 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. Returns ------- @@ -3699,8 +3716,8 @@ def from_itk( """ func = cls.from_itk itk = import_optional_dependency( - module_name="itk", - feature=f"{func.__module__}.{func.__qualname__}" + module_name='itk', + feature=f'{func.__module__}.{func.__qualname__}' ) array = itk.GetArrayFromImage(itk_im) @@ -3711,9 +3728,11 @@ def from_itk( return cls.from_components( array=array, spacing=np.array(itk_im.GetSpacing()), - coordinate_system="PATIENT", + coordinate_system=coordinate_system, direction=np.reshape(itk_im.GetDirection(), (3, 3)), - position=np.array(itk_im.GetOrigin()) + position=np.array(itk_im.GetOrigin()), + frame_of_reference_uid=frame_of_reference_uid, + channels=channels ) From b6b7d97da4633a4c8d77ab40f82f9febf163b264 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 17 Apr 2026 00:25:47 -0400 Subject: [PATCH 15/33] remove channel argument --- src/highdicom/volume.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 5d0c56cbd..5beb96cdc 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -3618,10 +3618,6 @@ def from_sitk( sitk_im: SimpleITK.Image, 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, ) -> Self: """Construct a Volume from a `SimpleITK.Image`. @@ -3658,8 +3654,7 @@ def from_sitk( coordinate_system=coordinate_system, direction=np.reshape(sitk_im.GetDirection(), (3, 3)), position=sitk_im.GetOrigin(), - frame_of_reference_uid=frame_of_reference_uid, - channels=channels + frame_of_reference_uid=frame_of_reference_uid ) def to_itk(self) -> itk.Image: @@ -3731,8 +3726,7 @@ def from_itk( coordinate_system=coordinate_system, direction=np.reshape(itk_im.GetDirection(), (3, 3)), position=np.array(itk_im.GetOrigin()), - frame_of_reference_uid=frame_of_reference_uid, - channels=channels + frame_of_reference_uid=frame_of_reference_uid ) From 68d17d1a009f6f9a73212b880ddd9447d3ba0329 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 17 Apr 2026 09:53:23 -0400 Subject: [PATCH 16/33] add noqa to work with flake8 --- src/highdicom/volume.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 5beb96cdc..076c11fce 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -3585,7 +3585,7 @@ def pad_array(array: np.ndarray, cval: float) -> float: channels=self._channels, ) - def to_sitk(self) -> SimpleITK.Image: + def to_sitk(self) -> SimpleITK.Image: # noqa """Convert the Volume to `SimpleITK.Image` format. Returns @@ -3615,7 +3615,7 @@ def to_sitk(self) -> SimpleITK.Image: @classmethod def from_sitk( cls, - sitk_im: SimpleITK.Image, + sitk_im: SimpleITK.Image, # noqa coordinate_system: CoordinateSystemNames | str = 'PATIENT', frame_of_reference_uid: str | None = None, ) -> Self: @@ -3657,7 +3657,7 @@ def from_sitk( frame_of_reference_uid=frame_of_reference_uid ) - def to_itk(self) -> itk.Image: + def to_itk(self) -> itk.Image: # noqa """Convert the volume to `itk.Image` format. Returns @@ -3687,7 +3687,7 @@ def to_itk(self) -> itk.Image: @classmethod def from_itk( cls, - itk_im: itk.Image, + itk_im: itk.Image, # noqa coordinate_system: CoordinateSystemNames | str = 'PATIENT', frame_of_reference_uid: str | None = None, ) -> Self: From 301c730bdfc51af4a04e75dd2538c7c852aa0dfe Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Mon, 27 Apr 2026 15:06:22 -0400 Subject: [PATCH 17/33] update type hints, minor tweaks to bool retyping --- src/highdicom/volume.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 076c11fce..3c5f07a9b 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -1,5 +1,4 @@ """Representations of multidimensional arrays with spatial metadata.""" -from __future__ import annotations from abc import ABC, abstractmethod from enum import Enum import itertools @@ -3585,7 +3584,7 @@ def pad_array(array: np.ndarray, cval: float) -> float: channels=self._channels, ) - def to_sitk(self) -> SimpleITK.Image: # noqa + def to_sitk(self) -> 'SimpleITK.Image': # noqa: F821 """Convert the Volume to `SimpleITK.Image` format. Returns @@ -3602,8 +3601,8 @@ def to_sitk(self) -> SimpleITK.Image: # noqa array = self.array.transpose(2, 1, 0) - if self.dtype == np.bool_: - array = array.astype(int) + if array.dtype == np.bool_: + array = array.astype(np.uint8) sitk_im = sitk.GetImageFromArray(array) sitk_im.SetSpacing(self.spacing) @@ -3615,7 +3614,7 @@ def to_sitk(self) -> SimpleITK.Image: # noqa @classmethod def from_sitk( cls, - sitk_im: SimpleITK.Image, # noqa + sitk_im: 'SimpleITK.Image', # noqa: F821 coordinate_system: CoordinateSystemNames | str = 'PATIENT', frame_of_reference_uid: str | None = None, ) -> Self: @@ -3645,9 +3644,6 @@ def from_sitk( array = sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0) - if array.dtype == np.bool_: - array = array.astype(int) - return cls.from_components( array=array, spacing=sitk_im.GetSpacing(), @@ -3657,7 +3653,7 @@ def from_sitk( frame_of_reference_uid=frame_of_reference_uid ) - def to_itk(self) -> itk.Image: # noqa + def to_itk(self) -> 'itk.Image': # noqa: F821 """Convert the volume to `itk.Image` format. Returns @@ -3674,8 +3670,8 @@ def to_itk(self) -> itk.Image: # noqa array = self.array - if self.dtype == np.bool_: - array = array.astype(int) + if array.dtype == np.bool_: + array = array.astype(np.uint8) itk_im = itk.GetImageFromArray(array) itk_im.SetSpacing(self.spacing) @@ -3687,7 +3683,7 @@ def to_itk(self) -> itk.Image: # noqa @classmethod def from_itk( cls, - itk_im: itk.Image, # noqa + itk_im: 'itk.Image', # noqa: F821 coordinate_system: CoordinateSystemNames | str = 'PATIENT', frame_of_reference_uid: str | None = None, ) -> Self: @@ -3717,9 +3713,6 @@ def from_itk( array = itk.GetArrayFromImage(itk_im) - if array.dtype == np.bool_: - array = array.astype(int) - return cls.from_components( array=array, spacing=np.array(itk_im.GetSpacing()), From edb9ac4725ea77a3e1e9063305d34d3e91d73ba7 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Wed, 6 May 2026 16:46:10 -0400 Subject: [PATCH 18/33] sync changes --- .github/workflows/run_unit_tests.yml | 2 +- pyproject.toml | 6 ++-- src/highdicom/volume.py | 44 ++++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index dea7ffbfe..fa75036ce 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -19,7 +19,7 @@ jobs: # and the likelihood of 3.12-specific bugs is considered low at this # stage python-version: ["3.10", "3.11", "3.13", "3.14"] - dependencies: [".", "'.[libjpeg]'"] + dependencies: [".", "'.[libjpeg]'", "'.[itk]'", "'.[sitk]'"] env: # Set this otherwise coverage on python 3.12 is absurdly slow diff --git a/pyproject.toml b/pyproject.toml index 87dc32358..01ab8c053 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,8 @@ libjpeg = [ "pylibjpeg-openjpeg>=2.0.0", "pylibjpeg>=2.0", ] +itk = ["itk>=5.4.0"] +sitk = ["SimpleITK>=2.2.1"] [dependency-groups] test = [ @@ -63,10 +65,6 @@ docs = [ "sphinxcontrib-autoprogram==0.1.7", "sphinxcontrib-websupport==1.2.4", ] -itk = [ - "itk>=5.4.0", - "SimpleITK>=2.5.0" -] [project.urls] homepage = "https://github.com/imagingdatacommons/highdicom" diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 3c5f07a9b..8f2db315b 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -45,6 +45,7 @@ ) from highdicom._dependency_utils import import_optional_dependency +import warnings _DCM_PYTHON_TYPE_MAP = { 'CS': str, @@ -3604,6 +3605,19 @@ def to_sitk(self) -> 'SimpleITK.Image': # noqa: F821 if array.dtype == np.bool_: array = array.astype(np.uint8) + elif array.dtype == np.float16: + warnings.warn('SimpleITK does not support float16 data. Safely recasting to float32.') + array = array.astype(np.float32) + + elif array.dtype == np.float128: + f64 = np.finfo(np.float64) + if array.min() >= f64.min and array.max() <= f64.max: + warnings.warn('SimpleITK does not support float128 data. Safely recasting to float64.') + array = array.astype(np.float64) + + else: + raise ValueError('SimpleITK does not support float128 data. Safely recasting to float64 is not possible.') + sitk_im = sitk.GetImageFromArray(array) sitk_im.SetSpacing(self.spacing) sitk_im.SetDirection(self.direction.flatten()) @@ -3668,11 +3682,37 @@ def to_itk(self) -> 'itk.Image': # noqa: F821 feature=f'{func.__module__}.{func.__qualname__}' ) - array = self.array + array = self.array.transpose(2, 1, 0) if array.dtype == np.bool_: array = array.astype(np.uint8) + elif array.dtype == np.int8: + warnings.warn('ITK does not support int8 data. Safely recasting to int16.') + array = array.astype(np.int16) + + elif array.dtype == np.int64: + i32 = np.iinfo(np.int32) + if array.min() >= i32.min and array.max() <= i32.max: + warnings.warn('ITK does not support int64 data. Safely recasting to int32.') + array = array.astype(np.int32) + + else: + raise ValueError('ITK does not support int64 data. Safely recasting to int32 is not possible.') + + elif array.dtype == np.float16: + warnings.warn('ITK does not support float16 data. Safely recasting to float32.') + array = array.astype(np.float32) + + elif array.dtype == np.float128: + f64 = np.finfo(np.float64) + if array.min() >= f64.min and array.max() <= f64.max: + warnings.warn('ITK does not support float128 data. Safely recasting to float64.') + array = array.astype(np.float64) + + else: + raise ValueError('ITK does not support float128 data. Safely recasting to float64 is not possible.') + itk_im = itk.GetImageFromArray(array) itk_im.SetSpacing(self.spacing) itk_im.SetDirection(self.direction) @@ -3711,7 +3751,7 @@ def from_itk( feature=f'{func.__module__}.{func.__qualname__}' ) - array = itk.GetArrayFromImage(itk_im) + array = itk.GetArrayFromImage(itk_im).transpose(2, 1, 0) return cls.from_components( array=array, From 6094f87980d5de046209d2b72b08c4dd5ddb3ef8 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Wed, 6 May 2026 17:18:35 -0400 Subject: [PATCH 19/33] flake8 --- src/highdicom/volume.py | 45 ++++++++++++++++++++++++++++++++--------- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 8f2db315b..ad630048c 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -3606,17 +3606,26 @@ def to_sitk(self) -> 'SimpleITK.Image': # noqa: F821 array = array.astype(np.uint8) elif array.dtype == np.float16: - warnings.warn('SimpleITK does not support float16 data. Safely recasting to float32.') + warnings.warn( + 'SimpleITK does not support float16 data.' + ' Safely recasting to float32.' + ) array = array.astype(np.float32) elif array.dtype == np.float128: f64 = np.finfo(np.float64) if array.min() >= f64.min and array.max() <= f64.max: - warnings.warn('SimpleITK does not support float128 data. Safely recasting to float64.') + warnings.warn( + 'SimpleITK does not support float128 data.' + ' Safely recasting to float64.' + ) array = array.astype(np.float64) else: - raise ValueError('SimpleITK does not support float128 data. Safely recasting to float64 is not possible.') + raise ValueError( + 'SimpleITK does not support float128 data.' + ' Safely recasting to float64 is not possible.' + ) sitk_im = sitk.GetImageFromArray(array) sitk_im.SetSpacing(self.spacing) @@ -3688,30 +3697,48 @@ def to_itk(self) -> 'itk.Image': # noqa: F821 array = array.astype(np.uint8) elif array.dtype == np.int8: - warnings.warn('ITK does not support int8 data. Safely recasting to int16.') + warnings.warn( + 'ITK does not support int8 data.' + ' Safely recasting to int16.' + ) array = array.astype(np.int16) elif array.dtype == np.int64: i32 = np.iinfo(np.int32) if array.min() >= i32.min and array.max() <= i32.max: - warnings.warn('ITK does not support int64 data. Safely recasting to int32.') + warnings.warn( + 'ITK does not support int64 data.' + ' Safely recasting to int32.' + ) array = array.astype(np.int32) else: - raise ValueError('ITK does not support int64 data. Safely recasting to int32 is not possible.') + raise ValueError( + 'ITK does not support int64 data. ' + ' Safely recasting to int32 is not possible.' + ) elif array.dtype == np.float16: - warnings.warn('ITK does not support float16 data. Safely recasting to float32.') + warnings.warn( + 'ITK does not support float16 data.' + ' Safely recasting to float32.' + ) array = array.astype(np.float32) elif array.dtype == np.float128: f64 = np.finfo(np.float64) if array.min() >= f64.min and array.max() <= f64.max: - warnings.warn('ITK does not support float128 data. Safely recasting to float64.') + warnings.warn( + 'ITK does not support float128 data. ' + ' Safely recasting to float64.' + ) array = array.astype(np.float64) else: - raise ValueError('ITK does not support float128 data. Safely recasting to float64 is not possible.') + raise ValueError( + 'ITK does not support float128 data. ' + ' Safely recasting to float64 is not possible.' + ) itk_im = itk.GetImageFromArray(array) itk_im.SetSpacing(self.spacing) From dfaba15eb5833e27e26579abcba6b28d8e26c316 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Wed, 6 May 2026 17:19:02 -0400 Subject: [PATCH 20/33] Add tests for sitk --- tests/test_sitk.py | 636 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 636 insertions(+) create mode 100644 tests/test_sitk.py diff --git a/tests/test_sitk.py b/tests/test_sitk.py new file mode 100644 index 000000000..6a9a225bc --- /dev/null +++ b/tests/test_sitk.py @@ -0,0 +1,636 @@ +import numpy as np +import pydicom +import tempfile +import zipfile +import os +import urllib +import pytest + +from pathlib import Path +from typing import Sequence + +from pydicom.data import get_testdata_file + +from highdicom import ( + Volume, + get_volume_from_series, + imread, +) + +from highdicom.spatial import get_closest_patient_orientation + +from highdicom._dependency_utils import import_optional_dependency + +# itk = import_optional_dependency('itk', feature='itk tests') +sitk = import_optional_dependency('SimpleITK', feature='itk tests') + +def read_multiframe_ct_volume(): + im = imread(get_testdata_file('eCT_Supplemental.dcm')) + return im.get_volume(), im + + +def read_ct_series_volume(): + ct_files = [ + get_testdata_file('dicomdirtests/77654033/CT2/17136'), + get_testdata_file('dicomdirtests/77654033/CT2/17196'), + get_testdata_file('dicomdirtests/77654033/CT2/17166'), + ] + ct_series = [pydicom.dcmread(f) for f in ct_files] + return get_volume_from_series(ct_series), ct_series + +def read_github_zip_volume(url: str): + with tempfile.TemporaryDirectory() as temp_dir: + zipfilename = Path(temp_dir) / Path(url).name + urllib.request.urlretrieve(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')] + + return get_volume_from_series(series), series + +def read_github_series_volume(urls: Sequence[str]): + series = [] + with tempfile.TemporaryDirectory() as temp_dir: + for url in urls: + filename = Path(temp_dir) / Path(url).name + urllib.request.urlretrieve(url, filename) + + series.append(pydicom.dcmread(filename)) + + return get_volume_from_series(series), series + +# def read_github_itk(url: str): +# with tempfile.TemporaryDirectory() as temp_dir: +# filename = Path(temp_dir) / Path(url).name +# urllib.request.urlretrieve(url, filename) +# +# itk_im = itk.imread(filename) +# +# return itk_im + +def read_github_sitk(url: str): + with tempfile.TemporaryDirectory() as temp_dir: + filename = Path(temp_dir) / Path(url).name + urllib.request.urlretrieve(url, filename) + + sitk_im = sitk.ReadImage(filename) + + return sitk_im + +@pytest.mark.parametrize( + 'vol', + [ + # 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): + # itk_im = vol.to_itk() + sitk_im = vol.to_sitk() + + # assert np.allclose(vol.position, itk_im.GetOrigin(), atol=1e-4) + assert np.allclose(vol.position, sitk_im.GetOrigin(), atol=1e-4) + + # assert np.allclose(vol.spacing, itk_im.GetSpacing(), atol=1e-4) + assert np.allclose(vol.spacing, sitk_im.GetSpacing(), atol=1e-4) + + # assert np.allclose(vol.direction, itk_im.GetDirection(), atol=1e-4) + assert np.allclose(vol.direction.flatten(), sitk_im.GetDirection(), atol=1e-4) + + # assert (vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0)).all() + assert (vol.array == sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0)).all() + + # itk_roundtrip = Volume.from_itk(itk_im) + sitk_roundtrip = Volume.from_sitk(sitk_im) + + # assert np.allclose(vol.affine, itk_roundtrip.affine, atol=1e-4) + assert np.allclose(vol.affine, sitk_roundtrip.affine, atol=1e-4) + + # assert (vol.array == itk_roundtrip.array).all() + assert (vol.array == sitk_roundtrip.array).all() + +@pytest.mark.parametrize( + 'dtype', + [ + np.uint8, + np.uint16, + np.uint32, + np.uint64, + np.int8, + np.int16, + np.int32, + np.int64, + np.float16, + np.float32, + np.float64, + np.float128, + np.bool_ + ] +) +def test_dtype_sitk(dtype: np.dtype): + array = np.zeros((10, 10, 10), dtype=dtype) + 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=1.0, + coordinate_system='PATIENT', + ) + + if dtype == np.bool_: + sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) + assert sitk_roundtrip.dtype == np.uint8 + + elif dtype == np.float16: + with pytest.warns( + UserWarning, + match='SimpleITK does not support float16 data. Safely recasting to float32.' + ): + sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) + assert sitk_roundtrip.dtype == np.float32 + + elif dtype == np.float128: + with pytest.warns( + UserWarning, + match='SimpleITK does not support float128 data. Safely recasting to float64.' + ): + sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) + assert sitk_roundtrip.dtype == np.float64 + + volume.array[(0, 0, 0)] = 1.1 * np.float128(np.finfo(np.float64).max) + with pytest.raises( + ValueError, + match='SimpleITK does not support float128 data. Safely recasting to float64 is not possible.' + ): + sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) + + volume.array[(0, 0, 0)] = 1.1 * np.float128(np.finfo(np.float64).min) + with pytest.raises( + ValueError, + match='SimpleITK does not support float128 data. Safely recasting to float64 is not possible.' + ): + sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) + + else: + sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) + assert sitk_roundtrip.dtype == volume.dtype + +# @pytest.mark.parametrize( +# 'dtype', +# [ +# np.uint8, +# np.uint16, +# np.uint32, +# np.uint64, +# np.int8, +# np.int16, +# np.int32, +# np.int64, +# np.float16, +# np.float32, +# np.float64, +# np.float128, +# np.bool_ +# ] +# ) +# def test_dtype_itk(dtype): +# array = np.zeros((10, 10, 10), dtype=dtype) +# 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=1.0, +# coordinate_system='PATIENT', +# ) +# +# if dtype == np.bool_: +# itk_roundtrip = Volume.from_itk(volume.to_itk()) +# assert itk_roundtrip.dtype == np.uint8 +# +# elif dtype == np.int8: +# with pytest.warns( +# UserWarning, +# match='ITK does not support int8 data. Safely recasting to int16.' +# ): +# itk_roundtrip = Volume.from_itk(volume.to_itk()) +# assert itk_roundtrip.dtype == np.int16 +# +# elif dtype == np.int64: +# with pytest.warns( +# UserWarning, +# match='ITK does not support int64 data. Safely recasting to int32.' +# ): +# itk_roundtrip = Volume.from_itk(volume.to_itk()) +# assert itk_roundtrip.dtype == np.int32 +# +# volume.array[(0, 0, 0)] = np.int64(np.iinfo(np.int32).max) + 1 +# with pytest.raises( +# ValueError, +# match='ITK does not support int64 data. Safely recasting to int32 is not possible.' +# ): +# itk_roundtrip = Volume.from_itk(volume.to_itk()) +# +# volume.array[(0, 0, 0)] = np.int64(np.iinfo(np.int32).min) - 1 +# with pytest.raises( +# ValueError, +# match='ITK does not support int64 data. Safely recasting to int32 is not possible.' +# ): +# itk_roundtrip = Volume.from_itk(volume.to_itk()) +# +# elif dtype == np.float16: +# with pytest.warns( +# UserWarning, +# match='ITK does not support float16 data. Safely recasting to float32.' +# ): +# itk_roundtrip = Volume.from_itk(volume.to_itk()) +# assert itk_roundtrip.dtype == np.float32 +# +# elif dtype == np.float128: +# with pytest.warns( +# UserWarning, +# match='ITK does not support float128 data. Safely recasting to float64.' +# ): +# itk_roundtrip = Volume.from_itk(volume.to_itk()) +# assert itk_roundtrip.dtype == np.float64 +# +# volume.array[(0, 0, 0)] = 1.1 * np.float128(np.finfo(np.float64).max) +# with pytest.raises( +# ValueError, +# match='ITK does not support float128 data. Safely recasting to float64 is not possible.' +# ): +# itk_roundtrip = Volume.from_itk(volume.to_itk()) +# +# volume.array[(0, 0, 0)] = 1.1 * np.float128(np.finfo(np.float64).min) +# with pytest.raises( +# ValueError, +# match='ITK does not support float128 data. Safely recasting to float64 is not possible.' +# ): +# itk_roundtrip = Volume.from_itk(volume.to_itk()) +# +# else: +# itk_roundtrip = Volume.from_itk(volume.to_itk()) +# assert itk_roundtrip.dtype == volume.dtype + +@pytest.mark.parametrize( + 'zip_url,nifti_url', + [ + ( + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/2_t1_mp2rage_sag_p3_32.zip', + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_2_t1_mp2rage_sag_p3_32_INV1.nii.gz' + ), + ( + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/3_t1_mp2rage_sag_p3_32.zip', + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_3_t1_mp2rage_sag_p3_32_INV2.nii.gz' + ), + ( + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/4_t1_mp2rage_sag_p3_32.zip', + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_4_t1_mp2rage_sag_p3_32_UNI_Images.nii.gz' + ), + ( + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/5_HCP_T1.zip', + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_5_HCP_T1.nii.gz' + ), + ( + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/6_T1_mprage_ns_sag_p2.zip', + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_6_T1_mprage_ns_sag_p2.nii.gz' + ), + ( + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/8_T1_memprage_rms.zip', + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_8_T1_memprage_rms_RMS.nii.gz' + ), + ] +) +def test_nifti_equivalence_zip(zip_url: str, nifti_url: str): + vol, series = read_github_zip_volume(zip_url) + # itk_im = read_github_itk(nifti_url) + sitk_im = read_github_sitk(nifti_url) + + # orientation = get_closest_patient_orientation(np.reshape(itk_im.GetDirection(), (3, 3))) + orientation = get_closest_patient_orientation(np.reshape(sitk_im.GetDirection(), (3, 3))) + oriented_vol = vol.to_patient_orientation(orientation) + + # assert np.allclose(oriented_vol.position, itk_im.GetOrigin(), atol=1e-4) + assert np.allclose(oriented_vol.position, sitk_im.GetOrigin(), atol=1e-4) + + # assert np.allclose(oriented_vol.spacing, itk_im.GetSpacing(), atol=1e-4) + assert np.allclose(oriented_vol.spacing, sitk_im.GetSpacing(), atol=1e-4) + + # assert np.allclose(oriented_vol.direction, itk_im.GetDirection(), atol=1e-4) + assert np.allclose(oriented_vol.direction.flatten(), sitk_im.GetDirection(), atol=1e-4) + + # assert (oriented_vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0)).all() + assert (oriented_vol.array == sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0)).all() + + +@pytest.mark.parametrize( + 'dcm_urls,nifti_url', + [ + ( + [ + f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/2_me_FieldMap_GRE/{i:04d}.dcm' + for i in range(1, 37) + ], + 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_2_e1.nii' + ), + ( + [ + f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/2_me_FieldMap_GRE/{i:04d}_e2.dcm' + for i in range(1, 37) + ], + 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_2_e2.nii' + ), + ( + [ + f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/3_me_FieldMap_GRE/{i:04d}_e2_ph.dcm' + for i in range(1, 37) + ], + 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_3_e2_ph.nii' + ), + ( + [ + f'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/In/Siemens/VE11/{i:04d}.dcm' + for i in range(1, 36) + ], + 'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/Ref/Siemens_pd+t2_tse_sag_ISO_1.8mm_3_e1.nii' + ), + ( + [ + f'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/In/Siemens/VE11/{i:04d}_e2.dcm' + for i in range(36, 71) + ], + 'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/Ref/Siemens_pd+t2_tse_sag_ISO_1.8mm_3_e2.nii' + ), + ] +) +def test_nifti_equivalence_series(dcm_urls: Sequence[str], nifti_url: str): + vol, series = read_github_series_volume(dcm_urls) + # itk_im = read_github_itk(nifti_url) + sitk_im = read_github_sitk(nifti_url) + + # orientation = get_closest_patient_orientation(np.reshape(itk_im.GetDirection(), (3, 3))) + orientation = get_closest_patient_orientation(np.reshape(sitk_im.GetDirection(), (3, 3))) + oriented_vol = vol.to_patient_orientation(orientation) + + # assert np.allclose(oriented_vol.position, itk_im.GetOrigin(), atol=1e-4) + assert np.allclose(oriented_vol.position, sitk_im.GetOrigin(), atol=1e-4) + + # assert np.allclose(oriented_vol.spacing, itk_im.GetSpacing(), atol=1e-4) + assert np.allclose(oriented_vol.spacing, sitk_im.GetSpacing(), atol=1e-4) + + # assert np.allclose(oriented_vol.direction, itk_im.GetDirection(), atol=1e-4) + assert np.allclose(oriented_vol.direction.flatten(), sitk_im.GetDirection(), atol=1e-4) + + # assert (oriented_vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0)).all() + assert (oriented_vol.array == sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0)).all() From c616590598c1026973672823a870d8aa46e115a6 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Wed, 6 May 2026 17:43:12 -0400 Subject: [PATCH 21/33] flake8 --- tests/test_sitk.py | 298 +++++++++++++++++++++++---------------------- 1 file changed, 150 insertions(+), 148 deletions(-) diff --git a/tests/test_sitk.py b/tests/test_sitk.py index 6a9a225bc..6ccb8315f 100644 --- a/tests/test_sitk.py +++ b/tests/test_sitk.py @@ -2,7 +2,6 @@ import pydicom import tempfile import zipfile -import os import urllib import pytest @@ -24,6 +23,7 @@ # itk = import_optional_dependency('itk', feature='itk tests') sitk = import_optional_dependency('SimpleITK', feature='itk tests') + def read_multiframe_ct_volume(): im = imread(get_testdata_file('eCT_Supplemental.dcm')) return im.get_volume(), im @@ -38,6 +38,7 @@ def read_ct_series_volume(): ct_series = [pydicom.dcmread(f) for f in ct_files] return get_volume_from_series(ct_series), ct_series + def read_github_zip_volume(url: str): with tempfile.TemporaryDirectory() as temp_dir: zipfilename = Path(temp_dir) / Path(url).name @@ -50,6 +51,7 @@ def read_github_zip_volume(url: str): return get_volume_from_series(series), series + def read_github_series_volume(urls: Sequence[str]): series = [] with tempfile.TemporaryDirectory() as temp_dir: @@ -70,6 +72,7 @@ def read_github_series_volume(urls: Sequence[str]): # # return itk_im + def read_github_sitk(url: str): with tempfile.TemporaryDirectory() as temp_dir: filename = Path(temp_dir) / Path(url).name @@ -79,6 +82,7 @@ def read_github_sitk(url: str): return sitk_im + @pytest.mark.parametrize( 'vol', [ @@ -266,7 +270,7 @@ def read_github_sitk(url: str): ), Volume.from_attributes( array=np.random.randint(0, 100, (16, 16, 16)), - image_position=[ -21.03512292, 35.19549233, -184.42393696], + 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, @@ -274,7 +278,7 @@ def read_github_sitk(url: str): ), Volume.from_attributes( array=np.random.randint(0, 100, (16, 16, 16)), - image_position=[-197.36060235, 86.22231644, -14.79874245], + 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, @@ -284,8 +288,14 @@ def read_github_sitk(url: str): 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] -, + 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' @@ -293,7 +303,14 @@ def read_github_sitk(url: str): 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], + 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' @@ -301,7 +318,14 @@ def read_github_sitk(url: str): 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], + 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' @@ -309,8 +333,15 @@ def read_github_sitk(url: str): # 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], + 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' @@ -318,7 +349,14 @@ def read_github_sitk(url: str): 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], + 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' @@ -326,7 +364,14 @@ def read_github_sitk(url: str): 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], + 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' @@ -345,10 +390,18 @@ def test_roundtrip(vol: Volume): assert np.allclose(vol.spacing, sitk_im.GetSpacing(), atol=1e-4) # assert np.allclose(vol.direction, itk_im.GetDirection(), atol=1e-4) - assert np.allclose(vol.direction.flatten(), sitk_im.GetDirection(), atol=1e-4) + assert np.allclose( + vol.direction.flatten(), + sitk_im.GetDirection(), + atol=1e-4 + ) - # assert (vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0)).all() - assert (vol.array == sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0)).all() + # assert ( + # vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0) + # ).all() + assert ( + vol.array == sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0) + ).all() # itk_roundtrip = Volume.from_itk(itk_im) sitk_roundtrip = Volume.from_sitk(sitk_im) @@ -359,6 +412,7 @@ def test_roundtrip(vol: Volume): # assert (vol.array == itk_roundtrip.array).all() assert (vol.array == sitk_roundtrip.array).all() + @pytest.mark.parametrize( 'dtype', [ @@ -395,7 +449,10 @@ def test_dtype_sitk(dtype: np.dtype): elif dtype == np.float16: with pytest.warns( UserWarning, - match='SimpleITK does not support float16 data. Safely recasting to float32.' + match=( + 'SimpleITK does not support float16 data.' + ' Safely recasting to float32.' + ) ): sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) assert sitk_roundtrip.dtype == np.float32 @@ -403,7 +460,10 @@ def test_dtype_sitk(dtype: np.dtype): elif dtype == np.float128: with pytest.warns( UserWarning, - match='SimpleITK does not support float128 data. Safely recasting to float64.' + match=( + 'SimpleITK does not support float128 data.' + ' Safely recasting to float64.' + ) ): sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) assert sitk_roundtrip.dtype == np.float64 @@ -411,14 +471,20 @@ def test_dtype_sitk(dtype: np.dtype): volume.array[(0, 0, 0)] = 1.1 * np.float128(np.finfo(np.float64).max) with pytest.raises( ValueError, - match='SimpleITK does not support float128 data. Safely recasting to float64 is not possible.' + match=( + 'SimpleITK does not support float128 data.' + ' Safely recasting to float64 is not possible.' + ) ): sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) volume.array[(0, 0, 0)] = 1.1 * np.float128(np.finfo(np.float64).min) with pytest.raises( ValueError, - match='SimpleITK does not support float128 data. Safely recasting to float64 is not possible.' + match=( + 'SimpleITK does not support float128 data.' + ' Safely recasting to float64 is not possible.' + ) ): sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) @@ -426,129 +492,33 @@ def test_dtype_sitk(dtype: np.dtype): sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) assert sitk_roundtrip.dtype == volume.dtype -# @pytest.mark.parametrize( -# 'dtype', -# [ -# np.uint8, -# np.uint16, -# np.uint32, -# np.uint64, -# np.int8, -# np.int16, -# np.int32, -# np.int64, -# np.float16, -# np.float32, -# np.float64, -# np.float128, -# np.bool_ -# ] -# ) -# def test_dtype_itk(dtype): -# array = np.zeros((10, 10, 10), dtype=dtype) -# 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=1.0, -# coordinate_system='PATIENT', -# ) -# -# if dtype == np.bool_: -# itk_roundtrip = Volume.from_itk(volume.to_itk()) -# assert itk_roundtrip.dtype == np.uint8 -# -# elif dtype == np.int8: -# with pytest.warns( -# UserWarning, -# match='ITK does not support int8 data. Safely recasting to int16.' -# ): -# itk_roundtrip = Volume.from_itk(volume.to_itk()) -# assert itk_roundtrip.dtype == np.int16 -# -# elif dtype == np.int64: -# with pytest.warns( -# UserWarning, -# match='ITK does not support int64 data. Safely recasting to int32.' -# ): -# itk_roundtrip = Volume.from_itk(volume.to_itk()) -# assert itk_roundtrip.dtype == np.int32 -# -# volume.array[(0, 0, 0)] = np.int64(np.iinfo(np.int32).max) + 1 -# with pytest.raises( -# ValueError, -# match='ITK does not support int64 data. Safely recasting to int32 is not possible.' -# ): -# itk_roundtrip = Volume.from_itk(volume.to_itk()) -# -# volume.array[(0, 0, 0)] = np.int64(np.iinfo(np.int32).min) - 1 -# with pytest.raises( -# ValueError, -# match='ITK does not support int64 data. Safely recasting to int32 is not possible.' -# ): -# itk_roundtrip = Volume.from_itk(volume.to_itk()) -# -# elif dtype == np.float16: -# with pytest.warns( -# UserWarning, -# match='ITK does not support float16 data. Safely recasting to float32.' -# ): -# itk_roundtrip = Volume.from_itk(volume.to_itk()) -# assert itk_roundtrip.dtype == np.float32 -# -# elif dtype == np.float128: -# with pytest.warns( -# UserWarning, -# match='ITK does not support float128 data. Safely recasting to float64.' -# ): -# itk_roundtrip = Volume.from_itk(volume.to_itk()) -# assert itk_roundtrip.dtype == np.float64 -# -# volume.array[(0, 0, 0)] = 1.1 * np.float128(np.finfo(np.float64).max) -# with pytest.raises( -# ValueError, -# match='ITK does not support float128 data. Safely recasting to float64 is not possible.' -# ): -# itk_roundtrip = Volume.from_itk(volume.to_itk()) -# -# volume.array[(0, 0, 0)] = 1.1 * np.float128(np.finfo(np.float64).min) -# with pytest.raises( -# ValueError, -# match='ITK does not support float128 data. Safely recasting to float64 is not possible.' -# ): -# itk_roundtrip = Volume.from_itk(volume.to_itk()) -# -# else: -# itk_roundtrip = Volume.from_itk(volume.to_itk()) -# assert itk_roundtrip.dtype == volume.dtype @pytest.mark.parametrize( 'zip_url,nifti_url', [ ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/2_t1_mp2rage_sag_p3_32.zip', - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_2_t1_mp2rage_sag_p3_32_INV1.nii.gz' + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/2_t1_mp2rage_sag_p3_32.zip' # noqa: E501, + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_2_t1_mp2rage_sag_p3_32_INV1.nii.gz' # noqa: E501 ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/3_t1_mp2rage_sag_p3_32.zip', - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_3_t1_mp2rage_sag_p3_32_INV2.nii.gz' + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/3_t1_mp2rage_sag_p3_32.zip' # noqa: E501, + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_3_t1_mp2rage_sag_p3_32_INV2.nii.gz' # noqa: E501 ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/4_t1_mp2rage_sag_p3_32.zip', - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_4_t1_mp2rage_sag_p3_32_UNI_Images.nii.gz' + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/4_t1_mp2rage_sag_p3_32.zip' # noqa: E501, + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_4_t1_mp2rage_sag_p3_32_UNI_Images.nii.gz' # noqa: E501 ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/5_HCP_T1.zip', - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_5_HCP_T1.nii.gz' + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/5_HCP_T1.zip' # noqa: E501, + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_5_HCP_T1.nii.gz' # noqa: E501 ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/6_T1_mprage_ns_sag_p2.zip', - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_6_T1_mprage_ns_sag_p2.nii.gz' + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/6_T1_mprage_ns_sag_p2.zip' # noqa: E501, + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_6_T1_mprage_ns_sag_p2.nii.gz' # noqa: E501 ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/8_T1_memprage_rms.zip', - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_8_T1_memprage_rms_RMS.nii.gz' + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/8_T1_memprage_rms.zip' # noqa: E501, + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_8_T1_memprage_rms_RMS.nii.gz' # noqa: E501 ), ] ) @@ -557,8 +527,12 @@ def test_nifti_equivalence_zip(zip_url: str, nifti_url: str): # itk_im = read_github_itk(nifti_url) sitk_im = read_github_sitk(nifti_url) - # orientation = get_closest_patient_orientation(np.reshape(itk_im.GetDirection(), (3, 3))) - orientation = get_closest_patient_orientation(np.reshape(sitk_im.GetDirection(), (3, 3))) + # orientation = get_closest_patient_orientation( + # np.reshape(itk_im.GetDirection(), (3, 3)) + # ) + orientation = get_closest_patient_orientation( + np.reshape(sitk_im.GetDirection(), (3, 3)) + ) oriented_vol = vol.to_patient_orientation(orientation) # assert np.allclose(oriented_vol.position, itk_im.GetOrigin(), atol=1e-4) @@ -567,11 +541,23 @@ def test_nifti_equivalence_zip(zip_url: str, nifti_url: str): # assert np.allclose(oriented_vol.spacing, itk_im.GetSpacing(), atol=1e-4) assert np.allclose(oriented_vol.spacing, sitk_im.GetSpacing(), atol=1e-4) - # assert np.allclose(oriented_vol.direction, itk_im.GetDirection(), atol=1e-4) - assert np.allclose(oriented_vol.direction.flatten(), sitk_im.GetDirection(), atol=1e-4) + # assert np.allclose( + # oriented_vol.direction, + # itk_im.GetDirection(), + # atol=1e-4 + # ) + assert np.allclose( + oriented_vol.direction.flatten(), + sitk_im.GetDirection(), + atol=1e-4 + ) - # assert (oriented_vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0)).all() - assert (oriented_vol.array == sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0)).all() + # assert ( + # oriented_vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0) + # ).all() + assert ( + oriented_vol.array == sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0) + ).all() @pytest.mark.parametrize( @@ -579,38 +565,38 @@ def test_nifti_equivalence_zip(zip_url: str, nifti_url: str): [ ( [ - f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/2_me_FieldMap_GRE/{i:04d}.dcm' + f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/2_me_FieldMap_GRE/{i:04d}.dcm' # noqa: E501 for i in range(1, 37) ], - 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_2_e1.nii' + 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_2_e1.nii' # noqa: E501 ), ( [ - f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/2_me_FieldMap_GRE/{i:04d}_e2.dcm' + f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/2_me_FieldMap_GRE/{i:04d}_e2.dcm' # noqa: E501 for i in range(1, 37) ], - 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_2_e2.nii' + 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_2_e2.nii' # noqa: E501 ), ( [ - f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/3_me_FieldMap_GRE/{i:04d}_e2_ph.dcm' + f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/3_me_FieldMap_GRE/{i:04d}_e2_ph.dcm' # noqa: E501 for i in range(1, 37) ], - 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_3_e2_ph.nii' + 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_3_e2_ph.nii' # noqa: E501 ), ( [ - f'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/In/Siemens/VE11/{i:04d}.dcm' + f'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/In/Siemens/VE11/{i:04d}.dcm' # noqa: E501 for i in range(1, 36) ], - 'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/Ref/Siemens_pd+t2_tse_sag_ISO_1.8mm_3_e1.nii' + 'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/Ref/Siemens_pd+t2_tse_sag_ISO_1.8mm_3_e1.nii' # noqa: E501 ), ( [ - f'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/In/Siemens/VE11/{i:04d}_e2.dcm' + f'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/In/Siemens/VE11/{i:04d}_e2.dcm' # noqa: E501 for i in range(36, 71) ], - 'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/Ref/Siemens_pd+t2_tse_sag_ISO_1.8mm_3_e2.nii' + 'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/Ref/Siemens_pd+t2_tse_sag_ISO_1.8mm_3_e2.nii' # noqa: E501 ), ] ) @@ -619,8 +605,12 @@ def test_nifti_equivalence_series(dcm_urls: Sequence[str], nifti_url: str): # itk_im = read_github_itk(nifti_url) sitk_im = read_github_sitk(nifti_url) - # orientation = get_closest_patient_orientation(np.reshape(itk_im.GetDirection(), (3, 3))) - orientation = get_closest_patient_orientation(np.reshape(sitk_im.GetDirection(), (3, 3))) + # orientation = get_closest_patient_orientation( + # np.reshape(itk_im.GetDirection(), (3, 3)) + # ) + orientation = get_closest_patient_orientation( + np.reshape(sitk_im.GetDirection(), (3, 3)) + ) oriented_vol = vol.to_patient_orientation(orientation) # assert np.allclose(oriented_vol.position, itk_im.GetOrigin(), atol=1e-4) @@ -629,8 +619,20 @@ def test_nifti_equivalence_series(dcm_urls: Sequence[str], nifti_url: str): # assert np.allclose(oriented_vol.spacing, itk_im.GetSpacing(), atol=1e-4) assert np.allclose(oriented_vol.spacing, sitk_im.GetSpacing(), atol=1e-4) - # assert np.allclose(oriented_vol.direction, itk_im.GetDirection(), atol=1e-4) - assert np.allclose(oriented_vol.direction.flatten(), sitk_im.GetDirection(), atol=1e-4) + # assert np.allclose( + # oriented_vol.direction, + # itk_im.GetDirection(), + # atol=1e-4 + # ) + assert np.allclose( + oriented_vol.direction.flatten(), + sitk_im.GetDirection(), + atol=1e-4 + ) - # assert (oriented_vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0)).all() - assert (oriented_vol.array == sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0)).all() + # assert ( + # oriented_vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0) + # ).all() + assert ( + oriented_vol.array == sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0) + ).all() From ad3fbcd6280ff2ba47c92d9591f1e7cee2f1582c Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Wed, 6 May 2026 17:48:17 -0400 Subject: [PATCH 22/33] skip sitk test on failed import --- tests/test_sitk.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_sitk.py b/tests/test_sitk.py index 6ccb8315f..0a062e227 100644 --- a/tests/test_sitk.py +++ b/tests/test_sitk.py @@ -21,7 +21,12 @@ from highdicom._dependency_utils import import_optional_dependency # itk = import_optional_dependency('itk', feature='itk tests') -sitk = import_optional_dependency('SimpleITK', feature='itk tests') + +try: + sitk = import_optional_dependency('SimpleITK', feature='itk tests') + +except Exception: + pytest.skip("Optional dependency not available", allow_module_level=True) def read_multiframe_ct_volume(): From 60f200065c907443eec53c321c7be379ad817b3f Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Wed, 6 May 2026 17:59:55 -0400 Subject: [PATCH 23/33] fix url syntax --- tests/test_sitk.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_sitk.py b/tests/test_sitk.py index 0a062e227..c34f3f4cb 100644 --- a/tests/test_sitk.py +++ b/tests/test_sitk.py @@ -502,27 +502,27 @@ def test_dtype_sitk(dtype: np.dtype): 'zip_url,nifti_url', [ ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/2_t1_mp2rage_sag_p3_32.zip' # noqa: E501, + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/2_t1_mp2rage_sag_p3_32.zip', # noqa: E501 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_2_t1_mp2rage_sag_p3_32_INV1.nii.gz' # noqa: E501 ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/3_t1_mp2rage_sag_p3_32.zip' # noqa: E501, + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/3_t1_mp2rage_sag_p3_32.zip', # noqa: E501 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_3_t1_mp2rage_sag_p3_32_INV2.nii.gz' # noqa: E501 ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/4_t1_mp2rage_sag_p3_32.zip' # noqa: E501, + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/4_t1_mp2rage_sag_p3_32.zip', # noqa: E501 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_4_t1_mp2rage_sag_p3_32_UNI_Images.nii.gz' # noqa: E501 ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/5_HCP_T1.zip' # noqa: E501, + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/5_HCP_T1.zip', # noqa: E501 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_5_HCP_T1.nii.gz' # noqa: E501 ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/6_T1_mprage_ns_sag_p2.zip' # noqa: E501, + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/6_T1_mprage_ns_sag_p2.zip', # noqa: E501 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_6_T1_mprage_ns_sag_p2.nii.gz' # noqa: E501 ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/8_T1_memprage_rms.zip' # noqa: E501, + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/8_T1_memprage_rms.zip', # noqa: E501 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_8_T1_memprage_rms_RMS.nii.gz' # noqa: E501 ), ] From 02b65fca4791dc171b1d82b79972c14662941541 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Thu, 7 May 2026 15:12:05 -0400 Subject: [PATCH 24/33] Fix itk conversion and add tests --- src/highdicom/volume.py | 10 +- tests/test_itk.py | 625 ++++++++++++++++++++++++++++++++++++++++ tests/test_sitk.py | 72 +---- 3 files changed, 636 insertions(+), 71 deletions(-) create mode 100644 tests/test_itk.py diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index ad630048c..49e65acc7 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -3691,7 +3691,7 @@ def to_itk(self) -> 'itk.Image': # noqa: F821 feature=f'{func.__module__}.{func.__qualname__}' ) - array = self.array.transpose(2, 1, 0) + array = self.array.transpose(2, 1, 0).copy() if array.dtype == np.bool_: array = array.astype(np.uint8) @@ -3714,7 +3714,7 @@ def to_itk(self) -> 'itk.Image': # noqa: F821 else: raise ValueError( - 'ITK does not support int64 data. ' + 'ITK does not support int64 data.' ' Safely recasting to int32 is not possible.' ) @@ -3729,14 +3729,14 @@ def to_itk(self) -> 'itk.Image': # noqa: F821 f64 = np.finfo(np.float64) if array.min() >= f64.min and array.max() <= f64.max: warnings.warn( - 'ITK does not support float128 data. ' + 'ITK does not support float128 data.' ' Safely recasting to float64.' ) array = array.astype(np.float64) else: raise ValueError( - 'ITK does not support float128 data. ' + 'ITK does not support float128 data.' ' Safely recasting to float64 is not possible.' ) @@ -3778,7 +3778,7 @@ def from_itk( feature=f'{func.__module__}.{func.__qualname__}' ) - array = itk.GetArrayFromImage(itk_im).transpose(2, 1, 0) + array = itk.GetArrayFromImage(itk_im).transpose(2, 1, 0).copy() return cls.from_components( array=array, diff --git a/tests/test_itk.py b/tests/test_itk.py new file mode 100644 index 000000000..2faaf44e8 --- /dev/null +++ b/tests/test_itk.py @@ -0,0 +1,625 @@ +import numpy as np +import pydicom +import tempfile +import zipfile +import urllib +import pytest + +from pathlib import Path +from typing import Sequence +from pydicom.data import get_testdata_file +from highdicom import ( + Volume, + get_volume_from_series, + imread, +) +from highdicom.spatial import get_closest_patient_orientation +from highdicom._dependency_utils import import_optional_dependency + +try: + itk = import_optional_dependency('itk', feature='itk tests') + +except Exception: + pytest.skip("Optional dependency not available", allow_module_level=True) + + +def read_multiframe_ct_volume(): + im = imread(get_testdata_file('eCT_Supplemental.dcm')) + return im.get_volume() + + +def read_ct_series_volume(): + ct_files = [ + get_testdata_file('dicomdirtests/77654033/CT2/17136'), + get_testdata_file('dicomdirtests/77654033/CT2/17196'), + get_testdata_file('dicomdirtests/77654033/CT2/17166'), + ] + ct_series = [pydicom.dcmread(f) for f in ct_files] + return get_volume_from_series(ct_series) + + +def read_github_zip_volume(url: str): + with tempfile.TemporaryDirectory() as temp_dir: + zipfilename = Path(temp_dir) / Path(url).name + urllib.request.urlretrieve(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')] + + return get_volume_from_series(series), series + + +def read_github_series_volume(urls: Sequence[str]): + series = [] + with tempfile.TemporaryDirectory() as temp_dir: + for url in urls: + filename = Path(temp_dir) / Path(url).name + urllib.request.urlretrieve(url, filename) + + series.append(pydicom.dcmread(filename)) + + return get_volume_from_series(series), series + + +def read_github_itk(url: str): + with tempfile.TemporaryDirectory() as temp_dir: + filename = Path(temp_dir) / Path(url).name + urllib.request.urlretrieve(url, filename) + + itk_im = itk.imread(filename) + + return itk_im + + +@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): + itk_im = vol.to_itk() + + assert np.allclose(vol.position, itk_im.GetOrigin(), atol=1e-4) + assert np.allclose(vol.spacing, itk_im.GetSpacing(), atol=1e-4) + assert np.allclose( + vol.direction, + itk_im.GetDirection(), + atol=1e-4 + ) + assert ( + vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0) + ).all() + + itk_roundtrip = Volume.from_itk(itk_im) + + assert np.allclose(vol.affine, itk_roundtrip.affine, atol=1e-4) + assert (vol.array == itk_roundtrip.array).all() + + +@pytest.mark.parametrize( + 'dtype', + [ + np.uint8, + np.uint16, + np.uint32, + np.uint64, + np.int8, + np.int16, + np.int32, + np.int64, + np.float16, + np.float32, + np.float64, + np.float128, + np.bool_ + ] +) +def test_dtype_itk(dtype): + array = np.zeros((10, 10, 10), dtype=dtype) + 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=1.0, + coordinate_system='PATIENT', + ) + + if dtype == np.bool_: + itk_roundtrip = Volume.from_itk(volume.to_itk()) + assert itk_roundtrip.dtype == np.uint8 + + elif dtype == np.int8: + with pytest.warns( + UserWarning, + match=( + 'ITK does not support int8 data.' + ' Safely recasting to int16.' + ) + ): + itk_roundtrip = Volume.from_itk(volume.to_itk()) + assert itk_roundtrip.dtype == np.int16 + + elif dtype == np.int64: + with pytest.warns( + UserWarning, + match=( + 'ITK does not support int64 data.' + ' Safely recasting to int32.' + ) + ): + itk_roundtrip = Volume.from_itk(volume.to_itk()) + assert itk_roundtrip.dtype == np.int32 + + volume.array[(0, 0, 0)] = np.int64(np.iinfo(np.int32).max) + 1 + with pytest.raises( + ValueError, + match=( + 'ITK does not support int64 data.' + ' Safely recasting to int32 is not possible.' + ) + ): + itk_roundtrip = Volume.from_itk(volume.to_itk()) + + volume.array[(0, 0, 0)] = np.int64(np.iinfo(np.int32).min) - 1 + with pytest.raises( + ValueError, + match=( + 'ITK does not support int64 data.' + ' Safely recasting to int32 is not possible.' + ) + ): + itk_roundtrip = Volume.from_itk(volume.to_itk()) + + elif dtype == np.float16: + with pytest.warns( + UserWarning, + match=( + 'ITK does not support float16 data.' + ' Safely recasting to float32.' + ) + ): + itk_roundtrip = Volume.from_itk(volume.to_itk()) + assert itk_roundtrip.dtype == np.float32 + + elif dtype == np.float128: + with pytest.warns( + UserWarning, + match=( + 'ITK does not support float128 data.' + ' Safely recasting to float64.' + ) + ): + itk_roundtrip = Volume.from_itk(volume.to_itk()) + assert itk_roundtrip.dtype == np.float64 + + volume.array[(0, 0, 0)] = 1.1 * np.float128(np.finfo(np.float64).max) + with pytest.raises( + ValueError, + match=( + 'ITK does not support float128 data.' + ' Safely recasting to float64 is not possible.' + ) + ): + itk_roundtrip = Volume.from_itk(volume.to_itk()) + + volume.array[(0, 0, 0)] = 1.1 * np.float128(np.finfo(np.float64).min) + with pytest.raises( + ValueError, + match=( + 'ITK does not support float128 data.' + ' Safely recasting to float64 is not possible.' + ) + ): + itk_roundtrip = Volume.from_itk(volume.to_itk()) + + else: + itk_roundtrip = Volume.from_itk(volume.to_itk()) + assert itk_roundtrip.dtype == volume.dtype + + +@pytest.mark.parametrize( + 'zip_url,nifti_url', + [ + ( + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/2_t1_mp2rage_sag_p3_32.zip', # noqa: E501 + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_2_t1_mp2rage_sag_p3_32_INV1.nii.gz' # noqa: E501 + ), + ( + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/3_t1_mp2rage_sag_p3_32.zip', # noqa: E501 + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_3_t1_mp2rage_sag_p3_32_INV2.nii.gz' # noqa: E501 + ), + ( + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/4_t1_mp2rage_sag_p3_32.zip', # noqa: E501 + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_4_t1_mp2rage_sag_p3_32_UNI_Images.nii.gz' # noqa: E501 + ), + ( + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/5_HCP_T1.zip', # noqa: E501 + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_5_HCP_T1.nii.gz' # noqa: E501 + ), + ( + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/6_T1_mprage_ns_sag_p2.zip', # noqa: E501 + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_6_T1_mprage_ns_sag_p2.nii.gz' # noqa: E501 + ), + ( + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/8_T1_memprage_rms.zip', # noqa: E501 + 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_8_T1_memprage_rms_RMS.nii.gz' # noqa: E501 + ), + ] +) +def test_nifti_equivalence_zip(zip_url: str, nifti_url: str): + vol, series = read_github_zip_volume(zip_url) + itk_im = read_github_itk(nifti_url) + + orientation = get_closest_patient_orientation( + np.reshape(itk_im.GetDirection(), (3, 3)) + ) + oriented_vol = vol.to_patient_orientation(orientation) + + assert np.allclose(oriented_vol.position, itk_im.GetOrigin(), atol=1e-4) + assert np.allclose(oriented_vol.spacing, itk_im.GetSpacing(), atol=1e-4) + assert np.allclose( + oriented_vol.direction, + itk_im.GetDirection(), + atol=1e-4 + ) + assert ( + oriented_vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0) + ).all() + + +@pytest.mark.parametrize( + 'dcm_urls,nifti_url', + [ + ( + [ + f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/2_me_FieldMap_GRE/{i:04d}.dcm' # noqa: E501 + for i in range(1, 37) + ], + 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_2_e1.nii' # noqa: E501 + ), + ( + [ + f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/2_me_FieldMap_GRE/{i:04d}_e2.dcm' # noqa: E501 + for i in range(1, 37) + ], + 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_2_e2.nii' # noqa: E501 + ), + ( + [ + f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/3_me_FieldMap_GRE/{i:04d}_e2_ph.dcm' # noqa: E501 + for i in range(1, 37) + ], + 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_3_e2_ph.nii' # noqa: E501 + ), + ( + [ + f'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/In/Siemens/VE11/{i:04d}.dcm' # noqa: E501 + for i in range(1, 36) + ], + 'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/Ref/Siemens_pd+t2_tse_sag_ISO_1.8mm_3_e1.nii' # noqa: E501 + ), + ( + [ + f'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/In/Siemens/VE11/{i:04d}_e2.dcm' # noqa: E501 + for i in range(36, 71) + ], + 'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/Ref/Siemens_pd+t2_tse_sag_ISO_1.8mm_3_e2.nii' # noqa: E501 + ), + ] +) +def test_nifti_equivalence_series(dcm_urls: Sequence[str], nifti_url: str): + vol, series = read_github_series_volume(dcm_urls) + itk_im = read_github_itk(nifti_url) + + orientation = get_closest_patient_orientation( + np.reshape(itk_im.GetDirection(), (3, 3)) + ) + oriented_vol = vol.to_patient_orientation(orientation) + + assert np.allclose(oriented_vol.position, itk_im.GetOrigin(), atol=1e-4) + assert np.allclose(oriented_vol.spacing, itk_im.GetSpacing(), atol=1e-4) + assert np.allclose( + oriented_vol.direction, + itk_im.GetDirection(), + atol=1e-4 + ) + assert ( + oriented_vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0) + ).all() diff --git a/tests/test_sitk.py b/tests/test_sitk.py index c34f3f4cb..0c8b41c93 100644 --- a/tests/test_sitk.py +++ b/tests/test_sitk.py @@ -7,23 +7,17 @@ from pathlib import Path from typing import Sequence - from pydicom.data import get_testdata_file - from highdicom import ( Volume, get_volume_from_series, imread, ) - from highdicom.spatial import get_closest_patient_orientation - from highdicom._dependency_utils import import_optional_dependency -# itk = import_optional_dependency('itk', feature='itk tests') - try: - sitk = import_optional_dependency('SimpleITK', feature='itk tests') + sitk = import_optional_dependency('SimpleITK', feature='sitk tests') except Exception: pytest.skip("Optional dependency not available", allow_module_level=True) @@ -31,7 +25,7 @@ def read_multiframe_ct_volume(): im = imread(get_testdata_file('eCT_Supplemental.dcm')) - return im.get_volume(), im + return im.get_volume() def read_ct_series_volume(): @@ -41,7 +35,7 @@ def read_ct_series_volume(): get_testdata_file('dicomdirtests/77654033/CT2/17166'), ] ct_series = [pydicom.dcmread(f) for f in ct_files] - return get_volume_from_series(ct_series), ct_series + return get_volume_from_series(ct_series) def read_github_zip_volume(url: str): @@ -68,15 +62,6 @@ def read_github_series_volume(urls: Sequence[str]): return get_volume_from_series(series), series -# def read_github_itk(url: str): -# with tempfile.TemporaryDirectory() as temp_dir: -# filename = Path(temp_dir) / Path(url).name -# urllib.request.urlretrieve(url, filename) -# -# itk_im = itk.imread(filename) -# -# return itk_im - def read_github_sitk(url: str): with tempfile.TemporaryDirectory() as temp_dir: @@ -91,6 +76,9 @@ def read_github_sitk(url: str): @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)), @@ -385,36 +373,22 @@ def read_github_sitk(url: str): ] ) def test_roundtrip(vol: Volume): - # itk_im = vol.to_itk() sitk_im = vol.to_sitk() - # assert np.allclose(vol.position, itk_im.GetOrigin(), atol=1e-4) assert np.allclose(vol.position, sitk_im.GetOrigin(), atol=1e-4) - - # assert np.allclose(vol.spacing, itk_im.GetSpacing(), atol=1e-4) assert np.allclose(vol.spacing, sitk_im.GetSpacing(), atol=1e-4) - - # assert np.allclose(vol.direction, itk_im.GetDirection(), atol=1e-4) assert np.allclose( vol.direction.flatten(), sitk_im.GetDirection(), atol=1e-4 ) - - # assert ( - # vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0) - # ).all() assert ( vol.array == sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0) ).all() - # itk_roundtrip = Volume.from_itk(itk_im) sitk_roundtrip = Volume.from_sitk(sitk_im) - # assert np.allclose(vol.affine, itk_roundtrip.affine, atol=1e-4) assert np.allclose(vol.affine, sitk_roundtrip.affine, atol=1e-4) - - # assert (vol.array == itk_roundtrip.array).all() assert (vol.array == sitk_roundtrip.array).all() @@ -529,37 +503,20 @@ def test_dtype_sitk(dtype: np.dtype): ) def test_nifti_equivalence_zip(zip_url: str, nifti_url: str): vol, series = read_github_zip_volume(zip_url) - # itk_im = read_github_itk(nifti_url) sitk_im = read_github_sitk(nifti_url) - # orientation = get_closest_patient_orientation( - # np.reshape(itk_im.GetDirection(), (3, 3)) - # ) orientation = get_closest_patient_orientation( np.reshape(sitk_im.GetDirection(), (3, 3)) ) oriented_vol = vol.to_patient_orientation(orientation) - # assert np.allclose(oriented_vol.position, itk_im.GetOrigin(), atol=1e-4) assert np.allclose(oriented_vol.position, sitk_im.GetOrigin(), atol=1e-4) - - # assert np.allclose(oriented_vol.spacing, itk_im.GetSpacing(), atol=1e-4) assert np.allclose(oriented_vol.spacing, sitk_im.GetSpacing(), atol=1e-4) - - # assert np.allclose( - # oriented_vol.direction, - # itk_im.GetDirection(), - # atol=1e-4 - # ) assert np.allclose( oriented_vol.direction.flatten(), sitk_im.GetDirection(), atol=1e-4 ) - - # assert ( - # oriented_vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0) - # ).all() assert ( oriented_vol.array == sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0) ).all() @@ -607,37 +564,20 @@ def test_nifti_equivalence_zip(zip_url: str, nifti_url: str): ) def test_nifti_equivalence_series(dcm_urls: Sequence[str], nifti_url: str): vol, series = read_github_series_volume(dcm_urls) - # itk_im = read_github_itk(nifti_url) sitk_im = read_github_sitk(nifti_url) - # orientation = get_closest_patient_orientation( - # np.reshape(itk_im.GetDirection(), (3, 3)) - # ) orientation = get_closest_patient_orientation( np.reshape(sitk_im.GetDirection(), (3, 3)) ) oriented_vol = vol.to_patient_orientation(orientation) - # assert np.allclose(oriented_vol.position, itk_im.GetOrigin(), atol=1e-4) assert np.allclose(oriented_vol.position, sitk_im.GetOrigin(), atol=1e-4) - - # assert np.allclose(oriented_vol.spacing, itk_im.GetSpacing(), atol=1e-4) assert np.allclose(oriented_vol.spacing, sitk_im.GetSpacing(), atol=1e-4) - - # assert np.allclose( - # oriented_vol.direction, - # itk_im.GetDirection(), - # atol=1e-4 - # ) assert np.allclose( oriented_vol.direction.flatten(), sitk_im.GetDirection(), atol=1e-4 ) - - # assert ( - # oriented_vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0) - # ).all() assert ( oriented_vol.array == sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0) ).all() From c3bfb69f15d5626f06b2696a89b1e5d829297c94 Mon Sep 17 00:00:00 2001 From: "Mason C. Cleveland" <104479423+mccle@users.noreply.github.com> Date: Thu, 7 May 2026 15:27:56 -0400 Subject: [PATCH 25/33] Update .github/workflows/run_unit_tests.yml Co-authored-by: Chris Bridge --- .github/workflows/run_unit_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml index fa75036ce..e22eb6fe1 100644 --- a/.github/workflows/run_unit_tests.yml +++ b/.github/workflows/run_unit_tests.yml @@ -19,7 +19,7 @@ jobs: # and the likelihood of 3.12-specific bugs is considered low at this # stage python-version: ["3.10", "3.11", "3.13", "3.14"] - dependencies: [".", "'.[libjpeg]'", "'.[itk]'", "'.[sitk]'"] + dependencies: [".", "'.[libjpeg,itk,sitk]'"] env: # Set this otherwise coverage on python 3.12 is absurdly slow From f72e2517c0fa79b11404f2766889a5575383eec1 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 8 May 2026 16:26:01 -0400 Subject: [PATCH 26/33] Replace common url elements --- tests/test_itk.py | 4 ++++ tests/test_sitk.py | 48 +++++++++++++++++++++++++--------------------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/tests/test_itk.py b/tests/test_itk.py index 2faaf44e8..d4e5641f0 100644 --- a/tests/test_itk.py +++ b/tests/test_itk.py @@ -22,6 +22,10 @@ except Exception: pytest.skip("Optional dependency not available", allow_module_level=True) +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' + def read_multiframe_ct_volume(): im = imread(get_testdata_file('eCT_Supplemental.dcm')) diff --git a/tests/test_sitk.py b/tests/test_sitk.py index 0c8b41c93..93994e2f0 100644 --- a/tests/test_sitk.py +++ b/tests/test_sitk.py @@ -22,6 +22,10 @@ except Exception: pytest.skip("Optional dependency not available", allow_module_level=True) +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' + def read_multiframe_ct_volume(): im = imread(get_testdata_file('eCT_Supplemental.dcm')) @@ -476,28 +480,28 @@ def test_dtype_sitk(dtype: np.dtype): 'zip_url,nifti_url', [ ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/2_t1_mp2rage_sag_p3_32.zip', # noqa: E501 - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_2_t1_mp2rage_sag_p3_32_INV1.nii.gz' # noqa: E501 + f'{DCM_QA_MPRAGE}/In/2_t1_mp2rage_sag_p3_32.zip', + f'{DCM_QA_MPRAGE}/Ref/Si_2_t1_mp2rage_sag_p3_32_INV1.nii.gz' ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/3_t1_mp2rage_sag_p3_32.zip', # noqa: E501 - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_3_t1_mp2rage_sag_p3_32_INV2.nii.gz' # noqa: E501 + f'{DCM_QA_MPRAGE}/In/3_t1_mp2rage_sag_p3_32.zip', + f'{DCM_QA_MPRAGE}/Ref/Si_3_t1_mp2rage_sag_p3_32_INV2.nii.gz' ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/4_t1_mp2rage_sag_p3_32.zip', # noqa: E501 - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_4_t1_mp2rage_sag_p3_32_UNI_Images.nii.gz' # noqa: E501 + f'{DCM_QA_MPRAGE}/In/4_t1_mp2rage_sag_p3_32.zip', + f'{DCM_QA_MPRAGE}/Ref/Si_4_t1_mp2rage_sag_p3_32_UNI_Images.nii.gz' ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/5_HCP_T1.zip', # noqa: E501 - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_5_HCP_T1.nii.gz' # noqa: E501 + f'{DCM_QA_MPRAGE}/In/5_HCP_T1.zip', + f'{DCM_QA_MPRAGE}/Ref/Si_5_HCP_T1.nii.gz' ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/6_T1_mprage_ns_sag_p2.zip', # noqa: E501 - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_6_T1_mprage_ns_sag_p2.nii.gz' # noqa: E501 + f'{DCM_QA_MPRAGE}/In/6_T1_mprage_ns_sag_p2.zip', + f'{DCM_QA_MPRAGE}/Ref/Si_6_T1_mprage_ns_sag_p2.nii.gz' ), ( - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/In/8_T1_memprage_rms.zip', # noqa: E501 - 'https://github.com/neurolabusc/dcm_qa_mprage/raw/refs/heads/main/Ref/Si_8_T1_memprage_rms_RMS.nii.gz' # noqa: E501 + f'{DCM_QA_MPRAGE}/In/8_T1_memprage_rms.zip', + f'{DCM_QA_MPRAGE}/Ref/Si_8_T1_memprage_rms_RMS.nii.gz' ), ] ) @@ -527,38 +531,38 @@ def test_nifti_equivalence_zip(zip_url: str, nifti_url: str): [ ( [ - f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/2_me_FieldMap_GRE/{i:04d}.dcm' # noqa: E501 + f'{DCM_QA_ME}/In/2_me_FieldMap_GRE/{i:04d}.dcm' for i in range(1, 37) ], - 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_2_e1.nii' # noqa: E501 + f'{DCM_QA_ME}/Ref/me_FieldMap_GRE_2_e1.nii' ), ( [ - f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/2_me_FieldMap_GRE/{i:04d}_e2.dcm' # noqa: E501 + f'{DCM_QA_ME}/In/2_me_FieldMap_GRE/{i:04d}_e2.dcm' for i in range(1, 37) ], - 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_2_e2.nii' # noqa: E501 + f'{DCM_QA_ME}/Ref/me_FieldMap_GRE_2_e2.nii' ), ( [ - f'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/In/3_me_FieldMap_GRE/{i:04d}_e2_ph.dcm' # noqa: E501 + f'{DCM_QA_ME}/In/3_me_FieldMap_GRE/{i:04d}_e2_ph.dcm' for i in range(1, 37) ], - 'https://github.com/neurolabusc/dcm_qa_me/raw/refs/heads/master/Ref/me_FieldMap_GRE_3_e2_ph.nii' # noqa: E501 + f'{DCM_QA_ME}/Ref/me_FieldMap_GRE_3_e2_ph.nii' ), ( [ - f'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/In/Siemens/VE11/{i:04d}.dcm' # noqa: E501 + f'{DCM_QA_PDT2}/In/Siemens/VE11/{i:04d}.dcm' for i in range(1, 36) ], - 'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/Ref/Siemens_pd+t2_tse_sag_ISO_1.8mm_3_e1.nii' # noqa: E501 + f'{DCM_QA_PDT2}/Ref/Siemens_pd+t2_tse_sag_ISO_1.8mm_3_e1.nii' ), ( [ - f'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/In/Siemens/VE11/{i:04d}_e2.dcm' # noqa: E501 + f'{DCM_QA_PDT2}/In/Siemens/VE11/{i:04d}_e2.dcm' for i in range(36, 71) ], - 'https://github.com/neurolabusc/dcm_qa_pdt2/raw/refs/heads/main/Ref/Siemens_pd+t2_tse_sag_ISO_1.8mm_3_e2.nii' # noqa: E501 + f'{DCM_QA_PDT2}/Ref/Siemens_pd+t2_tse_sag_ISO_1.8mm_3_e2.nii' ), ] ) From 0bbbca7023739bc7bddc8194c9810532b0183b9e Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Thu, 14 May 2026 22:16:45 -0400 Subject: [PATCH 27/33] Update docstrings and error messages --- src/highdicom/volume.py | 117 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 107 insertions(+), 10 deletions(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 49e65acc7..290b89d16 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -3586,13 +3586,37 @@ def pad_array(array: np.ndarray, cval: float) -> float: ) def to_sitk(self) -> 'SimpleITK.Image': # noqa: F821 - """Convert the Volume to `SimpleITK.Image` format. + """Convert the Volume to ``SimpleITK.Image`` format. + + The Volume is converted to a 3D ``SimpleITK.image``. If + its array's current datatype is not supported by SimpleITK, + it is safely cast to a compatible type where possible. If + impossible to cast safely, a ``ValueError`` is raised. + Casting is performed on the following data types: + + - ``bool`` -> ``uint8`` + - ``float16`` -> ``float32`` (with warning) + - ``float128`` -> ``float64`` (with warning if possible, + else raises error) + + Spatial metadata (spacing, direction, origin) is preserved. + As SimpleITK uses a different dimension convention (zyx) + than NumPy (xyz), this method automatically applies a + transpose to the array. Returns ------- SimpleITK.Image: Image constructed from the Volume. + Raises + ------ + ValueError + When the volume is not 3D (multiple channels are unsupported). + ValueError + When the array's current datatype is not supported + and it is not possible to safely cast to a new datatype. + """ func = self.to_sitk sitk = import_optional_dependency( @@ -3602,13 +3626,19 @@ def to_sitk(self) -> 'SimpleITK.Image': # noqa: F821 array = self.array.transpose(2, 1, 0) + if array.ndim != 3: + raise ValueError( + 'SimpleITK conversion does not currently support' + ' volumes with multiple channels.' + ) + if array.dtype == np.bool_: array = array.astype(np.uint8) elif array.dtype == np.float16: warnings.warn( 'SimpleITK does not support float16 data.' - ' Safely recasting to float32.' + ' Safely casting to float32.' ) array = array.astype(np.float32) @@ -3617,14 +3647,14 @@ def to_sitk(self) -> 'SimpleITK.Image': # noqa: F821 if array.min() >= f64.min and array.max() <= f64.max: warnings.warn( 'SimpleITK does not support float128 data.' - ' Safely recasting to float64.' + ' Casting to float64, precision may be lost.' ) array = array.astype(np.float64) else: raise ValueError( 'SimpleITK does not support float128 data.' - ' Safely recasting to float64 is not possible.' + ' Casting to float64 is not possible.' ) sitk_im = sitk.GetImageFromArray(array) @@ -3643,6 +3673,12 @@ def from_sitk( ) -> Self: """Construct a Volume from a `SimpleITK.Image`. + The ``SimpleITK.Image`` is converted to a 3D Volume. + Spatial metadata (spacing, direction, origin) is preserved. + As SimpleITK uses a different dimension convention (zyx) + than NumPy (xyz), this method automatically applies a + transpose to the array. + Parameters ---------- sitk_im: SimpleITK.Image @@ -3658,6 +3694,11 @@ def from_sitk( highdicom.Volume: Volume constructed from the `SimpleITK.Image`. + Raises + ------ + ValueError + When the volume is not 3D (multiple channels are unsupported). + """ func = cls.from_sitk sitk = import_optional_dependency( @@ -3667,6 +3708,12 @@ def from_sitk( array = sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0) + if array.ndim != 3: + raise ValueError( + 'SimpleITK conversion does not currently support' + ' volumes with multiple channels.' + ) + return cls.from_components( array=array, spacing=sitk_im.GetSpacing(), @@ -3679,11 +3726,38 @@ def from_sitk( def to_itk(self) -> 'itk.Image': # noqa: F821 """Convert the volume to `itk.Image` format. + The Volume is converted to a 3D ``itk.image``. If its array's + current datatype is not supported by ITK, it is safely cast to + a compatible type where possible. If impossible to cast safely, + a ``ValueError`` is raised. Casting is performed on the following + data types: + + - ``bool`` -> ``uint8`` + - ``int8`` -> ``int16`` (with warning) + - ``int64`` -> ``int32`` (with warning if possible, else + raises error) + - ``float16`` -> ``float32`` (with warning) + - ``float128`` -> ``float64`` (with warning if possible, else + raises error) + + Spatial metadata (spacing, direction, origin) is preserved. + As ITK uses a different dimension convention (zyx) + than NumPy (xyz), this method automatically applies a + transpose to the array. + Returns ------- itk.Image: Image constructed from the volume. + Raises + ------ + ValueError + When the volume is not 3D (multiple channels are unsupported). + ValueError + When the array's current datatype is not supported + and it is not possible to safely cast to a new datatype. + """ func = self.to_itk itk = import_optional_dependency( @@ -3693,13 +3767,19 @@ def to_itk(self) -> 'itk.Image': # noqa: F821 array = self.array.transpose(2, 1, 0).copy() + if array.ndim != 3: + raise ValueError( + 'ITK conversion does not currently support' + ' volumes with multiple channels.' + ) + if array.dtype == np.bool_: array = array.astype(np.uint8) elif array.dtype == np.int8: warnings.warn( 'ITK does not support int8 data.' - ' Safely recasting to int16.' + ' Safely casting to int16.' ) array = array.astype(np.int16) @@ -3708,20 +3788,20 @@ def to_itk(self) -> 'itk.Image': # noqa: F821 if array.min() >= i32.min and array.max() <= i32.max: warnings.warn( 'ITK does not support int64 data.' - ' Safely recasting to int32.' + ' Safely casting to int32.' ) array = array.astype(np.int32) else: raise ValueError( 'ITK does not support int64 data.' - ' Safely recasting to int32 is not possible.' + ' Safely casting to int32 is not possible.' ) elif array.dtype == np.float16: warnings.warn( 'ITK does not support float16 data.' - ' Safely recasting to float32.' + ' Safely casting to float32.' ) array = array.astype(np.float32) @@ -3730,14 +3810,14 @@ def to_itk(self) -> 'itk.Image': # noqa: F821 if array.min() >= f64.min and array.max() <= f64.max: warnings.warn( 'ITK does not support float128 data.' - ' Safely recasting to float64.' + ' Casting to float64, precision may be lost.' ) array = array.astype(np.float64) else: raise ValueError( 'ITK does not support float128 data.' - ' Safely recasting to float64 is not possible.' + ' Casting to float64 is not possible.' ) itk_im = itk.GetImageFromArray(array) @@ -3756,6 +3836,12 @@ def from_itk( ) -> Self: """Construct a Volume from an `itk.Image`. + The ``itk.Image`` is converted to a 3D Volume. + Spatial metadata (spacing, direction, origin) is preserved. + As SimpleITK uses a different dimension convention (zyx) + than NumPy (xyz), this method automatically applies a + transpose to the array. + Parameters ---------- itk_im: itk.Image @@ -3771,6 +3857,11 @@ def from_itk( highdicom.Volume: Volume constructed from the `itk.Image`. + Raises + ------ + ValueError + When the volume is not 3D (multiple channels are unsupported). + """ func = cls.from_itk itk = import_optional_dependency( @@ -3780,6 +3871,12 @@ def from_itk( array = itk.GetArrayFromImage(itk_im).transpose(2, 1, 0).copy() + if array.ndim != 3: + raise ValueError( + 'ITK conversion does not currently support' + ' volumes with multiple channels.' + ) + return cls.from_components( array=array, spacing=np.array(itk_im.GetSpacing()), From 8e8802f5eb0bbb9f8d15cc9d0d85a9931ed7f0bd Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 15 May 2026 00:08:39 -0400 Subject: [PATCH 28/33] Update volume docs file --- docs/volume.rst | 95 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 66 insertions(+), 29 deletions(-) diff --git a/docs/volume.rst b/docs/volume.rst index ee9f99de6..5161b1920 100644 --- a/docs/volume.rst +++ b/docs/volume.rst @@ -731,60 +731,97 @@ Writing a volume to a NIfTI file: nifti_path = '/path/to/nifti.nii' # or .nii.gz nib.save(nifti, nifti_path) -Volumes To/From ITK Images --------------------------- -`ITK`_ is a widely-used library for volumetric image processing. Its ``Image`` -class shares many similarities with our :class:`highdicom.Volume` class. Like -highdicom, ITK uses the "LPS" convention. However, when converting to and from -NumPy arrays, ITK reverses the order of dimensions. It is important to account -for this when performing conversions. +Volumes To/From Other Libraries +------------------------------- -We plan to add tools to handle this conversion in the near future, but for now -these snippets should correctly handle simple situations converting to and from -ITK Images. +Many other libraries have classes that share similarities with our +:class:`highdicom.Volume` class and allow for simple conversions between +formats. For commonly used libraries, highdicom Volumes provide +``to_()`` and ``from_()`` methods to simplify moving to and +from other libraries as much as possible. We have listed currently supported +libraries below and provided example snippets implementing the conversions. -Creating a volume from an ITK Image: + +ITK +~~~ + +`ITK`_ is a widely-used library for volumetric image processing. We support +conversions with its ``Image`` class through the :meth:`highdicom.Volume.to_itk` +and :meth:`highdicom.Volume.from_itk` methods. Like highdicom, ITK uses the "LPS" +convention. However, when converting to and from NumPy arrays, ITK reverses the +order of dimensions. This permutation is handled automatically by highdicom and +requires no intervention by the user. + +Creating an ITK Image from a Volume: .. code-block:: python import itk - import numpy as np import highdicom as hd - im = itk.image(...) + vol = hd.Volume(...) - # Reverse array dimension order - array = itk.array_from_image(im).transpose([2, 1, 0]) + itk_im = vol.to_itk() - vol2 = hd.Volume.from_components( - array=array, - direction=np.asarray(im.GetDirection()), - spacing=np.asarray(im.GetSpacing()), - position=np.asarray(im.GetOrigin()), - coordinate_system="PATIENT" +Creating a volume from an ITK Image: + +.. code-block:: python + + import itk + import highdicom as hd + + + itk_im = itk.image(...) + + vol = hd.Volume.from_itk( + itk_im=itk_im, + coordinate_system='PATIENT', + frame_of_reference_uid=None ) -Creating an ITK Image from a Volume: + +SImpleITK +~~~~~~~~~ + +`SimpleITK`_ provides a simplified interface for the algorithms and data structures +of ITK. We similarly support conversions with its ``Image`` class through the +:meth:`highdicom.Volume.to_sitk` and :meth:`highdicom.Volume.from_sitk` methods. +Conversion is essentially the same as the ITK interface, also using the "LPS" +convention and reversing the order of dimensions of the array. This permutation is +again handled automatically by highdicom and requires no intervention by the user. + +Creating a SimpleITK Image from a Volume: .. code-block:: python - import itk + import SimpleITK as sitk import highdicom as hd vol = hd.Volume(...) - # Reverse array dimension order - array = vol.array.transpose([2, 1, 0]) + sitk_im = vol.to_sitk() - im = itk.image_from_array(array) - im.SetOrigin(vol.position) - im.SetDirection(vol.direction) - im.SetSpacing(vol.spacing) +Creating a volume from a SimpleITK Image: + +.. code-block:: python + + import SimpleITK as sitk + import highdicom as hd + + + sitk_im = sitk.image(...) + + vol = hd.Volume.from_sitk( + sitk_im=sitk_im, + coordinate_system='PATIENT', + frame_of_reference_uid=None + ) .. _`NIfTI`: https://nifti.nimh.nih.gov/ .. _`ITK`: https://itk.org/ +.. _`SimpleITK`: https://simpleitk.org/ .. _`nibabel`: https://nipy.org/nibabel/ From 915f086436d769346530a7bbdf554b18508d919e Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 15 May 2026 00:09:22 -0400 Subject: [PATCH 29/33] Update dtype tests with array equality and random values --- tests/test_itk.py | 71 +++++++++++++++++++++++++++++++++++----------- tests/test_sitk.py | 41 ++++++++++++++++++++++---- 2 files changed, 90 insertions(+), 22 deletions(-) diff --git a/tests/test_itk.py b/tests/test_itk.py index d4e5641f0..866855b75 100644 --- a/tests/test_itk.py +++ b/tests/test_itk.py @@ -415,9 +415,11 @@ def test_roundtrip(vol: Volume): ] ) def test_dtype_itk(dtype): - array = np.zeros((10, 10, 10), dtype=dtype) + rng = np.random.default_rng() + size = (10, 10, 10) + volume = Volume.from_attributes( - array=array, + array=np.zeros(size), 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), @@ -426,96 +428,133 @@ def test_dtype_itk(dtype): ) if dtype == np.bool_: + volume.array = np.round(rng.random(size=size)).astype(dtype) + itk_roundtrip = Volume.from_itk(volume.to_itk()) assert itk_roundtrip.dtype == np.uint8 + assert (itk_roundtrip.array == volume.array).all() elif dtype == np.int8: + i8 = np.iinfo(np.int8) + volume.array = rng.integers(i8.min, i8.max, size=size, dtype=dtype) + with pytest.warns( UserWarning, match=( 'ITK does not support int8 data.' - ' Safely recasting to int16.' + ' Safely casting to int16.' ) ): itk_roundtrip = Volume.from_itk(volume.to_itk()) - assert itk_roundtrip.dtype == np.int16 + + assert itk_roundtrip.dtype == np.int16 + assert (itk_roundtrip.array == volume.array).all() elif dtype == np.int64: + i32 = np.iinfo(np.int32) + volume.array = rng.integers(i32.min, i32.max, size=size, dtype=dtype) + with pytest.warns( UserWarning, match=( 'ITK does not support int64 data.' - ' Safely recasting to int32.' + ' Safely casting to int32.' ) ): itk_roundtrip = Volume.from_itk(volume.to_itk()) - assert itk_roundtrip.dtype == np.int32 - volume.array[(0, 0, 0)] = np.int64(np.iinfo(np.int32).max) + 1 + assert itk_roundtrip.dtype == np.int32 + assert (itk_roundtrip.array == volume.array).all() + + volume.array[(0, 0, 0)] = np.int64(i32.max) + 1 with pytest.raises( ValueError, match=( 'ITK does not support int64 data.' - ' Safely recasting to int32 is not possible.' + ' Safely casting to int32 is not possible.' ) ): itk_roundtrip = Volume.from_itk(volume.to_itk()) - volume.array[(0, 0, 0)] = np.int64(np.iinfo(np.int32).min) - 1 + volume.array[(0, 0, 0)] = np.int64(i32.min) - 1 with pytest.raises( ValueError, match=( 'ITK does not support int64 data.' - ' Safely recasting to int32 is not possible.' + ' Safely casting to int32 is not possible.' ) ): itk_roundtrip = Volume.from_itk(volume.to_itk()) elif dtype == np.float16: + f16 = np.finfo(np.float16) + volume.array = rng.uniform(f16.min, f16.max, size=size).astype(dtype) + with pytest.warns( UserWarning, match=( 'ITK does not support float16 data.' - ' Safely recasting to float32.' + ' Safely casting to float32.' ) ): itk_roundtrip = Volume.from_itk(volume.to_itk()) + assert itk_roundtrip.dtype == np.float32 + assert (itk_roundtrip.array == volume.array).all() elif dtype == np.float128: + f64 = np.finfo(np.float64) + array = rng.random(size).astype(dtype) + volume.array = (2 * array - 1) * 0.9 * f64.max + with pytest.warns( UserWarning, match=( 'ITK does not support float128 data.' - ' Safely recasting to float64.' + ' Casting to float64, precision may be lost.' ) ): itk_roundtrip = Volume.from_itk(volume.to_itk()) + assert itk_roundtrip.dtype == np.float64 + assert np.allclose(itk_roundtrip.array, volume.array, atol=1e-4) - volume.array[(0, 0, 0)] = 1.1 * np.float128(np.finfo(np.float64).max) + volume.array[(0, 0, 0)] = 1.1 * np.float128(f64.max) with pytest.raises( ValueError, match=( 'ITK does not support float128 data.' - ' Safely recasting to float64 is not possible.' + ' Casting to float64 is not possible.' ) ): itk_roundtrip = Volume.from_itk(volume.to_itk()) - volume.array[(0, 0, 0)] = 1.1 * np.float128(np.finfo(np.float64).min) + volume.array[(0, 0, 0)] = 1.1 * np.float128(f64.min) with pytest.raises( ValueError, match=( 'ITK does not support float128 data.' - ' Safely recasting to float64 is not possible.' + ' Casting to float64 is not possible.' ) ): itk_roundtrip = Volume.from_itk(volume.to_itk()) + elif np.issubdtype(dtype, np.integer): + ib = np.iinfo(dtype) + volume.array = rng.integers(ib.min, ib.max, size=size, dtype=dtype) + + itk_roundtrip = Volume.from_itk(volume.to_itk()) + assert itk_roundtrip.dtype == volume.dtype + assert (itk_roundtrip.array == volume.array).all() + else: + fb = np.finfo(dtype) + array = rng.random(size).astype(dtype) + volume.array = (2 * array - 1) * 0.9 * fb.max + itk_roundtrip = Volume.from_itk(volume.to_itk()) assert itk_roundtrip.dtype == volume.dtype + assert (itk_roundtrip.array == volume.array).all() @pytest.mark.parametrize( diff --git a/tests/test_sitk.py b/tests/test_sitk.py index 93994e2f0..d1f7f9a1a 100644 --- a/tests/test_sitk.py +++ b/tests/test_sitk.py @@ -415,9 +415,11 @@ def test_roundtrip(vol: Volume): ] ) def test_dtype_sitk(dtype: np.dtype): - array = np.zeros((10, 10, 10), dtype=dtype) + rng = np.random.default_rng() + size = (10, 10, 10) + volume = Volume.from_attributes( - array=array, + array=np.zeros(size), 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), @@ -426,37 +428,51 @@ def test_dtype_sitk(dtype: np.dtype): ) if dtype == np.bool_: + volume.array = np.round(rng.random(size=size)).astype(dtype) + sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) assert sitk_roundtrip.dtype == np.uint8 + assert (sitk_roundtrip.array == volume.array).all() elif dtype == np.float16: + f16 = np.finfo(np.float16) + volume.array = rng.uniform(f16.min, f16.max, size=size).astype(dtype) + with pytest.warns( UserWarning, match=( 'SimpleITK does not support float16 data.' - ' Safely recasting to float32.' + ' Safely casting to float32.' ) ): sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) + assert sitk_roundtrip.dtype == np.float32 + assert (sitk_roundtrip.array == volume.array).all() elif dtype == np.float128: + f64 = np.finfo(np.float64) + array = rng.random(size).astype(dtype) + volume.array = (2 * array - 1) * 0.9 * f64.max + with pytest.warns( UserWarning, match=( 'SimpleITK does not support float128 data.' - ' Safely recasting to float64.' + ' Casting to float64, precision may be lost.' ) ): sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) + assert sitk_roundtrip.dtype == np.float64 + assert np.allclose(sitk_roundtrip.array, volume.array, atol=1e-4) volume.array[(0, 0, 0)] = 1.1 * np.float128(np.finfo(np.float64).max) with pytest.raises( ValueError, match=( 'SimpleITK does not support float128 data.' - ' Safely recasting to float64 is not possible.' + ' Casting to float64 is not possible.' ) ): sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) @@ -466,14 +482,27 @@ def test_dtype_sitk(dtype: np.dtype): ValueError, match=( 'SimpleITK does not support float128 data.' - ' Safely recasting to float64 is not possible.' + ' Casting to float64 is not possible.' ) ): sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) + elif np.issubdtype(dtype, np.integer): + ib = np.iinfo(dtype) + volume.array = rng.integers(ib.min, ib.max, size=size, dtype=dtype) + + sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) + assert sitk_roundtrip.dtype == volume.dtype + assert (sitk_roundtrip.array == volume.array).all() + else: + fb = np.finfo(dtype) + array = rng.random(size).astype(dtype) + volume.array = (2 * array - 1) * 0.9 * fb.max + sitk_roundtrip = Volume.from_sitk(volume.to_sitk()) assert sitk_roundtrip.dtype == volume.dtype + assert (sitk_roundtrip.array == volume.array).all() @pytest.mark.parametrize( From 20c207e7055ac419742cc122be780fe15f755d30 Mon Sep 17 00:00:00 2001 From: "Mason C. Cleveland" <104479423+mccle@users.noreply.github.com> Date: Fri, 15 May 2026 10:15:58 -0400 Subject: [PATCH 30/33] Apply suggestion from @CPBridge Co-authored-by: Chris Bridge --- docs/volume.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/volume.rst b/docs/volume.rst index 5161b1920..a0e098184 100644 --- a/docs/volume.rst +++ b/docs/volume.rst @@ -782,7 +782,7 @@ Creating a volume from an ITK Image: ) -SImpleITK +SimpleITK ~~~~~~~~~ `SimpleITK`_ provides a simplified interface for the algorithms and data structures From d6a0d5fb41f04eb318feda5145d19066a2d38ad6 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 15 May 2026 14:33:01 -0400 Subject: [PATCH 31/33] Reorganize and update docs files --- docs/general.rst | 1 + docs/itk_lib.rst | 49 +++++++++++++++++++++++ docs/other_libraries.rst | 24 ++++++++++++ docs/sitk_lib.rst | 51 ++++++++++++++++++++++++ docs/volume.rst | 85 ++-------------------------------------- 5 files changed, 129 insertions(+), 81 deletions(-) create mode 100644 docs/itk_lib.rst create mode 100644 docs/other_libraries.rst create mode 100644 docs/sitk_lib.rst diff --git a/docs/general.rst b/docs/general.rst index 13ad6fa36..9103f9a6f 100644 --- a/docs/general.rst +++ b/docs/general.rst @@ -16,3 +16,4 @@ parts of the library. volume coding remote + other_libraries diff --git a/docs/itk_lib.rst b/docs/itk_lib.rst new file mode 100644 index 000000000..b6609fc8d --- /dev/null +++ b/docs/itk_lib.rst @@ -0,0 +1,49 @@ +.. _itk_lib: + +ITK +=== + +`ITK`_ is a widely-used library for volumetric image processing. In order to +use ITK with highdicom, the ``itk`` python package must be installed separately. +Version 5.4.0 or later is required. + +.. _itk_vol: + +Volume Conversions +------------------ + +Highdicom supports conversions with the ``itk.Image`` class through the +:meth:`highdicom.Volume.to_itk` and :meth:`highdicom.Volume.from_itk` methods. +Like highdicom, ITK uses the "LPS" convention. However, when converting to and +from NumPy arrays, ITK reverses the order of dimensions. This permutation is +handled automatically by highdicom and requires no intervention by the user. + +Creating an ITK Image from a Volume: + +.. code-block:: python + + import highdicom as hd + + + vol = hd.Volume(...) + + itk_im = vol.to_itk() + +Creating a volume from an ITK Image: + +.. code-block:: python + + import itk + import highdicom as hd + + + itk_im = itk.image(...) + + vol = hd.Volume.from_itk( + itk_im=itk_im, + coordinate_system='PATIENT', + frame_of_reference_uid=None + ) + + +.. _`ITK`: https://itk.org/ diff --git a/docs/other_libraries.rst b/docs/other_libraries.rst new file mode 100644 index 000000000..dcab858d1 --- /dev/null +++ b/docs/other_libraries.rst @@ -0,0 +1,24 @@ +.. _other_libraries: + +Interactions with Other Libraries +================================= + +Highdicom is able to interact with other imaging and scientific python +libraries. These integrations are intended to provide streamlined access to +complementary functionality available in external ecosystems that would +otherwise require custom user code. + +Because these libraries are not required for all workflows, they are +treated as optional dependencies and are not installed as part of the +default ``highdicom`` installation. Functionality requiring an external +library becomes available when an appropriate version of the corresponding +package is installed alongside ``highdicom``. The following sections describe +which libraries are currently supported and the additional features each +library enables. + +.. toctree:: + :maxdepth: 3 + :caption: Contents: + + itk_lib + sitk_lib diff --git a/docs/sitk_lib.rst b/docs/sitk_lib.rst new file mode 100644 index 000000000..d9321571f --- /dev/null +++ b/docs/sitk_lib.rst @@ -0,0 +1,51 @@ +.. _sitk_lib: + +SimpleITK +========= + +`SimpleITK`_ provides a simplified interface for the algorithms and data structures +of `ITK`_. In order to use ITK with highdicom, the ``SimpleITK`` python package must +be installed separately. Version 2.2.1 or later is required. + +.. _sitk_vol: + +Volume Conversions +------------------ + +Highdicom supports conversions with the ``SimpleITK.Image`` class through the +:meth:`highdicom.Volume.to_sitk` and :meth:`highdicom.Volume.from_sitk` methods. +Like highdicom, SimpleITK uses the "LPS" convention. However, when converting to +and from NumPy arrays, SimpleITK reverses the order of dimensions. This permutation +is handled automatically by highdicom and requires no intervention by the user. + + +Creating a SimpleITK Image from a Volume: + +.. code-block:: python + + import highdicom as hd + + + vol = hd.Volume(...) + + sitk_im = vol.to_sitk() + +Creating a volume from a SimpleITK Image: + +.. code-block:: python + + import SimpleITK as sitk + import highdicom as hd + + + sitk_im = sitk.image(...) + + vol = hd.Volume.from_sitk( + sitk_im=sitk_im, + coordinate_system='PATIENT', + frame_of_reference_uid=None + ) + + +.. _`ITK`: https://itk.org/ +.. _`SimpleITK`: https://simpleitk.org/ diff --git a/docs/volume.rst b/docs/volume.rst index a0e098184..7bbff751a 100644 --- a/docs/volume.rst +++ b/docs/volume.rst @@ -739,89 +739,12 @@ Many other libraries have classes that share similarities with our :class:`highdicom.Volume` class and allow for simple conversions between formats. For commonly used libraries, highdicom Volumes provide ``to_()`` and ``from_()`` methods to simplify moving to and -from other libraries as much as possible. We have listed currently supported -libraries below and provided example snippets implementing the conversions. +from other libraries as much as possible. For more information about each library, +visit the following: - -ITK -~~~ - -`ITK`_ is a widely-used library for volumetric image processing. We support -conversions with its ``Image`` class through the :meth:`highdicom.Volume.to_itk` -and :meth:`highdicom.Volume.from_itk` methods. Like highdicom, ITK uses the "LPS" -convention. However, when converting to and from NumPy arrays, ITK reverses the -order of dimensions. This permutation is handled automatically by highdicom and -requires no intervention by the user. - -Creating an ITK Image from a Volume: - -.. code-block:: python - - import itk - import highdicom as hd - - - vol = hd.Volume(...) - - itk_im = vol.to_itk() - -Creating a volume from an ITK Image: - -.. code-block:: python - - import itk - import highdicom as hd - - - itk_im = itk.image(...) - - vol = hd.Volume.from_itk( - itk_im=itk_im, - coordinate_system='PATIENT', - frame_of_reference_uid=None - ) - - -SimpleITK -~~~~~~~~~ - -`SimpleITK`_ provides a simplified interface for the algorithms and data structures -of ITK. We similarly support conversions with its ``Image`` class through the -:meth:`highdicom.Volume.to_sitk` and :meth:`highdicom.Volume.from_sitk` methods. -Conversion is essentially the same as the ITK interface, also using the "LPS" -convention and reversing the order of dimensions of the array. This permutation is -again handled automatically by highdicom and requires no intervention by the user. - -Creating a SimpleITK Image from a Volume: - -.. code-block:: python - - import SimpleITK as sitk - import highdicom as hd - - - vol = hd.Volume(...) - - sitk_im = vol.to_sitk() - -Creating a volume from a SimpleITK Image: - -.. code-block:: python - - import SimpleITK as sitk - import highdicom as hd - - - sitk_im = sitk.image(...) - - vol = hd.Volume.from_sitk( - sitk_im=sitk_im, - coordinate_system='PATIENT', - frame_of_reference_uid=None - ) + - :ref:`ITK ` + - :ref:`SimpleITK ` .. _`NIfTI`: https://nifti.nimh.nih.gov/ -.. _`ITK`: https://itk.org/ -.. _`SimpleITK`: https://simpleitk.org/ .. _`nibabel`: https://nipy.org/nibabel/ From d8acf679c535e80de414aab6d15049f32fb9818e Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 15 May 2026 14:45:04 -0400 Subject: [PATCH 32/33] Update docstrings --- src/highdicom/volume.py | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 290b89d16..9f298f27f 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -3588,7 +3588,7 @@ def pad_array(array: np.ndarray, cval: float) -> float: def to_sitk(self) -> 'SimpleITK.Image': # noqa: F821 """Convert the Volume to ``SimpleITK.Image`` format. - The Volume is converted to a 3D ``SimpleITK.image``. If + The Volume is converted to a 3D ``SimpleITK.Image``. If its array's current datatype is not supported by SimpleITK, it is safely cast to a compatible type where possible. If impossible to cast safely, a ``ValueError`` is raised. @@ -3599,10 +3599,11 @@ def to_sitk(self) -> 'SimpleITK.Image': # noqa: F821 - ``float128`` -> ``float64`` (with warning if possible, else raises error) - Spatial metadata (spacing, direction, origin) is preserved. - As SimpleITK uses a different dimension convention (zyx) - than NumPy (xyz), this method automatically applies a - transpose to the array. + Spatial metadata (spacing, direction, origin) is preserved + with both highdicom and SimpletITK using "LPS" convention. + As SimpleITK uses column-major order and NumPy uses row-major, + this method automatically applies a transpose to the original + array. Returns ------- @@ -3674,10 +3675,11 @@ def from_sitk( """Construct a Volume from a `SimpleITK.Image`. The ``SimpleITK.Image`` is converted to a 3D Volume. - Spatial metadata (spacing, direction, origin) is preserved. - As SimpleITK uses a different dimension convention (zyx) - than NumPy (xyz), this method automatically applies a - transpose to the array. + Spatial metadata (spacing, direction, origin) is preserved + with both highdicom and SimpletITK using "LPS" convention. + As SimpleITK uses column-major order and NumPy uses row-major, + this method automatically applies a transpose to the original + array. Parameters ---------- @@ -3740,10 +3742,10 @@ def to_itk(self) -> 'itk.Image': # noqa: F821 - ``float128`` -> ``float64`` (with warning if possible, else raises error) - Spatial metadata (spacing, direction, origin) is preserved. - As ITK uses a different dimension convention (zyx) - than NumPy (xyz), this method automatically applies a - transpose to the array. + Spatial metadata (spacing, direction, origin) is preserved + with both highdicom and ITK using "LPS" convention. As ITK uses + column-major order and NumPy uses row-major, this method automatically + applies a transpose to the original array. Returns ------- @@ -3837,10 +3839,10 @@ def from_itk( """Construct a Volume from an `itk.Image`. The ``itk.Image`` is converted to a 3D Volume. - Spatial metadata (spacing, direction, origin) is preserved. - As SimpleITK uses a different dimension convention (zyx) - than NumPy (xyz), this method automatically applies a - transpose to the array. + Spatial metadata (spacing, direction, origin) is preserved + with both highdicom and ITK using "LPS" convention. As ITK uses + column-major order and NumPy uses row-major, this method automatically + applies a transpose to the original array. Parameters ---------- From fe87e09a2d0efd22d9911ec5476388dd2f482d09 Mon Sep 17 00:00:00 2001 From: Mason Cleveland Date: Fri, 15 May 2026 15:26:04 -0400 Subject: [PATCH 33/33] Fix multichannel check and add tests --- src/highdicom/volume.py | 20 ++++++++++++-------- tests/test_itk.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_sitk.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py index 9f298f27f..0e9d949d1 100644 --- a/src/highdicom/volume.py +++ b/src/highdicom/volume.py @@ -3625,14 +3625,14 @@ def to_sitk(self) -> 'SimpleITK.Image': # noqa: F821 feature=f'{func.__module__}.{func.__qualname__}' ) - array = self.array.transpose(2, 1, 0) - - if array.ndim != 3: + if self.array.ndim != 3: raise ValueError( 'SimpleITK conversion does not currently support' ' volumes with multiple channels.' ) + array = self.array.transpose(2, 1, 0) + if array.dtype == np.bool_: array = array.astype(np.uint8) @@ -3708,7 +3708,7 @@ def from_sitk( feature=f'{func.__module__}.{func.__qualname__}' ) - array = sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0) + array = sitk.GetArrayFromImage(sitk_im) if array.ndim != 3: raise ValueError( @@ -3716,6 +3716,8 @@ def from_sitk( ' volumes with multiple channels.' ) + array = array.transpose(2, 1, 0) + return cls.from_components( array=array, spacing=sitk_im.GetSpacing(), @@ -3767,14 +3769,14 @@ def to_itk(self) -> 'itk.Image': # noqa: F821 feature=f'{func.__module__}.{func.__qualname__}' ) - array = self.array.transpose(2, 1, 0).copy() - - if array.ndim != 3: + if self.array.ndim != 3: raise ValueError( 'ITK conversion does not currently support' ' volumes with multiple channels.' ) + array = self.array.transpose(2, 1, 0).copy() + if array.dtype == np.bool_: array = array.astype(np.uint8) @@ -3871,7 +3873,7 @@ def from_itk( feature=f'{func.__module__}.{func.__qualname__}' ) - array = itk.GetArrayFromImage(itk_im).transpose(2, 1, 0).copy() + array = itk.GetArrayFromImage(itk_im) if array.ndim != 3: raise ValueError( @@ -3879,6 +3881,8 @@ def from_itk( ' volumes with multiple channels.' ) + array = array.transpose(2, 1, 0).copy() + return cls.from_components( array=array, spacing=np.array(itk_im.GetSpacing()), diff --git a/tests/test_itk.py b/tests/test_itk.py index 866855b75..77da27d87 100644 --- a/tests/test_itk.py +++ b/tests/test_itk.py @@ -666,3 +666,37 @@ def test_nifti_equivalence_series(dcm_urls: Sequence[str], nifti_url: str): assert ( oriented_vol.array == itk.GetArrayFromImage(itk_im).transpose(2, 1, 0) ).all() + + +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", + ) + + with pytest.raises( + ValueError, + match=( + 'ITK conversion does not currently support' + ' volumes with multiple channels.' + ) + ): + volume.to_itk() + + array = np.zeros((10, 10, 10, 2)) + itk_im = itk.GetImageFromArray(array) + + with pytest.raises( + ValueError, + match=( + 'ITK conversion does not currently support' + ' volumes with multiple channels.' + ) + ): + Volume.from_itk(itk_im) diff --git a/tests/test_sitk.py b/tests/test_sitk.py index d1f7f9a1a..1d6d605d8 100644 --- a/tests/test_sitk.py +++ b/tests/test_sitk.py @@ -614,3 +614,37 @@ def test_nifti_equivalence_series(dcm_urls: Sequence[str], nifti_url: str): assert ( oriented_vol.array == sitk.GetArrayFromImage(sitk_im).transpose(2, 1, 0) ).all() + + +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", + ) + + with pytest.raises( + ValueError, + match=( + 'SimpleITK conversion does not currently support' + ' volumes with multiple channels.' + ) + ): + volume.to_sitk() + + array = np.zeros((10, 10, 10, 2)) + sitk_im = sitk.GetImageFromArray(array) + + with pytest.raises( + ValueError, + match=( + 'SimpleITK conversion does not currently support' + ' volumes with multiple channels.' + ) + ): + Volume.from_sitk(sitk_im)