Skip to content
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ dependencies = [
"pydicom>=3.0.1",
"pyjpegls>=1.0.0",
"typing-extensions>=4.0.0",
"packaging>=25.0"
]

[project.optional-dependencies]
Expand All @@ -60,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"
Expand Down
64 changes: 64 additions & 0 deletions src/highdicom/_dependency_utils.py
Original file line number Diff line number Diff line change
@@ -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
133 changes: 133 additions & 0 deletions src/highdicom/volume.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -44,6 +45,7 @@
keyword_for_tag,
)

from highdicom._dependency_utils import import_optional_dependency

_DCM_PYTHON_TYPE_MAP = {
'CS': str,
Expand Down Expand Up @@ -3583,6 +3585,137 @@ def pad_array(array: np.ndarray, cval: float) -> float:
channels=self._channels,
)

def to_sitk(self) -> SimpleITK.Image:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this type hint will fail at runtime if SimpleITK is not installed. I think this would work though:

Suggested change
def to_sitk(self) -> SimpleITK.Image:
def to_sitk(self) -> 'SimpleITK.Image':

"""Convert the Volume to `SimpleITK.Image` format.

Returns
-------
SimpleITK.Image:
Image constructed from the Volume.

"""
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to add some more information to the docstring to help users:

  • SITK (itk) uses the same LPS convention as highdicom
  • The returned volume is transposed to hide the difference in row major / column major orderings between the two formats transparent from the user

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
):
Comment on lines +3617 to +3620
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should expose frame_of_reference_uid and coordinate_system as optional parameters (with default parameters None and PATIENT respectively) and pass them through to the new volume

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also missing type hint on the return

"""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:

Expand Down
Loading