diff --git a/data/test_files/seg_image_sm_control_labelmap.dcm b/data/test_files/seg_image_sm_control_labelmap.dcm new file mode 100644 index 00000000..37d4ed40 Binary files /dev/null and b/data/test_files/seg_image_sm_control_labelmap.dcm differ diff --git a/data/test_files/seg_image_sm_control_labelmap_palette_color.dcm b/data/test_files/seg_image_sm_control_labelmap_palette_color.dcm new file mode 100644 index 00000000..65ad7a07 Binary files /dev/null and b/data/test_files/seg_image_sm_control_labelmap_palette_color.dcm differ diff --git a/data/test_files/sm_image_control.dcm b/data/test_files/sm_image_control.dcm index 7763d5be..58bbde1d 100644 Binary files a/data/test_files/sm_image_control.dcm and b/data/test_files/sm_image_control.dcm differ diff --git a/docs/package.rst b/docs/package.rst index 8a6b787c..0683c41c 100644 --- a/docs/package.rst +++ b/docs/package.rst @@ -75,6 +75,15 @@ highdicom.utils module :undoc-members: :show-inheritance: +highdicom.volume module ++++++++++++++++++++++++ + +.. automodule:: highdicom.volume + :members: + :inherited-members: pydicom.dataset.Dataset,pydicom.sequence.Sequence,Dataset,Sequence,list,str,DataElementSequence,enum.Enum,Enum, + :special-members: __call__ + :undoc-members: + :show-inheritance: .. _highdicom-legacy-subpackage: diff --git a/docs/seg.rst b/docs/seg.rst index 2abdda37..96a19e1e 100644 --- a/docs/seg.rst +++ b/docs/seg.rst @@ -447,7 +447,7 @@ segments. Note that passing a "label map" is purely a convenience provided by (`highdicom` splits the label map into multiple single-segment frames and stores these, as required by the standard). -Therefore, The following snippet produces an equivalent SEG image to the +Therefore, the following snippet produces an equivalent SEG image to the previous snippet, but passes the mask as a label map rather than as a stack of segments. diff --git a/pyproject.toml b/pyproject.toml index a6c9f2be..87147bba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "pillow>=8.3", "pydicom>=3.0.1", "pyjpegls>=1.0.0", + "typing-extensions>=4.0.0", ] [project.optional-dependencies] diff --git a/src/highdicom/__init__.py b/src/highdicom/__init__.py index 02b7cbbf..0066cba9 100644 --- a/src/highdicom/__init__.py +++ b/src/highdicom/__init__.py @@ -34,11 +34,13 @@ VOILUTTransformation, ) from highdicom.enum import ( + AxisHandedness, AnatomicalOrientationTypeValues, CoordinateSystemNames, ContentQualificationValues, DimensionOrganizationTypeValues, LateralityValues, + PadModes, PatientSexValues, PhotometricInterpretationValues, PixelRepresentationValues, @@ -51,11 +53,14 @@ VOILUTFunctionValues, ) from highdicom import frame +from highdicom.image import Image from highdicom import io from highdicom import spatial from highdicom.uid import UID from highdicom import utils from highdicom.version import __version__ +from highdicom import volume + __all__ = [ 'LUT', @@ -63,6 +68,7 @@ 'VOILUT', 'AlgorithmIdentificationSequence', 'AnatomicalOrientationTypeValues', + 'AxisHandedness', 'ContentCreatorIdentificationCodeSequence', 'ContentQualificationValues', 'CoordinateSystemNames', @@ -71,6 +77,8 @@ 'LateralityValues', 'ModalityLUT', 'ModalityLUTTransformation', + 'Image', + 'PadModes', 'PaletteColorLUT', 'PaletteColorLUTTransformation', 'PatientOrientationValuesBiped', @@ -112,4 +120,5 @@ 'spatial', 'sr', 'utils', + 'volume', ] diff --git a/src/highdicom/_module_utils.py b/src/highdicom/_module_utils.py index 5ebc2673..4586d9ad 100644 --- a/src/highdicom/_module_utils.py +++ b/src/highdicom/_module_utils.py @@ -216,7 +216,11 @@ def get_module_usage( return None -def is_attribute_in_iod(attribute: str, sop_class_uid: str) -> bool: +def is_attribute_in_iod( + attribute: str, + sop_class_uid: str, + exclude_path_elements: Sequence[str] | None = None, +) -> bool: """Check whether an attribute is present within an IOD. Parameters @@ -225,6 +229,9 @@ def is_attribute_in_iod(attribute: str, sop_class_uid: str) -> bool: Keyword for the attribute sop_class_uid: str SOP Class UID identifying the IOD. + exclude_path_elements: Sequence[str] | None, optional + If any of these elements are found anywhere in the attribute's path, + that occurrence is excluded. Returns ------- @@ -247,6 +254,11 @@ def is_attribute_in_iod(attribute: str, sop_class_uid: str) -> bool: for module in IOD_MODULE_MAP[iod_name]: module_attributes = MODULE_ATTRIBUTE_MAP[module['key']] for attr in module_attributes: + if exclude_path_elements is not None: + if any( + p in exclude_path_elements for p in attr['path'] + ): + continue if attr['keyword'] == attribute: return True @@ -279,12 +291,17 @@ def does_iod_have_pixel_data(sop_class_uid: str) -> bool: 'DoubleFloatPixelData', ] return any( - is_attribute_in_iod(attr, sop_class_uid) for attr in pixel_attrs + is_attribute_in_iod( + attr, + sop_class_uid, + exclude_path_elements=['IconImageSequence'], + ) for attr in pixel_attrs ) def is_multiframe_image(dataset: Dataset): """Determine whether an image is a multiframe image. + The definition used is whether the IOD allows for multiple frames, not whether this particular instance has more than one frame. diff --git a/src/highdicom/_value_types.py b/src/highdicom/_value_types.py new file mode 100644 index 00000000..6e88f375 --- /dev/null +++ b/src/highdicom/_value_types.py @@ -0,0 +1,43 @@ +"""Value types used within images and volumes.""" + +# Dictionary mapping DCM VRs to appropriate SQLite types +_DCM_SQL_TYPE_MAP = { + 'CS': 'VARCHAR', + 'DS': 'REAL', + 'FD': 'REAL', + 'FL': 'REAL', + 'IS': 'INTEGER', + 'LO': 'TEXT', + 'LT': 'TEXT', + 'PN': 'TEXT', + 'SH': 'TEXT', + 'SL': 'INTEGER', + 'SS': 'INTEGER', + 'ST': 'TEXT', + 'UI': 'TEXT', + 'UL': 'INTEGER', + 'UR': 'TEXT', + 'US or SS': 'INTEGER', + 'US': 'INTEGER', + 'UT': 'TEXT', +} +_DCM_PYTHON_TYPE_MAP = { + 'CS': str, + 'DS': float, + 'FD': float, + 'FL': float, + 'IS': int, + 'LO': str, + 'LT': str, + 'PN': str, + 'SH': str, + 'SL': int, + 'SS': int, + 'ST': str, + 'UI': str, + 'UL': int, + 'UR': str, + 'US or SS': int, + 'US': int, + 'UT': str, +} diff --git a/src/highdicom/ann/content.py b/src/highdicom/ann/content.py index b2629c0b..10f8d4bb 100644 --- a/src/highdicom/ann/content.py +++ b/src/highdicom/ann/content.py @@ -1,6 +1,7 @@ """Content that is specific to Annotation IODs.""" from copy import deepcopy from typing import cast, List, Optional, Sequence, Tuple, Union +from typing_extensions import Self import numpy as np from pydicom.dataset import Dataset @@ -119,7 +120,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True - ) -> 'Measurements': + ) -> Self: """Construct instance from an existing dataset. Parameters @@ -165,7 +166,7 @@ def from_dataset( ) ] - return cast(Measurements, measurements) + return cast(cls, measurements) class AnnotationGroup(Dataset): @@ -770,7 +771,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'AnnotationGroup': + ) -> Self: """Construct instance from an existing dataset. Parameters @@ -838,4 +839,4 @@ def from_dataset( for ds in group.PrimaryAnatomicStructureSequence ] - return cast(AnnotationGroup, group) + return cast(cls, group) diff --git a/src/highdicom/ann/sop.py b/src/highdicom/ann/sop.py index a58f7199..ff0e1123 100644 --- a/src/highdicom/ann/sop.py +++ b/src/highdicom/ann/sop.py @@ -14,6 +14,7 @@ Tuple, Union, ) +from typing_extensions import Self import numpy as np from pydicom import dcmread @@ -418,7 +419,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'MicroscopyBulkSimpleAnnotations': + ) -> Self: """Construct instance from an existing dataset. Parameters @@ -450,14 +451,14 @@ def from_dataset( ann = deepcopy(dataset) else: ann = dataset - ann.__class__ = MicroscopyBulkSimpleAnnotations + ann.__class__ = cls ann.AnnotationGroupSequence = [ AnnotationGroup.from_dataset(item, copy=copy) for item in ann.AnnotationGroupSequence ] - return cast(MicroscopyBulkSimpleAnnotations, ann) + return cast(cls, ann) def annread( diff --git a/src/highdicom/base.py b/src/highdicom/base.py index 4583537b..ceb45425 100644 --- a/src/highdicom/base.py +++ b/src/highdicom/base.py @@ -244,6 +244,11 @@ def __init__( ) self.CodingSchemeIdentificationSequence.append(item) + @property + def transfer_syntax_uid(self) -> UID: + """highdicom.UID: TransferSyntaxUID.""" + return UID(self.file_meta.TransferSyntaxUID) + def _copy_attribute( self, dataset: Dataset, @@ -359,7 +364,7 @@ def _check_little_endian(dataset: Dataset) -> None: """ if not hasattr(dataset, 'file_meta'): logger.warning( - 'Transfer syntax cannot be determined from the file metadata.' + 'Transfer syntax cannot be determined from the file metadata. ' 'Little endian encoding of attributes has been assumed.' ) return diff --git a/src/highdicom/content.py b/src/highdicom/content.py index 2ebc9d1a..534b9544 100644 --- a/src/highdicom/content.py +++ b/src/highdicom/content.py @@ -3,10 +3,14 @@ import datetime from copy import deepcopy from typing import cast, Dict, List, Optional, Union, Sequence, Tuple +from pydicom.multival import MultiValue +from typing_extensions import Self import numpy as np +from PIL import ImageColor from pydicom.dataset import Dataset from pydicom import DataElement +from pydicom.multival import MultiValue from pydicom.sequence import Sequence as DataElementSequence from pydicom.sr.coding import Code from pydicom.sr.codedict import codes @@ -20,6 +24,15 @@ UniversalEntityIDTypeValues, VOILUTFunctionValues, ) +from highdicom.pixel_transforms import ( + _check_rescale_dtype, + _get_combined_palette_color_lut, + _parse_palette_color_lut_attributes, + _select_voi_lut, + _select_voi_window_center_width, + apply_lut, + apply_voi_window, +) from highdicom.sr.enum import ValueTypeValues from highdicom.sr.coding import CodedConcept from highdicom.sr.value_types import ( @@ -37,7 +50,8 @@ ) from highdicom._module_utils import ( check_required_attributes, - does_iod_have_pixel_data + does_iod_have_pixel_data, + is_multiframe_image, ) @@ -97,7 +111,7 @@ def from_sequence( cls, sequence: DataElementSequence, copy: bool = True, - ) -> 'AlgorithmIdentificationSequence': + ) -> Self: """Construct instance from an existing data element sequence. Parameters @@ -134,8 +148,8 @@ def from_sequence( algo_id_sequence = deepcopy(sequence) else: algo_id_sequence = sequence - algo_id_sequence.__class__ = AlgorithmIdentificationSequence - return cast(AlgorithmIdentificationSequence, algo_id_sequence) + algo_id_sequence.__class__ = cls + return cast(cls, algo_id_sequence) @property def name(self) -> str: @@ -343,7 +357,7 @@ def from_sequence( cls, sequence: DataElementSequence, copy: bool = True, - ) -> 'PixelMeasuresSequence': + ) -> Self: """Create a PixelMeasuresSequence from an existing Sequence. Parameters @@ -388,8 +402,8 @@ def from_sequence( pixel_measures = deepcopy(sequence) else: pixel_measures = sequence - pixel_measures.__class__ = PixelMeasuresSequence - return cast(PixelMeasuresSequence, pixel_measures) + pixel_measures.__class__ = cls + return cast(cls, pixel_measures) def __eq__(self, other: DataElementSequence) -> bool: """Determine whether two sets of pixel measures are the same. @@ -410,6 +424,11 @@ def __eq__(self, other: DataElementSequence) -> bool: if len(other) != 1: raise ValueError('Second item must have length 1.') + if ( + hasattr(other[0], 'SliceThickness') != + hasattr(self[0], 'SliceThickness') + ): + return False if other[0].SliceThickness != self[0].SliceThickness: return False if other[0].PixelSpacing != self[0].PixelSpacing: @@ -548,7 +567,7 @@ def from_sequence( cls, sequence: DataElementSequence, copy: bool = True, - ) -> 'PlanePositionSequence': + ) -> Self: """Create a PlanePositionSequence from an existing Sequence. The coordinate system is inferred from the attributes in the sequence. @@ -598,8 +617,8 @@ def from_sequence( plane_position = deepcopy(sequence) else: plane_position = sequence - plane_position.__class__ = PlanePositionSequence - return cast(PlanePositionSequence, plane_position) + plane_position.__class__ = cls + return cast(cls, plane_position) class PlaneOrientationSequence(DataElementSequence): @@ -688,7 +707,7 @@ def from_sequence( cls, sequence: DataElementSequence, copy: bool = True, - ) -> 'PlaneOrientationSequence': + ) -> Self: """Create a PlaneOrientationSequence from an existing Sequence. The coordinate system is inferred from the attributes in the sequence. @@ -735,8 +754,8 @@ def from_sequence( plane_orientation = deepcopy(sequence) else: plane_orientation = sequence - plane_orientation.__class__ = PlaneOrientationSequence - return cast(PlaneOrientationSequence, plane_orientation) + plane_orientation.__class__ = cls + return cast(cls, plane_orientation) class IssuerOfIdentifier(Dataset): @@ -790,7 +809,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'IssuerOfIdentifier': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -834,7 +853,7 @@ def from_dataset( issuer_of_identifier._issuer_of_identifier = issuer_id issuer_of_identifier._issuer_of_identifier_type = issuer_type - return cast(IssuerOfIdentifier, issuer_of_identifier) + return cast(cls, issuer_of_identifier) class SpecimenCollection(ContentSequence): @@ -1325,7 +1344,7 @@ def specimen_type(self) -> Union[CodedConcept, None]: def from_dataset( cls, dataset: Dataset, - ) -> 'SpecimenPreparationStep': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -1647,7 +1666,7 @@ def primary_anatomic_structures(self) -> Union[List[CodedConcept], None]: return self.get("PrimaryAnatomicStructureSequence") @classmethod - def from_dataset(cls, dataset: Dataset) -> 'SpecimenDescription': + def from_dataset(cls, dataset: Dataset) -> Self: """Construct object from an existing dataset. Parameters @@ -1764,7 +1783,10 @@ def __init__( 'Specifying "referenced_frame_number" is not supported ' 'with multiple referenced images.' ) - if not hasattr(referenced_images[0], 'NumberOfFrames'): + # note cannot use the highdicom.utils function here due to + # circular import issues + is_multiframe = is_multiframe_image(referenced_images[0]) + if not is_multiframe: raise TypeError( 'Specifying "referenced_frame_number" is not valid ' 'when the referenced image is not a multi-frame image.' @@ -1855,7 +1877,7 @@ def __init__( Pixel value that will be mapped to the first value in the lookup-table. lut_data: numpy.ndarray - Lookup table data. Must be of type uint16. + Lookup table data. Must be of type uint8 or uint16. lut_explanation: Union[str, None], optional Free-form text explanation of the meaning of the LUT. @@ -1896,16 +1918,16 @@ def __init__( elif len_data == 2**16: # Per the standard, this is recorded as 0 len_data = 0 - # Note 8 bit LUT data is unsupported pending clarification on the - # standard if lut_data.dtype.type == np.uint16: bits_per_entry = 16 + elif lut_data.dtype.type == np.uint8: + bits_per_entry = 8 else: raise ValueError( - "Numpy array must have dtype uint16." + "Numpy array must have dtype uint8 or uint16." ) # The LUT data attribute has VR OW (16-bit other words) - self.LUTData = lut_data.astype(np.uint16).tobytes() + self.LUTData = lut_data.tobytes() self.LUTDescriptor = [ len_data, @@ -1920,18 +1942,28 @@ def __init__( @property def lut_data(self) -> np.ndarray: """numpy.ndarray: LUT data""" - if self.bits_per_entry == 8: - raise RuntimeError("8 bit LUTs are currently unsupported.") - elif self.bits_per_entry == 16: + bits_per_entry = self.bits_per_entry + if bits_per_entry == 8: + dtype = np.uint8 + elif bits_per_entry == 16: dtype = np.uint16 else: raise RuntimeError("Invalid LUT descriptor.") length = self.LUTDescriptor[0] data = self.LUTData - # The LUT data attributes have VR OW (16-bit other words) - array = np.frombuffer(data, dtype=np.uint16) - # Needs to be casted according to third descriptor value. - array = array.astype(dtype) + + # Account for a zero-padding byte in the case of an 8 bit LUT + if bits_per_entry == 8 and length % 2 == 1 and len(data) == length + 1: + data = data[:-1] + + # LUT Data may have value representation of either US (which pydicom + # will return as a list of ints) or OW, which pydicom will return as a + # bytes object + if self['LUTData'].VR == 'US': + array = np.array(data, dtype=dtype) + else: + # The LUT data attributes have VR OW (16-bit other words) + array = np.frombuffer(data, dtype=dtype) if len(array) != length: raise RuntimeError( 'Length of LUTData does not match the value expected from the ' @@ -1965,6 +1997,168 @@ def bits_per_entry(self) -> int: """int: Bits allocated for the lookup table data. 8 or 16.""" return int(self.LUTDescriptor[2]) + @classmethod + def from_dataset( + cls, + dataset: Dataset, + copy: bool = True, + ) -> Self: + """Create a LUT from an existing Dataset. + + Parameters + ---------- + dataset: pydicom.Dataset + Dataset representing a LUT. + copy: bool + If True, the underlying dataset is deep-copied such that the + original dataset remains intact. If False, this operation will + alter the original dataset in place. + + Returns + ------- + highdicom.LUT + Constructed object + + """ + # TODO should this be an extract_from_dataset? + attrs = [ + 'LUTDescriptor', + 'LUTData' + ] + for attr in attrs: + if attr not in dataset: + raise AttributeError( + f"Required attribute '{attr}' is not present in dataset." + ) + + if copy: + dataset_copy = deepcopy(dataset) + else: + dataset_copy = dataset + + dataset_copy = dataset + dataset_copy.__class__ = cls + return cast(cls, dataset_copy) + + def get_scaled_lut_data( + self, + output_range: Tuple[float, float] = (0.0, 1.0), + dtype: Union[type, str, np.dtype, None] = np.float64, + invert: bool = False, + ) -> np.ndarray: + """Get LUT data array with output values scaled to a given range. + + Parameters + ---------- + output_range: Tuple[float, float], optional + Tuple containing (lower, upper) value of the range into which to + scale the output values. The lowest value in the LUT data will be + mapped to the lower limit, and the highest value will be mapped to + the upper limit, with a linear scaling used elsewhere. + dtype: Union[type, str, numpy.dtype, None], optional + Data type of the returned array (must be a floating point NumPy + data type). + invert: bool, optional + Invert the returned array such that the lowest original value in + the LUT is mapped to the upper limit and the highest original value + is mapped to the lower limit. This may be used to efficiently + combined a LUT with a Resentation transform that inverts the range. + + Returns + ------- + numpy.ndarray: + Rescaled LUT data array. + + """ + dtype = np.dtype(dtype) + + # Check dtype is suitable + if dtype.kind != 'f': + raise ValueError( + f'Data type "{dtype}" is not suitable.' + ) + + lut_data = self.lut_data + min = lut_data.min() + max = lut_data.max() + output_min, output_max = output_range + output_scale = output_max - output_min + if output_scale <= 0.0: + raise ValueError('Invalid output range.') + + input_scale = max - min + scale_factor = output_scale / input_scale + + scale_factor = dtype.type(scale_factor) + output_min = dtype.type(output_min) + + lut_data = lut_data.astype(dtype, casting='safe') + if invert: + lut_data = -lut_data + min = -max.astype(dtype) + + if min != 0: + lut_data = lut_data - min + + lut_data = lut_data * scale_factor + + if output_min != 0.0: + lut_data = lut_data + output_min + + return lut_data + + def get_inverted_lut_data( + self, + ) -> np.ndarray: + """Get LUT data array with output values inverted within the same range. + + This returns the LUT data inverted within its original range. So if the + original LUT data has output values in the range 10-20 inclusive, then + the entries with output value 10 will be mapped to 20, the entries with + output value 11 will be mapped to value 19, and so on until the entries + with value 20 are mapped to 10. + + Returns + ------- + numpy.ndarray: + Inverted LUT data array, with the same size and data type as the + original array. + + """ + lut_data = self.lut_data + return lut_data.min() + lut_data.max() - lut_data + + def apply( + self, + array: np.ndarray, + dtype: Union[type, str, np.dtype, None] = None, + ) -> np.ndarray: + """Apply the LUT to a pixel array. + + Parameters + ---------- + apply: numpy.ndarray + Pixel array to which the LUT should be applied. Can be of any shape + but must have an integer datatype. + dtype: Union[type, str, numpy.dtype, None], optional + Datatype of the output array. If ``None``, an unsigned integer + datatype corresponding to the number of bits in the LUT will be + used (either ``numpy.uint8`` or ``numpy.uint16``). Only safe casts + are permitted. + + Returns + ------- + numpy.ndarray + Array with LUT applied. + + """ + return apply_lut( + array=array, + lut_data=self.lut_data, + first_mapped_value=self.first_mapped_value, + dtype=dtype, + ) + class ModalityLUT(LUT): @@ -2177,6 +2371,141 @@ def __init__( 'provided.' ) + def has_lut(self) -> bool: + """Determine whether the transformation contains a lookup table. + + Returns + ------- + bool: + True if the transformation contains a look-up table. False + otherwise, when the mapping is represented by window center and + width defining a linear relationship. Note that it is possible for + a transformation to contain both a LUT and window parameters. + + """ + return 'VOILUTSequence' in self + + def has_window(self) -> bool: + """Determine whether the transformation contains window parameters. + + Returns + ------- + bool: + True if the transformation contains one or more sets of window + parameters defining a linear relationship. False otherwise, when + the mapping is represented by a lookup table. Note that it is + possible for a transformation to contain both a LUT and window + parameters. + + """ + return 'WindowCenter' in self + + def apply( + self, + array: np.ndarray, + output_range: Tuple[float, float] = (0.0, 1.0), + voi_transform_selector: int | str = 0, + dtype: Union[type, str, np.dtype, None] = None, + invert: bool = False, + prefer_lut: bool = False, + ) -> np.ndarray: + """Apply the transformation to an array. + + Parameters + ---------- + apply: numpy.ndarray + Pixel array to which the transformation should be applied. Can be + of any shape but must have an integer datatype if the + transformation uses a LUT. + output_range: Tuple[float, float], optional + Range of output values to which the VOI range is mapped. + voi_transform_selector: int | str, optional + Specification of the VOI transform to select (multiple may be + present). May either be an int or a str. If an int, it is + interpretted as a (zero-based) index of the list of VOI transforms + to apply. A negative integer may be used to index from the end of + the list following standard Python indexing convention. If a str, + the string that will be used to match the Window Center Width + Explanation or the LUT Explanation to choose from multiple VOI + transforms. Note that such explanations are optional according to + the standard and therefore may not be present. + dtype: Union[type, str, numpy.dtype, None], optional + Data type the output array. Should be a floating point data type. + If not specified, ``numpy.float64`` is used. + invert: bool, optional + Invert the returned array such that the lowest original value in + the LUT or input window is mapped to the upper limit and the + highest original value is mapped to the lower limit. This may be + used to efficiently combined a VOI LUT transformation with a + presentation transform that inverts the range. + prefer_lut: bool, optional + If True and the transformation contains both a LUT and a window + parameters, apply the LUT. If False and both a LUT and window + parameters are present, apply the window. + + Returns + ------- + numpy.ndarray + Array with transformation applied. + + """ + if dtype is None: + dtype = np.dtype(np.float64) + else: + dtype = np.dtype(dtype) + + # Check dtype is suitable + if dtype.kind != 'f': + raise ValueError( + f'Data type "{dtype}" is not suitable.' + ) + + if not self.has_window() or (self.has_lut() and prefer_lut): + voi_lut = _select_voi_lut(self, voi_transform_selector) + + if voi_lut is None: + raise IndexError( + "Requested 'voi_transform_selector' is " + "not present." + ) + + scaled_lut_data = voi_lut.get_scaled_lut_data( + output_range=output_range, + dtype=dtype, + invert=invert, + ) + array = apply_lut( + array=array, + lut_data=scaled_lut_data, + first_mapped_value=voi_lut.first_mapped_value, + ) + else: + voi_lut_function = self.get('VOILUTFunction', 'LINEAR') + + center_width = _select_voi_window_center_width( + self, + voi_transform_selector + ) + + if center_width is None: + raise IndexError( + "Requested 'voi_transform_selector' is not present." + ) + + window_center, window_width = center_width + + array = apply_voi_window( + array, + window_center=window_center, + window_width=window_width, + voi_lut_function=voi_lut_function, + output_range=output_range, + dtype=dtype, + invert=invert, + ) + + return array + class ModalityLUTTransformation(Dataset): @@ -2263,6 +2592,75 @@ def __init__( _check_long_string(rescale_type) self.RescaleType = rescale_type + def has_lut(self) -> bool: + """Determine whether the transformation contains a lookup table. + + Returns + ------- + bool: + True if the transformation contains a look-up table. False + otherwise, when the mapping is represented by slope and intercept + defining a linear relationship. + + """ + return 'ModalityLUTSequence' in self + + def apply( + self, + array: np.ndarray, + dtype: Union[type, str, np.dtype, None] = None, + ) -> np.ndarray: + """Apply the transformation to a pixel array. + + Parameters + ---------- + apply: numpy.ndarray + Pixel array to which the transformation should be applied. Can be + of any shape but must have an integer datatype if the + transformation uses a LUT. + dtype: Union[type, str, numpy.dtype, None], optional + Ensure the output type has this value. By default, this will have + type ``numpy.float64`` if the transformation uses a rescale + operation, or the datatype of the Modality LUT (``numpy.uint8`` or + ``numpy.uint16``) if it uses a LUT. An integer datatype may be + specified if a rescale operation is used, however if Rescale Slope + or Rescale Intecept are non-integer values an error will be raised. + + Returns + ------- + numpy.ndarray + Array with transformation applied. + + """ + if 'ModalityLUTSequence' in self: + return self.ModalityLUTSequence[0].apply(array, dtype=dtype) + else: + slope = self.get('RescaleSlope', 1.0) + intercept = self.get('RescaleIntercept', 0.0) + + if dtype is None: + dtype = np.dtype(np.float64) + dtype = np.dtype(dtype) + + _check_rescale_dtype( + input_dtype=array.dtype, + output_dtype=dtype, + intercept=intercept, + slope=slope, + ) + + # Avoid unnecessary array operations for efficiency + if slope != 1.0: + slope = np.float64(slope).astype(dtype) + array = array * slope + if intercept != 0.0: + intercept = np.float64(intercept).astype(dtype) + array = array + intercept + if array.dtype != dtype: + array = array.astype(dtype) + + return array + class PresentationLUT(LUT): @@ -2449,14 +2847,20 @@ def __init__( @property def lut_data(self) -> np.ndarray: """numpy.ndarray: lookup table data""" - if self.bits_per_entry == 8: + bits_per_entry = self.bits_per_entry + if bits_per_entry == 8: dtype = np.uint8 - elif self.bits_per_entry == 16: + elif bits_per_entry == 16: dtype = np.uint16 else: raise RuntimeError("Invalid LUT descriptor.") length = self.number_of_entries data = getattr(self, f'{self._attr_name_prefix}Data') + + # Account for a zero-padding byte in the case of an 8 bit LUT + if bits_per_entry == 8 and length % 2 == 1 and len(data) == length + 1: + data = data[:-1] + # The LUT data attributes have VR OW (16-bit other words) array = np.frombuffer(data, dtype=dtype) # Needs to be casted according to third descriptor value. @@ -2475,7 +2879,7 @@ def number_of_entries(self) -> int: descriptor = getattr(self, f'{self._attr_name_prefix}Descriptor') value = int(descriptor[0]) if value == 0: - return 2 ** self.bits_per_entry + return 2 ** 16 return value @property @@ -2492,6 +2896,73 @@ def bits_per_entry(self) -> int: descriptor = getattr(self, f'{self._attr_name_prefix}Descriptor') return int(descriptor[2]) + def apply( + self, + array: np.ndarray, + dtype: Union[type, str, np.dtype, None] = None, + ) -> np.ndarray: + """Apply the LUT to a pixel array. + + Parameters + ---------- + apply: numpy.ndarray + Pixel array to which the LUT should be applied. Can be of any shape + but must have an integer datatype. + dtype: Union[type, str, numpy.dtype, None], optional + Datatype of the output array. If ``None``, an unsigned integer + datatype corresponding to the number of bits in the LUT will be + used (either ``numpy.uint8`` or ``numpy.uint16``). Only safe casts + are permitted. + + Returns + ------- + numpy.ndarray + Array with LUT applied. + + """ + return apply_lut( + array=array, + lut_data=self.lut_data, + first_mapped_value=self.first_mapped_value, + dtype=dtype, + ) + + @classmethod + def extract_from_dataset(cls, dataset: Dataset, color: str) -> Self: + """Construct from an existing dataset. + + Note that unlike many other ``from_dataset()`` methods, this method + extracts only the atrributes it needs from the original dataset, and + always returns a new object. + + Parameters + ---------- + dataset: pydicom.Dataset + Dataset containing the attributes of the Palette Color Lookup Table + Transformation. + color: str + Text representing the color (``red``, ``green``, or + ``blue``). + + Returns + ------- + highdicom.PaletteColorLUT + New object containing attributes found in ``dataset``. + + """ + kw_prefix = f'{color.title()}PaletteColorLookupTable' + descriptor_kw = kw_prefix + 'Descriptor' + data_kw = kw_prefix + 'Data' + + new_ds = Dataset() + new_ds._attr_name_prefix = kw_prefix + + for kw in [descriptor_kw, data_kw]: + setattr(new_ds, kw, getattr(dataset, kw)) + + new_ds.__class__ = cls + return cast(cls, new_ds) + class SegmentedPaletteColorLUT(Dataset): @@ -2529,8 +3000,6 @@ def __init__( """ super().__init__() - # Note 8 bit LUT data is unsupported for presentation states pending - # clarification on the standard, but is valid for segmentations if segmented_lut_data.dtype.type == np.uint8: bits_per_entry = 8 elif segmented_lut_data.dtype.type == np.uint16: @@ -2680,7 +3149,7 @@ def number_of_entries(self) -> int: # That's because the descriptor attributes have VR US, which cannot # encode the value of 2^16, but only values in the range [0, 2^16 - 1]. if value == 0: - return 2 ** self.bits_per_entry + return 2 ** 16 else: return value @@ -2698,6 +3167,42 @@ def bits_per_entry(self) -> int: descriptor = getattr(self, f'{self._attr_name_prefix}Descriptor') return int(descriptor[2]) + @classmethod + def extract_from_dataset(cls, dataset: Dataset, color: str) -> Self: + """Construct from an existing dataset. + + Note that unlike many other ``from_dataset()`` methods, this method + extracts only the atrributes it needs from the original dataset, and + always returns a new object. + + Parameters + ---------- + dataset: pydicom.Dataset + Dataset containing the attributes of the Palette Color Lookup Table + Transformation. + color: str + Text representing the color (``red``, ``green``, or + ``blue``). + + Returns + ------- + highdicom.SegmentedPaletteColorLUT + New object containing attributes found in ``dataset``. + + """ + kw_prefix = f'{color.title()}PaletteColorLookupTable' + descriptor_kw = kw_prefix + 'Descriptor' + data_kw = 'Segmented' + kw_prefix + 'Data' + + new_ds = Dataset() + new_ds._attr_name_prefix = kw_prefix + + for kw in [descriptor_kw, data_kw]: + setattr(new_ds, kw, getattr(dataset, kw)) + + new_ds.__class__ = cls + return cast(cls, new_ds) + class PaletteColorLUTTransformation(Dataset): @@ -2731,12 +3236,12 @@ def __init__( super().__init__() # Checks on inputs - self._color_luts = { + _color_luts = { 'Red': red_lut, 'Green': green_lut, 'Blue': blue_lut } - for lut in self._color_luts.values(): + for lut in _color_luts.values(): if not isinstance(lut, (PaletteColorLUT, SegmentedPaletteColorLUT)): raise TypeError( 'Arguments "red_lut", "green_lut", and "blue_lut" must be ' @@ -2780,7 +3285,7 @@ def __init__( 'first mapped value.' ) - for name, lut in self._color_luts.items(): + for name, lut in _color_luts.items(): desc_attr = f'{name}PaletteColorLookupTableDescriptor' setattr( self, @@ -2803,13 +3308,239 @@ def __init__( # To cache the array self._lut_data = None + @classmethod + def from_colors( + cls, + colors: Sequence[str], + first_mapped_value: int = 0, + palette_color_lut_uid: Union[UID, str, None] = None + ) -> Self: + """Create a palette color lookup table from a list of colors. + + Parameters + ---------- + colors: Sequence[str] + List of colors. Item ``i`` of the list will be used as the color + for input value ``first_mapped_value + i``. Each color should be a + string understood by PIL's ``getrgb()`` function (see `here + `_ + for the documentation of that function or `here + `_) for the + original list of colors). This includes many case-insensitive color + names (e.g. ``"red"``, ``"Crimson"``, or ``"INDIGO"``), hex codes + (e.g. ``"#ff7733"``) or decimal integers in the format of this + example: ``"RGB(255, 255, 0)"``. + first_mapped_value: int + Pixel value that will be mapped to the first value in the + lookup table. + palette_color_lut_uid: Union[highdicom.UID, str, None], optional + Unique identifier for the palette color lookup table. + + Examples + -------- + + Create a ``PaletteColorLUTTransformation`` for a small number of values + (4 in this case). This would be typical for a labelmap segmentation. + + >>> import highdicom as hd + >>> + >>> lut = hd.PaletteColorLUTTransformation.from_colors( + >>> colors=['black', 'red', 'orange', 'yellow'], + >>> palette_color_lut_uid=hd.UID(), + >>> ) + + Returns + ------- + highdicom.PaletteColorLUTTransformation: + Palette Color Lookup table created from the given colors. This will + always be an 8 bit LUT. + + """ # noqa: E501 + if len(colors) == 0: + raise ValueError("List 'colors' may not be empty.") + + r_list, g_list, b_list = zip( + *[ImageColor.getrgb(c) for c in colors] + ) + + red_lut = PaletteColorLUT( + first_mapped_value=first_mapped_value, + lut_data=np.array(r_list, dtype=np.uint8), + color='red' + ) + green_lut = PaletteColorLUT( + first_mapped_value=first_mapped_value, + lut_data=np.array(g_list, dtype=np.uint8), + color='green' + ) + blue_lut = PaletteColorLUT( + first_mapped_value=first_mapped_value, + lut_data=np.array(b_list, dtype=np.uint8), + color='blue' + ) + + return cls( + red_lut=red_lut, + green_lut=green_lut, + blue_lut=blue_lut, + palette_color_lut_uid=palette_color_lut_uid, + ) + + @classmethod + def from_combined_lut( + cls, + lut_data: np.ndarray, + first_mapped_value: int = 0, + palette_color_lut_uid: Union[UID, str, None] = None + ) -> Self: + """Create a palette color lookup table from a combined LUT array. + + Parameters + ---------- + lut_data: numpy.ndarray + LUT array with shape ``(number_of_entries, 3)`` where the entries + are stacked as rows and the 3 columns represent the red, green, and + blue channels (in that order). Data type must be ``numpy.uint8`` or + ``numpy.uint16``. + first_mapped_value: int + Input pixel value that will be mapped to the first value in the + lookup table. + palette_color_lut_uid: Union[highdicom.UID, str, None], optional + Unique identifier for the palette color lookup table. + + Returns + ------- + highdicom.PaletteColorLUTTransformation: + Palette Color Lookup table created from the given colors. This will + be an 8-bit or 16-bit LUT depending on the data type of the input + ``lut_data``. + + + Examples + -------- + + Create a ``PaletteColorLUTTransformation`` from a built-in colormap + from the well-known ``matplotlib`` python package (must be installed + separately). + + >>> import numpy as np + >>> from matplotlib import colormaps + >>> import highdicom as hd + >>> + >>> # Use matplotlib's built-in 'gist_rainbow_r' colormap as an example + >>> cmap = colormaps['gist_rainbow_r'] + >>> + >>> # Create an 8-bit RGBA LUT array from the colormap + >>> num_entries = 10 # e.g. number of classes in a segmentation + >>> lut_data = cmap(np.arange(num_entries) / (num_entries + 1), bytes=True) + >>> + >>> # Remove the alpha channel (at index 3) + >>> lut_data = lut_data[:, :3] + >>> + >>> lut = hd.PaletteColorLUTTransformation.from_combined_lut( + >>> lut_data, + >>> palette_color_lut_uid=hd.UID(), + >>> ) + + """ # noqa: E501 + if lut_data.ndim != 2 or lut_data.shape[1] != 3: + raise ValueError( + "Argument 'lut_data' must have shape (number_of_entries, 3)." + ) + + if lut_data.dtype not in (np.uint8, np.uint16): + raise ValueError( + "Argument 'lut_data' must have data type numpy.uint8 or " + 'numpy.uint16.' + ) + + red_lut = PaletteColorLUT( + first_mapped_value=first_mapped_value, + lut_data=lut_data[:, 0], + color='red' + ) + green_lut = PaletteColorLUT( + first_mapped_value=first_mapped_value, + lut_data=lut_data[:, 1], + color='green' + ) + blue_lut = PaletteColorLUT( + first_mapped_value=first_mapped_value, + lut_data=lut_data[:, 2], + color='blue' + ) + + return cls( + red_lut=red_lut, + green_lut=green_lut, + blue_lut=blue_lut, + palette_color_lut_uid=palette_color_lut_uid, + ) + + @property + def is_segmented(self) -> bool: + """bool: True if the transformation is a segmented LUT. + False otherwise.""" + return 'SegmentedRedPaletteColorLookupTableData' in self + + @property + def number_of_entries(self) -> int: + """int: Number of entries in the lookup table.""" + value = int(self.RedPaletteColorLookupTableDescriptor[0]) + # Part 3 Section C.7.6.3.1.5 Palette Color Lookup Table Descriptor + # "When the number of table entries is equal to 2^16 + # then this value shall be 0". + # That's because the descriptor attributes have VR US, which cannot + # encode the value of 2^16, but only values in the range [0, 2^16 - 1]. + if value == 0: + return 2**16 + else: + return value + + @property + def first_mapped_value(self) -> int: + """int: Pixel value that will be mapped to the first value in the + lookup table. + """ + return int(self.RedPaletteColorLookupTableDescriptor[1]) + + @property + def bits_per_entry(self) -> int: + """int: Bits allocated for the lookup table data. 8 or 16.""" + return int(self.RedPaletteColorLookupTableDescriptor[2]) + + def _get_lut(self, color: str): + """Get a LUT for a single given color channel. + + Parameters + ---------- + color: str + Name of the color, either ``'red'``, ``'green'``, or ``'blue'``. + + Returns + ------- + Union[highdicom.PaletteColorLUT, highdicom.SegmentedPaletteColorLUT]: + Lookup table for the given output color channel + + """ + if self.is_segmented: + return SegmentedPaletteColorLUT.extract_from_dataset( + self, + color=color.lower(), + ) + else: + return PaletteColorLUT.extract_from_dataset( + self, + color=color.lower(), + ) + @property def red_lut(self) -> Union[PaletteColorLUT, SegmentedPaletteColorLUT]: """Union[highdicom.PaletteColorLUT, highdicom.SegmentedPaletteColorLUT]: Lookup table for the red output color channel """ - return self._color_luts['Red'] + return self._get_lut('red') @property def green_lut(self) -> Union[PaletteColorLUT, SegmentedPaletteColorLUT]: @@ -2817,7 +3548,7 @@ def green_lut(self) -> Union[PaletteColorLUT, SegmentedPaletteColorLUT]: Lookup table for the green output color channel """ - return self._color_luts['Green'] + return self._get_lut('green') @property def blue_lut(self) -> Union[PaletteColorLUT, SegmentedPaletteColorLUT]: @@ -2825,4 +3556,98 @@ def blue_lut(self) -> Union[PaletteColorLUT, SegmentedPaletteColorLUT]: Lookup table for the blue output color channel """ - return self._color_luts['Blue'] + return self._get_lut('blue') + + @property + def combined_lut_data(self) -> np.ndarray: + """numpy.ndarray: + + An NumPy array of shape (number_of_entries, 3) containing the red, + green and blue lut data stacked along the final dimension of the + array. Data type with be 8 or 16 bit unsigned integer depending on + the number of bits per entry in the LUT. + + """ + if self._lut_data is None: + _, self._lut_data = _get_combined_palette_color_lut(self) + return cast(np.ndarray, self._lut_data) + + @classmethod + def extract_from_dataset(cls, dataset: Dataset) -> Self: + """Construct from an existing dataset. + + Note that unlike many other ``from_dataset()`` methods, this method + extracts only the atrributes it needs from the original dataset, and + always returns a new object. + + Parameters + ---------- + dataset: pydicom.Dataset + Dataset containing Palette Color LUT information. Note that any + number of other attributes may be included and will be ignored (for + example allowing an entire image with Palette Color LUT information + at the top level to be passed). + + Returns + ------- + highdicom.PaletteColorLUTTransformation + New object containing attributes found in ``dataset``. + + """ + new_dataset = Dataset() + + ( + is_segmented, + descriptor, + lut_data, + ) = _parse_palette_color_lut_attributes(dataset) + + for color, data in zip(['Red', 'Green', 'Blue'], lut_data): + desc_kw = f'{color}PaletteColorLookupTableDescriptor' + setattr(new_dataset, desc_kw, list(descriptor)) + + if is_segmented: + data_kw = f'Segmented{color}PaletteColorLookupTableData' + else: + data_kw = f'{color}PaletteColorLookupTableData' + setattr(new_dataset, data_kw, data) + + new_dataset.__class__ = cls + return cast(cls, new_dataset) + + def apply( + self, + array: np.ndarray, + dtype: Union[type, str, np.dtype, None] = None, + ) -> np.ndarray: + """Apply the LUT to a pixel array. + + Parameters + ---------- + apply: numpy.ndarray + Pixel array to which the LUT should be applied. Can be of any shape + but must have an integer datatype. + dtype: Union[type, str, numpy.dtype, None], optional + Datatype of the output array. If ``None``, an unsigned integer + datatype corresponding to the number of bits in the LUT will be + used (either ``numpy.uint8`` or ``numpy.uint16``). Only safe casts + are permitted. + + Returns + ------- + numpy.ndarray + Array with LUT applied. The RGB channels will be stacked along a + new final dimension. + + """ + if isinstance(self.red_lut, SegmentedPaletteColorLUT): + raise RuntimeError( + "The 'apply' method is not implemented for segmented LUTs." + ) + + return apply_lut( + array=array, + lut_data=self.combined_lut_data, + first_mapped_value=self.first_mapped_value, + dtype=dtype, + ) diff --git a/src/highdicom/enum.py b/src/highdicom/enum.py index 32f2bf15..0ca66faa 100644 --- a/src/highdicom/enum.py +++ b/src/highdicom/enum.py @@ -1,7 +1,18 @@ -"""Enumerated values.""" +"""Enumerated halues.""" from enum import Enum +class RGBColorChannels(Enum): + R = 'R' + """Red color channel.""" + + G = 'G' + """Green color channel.""" + + B = 'B' + """Blue color channel.""" + + class CoordinateSystemNames(Enum): """Enumerated values for coordinate system names.""" @@ -10,6 +21,43 @@ class CoordinateSystemNames(Enum): SLIDE = 'SLIDE' +class PixelIndexDirections(Enum): + + """ + + Enumerated values used to describe indexing conventions of pixel arrays. + + """ + + L = 'L' + """ + + Left: Pixel index that increases moving across the rows from right to left. + + """ + + R = 'R' + """ + + Right: Pixel index that increases moving across the rows from left to right. + + """ + + U = 'U' + """ + + Up: Pixel index that increases moving up the columns from bottom to top. + + """ + + D = 'D' + """ + + Down: Pixel index that increases moving down the columns from top to bottom. + + """ + + class ContentQualificationValues(Enum): """Enumerated values for Content Qualification attribute.""" @@ -267,3 +315,57 @@ class UniversalEntityIDTypeValues(Enum): X500 = 'X500' """An X.500 directory name.""" + + +class PadModes(Enum): + + """Enumerated values of modes to pad an array.""" + + CONSTANT = 'CONSTANT' + """Pad with a specified constant value.""" + + EDGE = 'EDGE' + """Pad with the edge value.""" + + MINIMUM = 'MINIMUM' + """Pad with the minimum value.""" + + MAXIMUM = 'MAXIMUM' + """Pad with the maximum value.""" + + MEAN = 'MEAN' + """Pad with the mean value.""" + + MEDIAN = 'MEDIAN' + """Pad with the median value.""" + + +class AxisHandedness(Enum): + + """Enumerated values for axis handedness. + + Axis handedness refers to a property of a mapping between voxel indices and + their corresponding coordinates in the frame-of-reference coordinate + system, as represented by the affine matrix. + + """ + + LEFT_HANDED = "LEFT_HANDED" + """ + + The unit vectors of the first, second and third axes form a left hand when + drawn in the frame-of-reference coordinate system with the thumb + representing the first vector, the index finger representing the second + vector, and the middle finger representing the third vector. + + """ + + RIGHT_HANDED = "RIGHT_HANDED" + """ + + The unit vectors of the first, second and third axes form a right hand when + drawn in the frame-of-reference coordinate system with the thumb + representing the first vector, the index finger representing the second + vector, and the middle finger representing the third vector. + + """ diff --git a/src/highdicom/frame.py b/src/highdicom/frame.py index adc569a3..d813f474 100644 --- a/src/highdicom/frame.py +++ b/src/highdicom/frame.py @@ -1,12 +1,12 @@ import logging from io import BytesIO -from typing import Optional, Union +from typing import Optional, Union, cast import numpy as np from PIL import Image from pydicom.dataset import Dataset, FileMetaDataset from pydicom.encaps import encapsulate -from pydicom.pixels.utils import pack_bits +from pydicom.pixels.utils import pack_bits, unpack_bits from pydicom.pixels.encoders.base import get_encoder from pydicom.uid import ( ExplicitVRLittleEndian, @@ -145,7 +145,7 @@ def encode_frame( 'with native encoding.' ) allowable_pis = { - 1: ['MONOCHROME1', 'MONOCHROME2', 'PALETTE_COLOR'], + 1: ['MONOCHROME1', 'MONOCHROME2', 'PALETTE COLOR'], 3: ['RGB', 'YBR_FULL'], }[samples_per_pixel] if photometric_interpretation not in allowable_pis: @@ -358,7 +358,8 @@ def decode_frame( bits_stored: int, photometric_interpretation: Union[PhotometricInterpretationValues, str], pixel_representation: Union[PixelRepresentationValues, int] = 0, - planar_configuration: Optional[Union[PlanarConfigurationValues, int]] = None + planar_configuration: Optional[Union[PlanarConfigurationValues, int]] = None, + index: int = 0, ) -> np.ndarray: """Decode pixel data of an individual frame. @@ -387,6 +388,16 @@ def decode_frame( planar_configuration: Union[highdicom.PlanarConfigurationValues, int, None], optional Whether color samples are encoded by pixel (``R1G1B1R2G2B2...``) or by plane (``R1R2...G1G2...B1B2...``). + index: int, optional + The (zero-based) index of the frame in the original dataset. This is + only required situation: when the bits allocated is 1, the transfer + syntax is not encapsulated (i.e. is native) and the number of pixels + per frame is not a multiple of 8. In this case, the index is required + to know how many bits need to be stripped from the start and/or end of + the byte array. In all other situtations, this parameter is not + required and will have no effect (since decoding a frame does not + depend on the index of the frame). + Returns ------- @@ -420,6 +431,17 @@ def decode_frame( of color image frames in RGB color space. """ # noqa: E501 + is_encapsulated = UID(transfer_syntax_uid).is_encapsulated + + # This is a special case since there may be extra bits that need stripping + # from the start and/or end + if bits_allocated == 1 and not is_encapsulated: + unpacked_frame = cast(np.ndarray, unpack_bits(value)) + n_pixels = (rows * columns * samples_per_pixel) + pixel_offset = int(((index * n_pixels / 8) % 1) * 8) + pixel_array = unpacked_frame[pixel_offset:pixel_offset + n_pixels] + return pixel_array.reshape(rows, columns) + # The pydicom library does currently not support reading individual frames. # This hack creates a small dataset containing only a single frame, which # can then be decoded using the pydicom API. @@ -453,7 +475,7 @@ def decode_frame( ).value ds.PlanarConfiguration = planar_configuration - if UID(file_meta.TransferSyntaxUID).is_encapsulated: + if is_encapsulated: ds.PixelData = encapsulate(frames=[value]) else: ds.PixelData = value diff --git a/src/highdicom/image.py b/src/highdicom/image.py new file mode 100644 index 00000000..9cacf2e0 --- /dev/null +++ b/src/highdicom/image.py @@ -0,0 +1,4044 @@ +"""Tools for working with general DICOM images.""" +from collections import Counter +from contextlib import contextmanager, nullcontext +from copy import deepcopy +from dataclasses import dataclass +from enum import Enum +import logging +from os import PathLike +import sqlite3 +from typing import ( + Any, + BinaryIO, + Iterable, + Iterator, + Dict, + Generator, + List, + Optional, + Set, + Sequence, + Tuple, + Union, + cast, +) +from typing_extensions import Self + +import numpy as np +from pydicom import Dataset, dcmread +from pydicom.encaps import get_frame +from pydicom.tag import BaseTag +from pydicom.datadict import ( + get_entry, + tag_for_keyword, +) +from pydicom.multival import MultiValue +from pydicom.sr.coding import Code +from pydicom.uid import ParametricMapStorage + +from highdicom._value_types import ( + _DCM_PYTHON_TYPE_MAP, + _DCM_SQL_TYPE_MAP, +) +from highdicom._module_utils import ( + does_iod_have_pixel_data, + is_multiframe_image, +) +from highdicom.base import SOPClass, _check_little_endian +from highdicom.color import ColorManager +from highdicom.content import LUT, VOILUTTransformation +from highdicom.enum import ( + CoordinateSystemNames, +) +from highdicom.frame import decode_frame +from highdicom.io import ImageFileReader +from highdicom.pixel_transforms import ( + _check_rescale_dtype, + _get_combined_palette_color_lut, + _select_real_world_value_map, + _select_voi_lut, + _select_voi_window_center_width, + apply_lut, + apply_voi_window, +) +from highdicom.seg.enum import SpatialLocationsPreservedValues +from highdicom.spatial import ( + get_image_coordinate_system, + get_series_volume_positions, + get_volume_positions, + is_tiled_image, + sort_datasets, +) +from highdicom.sr.coding import CodedConcept +from highdicom.uid import UID as UID +from highdicom.utils import ( + iter_tiled_full_frame_data, +) +from highdicom.volume import ( + VolumeGeometry, + Volume, + RGB_COLOR_CHANNEL_IDENTIFIER, +) + + +logger = logging.getLogger(__name__) + + +# TODO deal with extended offset table +# TODO test laziness +# TODO rebase parametric map +# TODO tiled volumes +# TODO additional get pixel methods +# TODO expose channel bhaviour +# TODO behavior of simple frame images +# TODO referenced images for non-seg images +# TODO exports/inits and docs +# TODO disallow direct creation of Image +# TODO docstrings for frame methods +# TODO frames by index as well as number? +# TODO fix voi window on volume +# TODO add labelmap to seg documentation +# TODO quickstart for image/volume +# TODO pixel_array for lazy retrieval + + +class _ImageColorType(Enum): + """Internal enum describing color arrangement of an image.""" + MONOCHROME = 'MONOCHROME' + COLOR = 'COLOR' + PALETTE_COLOR = 'PALETTE_COLOR' + + +def _deduce_color_type(image: Dataset): + """Deduce the color type for an image. + + Parameters + ---------- + image: pydicom.Dataset + Image dataset. + + Returns + ------- + _ImageColorType: + Color type of the image. + + """ + photometric_interpretation = image.PhotometricInterpretation + + if photometric_interpretation in ( + 'MONOCHROME1', + 'MONOCHROME2', + ): + return _ImageColorType.MONOCHROME + elif photometric_interpretation == 'PALETTE COLOR': + return _ImageColorType.PALETTE_COLOR + return _ImageColorType.COLOR + + +class _CombinedPixelTransformation: + + """Class representing a combined pixel transformation. + + DICOM images contain multi-stage transformations to apply to the raw stored + pixel values. This class is intended to provdie a single class that + configurably and efficiently applies the net effect of the selected + transforms to stored pixel data. + + Depending on the parameters, it may perform operations related to the + following: + + For monochrome images: + * Real world value maps, which map stored pixels to real-world values and + is independent of all other transforms + * Modality LUT transformation, which transforms stored pixel values to + modality-specific values + * Value-of-interest (VOI) LUT transformation, which transforms the output + of the Modality LUT transform to output values in order to focus on a + particular region of intensities values of particular interest (such as a + windowing operation). + * Presentation LUT transformation, which inverts the range of values for + display. + + For pseudo-color images (stored as monochrome images but displayed as color + images): + * The Palette Color LUT transformation, which maps stored single-sample + pixel values to 3-samples-per-pixel RGB color images. + + For color images and pseudo-color images: + * The ICCProfile, which performs color correction. + + """ + + def __init__( + self, + image: Dataset, + frame_index: int = 0, + *, + output_dtype: Union[type, str, np.dtype, None] = np.float64, + apply_real_world_transform: bool | None = None, + real_world_value_map_selector: int | str | Code | CodedConcept = 0, + apply_modality_transform: bool | None = None, + apply_voi_transform: bool | None = False, + voi_transform_selector: int | str | VOILUTTransformation = 0, + voi_output_range: Tuple[float, float] = (0.0, 1.0), + apply_presentation_lut: bool = True, + apply_palette_color_lut: bool | None = None, + remove_palette_color_values: Sequence[int] | None = None, + palette_color_background_index: int = 0, + apply_icc_profile: bool | None = None, + ): + """ + + Parameters + ---------- + image: pydicom.Dataset + Image (single frame or multiframe) for which the pixel + transformation should be represented. + frame_index: int + Zero-based index (one less than the frame number). + output_dtype: Union[type, str, np.dtype, None], optional + Data type of the output array. + apply_real_world_transform: bool | None, optional + Whether to apply to apply the real-world value map to the frame. + The real world value map converts stored pixel values to output + values with a real-world meaning, either using a LUT or a linear + slope and intercept. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if present but no error will be raised if + it is not present. + + Note that if the dataset contains both a modality LUT and a real + world value map, the real world value map will be applied + preferentially. This also implies that specifying both + ``apply_real_world_transform`` and ``apply_modality_transform`` to + True is not permitted. + real_world_value_map_selector: int | str | pydicom.sr.coding.Code | highdicom.sr.coding.CodedConcept, optional + Specification of the real world value map to use (multiple may be + present in the dataset). If an int, it is used to index the list of + available maps. A negative integer may be used to index from the + end of the list following standard Python indexing convention. If a + str, the string will be used to match the ``"LUTLabel"`` attribute + to select the map. If a ``pydicom.sr.coding.Code`` or + ``highdicom.sr.coding.CodedConcept``, this will be used to match + the units (contained in the ``"MeasurementUnitsCodeSequence"`` + attribute). + apply_modality_transform: bool | None, optional + Whether to apply to the modality transform (if present in the + dataset) the frame. The modality transformation maps stored pixel + values to output values, either using a LUT or rescale slope and + intercept. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present and no real world value + map takes precedence, but no error will be raised if it is not + present. + apply_voi_transform: bool | None, optional + Apply the value-of-interest (VOI) transformation (if present in the + dataset), which limits the range of pixel values to a particular + range of interest, using either a windowing operation or a LUT. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present and no real world value + map takes precedence, but no error will be raised if it is not + present. + voi_transform_selector: int | str | highdicom.content.VOILUTTransformation, optional + Specification of the VOI transform to select (multiple may be + present). May either be an int or a str. If an int, it is + interpretted as a (zero-based) index of the list of VOI transforms + to apply. A negative integer may be used to index from the end of + the list following standard Python indexing convention. If a str, + the string that will be used to match the + ``"WindowCenterWidthExplanation"`` or the ``"LUTExplanation"`` + attributes to choose from multiple VOI transforms. Note that such + explanations are optional according to the standard and therefore + may not be present. Ignored if ``apply_voi_transform`` is ``False`` + or no VOI transform is included in the datasets. + voi_output_range: Tuple[float, float], optional + Range of output values to which the VOI range is mapped. Only + relevant if ``apply_voi_transform`` is True and a VOI transform is + present. + apply_palette_color_lut: bool | None, optional + Apply the palette color LUT, if present in the dataset. The palette + color LUT maps a single sample for each pixel stored in the dataset + to a 3 sample-per-pixel color image. + apply_presentation_lut: bool, optional + Apply the presentation LUT transform to invert the pixel values. If + the PresentationLUTShape is present with the value ``'INVERSE''``, + or the PresentationLUTShape is not present but the Photometric + Interpretation is MONOCHROME1, convert the range of the output + pixels corresponds to MONOCHROME2 (in which high values are + represent white and low values represent black). Ignored if + PhotometricInterpretation is not MONOCHROME1 and the + PresentationLUTShape is not present, or if a real world value + transform is applied. + remove_palette_color_values: Sequence[int] | None, optional + Remove values from the palette color LUT (if any) by altering the + LUT so that these values map to the RGB value at position + ``palette_color_background_index`` instead of their original value. + This is intended to remove segments from a palette color labelmap + segmentation. + palette_color_background_index: int, optional + The index (i.e. input) of the palette color LUT that corresponds to + background. Relevant only if ``remove_palette_color_values`` is + provided. + apply_icc_profile: bool | None, optional + Whether colors should be corrected by applying an ICC + transformation. Will only be performed if metadata contain an + ICC Profile. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present, but no error will be + raised if it is not present. + + """ # noqa: E501 + if not does_iod_have_pixel_data(image.SOPClassUID): + raise ValueError( + 'Input dataset does not represent an image.' + ) + + if not isinstance( + voi_transform_selector, + (int, str, VOILUTTransformation), + ): + raise TypeError( + "Parameter 'voi_transform_selector' must have type 'int', " + "'str', or 'highdicom.content.VOILUTTransformation'." + ) + + self._color_type = _deduce_color_type(image) + + if apply_real_world_transform is None: + use_rwvm = True + require_rwvm = False + else: + use_rwvm = bool(apply_real_world_transform) + require_rwvm = use_rwvm + + if apply_modality_transform is None: + use_modality = True + require_modality = False + else: + use_modality = bool(apply_modality_transform) + require_modality = use_modality + + if require_modality and require_rwvm: + raise ValueError( + "Setting both 'apply_real_world_transform' and " + "'apply_modality_transform' to True is not " + "permitted." + ) + if require_rwvm: + # No need to search for modality or VOI since they won't be used + use_modality = False + use_voi = False + if require_modality: + # No need to search for rwvm since it won't be used + use_rwvm = False + + if apply_voi_transform is None: + use_voi = True + require_voi = False + else: + use_voi = bool(apply_voi_transform) + require_voi = use_voi + + if use_voi and not use_modality: + # The transform is dependent on first applying the modality + # transform + raise ValueError( + "If 'apply_voi_transform' is True or None, " + "'apply_modality_transform' cannot be False." + ) + + if require_rwvm and self._color_type != _ImageColorType.MONOCHROME: + raise ValueError( + 'Real-world value map is required but the image is not ' + 'a monochrome image.' + ) + if require_modality and self._color_type != _ImageColorType.MONOCHROME: + raise ValueError( + 'Modality transform is required but the image is not ' + 'a monochrome image.' + ) + if require_voi and self._color_type != _ImageColorType.MONOCHROME: + raise ValueError( + 'VOI transform is required but the image is not ' + 'a monochrome image.' + ) + + if apply_palette_color_lut is None: + use_palette_color = True + require_palette_color = False + else: + use_palette_color = bool(apply_palette_color_lut) + require_palette_color = use_palette_color + + if ( + require_palette_color and self._color_type != + _ImageColorType.PALETTE_COLOR + ): + raise ValueError( + 'Palette color transform is required but the image is not ' + 'a palette color image.' + ) + + if apply_icc_profile is None: + use_icc = True + require_icc = False + else: + use_icc = bool(apply_icc_profile) + require_icc = use_icc + + if use_icc and not use_palette_color: + # The transform is dependent on first applying the icc + # transform + raise ValueError( + "If 'apply_icc_transform' is True or None, " + "'apply_palette_color_lut' cannot be False." + ) + + if require_icc and self._color_type == _ImageColorType.MONOCHROME: + raise ValueError( + 'ICC profile is required but the image is not ' + 'a color or palette color image.' + ) + + if not isinstance(apply_presentation_lut, bool): + raise TypeError( + "Parameter 'apply_presentation_lut' must have type bool." + ) + + output_min, output_max = voi_output_range + if output_min >= output_max: + raise ValueError( + "Second value of 'voi_output_range' must be higher than " + "the first." + ) + + self.output_dtype = np.dtype(output_dtype) + self.applies_to_all_frames = True + self._input_range_check: tuple[int, int] | None = None + self._voi_output_range = voi_output_range + self._effective_lut_data: np.ndarray | None = None + self._effective_lut_first_mapped_value = 0 + self._effective_window_center_width: tuple[float, float] | None = None + self._effective_voi_function = None + self._effective_slope_intercept: tuple[float, float] | None = None + self._invert = False + self._clip = True + + # Determine input type and range of values + input_range = None + if ( + image.SOPClassUID == ParametricMapStorage + and image.BitsAllocated > 16 + ): + # Parametric Maps are the only SOP Class (currently) that allows + # floating point pixels + if image.BitsAllocated == 32: + self.input_dtype = np.dtype(np.float32) + elif image.BitsAllocated == 64: + self.input_dtype = np.dtype(np.float64) + else: + if image.PixelRepresentation == 1: + if image.BitsAllocated == 8: + self.input_dtype = np.dtype(np.int8) + elif image.BitsAllocated == 16: + self.input_dtype = np.dtype(np.int16) + elif image.BitsAllocated == 32: + self.input_dtype = np.dtype(np.int32) + + # 2's complement to define the range + half_range = 2 ** (image.BitsStored - 1) + input_range = (-half_range, half_range - 1) + else: + if image.BitsAllocated == 1: + self.input_dtype = np.dtype(np.uint8) + elif image.BitsAllocated == 8: + self.input_dtype = np.dtype(np.uint8) + elif image.BitsAllocated == 16: + self.input_dtype = np.dtype(np.uint16) + elif image.BitsAllocated == 32: + self.input_dtype = np.dtype(np.uint32) + input_range = (0, 2 ** image.BitsStored - 1) + + if self._color_type == _ImageColorType.PALETTE_COLOR: + # Note that some monochrome images have optional palette color + # LUTs. Currently such LUTs will never be applied + if use_palette_color: + if 'SegmentedRedPaletteColorLookupTableData' in image: + # TODO + raise RuntimeError("Segmented LUTs are not implemented.") + + ( + self._effective_lut_first_mapped_value, + self._effective_lut_data + ) = _get_combined_palette_color_lut(image) + + # Zero out certain indices if requested + if ( + remove_palette_color_values is not None and + len(remove_palette_color_values) > 0 + ): + to_remove = np.array( + remove_palette_color_values + ) - self._effective_lut_first_mapped_value + target = ( + palette_color_background_index - + self._effective_lut_first_mapped_value + ) + self._effective_lut_data[ + to_remove, : + ] = self._effective_lut_data[target, :] + + elif self._color_type == _ImageColorType.MONOCHROME: + # Create a list of all datasets to check for transforms for + # this frame, and whether they are shared by all frames + datasets = [(image, True)] + + if 'SharedFunctionalGroupsSequence' in image: + datasets.append( + (image.SharedFunctionalGroupsSequence[0], True) + ) + + if 'PerFrameFunctionalGroupsSequence' in image: + datasets.append( + ( + image.PerFrameFunctionalGroupsSequence[frame_index], + False, + ) + ) + + modality_lut: Optional[LUT] = None + modality_slope_intercept: Optional[Tuple[float, float]] = None + + voi_lut: Optional[LUT] = None + voi_scaled_lut_data: Optional[np.ndarray] = None + voi_center_width: Optional[Tuple[float, float]] = None + voi_function = 'LINEAR' + invert = False + has_rwvm = False + + if apply_presentation_lut: + if 'PresentationLUTShape' in image: + invert = image.PresentationLUTShape == 'INVERSE' + elif image.PhotometricInterpretation == 'MONOCHROME1': + invert = True + + if use_rwvm: + for ds, is_shared in datasets: + rwvm_seq = ds.get('RealWorldValueMappingSequence') + if rwvm_seq is not None: + + rwvm_item = _select_real_world_value_map( + rwvm_seq, + real_world_value_map_selector, + ) + + if rwvm_item is None: + raise IndexError( + "Requested 'real_world_value_map_selector' is " + "not present." + ) + + if 'RealWorldValueLUTData' in rwvm_item: + self._effective_lut_data = np.array( + rwvm_item.RealWorldValueLUTData + ) + self._effective_lut_first_mapped_value = int( + rwvm_item.RealWorldValueFirstValueMapped + ) + self._clip = False + else: + self._effective_slope_intercept = ( + rwvm_item.RealWorldValueSlope, + rwvm_item.RealWorldValueIntercept, + ) + if 'DoubleFloatRealWorldValueFirstValueMapped' in rwvm_item: + self._input_range_check = ( + rwvm_item.DoubleFloatRealWorldValueFirstValueMapped, + rwvm_item.DoubleFloatRealWorldValueLastValueMapped + ) + else: + self._input_range_check = ( + rwvm_item.RealWorldValueFirstValueMapped, + rwvm_item.RealWorldValueLastValueMapped + ) + self.applies_to_all_frames = ( + self.applies_to_all_frames and is_shared + ) + has_rwvm = True + break + + if require_rwvm and not has_rwvm: + raise RuntimeError( + 'A real-world value map is required but not found in the ' + 'image.' + ) + + if not has_rwvm and use_modality: + + if 'ModalityLUTSequence' in image: + modality_lut = LUT.from_dataset( + image.ModalityLUTSequence[0] + ) + else: + for ds, is_shared in datasets: + if 'PixelValueTransformationSequence' in ds: + sub_ds = ds.PixelValueTransformationSequence[0] + else: + sub_ds = ds + + if ( + 'RescaleSlope' in sub_ds or + 'RescaleIntercept' in sub_ds + ): + modality_slope_intercept = ( + float(sub_ds.get('RescaleSlope', 1.0)), + float(sub_ds.get('RescaleIntercept', 0.0)) + ) + self.applies_to_all_frames = ( + self.applies_to_all_frames and is_shared + ) + break + + if ( + require_modality and + modality_lut is None and + modality_slope_intercept is None + ): + raise RuntimeError( + 'A modality LUT transform is required but not found in ' + 'the image.' + ) + + if not has_rwvm and use_voi: + + if isinstance(voi_transform_selector, VOILUTTransformation): + + if voi_transform_selector.has_lut(): + if len(voi_transform_selector.VOILUTSequence) > 1: + raise ValueError( + "If providing a VOILUTTransformation as the " + "'voi_transform_selector', it must contain " + "a single transform." + ) + voi_lut = voi_transform_selector.VOILUTSequence[0] + else: + voi_center = voi_transform_selector.WindowCenter + voi_width = voi_transform_selector.WindowWidth + if ( + isinstance(voi_width, MultiValue) or + isinstance(voi_center, MultiValue) + ): + raise ValueError( + "If providing a VOILUTTransformation as the " + "'voi_transform_selector', it must contain " + "a single transform." + ) + voi_center_width = (float(voi_center), float(voi_width)) + else: + # Need to find existing VOI LUT information + if 'VOILUTSequence' in image: + + voi_lut_ds = _select_voi_lut(image, voi_transform_selector) + + if voi_lut_ds is None: + raise IndexError( + "Requested 'voi_transform_selector' is " + "not present." + ) + + voi_lut = LUT.from_dataset(voi_lut_ds) + else: + for ds, is_shared in datasets: + if 'FrameVOILUTSequence' in ds: + sub_ds = ds.FrameVOILUTSequence[0] + else: + sub_ds = ds + + if ( + 'WindowCenter' in sub_ds or + 'WindowWidth' in sub_ds + ): + voi_function = str( + sub_ds.get('VOILUTFunction', 'LINEAR') + ) + + voi_center_width = _select_voi_window_center_width( + sub_ds, + voi_transform_selector, + ) + if voi_center_width is None: + raise IndexError( + "Requested 'voi_transform_selector' is " + 'not present.' + ) + self.applies_to_all_frames = ( + self.applies_to_all_frames and is_shared + ) + break + + if ( + require_voi and + voi_center_width is None and + voi_lut is None + ): + if has_rwvm: + raise RuntimeError( + 'A VOI transform is required but is superseded by ' + 'a real world value transform.' + ) + else: + raise RuntimeError( + 'A VOI transform is required but not found in ' + 'the image.' + ) + + # Determine how to combine modality, voi and presentation + # transforms + if modality_lut is not None and not has_rwvm: + if voi_center_width is not None: + # Apply the window function to the modality LUT + self._effective_lut_data = apply_voi_window( + array=modality_lut.lut_data, + window_center=voi_center_width[0], + window_width=voi_center_width[1], + output_range=voi_output_range, + dtype=output_dtype, + invert=invert, + voi_lut_function=voi_function, + ) + self._effective_lut_first_mapped_value = ( + modality_lut.first_mapped_value + ) + + elif voi_lut is not None: + # "Compose" the two LUTs together by applying the + # second to the first + self._effective_lut_data = voi_lut.apply( + modality_lut.lut_data + ) + self._effective_lut_first_mapped_value = ( + modality_lut.first_mapped_value + ) + else: + # No VOI LUT transform so the modality lut operates alone + if invert: + self._effective_lut_data = ( + modality_lut.get_inverted_lut_data() + ) + else: + self._effective_lut_data = modality_lut.lut_data + self._effective_lut_first_mapped_value = ( + modality_lut.first_mapped_value + ) + + elif not has_rwvm: + # modality LUT either doesn't exist or is a rescale/slope + if modality_slope_intercept is not None: + slope, intercept = modality_slope_intercept + else: + # No rescale slope found in dataset, so treat them as the + # 'identity' values + slope, intercept = (1.0, 0.0) + + if voi_center_width is not None: + # Shift and scale the window to account for the scaling + # and intercept + center, width = voi_center_width + self._effective_window_center_width = ( + (center - intercept) / slope, + width / slope + ) + self._effective_voi_function = voi_function + self._invert = invert + + elif voi_lut is not None: + # Shift and "scale" the LUT to account for the rescale + if not intercept.is_integer() and slope.is_integer(): + raise ValueError( + "Cannot apply a VOI LUT when rescale intercept " + "or slope have non-integer values." + ) + intercept = int(intercept) + slope = int(slope) + voi_scaled_lut_data = voi_lut.get_scaled_lut_data( + output_range=voi_output_range, + dtype=output_dtype, + invert=invert, + ) + if slope != 1: + self._effective_lut_data = voi_scaled_lut_data[::slope] + else: + self._effective_lut_data = voi_scaled_lut_data + adjusted_first_value = ( + (voi_lut.first_mapped_value - intercept) / slope + ) + if not adjusted_first_value.is_integer(): + raise ValueError( + "Cannot apply a VOI LUT when rescale intercept " + "or slope have non-integer values." + ) + self._effective_lut_first_mapped_value = int( + adjusted_first_value + ) + else: + # No VOI LUT transform, so the modality rescale + # operates alone + if invert: + # Adjust the parameters to invert the intensities + # within the scaled and offset range + eff_slope = -slope + if input_range is None: + # This situation will be unusual: float valued + # pixels with a rescale transform that needs to + # be inverted. For simplicity, just invert + # the pixel values + eff_intercept = -intercept + else: + imin, imax = input_range + eff_intercept = ( + slope * (imin + imax) + intercept + ) + self._effective_slope_intercept = ( + eff_slope, eff_intercept + ) + else: + self._effective_slope_intercept = ( + modality_slope_intercept + ) + + self._color_manager = None + if use_icc: + if 'ICCProfile' in image: + # ICC is normally at the top level of the dataset + self._color_manager = ColorManager(image.ICCProfile) + elif 'OpticalPathSequence' in image: + # In certain microscopy images, ICC is in the optical paths + # sequence + if len(image.OpticalPathSequence) == 1: + optical_path_item = image.OpticalPathSequence[0] + else: + # Multiple optical paths, need to find the identifier for + # this frame + identifier = None + if 'SharedFunctionalGroupsSequence' in image: + sfgs = image.SharedFunctionalGroupsSequence[0] + if 'OpticalPathIdentificationSequence' in sfgs: + identifier = ( + sfgs + .OpticalPathIdentificationSequence[0] + .OpticalPathIdentifier + ) + + if 'PerFrameFunctionalGroupsSequence' in image: + pffg = image.PerFrameFunctionalGroupsSequence[frame_index] + if 'OpticalPathIdentificationSequence' in pffg: + identifier = ( + pffg + .OpticalPathIdentificationSequence[0] + .OpticalPathIdentifier + ) + self.applies_to_all_frames = False + + if identifier is None: + raise ValueError('Could not determine optical path identifier.') + + for optical_path_item in image.OpticalPathSequence: + if optical_path_item.OpticalPathIdentifier == identifier: + break + else: + raise ValueError('No information on optical path found.') + + if 'ICCProfile' in optical_path_item: + self._color_manager = ColorManager( + optical_path_item.ICCProfile + ) + + if require_icc and self._color_manager is None: + raise RuntimeError( + 'An ICC profile is required but not found in ' + 'the image.' + ) + + if self._effective_lut_data is not None: + if self._color_manager is None: + # If using palette color LUT, need to keep pixels as integers + # to pass into color manager, otherwise eagerly converted the + # LUT data to the requested output type + if self._effective_lut_data.dtype != output_dtype: + self._effective_lut_data = ( + self._effective_lut_data.astype( + output_dtype, + casting='safe', + ) + ) + + if self.input_dtype.kind == 'f': + raise ValueError( + 'Images with floating point data may not contain LUTs.' + ) + + # Slope/intercept of 1/0 is just a no-op + if self._effective_slope_intercept is not None: + if self._effective_slope_intercept == (1.0, 0.0): + self._effective_slope_intercept = None + + if self._effective_slope_intercept is not None: + slope, intercept = self._effective_slope_intercept + _check_rescale_dtype( + slope=slope, + intercept=intercept, + output_dtype=self.output_dtype, + input_dtype=self.input_dtype, + input_range=input_range, + ) + self._effective_slope_intercept = ( + np.float64(slope).astype(self.output_dtype), + np.float64(intercept).astype(self.output_dtype), + ) + + if self._effective_window_center_width is not None: + if self.output_dtype.kind != 'f': + raise ValueError( + 'The VOI transformation requires a floating point data ' + 'type.' + ) + + self.color_output = ( + self._color_type == _ImageColorType.COLOR or + ( + self._color_type == _ImageColorType.PALETTE_COLOR and + self._effective_lut_data is not None + ) + ) + + self.transfer_syntax_uid = image.file_meta.TransferSyntaxUID + self.rows = image.Rows + self.columns = image.Columns + self.samples_per_pixel = image.SamplesPerPixel + self.bits_allocated = image.BitsAllocated + self.bits_stored = image.get('BitsAllocated', image.BitsAllocated) + self.photometric_interpretation = image.PhotometricInterpretation + self.pixel_representation = image.PixelRepresentation + self.planar_configuration = image.get('PlanarConfiguration') + + def __call__( + self, + frame: np.ndarray | bytes, + frame_index: int = 0, + ) -> np.ndarray: + """Apply the composed transform. + + Parameters + ---------- + frame: numpy.ndarray | bytes + Input frame for the transformation. Either a raw bytes array or the + numpy array of the stored values. + frame_index: int, optional + Frame index. This is only required if frame is a raw bytes array, + the number of bits allocated is 1 and the number of pixels per + frame is not a multiple of 8. In this case, the frame index is + required to extract the frame from the bytes array. + + Returns + ------- + numpy.ndarray: + Output frame after the transformation is applied. + + """ + if isinstance(frame, bytes): + frame_out = decode_frame( + value=frame, + transfer_syntax_uid=self.transfer_syntax_uid, + rows=self.rows, + columns=self.columns, + samples_per_pixel=self.samples_per_pixel, + bits_allocated=self.bits_allocated, + bits_stored=self.bits_stored, + photometric_interpretation=self.photometric_interpretation, + pixel_representation=self.pixel_representation, + planar_configuration=self.planar_configuration, + index=frame_index, + ) + elif isinstance(frame, np.ndarray): + frame_out = frame + else: + raise TypeError( + "Argument 'frame' must be either bytes or a numpy ndarray." + ) + + if self._color_type == _ImageColorType.COLOR: + if frame_out.ndim != 3 or frame_out.shape[2] != 3: + raise ValueError( + "Expected an image of shape (R, C, 3)." + ) + + else: + if frame_out.ndim != 2: + raise ValueError( + "Expected an image of shape (R, C)." + ) + + if self._input_range_check is not None: + first, last = self._input_range_check + if frame_out.min() < first or frame_out.max() > last: + raise ValueError( + 'Array contains value outside the valid range.' + ) + + if self._effective_lut_data is not None: + frame_out = apply_lut( + frame_out, + self._effective_lut_data, + self._effective_lut_first_mapped_value, + clip=self._clip, + ) + + elif self._effective_slope_intercept is not None: + slope, intercept = self._effective_slope_intercept + + # Avoid unnecessary array operations for efficiency + if slope != 1.0: + frame_out = frame_out * slope + if intercept != 0.0: + frame_out = frame_out + intercept + + elif self._effective_window_center_width is not None: + frame_out = apply_voi_window( + frame_out, + window_center=self._effective_window_center_width[0], + window_width=self._effective_window_center_width[1], + dtype=self.output_dtype, + invert=self._invert, + output_range=self._voi_output_range, + voi_lut_function=self._effective_voi_function or 'LINEAR', + ) + + if self._color_manager is not None: + frame_out = self._color_manager.transform_frame(frame_out) + + if frame_out.dtype != self.output_dtype: + frame_out = frame_out.astype(self.output_dtype) + + return frame_out + + +@dataclass +class _SQLTableDefinition: + + """Utility class holding the specification of a single SQL table.""" + + table_name: str + """Name of the temporary table.""" + + column_defs: Sequence[str] + """SQL syntax strings defining each column in the temporary table, one + string per column.""" + + column_data: Iterable[Sequence[Any]] + """Column data to place into the table.""" + + +class _Image(SOPClass): + + """Base class representing a general DICOM image. + + An "image" is any object representing an Image Information Entity. + + This class serves as a base class for specific image types, including + Segmentations and Parametric Maps, as well as the general Image base class. + + """ + + _coordinate_system: CoordinateSystemNames | None + _is_tiled_full: bool + _single_source_frame_per_frame: bool + _dim_ind_pointers: List[BaseTag] + # Mapping of tag value to (index column name, val column name(s)) + _dim_ind_col_names: Dict[int, Tuple[str, Union[str, Tuple[str, ...], None]]] + _locations_preserved: Optional[SpatialLocationsPreservedValues] + _db_con: sqlite3.Connection + _volume_geometry: Optional[VolumeGeometry] + _file_reader: ImageFileReader | None + + @classmethod + def from_dataset( + cls, + dataset: Dataset, + copy: bool = True, + ) -> Self: + """Create an Image from an existing pydicom Dataset. + + Parameters + ---------- + dataset: pydicom.Dataset + Dataset of a multi-frame image. + copy: bool + If True, the underlying dataset is deep-copied such that the + original dataset remains intact. If False, this operation will + alter the original dataset in place. + + """ + if not isinstance(dataset, Dataset): + raise TypeError( + 'Dataset must be of type pydicom.dataset.Dataset.' + ) + _check_little_endian(dataset) + + # Checks on integrity of input dataset + if copy: + im = deepcopy(dataset) + else: + im = dataset + im.__class__ = cls + im = cast(cls, im) + + im._build_luts() + return im + + @property + def number_of_frames(self) -> int: + """int: Number of frames in the image.""" + return self.get('NumberOfFrames', 1) + + def _get_color_tyoe(self) -> _ImageColorType: + """_ImageColorType: Color type of the image.""" + return _deduce_color_type(self) + + @property + def is_tiled(self): + return is_tiled_image(self) + + def get_raw_frame(self, frame_number: int) -> bytes: + """Get the raw data for an encoded frame. + + Parameters + ---------- + frame_number: int + One-based frame number. + + Returns + ------- + bytes: + Raw encoded data relating to the requested frame. + + Note + ---- + In some situations, where the number of bits allocated is 1, the + transfer syntax is not encapsulated (i.e. is native), and the number of + pixels per frame is not a multiple of 8, frame boundaries are not + aligned with byte boundaries in the raw bytes. In this situation, the + returned bytes will contain the minimum range of bytes required to + entirely contain the requested frame, however some bits may need + stripping from the start and/or end to get the bits related to the + requested frame. + + """ + if frame_number < 1 or frame_number > self.number_of_frames: + raise IndexError( + f"Invalid frame number '{frame_number}' for image with " + f"{self.number_of_frames} frame. Note that frame numbers " + "use a 1-based index." + ) + + index = frame_number - 1 + + if self._file_reader is not None: + with self._file_reader: + return self._file_reader.read_frame_raw(index) + + if UID(self.file_meta.TransferSyntaxUID).is_encapsulated: + return get_frame( + self.PixelData, + index=index, + number_of_frames=self.number_of_frames, + ) + else: + if self.PhotometricInterpretation == 'YBR_FULL_422': + # Account for subsampling of CB and CR when calculating + # expected number of samples + # See https://dicom.nema.org/medical/dicom/current/output/chtml + # /part03/sect_C.7.6.3.html#sect_C.7.6.3.1.2 + n_pixels = self.metadata.Rows * self.metadata.Columns * 2 + else: + n_pixels = self.Rows * self.Columns * self.SamplesPerPixel + + frame_length_bits = self.BitsAllocated * n_pixels + if self.BitsAllocated == 1 and (n_pixels % 8 != 0): + start = (index * frame_length_bits) // 8 + end = ((index + 1) * frame_length_bits + 7) // 8 + else: + frame_length = frame_length_bits // 8 + start = index * frame_length + end = start + frame_length + + return self.PixelData[start:end] + + def get_stored_frame( + self, + frame_number: int, + )-> np.ndarray: + if frame_number < 1 or frame_number > self.number_of_frames: + raise IndexError( + f"Invalid frame number '{frame_number}' for image with " + f"{self.number_of_frames} frame. Note that frame numbers " + "use a 1-based index." + ) + + if self._pixel_array is None: + raw_frame = self.get_raw_frame(frame_number) + frame = decode_frame( + value=raw_frame, + transfer_syntax_uid=self.transfer_syntax_uid, + rows=self.Rows, + columns=self.Columns, + samples_per_pixel=self.SamplesPerPixel, + bits_allocated=self.BitsAllocated, + bits_stored=self.get('BitsAllocated', self.BitsAllocated), + photometric_interpretation=self.PhotometricInterpretation, + pixel_representation=self.PixelRepresentation, + planar_configuration=self.get('PlanarConfiguration'), + index=frame_number - 1, + ) + else: + if self.number_of_frames == 1: + frame = self.pixel_array + else: + frame = self.pixel_array[frame_number - 1] + + return frame + + def get_frame( + self, + frame_number: int, + *, + output_dtype: Union[type, str, np.dtype, None] = np.float64, + apply_real_world_transform: bool | None = None, + real_world_value_map_selector: int | str | Code | CodedConcept = 0, + apply_modality_transform: bool | None = None, + apply_voi_transform: bool | None = False, + voi_transform_selector: int | str | VOILUTTransformation = 0, + voi_output_range: Tuple[float, float] = (0.0, 1.0), + apply_presentation_lut: bool = True, + apply_palette_color_lut: bool | None = None, + apply_icc_profile: bool | None = None, + ) -> np.ndarray: + + frame_index = frame_number - 1 + + frame = self.get_stored_frame(frame_number) + + frame_transform = _CombinedPixelTransformation( + self, + frame_index=frame_index, + output_dtype=output_dtype, + apply_real_world_transform=apply_real_world_transform, + real_world_value_map_selector=real_world_value_map_selector, + apply_modality_transform=apply_modality_transform, + apply_voi_transform=apply_voi_transform, + voi_transform_selector=voi_transform_selector, + voi_output_range=voi_output_range, + apply_presentation_lut=apply_presentation_lut, + apply_palette_color_lut=apply_palette_color_lut, + apply_icc_profile=apply_icc_profile, + ) + + return frame_transform(frame) + + def __getstate__(self) -> Dict[str, Any]: + """Get the state for pickling. + + This is required to work around the fact that a sqlite3 + Connection object cannot be pickled. + + Returns + ------- + Dict[str, Any]: + State of the object. + + """ + state = super().__dict__.copy() + + db_data = self._serialize_db() + + del state['_db_con'] + state['db_data'] = db_data + + return state + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Set the state of the object. + + This is required to work around the fact that a sqlite3 + Connection object cannot be pickled. + + Parameters + ---------- + state: Dict[str, Any] + State of the object. + + """ + self._db_con = sqlite3.connect(':memory:') + with self._db_con: + self._db_con.executescript(state['db_data'].decode('utf-8')) + + del state['db_data'] + + self.__dict__.update(state) + + def _serialize_db(self) -> bytes: + """Get a serialized copy of the internal database. + + Returns + ------- + bytes: + Serialized copy of the internal database. + + """ + return b''.join( + [ + line.encode('utf-8') + for line in self._db_con.iterdump() + ] + ) + + def _build_luts(self) -> None: + """Build lookup tables for efficient querying. + + Two lookup tables are currently constructed. The first maps the + SOPInstanceUIDs of all datasets referenced in the image to a + tuple containing the StudyInstanceUID, SeriesInstanceUID and + SOPInstanceUID. + + The second look-up table contains information about each frame of the + segmentation, including the segment it contains, the instance and frame + from which it was derived (if these are unique), and its dimension + index values. + + """ + self._file_reader = None + self._coordinate_system = get_image_coordinate_system( + self + ) + + if is_multiframe_image(self): + self._build_luts_multiframe() + else: + self._build_luts_single_frame() + + def _build_luts_single_frame(self) -> None: + """Populates LUT information for a single frame image.""" + self._is_tiled_full = False + self._dim_ind_pointers = [] + self._dim_ind_col_names = {} + self._single_source_frame_per_frame = True + self._locations_preserved = None + + if 'SourceImageSequence' in self: + self._single_source_frame_per_frame = ( + len(self.SourceImageSequence) == 1 + ) + locations_preserved = [ + item.get('SpatialLocationsPreserved') + for item in self.SourceImageSequence + ] + if all( + v is not None and v == "YES" for v in locations_preserved + ): + self._locations_preserved = ( + SpatialLocationsPreservedValues.YES + ) + elif all( + v is not None and v == "NO" for v in locations_preserved + ): + self._locations_preserved = ( + SpatialLocationsPreservedValues.NO + ) + + col_defs = [] + col_defs.append('FrameNumber INTEGER PRIMARY KEY') + col_data = [[1]] + + if self._coordinate_system is not None: + + if self._coordinate_system == CoordinateSystemNames.SLIDE: + position = self.ImagePositionSlide + orientation = self.ImageOrientationSlide + else: + position = self.ImagePositionPatient + orientation = self.ImageOrientationPatient + + self._volume_geometry = VolumeGeometry.from_attributes( + image_position=position, + image_orientation=orientation, + rows=self.Rows, + columns=self.Columns, + pixel_spacing=self.PixelSpacing, + number_of_frames=1, + spacing_between_slices=self.get('SpacingBetweenSlices', 1.0), + ) + col_defs.append('VolumePosition INTEGER NOT NULL') + col_data.append([0]) + else: + self._volume_geometry = None + + referenced_uids = self._get_ref_instance_uids() + self._db_con = sqlite3.connect(":memory:") + self._create_ref_instance_table(referenced_uids) + + self._create_frame_lut(col_defs, col_data) + + def _build_luts_multiframe(self) -> None: + """Build lookup tables for efficient querying. + + Two lookup tables are currently constructed. The first maps the + SOPInstanceUIDs of all datasets referenced in the image to a + tuple containing the StudyInstanceUID, SeriesInstanceUID and + SOPInstanceUID. + + The second look-up table contains information about each frame of the + segmentation, including the segment it contains, the instance and frame + from which it was derived (if these are unique), and its dimension + index values. + + """ + referenced_uids = self._get_ref_instance_uids() + all_referenced_sops = {uids[2] for uids in referenced_uids} + + self._is_tiled_full = ( + hasattr(self, 'DimensionOrganizationType') and + self.DimensionOrganizationType == 'TILED_FULL' + ) + + self._dim_ind_pointers = [] + func_grp_pointers = {} + dim_ind_positions = {} + if 'DimensionIndexSequence' in self: + self._dim_ind_pointers = [ + dim_ind.DimensionIndexPointer + for dim_ind in self.DimensionIndexSequence + ] + for dim_ind in self.DimensionIndexSequence: + ptr = dim_ind.DimensionIndexPointer + if ptr in self._dim_ind_pointers: + grp_ptr = getattr(dim_ind, "FunctionalGroupPointer", None) + func_grp_pointers[ptr] = grp_ptr + dim_ind_positions = { + dim_ind.DimensionIndexPointer: i + for i, dim_ind in enumerate(self.DimensionIndexSequence) + } + + # We may want to gather additional information that is not one of the + # indices + extra_collection_pointers = [] + extra_collection_func_pointers = {} + slice_spacing_hint = None + image_position_tag = tag_for_keyword('ImagePositionPatient') + shared_pixel_spacing: Optional[List[float]] = None + if self._coordinate_system == CoordinateSystemNames.PATIENT: + plane_pos_seq_tag = tag_for_keyword('PlanePositionSequence') + # Include the image position if it is not an index + if image_position_tag not in self._dim_ind_pointers: + extra_collection_pointers.append(image_position_tag) + extra_collection_func_pointers[ + image_position_tag + ] = plane_pos_seq_tag + + if hasattr(self, 'SharedFunctionalGroupsSequence'): + sfgs = self.SharedFunctionalGroupsSequence[0] + if hasattr(sfgs, 'PixelMeasuresSequence'): + measures = sfgs.PixelMeasuresSequence[0] + slice_spacing_hint = measures.get('SpacingBetweenSlices') + shared_pixel_spacing = measures.get('PixelSpacing') + if slice_spacing_hint is None or shared_pixel_spacing is None: + # Get the orientation of the first frame, and in the later loop + # check whether it is shared. + if hasattr(self, 'PerFrameFunctionalGroupsSequence'): + pfg1 = self.PerFrameFunctionalGroupsSequence[0] + if hasattr(pfg1, 'PixelMeasuresSequence'): + measures = pfg1.PixelMeasuresSequence[0] + slice_spacing_hint = measures.get( + 'SpacingBetweenSlices' + ) + shared_pixel_spacing = measures.get('PixelSpacing') + + dim_indices: Dict[int, List[int]] = { + ptr: [] for ptr in self._dim_ind_pointers + } + dim_values: Dict[int, List[Any]] = { + ptr: [] for ptr in self._dim_ind_pointers + } + + extra_collection_values: Dict[int, List[Any]] = { + ptr: [] for ptr in extra_collection_pointers + } + + # Get the shared orientation + shared_image_orientation: Optional[List[float]] = None + if hasattr(self, 'ImageOrientationSlide'): + shared_image_orientation = self.ImageOrientationSlide + if hasattr(self, 'SharedFunctionalGroupsSequence'): + sfgs = self.SharedFunctionalGroupsSequence[0] + if hasattr(sfgs, 'PlaneOrientationSequence'): + shared_image_orientation = ( + sfgs.PlaneOrientationSequence[0].ImageOrientationPatient + ) + if shared_image_orientation is None: + # Get the orientation of the first frame, and in the later loop + # check whether it is shared. + if hasattr(self, 'PerFrameFunctionalGroupsSequence'): + pfg1 = self.PerFrameFunctionalGroupsSequence[0] + if hasattr(pfg1, 'PlaneOrientationSequence'): + shared_image_orientation = ( + pfg1 + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + + self._single_source_frame_per_frame = True + + if self._is_tiled_full: + # With TILED_FULL, there is no PerFrameFunctionalGroupsSequence, + # so we have to deduce the per-frame information + row_tag = tag_for_keyword('RowPositionInTotalImagePixelMatrix') + col_tag = tag_for_keyword('ColumnPositionInTotalImagePixelMatrix') + x_tag = tag_for_keyword('XOffsetInSlideCoordinateSystem') + y_tag = tag_for_keyword('YOffsetInSlideCoordinateSystem') + z_tag = tag_for_keyword('ZOffsetInSlideCoordinateSystem') + tiled_full_dim_indices = {row_tag, col_tag} + if len(tiled_full_dim_indices - set(dim_indices.keys())) > 0: + raise RuntimeError( + 'Expected images with ' + '"DimensionOrganizationType" of "TILED_FULL" ' + 'to have the following dimension index pointers: ' + 'RowPositionInTotalImagePixelMatrix, ' + 'ColumnPositionInTotalImagePixelMatrix.' + ) + self._single_source_frame_per_frame = False + ( + channel_numbers, + _, + dim_values[col_tag], + dim_values[row_tag], + dim_values[x_tag], + dim_values[y_tag], + dim_values[z_tag], + ) = zip(*iter_tiled_full_frame_data(self)) + + if ( + hasattr(self, 'SegmentSequence') and + self.SegmentationType != 'LABELMAP' + ): + segment_tag = tag_for_keyword('ReferencedSegmentNumber') + dim_values[segment_tag] = channel_numbers + elif hasattr(self, 'OpticalPathSequence'): + op_tag = tag_for_keyword('OpticalPathIdentifier') + dim_values[op_tag] = channel_numbers + + # Create indices for each of the dimensions + for ptr, vals in dim_values.items(): + _, indices = np.unique(vals, return_inverse=True) + dim_indices[ptr] = (indices + 1).tolist() + + # There is no way to deduce whether the spatial locations are + # preserved in the tiled full case + self._locations_preserved = None + + referenced_instances = None + referenced_frames = None + else: + referenced_instances: Optional[List[str]] = [] + referenced_frames: Optional[List[int]] = [] + + # Create a list of source images and check for spatial locations + # preserved + locations_preserved: list[ + SpatialLocationsPreservedValues | None + ] = [] + + for frame_item in self.PerFrameFunctionalGroupsSequence: + # Get dimension indices for this frame + if len(self._dim_ind_pointers) > 0: + content_seq = frame_item.FrameContentSequence[0] + indices = content_seq.DimensionIndexValues + if not isinstance(indices, (MultiValue, list)): + # In case there is a single dimension index + indices = [indices] + else: + indices = [] + if len(indices) != len(self._dim_ind_pointers): + raise RuntimeError( + 'Unexpected mismatch between dimension index values in ' + 'per-frames functional groups sequence and items in ' + 'the dimension index sequence.' + ) + for ptr in self._dim_ind_pointers: + dim_indices[ptr].append(indices[dim_ind_positions[ptr]]) + grp_ptr = func_grp_pointers[ptr] + if grp_ptr is not None: + dim_val = frame_item[grp_ptr][0][ptr].value + else: + dim_val = frame_item[ptr].value + dim_values[ptr].append(dim_val) + for ptr in extra_collection_pointers: + grp_ptr = extra_collection_func_pointers[ptr] + if grp_ptr is not None: + dim_val = frame_item[grp_ptr][0][ptr].value + else: + dim_val = frame_item[ptr].value + extra_collection_values[ptr].append(dim_val) + + frame_source_instances = [] + frame_source_frames = [] + for der_im in getattr( + frame_item, + 'DerivationImageSequence', + [] + ): + for src_im in getattr( + der_im, + 'SourceImageSequence', + [] + ): + frame_source_instances.append( + src_im.ReferencedSOPInstanceUID + ) + if hasattr(src_im, 'SpatialLocationsPreserved'): + locations_preserved.append( + SpatialLocationsPreservedValues( + src_im.SpatialLocationsPreserved + ) + ) + else: + locations_preserved.append( + None + ) + + if hasattr(src_im, 'ReferencedFrameNumber'): + if isinstance( + src_im.ReferencedFrameNumber, + MultiValue + ): + frame_source_frames.extend( + [ + int(f) + for f in src_im.ReferencedFrameNumber + ] + ) + else: + frame_source_frames.append( + int(src_im.ReferencedFrameNumber) + ) + else: + frame_source_frames.append(None) + + if ( + len(set(frame_source_instances)) != 1 or + len(set(frame_source_frames)) != 1 + ): + self._single_source_frame_per_frame = False + else: + ref_instance_uid = frame_source_instances[0] + if ref_instance_uid not in all_referenced_sops: + raise AttributeError( + f'SOP instance {ref_instance_uid} referenced in ' + 'the source image sequence is not included in the ' + 'Referenced Series Sequence or Studies Containing ' + 'Other Referenced Instances Sequence. This is an ' + 'error with the integrity of the ' + 'object.' + ) + referenced_instances.append(ref_instance_uid) + referenced_frames.append(frame_source_frames[0]) + + # Check that this doesn't have a conflicting orientation + if shared_image_orientation is not None: + if hasattr(frame_item, 'PlaneOrientationSequence'): + iop = ( + frame_item + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + if iop != shared_image_orientation: + shared_image_orientation = None + + if hasattr(frame_item, 'PixelMeasuresSequence'): + measures = frame_item.PixelMeasuresSequence[0] + + fm_slice_spacing = measures.get( + 'SpacingBetweenSlices' + ) + if ( + slice_spacing_hint is not None and + fm_slice_spacing != slice_spacing_hint + ): + slice_spacing_hint = None + + fm_pixel_spacing = measures.get('PixelSpacing') + if ( + shared_pixel_spacing is not None and + fm_pixel_spacing != shared_pixel_spacing + ): + shared_pixel_spacing = None + + # Summarise + if any( + isinstance(v, SpatialLocationsPreservedValues) and + v == SpatialLocationsPreservedValues.NO + for v in locations_preserved + ): + + self._locations_preserved = SpatialLocationsPreservedValues.NO + elif all( + isinstance(v, SpatialLocationsPreservedValues) and + v == SpatialLocationsPreservedValues.YES + for v in locations_preserved + ): + self._locations_preserved = SpatialLocationsPreservedValues.YES + else: + self._locations_preserved = None + + if not self._single_source_frame_per_frame: + referenced_instances = None + referenced_frames = None + + self._db_con = sqlite3.connect(":memory:") + + self._create_ref_instance_table(referenced_uids) + + # Construct the columns and values to put into a frame look-up table + # table within sqlite. There will be one row per frame in the + # image + col_defs = [] # SQL column definitions + col_data = [] # lists of column data + + # Frame number column + col_defs.append('FrameNumber INTEGER PRIMARY KEY') + col_data.append(list(range(1, self.NumberOfFrames + 1))) + + self._dim_ind_col_names = {} + for i, t in enumerate(dim_indices.keys()): + vr, vm_str, _, _, kw = get_entry(t) + if kw == '': + kw = f'UnknownDimensionIndex{i}' + ind_col_name = kw + '_DimensionIndexValues' + + # Add column for dimension index + col_defs.append(f'{ind_col_name} INTEGER NOT NULL') + col_data.append(dim_indices[t]) + + # Add column for dimension value + # For this to be possible, must have a fixed VM + # and a VR that we can map to a sqlite type + # Otherwise, we just omit the data from the db + if kw == 'ReferencedSegmentNumber': + # Special case since this tag technically has VM 1-n + vm = 1 + else: + try: + vm = int(vm_str) + except ValueError: + self._dim_ind_col_names[t] = (ind_col_name, None) + continue + try: + sql_type = _DCM_SQL_TYPE_MAP[vr] + except KeyError: + self._dim_ind_col_names[t] = (ind_col_name, None) + continue + + if vm > 1: + val_col_names = [] + for d in range(vm): + data = [el[d] for el in dim_values[t]] + col_name = f'{kw}_{d}' + col_defs.append(f'{col_name} {sql_type} NOT NULL') + col_data.append(data) + val_col_names.append(col_name) + + self._dim_ind_col_names[t] = (ind_col_name, tuple(val_col_names)) + else: + # Single column + col_defs.append(f'{kw} {sql_type} NOT NULL') + col_data.append(dim_values[t]) + self._dim_ind_col_names[t] = (ind_col_name, kw) + + for i, t in enumerate(extra_collection_pointers): + vr, vm_str, _, _, kw = get_entry(t) + + # Add column for dimension value + # For this to be possible, must have a fixed VM + # and a VR that we can map to a sqlite type + # Otherwise, we just omit the data from the db + vm = int(vm_str) + sql_type = _DCM_SQL_TYPE_MAP[vr] + + if vm > 1: + for d in range(vm): + data = [el[d] for el in extra_collection_values[t]] + col_name = f'{kw}_{d}' + col_defs.append(f'{col_name} {sql_type} NOT NULL') + col_data.append(data) + else: + # Single column + col_defs.append(f'{kw} {sql_type} NOT NULL') + col_data.append(dim_values[t]) + + # Volume related information + self._volume_geometry = None + if ( + self._coordinate_system == CoordinateSystemNames.PATIENT + and shared_image_orientation is not None + ): + if shared_image_orientation is not None: + if image_position_tag in self._dim_ind_pointers: + image_positions = dim_values[image_position_tag] + else: + image_positions = extra_collection_values[ + image_position_tag + ] + volume_spacing, volume_positions = get_volume_positions( + image_positions=image_positions, + image_orientation=shared_image_orientation, + allow_missing=True, + allow_duplicates=True, + spacing_hint=slice_spacing_hint, + ) + if volume_positions is not None: + origin_slice_index = volume_positions.index(0) + number_of_slices = max(volume_positions) + 1 + self._volume_geometry = VolumeGeometry.from_attributes( + image_position=image_positions[origin_slice_index], + image_orientation=shared_image_orientation, + rows=self.Rows, + columns=self.Columns, + pixel_spacing=shared_pixel_spacing, + number_of_frames=number_of_slices, + spacing_between_slices=volume_spacing, + ) + col_defs.append('VolumePosition INTEGER NOT NULL') + col_data.append(volume_positions) + + elif self.is_tiled: + if 'SharedFunctionalGroupsSequence' in self: + sfgs = self.SharedFunctionalGroupsSequence[0] + pixel_measures = sfgs.PixelMeasuresSequence[0] + slice_spacing = pixel_measures.get('SpacingBetweenSlices', 1.0) + pixel_spacing = pixel_measures.PixelSpacing + origin_seq = self.TotalPixelMatrixOriginSequence[0] + origin_position = [ + origin_seq.XOffsetInSlideCoordinateSystem, + origin_seq.YOffsetInSlideCoordinateSystem, + origin_seq.get('ZOffsetInSlideCoordinateSystem', 0.0), + ] + self._volume_geometry = VolumeGeometry.from_attributes( + image_position=origin_position, + image_orientation=shared_image_orientation, + rows=self.TotalPixelMatrixRows, + columns=self.TotalPixelMatrixColumns, + pixel_spacing=pixel_spacing, + number_of_frames=1, + spacing_between_slices=slice_spacing, + ) + + # Columns related to source frames, if they are usable for indexing + if (referenced_frames is None) != (referenced_instances is None): + raise TypeError( + "'referenced_frames' and 'referenced_instances' should be " + "provided together or not at all." + ) + if referenced_instances is not None: + col_defs.append('ReferencedFrameNumber INTEGER') + col_defs.append('ReferencedSOPInstanceUID VARCHAR NOT NULL') + col_defs.append( + 'FOREIGN KEY(ReferencedSOPInstanceUID) ' + 'REFERENCES InstanceUIDs(SOPInstanceUID)' + ) + col_data += [ + referenced_frames, + referenced_instances, + ] + + self._create_frame_lut(col_defs, col_data) + + def _get_frame_lut_col_type(self, column_name: str) -> str: + """Get the SQL type of a column in the FrameLUT table. + + Parameters + ---------- + column_name: str + Name of a colume in the FrameLUT whose type is requested. + + Returns + ------- + str: + String representation of the SQL type used for this column. + + """ + query = ( + "SELECT type FROM pragma_table_info('FrameLUT') " + f"WHERE name = '{column_name}'" + ) + result = list(self._db_con.execute(query)) + if len(result) == 0: + raise ValueError(f'No such colume found in frame LUT: {column_name}') + return result[0][0] + + def _create_frame_lut( + self, + column_defs: list[str], + column_data: list[list[Any]] + ) -> None: + """Create a SQL table containing frame information. + + Parameters + ---------- + column_defs: list[str] + String for each column containing SQL-syntax column definitions. + column_data: list[list[Any]] + Column data. Outer list contains columns, inner list contains + values within that column. + + """ + # Build LUT from columns + all_defs = ", ".join(column_defs) + cmd = f'CREATE TABLE FrameLUT({all_defs})' + placeholders = ', '.join(['?'] * len(column_data)) + with self._db_con: + self._db_con.execute(cmd) + self._db_con.executemany( + f'INSERT INTO FrameLUT VALUES({placeholders})', + zip(*column_data), + ) + + def _get_ref_instance_uids(self) -> List[Tuple[str, str, str]]: + """List all instances referenced in the image. + + Returns + ------- + List[Tuple[str, str, str]] + List of all instances referenced in the image in the format + (StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID). + + """ + instance_data = [] + if hasattr(self, 'ReferencedSeriesSequence'): + for ref_series in self.ReferencedSeriesSequence: + for ref_ins in ref_series.ReferencedInstanceSequence: + instance_data.append( + ( + self.StudyInstanceUID, + ref_series.SeriesInstanceUID, + ref_ins.ReferencedSOPInstanceUID + ) + ) + other_studies_kw = 'StudiesContainingOtherReferencedInstancesSequence' + if hasattr(self, other_studies_kw): + for ref_study in getattr(self, other_studies_kw): + for ref_series in ref_study.ReferencedSeriesSequence: + for ref_ins in ref_series.ReferencedInstanceSequence: + instance_data.append( + ( + ref_study.StudyInstanceUID, + ref_series.SeriesInstanceUID, + ref_ins.ReferencedSOPInstanceUID, + ) + ) + + # There shouldn't be duplicates here, but there's no explicit rule + # preventing it. + # Since dictionary ordering is preserved, this trick deduplicates + # the list without changing the order + unique_instance_data = list(dict.fromkeys(instance_data)) + if len(unique_instance_data) != len(instance_data): + counts = Counter(instance_data) + duplicate_sop_uids = [ + f"'{key[2]}'" for key, value in counts.items() if value > 1 + ] + display_str = ', '.join(duplicate_sop_uids) + logger.warning( + 'Duplicate entries found in the ReferencedSeriesSequence. ' + f"SOP Instance UID: '{self.SOPInstanceUID}', " + f'duplicated referenced SOP Instance UID items: {display_str}.' + ) + + return unique_instance_data + + def _check_indexing_with_source_frames( + self, + ignore_spatial_locations: bool = False + ) -> None: + """Check if indexing by source frames is possible. + + Raise exceptions with useful messages otherwise. + + Possible problems include: + * Spatial locations are not preserved. + * The dataset does not specify that spatial locations are preserved + and the user has not asserted that they are. + * At least one frame in the image lists multiple + source frames. + + Parameters + ---------- + ignore_spatial_locations: bool + Allows the user to ignore whether spatial locations are preserved + in the frames. + + """ + # Checks that it is possible to index using source frames in this + # dataset + if self._is_tiled_full: + raise RuntimeError( + 'Indexing via source frames is not possible when an ' + 'image is stored using the DimensionOrganizationType ' + '"TILED_FULL".' + ) + elif self._locations_preserved is None: + if not ignore_spatial_locations: + raise RuntimeError( + 'Indexing via source frames is not permissible since this ' + 'image does not specify that spatial locations are ' + 'preserved in the course of deriving the image ' + 'from the source image. If you are confident that spatial ' + 'locations are preserved, or do not require that spatial ' + 'locations are preserved, you may override this behavior ' + "with the 'ignore_spatial_locations' parameter." + ) + elif self._locations_preserved == SpatialLocationsPreservedValues.NO: + if not ignore_spatial_locations: + raise RuntimeError( + 'Indexing via source frames is not permissible since this ' + 'image specifies that spatial locations are not preserved ' + 'in the course of deriving the image from the ' + 'source image. If you do not require that spatial ' + ' locations are preserved you may override this behavior ' + "with the 'ignore_spatial_locations' parameter." + ) + if not self._single_source_frame_per_frame: + raise RuntimeError( + 'Indexing via source frames is not permissible since some ' + 'frames in the image specify multiple source frames.' + ) + + @property + def dimension_index_pointers(self) -> List[BaseTag]: + """List[pydicom.tag.BaseTag]: + List of tags used as dimension indices. + """ + return [BaseTag(t) for t in self._dim_ind_pointers] + + def _create_ref_instance_table( + self, + referenced_uids: List[Tuple[str, str, str]], + ) -> None: + """Create a table of referenced instances. + + The resulting table (called InstanceUIDs) contains Study, Series and + SOP instance UIDs for each instance referenced by the image. + + Parameters + ---------- + referenced_uids: List[Tuple[str, str, str]] + List of UIDs for each instance referenced in the image. + Each tuple should be in the format + (StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID). + + """ + with self._db_con: + self._db_con.execute( + "CREATE TABLE InstanceUIDs(" + "StudyInstanceUID VARCHAR NOT NULL, " + "SeriesInstanceUID VARCHAR NOT NULL, " + "SOPInstanceUID VARCHAR PRIMARY KEY" + ")" + ) + self._db_con.executemany( + "INSERT INTO InstanceUIDs " + "(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) " + "VALUES(?, ?, ?)", + referenced_uids, + ) + + def _are_columns_unique( + self, + column_names: Sequence[str], + ) -> bool: + """Check if a list of columns uniquely identifies frames. + + For a given list of columns, check whether every combination of values + for these column identifies a unique image frame. This is a + pre-requisite for indexing frames using this list of columns. + + Parameters + ---------- + column_names: Sequence[str] + Column names. + + Returns + ------- + bool + True if combination of columns is sufficient to identify unique + frames. + + """ + col_str = ", ".join(column_names) + cur = self._db_con.cursor() + n_unique_combos = cur.execute( + f"SELECT COUNT(*) FROM (SELECT 1 FROM FrameLUT GROUP BY {col_str})" + ).fetchone()[0] + return n_unique_combos == self.number_of_frames + + def are_dimension_indices_unique( + self, + dimension_index_pointers: Sequence[Union[int, BaseTag, str]], + ) -> bool: + """Check if a list of index pointers uniquely identifies frames. + + For a given list of dimension index pointers, check whether every + combination of index values for these pointers identifies a unique + image frame. This is a pre-requisite for indexing using this list of + dimension index pointers. + + Parameters + ---------- + dimension_index_pointers: Sequence[Union[int, pydicom.tag.BaseTag, str]] + Sequence of tags serving as dimension index pointers. If strings, + the items are interpretted as keywords. + + Returns + ------- + bool + True if dimension indices are unique. + + """ + column_names = [] + for ptr in dimension_index_pointers: + if isinstance(ptr, str): + t = tag_for_keyword(ptr) + if t is None: + raise ValueError( + f"Keyword '{ptr}' is not a valid DICOM keyword." + ) + ptr = t + column_names.append(self._dim_ind_col_names[ptr][0]) + return self._are_columns_unique(column_names) + + def get_source_image_uids(self) -> List[Tuple[UID, UID, UID]]: + """Get UIDs of source image instances referenced in the image. + + Returns + ------- + List[Tuple[highdicom.UID, highdicom.UID, highdicom.UID]] + (Study Instance UID, Series Instance UID, SOP Instance UID) triplet + for every image instance referenced in the image. + + """ + cur = self._db_con.cursor() + res = cur.execute( + 'SELECT StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID ' + 'FROM InstanceUIDs' + ) + + return [ + (UID(a), UID(b), UID(c)) for a, b, c in res.fetchall() + ] + + def _get_unique_referenced_sop_instance_uids(self) -> Set[str]: + """Get set of unique Referenced SOP Instance UIDs. + + Returns + ------- + Set[str] + Set of unique Referenced SOP Instance UIDs. + + """ + cur = self._db_con.cursor() + return { + r[0] for r in + cur.execute( + 'SELECT DISTINCT(SOPInstanceUID) from InstanceUIDs' + ) + } + + def _get_max_referenced_frame_number(self) -> int: + """Get highest frame number of any referenced frame. + + Absent access to the referenced dataset itself, being less than this + value is a sufficient condition for the existence of a frame number + in the source image. + + Returns + ------- + int + Highest frame number referenced in the image. + + """ + cur = self._db_con.cursor() + return cur.execute( + 'SELECT MAX(ReferencedFrameNumber) FROM FrameLUT' + ).fetchone()[0] + + def is_indexable_as_total_pixel_matrix(self) -> bool: + """Whether the image can be indexed as a total pixel matrix. + + Returns + ------- + bool: + True if the image may be indexed using row and column + positions in the total pixel matrix. False otherwise. + + """ + row_pos_tag = tag_for_keyword('RowPositionInTotalImagePixelMatrix') + col_pos_tag = tag_for_keyword('ColumnPositionInTotalImagePixelMatrix') + return ( + row_pos_tag in self._dim_ind_col_names and + col_pos_tag in self._dim_ind_col_names + ) + + def _get_unique_dim_index_values( + self, + dimension_index_pointers: Sequence[int], + ) -> Set[Tuple[int, ...]]: + """Get set of unique dimension index value combinations. + + Parameters + ---------- + dimension_index_pointers: Sequence[int] + List of dimension index pointers for which to find unique + combinations of values. + + Returns + ------- + Set[Tuple[int, ...]] + Set of unique dimension index value combinations for the given + input dimension index pointers. + + """ + cols = [self._dim_ind_col_names[p][0] for p in dimension_index_pointers] + cols_str = ', '.join(cols) + cur = self._db_con.cursor() + return { + r for r in + cur.execute( + f'SELECT DISTINCT {cols_str} FROM FrameLUT' + ) + } + + @property + def volume_geometry(self) -> Optional[VolumeGeometry]: + """Union[highdicom.VolumeGeometry, None]: Geometry of the volume if the + image represents a regularly-spaced 3D volume. ``None`` + otherwise. + + """ + return self._volume_geometry + + @contextmanager + def _generate_temp_tables( + self, + table_defs: Sequence[_SQLTableDefinition], + ) -> Generator[None, None, None]: + """Context manager that handles multiple temporary table. + + The temporary tables are created with the specified information. Control + flow then returns to code within the "with" block. After the "with" + block has completed, the cleanup of the tables is automatically handled. + + Parameters + ---------- + table_defs: Sequence[_SQLTableDefinition] + Specifications of each table to create. + + Yields + ------ + None: + Yields control to the "with" block, with the temporary tables + created. + + """ + for tdef in table_defs: + # First check whether the table already exists and remove it if it + # does. This shouldn't happen usually as the context manager should + # ensure that the temporary tables are always cleared up. However + # it does seem to happen when interactive REPLs such as ipython + # handle errors within the context manager + query = ( + "SELECT COUNT(*) FROM sqlite_master " + f"WHERE type = 'table' AND name = '{tdef.table_name}'" + ) + result = next(self._db_con.execute(query))[0] + if result > 0: + with self._db_con: + self._db_con.execute(f"DROP TABLE {tdef.table_name}") + + defs_str = ', '.join(tdef.column_defs) + create_cmd = (f'CREATE TABLE {tdef.table_name}({defs_str})') + placeholders = ', '.join(['?'] * len(tdef.column_defs)) + + with self._db_con: + self._db_con.execute(create_cmd) + self._db_con.executemany( + f'INSERT INTO {tdef.table_name} VALUES({placeholders})', + tdef.column_data + ) + + # Return control flow to "with" block + yield + + for tdef in table_defs: + # Clean up the tables + cmd = (f'DROP TABLE {tdef.table_name}') + with self._db_con: + self._db_con.execute(cmd) + + def _get_pixels_by_frame( + self, + spatial_shape: Union[int, Tuple[int, int]], + indices_iterator: Iterator[ + Tuple[ + int, + Tuple[Union[slice, int], ...], + Tuple[Union[slice, int], ...], + Tuple[int, ...], + ] + ], + *, + dtype: Union[type, str, np.dtype, None] = np.float64, + channel_shape: tuple[int, ...] = (), + apply_real_world_transform: bool | None = None, + real_world_value_map_selector: int | str | Code | CodedConcept = 0, + apply_modality_transform: bool | None = None, + apply_voi_transform: bool | None = False, + voi_transform_selector: int | str | VOILUTTransformation = 0, + voi_output_range: Tuple[float, float] = (0.0, 1.0), + apply_presentation_lut: bool = True, + apply_palette_color_lut: bool | None = None, + remove_palette_color_values: Sequence[int] | None = None, + palette_color_background_index: int = 0, + apply_icc_profile: bool | None = None, + ) -> np.ndarray: + """Construct a pixel array given an array of frame numbers. + + The output array has 3 dimensions (frame, rows, columns), followed by 1 + for RGB color channels, if applicable, followed by one for each + additional channel specified. + + Parameters + ---------- + spatial_shape: Union[int, Tuple[int, int]] + Spatial shape of the output array. If an integer, this is the + number of frames in the output array and the number of rows and + columns are taken to match those of each frame. If a tuple of + integers, it contains the number of (rows, columns) in the output + array and there is no frame dimension (this is the tiled case). + Note in either case, the channel dimensions (if relevant) are + omitted. + indices_iterator: Iterator[Tuple[int, Tuple[Union[slice, int], ...], Tuple[Union[slice, int], ...], Tuple[int, ...]]] + An iterable object that yields tuples of (frame_index, + input_indexer, spatial_indexer, channel_indexer) that describes how + to construct the desired output pixel array from the multiframe + image's pixel array. 'frame_index' specifies the zero-based index + of the input frame and 'input_indexer' is a tuple that may be used + directly to index a region of that frame. 'spatial_indexer' is a + tuple that may be used directly to index the output array to place + a single frame's pixels into the output array (excluding the + channel dimensions). The 'channel_indexer' indexes a channel of the + output array into which the result should be placed. Note that in + both cases the indexers access the frame, row and column dimensions + of the relevant array, but not the channel dimension (if relevant). + channel_shape: tuple[int, ...], optional + Channel shape of the output array. The use of channels depends + on image type, for example it may be segments in a segmentation, + optical paths in a microscopy image, or B-values in an MRI. + dtype: Union[type, str, np.dtype, None] + Data type of the returned array. If None, an appropriate type will + be chosen automatically. If the returned values are rescaled + fractional values, this will be numpy.float32. Otherwise, the + smallest unsigned integer type that accommodates all of the output + values will be chosen. + apply_real_world_transform: bool | None, optional + Whether to apply to apply the real-world value map to the frame. + The real world value map converts stored pixel values to output + values with a real-world meaning, either using a LUT or a linear + slope and intercept. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if present but no error will be raised if + it is not present. + + Note that if the dataset contains both a modality LUT and a real + world value map, the real world value map will be applied + preferentially. This also implies that specifying both + ``apply_real_world_transform`` and ``apply_modality_transform`` to + True is not permitted. + real_world_value_map_selector: int | str | pydicom.sr.coding.Code | highdicom.sr.coding.CodedConcept, optional + Specification of the real world value map to use (multiple may be + present in the dataset). If an int, it is used to index the list of + available maps. A negative integer may be used to index from the + end of the list following standard Python indexing convention. If a + str, the string will be used to match the ``"LUTLabel"`` attribute + to select the map. If a ``pydicom.sr.coding.Code`` or + ``highdicom.sr.coding.CodedConcept``, this will be used to match + the units (contained in the ``"MeasurementUnitsCodeSequence"`` + attribute). + apply_modality_transform: bool | None, optional + Whether to apply to the modality transform (if present in the + dataset) the frame. The modality transformation maps stored pixel + values to output values, either using a LUT or rescale slope and + intercept. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present and no real world value + map takes precedence, but no error will be raised if it is not + present. + apply_voi_transform: bool | None, optional + Apply the value-of-interest (VOI) transformation (if present in the + dataset), which limits the range of pixel values to a particular + range of interest, using either a windowing operation or a LUT. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present and no real world value + map takes precedence, but no error will be raised if it is not + present. + voi_transform_selector: int | str | highdicom.content.VOILUTTransformation, optional + Specification of the VOI transform to select (multiple may be + present). May either be an int or a str. If an int, it is + interpretted as a (zero-based) index of the list of VOI transforms + to apply. A negative integer may be used to index from the end of + the list following standard Python indexing convention. If a str, + the string that will be used to match the + ``"WindowCenterWidthExplanation"`` or the ``"LUTExplanation"`` + attributes to choose from multiple VOI transforms. Note that such + explanations are optional according to the standard and therefore + may not be present. Ignored if ``apply_voi_transform`` is ``False`` + or no VOI transform is included in the datasets. + voi_output_range: Tuple[float, float], optional + Range of output values to which the VOI range is mapped. Only + relevant if ``apply_voi_transform`` is True and a VOI transform is + present. + apply_palette_color_lut: bool | None, optional + Apply the palette color LUT, if present in the dataset. The palette + color LUT maps a single sample for each pixel stored in the dataset + to a 3 sample-per-pixel color image. + apply_presentation_lut: bool, optional + Apply the presentation LUT transform to invert the pixel values. If + the PresentationLUTShape is present with the value ``'INVERSE''``, + or the PresentationLUTShape is not present but the Photometric + Interpretation is MONOCHROME1, convert the range of the output + pixels corresponds to MONOCHROME2 (in which high values are + represent white and low values represent black). Ignored if + PhotometricInterpretation is not MONOCHROME1 and the + PresentationLUTShape is not present, or if a real world value + transform is applied. + remove_palette_color_values: Sequence[int] | None, optional + Remove values from the palette color LUT (if any) by altering the + LUT so that these values map to the RGB value at position + ``palette_color_background_index`` instead of their original value. + This is intended to remove segments from a palette color labelmap + segmentation. + palette_color_background_index: int, optional + The index (i.e. input) of the palette color LUT that corresponds to + background. Relevant only if ``remove_palette_color_values`` is + provided. + apply_icc_profile: bool | None, optional + Whether colors should be corrected by applying an ICC + transformation. Will only be performed if metadata contain an + ICC Profile. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present, but no error will be + raised if it is not present. + + Returns + ------- + pixel_array: numpy.ndarray + Pixel array + + """ # noqa: E501 + shared_frame_transform = _CombinedPixelTransformation( + self, + apply_real_world_transform=apply_real_world_transform, + real_world_value_map_selector=real_world_value_map_selector, + apply_modality_transform=apply_modality_transform, + apply_voi_transform=apply_voi_transform, + voi_transform_selector=voi_transform_selector, + voi_output_range=voi_output_range, + apply_presentation_lut=apply_presentation_lut, + apply_palette_color_lut=apply_palette_color_lut, + remove_palette_color_values=remove_palette_color_values, + palette_color_background_index=palette_color_background_index, + apply_icc_profile=apply_icc_profile, + output_dtype=dtype, + ) + + # Initialize empty pixel array + if isinstance(spatial_shape, tuple): + initial_shape = spatial_shape + else: + initial_shape = (spatial_shape, self.Rows, self.Columns) + color_frames = shared_frame_transform.color_output + samples_shape = (3, ) if color_frames else () + + full_output_shape = ( + *initial_shape, + *samples_shape, + *channel_shape, + ) + + out_array = np.zeros( + full_output_shape, + dtype=dtype + ) + + context_manager = ( + self._file_reader + if self._file_reader is not None + else nullcontext() + ) + + with context_manager: + + # loop through output frames + for ( + frame_index, + input_indexer, + spatial_indexer, + channel_indexer + ) in indices_iterator: + + if shared_frame_transform.applies_to_all_frames: + frame_transform = shared_frame_transform + else: + frame_transform = _CombinedPixelTransformation( + self, + frame_index=frame_index, + apply_real_world_transform=apply_real_world_transform, + real_world_value_map_selector=real_world_value_map_selector, + apply_modality_transform=apply_modality_transform, + apply_voi_transform=apply_voi_transform, + voi_transform_selector=voi_transform_selector, + voi_output_range=voi_output_range, + apply_presentation_lut=apply_presentation_lut, + apply_palette_color_lut=apply_palette_color_lut, + apply_icc_profile=apply_icc_profile, + output_dtype=dtype, + ) + + if color_frames: + # Include the sample dimension for color images + output_indexer = ( + *spatial_indexer, + slice(None), + *channel_indexer + ) + else: + output_indexer = (*spatial_indexer, *channel_indexer) + + if self._pixel_array is None: + if self._file_reader is not None: + frame_bytes = self._file_reader.read_frame_raw( + frame_index + ) + else: + frame_bytes = self.get_raw_frame(frame_index + 1) + frame = frame_transform(frame_bytes, frame_index) + else: + if self.pixel_array.ndim == 2: + if frame_index == 0: + frame = self.pixel_array + else: + raise IndexError( + f'Index {frame_index} is out of bounds for ' + 'an image with a single frame.' + ) + else: + frame = self.pixel_array[frame_index] + frame = frame_transform(frame, frame_index) + + out_array[output_indexer] = frame[input_indexer] + + return out_array + + def _normalize_dimension_queries( + self, + queries: Dict[Union[int, str], Any], + use_indices: bool, + multiple_values: bool, + ) -> Dict[str, Any]: + normalized_queries: Dict[str, Any] = {} + tag: BaseTag | None = None + + if len(queries) == 0: + raise ValueError("Query definitions must not be empty.") + + if multiple_values: + n_values = len(list(queries.values())[0]) + + for p, value in queries.items(): + if isinstance(p, int): # also covers BaseTag + tag = BaseTag(p) + + elif isinstance(p, str): + # Special cases + if p == 'VolumePosition': + col_name = 'VolumePosition' + python_type = int + elif p == 'ReferencedSOPInstanceUID': + col_name = 'ReferencedSOPInstanceUID' + python_type = str + elif p == 'ReferencedFrameNumber': + col_name = 'ReferencedFrameNumber' + python_type = int + else: + t = tag_for_keyword(p) + + if t is None: + raise ValueError( + f'No attribute found with name {p}.' + ) + + tag = BaseTag(t) + + else: + raise TypeError( + "Every item in 'stack_dimension_pointers' must be an " + 'int, str, or pydicom.tag.BaseTag.' + ) + + if tag is None: + if use_indices: + raise ValueError( + f'Cannot query by index value for column {p}.' + ) + else: + vr, _, _, _, kw = get_entry(tag) + if kw == '': + kw = '' + + try: + ind_col_name, val_col_name = self._dim_ind_col_names[tag] + except KeyError as e: + msg = ( + f'The tag {BaseTag(tag)} ({kw}) is not used as ' + 'a dimension index for this image.' + ) + raise KeyError(msg) from e + + if use_indices: + col_name = ind_col_name + python_type = int + else: + col_name = val_col_name + python_type = _DCM_PYTHON_TYPE_MAP[vr] + if col_name is None: + raise RuntimeError( + f'Cannot query attribute with tag {BaseTag(p)} ' + 'by value. Try querying by index value instead. ' + 'If you think this should be possible, please ' + 'report an issue to the highdicom maintainers.' + ) + elif isinstance(col_name, tuple): + raise ValueError( + f'Cannot query attribute with tag {BaseTag(p)} ' + 'by value because it is a multi-valued attribute. ' + 'Try querying by index value instead. ' + ) + + if multiple_values: + if len(value) != n_values: + raise ValueError( + 'Number of values along all dimensions must match.' + ) + for v in value: + if not isinstance(v, python_type): + raise TypeError( + f'For dimension {p}, expected all values to be of type ' + f'{python_type}.' + ) + else: + if not isinstance(value, python_type): + raise TypeError( + f'For dimension {p}, expected value to be of type ' + f'{python_type}.' + ) + + if col_name in normalized_queries: + raise ValueError( + 'All dimensions must be unique.' + ) + normalized_queries[col_name] = value + + return normalized_queries + + def _prepare_channel_tables( + self, + norm_channel_indices_list: list[dict[str, Any]], + remap_channel_indices: Optional[Sequence[int]] = None, + ) -> tuple[list[str], list[str], list[_SQLTableDefinition]]: + """Prepare query elements for a query involving output channels. + + This is common code shared between multiple query types that involve + channels. + + Parameters + ---------- + norm_channel_indices_list: list[dict[str, Any]] + List of dictionaries defining the channel dimensions. The first + item in the list corresponds to axis 3 of the output array, if any, + the next to axis 4 and so so. Each dictionary has a format + identical to that of ``stack_indices``, however the dimensions used + must be distinct. Note that each item in the list may contain + multiple items, provided that the number of items in each value + matches within a single dictionary. + remap_channel_indices: Sequence[int] | None, optional + Use these values to remap the channel indices returned in the + output iterator. The ith item applies to output channel i, and + within that list index ``j`` is mapped to + ``remap_channel_indices[i][j]``. Ignored if ``channel_indices`` is + ``None``. If ``None``, or ``remap_channel_indices[i]`` is ``None`` + no mapping is performed for output channel ``i``. + + Returns + ------- + list[str]: + Channel selection strings for the query. + list[str]: + Channel join strings for the query. + list[_SQLTableDefinition]: + Temporary table definitions for the query. + + """ + selection_lines = [] + join_lines = [] + table_defs = [] + + for i, channel_indices_dict in enumerate( + norm_channel_indices_list + ): + channel_table_name = f'TemporaryChannelTable{i}' + channel_column_defs = ( + [f'OutputChannelIndex INTEGER UNIQUE NOT NULL'] + + [ + f'{c} {self._get_frame_lut_col_type(c)} NOT NULL' + for c in channel_indices_dict.keys() + ] + ) + + selection_lines.append( + f'{channel_table_name}.OutputChannelIndex' + ) + + num_channels = len(list(channel_indices_dict.values())[0]) + if ( + remap_channel_indices is not None and + remap_channel_indices[i] is not None + ): + output_channel_indices = remap_channel_indices[i] + else: + output_channel_indices = range(num_channels) + + channel_column_data = zip( + output_channel_indices, + *channel_indices_dict.values() + ) + + table_defs.append( + _SQLTableDefinition( + table_name=channel_table_name, + column_defs=channel_column_defs, + column_data=list(channel_column_data), + ) + ) + + channel_join_condition = ' AND '.join( + f'L.{col} = {channel_table_name}.{col}' + for col in channel_indices_dict.keys() + ) + join_lines.append( + f'INNER JOIN {channel_table_name} ON {channel_join_condition}' + ) + + return selection_lines, join_lines, table_defs + + @contextmanager + def _iterate_indices_for_stack( + self, + stack_indices: Dict[Union[int, str], Sequence[Any]], + stack_dimension_use_indices: bool = False, + channel_indices: Optional[List[Dict[Union[int, str], Sequence[Any]]]] = None, + channel_dimension_use_indices: bool = False, + remap_channel_indices: Optional[Sequence[int]] = None, + filters: Optional[Dict[Union[int, str], Any]] = None, + filters_use_indices: bool = False, + allow_missing_frames: bool = False, + ) -> Generator[ + Iterator[ + Tuple[ + int, # frame index + Tuple[slice, slice], # input indexer + Tuple[int, slice, slice], # output indexer + Tuple[int, ...], # channel indexer + ] + ], + None, + None, + ]: + """Get indices required to reconstruct pixels into a stack of frames. + + The frames will be stacked down dimension 0 of the returned array. + There may optionally be a channel dimension at dimension 3. + + Parameters + ---------- + stack_indices: Dict[Union[int, str], Sequence[Any]] + Dictionary defining the stack dimension (axis 0 of the output + array). The keys define the dimensions used. They may be either the + tags or keywords of attributes in the image's dimension index, or + the special values 'VolumePosition', 'ReferencedSOPInstanceUID', + and 'ReferencedFrameNumber'. The values of the dictionary give + sequences of values of corresponding dimension that define each + slice of the output array. Note that multiple dimensions may be + used, in which case a frame must match the values of all provided + dimensions to be placed in the output array. + stack_dimension_use_indices: bool, optional + If True, the values in ``stack_indices`` are integer-valued + dimension *index* values. If False the dimension values themselves + are used, whose type depends on the choice of dimension. + channel_indices: Union[List[Dict[Union[int, str], Sequence[Any]], None]], optional + List of dictionaries defining the channel dimensions. The first + item in the list corresponds to axis 3 of the output array, if any, + the next to axis 4 and so so. Each dictionary has a format + identical to that of ``stack_indices``, however the dimensions used + must be distinct. Note that each item in the list may contain + multiple items, provided that the number of items in each value + matches within a single dictionary. + channel_dimension_use_indices: bool, optional + As ``stack_dimension_use_indices`` but for the channel axis. + remap_channel_indices: Union[Sequence[Union[Sequence[int], None], None], optional + Use these values to remap the channel indices returned in the + output iterator. The ith item applies to output channel i, and + within that list index ``j`` is mapped to + ``remap_channel_indices[i][j]``. Ignored if ``channel_indices`` is + ``None``. If ``None``, or ``remap_channel_indices[i]`` is ``None`` + no mapping is performed for output channel ``i``. + filters: Union[Dict[Union[int, str], Any], None], optional + Additional filters to use to limit frames. Definition is similar to + ``stack_indices`` except that the dictionary's values are single + values rather than lists. + filters_use_indices: bool, optional + As ``stack_dimension_use_indices`` but for the filters. + allow_missing_frames: bool, optional + Allow frames in the output array to be blank because these frames + are omitted from the image. If False and missing frames are found, + an error is raised. + + Yields + ------ + Iterator[ Tuple[int, Tuple[slice, slice], Tuple[int, slice, slice], Tuple[int, ...]]]: + Indices required to construct the requested mask. Each triplet + denotes the (frame_index, input indexer, spatial indexer, channel + indexer) representing a list of "instructions" to create the + requested output array by copying frames from the image dataset and + inserting them into the output array. + + """ + norm_stack_indices = self._normalize_dimension_queries( + stack_indices, + stack_dimension_use_indices, + True, + ) + all_columns = list(norm_stack_indices.keys()) + + if channel_indices is not None: + norm_channel_indices_list = [ + self._normalize_dimension_queries( + indices_dict, + channel_dimension_use_indices, + True, + ) for indices_dict in channel_indices + ] + for indices_dict in norm_channel_indices_list: + all_columns.extend(list(indices_dict.keys())) + else: + norm_channel_indices_list = [] + + if filters is not None: + norm_filters = self._normalize_dimension_queries( + filters, + filters_use_indices, + False, + ) + all_columns.extend(list(norm_filters.keys())) + else: + norm_filters = None + + all_dimensions = [ + c.replace('_DimensionIndexValues', '') + for c in all_columns + ] + if len(set(all_dimensions)) != len(all_dimensions): + raise ValueError( + 'Dimensions used for stack, channel, and filter must all be ' + 'distinct.' + ) + + # Check for uniqueness + if not self._are_columns_unique(all_columns): + raise RuntimeError( + 'The chosen dimensions do not uniquely identify frames of ' + 'the image. You may need to provide further dimensions or ' + 'a filter to disambiguate.' + ) + + # Create temporary table of desired dimension indices + stack_table_name = 'TemporaryStackTable' + + stack_column_defs = ( + ['OutputFrameIndex INTEGER UNIQUE NOT NULL'] + + [ + f'{c} {self._get_frame_lut_col_type(c)} NOT NULL' + for c in norm_stack_indices.keys() + ] + ) + stack_column_data = list( + (i, *row) + for i, row in enumerate(zip(*norm_stack_indices.values())) + ) + stack_join_str = ' AND '.join( + f'F.{col} = L.{col}' for col in norm_stack_indices.keys() + ) + + # Filters + if norm_filters is not None: + filter_comparisons = [] + for c, v in norm_filters.items(): + if isinstance(v, str): + v = f"'{v}'" + filter_comparisons.append(f'L.{c} = {v}') + filter_str = 'WHERE ' + ' AND '.join(filter_comparisons) + else: + filter_str = '' + + stack_table_def = _SQLTableDefinition( + table_name=stack_table_name, + column_defs=stack_column_defs, + column_data=stack_column_data, + ) + + selection_lines = [ + 'F.OutputFrameIndex', # frame index of the output array + 'L.FrameNumber - 1', # frame *index* of the file + ] + + ( + channel_selection_lines, + channel_join_lines, + channel_table_defs, + ) = self._prepare_channel_tables( + norm_channel_indices_list, + remap_channel_indices, + ) + + selection_str = ', '.join(selection_lines + channel_selection_lines) + + # Construct the query. The ORDER BY is not logically necessary but + # seems to improve performance of the downstream numpy operations, + # presumably as it is more cache efficient + query_template = ( + 'SELECT {selection_str} ' + f'FROM {stack_table_name} F ' + f'INNER JOIN FrameLUT L ON {stack_join_str} ' + f'{" ".join(channel_join_lines)} ' + f'{filter_str} ' + '{order_str}' + ) + + with self._generate_temp_tables([stack_table_def] + channel_table_defs): + + if not allow_missing_frames: + counting_query = query_template.format( + selection_str='COUNT(*)', + order_str='', + ) + + # Calculate the number of output frames + number_of_output_frames = len(stack_table_def.column_data) + for tdef in channel_table_defs: + number_of_output_frames *= len(tdef.column_data) + + # Use a query to find the number of input frames + found_number = next(self._db_con.execute(counting_query))[0] + + # If these two numbers are not the same, there are missing + # frames + if found_number != number_of_output_frames: + raise RuntimeError( + 'The requested set of frames includes frames that ' + 'are missing from the image. You may need to allow ' + 'missing frames or add additional filters.' + ) + + full_query = query_template.format( + selection_str=selection_str, + order_str='ORDER BY F.OutputFrameIndex', + ) + + yield ( + ( + fi, + (slice(None), slice(None)), + (fo, slice(None), slice(None)), + tuple(channel), + ) + for (fo, fi, *channel) in self._db_con.execute(full_query) + ) + + @contextmanager + def _iterate_indices_for_tiled_region( + self, + row_start: int = 1, + row_end: Optional[int] = None, + column_start: int = 1, + column_end: Optional[int] = None, + channel_indices: Optional[List[Dict[Union[int, str], Sequence[Any]]]] = None, + channel_dimension_use_indices: bool = False, + remap_channel_indices: Optional[Sequence[int]] = None, + filters: Optional[Dict[Union[int, str], Any]] = None, + filters_use_indices: bool = False, + allow_missing_frames: bool = False, + ) -> tuple[ + Generator[ + Iterator[ + Tuple[ + int, + Tuple[slice, slice], + Tuple[slice, slice], + Tuple[int, ...] + ] + ], + None, + None, + ], + tuple[int, int] + ]: + """Iterate over segmentation frame indices for a given region of the + image's total pixel matrix. + + This is intended for the case of an image that is stored as a tiled + representation of total pixel matrix. + + This yields an iterator to the underlying database result that iterates + over information on the steps required to construct the requested + image from the stored frame. + + This method is intended to be used as a context manager that yields the + requested iterator. The iterator is only valid while the context + manager is active. + + Parameters + ---------- + row_start: int, optional + 1-based row index in the total pixel matrix of the first row to + include in the output array. May be negative, in which case the + last row is considered index -1. + row_end: Union[int, None], optional + 1-based row index in the total pixel matrix of the first row beyond + the last row to include in the output array. A ``row_end`` value of + ``n`` will include rows ``n - 1`` and below, similar to standard + Python indexing. If ``None``, rows up until the final row of the + total pixel matrix are included. May be negative, in which case the + last row is considered index -1. + column_start: int, optional + 1-based column index in the total pixel matrix of the first column + to include in the output array. May be negative, in which case the + last column is considered index -1. + column_end: Union[int, None], optional + 1-based column index in the total pixel matrix of the first column + beyond the last column to include in the output array. A + ``column_end`` value of ``n`` will include columns ``n - 1`` and + below, similar to standard Python indexing. If ``None``, columns up + until the final column of the total pixel matrix are included. May + be negative, in which case the last column is considered index -1. + channel_indices: Union[List[Dict[Union[int, str], Sequence[Any]], None]], optional + List of dictionaries defining the channel dimensions. Within each + dictionary, The keys define the dimensions used. They may be either + the tags or keywords of attributes in the image's dimension index, + or the special values 'ReferencedSOPInstanceUID', + and 'ReferencedFrameNumber'. The values of the dictionary give + sequences of values of corresponding dimension that define each + slice of the output array. Note that multiple dimensions may be + used, in which case a frame must match the values of all provided + dimensions to be placed in the output array.The first item in the + list corresponds to axis 3 of the output array, if any, the next to + axis 4 and so so. Note that each item in the list may contain + multiple items, provided that the number of items in each value + matches within a single dictionary. + channel_dimension_use_indices: bool, optional + As ``stack_dimension_use_indices`` but for the channel axis. + remap_channel_indices: Union[Sequence[int], None], optional + Use these values to remap the channel indices returned in the + output iterator. The ith item applies to output channel i, and + within that list index ``j`` is mapped to + ``remap_channel_indices[i][j]``. Ignored if ``channel_indices`` is + ``None``. If ``None``, or ``remap_channel_indices[i]`` is ``None`` + no mapping is performed for output channel ``i``. + filters: Union[Dict[Union[int, str], Any], None], optional + Additional filters to use to limit frames. Definition is similar to + ``stack_indices`` except that the dictionary's values are single + values rather than lists. + filters_use_indices: bool, optional + As ``stack_dimension_use_indices`` but for the filters. + allow_missing_frames: bool, optional + Allow frames in the output array to be blank because these frames + are omitted from the image. If False and missing frames are found, + an error is raised. + + Yields + ------ + Iterator[Tuple[int, Tuple[slice, slice], Tuple[slice, slice], Tuple[int, ...]]]: + Indices required to construct the requested mask. Each triplet + denotes the (frame_index, input indexer, spatial indexer, channel + indexer) representing a list of "instructions" to create the + requested output array by copying frames from the image dataset and + inserting them into the output array. + tuple[int, int]: + Output shape. + + """ # noqa: E501 + all_columns = [ + 'RowPositionInTotalImagePixelMatrix', + 'ColumnPositionInTotalImagePixelMatrix', + ] + if channel_indices is not None: + norm_channel_indices_list = [ + self._normalize_dimension_queries( + indices_dict, + channel_dimension_use_indices, + True, + ) for indices_dict in channel_indices + ] + for indices_dict in norm_channel_indices_list: + all_columns.extend(list(indices_dict.keys())) + else: + norm_channel_indices_list = [] + + if filters is not None: + norm_filters = self._normalize_dimension_queries( + filters, + filters_use_indices, + False, + ) + all_columns.extend(list(norm_filters.keys())) + else: + norm_filters = None + + all_dimensions = [ + c.replace('_DimensionIndexValues', '') + for c in all_columns + ] + if len(all_dimensions) != len(all_dimensions): + raise ValueError( + 'Dimensions used for tile position, channel, and filter ' + 'must all be distinct.' + ) + + # Check for uniqueness + if not self._are_columns_unique(all_columns): + raise RuntimeError( + 'The chosen dimensions do not uniquely identify frames of' + 'the image. You may need to provide further dimensions or ' + 'a filter to disambiguate.' + ) + + # Filters + if norm_filters is not None: + filter_comparisons = [] + for c, v in norm_filters: + if isinstance(v, str): + v = f"'{v}'" + filter_comparisons.append(f'L.{c} = {v}') + filter_str = ' AND ' + ' AND '.join(filter_comparisons) + else: + filter_str = '' + + if row_start is None: + row_start = 1 + if row_end is None: + row_end = self.TotalPixelMatrixRows + 1 + if column_start is None: + column_start = 1 + if column_end is None: + column_end = self.TotalPixelMatrixColumns + 1 + + if column_start == 0 or row_start == 0: + raise ValueError( + 'Arguments "row_start" and "column_start" may not be 0.' + ) + + if row_start > self.TotalPixelMatrixRows + 1: + raise ValueError( + 'Invalid value for "row_start".' + ) + elif row_start < 0: + row_start = self.TotalPixelMatrixRows + row_start + 1 + if row_end > self.TotalPixelMatrixRows + 1: + raise ValueError( + 'Invalid value for "row_end".' + ) + elif row_end < 0: + row_end = self.TotalPixelMatrixRows + row_end + 1 + + if column_start > self.TotalPixelMatrixColumns + 1: + raise ValueError( + 'Invalid value for "column_start".' + ) + elif column_start < 0: + column_start = self.TotalPixelMatrixColumns + column_start + 1 + if column_end > self.TotalPixelMatrixColumns + 1: + raise ValueError( + 'Invalid value for "column_end".' + ) + elif column_end < 0: + column_end = self.TotalPixelMatrixColumns + column_end + 1 + + output_shape = ( + row_end - row_start, + column_end - column_start, + ) + + th, tw = self.Rows, self.Columns + + oh = row_end - row_start + ow = column_end - column_start + + row_offset_start = row_start - th + 1 + column_offset_start = column_start - tw + 1 + + selection_lines = [ + 'L.RowPositionInTotalImagePixelMatrix', + 'L.ColumnPositionInTotalImagePixelMatrix', + 'L.FrameNumber - 1', + ] + + ( + channel_selection_lines, + channel_join_lines, + channel_table_defs, + ) = self._prepare_channel_tables( + norm_channel_indices_list, + remap_channel_indices, + ) + + selection_str = ', '.join(selection_lines + channel_selection_lines) + + # Construct the query The ORDER BY is not logically necessary + # but seems to improve performance of the downstream numpy + # operations, presumably as it is more cache efficient + # Create temporary table of channel indices + query_template = ( + 'SELECT {selection_str} ' + 'FROM FrameLUT L ' + f'{" ".join(channel_join_lines)} ' + 'WHERE (' + ' L.RowPositionInTotalImagePixelMatrix >= ' + f' {row_offset_start}' + f' AND L.RowPositionInTotalImagePixelMatrix < {row_end}' + ' AND L.ColumnPositionInTotalImagePixelMatrix >= ' + f' {column_offset_start}' + f' AND L.ColumnPositionInTotalImagePixelMatrix < {column_end}' + f' {filter_str} ' + ') ' + '{order_str}' + ) + + order_str = ( + 'ORDER BY ' + ' L.RowPositionInTotalImagePixelMatrix,' + ' L.ColumnPositionInTotalImagePixelMatrix' + ) + + with self._generate_temp_tables(channel_table_defs): + + if ( + not allow_missing_frames and + self.get('DimensionOrganizationType', '') != "TILED_FULL" + ): + counting_query = query_template.format( + selection_str='COUNT(*)', + order_str='', + ) + + # Calculate the number of output frames + v_frames = ((row_end - 2) // th) - ((row_start - 1) // th) + 1 + h_frames = ( + ((column_end - 2) // tw) - ((column_start - 1) // tw) + 1 + ) + number_of_output_frames = v_frames * h_frames + for tdef in channel_table_defs: + number_of_output_frames *= len(tdef.column_data) + + # Use a query to find the number of input frames + found_number = next(self._db_con.execute(counting_query))[0] + + # If these two numbers are not the same, there are missing + # frames + if found_number != number_of_output_frames: + raise RuntimeError( + 'The requested set of frames includes frames that ' + 'are missing from the image. You may need to allow ' + 'missing frames or add additional filters.' + ) + + full_query = query_template.format( + selection_str=selection_str, + order_str=order_str, + ) + + yield ( + ( + fi, + ( + slice( + max(row_start - rp, 0), + min(row_end - rp, th) + ), + slice( + max(column_start - cp, 0), + min(column_end - cp, tw) + ), + ), + ( + slice( + max(rp - row_start, 0), + min(rp + th - row_start, oh) + ), + slice( + max(cp - column_start, 0), + min(cp + tw - column_start, ow) + ), + ), + tuple(channel), + ) + for (rp, cp, fi, *channel) in self._db_con.execute(full_query) + ), output_shape + + @classmethod + def from_file( + cls, + fp: Union[str, bytes, PathLike, BinaryIO], + lazy_frame_retrieval: bool = False + ) -> Self: + """Read an image stored in DICOM File Format. + + Parameters + ---------- + fp: Union[str, bytes, os.PathLike] + Any file-like object representing a DICOM file containing an + image. + lazy_frame_retrieval: bool + If True, the returned image will retrieve frames from the file as + requested, rather than loading in the entire object to memory + initially. This may be a good idea if file reading is slow and you are + likely to need only a subset of the frames in the image. + + Returns + ------- + highdicom._Image + Image read from the file. + + """ + if lazy_frame_retrieval: + if not isinstance(fp, (str, PathLike)): + raise TypeError( + "Argument 'fp' may not be of type bytes or BinaryIO " + "if using 'lazy_frame_retrieval'." + ) + reader = ImageFileReader(fp) + metadata = reader._change_metadata_ownership() + image = cls.from_dataset(metadata, copy=False) + image._file_reader = reader + else: + image = cls.from_dataset(dcmread(fp), copy=False) + + return image + + +class Image(_Image): + + """Class representing a general DICOM image. + + An "image" is any object representing an Image Information Entity. + + Note that this does not correspond to a particular SOP class in DICOM, but + instead captures behavior that is common to a number of SOP classes. + + The class may not be instantiated directly, but should be created from an + existing dataset. + + """ + + def get_volume( + self, + *, + slice_start: int = 0, + slice_end: Optional[int] = None, + dtype: Union[type, str, np.dtype, None] = None, + apply_real_world_transform: bool | None = None, + real_world_value_map_selector: int | str | Code | CodedConcept = 0, + apply_modality_transform: bool | None = None, + apply_voi_transform: bool | None = False, + voi_transform_selector: int | str | VOILUTTransformation = 0, + voi_output_range: Tuple[float, float] = (0.0, 1.0), + apply_presentation_lut: bool = True, + apply_palette_color_lut: bool | None = None, + apply_icc_profile: bool | None = None, + allow_missing_frames: bool = False, + ) -> Volume: + """Create a :class:`highdicom.Volume` from the image. + + This is only possible if the image represents a regularly-spaced + 3D volume. + + Parameters + ---------- + slice_start: int, optional + Zero-based index of the "volume position" of the first slice of the + returned volume. The "volume position" refers to the position of + slices after sorting spatially, and may correspond to any frame in + the segmentation file, depending on its construction. May be + negative, in which case standard Python indexing behavior is + followed (-1 corresponds to the last volume position, etc). + slice_end: Union[int, None], optional + Zero-based index of the "volume position" one beyond the last slice + of the returned volume. The "volume position" refers to the + position of slices after sorting spatially, and may correspond to + any frame in the segmentation file, depending on its construction. + May be negative, in which case standard Python indexing behavior is + followed (-1 corresponds to the last volume position, etc). If + None, the last volume position is included as the last output + slice. + dtype: Union[type, str, numpy.dtype, None] + Data type of the returned array. If None, an appropriate type will + be chosen automatically. If the returned values are rescaled + fractional values, this will be numpy.float32. Otherwise, the + smallest unsigned integer type that accommodates all of the output + values will be chosen. + apply_real_world_transform: bool | None, optional + Whether to apply to apply the real-world value map to the frame. + The real world value map converts stored pixel values to output + values with a real-world meaning, either using a LUT or a linear + slope and intercept. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if present but no error will be raised if + it is not present. + + Note that if the dataset contains both a modality LUT and a real + world value map, the real world value map will be applied + preferentially. This also implies that specifying both + ``apply_real_world_transform`` and ``apply_modality_transform`` to + True is not permitted. + real_world_value_map_selector: int | str | pydicom.sr.coding.Code | highdicom.sr.coding.CodedConcept, optional + Specification of the real world value map to use (multiple may be + present in the dataset). If an int, it is used to index the list of + available maps. A negative integer may be used to index from the + end of the list following standard Python indexing convention. If a + str, the string will be used to match the ``"LUTLabel"`` attribute + to select the map. If a ``pydicom.sr.coding.Code`` or + ``highdicom.sr.coding.CodedConcept``, this will be used to match + the units (contained in the ``"MeasurementUnitsCodeSequence"`` + attribute). + apply_modality_transform: bool | None, optional + Whether to apply to the modality transform (if present in the + dataset) the frame. The modality transformation maps stored pixel + values to output values, either using a LUT or rescale slope and + intercept. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present and no real world value + map takes precedence, but no error will be raised if it is not + present. + apply_voi_transform: bool | None, optional + Apply the value-of-interest (VOI) transformation (if present in the + dataset), which limits the range of pixel values to a particular + range of interest, using either a windowing operation or a LUT. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present and no real world value + map takes precedence, but no error will be raised if it is not + present. + voi_transform_selector: int | str | highdicom.content.VOILUTTransformation, optional + Specification of the VOI transform to select (multiple may be + present). May either be an int or a str. If an int, it is + interpretted as a (zero-based) index of the list of VOI transforms + to apply. A negative integer may be used to index from the end of + the list following standard Python indexing convention. If a str, + the string that will be used to match the + ``"WindowCenterWidthExplanation"`` or the ``"LUTExplanation"`` + attributes to choose from multiple VOI transforms. Note that such + explanations are optional according to the standard and therefore + may not be present. Ignored if ``apply_voi_transform`` is ``False`` + or no VOI transform is included in the datasets. + voi_output_range: Tuple[float, float], optional + Range of output values to which the VOI range is mapped. Only + relevant if ``apply_voi_transform`` is True and a VOI transform is + present. + apply_palette_color_lut: bool | None, optional + Apply the palette color LUT, if present in the dataset. The palette + color LUT maps a single sample for each pixel stored in the dataset + to a 3 sample-per-pixel color image. + apply_presentation_lut: bool, optional + Apply the presentation LUT transform to invert the pixel values. If + the PresentationLUTShape is present with the value ``'INVERSE''``, + or the PresentationLUTShape is not present but the Photometric + Interpretation is MONOCHROME1, convert the range of the output + pixels corresponds to MONOCHROME2 (in which high values are + represent white and low values represent black). Ignored if + PhotometricInterpretation is not MONOCHROME1 and the + PresentationLUTShape is not present, or if a real world value + transform is applied. + apply_icc_profile: bool | None, optional + Whether colors should be corrected by applying an ICC + transformation. Will only be performed if metadata contain an + ICC Profile. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present, but no error will be + raised if it is not present. + allow_missing_frames: bool, optional + Allow frames in the output array to be blank because these frames + are omitted from the image. If False and missing frames are found, + an error is raised. + + """ + if self.volume_geometry is None: + raise RuntimeError( + "This image is not a regularly-spaced 3D volume." + ) + n_vol_positions = self.volume_geometry.spatial_shape[0] + + # Check that the combination of frame numbers uniquely identify + # frames + columns = ['VolumePosition'] + if not self._are_columns_unique(columns): + raise RuntimeError( + 'Volume positions and do not ' + 'uniquely identify frames of the image.' + ) + + if slice_start < 0: + slice_start = n_vol_positions + slice_start + + if slice_end is None: + slice_end = n_vol_positions + elif slice_end > n_vol_positions: + raise IndexError( + f"Value of {slice_end} is not valid for segmentation with " + f"{n_vol_positions} volume positions." + ) + elif slice_end < 0: + if slice_end < (- n_vol_positions): + raise IndexError( + f"Value of {slice_end} is not valid for segmentation with " + f"{n_vol_positions} volume positions." + ) + slice_end = n_vol_positions + slice_end + + number_of_slices = cast(int, slice_end) - slice_start + + if number_of_slices < 1: + raise ValueError( + "The combination of 'slice_start' and 'slice_end' gives an " + "empty volume." + ) + + volume_positions = range(slice_start, slice_end) + + channel_spec = None + + color_type = self._get_color_tyoe() + if ( + color_type == _ImageColorType.COLOR or + ( + color_type == _ImageColorType.PALETTE_COLOR and + (apply_palette_color_lut or apply_palette_color_lut is None) + ) + ): + channel_spec = {RGB_COLOR_CHANNEL_IDENTIFIER: ['R', 'G', 'B']} + + with self._iterate_indices_for_stack( + stack_indices={'VolumePosition': volume_positions}, + # channel_indices=channel_indices, + # remap_channel_indices=[remap_channel_indices], + allow_missing_frames=allow_missing_frames, + ) as indices: + + array = self._get_pixels_by_frame( + spatial_shape=number_of_slices, + indices_iterator=indices, + # channel_shape=channel_shape, + apply_real_world_transform=apply_real_world_transform, + real_world_value_map_selector=real_world_value_map_selector, + apply_modality_transform=apply_modality_transform, + apply_voi_transform=apply_voi_transform, + voi_transform_selector=voi_transform_selector, + voi_output_range=voi_output_range, + apply_presentation_lut=apply_presentation_lut, + apply_palette_color_lut=apply_palette_color_lut, + apply_icc_profile=apply_icc_profile, + dtype=dtype, + ) + + affine = self.volume_geometry[slice_start].affine + + return Volume( + array=array, + affine=affine, + frame_of_reference_uid=self.FrameOfReferenceUID, + channels=channel_spec, + ) + + def get_total_pixel_matrix( + self, + row_start: int = 1, + row_end: Optional[int] = None, + column_start: int = 1, + column_end: Optional[int] = None, + dtype: Union[type, str, np.dtype, None] = None, + apply_real_world_transform: bool | None = None, + real_world_value_map_selector: int | str | Code | CodedConcept = 0, + apply_modality_transform: bool | None = None, + apply_voi_transform: bool | None = False, + voi_transform_selector: int | str | VOILUTTransformation = 0, + voi_output_range: Tuple[float, float] = (0.0, 1.0), + apply_presentation_lut: bool = True, + apply_palette_color_lut: bool | None = None, + apply_icc_profile: bool | None = None, + allow_missing_frames: bool = True, + ): + """Get the pixel array as a (region of) the total pixel matrix. + + Parameters + ---------- + row_start: int, optional + 1-based row index in the total pixel matrix of the first row to + include in the output array. May be negative, in which case the + last row is considered index -1. + row_end: Union[int, None], optional + 1-based row index in the total pixel matrix of the first row beyond + the last row to include in the output array. A ``row_end`` value of + ``n`` will include rows ``n - 1`` and below, similar to standard + Python indexing. If ``None``, rows up until the final row of the + total pixel matrix are included. May be negative, in which case the + last row is considered index -1. + column_start: int, optional + 1-based column index in the total pixel matrix of the first column + to include in the output array. May be negative, in which case the + last column is considered index -1. + column_end: Union[int, None], optional + 1-based column index in the total pixel matrix of the first column + beyond the last column to include in the output array. A + ``column_end`` value of ``n`` will include columns ``n - 1`` and + below, similar to standard Python indexing. If ``None``, columns up + until the final column of the total pixel matrix are included. May + be negative, in which case the last column is considered index -1. + dtype: Union[type, str, numpy.dtype, None] + Data type of the returned array. If None, an appropriate type will + be chosen automatically. If the returned values are rescaled + fractional values, this will be numpy.float32. Otherwise, the + smallest unsigned integer type that accommodates all of the output + values will be chosen. + apply_real_world_transform: bool | None, optional + Whether to apply to apply the real-world value map to the frame. + The real world value map converts stored pixel values to output + values with a real-world meaning, either using a LUT or a linear + slope and intercept. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if present but no error will be raised if + it is not present. + + Note that if the dataset contains both a modality LUT and a real + world value map, the real world value map will be applied + preferentially. This also implies that specifying both + ``apply_real_world_transform`` and ``apply_modality_transform`` to + True is not permitted. + real_world_value_map_selector: int | str | pydicom.sr.coding.Code | highdicom.sr.coding.CodedConcept, optional + Specification of the real world value map to use (multiple may be + present in the dataset). If an int, it is used to index the list of + available maps. A negative integer may be used to index from the + end of the list following standard Python indexing convention. If a + str, the string will be used to match the ``"LUTLabel"`` attribute + to select the map. If a ``pydicom.sr.coding.Code`` or + ``highdicom.sr.coding.CodedConcept``, this will be used to match + the units (contained in the ``"MeasurementUnitsCodeSequence"`` + attribute). + apply_modality_transform: bool | None, optional + Whether to apply to the modality transform (if present in the + dataset) the frame. The modality transformation maps stored pixel + values to output values, either using a LUT or rescale slope and + intercept. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present and no real world value + map takes precedence, but no error will be raised if it is not + present. + apply_voi_transform: bool | None, optional + Apply the value-of-interest (VOI) transformation (if present in the + dataset), which limits the range of pixel values to a particular + range of interest, using either a windowing operation or a LUT. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present and no real world value + map takes precedence, but no error will be raised if it is not + present. + voi_transform_selector: int | str | highdicom.content.VOILUTTransformation, optional + Specification of the VOI transform to select (multiple may be + present). May either be an int or a str. If an int, it is + interpretted as a (zero-based) index of the list of VOI transforms + to apply. A negative integer may be used to index from the end of + the list following standard Python indexing convention. If a str, + the string that will be used to match the + ``"WindowCenterWidthExplanation"`` or the ``"LUTExplanation"`` + attributes to choose from multiple VOI transforms. Note that such + explanations are optional according to the standard and therefore + may not be present. Ignored if ``apply_voi_transform`` is ``False`` + or no VOI transform is included in the datasets. + voi_output_range: Tuple[float, float], optional + Range of output values to which the VOI range is mapped. Only + relevant if ``apply_voi_transform`` is True and a VOI transform is + present. + apply_palette_color_lut: bool | None, optional + Apply the palette color LUT, if present in the dataset. The palette + color LUT maps a single sample for each pixel stored in the dataset + to a 3 sample-per-pixel color image. + apply_presentation_lut: bool, optional + Apply the presentation LUT transform to invert the pixel values. If + the PresentationLUTShape is present with the value ``'INVERSE''``, + or the PresentationLUTShape is not present but the Photometric + Interpretation is MONOCHROME1, convert the range of the output + pixels corresponds to MONOCHROME2 (in which high values are + represent white and low values represent black). Ignored if + PhotometricInterpretation is not MONOCHROME1 and the + PresentationLUTShape is not present, or if a real world value + transform is applied. + apply_icc_profile: bool | None, optional + Whether colors should be corrected by applying an ICC + transformation. Will only be performed if metadata contain an + ICC Profile. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present, but no error will be + raised if it is not present. + allow_missing_frames: bool, optional + Allow frames in the output array to be blank because these frames + are omitted from the image. If False and missing frames are found, + an error is raised. + + Returns + ------- + pixel_array: numpy.ndarray + Pixel array representing the image's total pixel matrix. + + Note + ---- + This method uses 1-based indexing of rows and columns in order to match + the conventions used in the DICOM standard. The first row of the total + pixel matrix is row 1, and the last is ``self.TotalPixelMatrixRows``. + This is is unlike standard Python and NumPy indexing which is 0-based. + For negative indices, the two are equivalent with the final row/column + having index -1. + + """ + # Check whether this segmentation is appropriate for tile-based indexing + if not self.is_tiled: + raise RuntimeError("Image is not a tiled image.") + if not self.is_indexable_as_total_pixel_matrix(): + raise RuntimeError( + "Image does not have appropriate dimension indices " + "to be indexed as a total pixel matrix." + ) + + with self._iterate_indices_for_tiled_region( + row_start=row_start, + row_end=row_end, + column_start=column_start, + column_end=column_end, + allow_missing_frames=allow_missing_frames, + ) as (indices, output_shape): + + return self._get_pixels_by_frame( + spatial_shape=output_shape, + indices_iterator=indices, + # channel_shape=channel_shape, + apply_real_world_transform=apply_real_world_transform, + real_world_value_map_selector=real_world_value_map_selector, + apply_modality_transform=apply_modality_transform, + apply_voi_transform=apply_voi_transform, + voi_transform_selector=voi_transform_selector, + voi_output_range=voi_output_range, + apply_presentation_lut=apply_presentation_lut, + apply_palette_color_lut=apply_palette_color_lut, + apply_icc_profile=apply_icc_profile, + dtype=dtype, + ) + + +def imread( + fp: Union[str, bytes, PathLike, BinaryIO], + lazy_frame_retrieval: bool = False +) -> Image: + """Read an image stored in DICOM File Format. + + Parameters + ---------- + fp: Union[str, bytes, os.PathLike] + Any file-like object representing a DICOM file containing an + image. + lazy_frame_retrieval: bool + If True, the returned image will retrieve frames from the file as + requested, rather than loading in the entire object to memory + initially. This may be a good idea if file reading is slow and you are + likely to need only a subset of the frames in the image. + + Returns + ------- + highdicom.Image + Image read from the file. + + """ + # This is essentially a convenience alias for the classmethod (which is + # used so that it is inherited correctly by subclasses). It is used + # becuse it follows the format of other similar functions around the + # library + return Image.from_file(fp, lazy_frame_retrieval=lazy_frame_retrieval) + + +def volume_from_image_series( + series_datasets: Sequence[Dataset], + dtype: Union[type, str, np.dtype, None] = None, + apply_real_world_transform: bool | None = None, + real_world_value_map_selector: int | str | Code | CodedConcept = 0, + apply_modality_transform: bool | None = None, + apply_voi_transform: bool | None = False, + voi_transform_selector: int | str | VOILUTTransformation = 0, + voi_output_range: Tuple[float, float] = (0.0, 1.0), + apply_presentation_lut: bool = True, + apply_palette_color_lut: bool | None = None, + apply_icc_profile: bool | None = None, +) -> Volume: + """Create volume from a series of single frame images. + + Parameters + ---------- + series_datasets: Sequence[pydicom.Dataset] + Series of single frame datasets. There is no requirement on the + sorting of the datasets. + dtype: Union[type, str, numpy.dtype, None] + Data type of the returned array. If None, an appropriate type will + be chosen automatically. If the returned values are rescaled + fractional values, this will be numpy.float32. Otherwise, the + smallest unsigned integer type that accommodates all of the output + values will be chosen. + apply_real_world_transform: bool | None, optional + Whether to apply to apply the real-world value map to the frame. + The real world value map converts stored pixel values to output + values with a real-world meaning, either using a LUT or a linear + slope and intercept. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if present but no error will be raised if + it is not present. + + Note that if the dataset contains both a modality LUT and a real + world value map, the real world value map will be applied + preferentially. This also implies that specifying both + ``apply_real_world_transform`` and ``apply_modality_transform`` to + True is not permitted. + real_world_value_map_selector: int | str | pydicom.sr.coding.Code | highdicom.sr.coding.CodedConcept, optional + Specification of the real world value map to use (multiple may be + present in the dataset). If an int, it is used to index the list of + available maps. A negative integer may be used to index from the + end of the list following standard Python indexing convention. If a + str, the string will be used to match the ``"LUTLabel"`` attribute + to select the map. If a ``pydicom.sr.coding.Code`` or + ``highdicom.sr.coding.CodedConcept``, this will be used to match + the units (contained in the ``"MeasurementUnitsCodeSequence"`` + attribute). + apply_modality_transform: bool | None, optional + Whether to apply to the modality transform (if present in the + dataset) the frame. The modality transformation maps stored pixel + values to output values, either using a LUT or rescale slope and + intercept. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present and no real world value + map takes precedence, but no error will be raised if it is not + present. + apply_voi_transform: bool | None, optional + Apply the value-of-interest (VOI) transformation (if present in the + dataset), which limits the range of pixel values to a particular + range of interest, using either a windowing operation or a LUT. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present and no real world value + map takes precedence, but no error will be raised if it is not + present. + voi_transform_selector: int | str | highdicom.content.VOILUTTransformation, optional + Specification of the VOI transform to select (multiple may be + present). May either be an int or a str. If an int, it is + interpretted as a (zero-based) index of the list of VOI transforms + to apply. A negative integer may be used to index from the end of + the list following standard Python indexing convention. If a str, + the string that will be used to match the + ``"WindowCenterWidthExplanation"`` or the ``"LUTExplanation"`` + attributes to choose from multiple VOI transforms. Note that such + explanations are optional according to the standard and therefore + may not be present. Ignored if ``apply_voi_transform`` is ``False`` + or no VOI transform is included in the datasets. + voi_output_range: Tuple[float, float], optional + Range of output values to which the VOI range is mapped. Only + relevant if ``apply_voi_transform`` is True and a VOI transform is + present. + apply_palette_color_lut: bool | None, optional + Apply the palette color LUT, if present in the dataset. The palette + color LUT maps a single sample for each pixel stored in the dataset + to a 3 sample-per-pixel color image. + apply_presentation_lut: bool, optional + Apply the presentation LUT transform to invert the pixel values. If + the PresentationLUTShape is present with the value ``'INVERSE''``, + or the PresentationLUTShape is not present but the Photometric + Interpretation is MONOCHROME1, convert the range of the output + pixels corresponds to MONOCHROME2 (in which high values are + represent white and low values represent black). Ignored if + PhotometricInterpretation is not MONOCHROME1 and the + PresentationLUTShape is not present, or if a real world value + transform is applied. + apply_icc_profile: bool | None, optional + Whether colors should be corrected by applying an ICC + transformation. Will only be performed if metadata contain an + ICC Profile. + + If True, the transform is applied if present, and if not + present an error will be raised. If False, the transform will not + be applied, regardless of whether it is present. If ``None``, the + transform will be applied if it is present, but no error will be + raised if it is not present. + + Returns + ------- + Volume: + Volume created from the series. + + """ + coordinate_system = get_image_coordinate_system(series_datasets[0]) + if ( + coordinate_system is None or + coordinate_system != CoordinateSystemNames.PATIENT + ): + raise ValueError( + "Dataset should exist in the patient " + "coordinate_system." + ) + + frame_of_reference_uid = series_datasets[0].FrameOfReferenceUID + series_instance_uid = series_datasets[0].SeriesInstanceUID + if not all( + ds.SeriesInstanceUID == series_instance_uid + for ds in series_datasets + ): + raise ValueError('Images do not belong to the same series.') + + if not all( + ds.FrameOfReferenceUID == frame_of_reference_uid + for ds in series_datasets + ): + raise ValueError('Images do not share a frame of reference.') + + series_datasets = sort_datasets(series_datasets) + + ds = series_datasets[0] + + if len(series_datasets) == 1: + slice_spacing = ds.get('SpacingBetweenSlices', 1.0) + else: + slice_spacing, _ = get_series_volume_positions(series_datasets) + if slice_spacing is None: + raise ValueError('Series is not a regularly-spaced volume.') + + frames = [] + for ds in series_datasets: + frame = ds.pixel_array + transf = _CombinedPixelTransformation( + ds, + output_dtype=dtype, + apply_real_world_transform=apply_real_world_transform, + real_world_value_map_selector=real_world_value_map_selector, + apply_modality_transform=apply_modality_transform, + apply_voi_transform=apply_voi_transform, + voi_transform_selector=voi_transform_selector, + voi_output_range=voi_output_range, + apply_presentation_lut=apply_presentation_lut, + apply_palette_color_lut=apply_palette_color_lut, + apply_icc_profile=apply_icc_profile, + ) + + frame = transf(frame) + frames.append(frame) + + array = np.stack(frames) + + channels = None + if array.ndim == 4: + channels = {RGB_COLOR_CHANNEL_IDENTIFIER: ['R', 'G', 'B']} + + return Volume.from_attributes( + array=array, + frame_of_reference_uid=frame_of_reference_uid, + image_position=ds.ImagePositionPatient, + image_orientation=ds.ImageOrientationPatient, + pixel_spacing=ds.PixelSpacing, + spacing_between_slices=slice_spacing, + channels=channels, + ) diff --git a/src/highdicom/io.py b/src/highdicom/io.py index 1bccfdef..70c182b2 100644 --- a/src/highdicom/io.py +++ b/src/highdicom/io.py @@ -3,11 +3,13 @@ import sys import traceback from typing import List, Tuple, Union +from typing_extensions import Self from pathlib import Path +import weakref import numpy as np import pydicom -from pydicom.dataset import Dataset +from pydicom.dataset import Dataset, FileDataset from pydicom.encaps import parse_basic_offsets from pydicom.filebase import DicomFile, DicomFileLike, DicomBytesIO from pydicom.filereader import ( @@ -16,12 +18,12 @@ read_file_meta_info, read_partial ) -from pydicom.pixels.utils import unpack_bits from pydicom.tag import TupleTag, ItemTag, SequenceDelimiterTag -from pydicom.uid import UID +from pydicom.uid import UID, DeflatedExplicitVRLittleEndian from highdicom.frame import decode_frame from highdicom.color import ColorManager +from highdicom.uid import UID as hd_UID logger = logging.getLogger(__name__) @@ -157,8 +159,13 @@ def _build_bot(fp: DicomFileLike, number_of_frames: int) -> List[int]: """ initial_position = fp.tell() - offset_values = [] - current_offset = 0 + + # We will keep two lists, one of all fragment boundaries (regardless of + # whether or not they are frame boundaries) and the other of just those + # frament boundaries that are known to be frame boundaries (as identified + # by JPEG start markers). + frame_offset_values = [] + fragment_offset_values = [] i = 0 while True: frame_position = fp.tell() @@ -185,26 +192,33 @@ def _build_bot(fp: DicomFileLike, number_of_frames: int) -> List[int]: f'Length of Frame item #{i} is zero.' ) - first_two_bytes = fp.read(2, True) - if not fp.is_little_endian: - first_two_bytes = first_two_bytes[::-1] + current_offset = frame_position - initial_position + fragment_offset_values.append(current_offset) + # In case of fragmentation, we only want to get the offsets to the # first fragment of a given frame. We can identify those based on the # JPEG and JPEG 2000 markers that should be found at the beginning and # end of the compressed byte stream. + first_two_bytes = fp.read(2) + if not fp.is_little_endian: + first_two_bytes = first_two_bytes[::-1] + if first_two_bytes in _START_MARKERS: - current_offset = frame_position - initial_position - offset_values.append(current_offset) + frame_offset_values.append(current_offset) i += 1 fp.seek(length - 2, 1) # minus the first two bytes - if len(offset_values) != number_of_frames: + if len(frame_offset_values) == number_of_frames: + basic_offset_table = frame_offset_values + elif len(fragment_offset_values) == number_of_frames: + # This covers RLE and others that have no frame markers but have a + # single fragment per frame + basic_offset_table = fragment_offset_values + else: raise ValueError( 'Number of frame items does not match specified Number of Frames.' ) - else: - basic_offset_table = offset_values fp.seek(initial_position, 0) return basic_offset_table @@ -264,14 +278,44 @@ def __init__(self, filename: Union[str, Path, DicomFileLike]): 'Argument "filename" must be either an open DICOM file object ' 'or the path to a DICOM file stored on disk.' ) - self._metadata = None + self._metadata: Dataset | weakref.ReferenceType | None = None + self._voi_lut = None + self._palette_color_lut = None + self._modality_lut = None + + def _change_metadata_ownership(self) -> FileDataset: + """Set the metadata using a weakref. + + This is used by imread to allow an Image object to take ownership of + the metadata and this file reader without creating potentially + problematic reference cycles. + + Returns + ------- + metadata: pydicom.FileDataset + Dataset containing the metadata. + + """ + with self: + self._read_metadata() + # The file meta was stripped from metadata previously, add it back + # here to give a FileDataset + metadata = FileDataset( + self._filename, + dataset=self.metadata, + file_meta=self._file_meta, + is_implicit_VR=self.transfer_syntax_uid.is_implicit_VR, + is_little_endian=self.transfer_syntax_uid.is_little_endian, + ) + self._metadata = weakref.ref(metadata) + return metadata @property def filename(self) -> str: """str: Path to the image file""" return str(self._filename) - def __enter__(self) -> 'ImageFileReader': + def __enter__(self) -> Self: self.open() return self @@ -280,6 +324,8 @@ def __exit__(self, except_type, except_value, except_trace) -> None: self._fp.close() except AttributeError: pass + else: + self._fp = None if except_value: sys.stderr.write( f'Error while accessing file "{self._filename}":\n' @@ -383,8 +429,8 @@ def _read_metadata(self) -> None: f'DICOM metadata cannot be read from file: "{err}"' ) from err - # Cache Transfer Syntax UID, since we need it to decode frame items - self._transfer_syntax_uid = UID(metadata.file_meta.TransferSyntaxUID) + # Cache file meta, since we need it to decode frame items + self._file_meta = metadata.file_meta # Construct a new Dataset that is fully decoupled from the file, i.e., # that does not contain any File Meta Information @@ -392,6 +438,16 @@ def _read_metadata(self) -> None: self._metadata = Dataset(metadata) self._pixel_data_offset = self._fp.tell() + + if self.transfer_syntax_uid == DeflatedExplicitVRLittleEndian: + # The entire file is compressed with DEFLATE. These cannot be used + # since the entire file must be decompressed to read or build the + # basic/extended offset + raise ValueError( + 'Deflated transfer syntaxes cannot be used with the ' + 'ImageFileReader.' + ) + # Determine whether dataset contains a Pixel Data element try: tag = TupleTag(self._fp.read_tag()) @@ -412,7 +468,7 @@ def _read_metadata(self) -> None: logger.debug('build Basic Offset Table') number_of_frames = int(getattr(self._metadata, 'NumberOfFrames', 1)) - if self._transfer_syntax_uid.is_encapsulated: + if self.transfer_syntax_uid.is_encapsulated: try: self._basic_offset_table = _get_bot(self._fp, number_of_frames) except Exception as err: @@ -449,17 +505,17 @@ def _read_metadata(self) -> None: icc_profile: Union[bytes, None] = None self._color_manager: Union[ColorManager, None] = None - if self.metadata.SamplesPerPixel > 1: + if metadata.SamplesPerPixel > 1: try: - icc_profile = self.metadata.ICCProfile + icc_profile = metadata.ICCProfile except AttributeError: try: - if len(self.metadata.OpticalPathSequence) > 1: + if len(metadata.OpticalPathSequence) > 1: # This should not happen in case of a color image. logger.warning( 'color image contains more than one optical path' ) - optical_path_item = self.metadata.OpticalPathSequence[0] + optical_path_item = metadata.OpticalPathSequence[0] icc_profile = optical_path_item.ICCProfile except (IndexError, AttributeError): logger.warning('no ICC Profile found in image metadata.') @@ -473,10 +529,18 @@ def _read_metadata(self) -> None: @property def metadata(self) -> Dataset: """pydicom.dataset.Dataset: Metadata""" + if isinstance(self._metadata, weakref.ReferenceType): + return self._metadata() if self._metadata is None: self._read_metadata() return self._metadata + @property + def transfer_syntax_uid(self) -> hd_UID: + """highdicom.UID: Transfer Syntax UID of the file.""" + self.metadata # ensure metadata has been read + return hd_UID(self._file_meta.TransferSyntaxUID) + @property def _pixels_per_frame(self) -> int: """int: Number of pixels per frame""" @@ -537,7 +601,7 @@ def read_frame_raw(self, index: int) -> bytes: frame_offset = self._basic_offset_table[index] self._fp.seek(self._first_frame_offset + frame_offset, 0) - if self._transfer_syntax_uid.is_encapsulated: + if self.transfer_syntax_uid.is_encapsulated: try: stop_at = self._basic_offset_table[index + 1] - frame_offset except IndexError: @@ -593,27 +657,20 @@ def read_frame(self, index: int, correct_color: bool = True) -> np.ndarray: logger.debug(f'decode frame #{index}') - if self.metadata.BitsAllocated == 1: - unpacked_frame = unpack_bits(frame_data) - rows, columns = self.metadata.Rows, self.metadata.Columns - n_pixels = self._pixels_per_frame - pixel_offset = int(((index * n_pixels / 8) % 1) * 8) - pixel_array = unpacked_frame[pixel_offset:pixel_offset + n_pixels] - return pixel_array.reshape(rows, columns) - frame_array = decode_frame( frame_data, rows=self.metadata.Rows, columns=self.metadata.Columns, samples_per_pixel=self.metadata.SamplesPerPixel, - transfer_syntax_uid=self._transfer_syntax_uid, + transfer_syntax_uid=self.transfer_syntax_uid, bits_allocated=self.metadata.BitsAllocated, bits_stored=self.metadata.BitsStored, photometric_interpretation=self.metadata.PhotometricInterpretation, pixel_representation=self.metadata.PixelRepresentation, planar_configuration=getattr( self.metadata, 'PlanarConfiguration', None - ) + ), + index=index, ) # We don't use the color_correct_frame() function here, since we cache diff --git a/src/highdicom/ko/content.py b/src/highdicom/ko/content.py index 2978ee7a..d7c1e9a2 100644 --- a/src/highdicom/ko/content.py +++ b/src/highdicom/ko/content.py @@ -1,5 +1,6 @@ """Content that is specific to Key Object Selection IODs.""" from typing import cast, List, Optional, Sequence, Union +from typing_extensions import Self from pydicom.dataset import Dataset from pydicom.sr.coding import Code @@ -137,7 +138,7 @@ def from_sequence( cls, sequence: Sequence[Dataset], is_root: bool = True - ) -> 'KeyObjectSelection': + ) -> Self: """Construct object from a sequence of datasets. Parameters @@ -174,8 +175,8 @@ def from_sequence( 'because it does not have Template Identifier "2010".' ) instance = ContentSequence.from_sequence(sequence, is_root=True) - instance.__class__ = KeyObjectSelection - return cast(KeyObjectSelection, instance) + instance.__class__ = cls + return cast(cls, instance) def get_observer_contexts( self, diff --git a/src/highdicom/ko/sop.py b/src/highdicom/ko/sop.py index 44c93034..ec33e19b 100644 --- a/src/highdicom/ko/sop.py +++ b/src/highdicom/ko/sop.py @@ -1,6 +1,7 @@ """Module for SOP Classes of Key Object (KO) IODs.""" import logging from typing import Any, cast, List, Optional, Sequence, Tuple, Union +from typing_extensions import Self from copy import deepcopy from pydicom.dataset import Dataset @@ -184,7 +185,7 @@ def resolve_reference(self, sop_instance_uid: str) -> Tuple[str, str, str]: ) from e @classmethod - def from_dataset(cls, dataset: Dataset) -> 'KeyObjectSelectionDocument': + def from_dataset(cls, dataset: Dataset) -> Self: """Construct object from an existing dataset. Parameters @@ -228,4 +229,4 @@ def from_dataset(cls, dataset: Dataset) -> 'KeyObjectSelectionDocument': sop_instance_uid ) - return cast(KeyObjectSelectionDocument, sop_instance) + return cast(cls, sop_instance) diff --git a/src/highdicom/pixel_transforms.py b/src/highdicom/pixel_transforms.py new file mode 100644 index 00000000..f30819eb --- /dev/null +++ b/src/highdicom/pixel_transforms.py @@ -0,0 +1,613 @@ +"""Functional interface for pixel transformations.""" +from enum import Enum +from typing import Optional, Union, Tuple + +import numpy as np + +from pydicom import Dataset +from pydicom.sr.coding import Code +from pydicom.sequence import Sequence as pydicom_sequence +from pydicom.multival import MultiValue +from highdicom.enum import VOILUTFunctionValues +from highdicom.sr.coding import CodedConcept + + +def _parse_palette_color_lut_attributes(dataset: Dataset) -> Tuple[ + bool, + Tuple[int, int, int], + Tuple[bytes, bytes, bytes], +]: + """Extract information about palette color lookup table from a dataset. + + Performs various checks that the information retrieved is valid. + + Parameters + ---------- + dataset: pydicom.Dataset + Dataset containing Palette Color LUT information. Note that any + number of other attributes may be included and will be ignored (for + example allowing an entire image with Palette Color LUT information + at the top level to be passed). + + Returns + ------- + is_segmented: bool + True if the LUT is segmented. False otherwise. + descriptor: Tuple[int, int, int] + Lookup table descriptor containing in this order the number of + entries, first mapped value, and bits per entry. These values are + shared between the three color LUTs. + lut_data: Tuple[bytes, bytes, bytes] + Raw bytes data for the red, green and blue LUTs. + + """ + is_segmented = 'SegmentedRedPaletteColorLookupTableData' in dataset + + if not is_segmented: + if 'RedPaletteColorLookupTableData' not in dataset: + raise AttributeError( + 'Dataset does not contain palette color lookup table ' + 'attributes.' + ) + + descriptor = dataset.RedPaletteColorLookupTableDescriptor + if len(descriptor) != 3: + raise RuntimeError( + 'Invalid Palette Color LUT Descriptor' + ) + number_of_entries, _, bits_per_entry = descriptor + + if number_of_entries == 0: + number_of_entries = 2 ** 16 + + strip_final_byte = False + if bits_per_entry == 8: + expected_num_bytes = number_of_entries + if expected_num_bytes % 2 == 1: + # Account for padding byte + expected_num_bytes += 1 + strip_final_byte = True + elif bits_per_entry == 16: + expected_num_bytes = number_of_entries * 2 + else: + raise RuntimeError( + 'Invalid number of bits per entry found in Palette Color ' + 'LUT Descriptor.' + ) + + lut_data = [] + for color in ['Red', 'Green', 'Blue']: + desc_kw = f'{color}PaletteColorLookupTableDescriptor' + if desc_kw not in dataset: + raise AttributeError( + f"Dataset has no attribute '{desc_kw}'." + ) + + color_descriptor = getattr(dataset, desc_kw) + if color_descriptor != descriptor: + # Descriptors must match between all three colors + raise RuntimeError( + 'Dataset has no mismatched palette color LUT ' + 'descriptors.' + ) + + segmented_kw = f'Segmented{color}PaletteColorLookupTableData' + standard_kw = f'{color}PaletteColorLookupTableData' + if is_segmented: + data_kw = segmented_kw + wrong_data_kw = standard_kw + else: + data_kw = standard_kw + wrong_data_kw = segmented_kw + + if data_kw not in dataset: + raise AttributeError( + f"Dataset has no attribute '{desc_kw}'." + ) + if wrong_data_kw in dataset: + raise AttributeError( + "Mismatch of segmented LUT and standard LUT found." + ) + + lut_bytes = getattr(dataset, data_kw) + if len(lut_bytes) != expected_num_bytes: + raise RuntimeError( + "LUT data has incorrect length" + ) + if strip_final_byte: + lut_bytes = lut_bytes[:-1] + lut_data.append(lut_bytes) + + return ( + is_segmented, + tuple(descriptor), + tuple(lut_data) + ) + + +def _get_combined_palette_color_lut( + dataset: Dataset, +) -> Tuple[int, np.ndarray]: + """Get a LUT array with three color channels from a dataset. + + Parameters + ---------- + dataset: pydicom.Dataset + Dataset containing Palette Color LUT information. Note that any number + of other attributes may be included and will be ignored (for example + allowing an entire image dataset with Palette Color LUT information at + the top level to be passed). + + Returns + ------- + first_mapped_value: int + The first input value included in the LUT. + lut_data: numpy.ndarray + An NumPy array of shape (number_of_entries, 3) containing the red, + green and blue lut data stacked along the final dimension of the + array. Data type with be 8 or 16 bit unsigned integer depending on + the number of bits per entry in the LUT. + + """ + ( + is_segmented, + (number_of_entries, first_mapped_value, bits_per_entry), + lut_data, + ) = _parse_palette_color_lut_attributes(dataset) + + if is_segmented: + raise RuntimeError( + 'Combined LUT data is not supported for segmented LUTs' + ) + + if bits_per_entry == 8: + dtype = np.uint8 + else: + dtype = np.uint16 + + combined_array = np.stack( + [np.frombuffer(buf, dtype=dtype) for buf in lut_data], + axis=-1 + ) + + # Account for padding byte + if combined_array.shape[0] == number_of_entries + 1: + combined_array = combined_array[:-1] + + return first_mapped_value, combined_array + + +def _check_rescale_dtype( + input_dtype: np.dtype, + output_dtype: np.dtype, + intercept: float, + slope: float, + input_range: Optional[Tuple[float, float]] = None, +) -> None: + """Checks whether it is appropriate to apply a given rescale to an array + with a given dtype. + + Raises an error if not compatible. + + Parameters + ---------- + input_dtype: numpy.dtype + Datatype of the input array of the rescale operation. + output_dtype: numpy.dtype + Datatype of the output array of the rescale operation. + intercept: float + Intercept of the rescale operation. + slope: float + Slope of the rescale operation. + input_range: Optional[Tuple[float, float]], optional + Known limit of values for the input array. This could for example be + deduced by the number of bits stored in an image. If not specified, the + full range of values of the ``input_dtype`` is assumed. + + """ + slope_np = np.float64(slope) + intercept_np = np.float64(intercept) + + # Check dtype is suitable + if output_dtype.kind not in ('u', 'i', 'f'): + raise ValueError( + f'Data type "{output_dtype}" is not suitable.' + ) + if output_dtype.kind in ('u', 'i'): + if not (slope.is_integer() and intercept.is_integer()): + raise ValueError( + 'An integer data type cannot be used if the slope ' + 'or intercept is a non-integer value.' + ) + if input_dtype.kind not in ('u', 'i'): + raise ValueError( + 'An integer data type cannot be used if the input ' + 'array is floating point.' + ) + + if output_dtype.kind == 'u' and intercept < 0.0: + raise ValueError( + 'An unsigned integer data type cannot be used if the ' + 'intercept is negative.' + ) + + if input_range is not None: + input_min, input_max = input_range + else: + input_min = np.iinfo(input_dtype).min + input_max = np.iinfo(input_dtype).max + + output_max = input_max * slope_np + intercept_np + output_min = input_min * slope_np + intercept_np + output_type_max = np.iinfo(output_dtype).max + output_type_min = np.iinfo(output_dtype).min + + if output_max > output_type_max or output_min < output_type_min: + raise ValueError( + f'Datatype {output_dtype} does not have capacity for values ' + f'with slope {slope:.2f} and intercept {intercept:.2f}.' + ) + + +def _select_voi_window_center_width( + dataset: Dataset, + selector: int | str, +) -> tuple[float, float] | None: + """Get a specific window center and width from a VOI LUT dataset. + + Parameters + ---------- + dataset: pydicom.Dataset + Dataset to search for window center and width information. This must + contain at a minimum the 'WindowCenter' and 'WindowWidth' attributes. + Note that the dataset is not search recursively, only window + information at the top level of the dataset is searched. + selector: int | str + Specification of the window to select. May either be an int or a str. + If an int, it is interpretted as a (zero-based) index of the list of + windows to apply. A negative integer may be used to index from the end + of the list following standard Python indexing convention. If a str, + the string that will be used to match the Window Center Width + Explanation to choose from multiple voi windows. Note that such + explanations are optional according to the standard and therefore may + not be present. + + Returns + ------- + tuple[float, float] | None: + If the specified window is found in the dataset, it is returned as a + tuple of (window center, window width). If it is not found, ``None`` is + returned. + + """ + voi_center = dataset.WindowCenter + voi_width = dataset.WindowWidth + + if isinstance(selector, str): + explanations = dataset.get( + 'WindowCenterWidthExplanation' + ) + if explanations is None: + return None + + if isinstance(explanations, str): + explanations = [explanations] + + try: + selector = explanations.index(selector) + except ValueError: + return None + + if isinstance(voi_width, MultiValue): + try: + voi_width = voi_width[selector] + except IndexError: + return None + elif selector not in (0, -1): + return None + + if isinstance(voi_center, MultiValue): + try: + voi_center = voi_center[selector] + except IndexError: + return None + elif selector not in (0, -1): + return None + + return float(voi_center), float(voi_width) + + +def _select_voi_lut( + dataset: Dataset, + selector: int | str +) -> Dataset | None: + """Get a specific VOI LUT dataset from dataset. + + Parameters + ---------- + dataset: pydicom.Dataset + Dataset to search for VOI LUT information. This must contain the + 'VOILUTSequence'. Note that the dataset is not search recursively, only + information at the top level of the dataset is searched. + selector: int | str + Specification of the LUT to select. May either be an int or a str. If + an int, it is interpretted as a (zero-based) index of the sequence of + LUTs to apply. A negative integer may be used to index from the end of + the list following standard Python indexing convention. If a str, the + string that will be used to match the LUT Explanation to choose from + multiple voi LUTs. Note that such explanations are optional according + to the standard and therefore may not be present. + + Returns + ------- + pydicom.Dataset | None: + If the LUT is found in the dataset, it is returned as a + ``pydicom.Dataset``. If it is not found, ``None`` is returned. + + """ + if isinstance(selector, str): + explanations = [ + ds.get('LUTExplanation') for ds in dataset.VOILUTSequence + ] + + try: + selector = explanations.index(selector) + except ValueError: + return None + + try: + voi_lut_ds = dataset.VOILUTSequence[selector] + except IndexError: + return None + + return voi_lut_ds + + +def apply_voi_window( + array: np.ndarray, + window_center: float, + window_width: float, + voi_lut_function: Union[ + str, + VOILUTFunctionValues + ] = VOILUTFunctionValues.LINEAR, + output_range: Tuple[float, float] = (0.0, 1.0), + dtype: Union[type, str, np.dtype, None] = np.float64, + invert: bool = False, +) -> np.ndarray: + """DICOM VOI windowing function. + + This function applies a "value-of-interest" window, defined by a window + center and width, to a pixel array. Values within the window are rescaled + to the output window, while values outside the range are clipped to the + upper or lower value of the output range. + + Parameters + ---------- + apply: numpy.ndarray + Pixel array to which the transformation should be applied. Can be + of any shape but must have an integer datatype if the + transformation uses a LUT. + window_center: float + Center of the window. + window_width: float + Width of the window. + voi_lut_function: Union[str, highdicom.VOILUTFunctionValues], optional + Type of VOI LUT function. + output_range: Tuple[float, float], optional + Range of output values to which the VOI range is mapped. + dtype: Union[type, str, numpy.dtype, None], optional + Data type the output array. Should be a floating point data type. + invert: bool, optional + Invert the returned array such that the lowest original value in + the LUT or input window is mapped to the upper limit and the + highest original value is mapped to the lower limit. This may be + used to efficiently combined a VOI LUT transformation with a + presentation transform that inverts the range. + + Returns + ------- + numpy.ndarray: + Array with the VOI window function applied. + + """ + voi_lut_function = VOILUTFunctionValues(voi_lut_function) + output_min, output_max = output_range + if output_min >= output_max: + raise ValueError( + "Second value of 'output_range' must be higher than the first." + ) + + if dtype is None: + dtype = np.dtype(np.float64) + else: + dtype = np.dtype(dtype) + if dtype.kind != 'f': + raise ValueError( + 'dtype must be a floating point data type.' + ) + + window_width = dtype.type(window_width) + window_center = dtype.type(window_center) + if array.dtype != dtype: + array = array.astype(dtype) + + if voi_lut_function in ( + VOILUTFunctionValues.LINEAR, + VOILUTFunctionValues.LINEAR_EXACT, + ): + output_scale = ( + output_max - output_min + ) + if voi_lut_function == VOILUTFunctionValues.LINEAR: + # LINEAR uses the range + # from c - 0.5w to c + 0.5w - 1 + scale_factor = ( + output_scale / (window_width - 1) + ) + else: + # LINEAR_EXACT uses the full range + # from c - 0.5w to c + 0.5w + scale_factor = output_scale / window_width + + window_min = window_center - window_width / 2.0 + if invert: + array = ( + (window_min - array) * scale_factor + + output_max + ) + else: + array = ( + (array - window_min) * scale_factor + + output_min + ) + + array = np.clip(array, output_min, output_max) + + elif voi_lut_function == VOILUTFunctionValues.SIGMOID: + if invert: + offset_array = window_center - array + else: + offset_array = array - window_center + exp_term = np.exp( + -4.0 * offset_array / + window_width + ) + array = ( + (output_max - output_min) / + (1.0 + exp_term) + ) + output_min + + return array + + +def apply_lut( + array: np.ndarray, + lut_data: np.ndarray, + first_mapped_value: int, + dtype: Union[type, str, np.dtype, None] = None, + clip: bool = True, +) -> np.ndarray: + """Apply a LUT to a pixel array. + + Parameters + ---------- + apply: numpy.ndarray + Pixel array to which the LUT should be applied. Can be of any shape + but must have an integer datatype. + lut_data: numpy.ndarray + Lookup table data. The items in the LUT will be indexed down axis 0, + but additional dimensions may optionally be included. + first_mapped_value: int + Input value that should be mapped to the first item in the LUT. + dtype: Union[type, str, numpy.dtype, None], optional + Datatype of the output array. If ``None``, the output data type will + match that of the input ``lut_data``. Only safe casts are permitted. + clip: bool + If True, values in ``array`` outside the range of the LUT (i.e. those + below ``first_mapped_value`` or those above ``first_mapped_value + + len(lut_data) - 1``) are clipped to lie within the range before + applying the LUT, meaning that after the LUT is applied they will take + the first or last value in the LUT. If False, values outside the range + of the LUT will raise an error. + + Returns + ------- + numpy.ndarray + Array with LUT applied. + + """ + if array.dtype.kind not in ('i', 'u'): + raise ValueError( + "Array must have an integer datatype." + ) + + if dtype is None: + dtype = lut_data.dtype + dtype = np.dtype(dtype) + + # Check dtype is suitable + if dtype.kind not in ('u', 'i', 'f'): + raise ValueError( + f'Data type "{dtype}" is not suitable.' + ) + + if dtype != lut_data.dtype: + # This is probably more efficient on average than applying the LUT and + # then casting(?) + lut_data = lut_data.astype(dtype, casting='safe') + + last_mapped_value = first_mapped_value + len(lut_data) - 1 + + if clip: + # Clip because values outside the range should be mapped to the + # first/last value + array = np.clip(array, first_mapped_value, last_mapped_value) + else: + if array.min() < first_mapped_value or array.max() > last_mapped_value: + raise ValueError( + 'Array contains values outside the range of the LUT.' + ) + + if first_mapped_value != 0: + # This is a common case and the subtraction may be slow, so avoid it if + # not needed + array = array - first_mapped_value + + return lut_data[array, ...] + + +def _select_real_world_value_map( + sequence: pydicom_sequence, + selector: int | str | CodedConcept | Code, +) -> Dataset | None: + """Select a real world value map from a sequence. + + Parameters + ---------- + sequence: pydicom.sequence.Sequence + Sequence representing a Real World Value Mapping Sequence. + selector: int | str | highdicom.sr.coding.CodedConcept | pydicom.sr.coding.Code + Selector specifying an item in the sequence. If an integer, it is used + as a index to the sequence in the usual way. If a string, the + ``"LUTLabel"`` attribute of the items will be searched for a value that + exactly matches the selector. If a code, the + ``"MeasurementUnitsCodeSequence"`` will be searched for a value that + matches the selector. + + Returns + ------- + pydicom.Dataset | None: + Either an item of the input sequence that matches the selector, or + ``None`` if no such item is found. + + """ + if isinstance(selector, int): + try: + item = sequence[selector] + except IndexError: + return None + + return item + + elif isinstance(selector, str): + labels = [item.LUTLabel for item in sequence] + + try: + index = labels.index(selector) + except ValueError: + return None + + return sequence[index] + + elif isinstance(selector, (CodedConcept, Code)): + units = [ + CodedConcept.from_dataset(item.MeasurementUnitsCodeSequence[0]) + for item in sequence + ] + try: + index = units.index(selector) + except ValueError: + return None + + return sequence[index] diff --git a/src/highdicom/pm/content.py b/src/highdicom/pm/content.py index 2d50eee1..bf660295 100644 --- a/src/highdicom/pm/content.py +++ b/src/highdicom/pm/content.py @@ -5,9 +5,11 @@ from pydicom.dataset import Dataset from pydicom.sequence import Sequence as DataElementSequence from pydicom.sr.coding import Code +from highdicom._module_utils import is_multiframe_image from highdicom.content import PlanePositionSequence from highdicom.enum import CoordinateSystemNames +from highdicom.pixel_transforms import apply_lut from highdicom.sr.coding import CodedConcept from highdicom.sr.value_types import CodeContentItem from highdicom.uid import UID @@ -151,6 +153,90 @@ def __init__( ) self.QuantityDefinitionSequence = [quantity_item] + def has_lut(self) -> bool: + """Determine whether the mapping contains a non-linear lookup table. + + Returns + ------- + bool: + True if the mapping contains a look-up table. False otherwise, when + the mapping is represented by a slope and intercept defining a + linear relationship. + + """ + return 'RealWorldValueLUTData' in self + + @property + def lut_data(self) -> Optional[np.ndarray]: + """Union[numpy.ndarray, None] LUT data, if present.""" + if self.has_lut(): + return np.array(self.RealWorldValueLUTData) + return None + + @property + def value_range(self) -> Tuple[float, float]: + """Tuple[float, float]: Range of valid input values.""" + if 'DoubleFloatRealWorldValueFirstValueMapped' in self: + return ( + self.DoubleFloatRealWorldValueFirstValueMapped, + self.DoubleFloatRealWorldValueLastValueMapped, + ) + return ( + float(self.RealWorldValueFirstValueMapped), + float(self.RealWorldValueLastValueMapped), + ) + + def apply( + self, + array: np.ndarray, + ) -> np.ndarray: + """Apply the mapping to a pixel array. + + Parameters + ---------- + apply: numpy.ndarray + Pixel array to which the transform should be applied. Can be of any + shape but must have an integer datatype if the mapping uses a LUT. + + Returns + ------- + numpy.ndarray + Array with LUT applied, will have data type ``numpy.float64``. + + """ + lut_data = self.lut_data + if lut_data is not None: + if array.dtype.kind not in ('u', 'i'): + raise ValueError( + 'Array must have an integer data type if the mapping ' + 'contains a LUT.' + ) + first = self.RealWorldValueFirstValueMapped + last = self.RealWorldValueLastValueMapped + if len(lut_data) != last + 1 - first: + raise RuntimeError( + "LUT data is stored with the incorrect number of elements." + ) + + return apply_lut( + array=array, + lut_data=lut_data, + first_mapped_value=first, + clip=False, # values outside the range are undefined + ) + else: + slope = self.RealWorldValueSlope + intercept = self.RealWorldValueIntercept + + first, last = self.value_range + + if array.min() < first or array.max() > last: + raise ValueError( + 'Array contains value outside the valid range.' + ) + + return array * slope + intercept + class DimensionIndexSequence(DataElementSequence): @@ -281,7 +367,7 @@ def get_plane_positions_of_image( Plane position of each frame in the image """ - is_multiframe = hasattr(image, 'NumberOfFrames') + is_multiframe = is_multiframe_image(image) if not is_multiframe: raise ValueError('Argument "image" must be a multi-frame image.') @@ -322,7 +408,7 @@ def get_plane_positions_of_series( Plane position of each frame in the image """ - is_multiframe = any([hasattr(img, 'NumberOfFrames') for img in images]) + is_multiframe = any([is_multiframe_image(img) for img in images]) if is_multiframe: raise ValueError( 'Argument "images" must be a series of single-frame images.' diff --git a/src/highdicom/pm/sop.py b/src/highdicom/pm/sop.py index 79424892..25f8e6b5 100644 --- a/src/highdicom/pm/sop.py +++ b/src/highdicom/pm/sop.py @@ -17,6 +17,7 @@ from highdicom.pm.content import DimensionIndexSequence, RealWorldValueMapping from highdicom.pm.enum import DerivedPixelContrastValues, ImageFlavorValues from highdicom.valuerep import check_person_name, _check_code_string +from highdicom._module_utils import is_multiframe_image from pydicom import Dataset from pydicom.uid import ( UID, @@ -271,7 +272,7 @@ def __init__( ) src_img = self._source_images[0] - is_multiframe = hasattr(src_img, 'NumberOfFrames') + is_multiframe = is_multiframe_image(src_img) # TODO: Revisit, may be overly restrictive # Check Source Image Sequence attribute in General Reference module if is_multiframe: @@ -816,7 +817,7 @@ def _get_pixel_data_type_and_attr( self._pixel_data_type_map[_PixelDataType.USHORT], ) raise ValueError( - 'Unsupported data type for pixel data.' + 'Unsupported data type for pixel data. ' 'Supported are 8-bit or 16-bit unsigned integer types as well as ' '32-bit (single-precision) or 64-bit (double-precision) ' 'floating-point types.' diff --git a/src/highdicom/pr/content.py b/src/highdicom/pr/content.py index 44d535d9..78129abd 100644 --- a/src/highdicom/pr/content.py +++ b/src/highdicom/pr/content.py @@ -41,6 +41,8 @@ _check_long_string, _check_short_text ) +from highdicom._module_utils import is_multiframe_image + logger = logging.getLogger(__name__) @@ -553,7 +555,7 @@ def __init__( ) self.GraphicLayer = graphic_layer.GraphicLayer - is_multiframe = hasattr(referenced_images[0], 'NumberOfFrames') + is_multiframe = is_multiframe_image(referenced_images[0]) if is_multiframe and len(referenced_images) > 1: raise ValueError( 'If referenced images are multi-frame, only a single image ' @@ -1087,7 +1089,7 @@ def _get_modality_lut_transformation( """ # Multframe images - if any(hasattr(im, 'NumberOfFrames') for im in referenced_images): + if any(is_multiframe_image(im) for im in referenced_images): im = referenced_images[0] if len(referenced_images) > 1 and not is_tiled_image(im): raise ValueError( @@ -1201,7 +1203,7 @@ def _get_modality_lut_transformation( if intercept is None: rescale_type = None else: - rescale_type = RescaleTypeValues.HU.value + rescale_type = RescaleTypeValues.US.value if intercept is None: return None @@ -1277,10 +1279,7 @@ def _add_softcopy_voi_lut_attributes( 'included in "referenced_images".' ) ref_im = ref_images_lut[uids] - is_multiframe = hasattr( - ref_im, - 'NumberOfFrames', - ) + is_multiframe = is_multiframe_image(ref_im) if uids in prev_ref_frames and not is_multiframe: raise ValueError( f'Instance with SOP Instance UID {uids[1]} ' @@ -1333,7 +1332,7 @@ def _add_softcopy_voi_lut_attributes( dataset.SoftcopyVOILUTSequence = voi_lut_transformations -def _get_softcopy_voi_lut_transformations( +def _get_voi_lut_transformations( referenced_images: Sequence[Dataset] ) -> Sequence[SoftcopyVOILUTTransformation]: """Get Softcopy VOI LUT Transformation from referenced images. @@ -1358,7 +1357,7 @@ def _get_softcopy_voi_lut_transformations( """ transformations = [] - if any(hasattr(im, 'NumberOfFrames') for im in referenced_images): + if any(is_multiframe_image(im) for im in referenced_images): if len(referenced_images) > 1: raise ValueError( "If multiple images are passed and any of them are multiframe, " @@ -1829,7 +1828,7 @@ def __init__( voi_lut_transformations=voi_lut_transformations ) else: - voi_lut_transformations = _get_softcopy_voi_lut_transformations( + voi_lut_transformations = _get_voi_lut_transformations( referenced_images ) if len(voi_lut_transformations) > 0: diff --git a/src/highdicom/pr/sop.py b/src/highdicom/pr/sop.py index 85086ed8..885eb7fa 100644 --- a/src/highdicom/pr/sop.py +++ b/src/highdicom/pr/sop.py @@ -35,7 +35,7 @@ _add_softcopy_presentation_lut_attributes, _add_softcopy_voi_lut_attributes, _get_modality_lut_transformation, - _get_softcopy_voi_lut_transformations, + _get_voi_lut_transformations, _get_icc_profile, AdvancedBlending, BlendingDisplay, @@ -292,7 +292,7 @@ def __init__( voi_lut_transformations=voi_lut_transformations ) else: - voi_lut_transformations = _get_softcopy_voi_lut_transformations( + voi_lut_transformations = _get_voi_lut_transformations( referenced_images ) if len(voi_lut_transformations) > 0: @@ -564,7 +564,7 @@ def __init__( voi_lut_transformations=voi_lut_transformations ) else: - voi_lut_transformations = _get_softcopy_voi_lut_transformations( + voi_lut_transformations = _get_voi_lut_transformations( referenced_images ) if len(voi_lut_transformations) > 0: diff --git a/src/highdicom/sc/sop.py b/src/highdicom/sc/sop.py index 0646d4ae..9dc18bb9 100644 --- a/src/highdicom/sc/sop.py +++ b/src/highdicom/sc/sop.py @@ -3,6 +3,7 @@ import logging import datetime from typing import Any, List, Optional, Sequence, Tuple, Union +from typing_extensions import Self import numpy as np from pydicom.uid import SecondaryCaptureImageStorage @@ -452,7 +453,7 @@ def from_ref_dataset( ] = None, transfer_syntax_uid: str = ImplicitVRLittleEndian, **kwargs: Any - ) -> 'SCImage': + ) -> Self: """Constructor that copies patient and study from an existing dataset. This provides a more concise way to construct an SCImage when an diff --git a/src/highdicom/seg/content.py b/src/highdicom/seg/content.py index 3b3a216b..40fc4525 100644 --- a/src/highdicom/seg/content.py +++ b/src/highdicom/seg/content.py @@ -1,6 +1,7 @@ """Content that is specific to Segmentation IODs.""" from copy import deepcopy from typing import cast, List, Optional, Sequence, Tuple, Union +from typing_extensions import Self import numpy as np from pydicom.datadict import keyword_for_tag, tag_for_keyword @@ -13,13 +14,24 @@ AlgorithmIdentificationSequence, PlanePositionSequence, ) -from highdicom.enum import CoordinateSystemNames +from highdicom.enum import ( + AxisHandedness, + CoordinateSystemNames, + PixelIndexDirections, +) from highdicom.seg.enum import SegmentAlgorithmTypeValues -from highdicom.spatial import map_pixel_into_coordinate_system +from highdicom.spatial import ( + _get_slice_distances, + get_normal_vector, + map_pixel_into_coordinate_system, +) from highdicom.sr.coding import CodedConcept from highdicom.uid import UID from highdicom.utils import compute_plane_position_slide_per_frame -from highdicom._module_utils import check_required_attributes +from highdicom._module_utils import ( + check_required_attributes, + is_multiframe_image, +) class SegmentDescription(Dataset): @@ -93,8 +105,10 @@ def __init__( """ # noqa: E501 super().__init__() - if segment_number < 1: - raise ValueError("Segment number must be a positive integer") + if segment_number < 1 or segment_number > 65535: + raise ValueError( + "Segment number must be a positive integer below 65536." + ) self.SegmentNumber = segment_number self.SegmentLabel = segment_label self.SegmentedPropertyCategoryCodeSequence = [ @@ -155,7 +169,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True - ) -> 'SegmentDescription': + ) -> Self: """Construct instance from an existing dataset. Parameters @@ -217,7 +231,7 @@ def from_dataset( CodedConcept.from_dataset(ds, copy=False) for ds in desc.PrimaryAnatomicStructureSequence ] - return cast(SegmentDescription, desc) + return cast(cls, desc) @property def segment_number(self) -> int: @@ -472,7 +486,7 @@ def get_plane_positions_of_image( Plane position of each frame in the image """ - is_multiframe = hasattr(image, 'NumberOfFrames') + is_multiframe = is_multiframe_image(image) if not is_multiframe: raise ValueError('Argument "image" must be a multi-frame image.') @@ -517,7 +531,7 @@ def get_plane_positions_of_series( Plane position of each frame in the image """ - is_multiframe = any([hasattr(img, 'NumberOfFrames') for img in images]) + is_multiframe = any([is_multiframe_image(img) for img in images]) if is_multiframe: raise ValueError( 'Argument "images" must be a series of single-frame images.' @@ -603,7 +617,16 @@ def get_index_position(self, pointer: str) -> int: def get_index_values( self, - plane_positions: Sequence[PlanePositionSequence] + plane_positions: Sequence[PlanePositionSequence], + image_orientation: Optional[Sequence[float]] = None, + index_convention: Union[ + str, + Sequence[Union[PixelIndexDirections, str]] + ] = ( + PixelIndexDirections.R, + PixelIndexDirections.D, + ), + handedness: Union[AxisHandedness, str] = AxisHandedness.RIGHT_HANDED, ) -> Tuple[np.ndarray, np.ndarray]: """Get values of indexed attributes that specify position of planes. @@ -611,7 +634,38 @@ def get_index_values( ---------- plane_positions: Sequence[highdicom.PlanePositionSequence] Plane position of frames in a multi-frame image or in a series of - single-frame images + single-frame images. + image_orientation: Union[Sequence[float], None], optional + An image orientation to use to order frames within a 3D coordinate + system. By default (if ``image_orientation`` is ``None``), the + plane positions are ordered using their raw numerical values and + not along any particular spatial vector. If ``image_orientation`` + is provided, planes are ordered along the positive direction of the + vector normal to the specified. Should be a sequence of 6 floats. + This is only valid when plane position inputs contain only the + ImagePositionPatient. + index_convention: Sequence[Union[highdicom.enum.PixelIndexDirections, str]], optional + Convention used to determine how to order frames if + ``image_orientation`` is specified. Should be a sequence of two + :class:`highdicom.enum.PixelIndexDirections` or their string + representations, giving in order, the indexing conventions used for + specifying pixel indices. For example ``('R', 'D')`` means that the + first pixel index indexes the columns from left to right, and the + second pixel index indexes the rows from top to bottom (this is the + convention typically used within DICOM). As another example ``('D', + 'R')`` would switch the order of the indices to give the convention + typically used within NumPy. + + Alternatively, a single shorthand string may be passed that combines + the string representations of the two directions. So for example, + passing ``'RD'`` is equivalent to passing ``('R', 'D')``. + + This is used in combination with the ``handedness`` to determine + the positive direction used to order frames. + handedness: Union[highdicom.enum.AxisHandedness, str], optional + Choose the frame order in order such that the frame axis creates a + coordinate system with this handedness in the when combined with + the within-frame convention given by ``index_convention``. Returns ------- @@ -632,7 +686,7 @@ def get_index_values( reference, and excludes values of the Referenced Segment Number attribute. - """ + """ # noqa: E501 if self._coordinate_system is None: raise RuntimeError( 'Cannot calculate index values for multiple plane ' @@ -662,21 +716,41 @@ def get_index_values( for p in plane_positions ]) - # Build an array that can be used to sort planes according to the - # Dimension Index Value based on the order of the items in the - # Dimension Index Sequence. - _, plane_sort_indices = np.unique( - plane_position_values, - axis=0, - return_index=True - ) + if image_orientation is not None: + if not hasattr(plane_positions[0][0], 'ImagePositionPatient'): + raise ValueError( + 'Provided "image_orientation" is only valid when ' + 'plane_positions contain the ImagePositionPatient.' + ) + normal_vector = get_normal_vector( + image_orientation, + index_convention=index_convention, + handedness=handedness, + ) + origin_distances = _get_slice_distances( + plane_position_values[:, 0, :], + normal_vector, + ) + _, plane_sort_indices = np.unique( + origin_distances, + return_index=True, + ) + else: + # Build an array that can be used to sort planes according to the + # Dimension Index Value based on the order of the items in the + # Dimension Index Sequence. + _, plane_sort_indices = np.unique( + plane_position_values, + axis=0, + return_index=True + ) if len(plane_sort_indices) != len(plane_positions): raise ValueError( - "Input image/frame positions are not unique according to the " - "Dimension Index Pointers. The generated segmentation would be " - "ambiguous. Ensure that source images/frames have distinct " - "locations." + 'Input image/frame positions are not unique according to the ' + 'Dimension Index Pointers. The generated segmentation would be ' + 'ambiguous. Ensure that source images/frames have distinct ' + 'locations.' ) return (plane_position_values, plane_sort_indices) diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index 2a300269..acc59d97 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -1,36 +1,31 @@ """Module for SOP classes of the SEG modality.""" import logging -from collections import Counter, defaultdict +from collections import defaultdict from concurrent.futures import Executor, Future, ProcessPoolExecutor -from contextlib import contextmanager from copy import deepcopy from itertools import chain from os import PathLike import pkgutil -import sqlite3 from typing import ( Any, BinaryIO, Dict, - Generator, - Iterable, Iterator, List, Optional, Sequence, - Set, Tuple, Union, cast, ) +from typing_extensions import Self import warnings import numpy as np from pydicom.dataelem import DataElement from pydicom.dataset import Dataset -from pydicom.datadict import get_entry, keyword_for_tag, tag_for_keyword +from pydicom.datadict import keyword_for_tag, tag_for_keyword from pydicom.encaps import encapsulate -from pydicom.multival import MultiValue from pydicom.pixels.utils import pack_bits from pydicom.tag import BaseTag, Tag from pydicom.uid import ( @@ -48,10 +43,12 @@ from highdicom._module_utils import ( ModuleUsageValues, - get_module_usage, does_iod_have_pixel_data, + get_module_usage, + is_multiframe_image, ) -from highdicom.base import SOPClass, _check_little_endian +from highdicom.image import _Image +from highdicom.base import _check_little_endian from highdicom.color import CIELabColor from highdicom.content import ( ContentCreatorIdentificationCodeSequence, @@ -80,7 +77,6 @@ SegmentationFractionalTypeValues, SegmentationTypeValues, SegmentsOverlapValues, - SpatialLocationsPreservedValues, SegmentAlgorithmTypeValues, ) from highdicom.seg.utils import iter_segments @@ -88,9 +84,8 @@ ImageToReferenceTransformer, compute_tile_positions_per_frame, get_image_coordinate_system, + get_volume_positions, get_tile_array, - is_tiled_image, - iter_tiled_full_frame_data, ) from highdicom.sr.coding import CodedConcept from highdicom.valuerep import ( @@ -98,14 +93,17 @@ _check_code_string, _check_long_string, ) -from highdicom.uid import UID as hd_UID +from highdicom.volume import ( + ChannelIdentifier, + Volume, + RGB_COLOR_CHANNEL_IDENTIFIER, + VOLUME_INDEX_CONVENTION, +) logger = logging.getLogger(__name__) -_NO_FRAME_REF_VALUE = -1 - # These codes are needed many times in loops so we precompute them _DERIVATION_CODE = CodedConcept.from_code( codes.cid7203.SegmentationImageDerivation @@ -165,6 +163,9 @@ def _check_numpy_value_representation( elif dtype.kind in ('i', 'u'): if max_val > np.iinfo(dtype).max: raise_error = True + elif dtype.kind == 'b': + if max_val > 1: + raise_error = True if raise_error: raise ValueError( "The maximum output value of the segmentation array is " @@ -173,1006 +174,14 @@ def _check_numpy_value_representation( ) -class _SegDBManager: - - """Database manager for data associated with a segmentation image.""" - - # Dictionary mapping DCM VRs to appropriate SQLite types - _DCM_SQL_TYPE_MAP = { - 'CS': 'VARCHAR', - 'DS': 'REAL', - 'FD': 'REAL', - 'FL': 'REAL', - 'IS': 'INTEGER', - 'LO': 'TEXT', - 'LT': 'TEXT', - 'PN': 'TEXT', - 'SH': 'TEXT', - 'SL': 'INTEGER', - 'SS': 'INTEGER', - 'ST': 'TEXT', - 'UI': 'TEXT', - 'UL': 'INTEGER', - 'UR': 'TEXT', - 'US or SS': 'INTEGER', - 'US': 'INTEGER', - 'UT': 'TEXT', - } - - def __init__( - self, - number_of_frames: int, - referenced_uids: List[Tuple[str, str, str]], - segment_numbers: Optional[List[int]], - dim_indices: Dict[int, List[int]], - dim_values: Dict[int, List[Any]], - referenced_instances: Optional[List[str]], - referenced_frames: Optional[List[int]], - ): - """ - - Parameters - ---------- - number_of_frames: int - Number of frames in the segmentation image. - referenced_uids: List[Tuple[str, str, str]] - Triplet of UIDs for each image instance (Study Instance UID, - Series Instance UID, SOP Instance UID) that is referenced - in the segmentation image. - segment_numbers: Optional[List[int]] - Segment numbers for each frame in the segmentation image. None - in the case of LABELMAP segmentations. - dim_indices: Dict[int, List[int]] - Dictionary mapping the integer tag value of each dimension index - pointer (excluding SegmentNumber) to a list of dimension indices - for each frame in the segmentation image. - dim_values: Dict[int, List[Values]] - Dictionary mapping the integer tag value of each dimension index - pointer (excluding SegmentNumber) to a list of dimension values - for each frame in the segmentation image. - referenced_instances: Optional[List[str]] - SOP Instance UID of each referenced image instance for each frame - in the segmentation image. Should be omitted if there is not a - single referenced image instance per segmentation image frame. - referenced_frames: Optional[List[int]] - Number of the corresponding frame in the referenced image - instance for each frame in the segmentation image. Should be - omitted if there is not a single referenced image instance per - segmentation image frame. - - """ - self._db_con: sqlite3.Connection = sqlite3.connect(":memory:") - - self._create_ref_instance_table(referenced_uids) - - self._number_of_frames = number_of_frames - - # Construct the columns and values to put into a frame look-up table - # table within sqlite. There will be one row per frame in the - # segmentation instance - col_defs = [] # SQL column definitions - col_data = [] # lists of column data - - # Frame number column - col_defs.append('FrameNumber INTEGER PRIMARY KEY') - col_data.append(list(range(1, self._number_of_frames + 1))) - - # Segment number column - if segment_numbers is not None: - col_defs.append('SegmentNumber INTEGER NOT NULL') - col_data.append(segment_numbers) - - self._dim_ind_col_names = {} - for i, t in enumerate(dim_indices.keys()): - vr, vm_str, _, _, kw = get_entry(t) - if kw == '': - kw = f'UnknownDimensionIndex{i}' - ind_col_name = kw + '_DimensionIndexValues' - self._dim_ind_col_names[t] = ind_col_name - - # Add column for dimension index - col_defs.append(f'{ind_col_name} INTEGER NOT NULL') - col_data.append(dim_indices[t]) - - # Add column for dimension value - # For this to be possible, must have a fixed VM - # and a VR that we can map to a sqlite type - # Otherwise, we just omit the data from the db - try: - vm = int(vm_str) - except ValueError: - continue - try: - sql_type = self._DCM_SQL_TYPE_MAP[vr] - except KeyError: - continue - - if vm > 1: - for d in range(vm): - data = [el[d] for el in dim_values[t]] - col_defs.append(f'{kw}_{d} {sql_type} NOT NULL') - col_data.append(data) - else: - # Single column - col_defs.append(f'{kw} {sql_type} NOT NULL') - col_data.append(dim_values[t]) - - # Columns related to source frames, if they are usable for indexing - if (referenced_frames is None) != (referenced_instances is None): - raise TypeError( - "'referenced_frames' and 'referenced_instances' should be " - "provided together or not at all." - ) - if referenced_instances is not None: - col_defs.append('ReferencedFrameNumber INTEGER') - col_defs.append('ReferencedSOPInstanceUID VARCHAR NOT NULL') - col_defs.append( - 'FOREIGN KEY(ReferencedSOPInstanceUID) ' - 'REFERENCES InstanceUIDs(SOPInstanceUID)' - ) - col_data += [ - referenced_frames, - referenced_instances, - ] - - # Build LUT from columns - all_defs = ", ".join(col_defs) - cmd = f'CREATE TABLE FrameLUT({all_defs})' - placeholders = ', '.join(['?'] * len(col_data)) - with self._db_con: - self._db_con.execute(cmd) - self._db_con.executemany( - f'INSERT INTO FrameLUT VALUES({placeholders})', - zip(*col_data), - ) - - def _create_ref_instance_table( - self, - referenced_uids: List[Tuple[str, str, str]], - ) -> None: - """Create a table of referenced instances. - - The resulting table (called InstanceUIDs) contains Study, Series and - SOP instance UIDs for each instance referenced by the segmentation - image. - - Parameters - ---------- - referenced_uids: List[Tuple[str, str, str]] - List of UIDs for each instance referenced in the segmentation. - Each tuple should be in the format - (StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID). - - """ - with self._db_con: - self._db_con.execute( - """ - CREATE TABLE InstanceUIDs( - StudyInstanceUID VARCHAR NOT NULL, - SeriesInstanceUID VARCHAR NOT NULL, - SOPInstanceUID VARCHAR PRIMARY KEY - ) - """ - ) - self._db_con.executemany( - "INSERT INTO InstanceUIDs " - "(StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) " - "VALUES(?, ?, ?)", - referenced_uids, - ) - - def get_source_image_uids(self) -> List[Tuple[hd_UID, hd_UID, hd_UID]]: - """Get UIDs of source image instances referenced in the segmentation. - - Returns - ------- - List[Tuple[highdicom.UID, highdicom.UID, highdicom.UID]] - (Study Instance UID, Series Instance UID, SOP Instance UID) triplet - for every image instance referenced in the segmentation. - - """ - cur = self._db_con.cursor() - res = cur.execute( - 'SELECT StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID ' - 'FROM InstanceUIDs' - ) - - return [ - (hd_UID(a), hd_UID(b), hd_UID(c)) for a, b, c in res.fetchall() - ] - - def are_dimension_indices_unique( - self, - dimension_index_pointers: Sequence[Union[int, BaseTag]], - ) -> bool: - """Check if a list of index pointers uniquely identifies frames. - - For a given list of dimension index pointers, check whether every - combination of index values for these pointers identifies a unique - frame per segment in the segmentation image. This is a pre-requisite - for indexing using this list of dimension index pointers in the - :meth:`Segmentation.get_pixels_by_dimension_index_values()` method. - - Parameters - ---------- - dimension_index_pointers: Sequence[Union[int, pydicom.tag.BaseTag]] - Sequence of tags serving as dimension index pointers. - - Returns - ------- - bool - True if dimension indices are unique. - - """ - column_names = ['SegmentNumber'] - for ptr in dimension_index_pointers: - column_names.append(self._dim_ind_col_names[ptr]) - col_str = ", ".join(column_names) - cur = self._db_con.cursor() - n_unique_combos = cur.execute( - f"SELECT COUNT(*) FROM (SELECT 1 FROM FrameLUT GROUP BY {col_str})" - ).fetchone()[0] - return n_unique_combos == self._number_of_frames - - def are_referenced_sop_instances_unique(self) -> bool: - """Check if Referenced SOP Instance UIDs uniquely identify frames. - - This is a pre-requisite for requesting segmentation masks defined by - the SOP Instance UIDs of their source frames, such as using the - Segmentation.get_pixels_by_source_instance() method and - _SegDBManager.iterate_indices_by_source_instance() method. - - Returns - ------- - bool - True if the ReferencedSOPInstanceUID (in combination with the - segment number) uniquely identifies frames of the segmentation - image. - - """ - cur = self._db_con.cursor() - n_unique_combos = cur.execute( - 'SELECT COUNT(*) FROM ' - '(SELECT 1 FROM FrameLUT GROUP BY ReferencedSOPInstanceUID, ' - 'SegmentNumber)' - ).fetchone()[0] - return n_unique_combos == self._number_of_frames - - def are_referenced_frames_unique(self) -> bool: - """Check if Referenced Frame Numbers uniquely identify frames. - - Returns - ------- - bool - True if the ReferencedFrameNumber (in combination with the - segment number) uniquely identifies frames of the segmentation - image. - - """ - cur = self._db_con.cursor() - n_unique_combos = cur.execute( - 'SELECT COUNT(*) FROM ' - '(SELECT 1 FROM FrameLUT GROUP BY ReferencedFrameNumber, ' - 'SegmentNumber)' - ).fetchone()[0] - return n_unique_combos == self._number_of_frames - - def get_unique_sop_instance_uids(self) -> Set[str]: - """Get set of unique Referenced SOP Instance UIDs. - - Returns - ------- - Set[str] - Set of unique Referenced SOP Instance UIDs. - - """ - cur = self._db_con.cursor() - return { - r[0] for r in - cur.execute( - 'SELECT DISTINCT(SOPInstanceUID) from InstanceUIDs' - ) - } - - def get_max_frame_number(self) -> int: - """Get highest frame number of any referenced frame. - - Absent access to the referenced dataset itself, being less than this - value is a sufficient condition for the existence of a frame number - in the source image. - - Returns - ------- - int - Highest frame number referenced in the segmentation image. - - """ - cur = self._db_con.cursor() - return cur.execute( - 'SELECT MAX(ReferencedFrameNumber) FROM FrameLUT' - ).fetchone()[0] - - def get_unique_dim_index_values( - self, - dimension_index_pointers: Sequence[int], - ) -> Set[Tuple[int, ...]]: - """Get set of unique dimension index value combinations. - - Parameters - ---------- - dimension_index_pointers: Sequence[int] - List of dimension index pointers for which to find unique - combinations of values. - - Returns - ------- - Set[Tuple[int, ...]] - Set of unique dimension index value combinations for the given - input dimension index pointers. - - """ - cols = [self._dim_ind_col_names[p] for p in dimension_index_pointers] - cols_str = ', '.join(cols) - cur = self._db_con.cursor() - return { - r for r in - cur.execute( - f'SELECT DISTINCT {cols_str} FROM FrameLUT' - ) - } - - def is_indexable_as_total_pixel_matrix(self) -> bool: - """Whether the segmentation can be indexed as a total pixel matrix. - - Returns - ------- - bool: - True if the segmentation may be indexed using row and column - positions in the total pixel matrix. False otherwise. - - """ - row_pos_kw = tag_for_keyword('RowPositionInTotalImagePixelMatrix') - col_pos_kw = tag_for_keyword('ColumnPositionInTotalImagePixelMatrix') - return ( - row_pos_kw in self._dim_ind_col_names and - col_pos_kw in self._dim_ind_col_names - ) - - @contextmanager - def _generate_temp_table( - self, - table_name: str, - column_defs: Sequence[str], - column_data: Iterable[Sequence[Any]], - ) -> Generator[None, None, None]: - """Context manager that handles a temporary table. - - The temporary table is created with the specified information. Control - flow then returns to code within the "with" block. After the "with" - block has completed, the cleanup of the table is automatically handled. - - Parameters - ---------- - table_name: str - Name of the temporary table. - column_defs: Sequence[str] - SQL syntax strings defining each column in the temporary table, one - string per column. - column_data: Iterable[Sequence[Any]] - Column data to place into the table. - - Yields - ------ - None: - Yields control to the "with" block, with the temporary table - created. - - """ - defs_str = ', '.join(column_defs) - create_cmd = (f'CREATE TABLE {table_name}({defs_str})') - placeholders = ', '.join(['?'] * len(column_defs)) - - with self._db_con: - self._db_con.execute(create_cmd) - self._db_con.executemany( - f'INSERT INTO {table_name} VALUES({placeholders})', - column_data - ) - - # Return control flow to "with" block - yield - - # Clean up the table - cmd = (f'DROP TABLE {table_name}') - with self._db_con: - self._db_con.execute(cmd) - - @contextmanager - def _generate_temp_segment_table( - self, - segment_numbers: Sequence[int], - combine_segments: bool, - relabel: bool - ) -> Generator[None, None, None]: - """Context manager that handles a temporary table for segments. - - The temporary table is named "TemporarySegmentNumbers" with columns - OutputSegmentNumber and SegmentNumber that are populated with values - derived from the input. Control flow then returns to code within the - "with" block. After the "with" block has completed, the cleanup of - the table is automatically handled. - - Parameters - ---------- - segment_numbers: Sequence[int] - Segment numbers to include, in the order desired. - combine_segments: bool - Whether the segments will be combined into a label map. - relabel: bool - Whether the output segment numbers should be relabelled to 1-n - (True) or retain their values in the original segmentation object. - - Yields - ------ - None: - Yields control to the "with" block, with the temporary table - created. - - """ - if combine_segments: - if relabel: - # Output segment numbers are consecutive and start at 1 - data = enumerate(segment_numbers, 1) - else: - # Output segment numbers are the same as the input - # segment numbers - data = zip(segment_numbers, segment_numbers) - else: - # Output segment numbers are indices along the output - # array's segment dimension, so are consecutive starting at - # 0 - data = enumerate(segment_numbers) - - cmd = ( - 'CREATE TABLE TemporarySegmentNumbers(' - ' SegmentNumber INTEGER UNIQUE NOT NULL,' - ' OutputSegmentNumber INTEGER UNIQUE NOT NULL' - ')' - ) - - with self._db_con: - self._db_con.execute(cmd) - self._db_con.executemany( - 'INSERT INTO ' - 'TemporarySegmentNumbers(' - ' OutputSegmentNumber, SegmentNumber' - ')' - 'VALUES(?, ?)', - data - ) - - # Yield execution to "with" block - yield - - # Clean up table after user code executes - with self._db_con: - self._db_con.execute('DROP TABLE TemporarySegmentNumbers') - - @contextmanager - def iterate_indices_by_source_instance( - self, - source_sop_instance_uids: Sequence[str], - segment_numbers: Sequence[int], - combine_segments: bool = False, - relabel: bool = False, - ) -> Generator[ - Iterator[ - Tuple[ - Tuple[Union[slice, int], ...], - Tuple[Union[slice, int], ...], - int - ] - ], - None, - None, - ]: - """Iterate over segmentation frame indices for given source image - instances. - - This is intended for the case of a segmentation image that references - multiple single frame sources images (typically a series). In this - case, the user supplies a list of SOP Instance UIDs of the source - images of interest, and this method returns information about the - frames of the segmentation image relevant to these source images. - - This yields an iterator to the underlying database result that iterates - over information on the steps required to construct the requested - segmentation mask from the stored frames of the segmentation image. - - This method is intended to be used as a context manager that yields the - requested iterator. The iterator is only valid while the context - manager is active. - - Parameters - ---------- - source_sop_instance_uids: str - SOP Instance UID of the source instances for which segmentation - image frames are requested. - segment_numbers: Sequence[int] - Numbers of segments to include. - combine_segments: bool, optional - If True, produce indices to combine the different segments into a - single label map in which the value of a pixel represents its - segment. If False (the default), segments are binary and stacked - down the last dimension of the output array. - relabel: bool, optional - If True and ``combine_segments`` is ``True``, the output segment - numbers are relabelled into the range ``0`` to - ``len(segment_numbers)`` (inclusive) according to the position of - the original segment numbers in ``segment_numbers`` parameter. If - ``combine_segments`` is ``False``, this has no effect. - - Yields - ------ - Iterator[Tuple[Tuple[Union[slice, int], ...], Tuple[Union[slice, int], ...], int]]: - Indices required to construct the requested mask. Each - triplet denotes the (output indexer, segmentation indexer, - output segment number) representing a list of "instructions" to - create the requested output array by copying frames from the - segmentation dataset and inserting them into the output array with - a given segment value. Output indexer and segmentation indexer are - tuples that can be used to index the output and segmentations - numpy arrays directly. - - """ # noqa: E501 - # Run query to create the iterable of indices needed to construct the - # desired pixel array. The approach here is to create two temporary - # tables in the SQLite database, one for the desired source UIDs, and - # another for the desired segments, then use table joins with the - # referenced UIDs table and the frame LUT at the relevant rows, before - # clearing up the temporary tables. - - # Create temporary table of desired frame numbers - table_name = 'TemporarySOPInstanceUIDs' - column_defs = [ - 'OutputFrameIndex INTEGER UNIQUE NOT NULL', - 'SourceSOPInstanceUID VARCHAR UNIQUE NOT NULL' - ] - column_data = enumerate(source_sop_instance_uids) - - # Construct the query The ORDER BY is not logically necessary - # but seems to improve performance of the downstream numpy - # operations, presumably as it is more cache efficient - query = ( - 'SELECT ' - ' T.OutputFrameIndex,' - ' L.FrameNumber - 1,' - ' S.OutputSegmentNumber ' - 'FROM TemporarySOPInstanceUIDs T ' - 'INNER JOIN FrameLUT L' - ' ON T.SourceSOPInstanceUID = L.ReferencedSOPInstanceUID ' - 'INNER JOIN TemporarySegmentNumbers S' - ' ON L.SegmentNumber = S.SegmentNumber ' - 'ORDER BY T.OutputFrameIndex' - ) - - with self._generate_temp_table( - table_name=table_name, - column_defs=column_defs, - column_data=column_data, - ): - with self._generate_temp_segment_table( - segment_numbers=segment_numbers, - combine_segments=combine_segments, - relabel=relabel - ): - yield ( - ( - (fo, slice(None), slice(None)), - (fi, slice(None), slice(None)), - seg_no - ) - for (fo, fi, seg_no) in self._db_con.execute(query) - ) - - @contextmanager - def iterate_indices_by_source_frame( - self, - source_sop_instance_uid: str, - source_frame_numbers: Sequence[int], - segment_numbers: Sequence[int], - combine_segments: bool = False, - relabel: bool = False, - ) -> Generator[ - Iterator[ - Tuple[ - Tuple[Union[slice, int], ...], - Tuple[Union[slice, int], ...], - int - ] - ], - None, - None, - ]: - """Iterate over frame indices for given source image frames. - - This is intended for the case of a segmentation image that references a - single multi-frame source image instance. In this case, the user - supplies a list of frames numbers of interest within the single source - instance, and this method returns information about the frames - of the segmentation image relevant to these frames. - - This yields an iterator to the underlying database result that iterates - over information on the steps required to construct the requested - segmentation mask from the stored frames of the segmentation image. - - This method is intended to be used as a context manager that yields the - requested iterator. The iterator is only valid while the context - manager is active. - - Parameters - ---------- - source_sop_instance_uid: str - SOP Instance UID of the source instance that contains the source - frames. - source_frame_numbers: Sequence[int] - A sequence of frame numbers (1-based) within the source instance - for which segmentations are requested. - segment_numbers: Sequence[int] - Sequence containing segment numbers to include. - combine_segments: bool, optional - If True, produce indices to combine the different segments into a - single label map in which the value of a pixel represents its - segment. If False (the default), segments are binary and stacked - down the last dimension of the output array. - relabel: bool, optional - If True and ``combine_segments`` is ``True``, the output segment - numbers are relabelled into the range ``0`` to - ``len(segment_numbers)`` (inclusive) according to the position of - the original segment numbers in ``segment_numbers`` parameter. If - ``combine_segments`` is ``False``, this has no effect. - - Yields - ------ - Iterator[Tuple[Tuple[Union[slice, int], ...], Tuple[Union[slice, int], ...], int]]: - Indices required to construct the requested mask. Each - triplet denotes the (output indexer, segmentation indexer, - output segment number) representing a list of "instructions" to - create the requested output array by copying frames from the - segmentation dataset and inserting them into the output array with - a given segment value. Output indexer and segmentation indexer are - tuples that can be used to index the output and segmentations - numpy arrays directly. - - """ # noqa: E501 - # Run query to create the iterable of indices needed to construct the - # desired pixel array. The approach here is to create two temporary - # tables in the SQLite database, one for the desired frame numbers, and - # another for the desired segments, then use table joins with the frame - # LUT to arrive at the relevant rows, before clearing up the temporary - # tables. - - # Create temporary table of desired frame numbers - table_name = 'TemporaryFrameNumbers' - column_defs = [ - 'OutputFrameIndex INTEGER UNIQUE NOT NULL', - 'SourceFrameNumber INTEGER UNIQUE NOT NULL' - ] - column_data = enumerate(source_frame_numbers) - - # Construct the query The ORDER BY is not logically necessary - # but seems to improve performance of the downstream numpy - # operations, presumably as it is more cache efficient - query = ( - 'SELECT ' - ' F.OutputFrameIndex,' - ' L.FrameNumber - 1,' - ' S.OutputSegmentNumber ' - 'FROM TemporaryFrameNumbers F ' - 'INNER JOIN FrameLUT L' - ' ON F.SourceFrameNumber = L.ReferencedFrameNumber ' - 'INNER JOIN TemporarySegmentNumbers S' - ' ON L.SegmentNumber = S.SegmentNumber ' - 'ORDER BY F.OutputFrameIndex' - ) - - with self._generate_temp_table( - table_name=table_name, - column_defs=column_defs, - column_data=column_data, - ): - with self._generate_temp_segment_table( - segment_numbers=segment_numbers, - combine_segments=combine_segments, - relabel=relabel - ): - yield ( - ( - (fo, slice(None), slice(None)), - (fi, slice(None), slice(None)), - seg_no - ) - for (fo, fi, seg_no) in self._db_con.execute(query) - ) - - @contextmanager - def iterate_indices_by_dimension_index_values( - self, - dimension_index_values: Sequence[Sequence[int]], - dimension_index_pointers: Sequence[int], - segment_numbers: Sequence[int], - combine_segments: bool = False, - relabel: bool = False, - ) -> Generator[ - Iterator[ - Tuple[ - Tuple[Union[slice, int], ...], - Tuple[Union[slice, int], ...], - int - ] - ], - None, - None, - ]: - """Iterate over frame indices for given dimension index values. - - This is intended to be the most flexible and lowest-level (and there - also least convenient) method to request information about - segmentation frames. The user can choose to specify which segmentation - frames are of interest using arbitrary dimension indices and their - associated values. This makes no assumptions about the dimension - organization of the underlying segmentation, except that the given - dimension indices can be used to uniquely identify frames in the - segmentation image. - - This yields an iterator to the underlying database result that iterates - over information on the steps required to construct the requested - segmentation mask from the stored frames of the segmentation image. - - This method is intended to be used as a context manager that yields the - requested iterator. The iterator is only valid while the context - manager is active. - - Parameters - ---------- - dimension_index_values: Sequence[Sequence[int]] - Dimension index values for the requested frames. - dimension_index_pointers: Sequence[Union[int, pydicom.tag.BaseTag]] - The data element tags that identify the indices used in the - ``dimension_index_values`` parameter. - segment_numbers: Sequence[int] - Sequence containing segment numbers to include. - combine_segments: bool, optional - If True, produce indices to combine the different segments into a - single label map in which the value of a pixel represents its - segment. If False (the default), segments are binary and stacked - down the last dimension of the output array. - relabel: bool, optional - If True and ``combine_segments`` is ``True``, the output segment - numbers are relabelled into the range ``0`` to - ``len(segment_numbers)`` (inclusive) according to the position of - the original segment numbers in ``segment_numbers`` parameter. If - ``combine_segments`` is ``False``, this has no effect. - - Yields - ------ - Iterator[Tuple[Tuple[Union[slice, int], ...], Tuple[Union[slice, int], ...], int]]: - Indices required to construct the requested mask. Each - triplet denotes the (output indexer, segmentation indexer, - output segment number) representing a list of "instructions" to - create the requested output array by copying frames from the - segmentation dataset and inserting them into the output array with - a given segment value. Output indexer and segmentation indexer are - tuples that can be used to index the output and segmentations - numpy arrays directly. - - """ # noqa: E501 - # Create temporary table of desired dimension indices - table_name = 'TemporaryDimensionIndexValues' - - dim_ind_cols = [ - self._dim_ind_col_names[p] for p in dimension_index_pointers - ] - column_defs = ( - ['OutputFrameIndex INTEGER UNIQUE NOT NULL'] + - [f'{col} INTEGER NOT NULL' for col in dim_ind_cols] - ) - column_data = ( - (i, *tuple(row)) - for i, row in enumerate(dimension_index_values) - ) - - # Construct the query The ORDER BY is not logically necessary - # but seems to improve performance of the downstream numpy - # operations, presumably as it is more cache efficient - join_str = ' AND '.join(f'D.{col} = L.{col}' for col in dim_ind_cols) - query = ( - 'SELECT ' - ' D.OutputFrameIndex,' # frame index of the output array - ' L.FrameNumber - 1,' # frame *index* of segmentation image - ' S.OutputSegmentNumber ' # output segment number - 'FROM TemporaryDimensionIndexValues D ' - 'INNER JOIN FrameLUT L' - f' ON {join_str} ' - 'INNER JOIN TemporarySegmentNumbers S' - ' ON L.SegmentNumber = S.SegmentNumber ' - 'ORDER BY D.OutputFrameIndex' - ) - - with self._generate_temp_table( - table_name=table_name, - column_defs=column_defs, - column_data=column_data, - ): - with self._generate_temp_segment_table( - segment_numbers=segment_numbers, - combine_segments=combine_segments, - relabel=relabel - ): - yield ( - ( - (fo, slice(None), slice(None)), - (fi, slice(None), slice(None)), - seg_no - ) - for (fo, fi, seg_no) in self._db_con.execute(query) - ) - - @contextmanager - def iterate_indices_for_tiled_region( - self, - row_start: int, - row_end: int, - column_start: int, - column_end: int, - tile_shape: Tuple[int, int], - segment_numbers: Sequence[int], - combine_segments: bool = False, - relabel: bool = False, - ) -> Generator[ - Iterator[ - Tuple[ - Tuple[Union[slice, int], ...], - Tuple[Union[slice, int], ...], - int - ] - ], - None, - None, - ]: - """Iterate over segmentation frame indices for a given region of the - segmentation's total pixel matrix. - - This is intended for the case of a segmentation image that is stored as - a tiled representation of total pixel matrix. - - This yields an iterator to the underlying database result that iterates - over information on the steps required to construct the requested - segmentation mask from the stored frames of the segmentation image. - - This method is intended to be used as a context manager that yields the - requested iterator. The iterator is only valid while the context - manager is active. - - Parameters - ---------- - row_start: int - Row index (1-based) in the total pixel matrix of the first row of - the output array. May be negative (last row is -1). - row_end: int - Row index (1-based) in the total pixel matrix one beyond the last - row of the output array. May be negative (last row is -1). - column_start: int - Column index (1-based) in the total pixel matrix of the first - column of the output array. May be negative (last column is -1). - column_end: int - Column index (1-based) in the total pixel matrix one beyond the last - column of the output array. May be negative (last column is -1). - tile_shape: Tuple[int, int] - Shape of each tile (rows, columns). - segment_numbers: Sequence[int] - Numbers of segments to include. - combine_segments: bool, optional - If True, produce indices to combine the different segments into a - single label map in which the value of a pixel represents its - segment. If False (the default), segments are binary and stacked - down the last dimension of the output array. - relabel: bool, optional - If True and ``combine_segments`` is ``True``, the output segment - numbers are relabelled into the range ``0`` to - ``len(segment_numbers)`` (inclusive) according to the position of - the original segment numbers in ``segment_numbers`` parameter. If - ``combine_segments`` is ``False``, this has no effect. - - Yields - ------ - Iterator[Tuple[Tuple[Union[slice, int], ...], Tuple[Union[slice, int], ...], int]]: - Indices required to construct the requested mask. Each - triplet denotes the (output indexer, segmentation indexer, - output segment number) representing a list of "instructions" to - create the requested output array by copying frames from the - segmentation dataset and inserting them into the output array with - a given segment value. Output indexer and segmentation indexer are - tuples that can be used to index the output and segmentations - numpy arrays directly. - - """ # noqa: E501 - th, tw = tile_shape - - oh = row_end - row_start - ow = column_end - column_start - - row_offset_start = row_start - th + 1 - column_offset_start = column_start - tw + 1 - - # Construct the query The ORDER BY is not logically necessary - # but seems to improve performance of the downstream numpy - # operations, presumably as it is more cache efficient - query = ( - 'SELECT ' - ' L.RowPositionInTotalImagePixelMatrix,' - ' L.ColumnPositionInTotalImagePixelMatrix,' - ' L.FrameNumber - 1,' - ' S.OutputSegmentNumber ' - 'FROM FrameLUT L ' - 'INNER JOIN TemporarySegmentNumbers S' - ' ON L.SegmentNumber = S.SegmentNumber ' - 'WHERE (' - ' L.RowPositionInTotalImagePixelMatrix >= ' - f' {row_offset_start}' - f' AND L.RowPositionInTotalImagePixelMatrix < {row_end}' - ' AND L.ColumnPositionInTotalImagePixelMatrix >= ' - f' {column_offset_start}' - f' AND L.ColumnPositionInTotalImagePixelMatrix < {column_end}' - ')' - 'ORDER BY ' - ' L.RowPositionInTotalImagePixelMatrix,' - ' L.ColumnPositionInTotalImagePixelMatrix,' - ' S.OutputSegmentNumber' - ) - - with self._generate_temp_segment_table( - segment_numbers=segment_numbers, - combine_segments=combine_segments, - relabel=relabel - ): - yield ( - ( - ( - slice( - max(rp - row_start, 0), - min(rp + th - row_start, oh) - ), - slice( - max(cp - column_start, 0), - min(cp + tw - column_start, ow) - ), - ), - ( - fi, - slice( - max(row_start - rp, 0), - min(row_end - rp, th) - ), - slice( - max(column_start - cp, 0), - min(column_end - cp, tw) - ), - ), - seg_no - ) - for (rp, cp, fi, seg_no) in self._db_con.execute(query) - ) - - -class Segmentation(SOPClass): +class Segmentation(_Image): """SOP class for the Segmentation IOD.""" def __init__( self, source_images: Sequence[Dataset], - pixel_array: np.ndarray, + pixel_array: Union[np.ndarray, Volume], segmentation_type: Union[str, SegmentationTypeValues], segment_descriptions: Sequence[SegmentDescription], series_instance_uid: str, @@ -1208,11 +217,11 @@ def __init__( tile_size: Union[Sequence[int], None] = None, pyramid_uid: Optional[str] = None, pyramid_label: Optional[str] = None, + further_source_images: Optional[Sequence[Dataset]] = None, palette_color_lut_transformation: Optional[ PaletteColorLUTTransformation ] = None, icc_profile: Optional[bytes] = None, - further_source_images: Optional[Sequence[Dataset]] = None, **kwargs: Any ) -> None: """ @@ -1261,7 +270,8 @@ def __init__( segments from 1 through ``pixel_array.max()`` (inclusive) must be described in `segment_descriptions`, regardless of whether they are present in the image. Note that this is valid for segmentations - encoded using the ``"BINARY"`` or ``"FRACTIONAL"`` methods. + encoded using the ``"BINARY"``, ``"LABELMAP"`` or ``"FRACTIONAL"`` + methods. Note that that a 2D numpy array and a 3D numpy array with a single frame along the first dimension may be used interchangeably @@ -1297,15 +307,22 @@ def __init__( or the extent to which a segment occupies the pixel (if `fractional_type` is ``"OCCUPANCY"``). + Alternatively, ``pixel_array`` may be an instance of a + :class:`highdicom.volume.Volume`. In this case, behavior is the + same as if the underlying numpy array is passed, and additionally, + the ``pixel_measures``, ``plane_positions`` and + ``plane_orientation`` will be computed from the volume, and + therefore should not be passed as parameters. + segmentation_type: Union[str, highdicom.seg.SegmentationTypeValues] Type of segmentation, either ``"BINARY"``, ``"FRACTIONAL"``, or ``"LABELMAP"``. segment_descriptions: Sequence[highdicom.seg.SegmentDescription] - Description of each segment encoded in `pixel_array`. In the case of - pixel arrays with multiple integer values, the segment description - with the corresponding segment number is used to describe each segment. - No description should be provided for pixels with value 0, which - are considered background pixels. + Description of each segment encoded in `pixel_array`. In the case + of pixel arrays with multiple integer values, the segment + description with the corresponding segment number is used to + describe each segment. No description should be provided for pixels + with value 0, which are considered background pixels. series_instance_uid: str UID of the series series_number: int @@ -1343,23 +360,32 @@ def __init__( JPEG 2000 Lossless (``"1.2.840.10008.1.2.4.90"``), and JPEG LS Lossless (``"1.2.840.10008.1.2.4.00"``). pixel_measures: Union[highdicom.PixelMeasures, None], optional - Physical spacing of image pixels in `pixel_array`. - If ``None``, it will be assumed that the segmentation image has the - same pixel measures as the source image(s). + Physical spacing of image pixels in `pixel_array`. If ``None``, it + will be assumed that the segmentation image has the same pixel + measures as the source image(s). If ``pixel_array`` is an instance + of :class:`highdicom.volume.Volume`, the pixel measures will be + computed from it and therefore this parameter should be left an + ``None``. plane_orientation: Union[highdicom.PlaneOrientationSequence, None], optional Orientation of planes in `pixel_array` relative to axes of - three-dimensional patient or slide coordinate space. - If ``None``, it will be assumed that the segmentation image as the - same plane orientation as the source image(s). + three-dimensional patient or slide coordinate space. If ``None``, + it will be assumed that the segmentation image as the same plane + orientation as the source image(s). If ``pixel_array`` is an + instance of :class:`highdicom.volume.Volume`, the plane orientation + will be computed from it and therefore this parameter should be + left an ``None``. plane_positions: Union[Sequence[highdicom.PlanePositionSequence], None], optional Position of each plane in `pixel_array` in the three-dimensional - patient or slide coordinate space. - If ``None``, it will be assumed that the segmentation image has the - same plane position as the source image(s). However, this will only - work when the first dimension of `pixel_array` matches the number - of frames in `source_images` (in case of multi-frame source images) - or the number of `source_images` (in case of single-frame source - images). + patient or slide coordinate space. If ``None``, it will be assumed + that the segmentation image has the same plane position as the + source image(s). However, this will only work when the first + dimension of `pixel_array` matches the number of frames in + `source_images` (in case of multi-frame source images) or the + number of `source_images` (in case of single-frame source images). + If ``pixel_array`` is an instance of + :class:`highdicom.volume.Volume`, the plane positions will be + computed from it and therefore this parameter should be left an + ``None``. omit_empty_frames: bool, optional If True (default), frames with no non-zero pixels are omitted from the segmentation image. If False, all frames are included. @@ -1425,12 +451,6 @@ def __init__( Human readable label for the pyramid containing this segmentation. Should only be used if this segmentation is part of a multi-resolution pyramid. - palette_color_lut_transformation: Union[highdicom.PaletteColorLUTTransformation, None], optional - A palette color lookup table transformation to apply to the pixels - for display. This is only permitted if segmentation_type is "LABELMAP". - icc_profile: Union[bytes, None] = None - An ICC profile to display the segmentation. This is only permitted - when palette_color_lut_transformation is provided. further_source_images: Optional[Sequence[pydicom.Dataset]], optional Additional images to record as source images in the segmentation. Unlike the main ``source_images`` parameter, these images will @@ -1438,6 +458,12 @@ def __init__( ``pixel_array`` in the case that no plane positions are supplied. Images from multiple series may be passed, however they must all belong to the same study. + palette_color_lut_transformation: Union[highdicom.PaletteColorLUTTransformation, None], optional + A palette color lookup table transformation to apply to the pixels + for display. This is only permitted if segmentation_type is "LABELMAP". + icc_profile: Union[bytes, None] = None + An ICC profile to display the segmentation. This is only permitted + when palette_color_lut_transformation is provided. **kwargs: Any, optional Additional keyword arguments that will be passed to the constructor of `highdicom.base.SOPClass` @@ -1462,7 +488,6 @@ def __init__( The assumption is made that segments in `pixel_array` are defined in the same frame of reference as `source_images`. - """ # noqa: E501 if len(source_images) == 0: raise ValueError('At least one source image is required.') @@ -1484,7 +509,7 @@ def __init__( ) src_img = source_images[0] - is_multiframe = hasattr(src_img, 'NumberOfFrames') + is_multiframe = is_multiframe_image(src_img) if is_multiframe and len(source_images) > 1: raise ValueError( 'Only one source image should be provided in case images ' @@ -1502,24 +527,6 @@ def __init__( f'Transfer syntax "{transfer_syntax_uid}" is not supported.' ) - if pixel_array.ndim == 2: - pixel_array = pixel_array[np.newaxis, ...] - if pixel_array.ndim not in [3, 4]: - raise ValueError('Pixel array must be a 2D, 3D, or 4D array.') - - is_tiled = hasattr(src_img, 'TotalPixelMatrixRows') - if tile_pixel_array and not is_tiled: - raise ValueError( - 'When argument "tile_pixel_array" is True, the source image ' - 'must be a tiled image.' - ) - if tile_pixel_array and pixel_array.shape[0] != 1: - raise ValueError( - 'When argument "tile_pixel_array" is True, the input pixel ' - 'array must contain only one "frame" representing the entire ' - 'entire pixel matrix.' - ) - segmentation_type = SegmentationTypeValues(segmentation_type) sop_class_uid = ( '1.2.840.10008.5.1.4.1.1.66.7' @@ -1607,6 +614,96 @@ def __init__( ) self._coordinate_system = None + # Check segment numbers + described_segment_numbers = np.array([ + int(item.SegmentNumber) + for item in segment_descriptions + ]) + self._check_segment_numbers( + described_segment_numbers, + segmentation_type, + ) + + from_volume = isinstance(pixel_array, Volume) + if from_volume: + if not has_ref_frame_uid: + raise ValueError( + "A volume should not be passed if the source image(s) " + "has/have no FrameOfReferenceUID." + ) + if pixel_array.frame_of_reference_uid is not None: + if ( + pixel_array.frame_of_reference_uid != + src_img.FrameOfReferenceUID + ): + raise ValueError( + "The volume passed as the pixel array has a " + "different frame of reference from the source " + "image." + ) + if pixel_measures is not None: + raise TypeError( + "Argument 'pixel_measures' should not be provided if " + "'pixel_array' is a highdicom.Volume." + ) + if plane_orientation is not None: + raise TypeError( + "Argument 'plane_orientation' should not be provided if " + "'pixel_array' is a highdicom.Volume." + ) + if plane_positions is not None: + raise TypeError( + "Argument 'plane_positions' should not be provided if " + "'pixel_array' is a highdicom.Volume." + ) + if pixel_array.number_of_channel_dimensions == 1: + if pixel_array.channel_identifiers != ( + ChannelIdentifier('SegmentNumber'), + ): + raise ValueError( + "Input volume should have no channels other than " + "'SegmentNumber'." + ) + vol_seg_nums = pixel_array.get_channel_values('SegmentNumber') + if not np.array_equal( + np.array(vol_seg_nums), described_segment_numbers + ): + raise ValueError( + "Segment numbers in the input volume do not match " + "the described segments." + ) + elif pixel_array.number_of_channel_dimensions != 0: + raise ValueError( + "If 'pixel_array' is a highdicom.Volume, it should have " + "0 or 1 channel dimensions." + ) + + plane_positions = pixel_array.get_plane_positions() + plane_orientation = pixel_array.get_plane_orientation() + pixel_measures = pixel_array.get_pixel_measures() + input_volume = pixel_array + pixel_array = pixel_array.array + else: + input_volume = None + + if pixel_array.ndim == 2: + pixel_array = pixel_array[np.newaxis, ...] + if pixel_array.ndim not in [3, 4]: + raise ValueError('Pixel array must be a 2D, 3D, or 4D array.') + + is_tiled = hasattr(src_img, 'TotalPixelMatrixRows') + if tile_pixel_array and not is_tiled: + raise ValueError( + 'When argument "tile_pixel_array" is True, the source image ' + 'must be a tiled image.' + ) + if tile_pixel_array and pixel_array.shape[0] != 1: + raise ValueError( + 'When argument "tile_pixel_array" is True, the input pixel ' + 'array must contain only one "frame" representing the ' + 'entire pixel matrix.' + ) + # Remember whether these values were provided by the user, or inferred # from the source image. If inferred, we can skip some checks user_provided_orientation = plane_orientation is not None @@ -1672,6 +769,7 @@ def __init__( # Segmentation Image self.ImageType = ['DERIVED', 'PRIMARY'] self.SamplesPerPixel = 1 + self.PhotometricInterpretation = 'MONOCHROME2' self.PixelRepresentation = 0 self.SegmentationType = segmentation_type.value @@ -1696,7 +794,7 @@ def __init__( self.ContentCreatorIdentificationCodeSequence = \ content_creator_identification - if self.SegmentationType == SegmentationTypeValues.BINARY.value: + if segmentation_type == SegmentationTypeValues.BINARY: dtype = np.uint8 self.BitsAllocated = 1 self.HighBit = 0 @@ -1709,7 +807,7 @@ def __init__( f'{self.file_meta.TransferSyntaxUID} ' 'is not compatible with the BINARY segmentation type' ) - elif self.SegmentationType == SegmentationTypeValues.FRACTIONAL.value: + elif segmentation_type == SegmentationTypeValues.FRACTIONAL: dtype = np.uint8 self.BitsAllocated = 8 self.HighBit = 7 @@ -1722,33 +820,19 @@ def __init__( 'Maximum fractional value must not exceed image bit depth.' ) self.MaximumFractionalValue = max_fractional_value - elif self.SegmentationType == SegmentationTypeValues.LABELMAP.value: + elif segmentation_type == SegmentationTypeValues.LABELMAP: # Decide on the output datatype and update the image metadata - # accordingly. Use the smallest possible type unless there is - # a palette color LUT that says otherwise. - if palette_color_lut_transformation is not None: - lut_bitdepth = ( - palette_color_lut_transformation.red_lut.bits_per_entry + # accordingly. Use the smallest possible type + dtype = _get_unsigned_dtype(described_segment_numbers.max()) + if dtype == np.uint32: + raise ValueError( + "Too many segments to represent with a 16 bit integer." ) - labelmap_bitdepth = lut_bitdepth - dtype = np.dtype(f'u{labelmap_bitdepth // 8}') - else: - dtype = _get_unsigned_dtype(len(segment_descriptions)) - if dtype == np.uint32: - raise ValueError( - "Too many classes to represent with a 16 bit integer." - ) - labelmap_bitdepth = np.iinfo(dtype).bits - self.BitsAllocated = labelmap_bitdepth + self.BitsAllocated = np.iinfo(dtype).bits self.HighBit = self.BitsAllocated - 1 self.BitsStored = self.BitsAllocated self.PixelPaddingValue = 0 - else: - raise ValueError( - f'Unknown segmentation type "{segmentation_type}"' - ) - self.BitsStored = self.BitsAllocated self.LossyImageCompression = getattr( src_img, @@ -1789,17 +873,23 @@ def __init__( 'of type highdicom.PaletteColorLUTTransformation.' ) + if palette_color_lut_transformation.is_segmented: + raise ValueError( + 'Palette Color LUT Transformations must not be ' + 'segmented when included in a Segmentation.' + ) + lut = palette_color_lut_transformation.red_lut lut_entries = lut.number_of_entries lut_start = lut.first_mapped_value lut_end = lut_start + lut_entries if ( - (lut_start > 0) or lut_end <= len(segment_descriptions) + (lut_start > 0) or lut_end <= described_segment_numbers.max() ): raise ValueError( 'The labelmap provided does not have entries ' - 'to cover all segments and background.' + 'to covering all segments and background.' ) for desc in segment_descriptions: @@ -1869,7 +959,6 @@ def __init__( ) # Multi-Frame Functional Groups and Multi-Frame Dimensions - sffg_item = Dataset() source_pixel_measures = self._get_pixel_measures_sequence( source_image=src_img, is_multiframe=is_multiframe, @@ -1886,10 +975,26 @@ def __init__( else: if is_multiframe: src_sfg = src_img.SharedFunctionalGroupsSequence[0] + + if 'PlaneOrientationSequence' not in src_sfg: + raise ValueError( + 'Source images must have a shared ' + 'orientation.' + ) + source_plane_orientation = deepcopy( src_sfg.PlaneOrientationSequence ) else: + iop = src_img.ImageOrientationPatient + + for image in source_images: + if image.ImageOrientationPatient != iop: + raise ValueError( + 'Source images must have a shared ' + 'orientation.' + ) + source_plane_orientation = PlaneOrientationSequence( coordinate_system=self._coordinate_system, image_orientation=src_img.ImageOrientationPatient @@ -1910,83 +1015,20 @@ def __init__( self.DimensionIndexSequence[0].DimensionOrganizationUID self.DimensionOrganizationSequence = [dimension_organization] - if pixel_measures is not None: - sffg_item.PixelMeasuresSequence = pixel_measures - if ( - self._coordinate_system is not None and - self._coordinate_system == CoordinateSystemNames.PATIENT - ): - sffg_item.PlaneOrientationSequence = plane_orientation - self.SharedFunctionalGroupsSequence = [sffg_item] - - # Check segment numbers - described_segment_numbers = np.array([ - int(item.SegmentNumber) - for item in segment_descriptions - ]) - self._check_segment_numbers(described_segment_numbers) - number_of_segments = len(described_segment_numbers) - if segmentation_type == SegmentationTypeValues.LABELMAP: - # Need to add a background description in the case of labelmap - - # Set the display color if other segments do - if any( - hasattr(desc, 'RecommendedDisplayCIELabValue') - for desc in segment_descriptions - ): - bg_color = CIELabColor(0.0, 0.0, 0.0) # black - else: - bg_color = None - - bg_algo_id = segment_descriptions[0].get( - 'SegmentationAlgorithmIdentificationSequence' - ) - - bg_description = SegmentDescription( - segment_number=1, - segment_label='Background', - segmented_property_category=codes.DCM.Background, - segmented_property_type=codes.DCM.Background, - algorithm_type=segment_descriptions[0].SegmentAlgorithmType, - algorithm_identification=bg_algo_id, - display_color=bg_color, - ) - # Override this such that the check on user-constructed segment - # descriptions having a positive value can remain in place. - bg_description.SegmentNumber = 0 - - self.SegmentSequence = [ - bg_description, - *segment_descriptions - ] - else: - self.SegmentSequence = segment_descriptions + self._add_segment_descriptions( + segment_descriptions, + segmentation_type, + ) # Checks on pixels and overlap pixel_array, segments_overlap = self._check_and_cast_pixel_array( pixel_array, - number_of_segments, + described_segment_numbers, segmentation_type, dtype=dtype, ) self.SegmentsOverlap = segments_overlap.value - # Combine segments to create a labelmap image if needed - if segmentation_type == SegmentationTypeValues.LABELMAP: - if segments_overlap == SegmentsOverlapValues.YES: - raise ValueError( - 'It is not possible to store a Segmentation with ' - 'SegmentationType "LABELMAP" if segments overlap.' - ) - - if pixel_array.ndim == 4: - pixel_array = self._combine_segments( - pixel_array, - labelmap_dtype=dtype - ) - else: - pixel_array = pixel_array.astype(dtype) - if has_ref_frame_uid: if tile_pixel_array: @@ -2049,7 +1091,10 @@ def __init__( ) and ( not user_provided_measures or - pixel_measures == source_pixel_measures + ( + pixel_measures[0].PixelSpacing == + source_pixel_measures[0].PixelSpacing + ) ) ) @@ -2129,7 +1174,10 @@ def __init__( ) and ( not user_provided_measures or - pixel_measures == source_pixel_measures + ( + pixel_measures[0].PixelSpacing == + source_pixel_measures[0].PixelSpacing + ) ) ) @@ -2184,17 +1232,49 @@ def __init__( # number) plane_sort_index is a list of indices into the input # planes giving the order in which they should be arranged to # correctly sort them for inclusion into the segmentation + sort_orientation = ( + plane_orientation[0].ImageOrientationPatient + if self._coordinate_system == CoordinateSystemNames.PATIENT + else None + ) plane_position_values, plane_sort_index = \ self.DimensionIndexSequence.get_index_values( - plane_positions + plane_positions, + image_orientation=sort_orientation, + index_convention=VOLUME_INDEX_CONVENTION, ) - else: - # Only one spatial location supported - plane_positions = [None] - plane_position_values = [None] - plane_sort_index = np.array([0]) - are_spatial_locations_preserved = True + else: + # Only one spatial location supported + plane_positions = [None] + plane_position_values = [None] + plane_sort_index = np.array([0]) + are_spatial_locations_preserved = True + + # Shared functional groops + sffg_item = Dataset() + if ( + self._coordinate_system is not None and + self._coordinate_system == CoordinateSystemNames.PATIENT + ): + sffg_item.PlaneOrientationSequence = plane_orientation + + # Automatically populate the spacing between slices in the + # pixel measures if it was not provided. This is done on the + # initial plane positions, before any removals, to give the + # receiver more information about how to reconstruct a volume + # from the frames in the case that slices are omitted + if 'SpacingBetweenSlices' not in pixel_measures[0]: + slice_spacing, _ = get_volume_positions( + image_positions=plane_position_values[:, 0, :], + image_orientation=plane_orientation[0].ImageOrientationPatient, + ) + if slice_spacing is not None: + pixel_measures[0].SpacingBetweenSlices = slice_spacing + + if pixel_measures is not None: + sffg_item.PixelMeasuresSequence = pixel_measures + self.SharedFunctionalGroupsSequence = [sffg_item] if are_spatial_locations_preserved and not tile_pixel_array: if pixel_array.shape[1:3] != (src_img.Rows, src_img.Columns): @@ -2203,19 +1283,6 @@ def __init__( "the source image." ) - # Dimension Organization Type - dimension_organization_type = self._check_dimension_organization_type( - dimension_organization_type=dimension_organization_type, - is_tiled=is_tiled, - omit_empty_frames=omit_empty_frames, - plane_positions=plane_positions, - tile_pixel_array=tile_pixel_array, - rows=self.Rows, - columns=self.Columns, - ) - if dimension_organization_type is not None: - self.DimensionOrganizationType = dimension_organization_type.value - # Find indices such that empty planes are removed if omit_empty_frames: if tile_pixel_array: @@ -2244,6 +1311,17 @@ def __init__( else: included_plane_indices = list(range(len(plane_positions))) + # Dimension Organization Type + dimension_organization_type = self._check_tiled_dimension_organization( + dimension_organization_type=dimension_organization_type, + is_tiled=is_tiled, + omit_empty_frames=omit_empty_frames, + plane_positions=plane_positions, + tile_pixel_array=tile_pixel_array, + rows=self.Rows, + columns=self.Columns, + ) + if ( has_ref_frame_uid and dimension_organization_type != @@ -2265,6 +1343,50 @@ def __init__( else: unique_dimension_values = [None] + if self._coordinate_system == CoordinateSystemNames.PATIENT: + inferred_dim_org_type = None + + # To be considered "3D", a segmentation should have frames that are + # differentiated only by location. This rules out any non-labelmap + # segmentations with more than a single segment. + # Further, only segmentation with multiple spatial positions in the + # final segmentation should be considered to have 3D dimension + # organization type + if ( + len(included_plane_indices) > 1 and + ( + segmentation_type == SegmentationTypeValues.LABELMAP + or len(described_segment_numbers) == 1 + ) + ): + # Calculate the spacing using only the included planes, and + # enfore ordering + spacing, _ = get_volume_positions( + image_positions=plane_position_values[ + included_plane_indices, 0, : + ], + image_orientation=plane_orientation[0].ImageOrientationPatient, + sort=False, + ) + if spacing is not None and spacing > 0.0: + inferred_dim_org_type = ( + DimensionOrganizationTypeValues.THREE_DIMENSIONAL + ) + + if ( + dimension_organization_type == + DimensionOrganizationTypeValues.THREE_DIMENSIONAL + ) and inferred_dim_org_type is None: + raise ValueError( + 'Dimension organization "3D" has been specified, ' + 'but the source image is not a regularly-spaced 3D ' + 'volume.' + ) + dimension_organization_type = inferred_dim_org_type + + if dimension_organization_type is not None: + self.DimensionOrganizationType = dimension_organization_type.value + if ( has_ref_frame_uid and self._coordinate_system == CoordinateSystemNames.SLIDE @@ -2313,7 +1435,7 @@ def __init__( # In the case of native encoding when the number pixels in a frame is # not a multiple of 8. This array carries "leftover" pixels that - # couldn't be encoded in previous iterations, to future iterations This + # couldn't be encoded in previous iterations, to future iterations. This # saves having to keep the entire un-endoded array in memory, which can # get extremely heavy on memory in the case of very large arrays remainder_pixels = np.empty((0, ), dtype=np.uint8) @@ -2372,7 +1494,7 @@ def __init__( for segment_number in segments_iterable: - for plane_index in plane_sort_index: + for plane_dim_ind, plane_index in enumerate(plane_sort_index, 1): if tile_pixel_array: if ( @@ -2411,7 +1533,7 @@ def __init__( segment_array = self._get_segment_pixel_array( plane_array, segment_number=segment_number, - number_of_segments=number_of_segments, + described_segment_numbers=described_segment_numbers, segmentation_type=segmentation_type, max_fractional_value=max_fractional_value, dtype=dtype, @@ -2448,21 +1570,28 @@ def __init__( # this segmentation frame if has_ref_frame_uid: plane_pos_val = plane_position_values[plane_index] - try: - dimension_index_values = ( - self._get_dimension_index_values( - unique_dimension_values=unique_dimension_values, # noqa: E501 - plane_position_value=plane_pos_val, - coordinate_system=self._coordinate_system, - ) - ) - except IndexError as error: - raise IndexError( - 'Could not determine position of plane ' - f'#{plane_index} in three dimensional ' - 'coordinate system based on dimension index ' - f'values: {error}' - ) from error + if ( + self._coordinate_system == + CoordinateSystemNames.SLIDE + ): + try: + dimension_index_values = [ + int( + np.where( + unique_dimension_values[idx] == pos + )[0][0] + 1 + ) + for idx, pos in enumerate(plane_pos_val) + ] + except IndexError as error: + raise IndexError( + 'Could not determine position of plane ' + f'#{plane_index} in three dimensional ' + 'coordinate system based on dimension index ' + f'values: {error}' + ) from error + else: + dimension_index_values = [plane_dim_ind] else: if segmentation_type == SegmentationTypeValues.LABELMAP: # Here we have to use the "Frame Label" dimension @@ -2481,6 +1610,7 @@ def __init__( are_spatial_locations_preserved=are_spatial_locations_preserved, # noqa: E501 has_ref_frame_uid=has_ref_frame_uid, coordinate_system=self._coordinate_system, + is_multiframe=is_multiframe, ) pffg_sequence.append(pffg_item) @@ -2585,18 +1715,30 @@ def add_segments( ) @staticmethod - def _check_segment_numbers(described_segment_numbers: np.ndarray): - """Checks on segment numbers extracted from the segment descriptions. + def _check_segment_numbers( + segment_numbers: np.ndarray, + segmentation_type: SegmentationTypeValues, + ): + """Checks on segment numbers for a new segmentation. + + For BINARY and FRACTIONAL segmentations, segment numbers should start + at 1 and increase by 1. Strictly there is no requirement on the + ordering of these items within the segment sequence, however we enforce + such an order anyway on segmentations created by highdicom. I.e. our + conditions are stricter than the standard requires. - Segment numbers should start at 1 and increase by 1. This method checks - this and raises an appropriate exception for the user if the segment - numbers are incorrect. + For LABELMAP segmentations, there are no such limitations. + + This method checks this and raises an appropriate exception for the + user if the segment numbers are incorrect. Parameters ---------- - described_segment_numbers: np.ndarray + segment_numbers: numpy.ndarray The segment numbers from the segment descriptions, in the order they were passed. 1D array of integers. + segmentation_type: highdicom.seg.SegmentationTypeValues + Type of segmentation being created. Raises ------ @@ -2604,18 +1746,51 @@ def _check_segment_numbers(described_segment_numbers: np.ndarray): If the ``described_segment_numbers`` do not have the required values """ - # Check segment numbers in the segment descriptions are - # monotonically increasing by 1 - if not (np.diff(described_segment_numbers) == 1).all(): - raise ValueError( - 'Segment descriptions must be sorted by segment number ' - 'and monotonically increasing by 1.' - ) - if described_segment_numbers[0] != 1: - raise ValueError( - 'Segment descriptions should be numbered starting ' - f'from 1. Found {described_segment_numbers[0]}. ' - ) + if segmentation_type == SegmentationTypeValues.LABELMAP: + # Segment numbers must lie between 1 and 65536 to be represented by + # an unsigned short, but need not be consecutive for LABELMAP segs. + # 0 is technically allowed by the standard, but at the moment it is + # always reserved by highdicom to use for the background segment + min_seg_no = segment_numbers.min() + max_seg_no = segment_numbers.max() + + if min_seg_no < 0 or max_seg_no > 65535: + raise ValueError( + 'Segmentation numbers must be positive integers below ' + '65536.' + ) + if min_seg_no == 0: + raise ValueError( + 'The segmentation number 0 is reserved by highdicom for ' + 'the background class.' + ) + + # Segment numbers must be unique within an instance + if len(np.unique(segment_numbers)) < len(segment_numbers): + raise ValueError( + 'Segments descriptions must have unique segment numbers.' + ) + + # We additionally impose an ordering constraint (not required by + # the standard) + if not np.all(segment_numbers[:-1] <= segment_numbers[1:]): + raise ValueError( + 'Segments descriptions must be in ascending order by ' + 'segment number.' + ) + else: + # Check segment numbers in the segment descriptions are + # monotonically increasing by 1 + if not (np.diff(segment_numbers) == 1).all(): + raise ValueError( + 'Segment descriptions must be sorted by segment number ' + 'and monotonically increasing by 1.' + ) + if segment_numbers[0] != 1: + raise ValueError( + 'Segment descriptions should be numbered starting ' + f'from 1. Found {segment_numbers[0]}.' + ) @staticmethod def _get_pixel_measures_sequence( @@ -2672,6 +1847,57 @@ def _get_pixel_measures_sequence( return pixel_measures + def _add_segment_descriptions( + self, + segment_descriptions: Sequence[SegmentDescription], + segmentation_type: SegmentationTypeValues, + ) -> None: + """Utility method for constructor that adds segment descriptions. + + Parameters + ---------- + segment_descriptions: Sequence[highdicom.seg.SegmentDescription] + User-provided descriptions for each non-background segment. + segmentation_type: highdicom.seg.SegmentationTypeValues + Type of segmentation being created. + + """ + if segmentation_type == SegmentationTypeValues.LABELMAP: + # Need to add a background description in the case of labelmap + + # Set the display color if other segments do + if any( + hasattr(desc, 'RecommendedDisplayCIELabValue') + for desc in segment_descriptions + ): + bg_color = CIELabColor(0.0, 0.0, 0.0) # black + else: + bg_color = None + + bg_algo_id = segment_descriptions[0].get( + 'SegmentationAlgorithmIdentificationSequence' + ) + + bg_description = SegmentDescription( + segment_number=1, + segment_label='Background', + segmented_property_category=codes.DCM.Background, + segmented_property_type=codes.DCM.Background, + algorithm_type=segment_descriptions[0].SegmentAlgorithmType, + algorithm_identification=bg_algo_id, + display_color=bg_color, + ) + # Override this such that the check on user-constructed segment + # descriptions having a positive value can remain in place. + bg_description.SegmentNumber = 0 + + self.SegmentSequence = [ + bg_description, + *segment_descriptions + ] + else: + self.SegmentSequence = segment_descriptions + def _add_slide_coordinate_metadata( self, source_image: Dataset, @@ -2800,7 +2026,7 @@ def _add_slide_coordinate_metadata( self.ImageCenterPointCoordinatesSequence = [center_item] @staticmethod - def _check_dimension_organization_type( + def _check_tiled_dimension_organization( dimension_organization_type: Union[ DimensionOrganizationTypeValues, str, @@ -2838,6 +2064,14 @@ def _check_dimension_organization_type( DimensionOrganizationType to use for the output Segmentation. """ # noqa: E501 + if ( + dimension_organization_type == + DimensionOrganizationTypeValues.THREE_DIMENSIONAL_TEMPORAL + ): + raise ValueError( + "Value of 'THREE_DIMENSIONAL_TEMPORAL' for " + "parameter 'dimension_organization_type' is not supported." + ) if is_tiled and dimension_organization_type is None: dimension_organization_type = \ DimensionOrganizationTypeValues.TILED_SPARSE @@ -2878,11 +2112,11 @@ def _check_dimension_organization_type( ): raise ValueError( 'A value of "TILED_FULL" for parameter ' - '"dimension_organization_type" is not permitted unless ' - 'the "plane_positions" of the segmentation do not ' + '"dimension_organization_type" is not permitted ' + 'because the "plane_positions" of the segmentation ' 'do not follow the relevant requirements. See ' 'https://dicom.nema.org/medical/dicom/current/output/' - 'chtml/part03/sect_C.7.6.17.3.html#sect_C.7.6.17.3.' + 'chtml/part03/sect_C.7.6.17.3.html#sect_C.7.6.17.3 .' ) if omit_empty_frames: raise ValueError( @@ -2892,10 +2126,11 @@ def _check_dimension_organization_type( return dimension_organization_type - @staticmethod + @classmethod def _check_and_cast_pixel_array( + cls, pixel_array: np.ndarray, - number_of_segments: int, + segment_numbers: np.ndarray, segmentation_type: SegmentationTypeValues, dtype: type, ) -> Tuple[np.ndarray, SegmentsOverlapValues]: @@ -2907,7 +2142,7 @@ def _check_and_cast_pixel_array( ---------- pixel_array: numpy.ndarray The segmentation pixel array. - number_of_segments: int + segment_numbers: numpy.ndarray The segment numbers from the segment descriptions, in the order they were passed. 1D array of integers. segmentation_type: highdicom.seg.SegmentationTypeValues @@ -2924,6 +2159,14 @@ def _check_and_cast_pixel_array( pixel array. """ + # Note that for large array (common in pathology) the checks in this + # method can take up a significant amount of the overall creation time. + # As a result, this method is optimized for runtime efficiency at the + # expense of simplicity. In particular, there are several common + # special cases that have optimized implementations, and intermediate + # results are re-used wherever possible + number_of_segments = len(segment_numbers) + if pixel_array.ndim == 4: # Check that the number of segments in the array matches if pixel_array.shape[-1] != number_of_segments: @@ -2935,7 +2178,6 @@ def _check_and_cast_pixel_array( ) if pixel_array.dtype in (np.bool_, np.uint8, np.uint16): - max_pixel = pixel_array.max() if pixel_array.ndim == 3: # A label-map style array where pixel values represent @@ -2943,7 +2185,26 @@ def _check_and_cast_pixel_array( # The pixel values in the pixel array must all belong to # a described segment - if max_pixel > number_of_segments: + if len( + np.setxor1d( + np.arange(1, number_of_segments + 1), + segment_numbers, + ) + ) == 0: + # This is a common special case where segment numbers are + # consecutive and start at 1 (as is required for FRACTIONAL + # and BINARY segmentations). In this case it is sufficient + # to check the max pixel value, which is MUCH more + # efficient than calculating the set of unique values + has_undescribed_segments = pixel_array.max() > number_of_segments + else: + # The general case, much slower + numbers_with_bg = np.concatenate([np.array([0]), segment_numbers]) + has_undescribed_segments = len( + np.setdiff1d(pixel_array, numbers_with_bg) + ) != 0 + + if has_undescribed_segments: raise ValueError( 'Pixel array contains segments that lack ' 'descriptions.' @@ -2953,6 +2214,8 @@ def _check_and_cast_pixel_array( # cannot overlap segments_overlap = SegmentsOverlapValues.NO else: + max_pixel = pixel_array.max() + # Pixel array is 4D where each segment is stacked down # the last dimension # In this case, each segment of the pixel array should be binary @@ -3022,12 +2285,21 @@ def _check_and_cast_pixel_array( else: raise TypeError('Pixel array has an invalid data type.') + # Combine segments to create a labelmap image if needed if segmentation_type == SegmentationTypeValues.LABELMAP: if segments_overlap == SegmentsOverlapValues.YES: raise ValueError( - 'Segments may not overlap if requesting a LABELMAP ' - 'segmentation type.' + 'It is not possible to store a Segmentation with ' + 'SegmentationType "LABELMAP" if segments overlap.' + ) + + if pixel_array.ndim == 4: + pixel_array = cls._combine_segments( + pixel_array, + labelmap_dtype=dtype ) + else: + pixel_array = pixel_array.astype(dtype) return pixel_array, segments_overlap @@ -3080,7 +2352,6 @@ def _combine_segments( labelmap_dtype: type, ): """Combine multiple segments into a labelmap. - Parameters ---------- pixel_array: np.ndarray @@ -3089,13 +2360,11 @@ def _combine_segments( labelmap_dtype: type Numpy data type to use for the output array and intermediate calculations. - Returns ------- pixel_array: np.ndarray A 3D output array with consisting of the original segments combined into a labelmap. - """ if pixel_array.shape[3] == 1: # Optimization in case of one class @@ -3181,7 +2450,7 @@ def _get_nonempty_tile_indices( def _get_segment_pixel_array( pixel_array: np.ndarray, segment_number: int, - number_of_segments: int, + described_segment_numbers: np.ndarray, segmentation_type: SegmentationTypeValues, max_fractional_value: int, dtype: type, @@ -3201,8 +2470,8 @@ def _get_segment_pixel_array( Columns) in case of a "label map" style array. segment_number: int The segment of interest. - number_of_segments: int - Number of segments in the the segmentation. + described_segment_numbers: np.ndarray + Array of all segment numbers in the segmentation. segmentation_type: highdicom.seg.SegmentationTypeValues Desired output segmentation type. max_fractional_value: int @@ -3220,7 +2489,7 @@ def _get_segment_pixel_array( """ if pixel_array.dtype in (np.float32, np.float64): # Based on the previous checks and casting, if we get here the - # output is a FRACTIONAL segmentation Floating-point numbers must + # output is a FRACTIONAL segmentation. Floating-point numbers must # be mapped to 8-bit integers in the range [0, # max_fractional_value]. if pixel_array.ndim == 3: @@ -3234,11 +2503,11 @@ def _get_segment_pixel_array( else: if pixel_array.ndim == 2: # "Label maps" that must be converted to binary masks. - if number_of_segments == 1: + if np.array_equal(described_segment_numbers, np.array([1])): # We wish to avoid unnecessary comparison or casting # operations here, for efficiency reasons. If there is only - # a single segment, the label map pixel array is already - # correct + # a single segment with value 1, the label map pixel array + # is already correct if pixel_array.dtype != dtype: segment_array = pixel_array.astype(dtype) else: @@ -3264,78 +2533,6 @@ def _get_segment_pixel_array( return segment_array - @staticmethod - def _get_dimension_index_values( - unique_dimension_values: List[np.ndarray], - plane_position_value: np.ndarray, - coordinate_system: Optional[CoordinateSystemNames], - ) -> List[int]: - """Get Dimension Index Values for a frame. - - The Dimension Index Values are a list of integer indices that describe - the position of a frame as indices along each of the dimensions of - the Dimension Index Sequence. See - :class:`highdicom.seg.DimensionIndexSequence`. - - Parameters - ---------- - unique_dimension_values: List[numpy.ndarray] - List of arrays containing, for each dimension in the dimension - index sequence (except ReferencedSegment), the sorted unique - values of all planes along that dimension. Each array in the list - corresponds to one dimension, and has shape (N x m) where N is the - number of unique values for that dimension and m is the - multiplicity of values for that dimension. - plane_position_value: numpy.ndarray - Plane position of the plane. This is a 1D or 2D array containing - each of the raw values for this plane of the attributes listed as - dimension index pointers (except ReferencedSegment). For dimension - indices where the value multiplicity of all attributes is 1, the - array will be 1D. If the value multiplicity of attributes is - greater than 1, these values are stacked along the second - dimension. - coordinate_system: Optional[highdicom.CoordinateSystemNames] - The type of coordinate system used (if any). - - Returns - ------- - dimension_index_values: List[int] - The dimension index values (except the segment number) for the - given plane. - - """ - # Look up the position of the plane relative to the indexed - # dimension. - if ( - coordinate_system == - CoordinateSystemNames.SLIDE - ): - index_values = [ - int( - np.where( - unique_dimension_values[idx] == pos - )[0][0] + 1 - ) - for idx, pos in enumerate(plane_position_value) - ] - else: - # In case of the patient coordinate system, the - # value of the attribute the Dimension Index - # Sequence points to (Image Position Patient) has a - # value multiplicity greater than one. - index_values = [ - int( - np.where( - (unique_dimension_values[idx] == pos).all( - axis=1 - ) - )[0][0] + 1 - ) - for idx, pos in enumerate(plane_position_value) - ] - - return index_values - @staticmethod def _get_pffg_item( segment_number: Optional[int], @@ -3346,6 +2543,7 @@ def _get_pffg_item( are_spatial_locations_preserved: bool, has_ref_frame_uid: bool, coordinate_system: Optional[CoordinateSystemNames], + is_multiframe: bool, ) -> Dataset: """Get a single item of the Per Frame Functional Groups Sequence. @@ -3371,6 +2569,8 @@ def _get_pffg_item( Whether the sources images have a frame of reference UID. coordinate_system: Optional[highdicom.CoordinateSystemNames] Coordinate system used, if any. + is_multiframe: bool + Whether source images are multiframe. Returns ------- @@ -3449,7 +2649,7 @@ def _get_pffg_item( ) derivation_src_img_item = Dataset() - if 0x00280008 in source_images[0]: # NumberOfFrames + if is_multiframe: # A single multi-frame source image src_img_item = source_images[0] # Frame numbers are one-based @@ -3563,7 +2763,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'Segmentation': + ) -> Self: """Create instance from an existing dataset. Parameters @@ -3597,7 +2797,7 @@ def from_dataset( seg = deepcopy(dataset) else: seg = dataset - seg.__class__ = Segmentation + seg.__class__ = cls sf_groups = seg.SharedFunctionalGroupsSequence[0] if hasattr(seg, 'PlaneOrientationSequence'): @@ -3611,20 +2811,6 @@ def from_dataset( else: seg._coordinate_system = None - for i, segment in enumerate(seg.SegmentSequence, 1): - if segment.SegmentNumber != i: - raise AttributeError( - 'Segments are expected to start at 1 and be consecutive ' - 'integers.' - ) - - for i, s in enumerate(seg.SegmentSequence, 1): - if s.SegmentNumber != i: - raise ValueError( - 'Segment numbers in the segmentation image must start at ' - '1 and increase by 1 with the segments sequence.' - ) - # Convert contained items to highdicom types # Segment descriptions seg.SegmentSequence = [ @@ -3674,297 +2860,9 @@ def from_dataset( ) pffg_item.PixelMeasuresSequence = pixel_measures - seg._build_luts() - - return cast(Segmentation, seg) - - def _get_ref_instance_uids(self) -> List[Tuple[str, str, str]]: - """List all instances referenced in the segmentation. - - Returns - ------- - List[Tuple[str, str, str]] - List of all instances referenced in the segmentation in the format - (StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID). - - """ - instance_data = [] - if hasattr(self, 'ReferencedSeriesSequence'): - for ref_series in self.ReferencedSeriesSequence: - for ref_ins in ref_series.ReferencedInstanceSequence: - instance_data.append( - ( - self.StudyInstanceUID, - ref_series.SeriesInstanceUID, - ref_ins.ReferencedSOPInstanceUID - ) - ) - other_studies_kw = 'StudiesContainingOtherReferencedInstancesSequence' - if hasattr(self, other_studies_kw): - for ref_study in getattr(self, other_studies_kw): - for ref_series in ref_study.ReferencedSeriesSequence: - for ref_ins in ref_series.ReferencedInstanceSequence: - instance_data.append( - ( - ref_study.StudyInstanceUID, - ref_series.SeriesInstanceUID, - ref_ins.ReferencedSOPInstanceUID, - ) - ) - - # There shouldn't be duplicates here, but there's no explicit rule - # preventing it. - # Since dictionary ordering is preserved, this trick deduplicates - # the list without changing the order - unique_instance_data = list(dict.fromkeys(instance_data)) - if len(unique_instance_data) != len(instance_data): - counts = Counter(instance_data) - duplicate_sop_uids = [ - f"'{key[2]}'" for key, value in counts.items() if value > 1 - ] - display_str = ', '.join(duplicate_sop_uids) - logger.warning( - 'Duplicate entries found in the ReferencedSeriesSequence. ' - f"Segmentation SOP Instance UID: '{self.SOPInstanceUID}', " - f'duplicated referenced SOP Instance UID items: {display_str}.' - ) - - return unique_instance_data - - def _build_luts(self) -> None: - """Build lookup tables for efficient querying. - - Two lookup tables are currently constructed. The first maps the - SOPInstanceUIDs of all datasets referenced in the segmentation to a - tuple containing the StudyInstanceUID, SeriesInstanceUID and - SOPInstanceUID. - - The second look-up table contains information about each frame of the - segmentation, including the segment it contains, the instance and frame - from which it was derived (if these are unique), and its dimension - index values. - - """ - referenced_uids = self._get_ref_instance_uids() - all_referenced_sops = {uids[2] for uids in referenced_uids} - - is_tiled_full = ( - hasattr(self, 'DimensionOrganizationType') and - self.DimensionOrganizationType == 'TILED_FULL' - ) - - if self.segmentation_type == SegmentationTypeValues.LABELMAP: - segment_numbers = None - else: - segment_numbers = [] - - # Get list of all dimension index pointers, excluding the segment - # number, since this is treated differently - seg_num_tag = tag_for_keyword('ReferencedSegmentNumber') - self._dim_ind_pointers = [ - dim_ind.DimensionIndexPointer - for dim_ind in self.DimensionIndexSequence - if dim_ind.DimensionIndexPointer != seg_num_tag - ] - - func_grp_pointers = {} - for dim_ind in self.DimensionIndexSequence: - ptr = dim_ind.DimensionIndexPointer - if ptr in self._dim_ind_pointers: - grp_ptr = getattr(dim_ind, "FunctionalGroupPointer", None) - func_grp_pointers[ptr] = grp_ptr - - dim_ind_positions = { - dim_ind.DimensionIndexPointer: i - for i, dim_ind in enumerate(self.DimensionIndexSequence) - if dim_ind.DimensionIndexPointer != seg_num_tag - } - dim_indices: Dict[int, List[int]] = { - ptr: [] for ptr in self._dim_ind_pointers - } - dim_values: Dict[int, List[Any]] = { - ptr: [] for ptr in self._dim_ind_pointers - } - - self._single_source_frame_per_seg_frame = True - - if is_tiled_full: - # With TILED_FULL, there is no PerFrameFunctionalGroupsSequence, - # so we have to deduce the per-frame information - row_tag = tag_for_keyword('RowPositionInTotalImagePixelMatrix') - col_tag = tag_for_keyword('ColumnPositionInTotalImagePixelMatrix') - x_tag = tag_for_keyword('XOffsetInSlideCoordinateSystem') - y_tag = tag_for_keyword('YOffsetInSlideCoordinateSystem') - z_tag = tag_for_keyword('ZOffsetInSlideCoordinateSystem') - tiled_full_dim_indices = {row_tag, col_tag, x_tag, y_tag, z_tag} - if len(set(dim_indices.keys()) - tiled_full_dim_indices) > 0: - raise RuntimeError( - 'Expected segmentation images with ' - '"DimensionOrganizationType" of "TILED_FULL" are expected ' - 'to have the following dimension index pointers: ' - 'SegmentNumber, RowPositionInTotalImagePixelMatrix, ' - 'ColumnPositionInTotalImagePixelMatrix.' - ) - self._single_source_frame_per_seg_frame = False - ( - segment_numbers, - _, - dim_values[col_tag], - dim_values[row_tag], - dim_values[x_tag], - dim_values[y_tag], - dim_values[z_tag], - ) = zip(*iter_tiled_full_frame_data(self)) - - # In the case of a LABELMAP seg, segment_numbers will be a list of - # None objects at this point. But the _SegDBManager expects a - # single None - if self.segmentation_type == SegmentationTypeValues.LABELMAP: - segment_numbers = None - - # Create indices for each of the dimensions - for ptr, vals in dim_values.items(): - _, indices = np.unique(vals, return_inverse=True) - dim_indices[ptr] = (indices + 1).tolist() - - # There is no way to deduce whether the spatial locations are - # preserved in the tiled full case - self._locations_preserved = None - - referenced_instances = None - referenced_frames = None - else: - referenced_instances: Optional[List[str]] = [] - referenced_frames: Optional[List[int]] = [] - - # Create a list of source images and check for spatial locations - # preserved - locations_list_type = List[ - Optional[SpatialLocationsPreservedValues] - ] - locations_preserved: locations_list_type = [] - - for frame_item in self.PerFrameFunctionalGroupsSequence: - if self.segmentation_type != SegmentationTypeValues.LABELMAP: - # Get segment number for this frame - seg_id_seg = frame_item.SegmentIdentificationSequence[0] - seg_num = seg_id_seg.ReferencedSegmentNumber - segment_numbers.append(int(seg_num)) - - # Get dimension indices for this frame - content_seq = frame_item.FrameContentSequence[0] - indices = content_seq.DimensionIndexValues - if not isinstance(indices, (MultiValue, list)): - # In case there is a single dimension index - indices = [indices] - if self.segmentation_type == SegmentationTypeValues.LABELMAP: - n_expected_dim_ind_pointers = len(self._dim_ind_pointers) - else: - # (+1 because referenced segment number is ignored) - n_expected_dim_ind_pointers = ( - len(self._dim_ind_pointers) + 1 - ) - if len(indices) != n_expected_dim_ind_pointers: - raise RuntimeError( - 'Unexpected mismatch between dimension index values in ' - 'per-frames functional groups sequence and items in ' - 'the dimension index sequence.' - ) - for ptr in self._dim_ind_pointers: - dim_indices[ptr].append(indices[dim_ind_positions[ptr]]) - grp_ptr = func_grp_pointers[ptr] - if grp_ptr is not None: - dim_val = frame_item[grp_ptr][0][ptr].value - else: - dim_val = frame_item[ptr].value - dim_values[ptr].append(dim_val) - - frame_source_instances = [] - frame_source_frames = [] - for der_im in frame_item.DerivationImageSequence: - for src_im in der_im.SourceImageSequence: - frame_source_instances.append( - src_im.ReferencedSOPInstanceUID - ) - if hasattr(src_im, 'SpatialLocationsPreserved'): - locations_preserved.append( - SpatialLocationsPreservedValues( - src_im.SpatialLocationsPreserved - ) - ) - else: - locations_preserved.append( - None - ) - - if hasattr(src_im, 'ReferencedFrameNumber'): - if isinstance( - src_im.ReferencedFrameNumber, - MultiValue - ): - frame_source_frames.extend( - [ - int(f) - for f in src_im.ReferencedFrameNumber - ] - ) - else: - frame_source_frames.append( - int(src_im.ReferencedFrameNumber) - ) - else: - frame_source_frames.append(_NO_FRAME_REF_VALUE) - - if ( - len(set(frame_source_instances)) != 1 or - len(set(frame_source_frames)) != 1 - ): - self._single_source_frame_per_seg_frame = False - else: - ref_instance_uid = frame_source_instances[0] - if ref_instance_uid not in all_referenced_sops: - raise AttributeError( - f'SOP instance {ref_instance_uid} referenced in ' - 'the source image sequence is not included in the ' - 'Referenced Series Sequence or Studies Containing ' - 'Other Referenced Instances Sequence. This is an ' - 'error with the integrity of the Segmentation ' - 'object.' - ) - referenced_instances.append(ref_instance_uid) - referenced_frames.append(frame_source_frames[0]) + seg = super().from_dataset(seg, copy=False) - # Summarise - if any( - isinstance(v, SpatialLocationsPreservedValues) and - v == SpatialLocationsPreservedValues.NO - for v in locations_preserved - ): - Type = Optional[SpatialLocationsPreservedValues] - self._locations_preserved: Type = \ - SpatialLocationsPreservedValues.NO - elif all( - isinstance(v, SpatialLocationsPreservedValues) and - v == SpatialLocationsPreservedValues.YES - for v in locations_preserved - ): - self._locations_preserved = SpatialLocationsPreservedValues.YES - else: - self._locations_preserved = None - - if not self._single_source_frame_per_seg_frame: - referenced_instances = None - referenced_frames = None - - self._db_man = _SegDBManager( - number_of_frames=self.NumberOfFrames, - referenced_uids=referenced_uids, - segment_numbers=segment_numbers, - dim_indices=dim_indices, - dim_values=dim_values, - referenced_instances=referenced_instances, - referenced_frames=referenced_frames, - ) + return cast(cls, seg) @property def segmentation_type(self) -> SegmentationTypeValues: @@ -4009,7 +2907,7 @@ def number_of_segments(self) -> int: @property def segment_numbers(self) -> List[int]: - """range: The segment numbers of non-background segments present + """List[int]: The segment numbers of non-background segments present in the SEG image.""" if hasattr(self, 'PixelPaddingValue'): return [ @@ -4030,7 +2928,7 @@ def get_segment_description( Parameters ---------- segment_number: int - Segment number for the segment, as a 1-based index. + Segment number for the segment. Returns ------- @@ -4038,12 +2936,14 @@ def get_segment_description( Description of the given segment. """ - if segment_number < 1 or segment_number > self.number_of_segments: - raise IndexError( - f'{segment_number} is an invalid segment number for this ' - 'dataset.' - ) - return self.SegmentSequence[segment_number - 1] + for desc in self.SegmentSequence: + if desc.segment_number == segment_number: + return desc + + raise IndexError( + f'{segment_number} is an invalid segment number for this ' + 'dataset.' + ) def get_segment_numbers( self, @@ -4054,7 +2954,7 @@ def get_segment_numbers( tracking_uid: Optional[str] = None, tracking_id: Optional[str] = None, ) -> List[int]: - """Get a list of segment numbers matching provided criteria. + """Get a list of non-background segment numbers with given criteria. Any number of optional filters may be provided. A segment must match all provided filters to be included in the returned list. @@ -4077,7 +2977,8 @@ def get_segment_numbers( Returns ------- List[int] - List of all segment numbers matching the provided criteria. + List of all non-background segment numbers matching the provided + criteria. Examples -------- @@ -4144,6 +3045,10 @@ def get_segment_numbers( filter_funcs.append( lambda desc: desc.tracking_id == tracking_id ) + if hasattr(self, 'PixelPaddingValue'): + filter_funcs.append( + lambda desc: desc.segment_number != self.PixelPaddingValue + ) return [ desc.segment_number @@ -4240,17 +3145,24 @@ def get_tracking_ids( @property def segmented_property_categories(self) -> List[CodedConcept]: - """Get all unique segmented property categories in this SEG image. + """Get all unique non-background segmented property categories. Returns ------- List[CodedConcept] - All unique segmented property categories referenced in segment - descriptions in this SEG image. + All unique non-background segmented property categories referenced + in segment descriptions in this SEG image. """ categories = [] for desc in self.SegmentSequence: + if ( + 'PixelPaddingValue' in self and + desc.segment_number == self.PixelPaddingValue + ): + # Skip background segment + continue + if desc.segmented_property_category not in categories: categories.append(desc.segmented_property_category) @@ -4258,17 +3170,24 @@ def segmented_property_categories(self) -> List[CodedConcept]: @property def segmented_property_types(self) -> List[CodedConcept]: - """Get all unique segmented property types in this SEG image. + """Get all unique non-background segmented property types. Returns ------- List[CodedConcept] - All unique segmented property types referenced in segment - descriptions in this SEG image. + All unique non-background segmented property types referenced in + segment descriptions in this SEG image. """ types = [] for desc in self.SegmentSequence: + if ( + 'PixelPaddingValue' in self and + desc.segment_number == self.PixelPaddingValue + ): + # Skip background segment + continue + if desc.segmented_property_type not in types: types.append(desc.segmented_property_type) @@ -4276,12 +3195,13 @@ def segmented_property_types(self) -> List[CodedConcept]: def _get_pixels_by_seg_frame( self, - output_shape: Union[int, Tuple[int, int]], + spatial_shape: Union[int, Tuple[int, int]], indices_iterator: Iterator[ Tuple[ + int, Tuple[Union[slice, int], ...], Tuple[Union[slice, int], ...], - int + Tuple[int, ...], ] ], segment_numbers: np.ndarray, @@ -4290,6 +3210,8 @@ def _get_pixels_by_seg_frame( rescale_fractional: bool = True, skip_overlap_checks: bool = False, dtype: Union[type, str, np.dtype, None] = None, + apply_palette_color_lut: bool = False, + apply_icc_profile: bool | None = None, ) -> np.ndarray: """Construct a segmentation array given an array of frame numbers. @@ -4300,26 +3222,27 @@ def _get_pixels_by_seg_frame( Parameters ---------- output_shape: Union[int, Tuple[int, int]] - Shape of the output array. If an integer is False, this is the + Shape of the output array. If an integer, this is the number of frames in the output array and the number of rows and columns are taken to match those of each segmentation frame. If a tuple of integers, it contains the number of (rows, columns) in the output array and there is no frame dimension (this is the tiled case). Note in either case, the segments dimension (if relevant) is omitted. - indices_iterator: Iterator[Tuple[Tuple[Union[slice, int], ...], Tuple[Union[slice, int], ...], int ]] - An iterable object that yields tuples of (output_indexer, - segmentation_indexer, output_segment_number) that describes how to - construct the desired output pixel array from the segmentation - image's pixel array. 'output_indexer' is a tuple that may be used - directly to index the output array to place a single frame's pixels - into the output array. Similarly 'segmentation_indexer' is a tuple - that may be used directly to index the segmentation pixel array - to retrieve the pixels to place into the output array. - with as segment number 'output_segment_number'. Note that in both - cases the indexers access the frame, row and column dimensions of - the relevant array, but not the segment dimension (if relevant). - segment_numbers: np.ndarray + indices_iterator: Iterator[Tuple[int, Tuple[Union[slice, int], ...], Tuple[Union[slice, int], ...], Tuple[int, ...]]] + An iterable object that yields tuples of (frame_index, + input_indexer, spatial_indexer, channel_indexer) that describes how + to construct the desired output pixel array from the multiframe + image's pixel array. 'frame_index' specifies the zero-based index + of the input frame and 'input_indexer' is a tuple that may be used + directly to index a region of that frame. 'spatial_indexer' is a + tuple that may be used directly to index the output array to place + a single frame's pixels into the output array (excluding the + channel dimensions). The 'channel_indexer' indexes a channel of the + output array into which the result should be placed. Note that in + both cases the indexers access the frame, row and column dimensions + of the relevant array, but not the channel dimension (if relevant). + segment_numbers: numpy.ndarray One dimensional numpy array containing segment numbers corresponding to the columns of the seg frames matrix. combine_segments: bool @@ -4353,6 +3276,16 @@ def _get_pixels_by_seg_frame( fractional values, this will be numpy.float32. Otherwise, the smallest unsigned integer type that accommodates all of the output values will be chosen. + apply_palette_color_lut: bool, optional + If True, apply the palette color LUT to give RGB output values. + This is only valid for ``"LABELMAP"`` segmentations that contain + palette color LUT information, and only when ``combine_segments`` + is ``True`` and ``relabel`` is ``False``. + apply_icc_profile: bool, optional + If True apply an ICC profile to the output and require it to be + present. If None, apply an ICC profile if found but do not require + it to be present. If False, never apply an ICC profile. Only + possible when ``apply_palette_color_lut`` is True. Returns ------- @@ -4360,10 +3293,7 @@ def _get_pixels_by_seg_frame( Segmentation pixel array """ # noqa: E501 - if ( - segment_numbers.min() < 1 or - segment_numbers.max() > self.number_of_segments - ): + if not np.all(np.isin(segment_numbers, self.segment_numbers)): raise ValueError( 'Segment numbers array contains invalid values.' ) @@ -4389,11 +3319,131 @@ def _get_pixels_by_seg_frame( dtype = np.dtype(dtype) # Check dtype is suitable - if dtype.kind not in ('u', 'i', 'f'): + if dtype.kind not in ('u', 'i', 'f', 'b'): raise ValueError( f'Data type "{dtype}" is not suitable.' ) + _check_numpy_value_representation(max_output_val, dtype) + num_output_segments = len(segment_numbers) + + if not isinstance(apply_palette_color_lut, bool): + raise ValueError( + "'apply_palette_color_lut' must have type bool" + ) + if apply_palette_color_lut: + if not combine_segments or relabel: + raise ValueError( + "'apply_palette_color_lut' requires that 'combine_segments' " + "is True and relabel is False." + ) + else: + apply_icc_profile = False + if apply_icc_profile and not apply_palette_color_lut: + raise ValueError( + "'apply_icc_profile' requires that 'apply_palette_color_lut' " + "is True." + ) + + if self.segmentation_type == SegmentationTypeValues.LABELMAP: + + if apply_palette_color_lut: + # Remap is handled by the frame transform + need_remap = False + # Any segment not requested is mapped to zero + # note that this assumes the background is RGB(0, 0, 0) + remove_palette_color_values = [ + s for s in self.segment_numbers + if s not in segment_numbers + ] + else: + if not combine_segments or relabel: + # If combining segments (i.e. if expanding segments), + # always remap the segments for the one-hot later on. + output_segment_numbers = np.arange(1, len(segment_numbers)) + need_remap = not np.array_equal( + segment_numbers, + output_segment_numbers + ) + else: + # Combining segments without relabelling. Need to remap if + # any existing segments are not requested, but order is not + # important + need_remap = len( + np.setxor1d( + segment_numbers, + self.segment_numbers + ) + ) > 1 + remove_palette_color_values = None + + intermediate_dtype = ( + _get_unsigned_dtype(self.BitsStored) + if need_remap else dtype + ) + + out_array = self._get_pixels_by_frame( + spatial_shape=spatial_shape, + indices_iterator=indices_iterator, + apply_real_world_transform=False, + apply_modality_transform=False, + apply_palette_color_lut=apply_palette_color_lut, + apply_icc_profile=apply_icc_profile, + remove_palette_color_values=remove_palette_color_values, + palette_color_background_index=self.get( + 'PixelPaddingValue', + 0 + ), + dtype=intermediate_dtype, + ) + + if need_remap: + num_input_segments = max(self.segment_numbers) + 1 + stored_bg_val = self.get('PixelPaddingValue', 0) + num_input_segments = max(stored_bg_val + 1, num_input_segments) + remap_dtype = ( + dtype if combine_segments else intermediate_dtype + ) + remapping = np.zeros(num_input_segments + 1, dtype=remap_dtype) + if combine_segments and not relabel: + # A remapping that just sets unused segments to the + # background value + for s in range(num_input_segments): + remapping[s] = ( + s if s in segment_numbers + else stored_bg_val + ) + else: + # A remapping that applies relabelling logic + output_bg_val = 0 # relabel changes background value + for s in range(num_input_segments + 1): + remapping[s] = ( + np.nonzero(segment_numbers == s)[0][0] + 1 + if s in segment_numbers + else output_bg_val + ) + + out_array = remapping[out_array] + + if not combine_segments: + # Obscure trick to calculate one-hot. By this point, whatever + # segments were requested will have been remapped to the + # numbers 1, 2, ... in the order expected in the output + # channels + shape = out_array.shape + flat_array = out_array.flatten() + out_array = np.eye( + num_output_segments + 1, + dtype=dtype, + )[flat_array] + + out_shape = (*shape, num_output_segments) + + # Remove the background segment (channel 0) + out_array = out_array[:, 1:].reshape(out_shape) + + return out_array + if will_be_rescaled: intermediate_dtype = np.uint8 if dtype.kind != 'f': @@ -4403,13 +3453,6 @@ def _get_pixels_by_seg_frame( ) else: intermediate_dtype = dtype - _check_numpy_value_representation(max_output_val, dtype) - - num_segments = len(segment_numbers) - if self.pixel_array.ndim == 2: - h, w = self.pixel_array.shape - else: - _, h, w = self.pixel_array.shape if combine_segments: # Check whether segmentation is binary, or fractional with only @@ -4421,33 +3464,12 @@ def _get_pixels_by_seg_frame( 'segmentation image, argument "rescale_fractional" ' 'must be set to True.' ) - # Combining fractional segs is only possible if there are - # two unique values in the array: 0 and MaximumFractionalValue - is_binary = np.isin( - np.unique(self.pixel_array), - np.array([0, self.MaximumFractionalValue]), - assume_unique=True - ).all() - if not is_binary: - raise ValueError( - 'Combining segments of a FRACTIONAL segmentation is ' - 'only possible if the pixel array contains only 0s ' - 'and the specified MaximumFractionalValue ' - f'({self.MaximumFractionalValue}).' - ) - pixel_array = self.pixel_array // self.MaximumFractionalValue - pixel_array = pixel_array.astype(np.uint8) - else: - pixel_array = self.pixel_array - - if pixel_array.ndim == 2: - pixel_array = pixel_array[None, :, :] # Initialize empty pixel array full_output_shape = ( - output_shape - if isinstance(output_shape, tuple) - else (output_shape, h, w) + spatial_shape + if isinstance(spatial_shape, tuple) + else (spatial_shape, self.Rows, self.Columns) ) out_array = np.zeros( full_output_shape, @@ -4455,13 +3477,35 @@ def _get_pixels_by_seg_frame( ) # Loop over the supplied iterable - for (output_indexer, seg_indexer, seg_n) in indices_iterator: - pix_value = intermediate_dtype.type(seg_n) + for (frame_index, input_indexer, output_indexer, seg_n) in indices_iterator: + pix_value = intermediate_dtype.type(seg_n[0]) + + pixel_array = self.get_stored_frame(frame_index + 1) + pixel_array = pixel_array[input_indexer] + + if self.segmentation_type == SegmentationTypeValues.FRACTIONAL: + # Combining fractional segs is only possible if there are + # two unique values in the array: 0 and MaximumFractionalValue + is_binary = np.isin( + np.unique(pixel_array), + np.array([0, self.MaximumFractionalValue]), + assume_unique=True + ).all() + if not is_binary: + raise ValueError( + 'Combining segments of a FRACTIONAL segmentation is ' + 'only possible if the pixel array contains only 0s ' + 'and the specified MaximumFractionalValue ' + f'({self.MaximumFractionalValue}).' + ) + pixel_array = pixel_array // self.MaximumFractionalValue + if pixel_array.dtype != np.uint8: + pixel_array = pixel_array.astype(np.uint8) if not skip_overlap_checks: if np.any( np.logical_and( - pixel_array[seg_indexer] > 0, + pixel_array > 0, out_array[output_indexer] > 0 ) ): @@ -4470,37 +3514,22 @@ def _get_pixels_by_seg_frame( "overlap." ) out_array[output_indexer] = np.maximum( - pixel_array[seg_indexer] * pix_value, + pixel_array * pix_value, out_array[output_indexer] ) else: - # Initialize empty pixel array - full_output_shape = ( - (*output_shape, num_segments) - if isinstance(output_shape, tuple) - else (output_shape, h, w, num_segments) - ) - out_array = np.zeros( - full_output_shape, - dtype=intermediate_dtype + out_array = self._get_pixels_by_frame( + spatial_shape=spatial_shape, + indices_iterator=indices_iterator, + channel_shape=(num_output_segments, ), + apply_real_world_transform=False, + apply_modality_transform=False, + apply_palette_color_lut=apply_palette_color_lut, + apply_icc_profile=apply_icc_profile, + dtype=intermediate_dtype, ) - # loop through output frames - for (output_indexer, seg_indexer, seg_n) in indices_iterator: - - # Output indexer needs segment index - output_indexer = (*output_indexer, seg_n) - - # Copy data to to output array - if self.pixel_array.ndim == 2: - # Special case with a single segmentation frame - out_array[output_indexer] = \ - self.pixel_array.copy() - else: - out_array[output_indexer] = \ - self.pixel_array[seg_indexer].copy() - if rescale_fractional: if self.segmentation_type == SegmentationTypeValues.FRACTIONAL: if out_array.max() > self.MaximumFractionalValue: @@ -4514,19 +3543,6 @@ def _get_pixels_by_seg_frame( return out_array - def get_source_image_uids(self) -> List[Tuple[hd_UID, hd_UID, hd_UID]]: - """Get UIDs for all source SOP instances referenced in the dataset. - - Returns - ------- - List[Tuple[highdicom.UID, highdicom.UID, highdicom.UID]] - List of tuples containing Study Instance UID, Series Instance UID - and SOP Instance UID for every SOP Instance referenced in the - dataset. - - """ - return self._db_man.get_source_image_uids() - def get_default_dimension_index_pointers( self ) -> List[BaseTag]: @@ -4546,7 +3562,11 @@ def get_default_dimension_index_pointers( List of tags used as the default dimension index pointers. """ - return self._dim_ind_pointers[:] + referenced_segment_number = tag_for_keyword('ReferencedSegmentNumber') + return [ + t for t in self.dimension_index_pointers + if t != referenced_segment_number + ] def are_dimension_indices_unique( self, @@ -4582,8 +3602,9 @@ def are_dimension_indices_unique( raise ValueError( 'Argument "dimension_index_pointers" may not be empty.' ) + dimension_index_pointers = list(dimension_index_pointers) for ptr in dimension_index_pointers: - if ptr not in self._dim_ind_pointers: + if ptr not in self.dimension_index_pointers: kw = keyword_for_tag(ptr) if kw == '': kw = '' @@ -4591,70 +3612,52 @@ def are_dimension_indices_unique( f'Tag {ptr} ({kw}) is not used as a dimension index ' 'in this image.' ) - return self._db_man.are_dimension_indices_unique( + + if self.segmentation_type != SegmentationTypeValues.LABELMAP: + dimension_index_pointers.append( + tag_for_keyword('ReferencedSegmentNumber') + ) + return super().are_dimension_indices_unique( dimension_index_pointers ) - def _check_indexing_with_source_frames( + def _get_segment_remap_values( self, - ignore_spatial_locations: bool = False - ) -> None: - """Check if indexing by source frames is possible. - - Raise exceptions with useful messages otherwise. - - Possible problems include: - * Spatial locations are not preserved. - * The dataset does not specify that spatial locations are preserved - and the user has not asserted that they are. - * At least one frame in the segmentation lists multiple - source frames. + segment_numbers: Sequence[int], + combine_segments: bool, + relabel: bool, + ): + """Get output segment numbers for retrieving pixels. Parameters ---------- - ignore_spatial_locations: bool - Allows the user to ignore whether spatial locations are preserved - in the frames. + segment_numbers: Union[Sequence[int], None] + Sequence containing segment numbers to include. + combine_segments: bool, optional + If True, combine the different segments into a single label + map in which the value of a pixel represents its segment. + If False, segments are binary and stacked down the + last dimension of the output array. + relabel: bool, optional + If True and ``combine_segments`` is ``True``, the pixel values in + the output array are relabelled into the range ``0`` to + ``len(segment_numbers)`` (inclusive) according to the position of + the original segment numbers in ``segment_numbers`` parameter. If + ``combine_segments`` is ``False``, this has no effect. + + Returns + ------- + Optional[Sequence[int]]: + Sequence of output segments for each item of the input segment + numbers, or None if no remapping is required. """ - # Checks that it is possible to index using source frames in this - # dataset - is_tiled_full = ( - hasattr(self, 'DimensionOrganizationType') and - self.DimensionOrganizationType == 'TILED_FULL' - ) - if is_tiled_full: - raise RuntimeError( - 'Indexing via source frames is not possible when a ' - 'segmentation is stored using the DimensionOrganizationType ' - '"TILED_FULL".' - ) - elif self._locations_preserved is None: - if not ignore_spatial_locations: - raise RuntimeError( - 'Indexing via source frames is not permissible since this ' - 'image does not specify that spatial locations are ' - 'preserved in the course of deriving the segmentation ' - 'from the source image. If you are confident that spatial ' - 'locations are preserved, or do not require that spatial ' - 'locations are preserved, you may override this behavior ' - "with the 'ignore_spatial_locations' parameter." - ) - elif self._locations_preserved == SpatialLocationsPreservedValues.NO: - if not ignore_spatial_locations: - raise RuntimeError( - 'Indexing via source frames is not permissible since this ' - 'image specifies that spatial locations are not preserved ' - 'in the course of deriving the segmentation from the ' - 'source image. If you do not require that spatial ' - ' locations are preserved you may override this behavior ' - "with the 'ignore_spatial_locations' parameter." - ) - if not self._single_source_frame_per_seg_frame: - raise RuntimeError( - 'Indexing via source frames is not permissible since some ' - 'frames in the segmentation specify multiple source frames.' - ) + if combine_segments: + if relabel: + return range(1, len(segment_numbers) + 1) + else: + return segment_numbers + return None def get_pixels_by_source_instance( self, @@ -4667,6 +3670,9 @@ def get_pixels_by_source_instance( rescale_fractional: bool = True, skip_overlap_checks: bool = False, dtype: Union[type, str, np.dtype, None] = None, + apply_palette_color_lut: bool = False, + apply_icc_profile: bool | None = None, + allow_missing_frames: bool = True, ) -> np.ndarray: """Get a pixel array for a list of source instances. @@ -4711,6 +3717,13 @@ def get_pixels_by_source_instance( segments). In this case, the values in the output pixel array will always lie in the range ``0`` to ``len(segment_numbers)`` inclusive. + With ``"LABELMAP"`` segmentations that use the ``"PALETTE COLOR"`` + photometric interpretation, the ``apply_palette_color_lut`` parameter + may be used to produce a color image in which each segment is given an + RGB defined in a palette color LUT within the segmentation object. + The three color channels (RGB) will be stacked down the final (4th) + dimension of the pixel array. + Parameters ---------- source_sop_instance_uids: str @@ -4773,10 +3786,24 @@ def get_pixels_by_source_instance( fractional values, this will be numpy.float32. Otherwise, the smallest unsigned integer type that accommodates all of the output values will be chosen. + apply_palette_color_lut: bool, optional + If True, apply the palette color LUT to give RGB output values. + This is only valid for ``"LABELMAP"`` segmentations that contain + palette color LUT information, and only when ``combine_segments`` + is ``True`` and ``relabel`` is ``False``. + apply_icc_profile: bool, optional + If True apply an ICC profile to the output and require it to be + present. If None, apply an ICC profile if found but do not require + it to be present. If False, never apply an ICC profile. Only + possible when ``apply_palette_color_lut`` is True. + allow_missing_frames: bool, optional + Allow frames in the output array to be blank because these frames + are omitted from the image. If False and missing frames are found, + an error is raised. Returns ------- - pixel_array: np.ndarray + pixel_array: numpy.ndarray Pixel array representing the segmentation. See notes for full explanation. @@ -4811,11 +3838,13 @@ def get_pixels_by_source_instance( """ # Check that indexing in this way is possible - self._check_indexing_with_source_frames(ignore_spatial_locations) + self._check_indexing_with_source_frames( + ignore_spatial_locations + ) # Checks on validity of the inputs if segment_numbers is None: - segment_numbers = list(self.segment_numbers) + segment_numbers = self.segment_numbers if len(segment_numbers) == 0: raise ValueError( 'Segment numbers may not be empty.' @@ -4832,7 +3861,10 @@ def get_pixels_by_source_instance( # Check that the combination of source instances and segment numbers # uniquely identify segmentation frames - if not self._db_man.are_referenced_sop_instances_unique(): + columns = ['ReferencedSOPInstanceUID'] + if self.segmentation_type != SegmentationTypeValues.LABELMAP: + columns.append('ReferencedSegmentNumber') + if not self._are_columns_unique(columns): raise RuntimeError( 'Source SOP instance UIDs and segment numbers do not ' 'uniquely identify frames of the segmentation image.' @@ -4840,7 +3872,9 @@ def get_pixels_by_source_instance( # Check that all frame numbers requested actually exist if not assert_missing_frames_are_empty: - unique_uids = self._db_man.get_unique_sop_instance_uids() + unique_uids = ( + self._get_unique_referenced_sop_instance_uids() + ) missing_uids = set(source_sop_instance_uids) - unique_uids if len(missing_uids) > 0: msg = ( @@ -4851,15 +3885,28 @@ def get_pixels_by_source_instance( ) raise KeyError(msg) - with self._db_man.iterate_indices_by_source_instance( - source_sop_instance_uids=source_sop_instance_uids, - segment_numbers=segment_numbers, + remap_channel_indices = self._get_segment_remap_values( + segment_numbers, combine_segments=combine_segments, - relabel=relabel, + relabel=relabel + ) + + if self.segmentation_type == SegmentationTypeValues.LABELMAP: + channel_indices = None + else: + channel_indices = [{'ReferencedSegmentNumber': segment_numbers}] + + with self._iterate_indices_for_stack( + stack_indices={ + 'ReferencedSOPInstanceUID': source_sop_instance_uids + }, + channel_indices=channel_indices, + remap_channel_indices=[remap_channel_indices], + allow_missing_frames=allow_missing_frames, ) as indices: return self._get_pixels_by_seg_frame( - output_shape=len(source_sop_instance_uids), + spatial_shape=len(source_sop_instance_uids), indices_iterator=indices, segment_numbers=np.array(segment_numbers), combine_segments=combine_segments, @@ -4867,6 +3914,8 @@ def get_pixels_by_source_instance( rescale_fractional=rescale_fractional, skip_overlap_checks=skip_overlap_checks, dtype=dtype, + apply_palette_color_lut=apply_palette_color_lut, + apply_icc_profile=apply_icc_profile, ) def get_pixels_by_source_frame( @@ -4881,6 +3930,9 @@ def get_pixels_by_source_frame( rescale_fractional: bool = True, skip_overlap_checks: bool = False, dtype: Union[type, str, np.dtype, None] = None, + apply_palette_color_lut: bool = False, + apply_icc_profile: bool | None = None, + allow_missing_frames: bool = True, ): """Get a pixel array for a list of frames within a source instance. @@ -4927,6 +3979,13 @@ def get_pixels_by_source_frame( segments). In this case, the values in the output pixel array will always lie in the range ``0`` to ``len(segment_numbers)`` inclusive. + With ``"LABELMAP"`` segmentations that use the ``"PALETTE COLOR"`` + photometric interpretation, the ``apply_palette_color_lut`` parameter + may be used to produce a color image in which each segment is given an + RGB defined in a palette color LUT within the segmentation object. + The three color channels (RGB) will be stacked down the final (4th) + dimension of the pixel array. + Parameters ---------- source_sop_instance_uid: str @@ -4992,10 +4051,24 @@ def get_pixels_by_source_frame( fractional values, this will be numpy.float32. Otherwise, the smallest unsigned integer type that accommodates all of the output values will be chosen. + apply_palette_color_lut: bool, optional + If True, apply the palette color LUT to give RGB output values. + This is only valid for ``"LABELMAP"`` segmentations that contain + palette color LUT information, and only when ``combine_segments`` + is ``True`` and ``relabel`` is ``False``. + apply_icc_profile: bool, optional + If True apply an ICC profile to the output and require it to be + present. If None, apply an ICC profile if found but do not require + it to be present. If False, never apply an ICC profile. Only + possible when ``apply_palette_color_lut`` is True. + allow_missing_frames: bool, optional + Allow frames in the output array to be blank because these frames + are omitted from the image. If False and missing frames are found, + an error is raised. Returns ------- - pixel_array: np.ndarray + pixel_array: numpy.ndarray Pixel array representing the segmentation. See notes for full explanation. @@ -5063,7 +4136,9 @@ def get_pixels_by_source_frame( """ # Check that indexing in this way is possible - self._check_indexing_with_source_frames(ignore_spatial_locations) + self._check_indexing_with_source_frames( + ignore_spatial_locations + ) # Checks on validity of the inputs if segment_numbers is None: @@ -5084,7 +4159,10 @@ def get_pixels_by_source_frame( # Check that the combination of frame numbers and segment numbers # uniquely identify segmentation frames - if not self._db_man.are_referenced_frames_unique(): + columns = ['ReferencedFrameNumber'] + if self.segmentation_type != SegmentationTypeValues.LABELMAP: + columns.append('ReferencedSegmentNumber') + if not self._are_columns_unique(columns): raise RuntimeError( 'Source frame numbers and segment numbers do not ' 'uniquely identify frames of the segmentation image.' @@ -5092,7 +4170,9 @@ def get_pixels_by_source_frame( # Check that all frame numbers requested actually exist if not assert_missing_frames_are_empty: - max_frame_number = self._db_man.get_max_frame_number() + max_frame_number = ( + self._get_max_referenced_frame_number() + ) for f in source_frame_numbers: if f > max_frame_number: msg = ( @@ -5104,16 +4184,204 @@ def get_pixels_by_source_frame( ) raise ValueError(msg) - with self._db_man.iterate_indices_by_source_frame( - source_sop_instance_uid=source_sop_instance_uid, - source_frame_numbers=source_frame_numbers, - segment_numbers=segment_numbers, + if self.segmentation_type == SegmentationTypeValues.LABELMAP: + channel_indices = None + else: + channel_indices = [{'ReferencedSegmentNumber': segment_numbers}] + + remap_channel_indices = self._get_segment_remap_values( + segment_numbers, combine_segments=combine_segments, - relabel=relabel, + relabel=relabel + ) + + with self._iterate_indices_for_stack( + stack_indices={'ReferencedFrameNumber': source_frame_numbers}, + channel_indices=channel_indices, + remap_channel_indices=[remap_channel_indices], + allow_missing_frames=allow_missing_frames, ) as indices: return self._get_pixels_by_seg_frame( - output_shape=len(source_frame_numbers), + spatial_shape=len(source_frame_numbers), + indices_iterator=indices, + segment_numbers=np.array(segment_numbers), + combine_segments=combine_segments, + relabel=relabel, + rescale_fractional=rescale_fractional, + skip_overlap_checks=skip_overlap_checks, + dtype=dtype, + apply_palette_color_lut=apply_palette_color_lut, + apply_icc_profile=apply_icc_profile, + ) + + def get_volume( + self, + *, + slice_start: int = 0, + slice_end: Optional[int] = None, + segment_numbers: Optional[Sequence[int]] = None, + combine_segments: bool = False, + relabel: bool = False, + rescale_fractional: bool = True, + skip_overlap_checks: bool = False, + dtype: Union[type, str, np.dtype, None] = None, + apply_palette_color_lut: bool = False, + apply_icc_profile: bool | None = None, + allow_missing_frames: bool = True, + ) -> Volume: + """Create a :class:`highdicom.Volume` from the segmentation. + + This is only possible if the segmentation represents a regularly-spaced + 3D volume. + + Parameters + ---------- + slice_start: int, optional + Zero-based index of the "volume position" of the first slice of the + returned volume. The "volume position" refers to the position of + slices after sorting spatially, and may correspond to any frame in + the segmentation file, depending on its construction. May be + negative, in which case standard Python indexing behavior is + followed (-1 corresponds to the last volume position, etc). + slice_end: Union[int, None], optional + Zero-based index of the "volume position" one beyond the last slice + of the returned volume. The "volume position" refers to the + position of slices after sorting spatially, and may correspond to + any frame in the segmentation file, depending on its construction. + May be negative, in which case standard Python indexing behavior is + followed (-1 corresponds to the last volume position, etc). If + None, the last volume position is included as the last output + slice. + segment_numbers: Optional[Sequence[int]], optional + Sequence containing segment numbers to include. If unspecified, + all segments are included. + combine_segments: bool, optional + If True, combine the different segments into a single label + map in which the value of a pixel represents its segment. + If False (the default), segments are binary and stacked down the + last dimension of the output array. + relabel: bool, optional + If True and ``combine_segments`` is ``True``, the pixel values in + the output array are relabelled into the range ``0`` to + ``len(segment_numbers)`` (inclusive) according to the position of + the original segment numbers in ``segment_numbers`` parameter. If + ``combine_segments`` is ``False``, this has no effect. + rescale_fractional: bool + If this is a FRACTIONAL segmentation and ``rescale_fractional`` is + True, the raw integer-valued array stored in the segmentation image + output will be rescaled by the MaximumFractionalValue such that + each pixel lies in the range 0.0 to 1.0. If False, the raw integer + values are returned. If the segmentation has BINARY type, this + parameter has no effect. + skip_overlap_checks: bool + If True, skip checks for overlap between different segments. By + default, checks are performed to ensure that the segments do not + overlap. However, this reduces performance. If checks are skipped + and multiple segments do overlap, the segment with the highest + segment number (after relabelling, if applicable) will be placed + into the output array. + dtype: Union[type, str, numpy.dtype, None] + Data type of the returned array. If None, an appropriate type will + be chosen automatically. If the returned values are rescaled + fractional values, this will be numpy.float32. Otherwise, the + smallest unsigned integer type that accommodates all of the output + values will be chosen. + apply_palette_color_lut: bool, optional + If True, apply the palette color LUT to give RGB output values. + This is only valid for ``"LABELMAP"`` segmentations that contain + palette color LUT information, and only when ``combine_segments`` + is ``True`` and ``relabel`` is ``False``. + apply_icc_profile: bool, optional + If True apply an ICC profile to the output and require it to be + present. If None, apply an ICC profile if found but do not require + it to be present. If False, never apply an ICC profile. Only + possible when ``apply_palette_color_lut`` is True. + allow_missing_frames: bool, optional + Allow frames in the output array to be blank because these frames + are omitted from the image. If False and missing frames are found, + an error is raised. + + """ + # Checks on validity of the inputs + if segment_numbers is None: + segment_numbers = list(self.segment_numbers) + if len(segment_numbers) == 0: + raise ValueError( + 'Segment numbers may not be empty.' + ) + + if self.volume_geometry is None: + raise RuntimeError( + "This segmentation is not a regularly-spaced 3D volume." + ) + n_vol_positions = self.volume_geometry.spatial_shape[0] + + # Check that the combination of frame numbers and segment numbers + # uniquely identify segmentation frames + columns = ['VolumePosition'] + if self.segmentation_type != SegmentationTypeValues.LABELMAP: + columns.append('ReferencedSegmentNumber') + if not self._are_columns_unique(columns): + raise RuntimeError( + 'Volume positions and segment numbers do not ' + 'uniquely identify frames of the segmentation image.' + ) + + if slice_start < 0: + slice_start = n_vol_positions + slice_start + + if slice_end is None: + slice_end = n_vol_positions + elif slice_end > n_vol_positions: + raise IndexError( + f"Value of {slice_end} is not valid for segmentation with " + f"{n_vol_positions} volume positions." + ) + elif slice_end < 0: + if slice_end < (- n_vol_positions): + raise IndexError( + f"Value of {slice_end} is not valid for segmentation with " + f"{n_vol_positions} volume positions." + ) + slice_end = n_vol_positions + slice_end + + number_of_slices = cast(int, slice_end) - slice_start + + if number_of_slices < 1: + raise ValueError( + "The combination of 'slice_start' and 'slice_end' gives an " + "empty volume." + ) + + remap_channel_indices = self._get_segment_remap_values( + segment_numbers, + combine_segments=combine_segments, + relabel=relabel + ) + + volume_positions = range(slice_start, slice_end) + + if self.segmentation_type == SegmentationTypeValues.LABELMAP: + channel_indices = None + else: + channel_indices = [{'ReferencedSegmentNumber': segment_numbers}] + + channel_spec = None + if not combine_segments: + channel_spec = {'ReferencedSegmentNumber': segment_numbers} + if apply_palette_color_lut: + channel_spec = {RGB_COLOR_CHANNEL_IDENTIFIER: ['R', 'G', 'B']} + + with self._iterate_indices_for_stack( + stack_indices={'VolumePosition': volume_positions}, + channel_indices=channel_indices, + remap_channel_indices=[remap_channel_indices], + allow_missing_frames=allow_missing_frames, + ) as indices: + + array = self._get_pixels_by_seg_frame( + spatial_shape=number_of_slices, indices_iterator=indices, segment_numbers=np.array(segment_numbers), combine_segments=combine_segments, @@ -5121,8 +4389,19 @@ def get_pixels_by_source_frame( rescale_fractional=rescale_fractional, skip_overlap_checks=skip_overlap_checks, dtype=dtype, + apply_palette_color_lut=apply_palette_color_lut, + apply_icc_profile=apply_icc_profile, ) + affine = self.volume_geometry[slice_start].affine + + return Volume( + array=array, + affine=affine, + frame_of_reference_uid=self.FrameOfReferenceUID, + channels=channel_spec, + ) + def get_pixels_by_dimension_index_values( self, dimension_index_values: Sequence[Sequence[int]], @@ -5134,6 +4413,9 @@ def get_pixels_by_dimension_index_values( rescale_fractional: bool = True, skip_overlap_checks: bool = False, dtype: Union[type, str, np.dtype, None] = None, + apply_palette_color_lut: bool = False, + apply_icc_profile: bool | None = None, + allow_missing_frames: bool = True, ): """Get a pixel array for a list of dimension index values. @@ -5179,6 +4461,13 @@ def get_pixels_by_dimension_index_values( segments). In this case, the values in the output pixel array will always lie in the range ``0`` to ``len(segment_numbers)`` inclusive. + With ``"LABELMAP"`` segmentations that use the ``"PALETTE COLOR"`` + photometric interpretation, the ``apply_palette_color_lut`` parameter + may be used to produce a color image in which each segment is given an + RGB defined in a palette color LUT within the segmentation object. + The three color channels (RGB) will be stacked down the final (4th) + dimension of the pixel array. + Parameters ---------- dimension_index_values: Sequence[Sequence[int]] @@ -5243,10 +4532,24 @@ def get_pixels_by_dimension_index_values( fractional values, this will be numpy.float32. Otherwise, the smallest unsigned integer type that accommodates all of the output values will be chosen. + apply_palette_color_lut: bool, optional + If True, apply the palette color LUT to give RGB output values. + This is only valid for ``"LABELMAP"`` segmentations that contain + palette color LUT information, and only when ``combine_segments`` + is ``True`` and ``relabel`` is ``False``. + apply_icc_profile: bool, optional + If True apply an ICC profile to the output and require it to be + present. If None, apply an ICC profile if found but do not require + it to be present. If False, never apply an ICC profile. Only + possible when ``apply_palette_color_lut`` is True. + allow_missing_frames: bool, optional + Allow frames in the output array to be blank because these frames + are omitted from the image. If False and missing frames are found, + an error is raised. Returns ------- - pixel_array: np.ndarray + pixel_array: numpy.ndarray Pixel array representing the segmentation. See notes for full explanation. @@ -5300,15 +4603,26 @@ def get_pixels_by_dimension_index_values( 'Segment numbers may not be empty.' ) + referenced_segment_number_tag = tag_for_keyword( + 'ReferencedSegmentNumber' + ) if dimension_index_pointers is None: - dimension_index_pointers = self._dim_ind_pointers + dimension_index_pointers = [ + t for t in self.dimension_index_pointers + if t != referenced_segment_number_tag + ] else: if len(dimension_index_pointers) == 0: raise ValueError( 'Argument "dimension_index_pointers" must not be empty.' ) for ptr in dimension_index_pointers: - if ptr not in self._dim_ind_pointers: + if ptr == referenced_segment_number_tag: + raise ValueError( + "Do not include the ReferencedSegmentNumber in the " + "argument 'dimension_index_pointers'." + ) + if ptr not in self.dimension_index_pointers: kw = keyword_for_tag(ptr) if kw == '': kw = '' @@ -5329,16 +4643,9 @@ def get_pixels_by_dimension_index_values( 'per dimension index pointer specified.' ) - if not self.are_dimension_indices_unique(dimension_index_pointers): - raise RuntimeError( - 'The chosen dimension indices do not uniquely identify ' - 'frames of the segmentation image. You may need to provide ' - 'further indices to disambiguate.' - ) - # Check that all frame numbers requested actually exist if not assert_missing_frames_are_empty: - unique_dim_ind_vals = self._db_man.get_unique_dim_index_values( + unique_dim_ind_vals = self._get_unique_dim_index_values( dimension_index_pointers ) queried_dim_inds = set(tuple(r) for r in dimension_index_values) @@ -5353,16 +4660,35 @@ def get_pixels_by_dimension_index_values( ) raise ValueError(msg) - with self._db_man.iterate_indices_by_dimension_index_values( - dimension_index_values=dimension_index_values, - dimension_index_pointers=dimension_index_pointers, - segment_numbers=segment_numbers, + if self.segmentation_type == SegmentationTypeValues.LABELMAP: + channel_indices = None + else: + channel_indices = [{'ReferencedSegmentNumber': segment_numbers}] + + remap_channel_indices = self._get_segment_remap_values( + segment_numbers, combine_segments=combine_segments, relabel=relabel, + ) + + stack_indices = { + ptr: vals + for ptr, vals in zip( + dimension_index_pointers, + zip(*dimension_index_values), + ) + } + + with self._iterate_indices_for_stack( + stack_indices=stack_indices, + stack_dimension_use_indices=True, + channel_indices=channel_indices, + remap_channel_indices=[remap_channel_indices], + allow_missing_frames=allow_missing_frames, ) as indices: return self._get_pixels_by_seg_frame( - output_shape=len(dimension_index_values), + spatial_shape=len(dimension_index_values), indices_iterator=indices, segment_numbers=np.array(segment_numbers), combine_segments=combine_segments, @@ -5370,6 +4696,8 @@ def get_pixels_by_dimension_index_values( rescale_fractional=rescale_fractional, skip_overlap_checks=skip_overlap_checks, dtype=dtype, + apply_palette_color_lut=apply_palette_color_lut, + apply_icc_profile=apply_icc_profile, ) def get_total_pixel_matrix( @@ -5384,6 +4712,9 @@ def get_total_pixel_matrix( rescale_fractional: bool = True, skip_overlap_checks: bool = False, dtype: Union[type, str, np.dtype, None] = None, + apply_palette_color_lut: bool = False, + apply_icc_profile: bool | None = None, + allow_missing_frames: bool = True, ): """Get the pixel array as a (region of) the total pixel matrix. @@ -5431,6 +4762,13 @@ def get_total_pixel_matrix( segments). In this case, the values in the output pixel array will always lie in the range ``0`` to ``len(segment_numbers)`` inclusive. + With ``"LABELMAP"`` segmentations that use the ``"PALETTE COLOR"`` + photometric interpretation, the ``apply_palette_color_lut`` parameter + may be used to produce a color image in which each segment is given an + RGB defined in a palette color LUT within the segmentation object. + The three color channels (RGB) will be stacked down the final (3rd) + dimension of the pixel array. + Parameters ---------- row_start: int, optional @@ -5489,10 +4827,24 @@ def get_total_pixel_matrix( fractional values, this will be numpy.float32. Otherwise, the smallest unsigned integer type that accommodates all of the output values will be chosen. + apply_palette_color_lut: bool, optional + If True, apply the palette color LUT to give RGB output values. + This is only valid for ``"LABELMAP"`` segmentations that contain + palette color LUT information, and only when ``combine_segments`` + is ``True`` and ``relabel`` is ``False``. + apply_icc_profile: bool | None, optional + If True apply an ICC profile to the output and require it to be + present. If None, apply an ICC profile if found but do not require + it to be present. If False, never apply an ICC profile. Only + possible when ``apply_palette_color_lut`` is True. + allow_missing_frames: bool, optional + Allow frames in the output array to be blank because these frames + are omitted from the image. If False and missing frames are found, + an error is raised. Returns ------- - pixel_array: np.ndarray + pixel_array: numpy.ndarray Pixel array representing the segmentation's total pixel matrix. Note @@ -5506,9 +4858,9 @@ def get_total_pixel_matrix( """ # Check whether this segmentation is appropriate for tile-based indexing - if not is_tiled_image(self): + if not self.is_tiled: raise RuntimeError("Segmentation is not a tiled image.") - if not self._db_man.is_indexable_as_total_pixel_matrix(): + if not self.is_indexable_as_total_pixel_matrix(): raise RuntimeError( "Segmentation does not have appropriate dimension indices " "to be indexed as a total pixel matrix." @@ -5522,64 +4874,29 @@ def get_total_pixel_matrix( 'Segment numbers may not be empty.' ) - if row_start is None: - row_start = 1 - if row_end is None: - row_end = self.TotalPixelMatrixRows + 1 - if column_start is None: - column_start = 1 - if column_end is None: - column_end = self.TotalPixelMatrixColumns + 1 - - if column_start == 0 or row_start == 0: - raise ValueError( - 'Arguments "row_start" and "column_start" may not be 0.' - ) - - if row_start > self.TotalPixelMatrixRows + 1: - raise ValueError( - 'Invalid value for "row_start".' - ) - elif row_start < 0: - row_start = self.TotalPixelMatrixRows + row_start + 1 - if row_end > self.TotalPixelMatrixRows + 1: - raise ValueError( - 'Invalid value for "row_end".' - ) - elif row_end < 0: - row_end = self.TotalPixelMatrixRows + row_end + 1 - - if column_start > self.TotalPixelMatrixColumns + 1: - raise ValueError( - 'Invalid value for "column_start".' - ) - elif column_start < 0: - column_start = self.TotalPixelMatrixColumns + column_start + 1 - if column_end > self.TotalPixelMatrixColumns + 1: - raise ValueError( - 'Invalid value for "column_end".' - ) - elif column_end < 0: - column_end = self.TotalPixelMatrixColumns + column_end + 1 + if self.segmentation_type == SegmentationTypeValues.LABELMAP: + channel_indices = None + else: + channel_indices = [{'ReferencedSegmentNumber': segment_numbers}] - output_shape = ( - row_end - row_start, - column_end - column_start, + remap_channel_indices = self._get_segment_remap_values( + segment_numbers, + combine_segments=combine_segments, + relabel=relabel, ) - with self._db_man.iterate_indices_for_tiled_region( + with self._iterate_indices_for_tiled_region( row_start=row_start, row_end=row_end, column_start=column_start, column_end=column_end, - tile_shape=(self.Rows, self.Columns), - segment_numbers=segment_numbers, - combine_segments=combine_segments, - relabel=relabel, - ) as indices: + channel_indices=channel_indices, + remap_channel_indices=[remap_channel_indices], + allow_missing_frames=allow_missing_frames, + ) as (indices, output_shape): return self._get_pixels_by_seg_frame( - output_shape=output_shape, + spatial_shape=output_shape, indices_iterator=indices, segment_numbers=np.array(segment_numbers), combine_segments=combine_segments, @@ -5587,10 +4904,15 @@ def get_total_pixel_matrix( rescale_fractional=rescale_fractional, skip_overlap_checks=skip_overlap_checks, dtype=dtype, + apply_palette_color_lut=apply_palette_color_lut, + apply_icc_profile=apply_icc_profile, ) -def segread(fp: Union[str, bytes, PathLike, BinaryIO]) -> Segmentation: +def segread( + fp: Union[str, bytes, PathLike, BinaryIO], + lazy_frame_retrieval: bool = False, +) -> Segmentation: """Read a segmentation image stored in DICOM File Format. Parameters @@ -5598,6 +4920,11 @@ def segread(fp: Union[str, bytes, PathLike, BinaryIO]) -> Segmentation: fp: Union[str, bytes, os.PathLike] Any file-like object representing a DICOM file containing a Segmentation image. + lazy_frame_retrieval: bool + If True, the returned segmentation will retrieve frames from the file as + requested, rather than loading in the entire object to memory + initially. This may be a good idea if file reading is slow and you are + likely to need only a subset of the frames in the segmentation. Returns ------- @@ -5605,4 +4932,11 @@ def segread(fp: Union[str, bytes, PathLike, BinaryIO]) -> Segmentation: Segmentation image read from the file. """ - return Segmentation.from_dataset(dcmread(fp), copy=False) + # This is essentially a convenience alias for the classmethod (which is + # used so that it is inherited correctly by subclasses). It is used + # becuse it follows the format of other similar functions around the + # library + return Segmentation.from_file( + fp, + lazy_frame_retrieval=lazy_frame_retrieval, + ) diff --git a/src/highdicom/spatial.py b/src/highdicom/spatial.py index 9635646a..e933e357 100644 --- a/src/highdicom/spatial.py +++ b/src/highdicom/spatial.py @@ -8,16 +8,45 @@ Tuple, Union, ) +from typing_extensions import Self from pydicom import Dataset import numpy as np +import pydicom -from highdicom.enum import CoordinateSystemNames from highdicom._module_utils import is_multiframe_image +from highdicom.enum import ( + AxisHandedness, + CoordinateSystemNames, + PixelIndexDirections, + PatientOrientationValuesBiped, +) + + +_DEFAULT_SPACING_TOLERANCE = 1e-2 +"""Default tolerance for determining whether slices are regularly spaced.""" -# Tolerance value used by default in tests for equality -_DEFAULT_TOLERANCE = 1e-5 +_DEFAULT_EQUALITY_TOLERANCE = 1e-5 +"""Tolerance value used by default in tests for equality""" + + +PATIENT_ORIENTATION_OPPOSITES = { + PatientOrientationValuesBiped.L: PatientOrientationValuesBiped.R, + PatientOrientationValuesBiped.R: PatientOrientationValuesBiped.L, + PatientOrientationValuesBiped.A: PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.P: PatientOrientationValuesBiped.A, + PatientOrientationValuesBiped.F: PatientOrientationValuesBiped.H, + PatientOrientationValuesBiped.H: PatientOrientationValuesBiped.F, +} +"""Mapping of each patient orientation value to its opposite.""" + + +VOLUME_INDEX_CONVENTION = ( + PixelIndexDirections.D, + PixelIndexDirections.R, +) +"""Indexing convention used within volumes.""" def is_tiled_image(dataset: Dataset) -> bool: @@ -597,40 +626,19 @@ def _get_spatial_information( return position, orientation, pixel_spacing, spacing_between_slices -def _get_normal_vector(image_orientation: Sequence[float]) -> np.ndarray: - """Get normal vector given image cosines. - - Parameters - ---------- - image_orientation: Sequence[float] - Row and column cosines (6 element list) giving the orientation of the - image. - - Returns - ------- - np.ndarray - Array of shape (3, ) giving the normal vector to the image plane. - - """ - row_cosines = np.array(image_orientation[:3], dtype=float) - column_cosines = np.array(image_orientation[3:], dtype=float) - n = np.cross(row_cosines.T, column_cosines.T) - return n - - def _are_images_coplanar( image_position_a: Sequence[float], image_orientation_a: Sequence[float], image_position_b: Sequence[float], image_orientation_b: Sequence[float], - tol: float = _DEFAULT_TOLERANCE, + tol: float = _DEFAULT_EQUALITY_TOLERANCE, ) -> bool: """Determine whether two images or image frames are coplanar. Two images are coplanar in the frame of reference coordinate system if and - only if their vectors have the same (or opposite direction) and the - shortest distance from the plane to the coordinate system origin is - the same for both planes. + only if their normal vectors have the same (or opposite direction) and the + shortest distance from the plane to the coordinate system origin is the + same for both planes. Parameters ---------- @@ -655,8 +663,8 @@ def _are_images_coplanar( True if the two images are coplanar. False otherwise. """ - n_a = _get_normal_vector(image_orientation_a) - n_b = _get_normal_vector(image_orientation_b) + n_a = get_normal_vector(image_orientation_a) + n_b = get_normal_vector(image_orientation_b) if 1.0 - np.abs(n_a @ n_b) > tol: return False @@ -667,10 +675,291 @@ def _are_images_coplanar( return abs(dis_a - dis_b) < tol +def _normalize_pixel_index_convention( + c: Union[str, Sequence[Union[str, PixelIndexDirections]]], +) -> Tuple[PixelIndexDirections, PixelIndexDirections]: + """Normalize and check a pixel index convention. + + Parameters + ---------- + c: Union[str, Sequence[Union[str, highdicom.enum.PixelIndexDirections]]] + Pixel index convention description consisting of two directions, + either L or R, and either U or D. + + Returns + ------- + Tuple[highdicom.enum.PixelIndexDirections, highdicom.enum.PixelIndexDirections]: + Convention description in a canonical form as a tuple of two enum + instances. Furthermore this is guaranteed to be a valid description. + + """ # noqa: E501 + if len(c) != 2: + raise ValueError('Length of pixel index convention must be 2.') + + c = tuple(PixelIndexDirections(d) for d in c) + + c_set = {d.value for d in c} + + criteria = [ + ('L' in c_set) != ('R' in c_set), + ('U' in c_set) != ('D' in c_set), + ] + if not all(criteria): + c_str = [d.value for d in c] + raise ValueError(f'Invalid combination of pixel directions: {c_str}.') + + return c + + +def _normalize_patient_orientation( + c: Union[str, Sequence[Union[str, PatientOrientationValuesBiped]]], +) -> Tuple[ + PatientOrientationValuesBiped, + PatientOrientationValuesBiped, + PatientOrientationValuesBiped, +]: + """Normalize and check a patient orientation. + + Parameters + ---------- + c: Union[str, Sequence[Union[str, highdicom.enum.PatientOrientationValuesBiped]]] + Patient orientation consisting of three directions, either L or R, + either A or P, and either F or H, in any order. + + Returns + ------- + Tuple[highdicom.enum.PatientOrientationValuesBiped, highdicom.enum.PatientOrientationValuesBiped, highdicom.enum.PatientOrientationValuesBiped]: + Convention description in a canonical form as a tuple of three enum + instances. Furthermore this is guaranteed to be a valid description. + + """ # noqa: E501 + if len(c) != 3: + raise ValueError('Length of pixel index convention must be 3.') + + c = tuple(PatientOrientationValuesBiped(d) for d in c) + + c_set = {d.value for d in c} + + criteria = [ + ('L' in c_set) != ('R' in c_set), + ('A' in c_set) != ('P' in c_set), + ('F' in c_set) != ('H' in c_set), + ] + if not all(criteria): + c_str = [d.value for d in c] + raise ValueError( + 'Invalid combination of frame of reference directions: ' + f'{c_str}.' + ) + + return c + + +def get_closest_patient_orientation(affine: np.ndarray) -> Tuple[ + PatientOrientationValuesBiped, + PatientOrientationValuesBiped, + PatientOrientationValuesBiped, +]: + """Given an affine matrix, find the closest patient orientation. + + Parameters + ---------- + affine: numpy.ndarray + Direction matrix (4x4 affine matrices and 3x3 direction matrices are + acceptable). + + Returns + ------- + Tuple[PatientOrientationValuesBiped, PatientOrientationValuesBiped, PatientOrientationValuesBiped]: + Tuple of PatientOrientationValuesBiped values, giving for each of the + three axes of the volume represented by the affine matrix, the closest + direction in the patient frame of reference coordinate system. + + """ # noqa: E501 + if ( + affine.ndim != 2 or + ( + affine.shape != (3, 3) and + affine.shape != (4, 4) + ) + ): + raise ValueError(f"Invalid shape for array: {affine.shape}") + + if not _is_matrix_orthogonal(affine[:3, :3], require_unit=False): + raise ValueError('Matrix is not orthogonal.') + + # Matrix representing alignment of dot product of rotation vector i with + # FoR reference j + alignments = np.eye(3) @ affine[:3, :3] + sort_indices = np.argsort(-np.abs(alignments), axis=0) + + result = [] + pos_directions = [ + PatientOrientationValuesBiped.L, + PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.H, + ] + neg_directions = [ + PatientOrientationValuesBiped.R, + PatientOrientationValuesBiped.A, + PatientOrientationValuesBiped.F, + ] + for d, sortind in enumerate(sort_indices.T): + # Check that this axis has not already been used. This can happen if + # one or more array axis is at 45 deg to some FoR axis. In this case + # take the next index in the sort list. + for i in sortind: + if ( + pos_directions[i] not in result and + neg_directions[i] not in result + ): + break + + if alignments[i, d] > 0: + result.append(pos_directions[i]) + else: + result.append(neg_directions[i]) + + return tuple(result) + + +def _is_matrix_orthogonal( + m: np.ndarray, + require_unit: bool = True, + tol: float = _DEFAULT_EQUALITY_TOLERANCE, +) -> bool: + """Check whether a matrix is orthogonal. + + Parameters + ---------- + m: numpy.ndarray + A matrix. + require_unit: bool, optional + Whether to require that the row vectors are unit vectors. + tol: float, optional + Tolerance. ``m`` will be deemed orthogonal if the product ``m.T @ m`` + is equal to diagonal matrix of squared column norms within this + tolerance. + + Returns + ------- + bool: + True if the matrix ``m`` is a square orthogonal matrix. False + otherwise. + + """ + if m.ndim != 2: + raise ValueError( + 'Argument "m" should be an array with 2 dimensions.' + ) + if m.shape[0] != m.shape[1]: + return False + norm_squared = (m ** 2).sum(axis=0) + if require_unit: + if not np.allclose( + norm_squared, + np.array([1.0, 1.0, 1.0]), + atol=tol, + ): + return False + + return np.allclose(m.T @ m, np.diag(norm_squared), atol=tol) + + +def get_normal_vector( + image_orientation: Sequence[float], + index_convention: Union[str, Sequence[Union[PixelIndexDirections, str]]] = ( + PixelIndexDirections.R, + PixelIndexDirections.D, + ), + handedness: Union[AxisHandedness, str] = AxisHandedness.RIGHT_HANDED, +): + """Get a vector normal to an imaging plane. + + Parameters + ---------- + image_orientation: Sequence[float] + Image orientation in the standard DICOM format used for the + ImageOrientationPatient and ImageOrientationSlide attributes, + consisting of 6 numbers representing the direction cosines along the + rows (first three elements) and columns (second three elements). + index_convention: Sequence[Union[highdicom.enum.PixelIndexDirections, str]], optional + Convention used to index pixels. Should be a sequence of two + :class:`highdicom.enum.PixelIndexDirections` or their string + representations, giving in order, the indexing conventions used for + specifying pixel indices. For example ``('R', 'D')`` means that the + first pixel index indexes the columns from left to right, and the + second pixel index indexes the rows from top to bottom (this is the + convention typically used within DICOM). As another example ``('D', + 'R')`` would switch the order of the indices to give the convention + typically used within NumPy. + + Alternatively, a single shorthand string may be passed that combines + the string representations of the two directions. So for example, + passing ``'RD'`` is equivalent to passing ``('R', 'D')``. + handedness: Union[highdicom.enum.AxisHandedness, str], optional + Choose the positive direction of the resulting normal in order to give + this handedness in the resulting coordinate system. This assumes that + the normal vector will be used to define a coordinate system when + combined with the column cosines (unit vector pointing down the + columns) and row cosines (unit vector pointing along the rows) in that + order (for the sake of handedness, it does not matter whether the axis + defined by the normal vector is placed before or after the column and + row vectors because the two possibilities are cyclic permutations of + each other). If used to define a coordinte system with the row cosines + followed by the column cosines, the handedness of the resulting + coordinate system will be inverted. + + Returns + ------- + np.ndarray: + Unit normal vector as a NumPy array with shape (3, ). + + """ # noqa: E501 + image_orientation_arr = np.array(image_orientation, dtype=np.float64) + if image_orientation_arr.ndim != 1 or image_orientation_arr.shape[0] != 6: + raise ValueError( + "Argument 'image_orientation' should be an array of " + "length 6." + ) + index_convention_ = _normalize_pixel_index_convention(index_convention) + handedness_ = AxisHandedness(handedness) + + # Find normal vector to the imaging plane + row_cosines = image_orientation_arr[:3] + column_cosines = image_orientation_arr[3:] + + rotation_columns = [] + for d in index_convention_: + if d == PixelIndexDirections.R: + rotation_columns.append(row_cosines) + elif d == PixelIndexDirections.L: + rotation_columns.append(-row_cosines) + elif d == PixelIndexDirections.D: + rotation_columns.append(column_cosines) + elif d == PixelIndexDirections.U: + rotation_columns.append(-column_cosines) + + if handedness_ == AxisHandedness.RIGHT_HANDED: + n = np.cross(rotation_columns[0], rotation_columns[1]) + else: + n = np.cross(rotation_columns[1], rotation_columns[0]) + + return n + + def create_rotation_matrix( image_orientation: Sequence[float], + index_convention: Union[str, Sequence[Union[PixelIndexDirections, str]]] = ( + PixelIndexDirections.R, + PixelIndexDirections.D, + ), + slices_first: bool = False, + handedness: Union[AxisHandedness, str] = AxisHandedness.RIGHT_HANDED, + pixel_spacing: Union[float, Sequence[float]] = 1.0, + spacing_between_slices: float = 1.0, ) -> np.ndarray: - """Builds a rotation matrix. + """Builds a rotation matrix (with or without scaling). Parameters ---------- @@ -679,30 +968,140 @@ def create_rotation_matrix( increasing column index) and the column direction (second triplet: vertical, top to bottom, increasing row index) direction expressed in the three-dimensional patient or slide coordinate system defined by the - frame of reference + frame of reference. + index_convention: Sequence[Union[highdicom.enum.PixelIndexDirections, str]], optional + Convention used to index pixels. Should be a sequence of two + :class:`highdicom.enum.PixelIndexDirections` or their string + representations, giving in order, the indexing conventions used for + specifying pixel indices. For example ``('R', 'D')`` means that the + first pixel index indexes the columns from left to right, and the + second pixel index indexes the rows from top to bottom (this is the + convention typically used within DICOM). As another example ``('D', + 'R')`` would switch the order of the indices to give the convention + typically used within NumPy. + + Alternatively, a single shorthand string may be passed that combines + the string representations of the two directions. So for example, + passing ``'RD'`` is equivalent to passing ``('R', 'D')``. + slices_first: bool, optional + Whether the slice index dimension is placed before the rows and columns + (``True``) or after them. + handedness: Union[highdicom.enum.AxisHandedness, str], optional + Handedness to use to determine the positive direction of the slice + index. The resulting rotation matrix will have the given handedness. + pixel_spacing: Union[float, Sequence[float]], optional + Spacing between pixels in the in-frame dimensions. Either a single + value to apply in both the row and column dimensions, or a sequence of + length 2 giving ``[spacing_between_rows, spacing_between_columns]`` in + the same format as the DICOM "PixelSpacing" attribute. Returns ------- numpy.ndarray - 3 x 3 rotation matrix + 3 x 3 rotation matrix. Pre-multiplying an image coordinate in the + format (column index, row index, slice index) by this matrix gives the + x, y, z position in the frame-of-reference coordinate system. - """ + """ # noqa: E501 if len(image_orientation) != 6: raise ValueError('Argument "image_orientation" must have length 6.') + index_convention_ = _normalize_pixel_index_convention(index_convention) + handedness_ = AxisHandedness(handedness) + row_cosines = np.array(image_orientation[:3], dtype=float) column_cosines = np.array(image_orientation[3:], dtype=float) - n = np.cross(row_cosines.T, column_cosines.T) - return np.column_stack([ - row_cosines, - column_cosines, - n - ]) + if isinstance(pixel_spacing, Sequence): + if len(pixel_spacing) != 2: + raise ValueError( + "A sequence passed to argument 'pixel_spacing' must have " + "length 2." + ) + spacing_between_rows = float(pixel_spacing[0]) + spacing_between_columns = float(pixel_spacing[1]) + else: + spacing_between_rows = pixel_spacing + spacing_between_columns = pixel_spacing + + rotation_columns = [] + spacings = [] + for d in index_convention_: + if d == PixelIndexDirections.R: + rotation_columns.append(row_cosines) + spacings.append(spacing_between_columns) + elif d == PixelIndexDirections.L: + rotation_columns.append(-row_cosines) + spacings.append(spacing_between_columns) + elif d == PixelIndexDirections.D: + rotation_columns.append(column_cosines) + spacings.append(spacing_between_rows) + elif d == PixelIndexDirections.U: + rotation_columns.append(-column_cosines) + spacings.append(spacing_between_rows) + + if handedness_ == AxisHandedness.RIGHT_HANDED: + n = np.cross(rotation_columns[0], rotation_columns[1]) + else: + n = np.cross(rotation_columns[1], rotation_columns[0]) + + if slices_first: + rotation_columns.insert(0, n) + spacings.insert(0, spacing_between_slices) + else: + rotation_columns.append(n) + spacings.append(spacing_between_slices) + + rotation_columns = [c * s for c, s in zip(rotation_columns, spacings)] + + return np.column_stack(rotation_columns) + + +def _stack_affine_matrix( + rotation: np.ndarray, + translation: np.ndarray, +) -> np.ndarray: + """Create an affine matrix by stacking together. + + Parameters + ---------- + rotation: numpy.ndarray + Numpy array of shape ``(3, 3)`` representing a scaled rotation matrix. + position: numpy.ndarray + Numpy array with three elements representing a translation. + + Returns + ------- + numpy.ndarray: + Affine matrix of shape ``(4, 4)``. + + """ + if rotation.shape != (3, 3): + raise ValueError( + "Argument 'rotation' must have shape (3, 3)." + ) + if translation.size != 3: + raise ValueError( + "Argument 'translation' must have 3 elements." + ) + + return np.vstack( + [ + np.column_stack([rotation, translation.reshape(3, 1)]), + [0.0, 0.0, 0.0, 1.0] + ] + ) def _create_affine_transformation_matrix( image_position: Sequence[float], image_orientation: Sequence[float], - pixel_spacing: Sequence[float], + pixel_spacing: Union[float, Sequence[float]], + spacing_between_slices: float = 1.0, + index_convention: Union[str, Sequence[Union[PixelIndexDirections, str]]] = ( + PixelIndexDirections.R, + PixelIndexDirections.D, + ), + slices_first: bool = False, + handedness: Union[AxisHandedness, str] = AxisHandedness.RIGHT_HANDED, ) -> np.ndarray: """Create affine matrix for transformation. @@ -730,14 +1129,41 @@ def _create_affine_transformation_matrix( direction (first value: spacing between rows, vertical, top to bottom, increasing row index) and the rows direction (second value: spacing between columns: horizontal, left to right, increasing - column index) + column index). This matches the format of the DICOM "PixelSpacing" + attribute. Alternatiely, a single value that is used along both + directions. + spacing_between_slices: float + Spacing between consecutive slices in the frame of reference coordinate + system in millimeter units. + index_convention: Sequence[Union[highdicom.enum.PixelIndexDirections, str]], optional + Convention used to index pixels. Should be a sequence of two + :class:`highdicom.enum.PixelIndexDirections` or their string + representations, giving in order, the indexing conventions used for + specifying pixel indices. For example ``('R', 'D')`` means that the + first pixel index indexes the columns from left to right, and the + second pixel index indexes the rows from top to bottom (this is the + convention typically used within DICOM). As another example ``('D', + 'R')`` would switch the order of the indices to give the convention + typically used within NumPy. + + Alternatively, a single shorthand string may be passed that combines + the string representations of the two directions. So for example, + passing ``'RD'`` is equivalent to passing ``('R', 'D')``. + slices_first: bool, optional + Whether the slice index dimension is placed before the rows and columns + (``True``) or after them. + handedness: Union[highdicom.enum.AxisHandedness, str], optional + Handedness to use to determine the positive direction of the slice + index. The resulting rotation matrix will have the given handedness. Returns ------- numpy.ndarray - 4 x 4 affine transformation matrix + 4 x 4 affine transformation matrix. Pre-multiplying a pixel index in + format (column index, row index, slice index, 1) by this matrix gives + the (x, y, z, 1) position in the frame-of-reference coordinate system. - """ + """ # noqa: E501 if not isinstance(image_position, Sequence): raise TypeError('Argument "image_position" must be a sequence.') if len(image_position) != 3: @@ -751,36 +1177,36 @@ def _create_affine_transformation_matrix( if len(pixel_spacing) != 2: raise ValueError('Argument "pixel_spacing" must have length 2.') - x_offset = float(image_position[0]) - y_offset = float(image_position[1]) - z_offset = float(image_position[2]) - translation = np.array([x_offset, y_offset, z_offset], dtype=float) + index_convention_ = _normalize_pixel_index_convention(index_convention) + if ( + PixelIndexDirections.L in index_convention_ or + PixelIndexDirections.U in index_convention_ + ): + raise ValueError( + "Index convention cannot include 'L' or 'U'." + ) + translation = np.array([float(x) for x in image_position], dtype=float) - rotation = create_rotation_matrix(image_orientation) - # Column direction (spacing between rows) - column_spacing = float(pixel_spacing[0]) - # Row direction (spacing between columns) - row_spacing = float(pixel_spacing[1]) - rotation[:, 0] *= row_spacing - rotation[:, 1] *= column_spacing + rotation = create_rotation_matrix( + image_orientation=image_orientation, + pixel_spacing=pixel_spacing, + spacing_between_slices=spacing_between_slices, + index_convention=index_convention_, + handedness=handedness, + slices_first=slices_first, + ) # 4x4 transformation matrix - return np.vstack( - [ - np.column_stack([ - rotation, - translation, - ]), - [0.0, 0.0, 0.0, 1.0] - ] - ) + affine = _stack_affine_matrix(rotation, translation) + + return affine def _create_inv_affine_transformation_matrix( image_position: Sequence[float], image_orientation: Sequence[float], pixel_spacing: Sequence[float], - spacing_between_slices: float = 1.0 + spacing_between_slices: float = 1.0, ) -> np.ndarray: """Create affine matrix for inverse transformation. @@ -812,6 +1238,14 @@ def _create_inv_affine_transformation_matrix( Distance (in the coordinate defined by the frame of reference) between neighboring slices. Default: 1 + Returns + ------- + numpy.ndarray + 4 x 4 affine transformation matrix. Pre-multiplying a + frame-of-reference coordinate in the format (x, y, z, 1) by this matrix + gives the pixel indices in the form (column index, row index, slice + index, 1). + Raises ------ TypeError @@ -835,33 +1269,254 @@ def _create_inv_affine_transformation_matrix( if len(pixel_spacing) != 2: raise ValueError('Argument "pixel_spacing" must have length 2.') - x_offset = float(image_position[0]) - y_offset = float(image_position[1]) - z_offset = float(image_position[2]) - translation = np.array([x_offset, y_offset, z_offset]) - - rotation = create_rotation_matrix(image_orientation) - # Column direction (spacing between rows) - column_spacing = float(pixel_spacing[0]) - # Row direction (spacing between columns) - row_spacing = float(pixel_spacing[1]) - rotation[:, 0] *= row_spacing - rotation[:, 1] *= column_spacing - rotation[:, 2] *= spacing_between_slices + translation = np.array([float(x) for x in image_position], dtype=float) + + rotation = create_rotation_matrix( + image_orientation=image_orientation, + pixel_spacing=pixel_spacing, + spacing_between_slices=spacing_between_slices, + ) + inv_rotation = np.linalg.inv(rotation) # 4x4 transformation matrix - return np.vstack( + return _stack_affine_matrix( + rotation=inv_rotation, + translation=-np.dot(inv_rotation, translation) + ) + + +def rotation_for_patient_orientation( + patient_orientation: Union[ + str, + Sequence[Union[str, PatientOrientationValuesBiped]], + ], + spacing: Union[float, Sequence[float]] = 1.0, +) -> np.ndarray: + """Create a (scaled) rotation matrix for a given patient orientation. + + The result is an axis-aligned rotation matrix. + + Parameters + ---------- + patient_orientation: Union[str, Sequence[Union[str, highdicom.enum.PatientOrientationValuesBiped]]] + Desired patient orientation, as either a sequence of three + highdicom.enum.PatientOrientationValuesBiped values, or a string + such as ``"FPL"`` using the same characters. + spacing: Union[float, Sequence[float]], optional + Spacing between voxels along each of the three dimensions in the frame + of reference coordinate system in pixel units. + + Returns + ------- + numpy.ndarray: + (Scaled) rotation matrix of shape (3 x 3). + + """ # noqa: E501 + norm_orientation = _normalize_patient_orientation(patient_orientation) + + direction_to_vector_mapping = { + PatientOrientationValuesBiped.L: np.array([1., 0., 0.]), + PatientOrientationValuesBiped.R: np.array([-1., 0., 0.]), + PatientOrientationValuesBiped.P: np.array([0., 1., 0.]), + PatientOrientationValuesBiped.A: np.array([0., -1., 0.]), + PatientOrientationValuesBiped.H: np.array([0., 0., 1.]), + PatientOrientationValuesBiped.F: np.array([0., 0., -1.]), + } + + if isinstance(spacing, float): + spacing = [spacing] * 3 + + return np.column_stack( [ - np.column_stack([ - inv_rotation, - -np.dot(inv_rotation, translation) - ]), - [0.0, 0.0, 0.0, 1.0] + s * direction_to_vector_mapping[d] + for d, s in zip(norm_orientation, spacing) ] ) +def _transform_affine_matrix( + affine: np.ndarray, + shape: Sequence[int], + flip_indices: Optional[Sequence[bool]] = None, + flip_reference: Optional[Sequence[bool]] = None, + permute_indices: Optional[Sequence[int]] = None, + permute_reference: Optional[Sequence[int]] = None, +) -> np.ndarray: + """Transform an affine matrix between conventions. + + Parameters + ---------- + affine: np.ndarray + 4 x 4 affine matrix to transform. + shape: Sequence[int] + Shape of the array. + flip_indices: Optional[Sequence[bool]], optional + Whether to flip each of the pixel index axes to index from the other + side of the array. Must consist of three boolean values, one for each + of the index axes (before any permutation is applied). + flip_reference: Optional[Sequence[bool]], optional + Whether to flip each of the frame of reference axes to about the + origin. Must consist of three boolean values, one for each of the frame + of reference axes (before any permutation is applied). + permute_indices: Optional[Sequence[int]], optional + Permutation (if any) to apply to the pixel index axes. Must consist of + the values [0, 1, 2] in some order. + permute_reference: Optional[Sequence[int]], optional + Permutation (if any) to apply to the frame of reference axes. Must + consist of the values [0, 1, 2] in some order. + + Returns + ------- + np.ndarray: + Affine matrix after operations are applied. + + """ + if affine.shape != (4, 4): + raise ValueError("Affine matrix must have shape (4, 4).") + if len(shape) != 3: + raise ValueError("Shape must have shape three elements.") + + transformed = affine.copy() + + if flip_indices is not None and any(flip_indices): + # Move the origin to the opposite side of the array + enable = np.array(flip_indices, np.uint8) + offset = transformed[:3, :3] * (np.array(shape).reshape(3, 1) - 1) + transformed[:3, 3] += enable @ offset + + # Inverting the columns + transformed *= np.array( + [*[-1 if x else 1 for x in flip_indices], 1] + ) + + if flip_reference is not None and any(flip_reference): + # Flipping the reference means inverting the rows (including the + # translation) + row_inv = np.diag( + [*[-1 if x else 1 for x in flip_reference], 1] + ) + transformed = row_inv @ transformed + + # Permuting indices is a permutation of the columns + if permute_indices is not None: + if len(permute_indices) != 3: + raise ValueError( + 'Argument "permute_indices" should have 3 elements.' + ) + if set(permute_indices) != set((0, 1, 2)): + raise ValueError( + 'Argument "permute_indices" should contain elements 0, 1, ' + "and 3 in some order." + ) + transformed = transformed[:, [*permute_indices, 3]] + + # Permuting the reference is a permutation of the rows + if permute_reference is not None: + if len(permute_reference) != 3: + raise ValueError( + 'Argument "permute_reference" should have 3 elements.' + ) + if set(permute_reference) != set((0, 1, 2)): + raise ValueError( + 'Argument "permute_reference" should contain elements 0, 1, ' + "and 3 in some order." + ) + transformed = transformed[[*permute_reference, 3], :] + + return transformed + + +def _translate_affine_matrix( + affine: np.ndarray, + pixel_offset: Sequence[int], +) -> np.ndarray: + """Translate the origin of an affine matrix by a pixel offset. + + Parameters + ---------- + affine: numpy.ndarray + Original affine matrix (4 x 4). + pixel_offset: Sequence[int] + Offset, in pixel units. + + Returns + ------- + numpy.ndarray: + Translated affine matrix. + + """ + if len(pixel_offset) != 3: + raise ValueError( + "Argument 'pixel_spacing' must have three elements." + ) + offset_arr = np.array(pixel_offset) + origin = affine[:3, 3] + direction = affine[:3, :3] + reference_offset = direction @ offset_arr + new_origin = origin + reference_offset + result = affine.copy() + result[:3, 3] = new_origin + return result + + +def _transform_affine_to_convention( + affine: np.ndarray, + shape: Sequence[int], + from_reference_convention: Union[ + str, Sequence[Union[str, PatientOrientationValuesBiped]], + ], + to_reference_convention: Union[ + str, Sequence[Union[str, PatientOrientationValuesBiped]], + ] +) -> np.ndarray: + """Transform an affine matrix between different conventions. + + Parameters + ---------- + affine: np.ndarray + Affine matrix to transform. + shape: Sequence[int] + Shape of the array. + from_reference_convention: Union[str, Sequence[Union[str, PatientOrientationValuesBiped]]], + Reference convention used in the input affine. + to_reference_convention: Union[str, Sequence[Union[str, PatientOrientationValuesBiped]]], + Desired reference convention for the output affine. + + Returns + ------- + np.ndarray: + Affine matrix after operations are applied. + + """ # noqa: E501 + from_reference_normed = _normalize_patient_orientation( + from_reference_convention + ) + to_reference_normed = _normalize_patient_orientation( + to_reference_convention + ) + + flip_reference = [ + d not in to_reference_normed for d in from_reference_normed + ] + permute_reference = [] + for d, flipped in zip(to_reference_normed, flip_reference): + if flipped: + d_ = PATIENT_ORIENTATION_OPPOSITES[d] + permute_reference.append(from_reference_normed.index(d_)) + else: + permute_reference.append(from_reference_normed.index(d)) + + return _transform_affine_matrix( + affine=affine, + shape=shape, + permute_indices=None, + permute_reference=permute_reference, + flip_indices=None, + flip_reference=flip_reference, + ) + + class PixelToReferenceTransformer: """Class for transforming pixel indices to reference coordinates. @@ -888,7 +1543,8 @@ class PixelToReferenceTransformer: >>> transformer = PixelToReferenceTransformer( ... image_position=[56.0, 34.2, 1.0], ... image_orientation=[1.0, 0.0, 0.0, 0.0, 1.0, 0.0], - ... pixel_spacing=[0.5, 0.5]) + ... pixel_spacing=[0.5, 0.5], + ... ) >>> >>> # Use the transformer to convert coordinates >>> pixel_indices = np.array([[0, 10], [5, 5]]) @@ -950,7 +1606,7 @@ def __init__( @property def affine(self) -> np.ndarray: """numpy.ndarray: 4x4 affine transformation matrix""" - return self._affine + return self._affine.copy() def __call__(self, indices: np.ndarray) -> np.ndarray: """Transform image pixel indices to frame of reference coordinates. @@ -1007,7 +1663,7 @@ def for_image( dataset: Dataset, frame_number: Optional[int] = None, for_total_pixel_matrix: bool = False, - ) -> 'PixelToReferenceTransformer': + ) -> Self: """Construct a transformer for a given image or image frame. Parameters @@ -1153,7 +1809,7 @@ def __init__( @property def affine(self) -> np.ndarray: """numpy.ndarray: 4 x 4 affine transformation matrix""" - return self._affine + return self._affine.copy() def __call__(self, coordinates: np.ndarray) -> np.ndarray: """Transform frame of reference coordinates into image pixel indices. @@ -1224,7 +1880,7 @@ def for_image( for_total_pixel_matrix: bool = False, round_output: bool = True, drop_slice_index: bool = False, - ) -> 'ReferenceToPixelTransformer': + ) -> Self: """Construct a transformer for a given image or image frame. Parameters @@ -1408,7 +2064,7 @@ def __init__( @property def affine(self) -> np.ndarray: """numpy.ndarray: 4x4 affine transformation matrix""" - return self._affine + return self._affine.copy() def __call__(self, indices: np.ndarray) -> np.ndarray: """Transform pixel indices between two images. @@ -1469,7 +2125,7 @@ def for_images( for_total_pixel_matrix_from: bool = False, for_total_pixel_matrix_to: bool = False, round_output: bool = True, - ) -> 'PixelToPixelTransformer': + ) -> Self: """Construct a transformer for two given images or image frames. Parameters @@ -1624,7 +2280,7 @@ def __init__( @property def affine(self) -> np.ndarray: """numpy.ndarray: 4x4 affine transformation matrix""" - return self._affine + return self._affine.copy() def __call__(self, coordinates: np.ndarray) -> np.ndarray: """Transform image coordinates to frame of reference coordinates. @@ -1674,7 +2330,7 @@ def for_image( dataset: Dataset, frame_number: Optional[int] = None, for_total_pixel_matrix: bool = False, - ) -> 'ImageToReferenceTransformer': + ) -> Self: """Construct a transformer for a given image or image frame. Parameters @@ -1827,7 +2483,7 @@ def __init__( @property def affine(self) -> np.ndarray: """numpy.ndarray: 4 x 4 affine transformation matrix""" - return self._affine + return self._affine.copy() def __call__(self, coordinates: np.ndarray) -> np.ndarray: """Apply the inverse of an affine transformation matrix to a batch of @@ -1888,7 +2544,7 @@ def for_image( frame_number: Optional[int] = None, for_total_pixel_matrix: bool = False, drop_slice_coord: bool = False, - ) -> 'ReferenceToImageTransformer': + ) -> Self: """Construct a transformer for a given image or image frame. Parameters @@ -2077,7 +2733,7 @@ def __init__( @property def affine(self) -> np.ndarray: """numpy.ndarray: 4x4 affine transformation matrix""" - return self._affine + return self._affine.copy() def __call__(self, coordinates: np.ndarray) -> np.ndarray: """Transform pixel indices between two images. @@ -2126,7 +2782,7 @@ def for_images( frame_number_to: Optional[int] = None, for_total_pixel_matrix_from: bool = False, for_total_pixel_matrix_to: bool = False, - ) -> 'ImageToImageTransformer': + ) -> Self: """Construct a transformer for two given images or image frames. Parameters @@ -2368,3 +3024,606 @@ def are_points_coplanar( deviations = normal.T @ points_centered.T max_dev = np.abs(deviations).max() return max_dev <= tol + + +def get_series_volume_positions( + datasets: Sequence[pydicom.Dataset], + tol: float = _DEFAULT_SPACING_TOLERANCE, + sort: bool = True, + allow_missing: bool = False, + allow_duplicates: bool = False, + index_convention: Union[ + str, + Sequence[Union[PixelIndexDirections, str]] + ] = VOLUME_INDEX_CONVENTION, + handedness: Union[AxisHandedness, str] = AxisHandedness.RIGHT_HANDED, + enforce_handedness: bool = False, +) -> Tuple[Optional[float], Optional[List[int]]]: + """Get volume positions and spacing for a series of single frame images. + + First determines whether the image series represents a 3D volume. + A 3D volume consists of regularly spaced slices with orthogonal axes, i.e. + the slices are spaced equally along the direction orthogonal to the + in-plane image coordinates. + + If the series does represent a volume, returns the absolute value of the + slice spacing and the slice indices in the volume for each of the input + datasets. If the series does not represent a volume, returns None for both + outputs. + + Note that we stipulate that a single image is a 3D volume for the purposes + of this function. In this case the returned slice spacing will be 1.0. + + Parameters + ---------- + datasets: Sequence[pydicom.Dataset] + Set of datasets representing an imaging series. + tol: float + Tolerance for determining spacing regularity. If slice spacings vary by + less that this spacing, they are considered to be regular. + sort: bool, optional + Sort the image positions before finding the spacing. If True, this + makes the function tolerant of unsorted inputs. Set to False to check + whether the positions represent a 3D volume in the specific order in + which they are passed. + allow_missing: bool, optional + Allow for slices missing from the volume. If True, the smallest + distance between two consective slices is found and returned as the + slice spacing, provided all other spacings are an integer multiple of + this value (within tolerance). Alternatively, if a SpacingBetweenSlices + value is found in the datasets, that value will be used instead of the + minimum consecutive spacing. If False, any gaps will result in failure. + allow_duplicates: bool, optional + Allow multiple slices to map to the same position within the volume. + If False, duplicated image positions will result in failure. + index_convention: Sequence[Union[highdicom.enum.PixelIndexDirections, str]], optional + Convention used to determine how to order frames. Should be a sequence + of two :class:`highdicom.enum.PixelIndexDirections` or their string + representations, giving in order, the indexing conventions used for + specifying pixel indices. For example ``('R', 'D')`` means that the + first pixel index indexes the columns from left to right, and the + second pixel index indexes the rows from top to bottom (this is the + convention typically used within DICOM). As another example ``('D', + 'R')`` would switch the order of the indices to give the convention + typically used within NumPy. + + Alternatively, a single shorthand string may be passed that combines + the string representations of the two directions. So for example, + passing ``'RD'`` is equivalent to passing ``('R', 'D')``. + + This is used in combination with the ``handedness`` to determine + the positive direction used to order frames. + handedness: Union[highdicom.enum.AxisHandedness, str], optional + Choose the frame order such that the frame axis creates a + coordinate system with this handedness in the when combined with + the within-frame convention given by ``index_convention``. + enforce_handedness: bool, optional + If True and sort is False, require that the images are not only + regularly spaced but also that they are ordered correctly to give a + coordinate system with the specified handedness, i.e. frames are + ordered along the direction of the increasing normal vector, as opposed + to being ordered regularly along the direction of the decreasing normal + vector. If sort is True, this has no effect since positions will be + sorted in the correct direction before finding the spacing. + + Returns + ------- + Union[float, None]: + If the image positions are regularly spaced, the (absolute value of) + the slice spacing. If the image positions do not represent a + regularly-spaced volume, returns None. + Union[List[int], None]: + List with the same length as the number of image positions. Each + element gives the zero-based index of the corresponding input position + in the volume. If the image positions do not represent a volume, + returns None. + + """ # noqa: E501 + if len(datasets) == 0: + raise ValueError("List must not be empty.") + # We stipluate that a single image does represent a volume with spacing 0.0 + if len(datasets) == 1: + return 1.0, [0] + for ds in datasets: + if is_multiframe_image(ds): + raise ValueError( + "Datasets should be single-frame images." + ) + + # Check image orientations are consistent + image_orientation = datasets[0].ImageOrientationPatient + for ds in datasets[1:]: + if ds.ImageOrientationPatient != image_orientation: + return None, None + + positions = [ds.ImagePositionPatient for ds in datasets] + + spacing_hint = datasets[0].get('SpacingBetweenSlices') + + return get_volume_positions( + image_positions=positions, + image_orientation=image_orientation, + tol=tol, + sort=sort, + allow_duplicates=allow_duplicates, + allow_missing=allow_missing, + spacing_hint=spacing_hint, + index_convention=index_convention, + handedness=handedness, + enforce_handedness=enforce_handedness, + ) + + +def get_volume_positions( + image_positions: Sequence[Sequence[float]], + image_orientation: Sequence[float], + tol: float = _DEFAULT_SPACING_TOLERANCE, + sort: bool = True, + allow_missing: bool = False, + allow_duplicates: bool = False, + spacing_hint: Optional[float] = None, + index_convention: Union[ + str, + Sequence[Union[PixelIndexDirections, str]] + ] = VOLUME_INDEX_CONVENTION, + handedness: Union[AxisHandedness, str] = AxisHandedness.RIGHT_HANDED, + enforce_handedness: bool = False, +) -> Tuple[Optional[float], Optional[List[int]]]: + """Get the spacing and positions of images within a 3D volume. + + First determines whether the image positions and orientation represent a 3D + volume. A 3D volume consists of regularly spaced slices with orthogonal + axes, i.e. the slices are spaced equally along the direction orthogonal to + the in-plane image coordinates. + + If the positions represent a volume, returns the absolute value of the + slice spacing and the volume indices for each of the input positions. If + the positions do not represent a volume, returns None for both outputs. + + Note that we stipulate that a single plane is a 3D volume for the purposes + of this function. In this case, and it ``spacing_hint`` is not provied, the + returned slice spacing will be 1.0. + + Parameters + ---------- + image_positions: Sequence[Sequence[float]] + Array of image positions for multiple frames. Should be a 2D array of + shape (N, 3) where N is the number of frames. Either a numpy array or + anything convertible to it may be passed. + image_orientation: Sequence[float] + Image orientation as direction cosine values taken directly from the + ImageOrientationPatient attribute. 1D array of length 6. Either a numpy + array or anything convertible to it may be passed. + tol: float, optional + Tolerance for determining spacing regularity. If slice spacings vary by + less that this spacing, they are considered to be regular. + sort: bool, optional + Sort the image positions before finding the spacing. If True, this + makes the function tolerant of unsorted inputs. Set to False to check + whether the positions represent a 3D volume in the specific order in + which they are passed. + allow_missing: bool, optional + Allow for slices missing from the volume. If True, the smallest + distance between two consective slices is found and returned as the + slice spacing, provided all other spacings are an integer multiple of + this value (within tolerance). Alternatively, if ``spacing_hint`` is + used, that value will be used instead of the minimum consecutive + spacing. If False, any gaps will result in failure. + allow_duplicates: bool, optional + Allow multiple slices to map to the same position within the volume. + If False, duplicated image positions will result in failure. + spacing_hint: Union[float, None], optional + Expected spacing between slices. If the calculated value is not equal + to this, within tolerance, the outputs will be None. The primary use of + this option is in combination with ``allow_missing``. If + ``allow_missing`` is ``True`` and a ``spacing_hint`` is given, the hint + is used to calculate the index positions instead of the smallest + consecutive spacing. + index_convention: Sequence[Union[highdicom.enum.PixelIndexDirections, str]], optional + Convention used to determine how to order frames. Should be a sequence + of two :class:`highdicom.enum.PixelIndexDirections` or their string + representations, giving in order, the indexing conventions used for + specifying pixel indices. For example ``('R', 'D')`` means that the + first pixel index indexes the columns from left to right, and the + second pixel index indexes the rows from top to bottom (this is the + convention typically used within DICOM). As another example ``('D', + 'R')`` would switch the order of the indices to give the convention + typically used within NumPy. + + Alternatively, a single shorthand string may be passed that combines + the string representations of the two directions. So for example, + passing ``'RD'`` is equivalent to passing ``('R', 'D')``. + + This is used in combination with the ``handedness`` to determine + the positive direction used to order frames. + handedness: Union[highdicom.enum.AxisHandedness, str], optional + Choose the frame order in order such that the frame axis creates a + coordinate system with this handedness in the when combined with + the within-frame convention given by ``index_convention``. + enforce_handedness: bool, optional + If True and sort is False, require that the images are not only + regularly spaced but also that they are ordered correctly to give a + coordinate system with the specified handedness, i.e. frames are + ordered along the direction of the increasing normal vector, as opposed + to being ordered regularly along the direction of the decreasing normal + vector. If sort is True, this has no effect since positions will be + sorted in the correct direction before finding the spacing. + + Returns + ------- + Union[float, None]: + If the image positions are regularly spaced, the (absolute value of) + the slice spacing. If the image positions do not represent a + regularly-spaced volume, returns None. + Union[List[int], None]: + List with the same length as the number of image positions. Each + element gives the zero-based index of the corresponding input position + in the volume. If the image positions do not represent a volume, + returns None. + + """ # noqa: E501 + if not sort: + if allow_duplicates: + raise ValueError( + "Argument 'allow_duplicates' requires 'sort'." + ) + if allow_missing: + raise ValueError( + "Argument 'allow_missing' requires 'sort'." + ) + + if spacing_hint is not None: + if spacing_hint < 0.0: + # There are some edge cases of the standard where this is valid + spacing_hint = abs(spacing_hint) + if spacing_hint == 0.0: + raise ValueError("Argument 'spacing_hint' cannot be 0.") + + image_positions_arr = np.array(image_positions) + + if image_positions_arr.ndim != 2 or image_positions_arr.shape[1] != 3: + raise ValueError( + "Argument 'image_positions' should be an (N, 3) array." + ) + n = image_positions_arr.shape[0] + if n == 0: + raise ValueError( + "Argument 'image_positions' should contain at least 1 position." + ) + elif n == 1: + # Special case, we stipulate that this has spacing 1.0 + # if not otherwise specified + spacing = 1.0 if spacing_hint is None else spacing_hint + return spacing, [0] + + normal_vector = get_normal_vector( + image_orientation, + index_convention=index_convention, + handedness=handedness, + ) + + if allow_duplicates: + # Unique index specifies, for each position in the input positions + # array, the position in the unique_positions array of the + # de-duplicated position + unique_positions, unique_index = np.unique( + image_positions_arr, + axis=0, + return_inverse=True, + ) + else: + unique_positions = image_positions_arr + unique_index = np.arange(image_positions_arr.shape[0]) + + if len(unique_positions) == 1: + # Special case, we stipulate that this has spacing 1.0 + # if not otherwise specified + spacing = 1.0 if spacing_hint is None else spacing_hint + return spacing, [0] * n + + # Calculate distance of each slice from coordinate system origin along the + # normal vector + origin_distances = _get_slice_distances(unique_positions, normal_vector) + + if sort: + # sort_index index gives, for each position in the sorted unique + # positions, the initial index of the corresponding unique position + sort_index = np.argsort(origin_distances) + origin_distances_sorted = origin_distances[sort_index] + inverse_sort_index = np.argsort(sort_index) + else: + sort_index = np.arange(unique_positions.shape[0]) + origin_distances_sorted = origin_distances + inverse_sort_index = sort_index + + if allow_missing: + if spacing_hint is not None: + spacing = spacing_hint + else: + spacings = np.diff(origin_distances_sorted) + spacing = spacings.min() + # Check here to prevent divide by zero errors. Positions should + # have been de-duplicated already, is this is allowed, so there + # should only be zero spacings if some positions are related by + # in-plane translations + if np.isclose(spacing, 0.0, atol=tol): + return None, None + + origin_distance_multiples = ( + (origin_distances - origin_distances.min()) / spacing + ) + + is_regular = np.allclose( + origin_distance_multiples, + origin_distance_multiples.round(), + atol=tol + ) + + inverse_sort_index = origin_distance_multiples.round().astype(np.int64) + + else: + spacings = np.diff(origin_distances_sorted) + spacing = spacings.mean() + + if spacing_hint is not None: + if not np.isclose(abs(spacing), spacing_hint): + raise RuntimeError( + f"Inferred spacing ({abs(spacing):.3f}) does not match the " + f"given 'spacing_hint' ({spacing_hint})." + ) + + is_regular = np.isclose( + spacing, + spacings, + atol=tol + ).all() + + if is_regular and enforce_handedness: + if spacing < 0.0: + return None, None + + # Additionally check that the vector from the first to the last plane lies + # approximately along the normal vector + pos1 = unique_positions[sort_index[0], :] + pos2 = unique_positions[sort_index[-1], :] + span = (pos2 - pos1) + span /= np.linalg.norm(span) + + dot_product = normal_vector.T @ span + is_perpendicular = ( + abs(dot_product - 1.0) < tol or + abs(dot_product + 1.0) < tol + ) + + if is_regular and is_perpendicular: + vol_positions = [ + inverse_sort_index[unique_index[i]].item() + for i in range(len(image_positions_arr)) + ] + return abs(spacing), vol_positions + else: + return None, None + + +def get_plane_sort_index( + image_positions: Sequence[Sequence[float]], + image_orientation: Sequence[float], + index_convention: Union[ + str, + Sequence[Union[PixelIndexDirections, str]] + ] = VOLUME_INDEX_CONVENTION, + handedness: Union[AxisHandedness, str] = AxisHandedness.RIGHT_HANDED, +) -> List[int]: + """ + + Parameters + ---------- + image_positions: Sequence[Sequence[float]] + Array of image positions for multiple frames. Should be a 2D array of + shape (N, 3) where N is the number of frames. Either a numpy array or + anything convertible to it may be passed. + image_orientation: Sequence[float] + Image orientation as direction cosine values taken directly from the + ImageOrientationPatient attribute. 1D array of length 6. Either a numpy + array or anything convertible to it may be passed. + index_convention: Sequence[Union[highdicom.enum.PixelIndexDirections, str]], optional + Convention used to determine how to order frames. Should be a sequence + of two :class:`highdicom.enum.PixelIndexDirections` or their string + representations, giving in order, the indexing conventions used for + specifying pixel indices. For example ``('R', 'D')`` means that the + first pixel index indexes the columns from left to right, and the + second pixel index indexes the rows from top to bottom (this is the + convention typically used within DICOM). As another example ``('D', + 'R')`` would switch the order of the indices to give the convention + typically used within NumPy. + + Alternatively, a single shorthand string may be passed that combines + the string representations of the two directions. So for example, + passing ``'RD'`` is equivalent to passing ``('R', 'D')``. + + This is used in combination with the ``handedness`` to determine + the positive direction used to order frames. + handedness: Union[highdicom.enum.AxisHandedness, str], optional + Choose the frame order in order such that the frame axis creates a + coordinate system with this handedness in the when combined with + the within-frame convention given by ``index_convention``. + + Returns + ------- + List[int] + Sorting index for the input planes. Element i of this list gives the + index in the original list of the frames such that the output list + is sorted along the positive direction of the normal vector of the + imaging plane. + + """ # noqa: E501 + pos_arr = np.array(image_positions) + if pos_arr.ndim != 2 or pos_arr.shape[1] != 3: + raise ValueError("Argument 'image_positions' must have shape (N, 3)") + ori_arr = np.array(image_orientation) + if ori_arr.ndim != 1 or ori_arr.shape[0] != 6: + raise ValueError("Argument 'image_orientation' must have shape (6, )") + + normal_vector = get_normal_vector( + ori_arr, + index_convention=index_convention, + handedness=handedness, + ) + + # Calculate distance of each slice from coordinate system origin along the + # normal vector + origin_distances = _get_slice_distances(pos_arr, normal_vector) + + sort_index = np.argsort(origin_distances) + + return sort_index.tolist() + + +def get_dataset_sort_index( + datasets: Sequence[Dataset], + index_convention: Union[ + str, + Sequence[Union[PixelIndexDirections, str]] + ] = VOLUME_INDEX_CONVENTION, + handedness: Union[AxisHandedness, str] = AxisHandedness.RIGHT_HANDED, +) -> List[int]: + """Get index to sort single frame datasets spatially. + + Parameters + ---------- + datasets: Sequence[pydicom.Dataset] + Datasets containing single frame images, with a consistent orientation. + index_convention: Sequence[Union[highdicom.enum.PixelIndexDirections, str]], optional + Convention used to determine how to order frames. Should be a sequence + of two :class:`highdicom.enum.PixelIndexDirections` or their string + representations, giving in order, the indexing conventions used for + specifying pixel indices. For example ``('R', 'D')`` means that the + first pixel index indexes the columns from left to right, and the + second pixel index indexes the rows from top to bottom (this is the + convention typically used within DICOM). As another example ``('D', + 'R')`` would switch the order of the indices to give the convention + typically used within NumPy. + + Alternatively, a single shorthand string may be passed that combines + the string representations of the two directions. So for example, + passing ``'RD'`` is equivalent to passing ``('R', 'D')``. + + This is used in combination with the ``handedness`` to determine + the positive direction used to order frames. + handedness: Union[highdicom.enum.AxisHandedness, str], optional + Choose the frame order in order such that the frame axis creates a + coordinate system with this handedness in the when combined with + the within-frame convention given by ``index_convention``. + + Returns + ------- + List[int] + Sorting index for the input datasets. Element i of this list gives the + index in the original list of datasets such that the output list is + sorted along the positive direction of the normal vector of the imaging + plane. + + """ # noqa: E501 + if is_multiframe_image(datasets[0]): + raise ValueError('Datasets should be single frame images.') + if 'ImageOrientationPatient' not in datasets[0]: + raise AttributeError( + 'Datasets do not have an orientation.' + ) + image_orientation = datasets[0].ImageOrientationPatient + if not all( + np.allclose(ds.ImageOrientationPatient, image_orientation) + for ds in datasets + ): + raise ValueError('Datasets do not have a consistent orientation.') + positions = [ds.ImagePositionPatient for ds in datasets] + return get_plane_sort_index( + positions, + image_orientation, + index_convention=index_convention, + handedness=handedness, + ) + + +def sort_datasets( + datasets: Sequence[Dataset], + index_convention: Union[ + str, + Sequence[Union[PixelIndexDirections, str]] + ] = VOLUME_INDEX_CONVENTION, + handedness: Union[AxisHandedness, str] = AxisHandedness.RIGHT_HANDED, +) -> List[Dataset]: + """Sort single frame datasets spatially. + + Parameters + ---------- + datasets: Sequence[pydicom.Dataset] + Datasets containing single frame images, with a consistent orientation. + index_convention: Sequence[Union[highdicom.enum.PixelIndexDirections, str]], optional + Convention used to determine how to order frames. Should be a sequence + of two :class:`highdicom.enum.PixelIndexDirections` or their string + representations, giving in order, the indexing conventions used for + specifying pixel indices. For example ``('R', 'D')`` means that the + first pixel index indexes the columns from left to right, and the + second pixel index indexes the rows from top to bottom (this is the + convention typically used within DICOM). As another example ``('D', + 'R')`` would switch the order of the indices to give the convention + typically used within NumPy. + + Alternatively, a single shorthand string may be passed that combines + the string representations of the two directions. So for example, + passing ``'RD'`` is equivalent to passing ``('R', 'D')``. + + This is used in combination with the ``handedness`` to determine + the positive direction used to order frames. + handedness: Union[highdicom.enum.AxisHandedness, str], optional + Choose the frame order in order such that the frame axis creates a + coordinate system with this handedness in the when combined with + the within-frame convention given by ``index_convention``. + + + Returns + ------- + List[Dataset] + Sorting index for the input datasets. Element i of this list gives the + index in the original list of datasets such that the output list is + sorted along the positive direction of the normal vector of the imaging + plane. + + """ # noqa: E501 + sort_index = get_dataset_sort_index( + datasets, + index_convention=index_convention, + handedness=handedness, + ) + return [datasets[i] for i in sort_index] + + +def _get_slice_distances( + image_positions: np.ndarray, + normal_vector: np.ndarray, +) -> np.ndarray: + """Get distances of a set of planes from the origin. + + For each plane position, find (signed) distance from origin along the + vector normal to the imaging plane. + + Parameters + ---------- + image_positions: np.ndarray + Image positions array. 2D array of shape (N, 3) where N is the number + of planes and each row gives the (x, y, z) image position of a plane. + normal_vector: np.ndarray + Unit normal vector (perpendicular to the imaging plane). + + Returns + ------- + np.ndarray: + 1D array of shape (N, ) giving signed distance from the origin of each + plane position. + + """ + origin_distances = normal_vector[None] @ image_positions.T + origin_distances = origin_distances.squeeze(0) + + return origin_distances diff --git a/src/highdicom/sr/coding.py b/src/highdicom/sr/coding.py index 22553839..de6f407e 100644 --- a/src/highdicom/sr/coding.py +++ b/src/highdicom/sr/coding.py @@ -1,6 +1,7 @@ from copy import deepcopy import logging from typing import Optional, Union +from typing_extensions import Self from pydicom.dataset import Dataset from pydicom.sr.coding import Code @@ -96,7 +97,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True - ) -> 'CodedConcept': + ) -> Self: """Construct a CodedConcept from an existing dataset. Parameters @@ -147,7 +148,7 @@ def from_dataset( return concept @classmethod - def from_code(cls, code: Union[Code, 'CodedConcept']) -> 'CodedConcept': + def from_code(cls, code: Union[Code, 'CodedConcept']) -> Self: """Construct a CodedConcept for a pydicom Code. Parameters diff --git a/src/highdicom/sr/content.py b/src/highdicom/sr/content.py index 9c4411a7..fcc5de14 100644 --- a/src/highdicom/sr/content.py +++ b/src/highdicom/sr/content.py @@ -2,6 +2,7 @@ import logging from copy import deepcopy from typing import cast, List, Optional, Sequence, Union +from typing_extensions import Self import numpy as np from pydicom.uid import ( @@ -32,6 +33,7 @@ Scoord3DContentItem, UIDRefContentItem, ) +from highdicom._module_utils import is_multiframe_image logger = logging.getLogger(__name__) @@ -90,7 +92,7 @@ def _check_frame_numbers_valid_for_dataset( referenced_frame_numbers: Optional[Sequence[int]] ) -> None: if referenced_frame_numbers is not None: - if not hasattr(dataset, 'NumberOfFrames'): + if not is_multiframe_image(dataset): raise TypeError( 'The dataset does not represent a multi-frame dataset, so no ' 'referenced frame numbers should be provided.' @@ -157,7 +159,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'LongitudinalTemporalOffsetFromEvent': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -180,7 +182,7 @@ def from_dataset( else: dataset_copy = dataset item = super()._from_dataset_base(dataset_copy) - return cast(LongitudinalTemporalOffsetFromEvent, item) + return cast(cls, item) class SourceImageForMeasurementGroup(ImageContentItem): @@ -235,7 +237,7 @@ def from_source_image( cls, image: Dataset, referenced_frame_numbers: Optional[Sequence[int]] = None - ) -> 'SourceImageForMeasurementGroup': + ) -> Self: """Construct the content item directly from an image dataset Parameters @@ -269,7 +271,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'SourceImageForMeasurementGroup': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -292,7 +294,7 @@ def from_dataset( else: dataset_copy = dataset item = super()._from_dataset_base(dataset_copy) - return cast(SourceImageForMeasurementGroup, item) + return cast(cls, item) class SourceImageForMeasurement(ImageContentItem): @@ -347,7 +349,7 @@ def from_source_image( cls, image: Dataset, referenced_frame_numbers: Optional[Sequence[int]] = None - ) -> 'SourceImageForMeasurement': + ) -> Self: """Construct the content item directly from an image dataset Parameters @@ -381,7 +383,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'SourceImageForMeasurement': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -404,7 +406,7 @@ def from_dataset( else: dataset_copy = dataset item = super()._from_dataset_base(dataset_copy) - return cast(SourceImageForMeasurement, item) + return cast(cls, item) class SourceImageForRegion(ImageContentItem): @@ -459,7 +461,7 @@ def from_source_image( cls, image: Dataset, referenced_frame_numbers: Optional[Sequence[int]] = None - ) -> 'SourceImageForRegion': + ) -> Self: """Construct the content item directly from an image dataset Parameters @@ -493,7 +495,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'SourceImageForRegion': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -513,7 +515,7 @@ def from_dataset( """ dataset_copy = deepcopy(dataset) item = super()._from_dataset_base(dataset_copy) - return cast(SourceImageForRegion, item) + return cast(cls, item) class SourceImageForSegmentation(ImageContentItem): @@ -568,7 +570,7 @@ def from_source_image( cls, image: Dataset, referenced_frame_numbers: Optional[Sequence[int]] = None - ) -> 'SourceImageForSegmentation': + ) -> Self: """Construct the content item directly from an image dataset Parameters @@ -602,7 +604,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'SourceImageForSegmentation': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -625,7 +627,7 @@ def from_dataset( else: dataset_copy = dataset item = super()._from_dataset_base(dataset_copy) - return cast(SourceImageForSegmentation, item) + return cast(cls, item) class SourceSeriesForSegmentation(UIDRefContentItem): @@ -656,7 +658,7 @@ def __init__(self, referenced_series_instance_uid: str): def from_source_image( cls, image: Dataset, - ) -> 'SourceSeriesForSegmentation': + ) -> Self: """Construct the content item directly from an image dataset Parameters @@ -681,7 +683,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'SourceSeriesForSegmentation': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -704,7 +706,7 @@ def from_dataset( else: dataset_copy = dataset item = super()._from_dataset_base(dataset_copy) - return cast(SourceSeriesForSegmentation, item) + return cast(cls, item) class ImageRegion(ScoordContentItem): @@ -782,7 +784,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'ImageRegion': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -805,7 +807,7 @@ def from_dataset( else: dataset_copy = dataset item = super()._from_dataset_base(dataset_copy) - return cast(ImageRegion, item) + return cast(cls, item) class ImageRegion3D(Scoord3DContentItem): @@ -858,7 +860,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'ImageRegion3D': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -881,7 +883,7 @@ def from_dataset( else: dataset_copy = dataset item = super()._from_dataset_base(dataset_copy) - return cast(ImageRegion3D, item) + return cast(cls, item) class VolumeSurface(ContentSequence): @@ -1010,7 +1012,7 @@ def __init__( def from_sequence( cls, sequence: Sequence[Dataset] - ) -> 'VolumeSurface': + ) -> Self: """Construct an object from an existing content sequence. Parameters @@ -1218,7 +1220,7 @@ def __init__(self, referenced_sop_instance_uid: str): def from_source_value_map( cls, value_map_dataset: Dataset, - ) -> 'RealWorldValueMap': + ) -> Self: """Construct the content item directly from an image dataset Parameters @@ -1246,7 +1248,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'RealWorldValueMap': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -1269,7 +1271,7 @@ def from_dataset( else: dataset_copy = dataset item = super()._from_dataset_base(dataset_copy) - return cast(RealWorldValueMap, item) + return cast(cls, item) class FindingSite(CodeContentItem): @@ -1370,7 +1372,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'FindingSite': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -1393,7 +1395,7 @@ def from_dataset( else: dataset_copy = dataset item = super()._from_dataset_base(dataset_copy) - return cast(FindingSite, item) + return cast(cls, item) class ReferencedSegmentationFrame(ContentSequence): @@ -1453,7 +1455,7 @@ def __init__( def from_sequence( cls, sequence: Sequence[Dataset] - ) -> 'ReferencedSegmentationFrame': + ) -> Self: """Construct an object from items within an existing content sequence. Parameters @@ -1515,7 +1517,7 @@ def from_sequence( new_seq = ContentSequence([seg_frame_items[0], source_image_items[0]]) new_seq.__class__ = cls - return cast(ReferencedSegmentationFrame, new_seq) + return cast(cls, new_seq) @classmethod def from_segmentation( @@ -1523,7 +1525,7 @@ def from_segmentation( segmentation: Dataset, frame_number: Optional[Union[int, Sequence[int]]] = None, segment_number: Optional[int] = None - ) -> 'ReferencedSegmentationFrame': + ) -> Self: """Construct the content item directly from a segmentation dataset Parameters @@ -1810,7 +1812,7 @@ def __init__( def from_sequence( cls, sequence: Sequence[Dataset] - ) -> 'ReferencedSegment': + ) -> Self: """Construct an object from items within an existing content sequence. Parameters @@ -1904,7 +1906,7 @@ def from_sequence( ) new_seq.__class__ = cls - return cast(ReferencedSegment, new_seq) + return cast(cls, new_seq) @classmethod def from_segmentation( @@ -1912,7 +1914,7 @@ def from_segmentation( segmentation: Dataset, segment_number: int, frame_numbers: Optional[Sequence[int]] = None - ) -> 'ReferencedSegment': + ) -> Self: """Construct the content item directly from a segmentation dataset Parameters diff --git a/src/highdicom/sr/sop.py b/src/highdicom/sr/sop.py index f60eaeeb..c0b54c95 100644 --- a/src/highdicom/sr/sop.py +++ b/src/highdicom/sr/sop.py @@ -17,6 +17,7 @@ Union, BinaryIO, ) +from typing_extensions import Self from pydicom import dcmread from pydicom.dataset import Dataset @@ -734,7 +735,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'ComprehensiveSR': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -755,8 +756,8 @@ def from_dataset( if dataset.SOPClassUID != ComprehensiveSRStorage: raise ValueError('Dataset is not a Comprehensive SR document.') sop_instance = super().from_dataset(dataset, copy=copy) - sop_instance.__class__ = ComprehensiveSR - return cast(ComprehensiveSR, sop_instance) + sop_instance.__class__ = cls + return cast(cls, sop_instance) class Comprehensive3DSR(_SR): @@ -885,7 +886,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True - ) -> 'Comprehensive3DSR': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -906,8 +907,8 @@ def from_dataset( if dataset.SOPClassUID != Comprehensive3DSRStorage: raise ValueError('Dataset is not a Comprehensive 3D SR document.') sop_instance = super().from_dataset(dataset, copy=copy) - sop_instance.__class__ = Comprehensive3DSR - return cast(Comprehensive3DSR, sop_instance) + sop_instance.__class__ = cls + return cast(cls, sop_instance) def srread( diff --git a/src/highdicom/sr/templates.py b/src/highdicom/sr/templates.py index 2cefa6c3..8e5a62f4 100644 --- a/src/highdicom/sr/templates.py +++ b/src/highdicom/sr/templates.py @@ -3,6 +3,7 @@ import logging from copy import deepcopy from typing import cast, Dict, Iterable, List, Optional, Sequence, Tuple, Union +from typing_extensions import Self from pydicom.dataset import Dataset from pydicom.sr.coding import Code @@ -1260,7 +1261,7 @@ def from_sequence( cls, sequence: Sequence[Dataset], is_root: bool = False - ) -> 'PersonObserverIdentifyingAttributes': + ) -> Self: """Construct object from a sequence of datasets. Parameters @@ -1496,7 +1497,7 @@ def from_sequence( cls, sequence: Sequence[Dataset], is_root: bool = False - ) -> 'DeviceObserverIdentifyingAttributes': + ) -> Self: """Construct object from a sequence of datasets. Parameters @@ -1650,7 +1651,7 @@ def from_sequence( cls, sequence: Sequence[Dataset], is_root: bool = False - ) -> 'SubjectContextFetus': + ) -> Self: """Construct object from a sequence of datasets. Parameters @@ -1814,7 +1815,7 @@ def specimen_type(self) -> Union[CodedConcept, None]: def from_image( cls, image: Dataset, - ) -> 'SubjectContextSpecimen': + ) -> Self: """Deduce specimen information from an existing image. This is appropriate, for example, when copying the specimen information @@ -1858,7 +1859,7 @@ def from_sequence( cls, sequence: Sequence[Dataset], is_root: bool = False - ) -> 'SubjectContextSpecimen': + ) -> Self: """Construct object from a sequence of datasets. Parameters @@ -2071,7 +2072,7 @@ def from_sequence( cls, sequence: Sequence[Dataset], is_root: bool = False - ) -> 'SubjectContextDevice': + ) -> Self: """Construct object from a sequence of datasets. Parameters @@ -2171,7 +2172,7 @@ def __init__( self.extend(subject_class_specific_context) @classmethod - def from_image(cls, image: Dataset) -> 'Optional[SubjectContext]': + def from_image(cls, image: Dataset) -> Self | None: """Get a subject context inferred from an existing image. Currently this is only supported for subjects that are specimens. @@ -2335,7 +2336,7 @@ def from_sequence( cls, sequence: Sequence[Dataset], is_root: bool = False - ) -> 'QualitativeEvaluation': + ) -> Self: """Construct object from a sequence of content items. Parameters @@ -2528,7 +2529,7 @@ def from_sequence( cls, sequence: Sequence[Dataset], is_root: bool = False - ) -> 'Measurement': + ) -> Self: """Construct object from a sequence of content items. Parameters @@ -2818,7 +2819,7 @@ def from_sequence( cls, sequence: Sequence[Dataset], is_root: bool = False - ) -> '_MeasurementsAndQualitativeEvaluations': + ) -> Self: """Construct object from a sequence of datasets. Parameters @@ -3306,7 +3307,7 @@ class PlanarROIMeasurementsAndQualitativeEvaluations( def __init__( self, tracking_identifier: TrackingIdentifier, - referenced_region: Union[ImageRegion, ImageRegion3D, None], + referenced_region: Union[ImageRegion, ImageRegion3D, None] = None, referenced_segment: Optional[ReferencedSegmentationFrame] = None, referenced_real_world_value_map: Optional[RealWorldValueMap] = None, time_point_context: Optional[TimePointContext] = None, @@ -3547,7 +3548,7 @@ def from_sequence( cls, sequence: Sequence[Dataset], is_root: bool = False - ) -> 'PlanarROIMeasurementsAndQualitativeEvaluations': + ) -> Self: """Construct object from a sequence of datasets. Parameters @@ -3567,8 +3568,8 @@ def from_sequence( """ instance = super().from_sequence(sequence) - instance.__class__ = PlanarROIMeasurementsAndQualitativeEvaluations - return cast(PlanarROIMeasurementsAndQualitativeEvaluations, instance) + instance.__class__ = cls + return cast(cls, instance) class VolumetricROIMeasurementsAndQualitativeEvaluations( @@ -3821,7 +3822,7 @@ def from_sequence( cls, sequence: Sequence[Dataset], is_root: bool = False - ) -> 'VolumetricROIMeasurementsAndQualitativeEvaluations': + ) -> Self: """Construct object from a sequence of datasets. Parameters @@ -3841,11 +3842,8 @@ def from_sequence( """ instance = super().from_sequence(sequence) - instance.__class__ = VolumetricROIMeasurementsAndQualitativeEvaluations - return cast( - VolumetricROIMeasurementsAndQualitativeEvaluations, - instance - ) + instance.__class__ = cls + return cast(cls, instance) class ImageLibraryEntryDescriptors(Template): @@ -4255,7 +4253,7 @@ def from_sequence( sequence: Sequence[Dataset], is_root: bool = True, copy: bool = True, - ) -> 'MeasurementReport': + ) -> Self: """Construct object from a sequence of datasets. Parameters @@ -4300,8 +4298,8 @@ def from_sequence( is_root=True, copy=copy ) - instance.__class__ = MeasurementReport - return cast(MeasurementReport, instance) + instance.__class__ = cls + return cast(cls, instance) def get_observer_contexts( self, diff --git a/src/highdicom/sr/value_types.py b/src/highdicom/sr/value_types.py index 04a6421b..5c81a613 100644 --- a/src/highdicom/sr/value_types.py +++ b/src/highdicom/sr/value_types.py @@ -14,6 +14,7 @@ Tuple, Union, ) +from typing_extensions import Self import numpy as np from pydicom.dataelem import DataElement @@ -158,7 +159,7 @@ def __setattr__( super().__setattr__(name, value) @classmethod - def _from_dataset_derived(cls, dataset: Dataset) -> 'ContentItem': + def _from_dataset_derived(cls, dataset: Dataset) -> Self: """Construct object of derived type from an existing dataset. Parameters @@ -184,7 +185,7 @@ def _from_dataset_derived(cls, dataset: Dataset) -> 'ContentItem': ) # type: ignore @classmethod - def _from_dataset_base(cls, dataset: Dataset) -> 'ContentItem': + def _from_dataset_base(cls, dataset: Dataset) -> Self: if not hasattr(dataset, 'ValueType'): raise AttributeError( 'Dataset is not an SR Content Item because it lacks ' @@ -226,7 +227,7 @@ def _from_dataset_base(cls, dataset: Dataset) -> 'ContentItem': copy=False ) ] - return cast(ContentItem, item) + return cast(cls, item) @property def name(self) -> CodedConcept: @@ -430,7 +431,7 @@ def index(self, val: ContentItem) -> int: # type: ignore[override] raise ValueError(error_message) from e return index - def find(self, name: Union[Code, CodedConcept]) -> 'ContentSequence': + def find(self, name: Union[Code, CodedConcept]) -> Self: """Find contained content items given their name. Parameters @@ -450,7 +451,7 @@ def find(self, name: Union[Code, CodedConcept]) -> 'ContentSequence': is_sr=self._is_sr ) - def get_nodes(self) -> 'ContentSequence': + def get_nodes(self) -> Self: """Get content items that represent nodes in the content tree. A node is hereby defined as a content item that has a `ContentSequence` @@ -556,7 +557,7 @@ def from_sequence( is_root: bool = False, is_sr: bool = True, copy: bool = True, - ) -> 'ContentSequence': + ) -> Self: """Construct object from a sequence of datasets. Parameters @@ -688,7 +689,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'CodeContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -715,7 +716,7 @@ def from_dataset( item.ConceptCodeSequence = DataElementSequence([ CodedConcept.from_dataset(item.ConceptCodeSequence[0], copy=False) ]) - return cast(CodeContentItem, item) + return cast(cls, item) class PnameContentItem(ContentItem): @@ -755,7 +756,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'PnameContentItem': + ) -> Self: """Construct object from existing dataset. Parameters @@ -779,7 +780,7 @@ def from_dataset( dataset_copy = dataset _assert_value_type(dataset_copy, ValueTypeValues.PNAME) item = super()._from_dataset_base(dataset_copy) - return cast(PnameContentItem, item) + return cast(cls, item) class TextContentItem(ContentItem): @@ -818,7 +819,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'TextContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -842,7 +843,7 @@ def from_dataset( dataset_copy = dataset _assert_value_type(dataset_copy, ValueTypeValues.TEXT) item = super()._from_dataset_base(dataset_copy) - return cast(TextContentItem, item) + return cast(cls, item) class TimeContentItem(ContentItem): @@ -890,7 +891,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'TimeContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -914,7 +915,7 @@ def from_dataset( dataset_copy = dataset _assert_value_type(dataset_copy, ValueTypeValues.TIME) item = super()._from_dataset_base(dataset_copy) - return cast(TimeContentItem, item) + return cast(cls, item) class DateContentItem(ContentItem): @@ -962,7 +963,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'DateContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -986,7 +987,7 @@ def from_dataset( dataset_copy = dataset _assert_value_type(dataset_copy, ValueTypeValues.DATE) item = super()._from_dataset_base(dataset_copy) - return cast(DateContentItem, item) + return cast(cls, item) class DateTimeContentItem(ContentItem): @@ -1034,7 +1035,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'DateTimeContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -1058,7 +1059,7 @@ def from_dataset( dataset_copy = dataset _assert_value_type(dataset_copy, ValueTypeValues.DATETIME) item = super()._from_dataset_base(dataset_copy) - return cast(DateTimeContentItem, item) + return cast(cls, item) class UIDRefContentItem(ContentItem): @@ -1097,7 +1098,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'UIDRefContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -1121,7 +1122,7 @@ def from_dataset( dataset_copy = dataset _assert_value_type(dataset_copy, ValueTypeValues.UIDREF) item = super()._from_dataset_base(dataset_copy) - return cast(UIDRefContentItem, item) + return cast(cls, item) class NumContentItem(ContentItem): @@ -1216,7 +1217,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'NumContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -1253,7 +1254,7 @@ def from_dataset( item.NumericValueQualifierCodeSequence = DataElementSequence([ CodedConcept.from_dataset(qualifier_item, copy=False) ]) - return cast(NumContentItem, item) + return cast(cls, item) class ContainerContentItem(ContentItem): @@ -1308,7 +1309,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'ContainerContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -1332,7 +1333,7 @@ def from_dataset( dataset_copy = dataset _assert_value_type(dataset_copy, ValueTypeValues.CONTAINER) item = super()._from_dataset_base(dataset_copy) - return cast(ContainerContentItem, item) + return cast(cls, item) class CompositeContentItem(ContentItem): @@ -1393,7 +1394,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'CompositeContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -1417,7 +1418,7 @@ def from_dataset( dataset_copy = dataset _assert_value_type(dataset_copy, ValueTypeValues.COMPOSITE) item = super()._from_dataset_base(dataset_copy) - return cast(CompositeContentItem, item) + return cast(cls, item) class ImageContentItem(ContentItem): @@ -1524,7 +1525,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'ImageContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -1548,7 +1549,7 @@ def from_dataset( dataset_copy = dataset _assert_value_type(dataset_copy, ValueTypeValues.IMAGE) item = super()._from_dataset_base(dataset_copy) - return cast(ImageContentItem, item) + return cast(cls, item) class ScoordContentItem(ContentItem): @@ -1654,7 +1655,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'ScoordContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -1678,7 +1679,7 @@ def from_dataset( dataset_copy = dataset _assert_value_type(dataset_copy, ValueTypeValues.SCOORD) item = super()._from_dataset_base(dataset_copy) - return cast(ScoordContentItem, item) + return cast(cls, item) class Scoord3DContentItem(ContentItem): @@ -1799,7 +1800,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'Scoord3DContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -1823,7 +1824,7 @@ def from_dataset( dataset_copy = dataset _assert_value_type(dataset_copy, ValueTypeValues.SCOORD3D) item = super()._from_dataset_base(dataset_copy) - return cast(Scoord3DContentItem, item) + return cast(cls, item) class TcoordContentItem(ContentItem): @@ -1904,7 +1905,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'TcoordContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -1928,7 +1929,7 @@ def from_dataset( dataset_copy = dataset _assert_value_type(dataset_copy, ValueTypeValues.TCOORD) item = super()._from_dataset_base(dataset_copy) - return cast(TcoordContentItem, item) + return cast(cls, item) class WaveformContentItem(ContentItem): @@ -2023,7 +2024,7 @@ def from_dataset( cls, dataset: Dataset, copy: bool = True, - ) -> 'WaveformContentItem': + ) -> Self: """Construct object from an existing dataset. Parameters @@ -2047,4 +2048,4 @@ def from_dataset( dataset_copy = dataset _assert_value_type(dataset_copy, ValueTypeValues.IMAGE) item = super()._from_dataset_base(dataset_copy) - return cast(WaveformContentItem, item) + return cast(cls, item) diff --git a/src/highdicom/uid.py b/src/highdicom/uid.py index 334bf877..862c8582 100644 --- a/src/highdicom/uid.py +++ b/src/highdicom/uid.py @@ -1,6 +1,7 @@ import logging from uuid import UUID from typing import Optional, Type, TypeVar +from typing_extensions import Self import pydicom @@ -25,7 +26,7 @@ def __new__(cls: Type[T], value: Optional[str] = None) -> T: return super().__new__(cls, value) @classmethod - def from_uuid(cls, uuid: str) -> 'UID': + def from_uuid(cls, uuid: str) -> Self: """Create a DICOM UID from a UUID using the 2.25 root. Parameters diff --git a/src/highdicom/volume.py b/src/highdicom/volume.py new file mode 100644 index 00000000..9706e18b --- /dev/null +++ b/src/highdicom/volume.py @@ -0,0 +1,3166 @@ +"""Representations of multidimensional arrays with spatial metadata.""" +from abc import ABC, abstractmethod +from enum import Enum +import itertools +from os import PathLike +from pathlib import Path +from typing import Any, List, Optional, Sequence, Union, Tuple, cast +from pydicom.tag import BaseTag +from typing_extensions import Self + +import numpy as np + +from highdicom._value_types import ( + _DCM_PYTHON_TYPE_MAP +) +from highdicom._module_utils import is_multiframe_image +from highdicom.enum import ( + AxisHandedness, + CoordinateSystemNames, + PadModes, + PatientOrientationValuesBiped, + RGBColorChannels, +) +from highdicom.spatial import ( + _create_affine_transformation_matrix, + _is_matrix_orthogonal, + _normalize_patient_orientation, + _stack_affine_matrix, + _transform_affine_matrix, + _translate_affine_matrix, + _DEFAULT_EQUALITY_TOLERANCE, + PATIENT_ORIENTATION_OPPOSITES, + VOLUME_INDEX_CONVENTION, + get_closest_patient_orientation, +) +from highdicom.content import ( + PixelMeasuresSequence, + PlaneOrientationSequence, + PlanePositionSequence, +) + +from pydicom import dcmread +from pydicom.datadict import ( + get_entry, + tag_for_keyword, + keyword_for_tag, +) + + +# TODO add pixel value transformations +# TODO should methods copy arrays? +# TODO trim non-zero +# TODO support slide coordinate system +# TODO volread and metadata +# TODO constructors for geometry, do they make sense for volume? +# TODO ordering of frames in seg, setting 3D dimension organization +# TODO get_volume to multiframe image +# TODO lazy loading for multiframe +# TODO get volume from legacy series +# TODO make multiframe public +# TODO allow non-consecutive segments when reading (confirm with standard)? +# TODO check logic around slice thickness and spacing for seg creation +# TODO what do about multiple custom channels +# TODO tidy up channel/dimension terminology + + +class ChannelIdentifier: + + def __init__( + self, + identifier: str | int | Self, + is_custom: bool = False, + value_type: type | None = None, + ): + if isinstance(identifier, self.__class__): + self._keyword = identifier.keyword + self._tag = identifier.tag + self._value_type = identifier.value_type + return + + if is_custom: + if not isinstance(identifier, str): + raise TypeError( + 'Custom identifiers must be specified via a string.' + ) + if tag_for_keyword(identifier) is not None: + raise ValueError( + f"The string '{identifier}' cannot be used for a " + "custom channel identifier because it is a DICOM " + "keyword." + ) + self._keyword = identifier + self._tag: BaseTag | None = None + + if value_type is None: + raise TypeError( + "Argument 'value_type' must be specified when defining " + "a custom channel identifier." + ) + + if not issubclass(value_type, (str, int, float, Enum)): + raise ValueError( + "Argument 'value_type' must be str, int, float, Enum " + "or a subclass of these." + ) + + if value_type is Enum: + raise ValueError( + "When using Enums, argument 'value_type' must be a specific " + "subclass of Enum " + ) + + self._value_type = value_type + else: + if isinstance(identifier, int): # also covers BaseTag + self._tag = BaseTag(identifier) + keyword = keyword_for_tag(identifier) + if keyword is None: + self._keyword = str(self._tag) + else: + self._keyword = keyword + + elif isinstance(identifier, str): + t = tag_for_keyword(identifier) + + if t is None: + raise ValueError( + f'No attribute found with keyword {identifier}. ' + 'You may need to specify a custom identifier ' + "using 'is_custom'." + ) + + self._tag = BaseTag(t) + self._keyword = identifier + else: + raise TypeError( + "Argument 'identifier' must be an int or str." + ) + + if value_type is not None: + raise TypeError( + "Argument 'value_type' should only be specified when defining " + "a custom channel identifier." + ) + + vr, _, _, _, _ = get_entry(self._tag) + self._value_type = _DCM_PYTHON_TYPE_MAP[vr] + + @property + def value_type(self) -> type: + return self._value_type + + @property + def keyword(self) -> str: + return self._keyword + + @property + def tag(self) -> BaseTag | None: + return self._tag + + @property + def is_custom(self) -> bool: + return self._tag is None + + @property + def is_enumerated(self) -> bool: + return issubclass(self.value_type, Enum) + + def __hash__(self) -> int: + return hash(self._keyword) + + def __str__(self): + return self._keyword + + def __repr__(self): + return self._keyword + + def __eq__(self, other): + return self._keyword == other._keyword + + +RGB_COLOR_CHANNEL_IDENTIFIER = ChannelIdentifier( + 'RGBColorChannel', + value_type=RGBColorChannels, + is_custom=True, +) + + +class _VolumeBase(ABC): + + """Base class for object exhibiting volume geometry.""" + + def __init__( + self, + affine: np.ndarray, + frame_of_reference_uid: Optional[str] = None, + ): + """ + + Parameters + ---------- + affine: numpy.ndarray + 4 x 4 affine matrix representing the transformation from pixel + indices (slice index, row index, column index) to the + frame-of-reference coordinate system. The top left 3 x 3 matrix + should be a scaled orthogonal matrix representing the rotation and + scaling. The top right 3 x 1 vector represents the translation + component. The last row should have value [0, 0, 0, 1]. + frame_of_reference_uid: Optional[str], optional + Frame of reference UID for the frame of reference, if known. + + """ + if affine.shape != (4, 4): + raise ValueError("Affine matrix must have shape (4, 4).") + if not np.array_equal(affine[-1, :], np.array([0.0, 0.0, 0.0, 1.0])): + raise ValueError( + "Final row of affine matrix must be [0.0, 0.0, 0.0, 1.0]." + ) + if not _is_matrix_orthogonal(affine[:3, :3], require_unit=False): + raise ValueError( + "Argument 'affine' must be an orthogonal matrix." + ) + + self._affine = affine + self._frame_of_reference_uid = frame_of_reference_uid + + @property + @abstractmethod + def spatial_shape(self) -> Tuple[int, int, int]: + """Tuple[int, int, int]: Spatial shape of the array. + + Does not include the channel dimension. + + """ + pass + + def get_center_index(self, round_output: bool = False) -> np.ndarray: + """Get array index of center of the volume. + + Parameters + ---------- + round_output: bool, optional + If True, the result is returned rounded down to and with an integer + datatype. Otherwise it is returned as a floating point datatype + without rounding, to sub-voxel precision. + + Returns + ------- + numpy.ndarray: + Array of shape 3 representing the array indices at the center of + the volume. + + """ + if round_output: + center = np.array( + [(self.spatial_shape[d] - 1) // 2 for d in range(3)], + dtype=np.uint32, + ) + else: + center = np.array( + [(self.spatial_shape[d] - 1) / 2.0 for d in range(3)] + ) + + return center + + def get_center_coordinate(self) -> np.ndarray: + """Get frame-of-reference coordinate at the center of the volume. + + Returns + ------- + numpy.ndarray: + Array of shape 3 representing the frame-of-reference coordinate at + the center of the volume. + + """ + center_index = self.get_center_index().reshape((1, 3)) + center_coordinate = self.map_indices_to_reference(center_index) + + return center_coordinate.reshape((3, )) + + def map_indices_to_reference( + self, + indices: np.ndarray, + ) -> np.ndarray: + """Transform image pixel indices to frame of reference coordinates. + + Parameters + ---------- + indices: numpy.ndarray + Array of zero-based array indices. Array of integer values with + shape ``(n, 3)``, where *n* is the number of indices, the first + column represents the `column` index and the second column + represents the `row` index. + + Returns + ------- + numpy.ndarray + Array of (x, y, z) coordinates in the coordinate system defined by + the frame of reference. Array has shape ``(n, 3)``, where *n* is + the number of coordinates, the first column represents the `x` + offsets, the second column represents the `y` offsets and the third + column represents the `z` offsets + + Raises + ------ + ValueError + When `indices` has incorrect shape. + + """ + if indices.ndim != 2 or indices.shape[1] != 3: + raise ValueError( + 'Argument "indices" must be a two-dimensional array ' + 'with shape [n, 3].' + ) + indices_augmented = np.vstack([ + indices.T.astype(float), + np.ones((indices.shape[0], ), dtype=float), + ]) + reference_coordinates = np.dot(self._affine, indices_augmented) + return reference_coordinates[:3, :].T + + def map_reference_to_indices( + self, + coordinates: np.ndarray, + round_output: bool = False, + check_bounds: bool = False, + ) -> np.ndarray: + """Transform frame of reference coordinates into array indices. + + Parameters + ---------- + coordinates: numpy.ndarray + Array of (x, y, z) coordinates in the coordinate system defined by + the frame of reference. Array has shape ``(n, 3)``, where *n* is + the number of coordinates, the first column represents the *X* + offsets, the second column represents the *Y* offsets and the third + column represents the *Z* offsets + + Returns + ------- + numpy.ndarray + Array of zero-based array indices at pixel resolution. Array of + integer or floating point values with shape ``(n, 3)``, where *n* + is the number of indices. The datatype of the array will be integer + if ``round_output`` is True (the default), or float if + ``round_output`` is False. + round_output: bool, optional + Whether to round the output to the nearest voxel. If True, the + output will have integer datatype. If False, the returned array + will have floating point data type and sub-voxel precision. + check_bounds: bool, optional + Whether to check that the returned indices lie within the bounds of + the array. If True, a ``RuntimeError`` will be raised if the + resulting array indices (before rounding) lie out of the bounds of + the array. + + Note + ---- + The returned pixel indices may be negative if `coordinates` fall + outside of the array. + + Raises + ------ + ValueError + When `indices` has incorrect shape. + RuntimeError + If `check_bounds` is True and any map coordinate lies outside the + bounds of the array. + + """ + if coordinates.ndim != 2 or coordinates.shape[1] != 3: + raise ValueError( + 'Argument "coordinates" must be a two-dimensional array ' + 'with shape [n, 3].' + ) + reference_coordinates = np.vstack([ + coordinates.T.astype(float), + np.ones((coordinates.shape[0], ), dtype=float) + ]) + indices = np.dot(self.inverse_affine, reference_coordinates) + indices = indices[:3, :].T + + if check_bounds: + out_of_bounds = False + for d in range(3): + if indices[:, d].min() < -0.5: + out_of_bounds = True + break + if indices[:, d].max() > self.spatial_shape[d] - 0.5: + out_of_bounds = True + break + + if out_of_bounds: + raise RuntimeError("Bounds check failed.") + + if round_output: + return np.around(indices).astype(int) + else: + return indices + + def get_plane_position(self, plane_number: int) -> PlanePositionSequence: + """Get plane position of a given plane. + + Parameters + ---------- + plane_number: int + Zero-based plane index (down the first dimension of the array). + + Returns + ------- + highdicom.content.PlanePositionSequence: + Plane position of the plane. + + """ + if plane_number < 0 or plane_number >= self.spatial_shape[0]: + raise ValueError("Invalid plane number for volume.") + index = np.array([[plane_number, 0, 0]]) + position = self.map_indices_to_reference(index)[0] + + return PlanePositionSequence( + CoordinateSystemNames.PATIENT, + position, + ) + + def get_plane_positions(self) -> List[PlanePositionSequence]: + """Get plane positions of all planes in the volume. + + This assumes that the volume is encoded in a DICOM file with frames + down axis 0, rows stacked down axis 1, and columns stacked down axis 2. + + Returns + ------- + List[highdicom.content.PlanePositionSequence]: + Plane position of the all planes (stacked down axis 0 of the + volume). + + """ + indices = np.array( + [ + [p, 0, 0] for p in range(self.spatial_shape[0]) + ] + ) + positions = self.map_indices_to_reference(indices) + + return [ + PlanePositionSequence( + CoordinateSystemNames.PATIENT, + pos, + ) + for pos in positions + ] + + def get_plane_orientation(self) -> PlaneOrientationSequence: + """Get plane orientation sequence for the volume. + + This assumes that the volume is encoded in a DICOM file with frames + down axis 0, rows stacked down axis 1, and columns stacked down axis 2. + + Returns + ------- + highdicom.PlaneOrientationSequence: + Plane orientation sequence. + + """ + return PlaneOrientationSequence( + CoordinateSystemNames.PATIENT, + self.direction_cosines, + ) + + def get_pixel_measures(self) -> PixelMeasuresSequence: + """Get pixel measures sequence for the volume. + + This assumes that the volume is encoded in a DICOM file with frames + down axis 0, rows stacked down axis 1, and columns stacked down axis 2. + + Returns + ------- + highdicom.PixelMeasuresSequence: + Pixel measures sequence for the volume. + + """ + return PixelMeasuresSequence( + pixel_spacing=self.pixel_spacing, + slice_thickness=self.spacing_between_slices, + spacing_between_slices=self.spacing_between_slices, + ) + + @property + def frame_of_reference_uid(self) -> Optional[str]: + """Union[str, None]: Frame of reference UID.""" + return self._frame_of_reference_uid + + @property + def affine(self) -> np.ndarray: + """numpy.ndarray: 4x4 affine transformation matrix + + This matrix maps an index into the array into a position in the LPS + frame of reference coordinate space. + + """ + return self._affine.copy() + + @property + def inverse_affine(self) -> np.ndarray: + """numpy.ndarray: 4x4 inverse affine transformation matrix + + Inverse of the affine matrix. This matrix maps a position in the LPS + frame of reference coordinate space into an index into the array. + + """ + return np.linalg.inv(self._affine) + + @property + def direction_cosines(self) -> Tuple[ + float, float, float, float, float, float + ]: + """Tuple[float, float, float, float, float float]: + + Tuple of 6 floats giving the direction cosines of the + vector along the rows and the vector along the columns, matching the + format of the DICOM Image Orientation Patient attribute. + + """ + vec_along_rows = self._affine[:3, 2].copy() + vec_along_columns = self._affine[:3, 1].copy() + vec_along_columns /= np.sqrt((vec_along_columns ** 2).sum()) + vec_along_rows /= np.sqrt((vec_along_rows ** 2).sum()) + return tuple([*vec_along_rows.tolist(), *vec_along_columns.tolist()]) + + @property + def pixel_spacing(self) -> Tuple[float, float]: + """Tuple[float, float]: + + Within-plane pixel spacing in millimeter units. Two + values (spacing between rows, spacing between columns). + + """ + vec_along_rows = self._affine[:3, 2] + vec_along_columns = self._affine[:3, 1] + spacing_between_columns = np.sqrt((vec_along_rows ** 2).sum()).item() + spacing_between_rows = np.sqrt((vec_along_columns ** 2).sum()).item() + return spacing_between_rows, spacing_between_columns + + @property + def spacing_between_slices(self) -> float: + """float: + + Spacing between consecutive slices in millimeter units. + + """ + slice_vec = self._affine[:3, 0] + spacing = np.sqrt((slice_vec ** 2).sum()).item() + return spacing + + @property + def spacing(self) -> Tuple[float, float, float]: + """Tuple[float, float, float]: + + Pixel spacing in millimeter units for the three spatial directions. + Three values, one for each spatial dimension. + + """ + dir_mat = self._affine[:3, :3] + norms = np.sqrt((dir_mat ** 2).sum(axis=0)) + return tuple(norms.tolist()) + + @property + def voxel_volume(self) -> float: + """float: The volume of a single voxel in cubic millimeters.""" + return np.prod(self.spacing).item() + + @property + def position(self) -> Tuple[float, float, float]: + """Tuple[float, float, float]: + + Position in the frame of reference space of the center of voxel at + indices (0, 0, 0). + + """ + return tuple(self._affine[:3, 3].tolist()) + + @property + def physical_extent(self) -> Tuple[float, float, float]: + """List[float]: Side lengths of the volume in millimeters.""" + return tuple( + [n * d for n, d in zip(self.spatial_shape, self.spacing)] + ) + + @property + def physical_volume(self) -> float: + """float: Total volume in cubic millimeter.""" + return self.voxel_volume * np.prod(self.spatial_shape).item() + + @property + def direction(self) -> np.ndarray: + """numpy.ndarray: + + Direction matrix for the volume. The columns of the direction + matrix are orthogonal unit vectors that give the direction in the + frame of reference space of the increasing direction of each axis + of the array. This matrix may be passed either as a 3x3 matrix or a + flattened 9 element array (first row, second row, third row). + + """ + dir_mat = self._affine[:3, :3] + norms = np.sqrt((dir_mat ** 2).sum(axis=0)) + return dir_mat / norms + + def spacing_vectors(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Get the vectors along the three array dimensions. + + Note that these vectors are not normalized, they have length equal to + the spacing along the relevant dimension. + + Returns + ------- + numpy.ndarray: + Vector between voxel centers along the increasing first axis. + 1D NumPy array. + numpy.ndarray: + Vector between voxel centers along the increasing second axis. + 1D NumPy array. + numpy.ndarray: + Vector between voxel centers along the increasing third axis. + 1D NumPy array. + + """ + return tuple(self.affine[:3, :3].T) + + def unit_vectors(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Get the normalized vectors along the three array dimensions. + + Returns + ------- + numpy.ndarray: + Unit vector along the increasing first axis. 1D NumPy array. + numpy.ndarray: + Unit vector along the increasing second axis. 1D NumPy array. + numpy.ndarray: + Unit vector along the increasing third axis. 1D NumPy array. + + """ + return tuple(self.direction.T) + + @abstractmethod + def __getitem__( + self, + index: Union[int, slice, Tuple[Union[int, slice]]], + ) -> Self: + pass + + def _prepare_getitem_index( + self, + index: Union[int, slice, Tuple[Union[int, slice]]], + ) -> Tuple[Tuple[slice], Tuple[int, int, int], np.ndarray]: + + def _check_int(val: int, dim: int) -> None: + if ( + val < -self.spatial_shape[dim] or + val >= self.spatial_shape[dim] + ): + raise IndexError( + f'Index {val} is out of bounds for axis {dim} with size ' + f'{self.spatial_shape[dim]}.' + ) + + def _check_slice(val: slice, dim: int) -> None: + if ( + val.start is not None and + ( + val.start < -self.spatial_shape[dim] or + val.start >= self.spatial_shape[dim] + ) + ): + raise ValueError( + f'val {val.start} is out of bounds for axis {dim} with ' + f'size {self.spatial_shape[dim]}.' + ) + if ( + val.stop is not None and + ( + val.stop < -self.spatial_shape[dim] - 1 or + val.stop > self.spatial_shape[dim] + ) + ): + raise ValueError( + f'val {val.stop} is out of bounds for axis {dim} with ' + f'size {self.spatial_shape[dim]}.' + ) + + if isinstance(index, int): + # Change the index to a slice of length one so that all dimensions + # are retained in the output array. Also make into a tuple of + # length 1 to standardize format + _check_int(index, 0) + if index == -1: + end_index = None + else: + end_index = index + 1 + tuple_index = (slice(index, end_index), ) + elif isinstance(index, slice): + # Make into a tuple of length one to standardize the format + _check_slice(index, 0) + tuple_index = (cast(slice, index), ) + elif isinstance(index, tuple): + index_list: List[slice] = [] + for dim, item in enumerate(index): + if isinstance(item, int): + # Change the index to a slice of length one so that all + # dimensions are retained in the output array. + _check_int(item, dim) + if item == -1: + end_index = None + else: + end_index = item + 1 + item = slice(item, end_index) + index_list.append(item) + elif isinstance(item, slice): + _check_slice(item, dim) + index_list.append(item) + else: + raise TypeError( + 'Items within "index" must be ints, or slices. Got ' + f'{type(item)}.' + ) + + tuple_index = tuple(index_list) + + else: + raise TypeError( + 'Argument "index" must be an int, slice or tuple. Got ' + f'{type(index)}.' + ) + + new_vectors = [] + origin_indices = [] + new_shape = [] + for d in range(0, 3): + # The index item along this dimension + if len(tuple_index) > d: + index_item = tuple_index[d] + first, last, step = index_item.indices(self.spatial_shape[d]) + index_range = last - first + if index_range == 0 or ((index_range < 0) != (step < 0)): + raise IndexError('Indexing would result in an empty array.') + size = (abs(index_range) - 1) // abs(step) + 1 + new_shape.append(size) + else: + index_item = None + first = 0 + step = 1 + new_shape.append(self.spatial_shape[d]) + + new_vectors.append(self._affine[:3, d] * step) + origin_indices.append(first) + + origin_index_arr = np.array([origin_indices]) + new_origin_arr = self.map_indices_to_reference(origin_index_arr).T + + new_rotation = np.column_stack(new_vectors) + new_affine = _stack_affine_matrix(new_rotation, new_origin_arr) + + return tuple_index, tuple(new_shape), new_affine + + @abstractmethod + def pad( + self, + pad_width: Union[int, Sequence[int], Sequence[Sequence[int]]], + *, + mode: Union[PadModes, str] = PadModes.CONSTANT, + constant_value: float = 0.0, + per_channel: bool = False, + ) -> Self: + pass + + def _prepare_pad_width( + self, + pad_width: Union[int, Sequence[int], Sequence[Sequence[int]]], + ) -> Tuple[np.ndarray, List[List[int]]]: + """Pad volume along the three spatial dimensions. + + Parameters + ---------- + pad_width: Union[int, Sequence[int], Sequence[Sequence[int]]] + Values to pad the array. Takes the same form as ``numpy.pad()``. + May be: + + * A single integer value, which results in that many voxels being + added to the beginning and end of all three spatial dimensions, + or + * A sequence of two values in the form ``[before, after]``, which + results in 'before' voxels being added to the beginning of each + of the three spatial dimensions, and 'after' voxels being added + to the end of each of the three spatial dimensions, or + * A nested sequence of integers of the form ``[[pad1], [pad2], + [pad3]]``, in which separate padding values are supplied for each + of the three spatial axes and used to pad before and after along + those axes, or + * A nested sequence of integers in the form ``[[before1, after1], + [before2, after2], [before3, after3]]``, in which separate values + are supplied for the before and after padding of each of the + three spatial dimensions. + + In all cases, all integer values must be non-negative. + + Returns + ------- + numpy.ndarray: + Affine matrix of the padded array. + List[List[int]]: + Padding specification along three spatial dimensions in format + ``[[before1, after1], [before2, after2], [before3, after3]]``. + + """ + if isinstance(pad_width, int): + if pad_width < 0: + raise ValueError( + "Argument 'pad_width' cannot contain negative values." + ) + full_pad_width: List[List[int]] = [[pad_width, pad_width]] * 3 + elif isinstance(pad_width, Sequence): + if isinstance(pad_width[0], int): + if len(pad_width) != 2: + raise ValueError("Invalid arrangement in 'pad_width'.") + if pad_width[0] < 0 or pad_width[1] < 0: + raise ValueError( + "Argument 'pad_width' cannot contain negative values." + ) + full_pad_width = [list(pad_width)] * 3 + elif isinstance(pad_width[0], Sequence): + if len(pad_width) != 3: + raise ValueError("Invalid arrangement in 'pad_width'.") + if len(pad_width[0]) == 1: + if len(pad_width[1]) != 1 or len(pad_width[2]) != 1: + raise ValueError("Invalid arrangement in 'pad_width'.") + full_pad_width = [[w[0], w[0]] for w in pad_width] + elif len(pad_width[0]) == 2: + if len(pad_width[1]) != 2 or len(pad_width[2]) != 2: + raise ValueError("Invalid arrangement in 'pad_width'.") + full_pad_width = [list(w) for w in pad_width] + else: + raise ValueError("Invalid arrangement in 'pad_width'.") + else: + raise TypeError("Invalid format for 'pad_width'.") + + origin_offset = [-p[0] for p in full_pad_width] + new_affine = _translate_affine_matrix(self.affine, origin_offset) + + return new_affine, full_pad_width + + def _permute_affine(self, indices: Sequence[int]) -> np.ndarray: + """Get affine after permuting spatial axes. + + Parameters + ---------- + indices: Sequence[int] + List of three integers containing the values 0, 1 and 2 in some + order. Note that you may not change the position of the channel + axis (if present). + + Returns + ------- + numpy.numpy: + Affine matrix (4 x 4) spatial axes permuted in the provided order. + + """ + if len(indices) != 3 or set(indices) != {0, 1, 2}: + raise ValueError( + 'Argument "indices" must consist of the values 0, 1, and 2 ' + 'in some order.' + ) + + return _transform_affine_matrix( + affine=self._affine, + shape=self.spatial_shape, + permute_indices=indices, + ) + + @abstractmethod + def copy(self) -> Self: + """Create a copy of the object. + + Returns + ------- + highdicom.volume._VolumeBase: + Copy of the original object. + + """ + pass + + @abstractmethod + def permute_spatial_axes(self, indices: Sequence[int]) -> Self: + """Create a new volume by permuting the spatial axes. + + Parameters + ---------- + indices: Sequence[int] + List of three integers containing the values 0, 1 and 2 in some + order. Note that you may not change the position of the channel + axis (if present). + + Returns + ------- + highdicom._VolumeBase: + New volume with spatial axes permuted in the provided order. + + """ + pass + + def random_permute_spatial_axes( + self, + axes: Sequence[int] = (0, 1, 2) + ) -> Self: + """Create a new geometry by randomly permuting the spatial axes. + + Parameters + ---------- + axes: Optional[Sequence[int]] + Sequence of three integers containing the values 0, 1 and 2 in some + order. The sequence must contain 2 or 3 elements. This subset of + axes will axes will be included when generating indices for + permutation. Any axis not in this sequence will remain in its + original position. + + Returns + ------- + highdicom.volume._VolumeBase: + New geometry with spatial axes permuted randomly. + + """ + if len(axes) < 2 or len(axes) > 3: + raise ValueError( + "Argument 'axes' must contain 2 or 3 items." + ) + + if len(set(axes)) != len(axes): + raise ValueError( + "Argument 'axes' should contain unique values." + ) + + if set(axes) <= {0, 1, 2}: + raise ValueError( + "Argument 'axes' should contain only 0, 1, and 2." + ) + + indices = np.random.permutation(axes).tolist() + if len(indices) == 2: + missing_index = {0, 1, 2} - set(indices) + indices.insert(missing_index, missing_index) + + return self.permute_spatial_axes(indices) + + def get_closest_patient_orientation(self) -> Tuple[ + PatientOrientationValuesBiped, + PatientOrientationValuesBiped, + PatientOrientationValuesBiped, + ]: + """Get patient orientation codes that best represent the affine. + + Returns + ------- + Tuple[highdicom.enum.PatientOrientationValuesBiped, highdicom.enum.PatientOrientationValuesBiped, highdicom.enum.PatientOrientationValuesBiped]: + Tuple giving the closest patient orientation. + + """ # noqa: E501 + return get_closest_patient_orientation(self._affine) + + def to_patient_orientation( + self, + patient_orientation: Union[ + str, + Sequence[Union[str, PatientOrientationValuesBiped]], + ], + ) -> Self: + """Rearrange the array to a given orientation. + + The resulting volume is formed from this volume through a combination + of axis permutations and flips of the spatial axes. Its patient + orientation will be as close to the desired orientation as can be + achieved with these operations alone (and in particular without + resampling the array). + + Parameters + ---------- + patient_orientation: Union[str, Sequence[Union[str, highdicom.enum.PatientOrientationValuesBiped]]] + Desired patient orientation, as either a sequence of three + highdicom.enum.PatientOrientationValuesBiped values, or a string + such as ``"FPL"`` using the same characters. + + Returns + ------- + highdicom.volume.Volume: + New volume with the requested patient orientation. + + """ # noqa: E501 + desired_orientation = _normalize_patient_orientation( + patient_orientation + ) + + current_orientation = self.get_closest_patient_orientation() + + permute_indices = [] + flip_axes = [] + for d in desired_orientation: + if d in current_orientation: + from_index = current_orientation.index(d) + else: + d_inv = PATIENT_ORIENTATION_OPPOSITES[d] + from_index = current_orientation.index(d_inv) + flip_axes.append(from_index) + permute_indices.append(from_index) + + if len(flip_axes) > 0: + result = self.flip_spatial(flip_axes) + else: + result = self + + return result.permute_spatial_axes(permute_indices) + + def swap_spatial_axes(self, axis_1: int, axis_2: int) -> Self: + """Swap the spatial axes of the array. + + Parameters + ---------- + axis_1: int + Spatial axis index (0, 1 or 2) to swap with ``axis_2``. + axis_2: int + Spatial axis index (0, 1 or 2) to swap with ``axis_1``. + + Returns + ------- + highdicom.volume.Volume: + New volume with spatial axes swapped as requested. + + """ + for a in [axis_1, axis_2]: + if a not in {0, 1, 2}: + raise ValueError( + 'Axis values must be one of 0, 1 or 2.' + ) + + if axis_1 == axis_2: + raise ValueError( + "Arguments 'axis_1' and 'axis_2' must be different." + ) + + permutation = [0, 1, 2] + permutation[axis_1] = axis_2 + permutation[axis_2] = axis_1 + + return self.permute_spatial_axes(permutation) + + def flip_spatial(self, axes: Union[int, Sequence[int]]) -> Self: + """Flip the spatial axes of the array. + + Note that this flips the array and updates the affine to reflect the + flip. + + Parameters + ---------- + axes: Union[int, Sequence[int]] + Axis or list of axis indices that should be flipped. These should + include only the spatial axes (0, 1, and/or 2). + + Returns + ------- + highdicom.volume.Volume: + New volume with spatial axes flipped as requested. + + """ + if isinstance(axes, int): + axes = [axes] + + if len(axes) > 3 or len(set(axes) - {0, 1, 2}) > 0: + raise ValueError( + 'Argument "axis" must contain only values 0, 1, and/or 2.' + ) + + # We will re-use the existing __getitem__ implementation, which has all + # this logic figured out already + index = [] + for d in range(3): + if d in axes: + index.append(slice(-1, None, -1)) + else: + index.append(slice(None)) + + return self[tuple(index)] + + def random_flip_spatial(self, axes: Sequence[int] = (0, 1, 2)) -> Self: + """Randomly flip the spatial axes of the array. + + Note that this flips the array and updates the affine to reflect the + flip. + + Parameters + ---------- + axes: Union[int, Sequence[int]] + Axis or list of axis indices that may be flipped. These should + include only the spatial axes (0, 1, and/or 2). Each axis in this + list is flipped in the output volume with probability 0.5. + + Returns + ------- + highdicom.volume.Volume: + New volume with selected spatial axes randomly flipped. + + """ + if len(axes) < 2 or len(axes) > 3: + raise ValueError( + "Argument 'axes' must contain 2 or 3 items." + ) + + if len(set(axes)) != len(axes): + raise ValueError( + "Argument 'axes' should contain unique values." + ) + + if set(axes) <= {0, 1, 2}: + raise ValueError( + "Argument 'axes' should contain only 0, 1, and 2." + ) + + slices = [] + for d in range(3): + if d in axes: + if np.random.randint(2) == 1: + slices.append(slice(None, None, -1)) + else: + slices.append(slice(None)) + else: + slices.append(slice(None)) + + return self[tuple(slices)] + + @property + def handedness(self) -> AxisHandedness: + """highdicom.AxisHandedness: Axis handedness of the volume.""" + v1, v2, v3 = self.spacing_vectors() + if np.cross(v1, v2) @ v3 < 0.0: + return AxisHandedness.LEFT_HANDED + return AxisHandedness.RIGHT_HANDED + + def ensure_handedness( + self, + handedness: Union[AxisHandedness, str], + *, + flip_axis: Optional[int] = None, + swap_axes: Optional[Sequence[int]] = None, + ) -> Self: + """Manipulate the volume if necessary to ensure a given handedness. + + If the volume already has the specified handedness, it is returned + unaltered. + + If the volume does not meet the requirement, the volume is manipulated + using a user specified operation to meet the requirement. The two + options are reversing the direction of a single axis ("flipping") or + swapping the position of two axes. + + Parameters + ---------- + handedness: highdicom.AxisHandedness + Handedness to ensure. + flip_axis: Union[int, None], optional + Specification of a spatial axis index (0, 1, or 2) to flip if + required to meet the given handedness requirement. + swap_axes: Union[int, None], optional + Specification of a sequence of two spatial axis indices (each being + 0, 1, or 2) to swap if required to meet the given handedness + requirement. + + Note + ---- + Either ``flip_axis`` or ``swap_axes`` must be provided (and not both) + to specify the operation to perform to correct the handedness (if + required). + + """ + if (flip_axis is None) == (swap_axes is None): + raise TypeError( + "Exactly one of either 'flip_axis' or 'swap_axes' " + "must be specified." + ) + handedness = AxisHandedness(handedness) + if handedness == self.handedness: + return self + + if flip_axis is not None: + return self.flip_spatial(flip_axis) + + if len(swap_axes) != 2: + raise ValueError( + "Argument 'swap_axes' must have length 2." + ) + + return self.swap_spatial_axes(swap_axes[0], swap_axes[1]) + + def pad_to_spatial_shape( + self, + spatial_shape: Sequence[int], + *, + mode: PadModes = PadModes.CONSTANT, + constant_value: float = 0.0, + per_channel: bool = False, + ) -> Self: + """Pad volume to given spatial shape. + + The volume is padded symmetrically, placing the original array at the + center of the output array, to achieve the given shape. If this + requires an odd number of elements to be added along a certain + dimension, one more element is placed at the end of the array than at + the start. + + Parameters + ---------- + spatial_shape: Sequence[int] + Sequence of three integers specifying the spatial shape to pad to. + This shape must be no smaller than the existing shape along any of + the three spatial dimensions. + mode: highdicom.PadModes, optional + Mode to use to pad the array. See :class:`highdicom.PadModes` for + options. + constant_value: Union[float, Sequence[float]], optional + Value used to pad when mode is ``"CONSTANT"``. If ``per_channel`` + if True, a sequence whose length is equal to the number of channels + may be passed, and each value will be used for the corresponding + channel. With other pad modes, this argument is ignored. + per_channel: bool, optional + For padding modes that involve calculation of image statistics to + determine the padding value (i.e. ``MINIMUM``, ``MAXIMUM``, + ``MEAN``, ``MEDIAN``), pad each channel separately using the value + calculated using that channel alone (rather than the statistics of + the entire array). For other padding modes, this argument makes no + difference. This should not the True if the image does not have a + channel dimension. + + Returns + ------- + highdicom.volume.Volume: + Volume with padding applied. + + """ + if len(spatial_shape) != 3: + raise ValueError( + "Argument 'shape' must have length 3." + ) + + pad_width = [] + for insize, outsize in zip(self.spatial_shape, spatial_shape): + to_pad = outsize - insize + if to_pad < 0: + raise ValueError( + 'Shape is smaller than existing shape along at least ' + 'one axis.' + ) + pad_front = to_pad // 2 + pad_back = to_pad - pad_front + pad_width.append((pad_front, pad_back)) + + return self.pad( + pad_width=pad_width, + mode=mode, + constant_value=constant_value, + per_channel=per_channel, + ) + + def crop_to_spatial_shape(self, spatial_shape: Sequence[int]) -> Self: + """Center-crop volume to a given spatial shape. + + Parameters + ---------- + spatial_shape: Sequence[int] + Sequence of three integers specifying the spatial shape to crop to. + This shape must be no larger than the existing shape along any of + the three spatial dimensions. + + Returns + ------- + highdicom.volume.Volume: + Volume with padding applied. + + """ + if len(spatial_shape) != 3: + raise ValueError( + "Argument 'shape' must have length 3." + ) + + crop_vals = [] + for insize, outsize in zip(self.spatial_shape, spatial_shape): + to_crop = insize - outsize + if to_crop < 0: + raise ValueError( + 'Shape is larger than existing shape along at least ' + 'one axis.' + ) + crop_front = to_crop // 2 + crop_back = to_crop - crop_front + crop_vals.append((crop_front, insize - crop_back)) + + return self[ + crop_vals[0][0]:crop_vals[0][1], + crop_vals[1][0]:crop_vals[1][1], + crop_vals[2][0]:crop_vals[2][1], + ] + + def pad_or_crop_to_spatial_shape( + self, + spatial_shape: Sequence[int], + *, + mode: PadModes = PadModes.CONSTANT, + constant_value: float = 0.0, + per_channel: bool = False, + ) -> Self: + """Pad and/or crop volume to given spatial shape. + + For each dimension where padding is required, the volume is padded + symmetrically, placing the original array at the center of the output + array, to achieve the given shape. If this requires an odd number of + elements to be added along a certain dimension, one more element is + placed at the end of the array than at the start. + + For each dimension where cropping is required, center cropping is used. + + Parameters + ---------- + spatial_shape: Sequence[int] + Sequence of three integers specifying the spatial shape to pad or + crop to. + mode: highdicom.PadModes, optional + Mode to use to pad the array, if padding is required. See + :class:`highdicom.PadModes` for options. + constant_value: Union[float, Sequence[float]], optional + Value used to pad when mode is ``"CONSTANT"``. If ``per_channel`` + if True, a sequence whose length is equal to the number of channels + may be passed, and each value will be used for the corresponding + channel. With other pad modes, this argument is ignored. + per_channel: bool, optional + For padding modes that involve calculation of image statistics to + determine the padding value (i.e. ``MINIMUM``, ``MAXIMUM``, + ``MEAN``, ``MEDIAN``), pad each channel separately using the value + calculated using that channel alone (rather than the statistics of + the entire array). For other padding modes, this argument makes no + difference. This should not the True if the image does not have a + channel dimension. + + Returns + ------- + highdicom.volume.Volume: + Volume with padding and/or cropping applied. + + """ + if len(spatial_shape) != 3: + raise ValueError( + "Argument 'shape' must have length 3." + ) + + pad_width = [] + crop_vals = [] + for insize, outsize in zip(self.spatial_shape, spatial_shape): + diff = outsize - insize + if diff > 0: + pad_front = diff // 2 + pad_back = diff - pad_front + pad_width.append((pad_front, pad_back)) + crop_vals.append((0, insize)) + elif diff < 0: + crop_front = (-diff) // 2 + crop_back = (-diff) - crop_front + crop_vals.append((crop_front, insize - crop_back)) + pad_width.append((0, 0)) + else: + pad_width.append((0, 0)) + crop_vals.append((0, outsize)) + + cropped = self[ + crop_vals[0][0]:crop_vals[0][1], + crop_vals[1][0]:crop_vals[1][1], + crop_vals[2][0]:crop_vals[2][1], + ] + padded = cropped.pad( + pad_width=pad_width, + mode=mode, + constant_value=constant_value, + per_channel=per_channel, + ) + return padded + + def random_spatial_crop(self, spatial_shape: Sequence[int]) -> Self: + """Create a random crop of a certain shape from the volume. + + Parameters + ---------- + spatial_shape: Sequence[int] + Sequence of three integers specifying the spatial shape to pad or + crop to. + + Returns + ------- + highdicom.volume.Volume: + New volume formed by cropping the volumes. + + """ + crop_slices = [] + for c, d in zip(spatial_shape, self.spatial_shape): + max_start = d - c + if max_start < 0: + raise ValueError( + 'Crop shape is larger than volume in at least one ' + 'dimension.' + ) + start = np.random.randint(0, max_start + 1) + crop_slices.append(slice(start, start + c)) + + return self[tuple(crop_slices)] + + def geometry_equal( + self, + other: Union['Volume', 'VolumeGeometry'], + tol: Optional[float] = _DEFAULT_EQUALITY_TOLERANCE, + ) -> bool: + """Determine whether two volumes have the same geometry. + + Parameters + ---------- + other: Union[highdicom.volume.Volume, highdicom.volume.VolumeGeometry] + Volume or volume geometry to which this volume should be compared. + tol: Union[float, None], optional + Absolute Tolerance used to determine equality of affine matrices. + If None, affine matrices must match exactly. + + Return + ------ + bool: + True if the geometries match (up to the specified tolerance). False + otherwise. + + """ + if ( + self.frame_of_reference_uid is not None and + other.frame_of_reference_uid is not None + ): + if self.frame_of_reference_uid != self.frame_of_reference_uid: + return False + + if self.spatial_shape != other.spatial_shape: + return False + + if tol is None: + return np.array_equal(self._affine, other._affine) + else: + return np.allclose( + self._affine, + other._affine, + atol=tol, + ) + + def match_geometry( + self, + other: Union['Volume', 'VolumeGeometry'], + *, + mode: PadModes = PadModes.CONSTANT, + constant_value: float = 0.0, + per_channel: bool = False, + tol: float = _DEFAULT_EQUALITY_TOLERANCE, + ) -> Self: + """Match the geometry of this volume to another. + + This performs a combination of permuting, padding and cropping, and + flipping (in that order) such that the geometry of this volume matches + that of ``other``. Notably, the voxels are not resampled. If the + geometry cannot be matched using these operations, then a + ``RuntimeError`` is raised. + + Parameters + ---------- + other: Union[highdicom.volume.Volume, highdicom.volume.VolumeGeometry] + Volume or volume geometry to which this volume should be matched. + + Returns + ------- + highdicom.volume._VolumeBase: + New volume formed by matching the geometry of this volume to that + of ``other``. + + Raises + ------ + RuntimeError: + If the geometries cannot be matched without resampling the array. + + """ + if ( + self.frame_of_reference_uid is not None and + other.frame_of_reference_uid is not None + ): + if self.frame_of_reference_uid != self.frame_of_reference_uid: + raise RuntimeError( + "Volumes do not have matching frame of reference UIDs." + ) + + permute_indices = [] + step_sizes = [] + for u, s in zip(self.unit_vectors(), self.spacing): + for j, (v, t) in enumerate( + zip(other.unit_vectors(), other.spacing) + ): + dot_product = u @ v + if ( + np.abs(dot_product - 1.0) < tol or + np.abs(dot_product + 1.0) < tol + ): + permute_indices.append(j) + + scale_factor = t / s + step = int(np.round(scale_factor)) + if abs(scale_factor - step) > tol: + raise RuntimeError( + "Non-integer scale factor required." + ) + + if dot_product < 0.0: + step = -step + + step_sizes.append(step) + + break + else: + raise RuntimeError( + "Direction vectors could not be aligned." + ) + + requires_permute = permute_indices != [0, 1, 2] + if requires_permute: + new_volume = self.permute_spatial_axes(permute_indices) + step_sizes = [step_sizes[i] for i in permute_indices] + else: + new_volume = self + + # Now figure out cropping + origin_offset = ( + np.array(other.position) - + np.array(new_volume.position) + ) + + crop_slices = [] + pad_values = [] + requires_crop = False + requires_pad = False + + for v, spacing, step, out_shape, in_shape in zip( + new_volume.unit_vectors(), + new_volume.spacing, + step_sizes, + other.spatial_shape, + new_volume.spatial_shape, + ): + offset = v @ origin_offset + start_ind = offset / spacing + start_pos = int(np.round(start_ind)) + end_pos = start_pos + out_shape * step + + if abs(start_pos - start_ind) > tol: + raise RuntimeError( + "Required translation is non-integer " + "multiple of voxel spacing." + ) + + if step > 0: + pad_before = max(-start_pos, 0) + pad_after = max(end_pos - in_shape, 0) + crop_start = start_pos + pad_before + crop_stop = end_pos + pad_before + + if crop_start > 0 or crop_stop < out_shape: + requires_crop = True + else: + pad_after = max(start_pos - in_shape + 1, 0) + pad_before = max(-end_pos - 1, 0) + crop_start = start_pos + pad_before + crop_stop = end_pos + pad_before + + # Need the crop operation to flip + requires_crop = True + + if crop_stop == -1: + crop_stop = None + + if pad_before > 0 or pad_after > 0: + requires_pad = True + + crop_slices.append( + slice(crop_start, crop_stop, step) + ) + pad_values.append((pad_before, pad_after)) + + if not ( + requires_permute or requires_pad or requires_crop + ): + new_volume = new_volume.copy() + + if requires_pad: + new_volume = new_volume.pad( + pad_values, + mode=mode, + constant_value=constant_value, + per_channel=per_channel, + ) + + if requires_crop: + new_volume = new_volume[tuple(crop_slices)] + + return new_volume + + +class VolumeGeometry(_VolumeBase): + + """Class encapsulating the geometry of a volume. + + Unlike the similar :class:`highdicom.volume.Volume`, items of this class do + not contain voxel data for the underlying volume, just a description of the + geometry. + + """ + + def __init__( + self, + affine: np.ndarray, + spatial_shape: Sequence[int], + frame_of_reference_uid: Optional[str] = None, + ): + """ + + Parameters + ---------- + affine: numpy.ndarray + 4 x 4 affine matrix representing the transformation from pixel + indices (slice index, row index, column index) to the + frame-of-reference coordinate system. The top left 3 x 3 matrix + should be a scaled orthogonal matrix representing the rotation and + scaling. The top right 3 x 1 vector represents the translation + component. The last row should have value [0, 0, 0, 1]. + spatial_shape: Sequence[int] + Number of voxels in the volume along the three spatial dimensions. + frame_of_reference_uid: Optional[str], optional + Frame of reference UID for the frame of reference, if known. + + """ + super().__init__(affine, frame_of_reference_uid) + + if len(spatial_shape) != 3: + raise ValueError("Argument 'spatial_shape' must have length 3.") + self._spatial_shape = tuple(spatial_shape) + + @classmethod + def from_attributes( + cls, + image_position: Sequence[float], + image_orientation: Sequence[float], + rows: int, + columns: int, + pixel_spacing: Sequence[float], + spacing_between_slices: float, + number_of_frames: int, + frame_of_reference_uid: Optional[str] = None, + ) -> Self: + """Create a volume from DICOM attributes. + + Parameters + ---------- + image_position: Sequence[float] + Position in the frame of reference space of the center of the top + left pixel of the image. Corresponds to DICOM attributes + "ImagePositionPatient". Should be a sequence of length 3. + image_orientation: Sequence[float] + Cosines of the row direction (first triplet: horizontal, left to + right, increasing column index) and the column direction (second + triplet: vertical, top to bottom, increasing row index) direction + expressed in the three-dimensional patient or slide coordinate + system defined by the frame of reference. Corresponds to the DICOM + attribute "ImageOrientationPatient". + rows: int + Number of rows in each frame. + columns: int + Number of columns in each frame. + pixel_spacing: Sequence[float] + Spacing between pixels in millimeter unit along the column + direction (first value: spacing between rows, vertical, top to + bottom, increasing row index) and the row direction (second value: + spacing between columns: horizontal, left to right, increasing + column index). Corresponds to DICOM attribute "PixelSpacing". + spacing_between_slices: float + Spacing between slices in millimeter units in the frame of + reference coordinate system space. Corresponds to the DICOM + attribute "SpacingBetweenSlices" (however, this may not be present + in many images and may need to be inferred from + "ImagePositionPatient" attributes of consecutive slices). + number_of_frames: int + Number of frames in the volume. + frame_of_reference_uid: Union[str, None], optional + Frame of reference UID, if known. Corresponds to DICOM attribute + FrameOfReferenceUID. + + Returns + ------- + highdicom.volume.Volume: + New Volume using the given array and DICOM attributes. + + """ + affine = _create_affine_transformation_matrix( + image_position=image_position, + image_orientation=image_orientation, + pixel_spacing=pixel_spacing, + spacing_between_slices=spacing_between_slices, + index_convention=VOLUME_INDEX_CONVENTION, + slices_first=True, + ) + spatial_shape = (number_of_frames, rows, columns) + + return cls( + affine=affine, + spatial_shape=spatial_shape, + frame_of_reference_uid=frame_of_reference_uid, + ) + + def copy(self) -> Self: + """Get an unaltered copy of the geometry. + + Returns + ------- + highdicom.volume.VolumeGeometry: + Copy of the original geometry. + + """ + return self.__class__( + affine=self._affine.copy(), + spatial_shape=self.spatial_shape, + frame_of_reference_uid=self.frame_of_reference_uid, + ) + + @property + def spatial_shape(self) -> Tuple[int, int, int]: + """Tuple[int, int, int]: Spatial shape of the array. + + Does not include the channel dimension. + + """ + return self._spatial_shape + + @property + def shape(self) -> Tuple[int, ...]: + """Tuple[int, ...]: Shape of the underlying array. + + For objects of type :class:`highdicom.volume.VolumeGeometry`, this is + equivalent to `.shape`. + + """ + return self.spatial_shape + + def __getitem__( + self, + index: Union[int, slice, Tuple[Union[int, slice]]], + ) -> Self: + """Get a sub-volume of this volume as a new volume. + + Parameters + ---------- + index: Union[int, slice, Tuple[Union[int, slice]]] + Index values. Most possibilities supported by numpy arrays are + supported, including negative indices and different step sizes. + Indexing with lists is not supported. + + Returns + ------- + highdicom.volume.VolumeGeometry: + New volume representing a sub-volume of the original volume. + + """ + _, new_shape, new_affine = self._prepare_getitem_index(index) + + return self.__class__( + affine=new_affine, + spatial_shape=new_shape, + frame_of_reference_uid=self.frame_of_reference_uid, + ) + + def pad( + self, + pad_width: Union[int, Sequence[int], Sequence[Sequence[int]]], + *, + mode: Union[PadModes, str] = PadModes.CONSTANT, + constant_value: float = 0.0, + per_channel: bool = False, + ) -> Self: + """Pad volume along the three spatial dimensions. + + Parameters + ---------- + pad_width: Union[int, Sequence[int], Sequence[Sequence[int]]] + Values to pad the array. Takes the same form as ``numpy.pad()``. + May be: + + * A single integer value, which results in that many voxels being + added to the beginning and end of all three spatial dimensions, + or + * A sequence of two values in the form ``[before, after]``, which + results in 'before' voxels being added to the beginning of each + of the three spatial dimensions, and 'after' voxels being added + to the end of each of the three spatial dimensions, or + * A nested sequence of integers of the form ``[[pad1], [pad2], + [pad3]]``, in which separate padding values are supplied for each + of the three spatial axes and used to pad before and after along + those axes, or + * A nested sequence of integers in the form ``[[before1, after1], + [before2, after2], [before3, after3]]``, in which separate values + are supplied for the before and after padding of each of the + three spatial dimensions. + + In all cases, all integer values must be non-negative. + mode: Union[highdicom.PadModes, str], optional + Ignored for :class:`highdicom.volume.VolumeGeometry`. + constant_value: Union[float, Sequence[float]], optional + Ignored for :class:`highdicom.volume.VolumeGeometry`. + per_channel: bool, optional + Ignored for :class:`highdicom.volume.VolumeGeometry`. + + Returns + ------- + highdicom.volume.VolumeGeometry: + Volume with padding applied. + + """ + new_affine, full_pad_width = self._prepare_pad_width(pad_width) + + new_shape = [ + d + p[0] + p[1] for d, p in zip(self.spatial_shape, full_pad_width) + ] + + return self.__class__( + spatial_shape=new_shape, + affine=new_affine, + frame_of_reference_uid=self.frame_of_reference_uid, + ) + + def permute_spatial_axes(self, indices: Sequence[int]) -> Self: + """Create a new geometry by permuting the spatial axes. + + Parameters + ---------- + indices: Sequence[int] + List of three integers containing the values 0, 1 and 2 in some + order. Note that you may not change the position of the channel + axis (if present). + + Returns + ------- + highdicom.volume.VolumeGeometry: + New geometry with spatial axes permuted in the provided order. + + """ + new_affine = self._permute_affine(indices) + + new_shape = [self.spatial_shape[i] for i in indices] + + return self.__class__( + spatial_shape=new_shape, + affine=new_affine, + frame_of_reference_uid=self.frame_of_reference_uid, + ) + + def with_array(self, array: np.ndarray) -> Self: + """Create a volume using this geometry and an array. + + Parameters + ---------- + array: numpy.ndarray + Array of voxel data. Must be either 3D (three spatial dimensions), + or 4D (three spatial dimensions followed by a channel dimension). + Any datatype is permitted. + + Returns + ------- + highdicom.volume.Volume: + Volume objects using this geometry and the given array. + + """ + return Volume( + array=array, + affine=self.affine, + frame_of_reference_uid=self.frame_of_reference_uid, + ) + + +class Volume(_VolumeBase): + + """Class representing a 3D array of regularly-spaced frames in 3D space. + + This class combines a 3D NumPy array with an affine matrix describing the + location of the voxels in the frame of reference coordinate space. A + Volume is not a DICOM object itself, but represents a volume that may + be extracted from DICOM image, and/or encoded within a DICOM object, + potentially following any number of processing steps. + + All such volumes have a geometry that exists within DICOM's patient + coordinate system. + + Internally this class uses the following conventions to represent the + geometry, however this can be constructed from or transformed to other + conventions with appropriate optional parameters to its methods: + + Note + ---- + The ordering of pixel indices used by this class (slice, row, column) + matches the way pydicom and highdicom represent pixel arrays but differs + from the (column, row, slice) convention used by the various "transformer" + classes in the ``highdicom.spatial`` module. + + """ + + def __init__( + self, + array: np.ndarray, + affine: np.ndarray, + frame_of_reference_uid: Optional[str] = None, + channels: dict[BaseTag | int | str | ChannelIdentifier, Sequence[int | str | float | Enum]] | None = None, + ): + """ + + Parameters + ---------- + array: numpy.ndarray + Array of voxel data. Must be at least 3D. The first three + dimensions are the three spatial dimensions, and any subsequent + dimensions are channel dimensions. Any datatype is permitted. + affine: numpy.ndarray + 4 x 4 affine matrix representing the transformation from pixel + indices (slice index, row index, column index) to the + frame-of-reference coordinate system. The top left 3 x 3 matrix + should be a scaled orthogonal matrix representing the rotation and + scaling. The top right 3 x 1 vector represents the translation + component. The last row should have value [0, 0, 0, 1]. + frame_of_reference_uid: Optional[str], optional + Frame of reference UID for the frame of reference, if known. + channels: dict[int | str | ChannelIdentifier, Sequence[int | str | float | Enum]] | None, optional + Specification of channels of the array. Channels are additional + dimensions of the array beyond the three spatial dimensions. For + each such additional dimension (if any), an item in this dictionary + is required to specify the meaning. The dictionary key specifies + the meaning of the dimension, which must be either an instance of + highdicom.ChannelIdentifier, specifying a DICOM tag whose attribute + describes the channel, a a DICOM keywork describing a DICOM + attribute, or an integer representing the tag of a DICOM attribute. + The corresponding item of the dictionary is a sequence giving the + value of the relevant attribute at each index in the array. The + insertion order of the dictionary is significant as it is used to + match items to the corresponding dimensions of the array (the first + item in the dictionary corresponds to axis 3 of the array and so + on). + + """ + super().__init__( + affine=affine, + frame_of_reference_uid=frame_of_reference_uid, + ) + if array.ndim < 3: + raise ValueError( + "Argument 'array' must be at least three dimensional." + ) + + if channels is None: + channels = {} + + if len(channels) != array.ndim - 3: + raise ValueError( + f"Number of items in the 'channels' parameter ({len(channels)}) " + 'does not match the number of channels dimensions in the array ' + f'({array.ndim - 3}).' + ) + + self._channels: dict[ + ChannelIdentifier, list[str | int | float | Enum] + ] = {} + + # NB insertion order of the dictionary is significant + for a, (iden, values) in enumerate(channels.items()): + + channel_number = a + 3 + + if not isinstance(iden, ChannelIdentifier): + iden_obj = ChannelIdentifier(iden) + else: + iden_obj = iden + + if iden_obj.is_enumerated: + values = [iden_obj.value_type(v) for v in values] + + expected_length = array.shape[channel_number] + if len(values) != expected_length: + raise ValueError( + f'Number of values for channel number {channel_number} ' + f'({len(values)}) does not match the size of the corresponding ' + f'dimension of the array ({expected_length}).' + ) + + if not all(isinstance(v, iden_obj.value_type) for v in values): + raise TypeError( + f'Values for channel {iden_obj} ' + f'do not have the expected type ({iden_obj.value_type}).' + ) + + if iden_obj in self._channels: + raise ValueError( + 'Channel identifiers must represent unique attributes.' + ) + self._channels[iden_obj] = list(values) + + self._array = array + + @classmethod + def from_attributes( + cls, + array: np.ndarray, + image_position: Sequence[float], + image_orientation: Sequence[float], + pixel_spacing: Sequence[float], + spacing_between_slices: float, + frame_of_reference_uid: Optional[str] = None, + channels: dict[BaseTag | int | str | ChannelIdentifier, Sequence[int | str | float | Enum]] | None = None, + ) -> Self: + """Create a volume from DICOM attributes. + + Parameters + ---------- + array: numpy.ndarray + Three dimensional array of voxel data. The first dimension indexes + slices, the second dimension indexes rows, and the final dimension + indexes columns. + image_position: Sequence[float] + Position in the frame of reference space of the center of the top + left pixel of the image. Corresponds to DICOM attributes + "ImagePositionPatient". Should be a sequence of length 3. + image_orientation: Sequence[float] + Cosines of the row direction (first triplet: horizontal, left to + right, increasing column index) and the column direction (second + triplet: vertical, top to bottom, increasing row index) direction + expressed in the three-dimensional patient or slide coordinate + system defined by the frame of reference. Corresponds to the DICOM + attribute "ImageOrientationPatient". + pixel_spacing: Sequence[float] + Spacing between pixels in millimeter unit along the column + direction (first value: spacing between rows, vertical, top to + bottom, increasing row index) and the row direction (second value: + spacing between columns: horizontal, left to right, increasing + column index). Corresponds to DICOM attribute "PixelSpacing". + spacing_between_slices: float + Spacing between slices in millimeter units in the frame of + reference coordinate system space. Corresponds to the DICOM + attribute "SpacingBetweenSlices" (however, this may not be present + in many images and may need to be inferred from + "ImagePositionPatient" attributes of consecutive slices). + frame_of_reference_uid: Union[str, None], optional + Frame of reference UID, if known. Corresponds to DICOM attribute + FrameOfReferenceUID. + channels: dict[int | str | ChannelIdentifier, Sequence[int | str | float | Enum]] | None, optional + Specification of channels of the array. Channels are additional + dimensions of the array beyond the three spatial dimensions. For + each such additional dimension (if any), an item in this dictionary + is required to specify the meaning. The dictionary key specifies + the meaning of the dimension, which must be either an instance of + highdicom.ChannelIdentifier, specifying a DICOM tag whose attribute + describes the channel, a a DICOM keywork describing a DICOM + attribute, or an integer representing the tag of a DICOM attribute. + The corresponding item of the dictionary is a sequence giving the + value of the relevant attribute at each index in the array. The + insertion order of the dictionary is significant as it is used to + match items to the corresponding dimensions of the array (the first + item in the dictionary corresponds to axis 3 of the array and so + on). + + Returns + ------- + highdicom.volume.Volume: + New Volume using the given array and DICOM attributes. + + """ + affine = _create_affine_transformation_matrix( + image_position=image_position, + image_orientation=image_orientation, + pixel_spacing=pixel_spacing, + spacing_between_slices=spacing_between_slices, + index_convention=VOLUME_INDEX_CONVENTION, + slices_first=True, + ) + return cls( + affine=affine, + array=array, + frame_of_reference_uid=frame_of_reference_uid, + channels=channels, + ) + + @classmethod + def from_components( + cls, + array: np.ndarray, + position: Sequence[float], + direction: Sequence[float], + spacing: Sequence[float], + frame_of_reference_uid: Optional[str] = None, + channels: dict[BaseTag | int | str | ChannelIdentifier, Sequence[int | str | float | Enum]] | None = None, + ) -> Self: + """Construct a Volume from components. + + Parameters + ---------- + array: numpy.ndarray + Three dimensional array of voxel data. + position: Sequence[float] + Sequence of three floats giving the position in the frame of + reference coordinate system of the center of the pixel at location + (0, 0, 0). + direction: Sequence[float] + Direction matrix for the volume. The columns of the direction + matrix are orthogonal unit vectors that give the direction in the + frame of reference space of the increasing direction of each axis + of the array. This matrix may be passed either as a 3x3 matrix or a + flattened 9 element array (first row, second row, third row). + spacing: Sequence[float] + Spacing between pixel centers in the the frame of reference + coordinate system along each of the dimensions of the array. + shape: Sequence[int] + Sequence of three integers giving the shape of the volume. + frame_of_reference_uid: Union[str, None], optional + Frame of reference UID for the frame of reference, if known. + channels: dict[int | str | ChannelIdentifier, Sequence[int | str | float | Enum]] | None, optional + Specification of channels of the array. Channels are additional + dimensions of the array beyond the three spatial dimensions. For + each such additional dimension (if any), an item in this dictionary + is required to specify the meaning. The dictionary key specifies + the meaning of the dimension, which must be either an instance of + highdicom.ChannelIdentifier, specifying a DICOM tag whose attribute + describes the channel, a a DICOM keywork describing a DICOM + attribute, or an integer representing the tag of a DICOM attribute. + The corresponding item of the dictionary is a sequence giving the + value of the relevant attribute at each index in the array. The + insertion order of the dictionary is significant as it is used to + match items to the corresponding dimensions of the array (the first + item in the dictionary corresponds to axis 3 of the array and so + on). + + Returns + ------- + highdicom.spatial.Volume: + Volume constructed from the provided components. + + """ + if not isinstance(position, Sequence): + raise TypeError('Argument "position" must be a sequence.') + if len(position) != 3: + raise ValueError('Argument "position" must have length 3.') + if not isinstance(spacing, Sequence): + raise TypeError('Argument "spacing" must be a sequence.') + if len(spacing) != 3: + raise ValueError('Argument "spacing" must have length 3.') + direction_arr = np.array(direction, dtype=np.float32) + if direction_arr.shape == (9, ): + direction_arr = direction_arr.reshape(3, 3) + elif direction_arr.shape == (3, 3): + pass + else: + raise ValueError( + "Argument 'direction' must have shape (9, ) or (3, 3)." + ) + if not _is_matrix_orthogonal(direction_arr, require_unit=True): + raise ValueError( + "Argument 'direction' must be an orthogonal matrix of " + "unit vectors." + ) + + scaled_direction = direction_arr * spacing + affine = _stack_affine_matrix(scaled_direction, np.array(position)) + return cls( + array=array, + affine=affine, + frame_of_reference_uid=frame_of_reference_uid, + channels=channels, + ) + + def get_geometry(self) -> VolumeGeometry: + """Get geometry for this volume. + + Returns + ------- + hd.VolumeGeometry: + Geometry object matching this volume. + + """ + return VolumeGeometry( + affine=self._affine.copy(), + spatial_shape=self.spatial_shape, + frame_of_reference_uid=self.frame_of_reference_uid + ) + + @property + def dtype(self) -> type: + """type: Datatype of the array.""" + return self._array.dtype.type + + @property + def shape(self) -> Tuple[int, ...]: + """Tuple[int, ...]: Shape of the underlying array. + + Includes any channel dimensions. + + """ + return tuple(self._array.shape) + + @property + def spatial_shape(self) -> Tuple[int, int, int]: + """Tuple[int, int, int]: Spatial shape of the array. + + Does not include the channel dimensions. + + """ + return tuple(self._array.shape[:3]) + + @property + def number_of_channel_dimensions(self) -> int: + """int: Number of channel dimensions.""" + return self._array.ndim - 3 + + @property + def channel_shape(self) -> Tuple[int, ...]: + """Tuple[int, ...]: Channel shape of the array. + + Does not include the spatial dimensions. + + """ + return tuple(self._array.shape[3:]) + + @property + def channel_identifiers(self) -> tuple[ChannelIdentifier, ...]: + """tuple[highdicom.volume.ChannelIdentifier] + Identifier of each channel. + + """ + return tuple(self._channels.keys()) + + @property + def array(self) -> np.ndarray: + """numpy.ndarray: Volume array.""" + return self._array + + @array.setter + def array(self, value: np.ndarray) -> None: + """Change the voxel array without changing the affine. + + Parameters + ---------- + array: np.ndarray + New array of voxel data. The shape (spatial and channel) must match + the existing array. + + """ + if value.shape != self.shape: + raise ValueError( + "Array must match the shape of the existing array." + ) + self._array = value + + def astype(self, dtype: type) -> Self: + """Get new volume with a new datatype. + + Parameters + ---------- + dtype: type + A numpy datatype for the new volume. + + Returns + ------- + highdicom.volume.Volume: + New volume with given datatype, and metadata copied from this + volume. + + """ + new_array = self._array.astype(dtype) + + return self.with_array(new_array) + + def copy(self) -> Self: + """Get an unaltered copy of the volume. + + Returns + ------- + highdicom.volume.Volume: + Copy of the original volume. + + """ + return self.__class__( + array=self.array.copy(), # TODO should this copy? + affine=self._affine.copy(), + frame_of_reference_uid=self.frame_of_reference_uid, + ) + + def with_array( + self, + array: np.ndarray, + channels: dict[BaseTag | int | str | ChannelIdentifier, Sequence[int | str | float | Enum]] | None = None, + ) -> Self: + """Get a new volume using a different array. + + The spatial and other metadata will be copied from this volume. + The original volume will be unaltered. + + By default, the new volume will have the same channels (if any) as the + existing volume. Different channels may be specified by passing the + 'channels' parameter. + + Parameters + ---------- + array: np.ndarray + New 3D or 4D array of voxel data. The spatial shape must match the + existing array, but the presence and number of channels and/or the + voxel datatype may differ. + channels: dict[int | str | ChannelIdentifier, Sequence[int | str | float | Enum]] | None, optional + Specification of channels as used by the constructor. If not + specified, the channels are assumed to match those in the original + volume and therefore the array must have the same shape as the + array of the original volume. + + Returns + ------- + highdicom.volume.Volume: + New volume using the given array and the metadata of this volume. + + """ + if array.shape[:3] != self.spatial_shape: + raise ValueError( + "Array must match the spatial shape of the existing array." + ) + if channels is None: + if array.ndim != 3: + channels = self._channels + if array.shape != self.shape: + raise ValueError( + "Array must match the shape of the existing array." + ) + return self.__class__( + array=array, + affine=self._affine.copy(), + frame_of_reference_uid=self.frame_of_reference_uid, + channels=channels, + ) + + def __getitem__( + self, + index: Union[int, slice, Tuple[Union[int, slice]]], + ) -> Self: + """Get a spatial sub-volume of this volume as a new volume. + + Parameters + ---------- + index: Union[int, slice, Tuple[Union[int, slice]]] + Index values. Most possibilities supported by numpy arrays are + supported, including negative indices and different step sizes. + Indexing with lists is not supported. + + Returns + ------- + highdicom.volume.Volume: + New volume representing a sub-volume of the original volume. + + """ + tuple_index, _, new_affine = self._prepare_getitem_index(index) + + new_array = self._array[tuple_index] + + return self.__class__( + array=new_array, + affine=new_affine, + frame_of_reference_uid=self.frame_of_reference_uid, + channels=self._channels, + ) + + def permute_spatial_axes(self, indices: Sequence[int]) -> Self: + """Create a new volume by permuting the spatial axes. + + Parameters + ---------- + indices: Sequence[int] + List of three integers containing the values 0, 1 and 2 in some + order. Note that you may not change the position of the channel + axis (if present). + + Returns + ------- + highdicom.volume.Volume: + New volume with spatial axes permuted in the provided order. + + """ + new_affine = self._permute_affine(indices) + + if self._array.ndim == 3: + new_array = np.transpose(self._array, indices) + else: + new_array = np.transpose(self._array, [*indices, 3]) + + return self.__class__( + array=new_array, + affine=new_affine, + frame_of_reference_uid=self.frame_of_reference_uid, + channels=self._channels, + ) + + def permute_channel_axes_by_index(self, indices: Sequence[int]) -> Self: + """Create a new volume by permuting the channel axes. + + Parameters + ---------- + indices: Sequence[int] + List of integers containing values in the range 0 (inclusive) to + the number of channel dimensions (exclusive) in some order, used + to permute the channels. A value of ``i`` corresponds to the channel + given by ``volume.channel_identifiers[i]``. + + Returns + ------- + highdicom.volume.Volume: + New volume with channel axes permuted in the provided order. + + """ + if len(set(indices)) != len(indices): + raise ValueError( + "Set of channel indices must not contain " + "duplicates." + ) + expected_indices = set(range(self.number_of_channel_dimensions)) + if set(indices) != expected_indices: + raise ValueError( + "Set of channel indices must match exactly those " + "present in the volume." + ) + full_indices = [0, 1, 2] + [ind + 3 for ind in indices] + + new_array = np.transpose(self._array, full_indices) + + new_channel_identifiers = [ + self.channel_identifiers[ind] for ind in indices + ] + new_channels = { + iden: self._channels[iden] for iden in new_channel_identifiers + } + + return self.with_array( + array=new_array, + channels=new_channels, + ) + + def permute_channel_axes( + self, + channel_identifiers: Sequence[BaseTag | int | str | ChannelIdentifier], + ) -> Self: + """Create a new volume by permuting the channel axes. + + Parameters + ---------- + channel_identifiers: Sequence[pydicom.BaseTag | int | str | highdicom.volume.ChannelIdentifier] + List of channel identifiers matching those in the volume but in an arbitrary order. + + Returns + ------- + highdicom.volume.Volume: + New volume with channel axes permuted in the provided order. + + """ + channel_identifier_objs = [ + self._get_channel_identifier(iden) for iden in channel_identifiers + ] + + current_identifiers = self.channel_identifiers + if len(set(channel_identifier_objs)) != len(channel_identifier_objs): + raise ValueError( + "Set of channel identifiers must not contain " + "duplicates." + ) + if set(channel_identifier_objs) != set(current_identifiers): + raise ValueError( + "Set of channel identifiers must match exactly those " + "present in the volume." + ) + + permutation_indices = [ + current_identifiers.index(iden) for iden in channel_identifier_objs + ] + + return self.permute_channel_axes_by_index(permutation_indices) + + def _get_channel_identifier( + self, + identifier: ChannelIdentifier | int | str, + ) -> ChannelIdentifier: + """Standardize representation of a channel identifier. + + Given a value used to specify a channel, check that such a channel + exists in the volume and return a channel identifier as a + highdicom.volume.ChannelIdentifier object. + + Parameters + ---------- + identifier: highdicom.volume.ChannelIdentifier | int | str + Identifier. Strings will be matched against keywords and integers + will be matched against tags. + + Returns + ------- + highdicom.volume.ChannelIdentifier: + Channel identifier in standard form. + + """ + if isinstance(identifier, ChannelIdentifier): + if identifier not in self._channels: + raise ValueError( + f"No channel with identifier '{identifier}' found " + 'in volume.' + ) + + return identifier + elif isinstance(identifier, str): + for c in self.channel_identifiers: + if c.keyword == identifier: + return c + else: + raise ValueError( + f"No channel identifier with keyword '{identifier}' found " + 'in volume.' + ) + elif isinstance(identifier, int): + t = BaseTag(identifier) + for c in self.channel_identifiers: + if c.tag is not None and c.tag == t: + return c + else: + raise ValueError( + f"No channel identifier with tag '{t}' found " + 'in volume.' + ) + else: + raise TypeError( + f'Invalid type for channel identifier: {type(identifier)}' + ) + + def _get_channel_index( + self, + identifier: ChannelIdentifier | int | str, + ) -> int: + """Get zero-based channel index for a given channel. + + Parameters + ---------- + identifier: highdicom.volume.ChannelIdentifier | int | str + Identifier. Strings will be matched against keywords and integers + will be matched against tags. + + Returns + ------- + int: + Zero-based index of the channel within the channel axes. + + """ + identifier_obj = self._get_channel_identifier(identifier) + + index = self.channel_identifiers.index(identifier_obj) + + return index + + def get_channel_values( + self, + channel_identifier: int | str | ChannelIdentifier + ) -> list[str | int | float | Enum]: + """Get channel values along a particular dimension. + + Parameters + ---------- + channel_identifier: highdicom.volume.ChannelIdentifier | int | str + Identifier of a channel within the image. + + Returns + ------- + list[str | int | float | Enum]: + Copy of channel values along the selected dimension. + + """ + iden = self._get_channel_identifier(channel_identifier) + return self._channels[iden][:] + + def get_channel(self, *, keepdims: bool = False, **kwargs) -> Self: + """Get a volume corresponding to a particular channel along one or more + dimensions. + + Parameters + ---------- + keepdims: bool + Whether to keep a singleton dimension in the output volume. + kwargs: dict[str, str | int | float | Enum] + kwargs where the keyword is the keyword of a channel present in the + volume and the value is the channel value along that channel. + + Returns + ------- + highdicom.volume.Volume: + Volume representing a single channel of the original volume. + + """ + indexer: list[slice | int] = [slice(None)] * self._array.ndim + + new_channels = self._channels.copy() + + for kw, v in kwargs.items(): + + iden = self._get_channel_identifier(kw) + cind = self._get_channel_index(iden) + + iden = self.channel_identifiers[cind] + if iden.is_enumerated: + v = iden.value_type(v) + elif not isinstance(v, iden.value_type): + raise TypeError( + f"Value for argument '{iden}' must be of type " + f"'{iden.value_type}'." + ) + + dim_ind = cind + 3 + try: + ind = self._channels[iden].index(v) + except IndexError as e: + raise IndexError( + f"Value {v} is not found in channel {iden}." + ) from e + + if keepdims: + indexer[dim_ind] = slice(ind, ind + 1) + new_channels[iden] = [v] + else: + indexer[dim_ind] = ind + del new_channels[iden] + + new_array = self._array[tuple(indexer)] + + return self.with_array( + array=new_array, + channels=new_channels, + ) + + def normalize_mean_std( + self, + per_channel: bool = True, + output_mean: float = 0.0, + output_std: float = 1.0, + ) -> Self: + """Normalize the intensities using the mean and variance. + + The resulting volume has zero mean and unit variance. + + Parameters + ---------- + per_channel: bool, optional + If True (the default), each channel along each channel dimension is + normalized by its own mean and variance. If False, all channels are + normalized together using the overall mean and variance. + output_mean: float, optional + The mean value of the output array (or channel), after scaling. + output_std: float, optional + The standard deviation of the output array (or channel), + after scaling. + + Returns + ------- + highdicom.volume.Volume: + Volume with normalized intensities. Note that the dtype will + be promoted to floating point. + + """ + if ( + per_channel and + self.number_of_channel_dimensions > 0 + ): + mean = self.array.mean(axis=(0, 1, 2), keepdims=True) + std = self.array.std(axis=(0, 1, 2), keepdims=True) + else: + mean = self.array.mean() + std = self.array.std() + new_array = ( + (self.array - mean) / (std / output_std) + output_mean + ) + + return self.with_array(new_array) + + def normalize_min_max( + self, + output_min: float = 0.0, + output_max: float = 1.0, + per_channel: bool = False, + ) -> Self: + """Normalize by mapping its full intensity range to a fixed range. + + Other pixel values are scaled linearly within this range. + + Parameters + ---------- + output_min: float, optional + The value to which the minimum intensity is mapped. + output_max: float, optional + The value to which the maximum intensity is mapped. + per_channel: bool, optional + If True, each channel along each channel dimension is normalized by + its own min and max. If False (the default), all channels are + normalized together using the overall min and max. + + Returns + ------- + highdicom.volume.Volume: + Volume with normalized intensities. Note that the dtype will + be promoted to floating point. + + """ + output_range = output_max - output_min + if output_range <= 0.0: + raise ValueError('Output min must be below output max.') + + if ( + per_channel and + self.number_of_channel_dimensions > 1 + ): + imin = self.array.min(axis=(0, 1, 2), keepdims=True) + imax = self.array.max(axis=(0, 1, 2), keepdims=True) + else: + imin = self.array.min() + imax = self.array.max() + + scale_factor = output_range / (imax - imin) + new_array = (self.array - imin) * scale_factor + output_min + + return self.with_array(new_array) + + def clip( + self, + a_min: Optional[float], + a_max: Optional[float], + ) -> Self: + """Clip voxel intensities to lie within a given range. + + Parameters + ---------- + a_min: Union[float, None] + Lower value to clip. May be None if no lower clipping is to be + applied. Voxel intensities below this value are set to this value. + a_max: Union[float, None] + Upper value to clip. May be None if no upper clipping is to be + applied. Voxel intensities above this value are set to this value. + + Returns + ------- + highdicom.volume.Volume: + Volume with clipped intensities. + + """ + new_array = np.clip(self.array, a_min, a_max) + + return self.with_array(new_array) + + def apply_window( + self, + *, + window_min: Optional[float] = None, + window_max: Optional[float] = None, + window_center: Optional[float] = None, + window_width: Optional[float] = None, + output_min: float = 0.0, + output_max: float = 1.0, + clip: bool = True, + ) -> Self: + """Apply a window (similar to VOI transform) to the volume. + + Parameters + ---------- + window_min: Union[float, None], optional + Minimum value of window (mapped to ``output_min``). + window_max: Union[float, None], optional + Maximum value of window (mapped to ``output_max``). + window_center: Union[float, None], optional + Center value of the window. + window_width: Union[float, None], optional + Width of the window. + output_min: float, optional + Value to which the lower edge of the window is mapped. + output_max: float, optional + Value to which the upper edge of the window is mapped. + clip: bool, optional + Whether to clip the values to lie within the output range. + + Note + ---- + Either ``window_min`` and ``window_max`` or ``window_center`` and + ``window_width`` should be specified. Other combinations are not valid. + + Returns + ------- + highdicom.volume.Volume: + Volume with windowed intensities. + + """ + if (window_min is None) != (window_max is None): + raise TypeError("Invalid combination of inputs specified.") + if (window_center is None) != (window_width is None): + raise TypeError("Invalid combination of inputs specified.") + if (window_center is None) == (window_min is None): + raise TypeError("Invalid combination of inputs specified.") + + if window_min is None: + window_min = window_center - (window_width / 2) + if window_width is None: + window_width = window_max - window_min + output_range = output_max - output_min + scale_factor = output_range / window_width + + new_array = (self.array - window_min) * scale_factor + output_min + + if clip: + new_array = np.clip(new_array, output_min, output_max) + + return self.with_array(new_array) + + def squeeze_channel( + self, + channel_identifiers: Sequence[ + int | str | BaseTag | ChannelIdentifier + ] | None = None, + ) -> Self: + """Removes any singleton channel axes. + + Parameters + ---------- + channel_identifiers: Sequence[str | int | highdicom.volume.ChannelIdentifier] | None + Identifiers of channels to squeeze. If ``None``, squeeze all + singleton channels. Otherwise squeeze only the specified channels + and raise an error if any cannot be squeezed. + + Returns + ------- + highdicom.volume.Volume: + Volume with channel axis removed. + + """ + if channel_identifiers is None: + channel_identifiers = self.channel_identifiers + raise_error = False + else: + raise_error = True + channel_identifiers = [ + ChannelIdentifier(iden) for iden in channel_identifiers + ] + for iden in channel_identifiers: + if iden not in self._channels: + raise ValueError( + f'No channel with identifier: {iden}' + ) + + to_squeeze = [] + new_channel_idens = [] + for iden in channel_identifiers: + cind = self.channel_identifiers.index(iden) + if self.channel_shape[cind] == 1: + to_squeeze.append(cind + 3) + else: + if raise_error: + raise RuntimeError( + f'Volume has channels along the dimension {iden} and cannot ' + 'be squeezed.' + ) + new_channel_idens.append(iden) + + array = self.array.squeeze(tuple(to_squeeze)) + new_channels = {iden: self._channels[iden] for iden in new_channel_idens} + + return self.with_array(array, channels=new_channels) + + def pad( + self, + pad_width: Union[int, Sequence[int], Sequence[Sequence[int]]], + *, + mode: Union[PadModes, str] = PadModes.CONSTANT, + constant_value: float = 0.0, + per_channel: bool = False, + ) -> Self: + """Pad volume along the three spatial dimensions. + + Parameters + ---------- + pad_width: Union[int, Sequence[int], Sequence[Sequence[int]]] + Values to pad the array. Takes the same form as ``numpy.pad()``. + May be: + + * A single integer value, which results in that many voxels being + added to the beginning and end of all three spatial dimensions, + or + * A sequence of two values in the form ``[before, after]``, which + results in 'before' voxels being added to the beginning of each + of the three spatial dimensions, and 'after' voxels being added + to the end of each of the three spatial dimensions, or + * A nested sequence of integers of the form ``[[pad1], [pad2], + [pad3]]``, in which separate padding values are supplied for each + of the three spatial axes and used to pad before and after along + those axes, or + * A nested sequence of integers in the form ``[[before1, after1], + [before2, after2], [before3, after3]]``, in which separate values + are supplied for the before and after padding of each of the + three spatial dimensions. + + In all cases, all integer values must be non-negative. + mode: Union[highdicom.PadModes, str], optional + Mode to use to pad the array. See :class:`highdicom.PadModes` for + options. + constant_value: Union[float, Sequence[float]], optional + Value used to pad when mode is ``"CONSTANT"``. With other pad + modes, this argument is ignored. + per_channel: bool, optional + For padding modes that involve calculation of image statistics to + determine the padding value (i.e. ``MINIMUM``, ``MAXIMUM``, + ``MEAN``, ``MEDIAN``), pad each channel separately using the value + calculated using that channel alone (rather than the statistics of + the entire array). For other padding modes, this argument makes no + difference. This should not the True if the image does not have a + channel dimension. + + Returns + ------- + highdicom.volume.Volume: + Volume with padding applied. + + """ + if isinstance(mode, str): + mode = mode.upper() + mode = PadModes(mode) + + if mode in ( + PadModes.MINIMUM, + PadModes.MAXIMUM, + PadModes.MEAN, + PadModes.MEDIAN, + ): + used_mode = PadModes.CONSTANT + else: + used_mode = mode + # per_channel result is same as default result, so just ignore it + per_channel = False + + if ( + self.number_of_channel_dimensions == 0 or + self.channel_shape == (1, ) + ): + # Zero or one channels, so can ignore the per_channel logic + per_channel = False + + new_affine, full_pad_width = self._prepare_pad_width(pad_width) + + if not per_channel: + # no padding for channel dims + full_pad_width.extend([[0, 0]] * self.number_of_channel_dimensions) + + def pad_array(array: np.ndarray, cval: float) -> float: + if used_mode == PadModes.CONSTANT: + if mode == PadModes.MINIMUM: + v = array.min() + elif mode == PadModes.MAXIMUM: + v = array.max() + elif mode == PadModes.MEAN: + v = array.mean() + elif mode == PadModes.MEDIAN: + v = np.median(array) + elif mode == PadModes.CONSTANT: + v = cval + pad_kwargs = {'constant_values': v} + else: + pad_kwargs = {} + + return np.pad( + array, + pad_width=full_pad_width, + mode=used_mode.value.lower(), + **pad_kwargs, + ) + + if per_channel: + out_spatial_shape = [ + s + p1 + p2 + for s, (p1, p2) in zip(self.spatial_shape, full_pad_width) + ] + # preallocate output array + new_array = np.zeros([*out_spatial_shape, *self.channel_shape]) + for cind in itertools.product(*[range(n) for n in self.channel_shape]): + indexer = (slice(None), slice(None), slice(None), *cind) + new_array[indexer] = pad_array(self.array[indexer], constant_value) + else: + new_array = pad_array(self.array, constant_value) + + return self.__class__( + array=new_array, + affine=new_affine, + frame_of_reference_uid=self.frame_of_reference_uid, + channels=self._channels, + ) + + +class VolumeToVolumeTransformer: + + """ + + Class for transforming voxel indices between two volumes. + + """ + + def __init__( + self, + volume_from: Union[Volume, VolumeGeometry], + volume_to: Union[Volume, VolumeGeometry], + round_output: bool = False, + check_bounds: bool = False, + ): + """Construct transformation object. + + The resulting object will map volume indices of the "from" volume to + volume indices of the "to" volume. + + Parameters + ---------- + volume_from: Union[highdicom.volume.Volume, highdicom.volume.VolumeGeometry] + Volume to which input volume indices refer. + volume_to: Union[highdicom.volume.Volume, highdicom.volume.VolumeGeometry] + Volume to which output volume indices refer. + round_output: bool, optional + Whether to round the output to the nearest integer (if ``True``) or + return with sub-voxel accuracy as floats (if ``False``). + check_bounds: bool, optional + Whether to perform a bounds check before returning the output + indices. Note there is no bounds check on the input indices. + + """ # noqa: E501 + self._affine = volume_to.inverse_affine @ volume_from.affine + self._output_shape = volume_to.spatial_shape + self._round_output = round_output + self._check_bounds = check_bounds + + @property + def affine(self) -> np.ndarray: + """numpy.ndarray: 4x4 affine transformation matrix""" + return self._affine.copy() + + def __call__(self, indices: np.ndarray) -> np.ndarray: + """Transform volume indices between two volumes. + + Parameters + ---------- + indices: numpy.ndarray + Array of voxel indices in the "from" volume. Array of integer or + floating-point values with shape ``(n, 3)``, where *n* is the + number of coordinates. The order of the three indices corresponds + to the three spatial dimensions volume in that order. Point ``(0, + 0, 0)`` refers to the center of the voxel at index ``(0, 0, 0)`` in + the array. + + Returns + ------- + numpy.ndarray + Array of indices in the output volume that spatially correspond to + those in the indices in the input array. This will have dtype an + integer datatype if ``round_output`` is ``True`` and a floating + point datatype otherwise. The output datatype will be matched to + the input datatype if possible, otherwise either ``np.int64`` or + ``np.float64`` is used. + + Raises + ------ + ValueError + If ``check_bounds`` is ``True`` and the output indices would + otherwise contain invalid indices for the "to" volume. + + """ + if indices.ndim != 2 or indices.shape[1] != 3: + raise ValueError( + 'Argument "indices" must be a two-dimensional array ' + 'with shape [n, 3].' + ) + input_is_int = indices.dtype.kind == 'i' + augmented_input = np.vstack( + [ + indices.T, + np.ones((indices.shape[0], ), dtype=indices.dtype), + ] + ) + augmented_output = np.dot(self._affine, augmented_input) + output_indices = augmented_output[:3, :].T + + if self._round_output: + output_dtype = indices.dtype if input_is_int else np.int64 + output_indices = np.around(output_indices).astype(output_dtype) + else: + if not input_is_int: + output_indices = output_indices.astype(indices.dtype) + + if self._check_bounds: + bounds_fail = False + min_indices = np.min(output_indices, axis=1) + max_indices = np.max(output_indices, axis=1) + + for shape, min_ind, max_ind in zip( + self._output_shape, + min_indices, + max_indices, + ): + if min_ind < -0.5: + bounds_fail = True + break + if max_ind > shape - 0.5: + bounds_fail = True + break + + if bounds_fail: + raise ValueError("Bounds check failed.") + + return output_indices + + +def volread( + fp: Union[str, bytes, PathLike, List[Union[str, PathLike]]], + glob: str = '*.dcm', +) -> Volume: + """Read a volume from a file or list of files or file-like objects. + + Parameters + ---------- + fp: Union[str, bytes, os.PathLike] + Any file-like object, directory, list of file-like objects representing + a DICOM file or set of files. + glob: str, optional + Glob pattern used to find files within the direcotry in the case that + ``fp`` is a string or path that represents a directory. Follows the + format of the standard library glob ``module``. + + Returns + ------- + highdicom.volume.Volume + Volume formed from the specified image file(s). + + """ + if isinstance(fp, (str, PathLike)): + fp = Path(fp) + if isinstance(fp, Path) and fp.is_dir(): + fp = list(fp.glob(glob)) + + if isinstance(fp, Sequence): + dcms = [dcmread(f) for f in fp] + else: + dcms = [dcmread(fp)] + + if len(dcms) == 1 and is_multiframe_image(dcms[0]): + return Volume.from_image(dcms[0]) + + return Volume.from_image_series(dcms) diff --git a/tests/test_content.py b/tests/test_content.py index e65b4276..7ea01251 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -17,6 +17,7 @@ PaletteColorLUT, ContentCreatorIdentificationCodeSequence, ModalityLUT, + ModalityLUTTransformation, LUT, PaletteColorLUTTransformation, PixelMeasuresSequence, @@ -127,18 +128,74 @@ def setUp(self): self._lut_data_16 = np.arange(510, 600, dtype=np.uint16) self._explanation = 'My LUT' - # Commented out until 8 bit LUTs are reimplemented - # def test_construction(self): - # first_value = 0 - # lut = LUT( - # first_mapped_value=first_value, - # lut_data=self._lut_data, - # ) - # assert lut.LUTDescriptor == [len(self._lut_data), first_value, 8] - # assert lut.bits_per_entry == 8 - # assert lut.first_mapped_value == first_value - # assert np.array_equal(lut.lut_data, self._lut_data) - # assert not hasattr(lut, 'LUTExplanation') + def test_construction(self): + first_value = 0 + lut = LUT( + first_mapped_value=first_value, + lut_data=self._lut_data, + ) + assert lut.LUTDescriptor == [len(self._lut_data), first_value, 8] + assert lut.bits_per_entry == 8 + assert lut.first_mapped_value == first_value + assert np.array_equal(lut.lut_data, self._lut_data) + assert not hasattr(lut, 'LUTExplanation') + + arr = np.array([0, 1, 0, 89, 1]) + expected = np.array([10, 11, 10, 99, 11]) + output = lut.apply(arr) + assert output.dtype == np.uint8 + assert np.array_equal(output, expected) + + for dtype in [ + np.uint8, + np.uint16, + np.uint32, + np.float32, + np.float64, + np.int16, + np.int32, + np.int64, + ]: + output = lut.apply(arr, dtype=dtype) + assert output.dtype == dtype + assert np.array_equal(output, expected.astype(dtype)) + + for dtype in [ + np.int8, + ]: + with pytest.raises(TypeError): + lut.apply(arr, dtype=dtype) + + for out_min, out_max in [ + (0.0, 1.0), + (-10, -5), + (100.0, 150.0), + ]: + for dtype in [ + np.float16, + np.float32, + np.float64, + ]: + scaled_data = lut.get_scaled_lut_data( + output_range=(out_min, out_max), + dtype=dtype, + ) + assert scaled_data.min() == out_min + assert scaled_data.max() == out_max + assert scaled_data[0] == out_min + assert scaled_data[-1] == out_max + assert scaled_data.dtype == dtype + + scaled_data = lut.get_scaled_lut_data( + output_range=(out_min, out_max), + dtype=dtype, + invert=True, + ) + assert scaled_data.min() == out_min + assert scaled_data.max() == out_max + assert scaled_data[-1] == out_min + assert scaled_data[0] == out_max + assert scaled_data.dtype == dtype def test_construction_16bit(self): first_value = 0 @@ -152,6 +209,62 @@ def test_construction_16bit(self): assert np.array_equal(lut.lut_data, self._lut_data_16) assert not hasattr(lut, 'LUTExplanation') + arr = np.array([0, 1, 0, 89, 1]) + expected = np.array([510, 511, 510, 599, 511]) + output = lut.apply(arr) + assert output.dtype == np.uint16 + assert np.array_equal(output, expected) + + for dtype in [ + np.uint16, + np.uint32, + np.float32, + np.float64, + np.int32, + np.int64, + ]: + output = lut.apply(arr, dtype=dtype) + assert output.dtype == dtype + assert np.array_equal(output, expected.astype(dtype)) + + for dtype in [ + np.uint8, + np.int8, + np.int16, + ]: + with pytest.raises(TypeError): + lut.apply(arr, dtype=dtype) + + for out_min, out_max in [ + (0.0, 1.0), + (-10, -5), + (100.0, 150.0), + ]: + for dtype in [ + np.float32, + np.float64, + ]: + scaled_data = lut.get_scaled_lut_data( + output_range=(out_min, out_max), + dtype=dtype, + ) + assert scaled_data.min() == out_min + assert scaled_data.max() == out_max + assert scaled_data[0] == out_min + assert scaled_data[-1] == out_max + assert scaled_data.dtype == dtype + + scaled_data = lut.get_scaled_lut_data( + output_range=(out_min, out_max), + dtype=dtype, + invert=True, + ) + assert scaled_data.min() == out_min + assert scaled_data.max() == out_max + assert scaled_data[-1] == out_min + assert scaled_data[0] == out_max + assert scaled_data.dtype == dtype + def test_construction_explanation(self): first_value = 0 lut = LUT( @@ -174,20 +287,45 @@ def setUp(self): self._lut_data_16 = np.arange(510, 600, dtype=np.uint16) self._explanation = 'My LUT' - # Commented out until 8 bit LUTs are reimplemented - # def test_construction(self): - # first_value = 0 - # lut = ModalityLUT( - # lut_type=RescaleTypeValues.HU, - # first_mapped_value=first_value, - # lut_data=self._lut_data, - # ) - # assert lut.ModalityLUTType == RescaleTypeValues.HU.value - # assert lut.LUTDescriptor == [len(self._lut_data), first_value, 8] - # assert lut.bits_per_entry == 8 - # assert lut.first_mapped_value == first_value - # assert np.array_equal(lut.lut_data, self._lut_data) - # assert not hasattr(lut, 'LUTExplanation') + def test_construction(self): + first_value = 0 + lut = ModalityLUT( + lut_type=RescaleTypeValues.HU, + first_mapped_value=first_value, + lut_data=self._lut_data, + ) + assert lut.ModalityLUTType == RescaleTypeValues.HU.value + assert lut.LUTDescriptor == [len(self._lut_data), first_value, 8] + assert lut.bits_per_entry == 8 + assert lut.first_mapped_value == first_value + assert np.array_equal(lut.lut_data, self._lut_data) + assert not hasattr(lut, 'LUTExplanation') + + arr = np.array([0, 1, 0, 89, 1]) + expected = np.array([10, 11, 10, 99, 11]) + output = lut.apply(arr) + assert output.dtype == np.uint8 + assert np.array_equal(output, expected) + + for dtype in [ + np.uint16, + np.uint32, + np.int16, + np.float16, + np.float32, + np.float64, + np.int32, + np.int64, + ]: + output = lut.apply(arr, dtype=dtype) + assert output.dtype == dtype + assert np.array_equal(output, expected.astype(dtype)) + + for dtype in [ + np.int8, + ]: + with pytest.raises(TypeError): + lut.apply(arr, dtype=dtype) def test_construction_16bit(self): first_value = 0 @@ -203,6 +341,33 @@ def test_construction_16bit(self): assert np.array_equal(lut.lut_data, self._lut_data_16) assert not hasattr(lut, 'LUTExplanation') + arr = np.array([0, 1, 0, 89, 1]) + expected = np.array([510, 511, 510, 599, 511]) + output = lut.apply(arr) + assert output.dtype == np.uint16 + assert np.array_equal(output, expected) + + for dtype in [ + np.uint16, + np.uint32, + np.float32, + np.float64, + np.int32, + np.int64, + ]: + output = lut.apply(arr, dtype=dtype) + assert output.dtype == dtype + assert np.array_equal(output, expected.astype(dtype)) + + for dtype in [ + np.uint8, + np.int8, + np.int16, + np.float16, + ]: + with pytest.raises(TypeError): + lut.apply(arr, dtype=dtype) + def test_construction_string_type(self): first_value = 0 lut_type = 'MY_MAPPING' @@ -249,9 +414,222 @@ def test_construction_wrong_dtype(self): with pytest.raises(ValueError): ModalityLUT( lut_type=RescaleTypeValues.HU, - first_mapped_value=0, # invalid - lut_data=np.array([0, 1, 2], dtype=np.int16), + first_mapped_value=0, + lut_data=np.array([0, 1, 2], dtype=np.int16), # invalid + ) + + +class TestModalityLUTTransformation(TestCase): + + def setUp(self): + super().setUp() + self._lut = ModalityLUT( + lut_type=RescaleTypeValues.HU, + first_mapped_value=0, + lut_data=np.array([11, 22, 33, 44], np.uint8), + ) + self._input_array = np.array( + [ + [0, 1, 2, 3], + [0, 1, 2, 3], + [0, 1, 2, 3], + ], + dtype=np.uint8, + ) + + def test_with_lut(self): + transf = ModalityLUTTransformation(modality_lut=self._lut) + assert transf.has_lut() + + out = transf.apply(self._input_array) + + expected = np.array( + [ + [11, 22, 33, 44], + [11, 22, 33, 44], + [11, 22, 33, 44], + ] + ) + + assert np.array_equal(out, expected) + assert out.dtype == np.uint8 + + def test_with_scale_1(self): + transf = ModalityLUTTransformation( + rescale_type=RescaleTypeValues.HU, + rescale_slope=1.0, + rescale_intercept=0.0, + ) + assert not transf.has_lut() + + out = transf.apply(self._input_array) + + expected = self._input_array + + assert np.array_equal(out, expected) + assert out.dtype == np.float64 + + for dtype in [ + np.uint8, + np.uint16, + np.uint32, + np.uint64, + np.float16, + np.float32, + np.float64, + np.int16, + np.int32, + np.int64, + ]: + out = transf.apply(self._input_array, dtype=dtype) + assert np.array_equal(out, expected) + assert out.dtype == dtype + + for dtype in [np.int8]: + msg = ( + f'Datatype {np.dtype(dtype)} does not have capacity for ' + 'values with slope 1.00 and intercept 0.00.' + ) + with pytest.raises(ValueError, match=msg): + transf.apply(self._input_array, dtype=dtype) + + msg = ( + 'An integer data type cannot be used if the input ' + 'array is floating point.' + ) + with pytest.raises(ValueError, match=msg): + transf.apply(self._input_array.astype(np.float32), dtype=np.int64) + + def test_with_scale_2(self): + transf = ModalityLUTTransformation( + rescale_type=RescaleTypeValues.HU, + rescale_slope=10.0, + rescale_intercept=0.0, + ) + assert not transf.has_lut() + + out = transf.apply(self._input_array) + + expected = self._input_array * 10 + + assert np.array_equal(out, expected) + assert out.dtype == np.float64 + + for dtype in [ + np.uint16, + np.uint32, + np.uint64, + np.float32, + np.float64, + np.int16, + np.int32, + np.int64, + ]: + out = transf.apply(self._input_array, dtype=dtype) + assert np.array_equal(out, expected) + assert out.dtype == dtype + + for dtype in [ + np.int8, + np.uint8, + ]: + msg = ( + f'Datatype {np.dtype(dtype)} does not have capacity for ' + 'values with slope 10.00 and intercept 0.00.' ) + with pytest.raises(ValueError, match=msg): + transf.apply(self._input_array, dtype=dtype) + + def test_with_scale_3(self): + transf = ModalityLUTTransformation( + rescale_type=RescaleTypeValues.HU, + rescale_slope=2.0, + rescale_intercept=-1000.0, + ) + assert not transf.has_lut() + + out = transf.apply(self._input_array) + + expected = self._input_array.astype(np.float64) * 2 - 1000 + + assert np.array_equal(out, expected) + assert out.dtype == np.float64 + + for dtype in [ + np.float16, + np.float32, + np.float64, + np.int16, + np.int32, + np.int64, + ]: + out = transf.apply(self._input_array, dtype=dtype) + assert np.array_equal(out, expected) + assert out.dtype == dtype + + for dtype in [ + np.uint8, + np.uint16, + np.uint32, + np.uint64, + ]: + msg = ( + 'An unsigned integer data type cannot be used if the ' + 'intercept is negative.' + ) + with pytest.raises(ValueError, match=msg): + transf.apply(self._input_array, dtype=dtype) + + for dtype in [ + np.int8, + ]: + msg = ( + f'Datatype {np.dtype(dtype)} does not have capacity for ' + 'values with slope 2.00 and intercept -1000.00.' + ) + with pytest.raises(ValueError, match=msg): + transf.apply(self._input_array, dtype=dtype) + + def test_with_scale_4(self): + transf = ModalityLUTTransformation( + rescale_type=RescaleTypeValues.HU, + rescale_slope=3.14159, + rescale_intercept=0.0, + ) + assert not transf.has_lut() + + out = transf.apply(self._input_array) + + expected = self._input_array.astype(np.float64) * 3.14159 + assert np.array_equal(out, expected) + assert out.dtype == np.float64 + + for dtype in [ + np.float16, + np.float32, + np.float64, + ]: + expected = self._input_array.astype(np.float64) * dtype(3.14159) + out = transf.apply(self._input_array, dtype=dtype) + assert np.array_equal(out, expected) + assert out.dtype == dtype + + for dtype in [ + np.int8, + np.int16, + np.int32, + np.int64, + np.uint8, + np.uint16, + np.uint32, + np.uint64, + ]: + msg = ( + 'An integer data type cannot be used if the slope ' + 'or intercept is a non-integer value.' + ) + with pytest.raises(ValueError, match=msg): + transf.apply(self._input_array, dtype=dtype) class TestPlanePositionSequence(TestCase): @@ -932,6 +1310,64 @@ def test_construction_basic(self): assert lut.WindowCenter == 40.0 assert lut.WindowWidth == 400.0 assert not hasattr(lut, 'VOILUTSequence') + assert not lut.has_lut() + + input_array = np.array( + [ + [-200, -200, -200], + [-160, -160, -160], + [39.5, 39.5, 39.5], + [239, 239, 239], + [300, 300, 300], + ] + ) + + expected = np.array( + [ + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [0.5, 0.5, 0.5], + [1.0, 1.0, 1.0], + [1.0, 1.0, 1.0], + ] + ) + inverted_expected = np.flipud(expected) + + out = lut.apply(array=input_array) + assert np.array_equal(expected, out) + assert out.dtype == np.float64 + + # Check with a different dtype + for dtype in [np.float16, np.float32, np.float64]: + out = lut.apply(array=input_array, dtype=dtype) + assert np.allclose(expected, out) + assert out.dtype == dtype + + out = lut.apply(array=input_array, dtype=dtype, invert=True) + assert np.allclose(inverted_expected, out) + assert out.dtype == dtype + + for dtype in [np.int16, np.uint8, np.int64]: + msg = f'Data type "{np.dtype(dtype)}" is not suitable.' + with pytest.raises(ValueError, match=msg): + lut.apply(array=input_array, dtype=dtype) + + # Check with a different window + expected = np.array( + [ + [-0.5, -0.5, -0.5], + [-0.5, -0.5, -0.5], + [0.0, 0.0, 0.0], + [0.5, 0.5, 0.5], + [0.5, 0.5, 0.5], + ] + ) + out = lut.apply( + array=input_array, + output_range=(-0.5, 0.5), + ) + assert np.array_equal(expected, out) + assert out.dtype == np.float64 def test_construction_explanation(self): lut = VOILUTTransformation( @@ -941,15 +1377,59 @@ def test_construction_explanation(self): ) assert lut.WindowCenter == 40.0 assert lut.WindowWidth == 400.0 + assert not lut.has_lut() def test_construction_multiple(self): lut = VOILUTTransformation( - window_center=[40.0, 600.0], - window_width=[400.0, 1500.0], - window_explanation=['Soft Tissue Window', 'Lung Window'], + window_center=[600.0, 40.0], + window_width=[1500.0, 400.0], + window_explanation=['Lung Window', 'Soft Tissue Window'], + ) + assert lut.WindowCenter == [600.0, 40.0] + assert lut.WindowWidth == [1500.0, 400.0] + assert not lut.has_lut() + + input_array_lung = np.array( + [ + [-200, -200, -200], + [-150, -150, -150], + [599.5, 599.5, 599.5], + [1350, 1350, 1350], + [1400, 1400, 1400], + ] ) - assert lut.WindowCenter == [40.0, 600.0] - assert lut.WindowWidth == [400.0, 1500.0] + + input_array_soft_tissue = np.array( + [ + [-200, -200, -200], + [-160, -160, -160], + [39.5, 39.5, 39.5], + [239, 239, 239], + [300, 300, 300], + ] + ) + + expected = np.array( + [ + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [0.5, 0.5, 0.5], + [1.0, 1.0, 1.0], + [1.0, 1.0, 1.0], + ] + ) + + out = lut.apply(array=input_array_lung, voi_transform_selector=0) + assert np.allclose(expected, out) + assert out.dtype == np.float64 + + out = lut.apply(array=input_array_soft_tissue, voi_transform_selector=1) + assert np.array_equal(expected, out) + assert out.dtype == np.float64 + + out = lut.apply(array=input_array_soft_tissue, voi_transform_selector=-1) + assert np.array_equal(expected, out) + assert out.dtype == np.float64 def test_construction_multiple_mismatch1(self): with pytest.raises(ValueError): @@ -988,7 +1468,7 @@ def test_construction_explanation_mismatch2(self): window_explanation=['Soft Tissue Window', 'Lung Window'], ) - def test_construction_lut_function(self): + def test_construction_sigmoid(self): window_center = 40.0 window_width = 400.0 voi_lut_function = VOILUTFunctionValues.SIGMOID @@ -1000,12 +1480,189 @@ def test_construction_lut_function(self): assert lut.WindowCenter == 40.0 assert lut.WindowWidth == 400.0 assert lut.VOILUTFunction == voi_lut_function.value + assert not lut.has_lut() + + input_array = np.array( + [ + [-2000, -20000, -2000], + [40, 40, 40], + [3000, 3000, 3000], + ] + ) + + expected = np.array( + [ + [0.0, 0.0, 0.0], + [0.5, 0.5, 0.5], + [1.0, 1.0, 1.0], + ] + ) + + out = lut.apply(array=input_array) + assert np.allclose(expected, out) + assert out.dtype == np.float64 + + # Check with a different dtype + for dtype in [np.float16, np.float32, np.float64]: + out = lut.apply(array=input_array, dtype=dtype) + assert np.allclose(expected, out) + assert out.dtype == dtype + + expected = np.array( + [ + [10.0, 10.0, 10.0], + [15.0, 15.0, 15.0], + [20.0, 20.0, 20.0], + ] + ) + + out = lut.apply( + array=input_array, + output_range=(10.0, 20.0), + ) + assert np.allclose(expected, out) + assert out.dtype == np.float64 + + # test with invert + inverted_expected = np.flipud(expected) + out = lut.apply( + array=input_array, + output_range=(10.0, 20.0), + invert=True, + ) + assert np.allclose(inverted_expected, out) + assert out.dtype == np.float64 + + def test_construction_linear_exact(self): + window_center = 40.0 + window_width = 400.0 + voi_lut_function = VOILUTFunctionValues.LINEAR_EXACT + lut = VOILUTTransformation( + window_center=window_center, + window_width=window_width, + voi_lut_function=voi_lut_function, + ) + assert lut.WindowCenter == 40.0 + assert lut.WindowWidth == 400.0 + assert lut.VOILUTFunction == voi_lut_function.value + assert not lut.has_lut() + + input_array = np.array( + [ + [-200, -200, -200], + [-160, -160, -160], + [40, 40, 40], + [240, 240, 240], + [300, 300, 300], + ], + np.int16 + ) + + expected = np.array( + [ + [0.0, 0.0, 0.0], + [0.0, 0.0, 0.0], + [0.5, 0.5, 0.5], + [1.0, 1.0, 1.0], + [1.0, 1.0, 1.0], + ] + ) + + out = lut.apply(array=input_array) + assert np.array_equal(expected, out) + assert out.dtype == np.float64 + + # Check with a different window + expected = np.array( + [ + [-20.0, -20.0, -20.0], + [-20.0, -20.0, -20.0], + [-15.0, -15.0, -15.0], + [-10.0, -10.0, -10.0], + [-10.0, -10.0, -10.0], + ] + ) + out = lut.apply( + array=input_array, + output_range=(-20.0, -10.0), + ) + assert np.array_equal(expected, out) + assert out.dtype == np.float64 + + # Check inverted + expected = np.array( + [ + [-10.0, -10.0, -10.0], + [-10.0, -10.0, -10.0], + [-15.0, -15.0, -15.0], + [-20.0, -20.0, -20.0], + [-20.0, -20.0, -20.0], + ] + ) + out = lut.apply( + array=input_array, + output_range=(-20.0, -10.0), + invert=True, + dtype=np.float32 + ) + assert np.array_equal(expected, out) + assert out.dtype == np.float32 def test_construction_luts(self): lut = VOILUTTransformation(voi_luts=[self._lut]) assert len(lut.VOILUTSequence) == 1 assert not hasattr(lut, 'WindowWidth') assert not hasattr(lut, 'WindowCenter') + assert lut.has_lut() + + input_array = np.array( + [ + [0, 0, 0], + [1, 1, 1], + [2, 2, 2], + ], + dtype=np.uint8 + ) + + expected = np.array( + [ + [0.0, 0.0, 0.0], + [0.5, 0.5, 0.5], + [1.0, 1.0, 1.0], + ], + ) + out = lut.apply(input_array) + assert np.array_equal(expected, out) + + expected = np.array( + [ + [1.0, 1.0, 1.0], + [0.5, 0.5, 0.5], + [0.0, 0.0, 0.0], + ], + ) + out = lut.apply(input_array, invert=True) + assert np.array_equal(expected, out) + + expected = np.array( + [ + [1.0, 1.0, 1.0], + [3.5, 3.5, 3.5], + [6.0, 6.0, 6.0], + ], + ) + out = lut.apply(input_array, output_range=(1.0, 6.0)) + assert np.array_equal(expected, out) + + expected = np.array( + [ + [6.0, 6.0, 6.0], + [3.5, 3.5, 3.5], + [1.0, 1.0, 1.0], + ], + ) + out = lut.apply(input_array, output_range=(1.0, 6.0), invert=True) + assert np.array_equal(expected, out) def test_construction_both(self): lut = VOILUTTransformation( @@ -1016,6 +1673,7 @@ def test_construction_both(self): assert len(lut.VOILUTSequence) == 1 assert lut.WindowCenter == 40.0 assert lut.WindowWidth == 400.0 + assert lut.has_lut() def test_construction_neither(self): with pytest.raises(TypeError): @@ -1186,7 +1844,32 @@ def test_construction_16bit(self): assert lut.lut_data.dtype == np.uint16 np.array_equal(lut.lut_data, lut_data) - # Commented out until 8 bit LUTs are reimplemented + arr = np.array([32, 33, 32, 132]) + expected = np.array([10, 11, 10, 110]) + output = lut.apply(arr) + assert output.dtype == np.uint16 + assert np.array_equal(output, expected) + + for dtype in [ + np.uint16, + np.uint32, + np.float32, + np.float64, + np.int32, + np.int64, + ]: + output = lut.apply(arr, dtype=dtype) + assert output.dtype == dtype + assert np.array_equal(output, expected.astype(dtype)) + + for dtype in [ + np.uint8, + np.int8, + np.int16, + ]: + with pytest.raises(TypeError): + lut.apply(arr, dtype=dtype) + def test_construction_8bit(self): lut_data = np.arange(0, 256, dtype=np.uint8) first_mapped_value = 0 @@ -1209,6 +1892,31 @@ def test_construction_8bit(self): assert lut.lut_data.dtype == np.uint8 np.array_equal(lut.lut_data, lut_data) + arr = np.array([0, 1, 0, 255]) + expected = np.array([0, 1, 0, 255]) + output = lut.apply(arr) + assert output.dtype == np.uint8 + assert np.array_equal(output, expected) + + for dtype in [ + np.uint16, + np.uint32, + np.int16, + np.float32, + np.float64, + np.int32, + np.int64, + ]: + output = lut.apply(arr, dtype=dtype) + assert output.dtype == dtype + assert np.array_equal(output, expected.astype(dtype)) + + for dtype in [ + np.int8, + ]: + with pytest.raises(TypeError): + lut.apply(arr, dtype=dtype) + class TestPaletteColorLUTTransformation(TestCase): @@ -1216,47 +1924,80 @@ def setUp(self): super().setUp() def test_construction(self): - dtype = np.uint16 - r_lut_data = np.arange(10, 120, dtype=dtype) - g_lut_data = np.arange(20, 130, dtype=dtype) - b_lut_data = np.arange(30, 140, dtype=dtype) - first_mapped_value = 32 - lut_uid = UID() - r_lut = PaletteColorLUT(first_mapped_value, r_lut_data, color='red') - g_lut = PaletteColorLUT(first_mapped_value, g_lut_data, color='green') - b_lut = PaletteColorLUT(first_mapped_value, b_lut_data, color='blue') - instance = PaletteColorLUTTransformation( - red_lut=r_lut, - green_lut=g_lut, - blue_lut=b_lut, - palette_color_lut_uid=lut_uid, - ) - assert instance.PaletteColorLookupTableUID == lut_uid - red_desc = [len(r_lut_data), first_mapped_value, 16] - r_lut_data_retrieved = np.frombuffer( - instance.RedPaletteColorLookupTableData, - dtype=np.uint16 - ) - assert np.array_equal(r_lut_data, r_lut_data_retrieved) - assert instance.RedPaletteColorLookupTableDescriptor == red_desc - green_desc = [len(g_lut_data), first_mapped_value, 16] - g_lut_data_retrieved = np.frombuffer( - instance.GreenPaletteColorLookupTableData, - dtype=np.uint16 - ) - assert np.array_equal(g_lut_data, g_lut_data_retrieved) - assert instance.GreenPaletteColorLookupTableDescriptor == green_desc - blue_desc = [len(b_lut_data), first_mapped_value, 16] - b_lut_data_retrieved = np.frombuffer( - instance.BluePaletteColorLookupTableData, - dtype=np.uint16 - ) - assert np.array_equal(b_lut_data, b_lut_data_retrieved) - assert instance.BluePaletteColorLookupTableDescriptor == blue_desc - - assert np.array_equal(instance.red_lut.lut_data, r_lut_data) - assert np.array_equal(instance.green_lut.lut_data, g_lut_data) - assert np.array_equal(instance.blue_lut.lut_data, b_lut_data) + for bits, dtype in [(8, np.uint8), (16, np.uint16)]: + r_lut_data = np.arange(10, 120, dtype=dtype) + g_lut_data = np.arange(20, 130, dtype=dtype) + b_lut_data = np.arange(30, 140, dtype=dtype) + first_mapped_value = 32 + lut_uid = UID() + r_lut = PaletteColorLUT( + first_mapped_value, + r_lut_data, + color='red', + ) + g_lut = PaletteColorLUT( + first_mapped_value, + g_lut_data, + color='green', + ) + b_lut = PaletteColorLUT( + first_mapped_value, + b_lut_data, + color='blue', + ) + instance = PaletteColorLUTTransformation( + red_lut=r_lut, + green_lut=g_lut, + blue_lut=b_lut, + palette_color_lut_uid=lut_uid, + ) + assert instance.PaletteColorLookupTableUID == lut_uid + red_desc = [len(r_lut_data), first_mapped_value, bits] + r_lut_data_retrieved = np.frombuffer( + instance.RedPaletteColorLookupTableData, + dtype=dtype, + ) + assert np.array_equal(r_lut_data, r_lut_data_retrieved) + assert ( + instance.RedPaletteColorLookupTableDescriptor == red_desc + ) + green_desc = [len(g_lut_data), first_mapped_value, bits] + g_lut_data_retrieved = np.frombuffer( + instance.GreenPaletteColorLookupTableData, + dtype=dtype, + ) + assert np.array_equal(g_lut_data, g_lut_data_retrieved) + assert ( + instance.GreenPaletteColorLookupTableDescriptor == green_desc + ) + blue_desc = [len(b_lut_data), first_mapped_value, bits] + b_lut_data_retrieved = np.frombuffer( + instance.BluePaletteColorLookupTableData, + dtype=dtype, + ) + assert np.array_equal(b_lut_data, b_lut_data_retrieved) + assert ( + instance.BluePaletteColorLookupTableDescriptor == blue_desc + ) + + assert np.array_equal(instance.red_lut.lut_data, r_lut_data) + assert np.array_equal(instance.green_lut.lut_data, g_lut_data) + assert np.array_equal(instance.blue_lut.lut_data, b_lut_data) + + assert instance.first_mapped_value == first_mapped_value + assert instance.number_of_entries == len(r_lut_data) + assert instance.bits_per_entry == bits + + arr = np.array([32, 33, 32, 132]) + expected = np.array( + [ + [10, 11, 10, 110], + [20, 21, 20, 120], + [30, 31, 30, 130], + ] + ).T + output = instance.apply(arr) + assert np.array_equal(output, expected) def test_construction_no_uid(self): r_lut_data = np.arange(10, 120, dtype=np.uint16) @@ -1288,21 +2029,20 @@ def test_construction_different_lengths(self): blue_lut=b_lut, ) - # Commented out until 8 bit LUTs are reimplemented - # def test_construction_different_dtypes(self): - # r_lut_data = np.arange(10, 120, dtype=np.uint8) - # g_lut_data = np.arange(20, 130, dtype=np.uint16) - # b_lut_data = np.arange(30, 140, dtype=np.uint16) - # first_mapped_value = 32 - # r_lut = PaletteColorLUT(first_mapped_value, r_lut_data, color='red') - # g_lut = PaletteColorLUT(first_mapped_value, g_lut_data, color='green') - # b_lut = PaletteColorLUT(first_mapped_value, b_lut_data, color='blue') - # with pytest.raises(ValueError): - # PaletteColorLUTTransformation( - # red_lut=r_lut, - # green_lut=g_lut, - # blue_lut=b_lut, - # ) + def test_construction_different_dtypes(self): + r_lut_data = np.arange(10, 120, dtype=np.uint8) + g_lut_data = np.arange(20, 130, dtype=np.uint16) + b_lut_data = np.arange(30, 140, dtype=np.uint16) + first_mapped_value = 32 + r_lut = PaletteColorLUT(first_mapped_value, r_lut_data, color='red') + g_lut = PaletteColorLUT(first_mapped_value, g_lut_data, color='green') + b_lut = PaletteColorLUT(first_mapped_value, b_lut_data, color='blue') + with pytest.raises(ValueError): + PaletteColorLUTTransformation( + red_lut=r_lut, + green_lut=g_lut, + blue_lut=b_lut, + ) def test_construction_different_first_values(self): r_lut_data = np.arange(10, 120, dtype=np.uint16) @@ -1321,6 +2061,32 @@ def test_construction_different_first_values(self): blue_lut=b_lut, ) + def test_construction_from_colors(self): + lut = PaletteColorLUTTransformation.from_colors( + ['black', 'red', 'green', 'blue', 'white'], + ) + + assert np.array_equal(lut.red_lut.lut_data, [0, 255, 0, 0, 255]) + assert np.array_equal(lut.green_lut.lut_data, [0, 0, 128, 0, 255]) + assert np.array_equal(lut.blue_lut.lut_data, [0, 0, 0, 255, 255]) + + def test_construction_from_combined(self): + + r_lut_data = np.arange(10, 120, dtype=np.uint8) + g_lut_data = np.arange(20, 130, dtype=np.uint8) + b_lut_data = np.arange(30, 140, dtype=np.uint8) + + combined_lut = np.stack( + [r_lut_data, g_lut_data, b_lut_data], + axis=-1 + ) + lut = PaletteColorLUTTransformation.from_combined_lut( + combined_lut, + ) + + assert np.array_equal(lut.red_lut.lut_data, r_lut_data) + assert np.array_equal(lut.blue_lut.lut_data, b_lut_data) + assert np.array_equal(lut.green_lut.lut_data, g_lut_data) class TestSpecimenDescription(TestCase): def test_construction(self): diff --git a/tests/test_image.py b/tests/test_image.py new file mode 100644 index 00000000..cf2535b0 --- /dev/null +++ b/tests/test_image.py @@ -0,0 +1,1026 @@ +"""Tests for the highdicom.image module.""" +from pathlib import Path +from pydicom.data import get_testdata_file, get_testdata_files +from pydicom.sr.codedict import codes +import numpy as np +import pickle +import pkgutil +import pydicom +import pytest +import re + +from highdicom._module_utils import ( + does_iod_have_pixel_data, +) +from highdicom.content import VOILUTTransformation +from highdicom.image import ( + _CombinedPixelTransformation, + Image, + imread, +) +from highdicom.pixel_transforms import ( + apply_voi_window, +) +from highdicom.pr.content import ( + _add_icc_profile_attributes, +) +from highdicom.pm import ( + RealWorldValueMapping, + ParametricMap, +) +from highdicom.sr.coding import CodedConcept +from highdicom.uid import UID +from highdicom.volume import Volume + + +def test_slice_spacing(): + ct_multiframe = pydicom.dcmread( + get_testdata_file('eCT_Supplemental.dcm') + ) + image = Image.from_dataset(ct_multiframe) + + expected_affine = np.array( + [ + [0.0, 0.0, -0.388672, 99.5], + [0.0, 0.388672, 0.0, -301.5], + [10.0, 0.0, 0.0, -159], + [0.0, 0.0, 0.0, 1.0], + ] + ) + assert image.volume_geometry is not None + assert image.volume_geometry.spatial_shape[0] == 2 + assert np.array_equal(image.volume_geometry.affine, expected_affine) + + +def test_slice_spacing_irregular(): + ct_multiframe = pydicom.dcmread( + get_testdata_file('eCT_Supplemental.dcm') + ) + + # Mock some irregular spacings + ct_multiframe.PerFrameFunctionalGroupsSequence[0].\ + PlanePositionSequence[0].ImagePositionPatient = [1.0, 0.0, 0.0] + + image = Image.from_dataset(ct_multiframe) + + assert image.volume_geometry is None + + +def test_pickle(): + # Check that the database is successfully serialized and deserialized + ct_multiframe = pydicom.dcmread( + get_testdata_file('eCT_Supplemental.dcm') + ) + image = Image.from_dataset(ct_multiframe) + + ptr = image.dimension_index_pointers[0] + + pickled = pickle.dumps(image) + + # Check that the pickling process has not damaged the db on the existing + # instance + # This is just an example operation that requires the db + assert not image.are_dimension_indices_unique([ptr]) + + unpickled = pickle.loads(pickled) + assert isinstance(unpickled, Image) + + # Check that the database has been successfully restored in the + # deserialization process + assert not unpickled.are_dimension_indices_unique([ptr]) + + +def test_combined_transform_ect_rwvm(): + + dcm = pydicom.dcmread( + get_testdata_file('eCT_Supplemental.dcm') + ) + rwvm_seq = ( + dcm + .SharedFunctionalGroupsSequence[0] + .RealWorldValueMappingSequence[0] + ) + slope = rwvm_seq.RealWorldValueSlope + intercept = rwvm_seq.RealWorldValueIntercept + first = rwvm_seq.RealWorldValueFirstValueMapped + last = rwvm_seq.RealWorldValueLastValueMapped + + for output_dtype in [ + np.int32, + np.int64, + np.float16, + np.float32, + np.float64, + ]: + tf = _CombinedPixelTransformation( + dcm, + output_dtype=output_dtype, + ) + + assert tf.applies_to_all_frames + + assert tf._effective_slope_intercept == ( + slope, intercept + ) + assert tf._input_range_check == ( + first, last + ) + + input_arr = np.array([[1, 2], [3, 4]], np.uint16) + expected = input_arr * slope + intercept + + output_arr = tf(input_arr) + + assert np.array_equal(output_arr, expected) + + assert output_arr.dtype == output_dtype + + full_output_arr = tf(dcm.pixel_array[0]) + assert full_output_arr.dtype == output_dtype + + out_of_range_input = np.array( + [[last + 1, 1], [1, 1]], + np.uint16 + ) + msg = 'Array contains value outside the valid range.' + with pytest.raises(ValueError, match=msg): + tf(out_of_range_input) + + msg = ( + 'An unsigned integer data type cannot be used if the intercept is ' + 'negative.' + ) + with pytest.raises(ValueError, match=msg): + _CombinedPixelTransformation( + dcm, + output_dtype=np.uint32, + ) + + msg = ( + 'Palette color transform is required but the image is not a palette ' + 'color image.' + ) + with pytest.raises(ValueError, match=msg): + _CombinedPixelTransformation( + dcm, + apply_palette_color_lut=True, + ) + + msg = ( + 'ICC profile is required but the image is not a color or palette ' + 'color image.' + ) + with pytest.raises(ValueError, match=msg): + _CombinedPixelTransformation( + dcm, + apply_icc_profile=True, + ) + + msg = ( + f'Datatype int16 does not have capacity for values ' + f'with slope 1.00 and intercept -1024.0.' + ) + with pytest.raises(ValueError, match=msg): + _CombinedPixelTransformation( + dcm, + output_dtype=np.int16, + ) + + # Various different indexing methods + unit_code = CodedConcept('ml/100ml/s', 'UCUM', 'ml/100ml/s', '1.4') + for selector in [-1, 'RCBF', unit_code]: + tf = _CombinedPixelTransformation( + dcm, + real_world_value_map_selector=selector, + ) + assert tf._effective_slope_intercept == (slope, intercept) + + # Various different incorrect indexing methods + msg = "Requested 'real_world_value_map_selector' is not present." + other_unit_code = CodedConcept('m/s', 'UCUM', 'm/s', '1.4') + for selector in [2, -2, 'ABCD', other_unit_code]: + with pytest.raises(IndexError, match=msg): + _CombinedPixelTransformation( + dcm, + real_world_value_map_selector=selector, + ) + + # Delete the real world value map + del ( + dcm + .SharedFunctionalGroupsSequence[0] + .RealWorldValueMappingSequence + ) + msg = ( + 'A real-world value map is required but not found in the image.' + ) + with pytest.raises(RuntimeError, match=msg): + _CombinedPixelTransformation( + dcm, + apply_real_world_transform=True, + ) + + +def test_combined_transform_ect_modality(): + + dcm = pydicom.dcmread( + get_testdata_file('eCT_Supplemental.dcm') + ) + pix_value_seq = ( + dcm + .SharedFunctionalGroupsSequence[0] + .PixelValueTransformationSequence[0] + ) + slope = pix_value_seq.RescaleSlope + intercept = pix_value_seq.RescaleIntercept + + for output_dtype in [ + np.int32, + np.int64, + np.float16, + np.float32, + np.float64, + ]: + tf = _CombinedPixelTransformation( + dcm, + output_dtype=output_dtype, + apply_real_world_transform=False, + ) + + assert tf.applies_to_all_frames + + assert tf._effective_slope_intercept == ( + slope, intercept + ) + assert tf._input_range_check is None + + input_arr = np.array([[1, 2], [3, 4]], np.uint16) + expected = input_arr * slope + intercept + + output_arr = tf(input_arr) + + assert np.array_equal(output_arr, expected) + + assert output_arr.dtype == output_dtype + + full_output_arr = tf(dcm.pixel_array[0]) + assert full_output_arr.dtype == output_dtype + + # Same thing should work by requiring the modality LUT + tf = _CombinedPixelTransformation( + dcm, + output_dtype=output_dtype, + apply_modality_transform=True, + ) + + assert tf.applies_to_all_frames + + assert tf._effective_slope_intercept == ( + slope, intercept + ) + assert tf._input_range_check is None + + full_output_arr = tf(dcm.pixel_array[0]) + assert full_output_arr.dtype == output_dtype + + msg = ( + 'An unsigned integer data type cannot be used if the intercept is ' + 'negative.' + ) + with pytest.raises(ValueError, match=msg): + _CombinedPixelTransformation( + dcm, + output_dtype=np.uint32, + ) + + msg = ( + f'Datatype int16 does not have capacity for values ' + f'with slope 1.00 and intercept -1024.0.' + ) + with pytest.raises(ValueError, match=msg): + _CombinedPixelTransformation( + dcm, + output_dtype=np.int16, + ) + + # Delete the modality transform + del ( + dcm + .SharedFunctionalGroupsSequence[0] + .PixelValueTransformationSequence + ) + msg = ( + 'A modality LUT transform is required but not found in ' + 'the image.' + ) + with pytest.raises(RuntimeError, match=msg): + _CombinedPixelTransformation( + dcm, + apply_modality_transform=True, + ) + + +def test_combined_transform_ect_with_voi(): + + dcm = pydicom.dcmread( + get_testdata_file('eCT_Supplemental.dcm') + ) + pix_value_seq = ( + dcm + .SharedFunctionalGroupsSequence[0] + .PixelValueTransformationSequence[0] + ) + slope = pix_value_seq.RescaleSlope + intercept = pix_value_seq.RescaleIntercept + frame_voi_seq = ( + dcm + .SharedFunctionalGroupsSequence[0] + .FrameVOILUTSequence[0] + ) + center = frame_voi_seq.WindowCenter + width = frame_voi_seq.WindowWidth + + lower = center - width // 2 + upper = center + width // 2 + + for output_dtype in [ + np.float16, + np.float32, + np.float64, + ]: + for output_range in [ + (0., 1.), + (-10.0, 10.0), + (50., 100.0), + ]: + tf = _CombinedPixelTransformation( + dcm, + output_dtype=output_dtype, + apply_real_world_transform=False, + apply_voi_transform=None, + voi_output_range=output_range, + ) + + assert tf.applies_to_all_frames + + assert tf._effective_window_center_width == ( + center - intercept, width / slope + ) + assert tf._input_range_check is None + assert tf._effective_slope_intercept is None + assert tf._color_manager is None + assert tf._voi_output_range == output_range + assert tf._effective_voi_function == 'LINEAR' + + input_arr = np.array( + [ + [lower - intercept, center - intercept], + [upper - intercept - 1, upper - intercept - 1] + ], + np.uint16 + ) + expected = np.array([[0.0, 0.5], [1.0, 1.0]]) + output_scale = output_range[1] - output_range[0] + expected = expected * output_scale + output_range[0] + + output_arr = tf(input_arr) + + assert np.allclose(output_arr, expected, atol=0.5) + + assert output_arr.dtype == output_dtype + + full_output_arr = tf(dcm.pixel_array[0]) + assert full_output_arr.dtype == output_dtype + + msg = ( + 'The VOI transformation requires a floating point data type.' + ) + with pytest.raises(ValueError, match=msg): + _CombinedPixelTransformation( + dcm, + output_dtype=np.int32, + apply_real_world_transform=False, + apply_voi_transform=None, + ) + + # Delete the voi transform + del ( + dcm + .SharedFunctionalGroupsSequence[0] + .PixelValueTransformationSequence + ) + msg = ( + 'A modality LUT transform is required but not found in ' + 'the image.' + ) + with pytest.raises(RuntimeError, match=msg): + _CombinedPixelTransformation( + dcm, + apply_modality_transform=True, + ) + + +def test_combined_transform_modality_lut(): + # A test file that has a modality LUT + f = get_testdata_file('mlut_18.dcm') + dcm = pydicom.dcmread(f) + lut_data = dcm.ModalityLUTSequence[0].LUTData + + input_arr = np.array([[-2048, -2047], [-2046, -2045]], np.int16) + expected = np.array( + [ + [lut_data[0], lut_data[1]], + [lut_data[2], lut_data[3]], + ], + ) + + for output_dtype in [ + np.int32, + np.int64, + np.float32, + np.float64, + ]: + tf = _CombinedPixelTransformation( + dcm, + output_dtype=output_dtype + ) + assert tf._effective_lut_data is not None + assert tf._effective_window_center_width is None + assert tf._effective_slope_intercept is None + assert tf._color_manager is None + assert tf._input_range_check is None + + output_arr = tf(input_arr) + assert np.array_equal(output_arr, expected) + assert output_arr.dtype == output_dtype + + full_output_arr = tf(dcm.pixel_array) + assert full_output_arr.dtype == output_dtype + + msg = ( + 'A VOI transform is required but not found in the image.' + ) + with pytest.raises(RuntimeError, match=msg): + _CombinedPixelTransformation( + dcm, + apply_voi_transform=True, + ) + + msg = re.escape( + "Cannot cast array data from dtype('uint16') to " + "dtype('float16') according to the rule 'safe'" + ) + with pytest.raises(TypeError, match=msg): + tf = _CombinedPixelTransformation(dcm, output_dtype=np.float16) + + # Add a voi lut + dcm.WindowCenter = 24 + dcm.WindowWidth = 24 + + tf = _CombinedPixelTransformation(dcm, apply_voi_transform=None) + output_arr = tf(input_arr) + expected = np.array([[0.0, 0.17391304], [0.86956522, 1.0]]) + assert np.allclose(output_arr, expected) + + +def test_combined_transform_multiple_vois(): + # This test file includes multiple windows + f = get_testdata_file('examples_overlay.dcm') + dcm = pydicom.dcmread(f) + c1, c2 = dcm.WindowCenter + w1, w2 = dcm.WindowWidth + + tf = _CombinedPixelTransformation(dcm, apply_voi_transform=None) + assert tf._effective_window_center_width == (c1, w1) + + tf = _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector=1, + ) + assert tf._effective_window_center_width == (c2, w2) + assert tf._effective_voi_function == 'LINEAR' + + tf = _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector=-1, + ) + assert tf._effective_window_center_width == (c2, w2) + assert tf._effective_voi_function == 'LINEAR' + + tf = _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector=-2, + ) + assert tf._effective_window_center_width == (c1, w1) + assert tf._effective_voi_function == 'LINEAR' + + tf = _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector='WINDOW1', + ) + assert tf._effective_window_center_width == (c1, w1) + assert tf._effective_voi_function == 'LINEAR' + + tf = _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector='WINDOW2', + ) + assert tf._effective_window_center_width == (c2, w2) + assert tf._effective_voi_function == 'LINEAR' + + msg = "Requested 'voi_transform_selector' is not present." + with pytest.raises(IndexError, match=msg): + _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector='DOES_NOT_EXIST', + ) + with pytest.raises(IndexError, match=msg): + _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector=2, + ) + with pytest.raises(IndexError, match=msg): + tf = _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector=-3, + ) + + c3, w3 = (40, 400) + external_voi = VOILUTTransformation( + window_center=c3, + window_width=w3, + ) + tf = _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector=external_voi, + ) + assert tf._effective_window_center_width == (c3, w3) + + # External VOIs should not contain multiple transforms + invalid_external_voi = VOILUTTransformation( + window_center=[100, 200], + window_width=[300, 400], + ) + msg = ( + "If providing a VOILUTTransformation as the " + "'voi_transform_selector', it must contain a single transform." + ) + with pytest.raises(ValueError, match=msg): + tf = _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector=invalid_external_voi, + ) + +def test_combined_transform_voi_lut(): + # A test file that has a voi LUT + f = get_testdata_file('vlut_04.dcm') + dcm = pydicom.dcmread(f) + lut_data = dcm.VOILUTSequence[0].LUTData + first_mapped_value = dcm.VOILUTSequence[0].LUTDescriptor[1] + + for output_dtype in [ + np.float32, + np.float64, + ]: + for output_range in [ + (0., 1.), + (-10.0, 10.0), + (50., 100.0), + ]: + tf = _CombinedPixelTransformation( + dcm, + output_dtype=output_dtype, + apply_voi_transform=None, + voi_output_range=output_range, + ) + assert tf._effective_lut_data is not None + assert tf._effective_window_center_width is None + assert tf._effective_slope_intercept is None + assert tf._color_manager is None + assert tf._input_range_check is None + assert tf._voi_output_range == output_range + + input_arr = np.array( + [ + [first_mapped_value, first_mapped_value + 1], + [first_mapped_value + 2, first_mapped_value + 3], + ] + ) + output_scale = ( + (max(lut_data) - min(lut_data)) / + (output_range[1] - output_range[0]) + ) + expected = np.array( + [ + [lut_data[0], lut_data[1]], + [lut_data[2], lut_data[3]], + ] + ) / output_scale + output_range[0] + + output_arr = tf(input_arr) + assert np.allclose(output_arr, expected, atol=0.1) + assert output_arr.dtype == output_dtype + + full_output_arr = tf(dcm.pixel_array) + assert full_output_arr.dtype == output_dtype + + # Create an explanation to use for searching by explanation + dcm.VOILUTSequence[0].LUTExplanation = 'BONE' + + tf = _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector='BONE' + ) + assert tf._effective_lut_data is not None + + tf = _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector=-1, + ) + assert tf._effective_lut_data is not None + + msg = "Requested 'voi_transform_selector' is not present." + with pytest.raises(IndexError, match=msg): + _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector='NOT_BONE', + ) + with pytest.raises(IndexError, match=msg): + _CombinedPixelTransformation( + dcm, + apply_voi_transform=None, + voi_transform_selector=1, + ) + + +def test_combined_transform_monochrome(): + # A test file that has a modality LUT + f = get_testdata_file('RG1_UNCR.dcm') + dcm = pydicom.dcmread(f) + + center_width = (dcm.WindowCenter, dcm.WindowWidth) + + max_value = 2 ** dcm.BitsStored - 1 + + for output_dtype in [ + np.int32, + np.int64, + np.float32, + np.float64, + ]: + # Default behavior; inversion but no VOI + tf = _CombinedPixelTransformation( + dcm, + output_dtype=output_dtype, + ) + assert tf._effective_slope_intercept == (-1, max_value) + assert tf._effective_lut_data is None + assert tf._effective_window_center_width is None + assert tf._color_manager is None + assert tf._input_range_check is None + + output_arr = tf(dcm.pixel_array) + + expected = max_value - dcm.pixel_array + expected = expected.astype(output_dtype) + if output_dtype != np.float16: + # float16 seems to give a lot of precision related errors in this + # range + assert np.array_equal(output_arr, expected) + assert output_arr.dtype == output_dtype + + # No inversion + tf = _CombinedPixelTransformation( + dcm, + output_dtype=output_dtype, + apply_presentation_lut=False, + ) + assert tf._effective_slope_intercept is None + assert tf._effective_lut_data is None + assert tf._effective_window_center_width is None + assert tf._color_manager is None + assert tf._input_range_check is None + + output_arr = tf(dcm.pixel_array) + + expected = dcm.pixel_array + expected = expected.astype(output_dtype) + assert np.array_equal(output_arr, expected) + assert output_arr.dtype == output_dtype + + for output_dtype in [ + np.float16, + np.float32, + np.float64, + ]: + # VOI and inversion + tf = _CombinedPixelTransformation( + dcm, + output_dtype=output_dtype, + apply_voi_transform=None, + ) + assert tf._effective_slope_intercept is None + assert tf._effective_lut_data is None + assert tf._effective_window_center_width == center_width + assert tf._color_manager is None + assert tf._input_range_check is None + assert tf._invert + + output_arr = tf(dcm.pixel_array) + + expected = apply_voi_window( + dcm.pixel_array, + window_width=dcm.WindowWidth, + window_center=dcm.WindowCenter, + dtype=output_dtype, + invert=True, + ) + if output_dtype != np.float16: + # float16 seems to give a lot of precision related errors in this + # range + assert np.array_equal(output_arr, expected) + assert output_arr.dtype == output_dtype + + # VOI and no inversion + tf = _CombinedPixelTransformation( + dcm, + output_dtype=output_dtype, + apply_voi_transform=None, + apply_presentation_lut=False, + ) + assert tf._effective_slope_intercept is None + assert tf._effective_lut_data is None + assert tf._effective_window_center_width == center_width + assert tf._color_manager is None + assert tf._input_range_check is None + assert not tf._invert + + output_arr = tf(dcm.pixel_array) + + expected = apply_voi_window( + dcm.pixel_array, + window_width=dcm.WindowWidth, + window_center=dcm.WindowCenter, + dtype=output_dtype, + invert=False, + ) + if output_dtype != np.float16: + # float16 seems to give a lot of precision related errors in this + # range + assert np.array_equal(output_arr, expected) + assert output_arr.dtype == output_dtype + + +def test_combined_transform_color(): + # A simple color image test file, with no ICC profile + f = get_testdata_file('color-pl.dcm') + dcm = pydicom.dcmread(f) + + # Not quite sure why this is needed... + # The original UID is not recognized + dcm.SOPClassUID = pydicom.uid.UltrasoundImageStorage + + tf = _CombinedPixelTransformation(dcm) + assert tf._effective_slope_intercept is None + assert tf._effective_lut_data is None + assert tf._effective_window_center_width is None + assert tf._color_manager is None + assert tf._input_range_check is None + assert not tf._invert + + output_arr = tf(dcm.pixel_array) + assert np.array_equal(output_arr, dcm.pixel_array) + + msg = "An ICC profile is required but not found in the image." + with pytest.raises(RuntimeError, match=msg): + _CombinedPixelTransformation( + dcm, + apply_icc_profile=True, + ) + + # Add an ICC profile + # Use default sRGB profile + icc_profile = pkgutil.get_data( + 'highdicom', + '_icc_profiles/sRGB_v4_ICC_preference.icc' + ) + _add_icc_profile_attributes( + dcm, + icc_profile=icc_profile, + ) + tf = _CombinedPixelTransformation(dcm) + assert tf._effective_slope_intercept is None + assert tf._effective_lut_data is None + assert tf._effective_window_center_width is None + assert tf._color_manager is not None + assert tf._input_range_check is None + assert not tf._invert + + output_arr = tf(dcm.pixel_array) + + +def test_combined_transform_labelmap_seg(): + file_path = Path(__file__) + data_dir = file_path.parent.parent.joinpath('data') + f = data_dir / 'test_files/seg_image_sm_control_labelmap_palette_color.dcm' + + dcm = pydicom.dcmread(f) + + for output_dtype in [ + np.uint8, + np.uint16, + np.uint32, + np.uint64, + np.int16, + np.int32, + np.int64, + np.float16, + np.float32, + np.float64, + ]: + tf = _CombinedPixelTransformation(dcm, output_dtype=output_dtype) + assert tf._effective_slope_intercept is None + assert tf._effective_lut_data is not None + assert tf._effective_window_center_width is None + assert tf._color_manager is not None + assert tf._input_range_check is None + assert not tf._invert + + input_arr = dcm.pixel_array[0] + output_arr = tf(input_arr) + assert output_arr.shape == (dcm.Rows, dcm.Columns, 3) + assert output_arr.dtype == output_dtype + + tf = _CombinedPixelTransformation( + dcm, + output_dtype=output_dtype, + apply_icc_profile=False, + ) + assert tf._effective_slope_intercept is None + assert tf._effective_lut_data is not None + assert tf._effective_lut_data.dtype == output_dtype + assert tf._effective_window_center_width is None + assert tf._color_manager is None + assert tf._input_range_check is None + assert not tf._invert + + input_arr = dcm.pixel_array[0] + output_arr = tf(input_arr) + assert output_arr.shape == (dcm.Rows, dcm.Columns, 3) + assert output_arr.dtype == output_dtype + + +def test_combined_transform_sm_image(): + file_path = Path(__file__) + data_dir = file_path.parent.parent.joinpath('data') + f = data_dir / 'test_files/sm_image_control.dcm' + + dcm = pydicom.dcmread(f) + + for output_dtype in [ + np.uint8, + np.uint16, + np.uint32, + np.uint64, + np.int16, + np.int32, + np.int64, + np.float16, + np.float32, + np.float64, + ]: + tf = _CombinedPixelTransformation(dcm, output_dtype=output_dtype) + assert tf._effective_slope_intercept is None + assert tf._effective_lut_data is None + assert tf._effective_window_center_width is None + assert tf._color_manager is not None + assert tf._input_range_check is None + assert not tf._invert + + input_arr = dcm.pixel_array[0] + output_arr = tf(input_arr) + assert output_arr.shape == (dcm.Rows, dcm.Columns, 3) + assert output_arr.dtype == output_dtype + + +def test_combined_transform_all_test_files(): + # A simple test that the trasnform at least does something for the default + # parameters for all images in the pydicom test suite + all_files = get_testdata_files() + + for f in all_files: + try: + dcm = pydicom.dcmread(f) + except: + continue + + if 'SOPClassUID' not in dcm: + continue + if not does_iod_have_pixel_data(dcm.SOPClassUID): + continue + + try: + pix = dcm.pixel_array + except: + continue + + tf = _CombinedPixelTransformation(dcm) + + # Crudely decide whether indexing by frame is needed + expected_dims = 3 if dcm.SamplesPerPixel > 1 else 2 + if pix.ndim > expected_dims: + pix = pix[0] + + out = tf(pix) + assert isinstance(out, np.ndarray) + + +def test_combined_transform_pmap_rwvm_lut(): + # Construct a temporary parametric map with a real world value map lut + file_path = Path(__file__) + data_dir = file_path.parent.parent.joinpath('data') + f = data_dir / 'test_files/ct_image.dcm' + source_image = pydicom.dcmread(f) + + m = RealWorldValueMapping( + lut_label='1', + lut_explanation='Feature 1', + unit=codes.UCUM.NoUnits, + value_range=(0, 255), + lut_data=[v**2 - 0.15 for v in range(256)] + ) + + pixel_array = np.zeros( + source_image.pixel_array.shape, + dtype=np.uint16 + ) + + pmap = ParametricMap( + pixel_array=pixel_array, + source_images=[source_image], + series_instance_uid=UID(), + series_number=1, + sop_instance_uid=UID(), + instance_number=1, + manufacturer='manufacturer', + manufacturer_model_name='manufacturer_model_name', + software_versions='software_versions', + device_serial_number='12345', + real_world_value_mappings=[m], + contains_recognizable_visual_features=False, + window_center=0, + window_width=100, + ) + + output_dtype = np.float64 + tf = _CombinedPixelTransformation(pmap, output_dtype=output_dtype) + assert tf._effective_lut_data is not None + assert tf._effective_lut_data.dtype == output_dtype + assert tf._effective_slope_intercept is None + assert tf._effective_window_center_width is None + assert tf._color_manager is None + assert tf._input_range_check is None + assert not tf._invert + + out = tf(pmap.pixel_array) + assert out.dtype == output_dtype + + test_arr = np.array([[0, 1], [254, 255]], np.uint16) + output_arr = tf(test_arr) + assert output_arr.dtype == output_dtype + + msg = re.escape( + "Cannot cast array data from dtype('float64') to " + "dtype('float32') according to the rule 'safe'" + ) + with pytest.raises(TypeError, match=msg): + tf = _CombinedPixelTransformation(pmap, output_dtype=np.float32) + + +def test_get_volume_multiframe_ct(): + im = imread(get_testdata_file('eCT_Supplemental.dcm')) + volume = im.get_volume() + + assert isinstance(volume, Volume) + assert volume.spatial_shape == (2, 512, 512) + assert volume.channel_shape == () + + volume = im.get_volume( + apply_voi_transform=True, + apply_real_world_transform=False + ) + assert volume.array.min() == 0.0 + assert volume.array.max() == 1.0 diff --git a/tests/test_io.py b/tests/test_io.py index 07d2504c..8e1c698e 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -6,8 +6,10 @@ from pydicom import dcmread from pydicom.data import get_testdata_file from pydicom.filebase import DicomBytesIO, DicomFileLike +import pytest from highdicom.io import ImageFileReader +from tests.utils import find_readable_images class TestImageFileReader(unittest.TestCase): @@ -212,3 +214,75 @@ def test_read_single_frame_ct_image_dicom_file_like_opened(self): reader.metadata.Columns, ) np.testing.assert_array_equal(frame, pixel_array) + + def test_read_rle_no_bot(self): + # This image is RLE compressed but has no BOT, requiring searching + # through the pixel data for delimiter tags + filename = Path(get_testdata_file('rtdose_rle.dcm')) + + dataset = dcmread(filename) + pixel_array = dataset.pixel_array + with ImageFileReader(filename) as reader: + assert reader.number_of_frames == 15 + for f in range(reader.number_of_frames): + frame = reader.read_frame(f, correct_color=False) + assert isinstance(frame, np.ndarray) + assert frame.ndim == 2 + assert frame.dtype == np.uint32 + assert frame.shape == ( + reader.metadata.Rows, + reader.metadata.Columns, + ) + np.testing.assert_array_equal(frame, pixel_array[f]) + + def test_disallow_deflated_dataset(self): + # Files with a deflated transfer + msg = ( + 'Deflated transfer syntaxes cannot be used with the ' + 'ImageFileReader.' + ) + filename = get_testdata_file('image_dfl.dcm') + + with pytest.raises(ValueError, match=msg): + with ImageFileReader(filename) as reader: + reader.read_frame(1) + + +@pytest.mark.parametrize( + 'filename', + find_readable_images(), +) +def test_all_images(filename): + dataset = dcmread(filename) + pixel_array = dataset.pixel_array + + is_color = dataset.SamplesPerPixel == 3 + number_of_frames = dataset.get('NumberOfFrames', 1) + is_multiframe = number_of_frames > 1 + + if is_color: + ndim = 3 + shape = ( + dataset.Rows, + dataset.Columns, + 3 + ) + else: + ndim = 2 + shape = ( + dataset.Rows, + dataset.Columns, + ) + + with ImageFileReader(filename) as reader: + assert reader.number_of_frames == number_of_frames + for f in range(reader.number_of_frames): + frame = reader.read_frame(f, correct_color=False) + assert isinstance(frame, np.ndarray) + assert frame.ndim == ndim + assert frame.dtype == pixel_array.dtype + assert frame.shape == shape + expected_frame = ( + pixel_array[f] if is_multiframe else pixel_array + ) + np.testing.assert_array_equal(frame, expected_frame) diff --git a/tests/test_pm.py b/tests/test_pm.py index 3538f300..37d00708 100644 --- a/tests/test_pm.py +++ b/tests/test_pm.py @@ -40,7 +40,7 @@ def test_failed_construction_missing_or_unnecessary_parameters(self): lut_label = '1' lut_explanation = 'Feature 1' unit = codes.UCUM.NoUnits - value_range = [0, 255] + value_range = (0, 255) lut_data = [v**2 for v in range(256)] intercept = 0 slope = 1 @@ -92,9 +92,9 @@ def test_construction_integer_linear_relationship(self): lut_label = '1' lut_explanation = 'Feature 1' unit = codes.UCUM.NoUnits - value_range = [0, 255] - intercept = 0 - slope = 1 + value_range = (0, 255) + intercept = 200 + slope = 10 quantity_definition = Code('130402', 'DCM', 'Class activation') m = RealWorldValueMapping( lut_label=lut_label, @@ -126,12 +126,33 @@ def test_construction_integer_linear_relationship(self): quantity_item = m.QuantityDefinitionSequence[0] assert quantity_item.name == codes.SCT.Quantity assert quantity_item.value == quantity_definition + assert not m.has_lut() + assert m.value_range == value_range + + array = np.array( + [ + [0, 0, 0], + [5, 5, 5], + [10, 10, 10], + ], + ) + expected = np.array( + [ + [200, 200, 200], + [250, 250, 250], + [300, 300, 300], + ], + ) + + out = m.apply(array) + assert np.array_equal(out, expected) + assert out.dtype == np.float64 def test_construction_integer_nonlinear_relationship(self): lut_label = '1' lut_explanation = 'Feature 1' unit = codes.UCUM.NoUnits - value_range = [0, 255] + value_range = (0, 255) lut_data = [v**2 for v in range(256)] m = RealWorldValueMapping( lut_label=lut_label, @@ -157,14 +178,35 @@ def test_construction_integer_nonlinear_relationship(self): m.RealWorldValueSlope # noqa: B018 with pytest.raises(AttributeError): m.RealWorldValueIntercept # noqa: B018 + assert m.has_lut() + assert m.value_range == value_range + + array = np.array( + [ + [0, 0, 0], + [5, 5, 5], + [10, 10, 10], + ], + ) + expected = np.array( + [ + [0, 0, 0], + [25, 25, 25], + [100, 100, 100], + ], + ) + + out = m.apply(array) + assert np.array_equal(out, expected) + assert out.dtype == np.float64 def test_construction_floating_point_linear_relationship(self): lut_label = '1' lut_explanation = 'Feature 1' unit = codes.UCUM.NoUnits - value_range = [0.0, 1.0] - intercept = 0 - slope = 1 + value_range = (-1000.0, 1000.0) + intercept = -23.13 + slope = 5.0 m = RealWorldValueMapping( lut_label=lut_label, lut_explanation=lut_explanation, @@ -190,12 +232,44 @@ def test_construction_floating_point_linear_relationship(self): m.RealWorldValueLastValueMapped # noqa: B018 with pytest.raises(AttributeError): m.RealWorldValueLUTData # noqa: B018 + assert not m.has_lut() + assert m.value_range == value_range + + array = np.array( + [ + [0, 0, 0], + [5, 5, 5], + [10, 10, 10], + ], + ) + expected = np.array( + [ + [-23.13, -23.13, -23.13], + [1.87, 1.87, 1.87], + [26.87, 26.87, 26.87], + ], + ) + + out = m.apply(array) + assert np.allclose(out, expected) + assert out.dtype == np.float64 + + invalid_array = np.array( + [ + [1200, 0, 0], + [5, 5, 5], + [10, 10, 10], + ], + ) + msg = 'Array contains value outside the valid range.' + with pytest.raises(ValueError, match=msg): + m.apply(invalid_array) def test_failed_construction_floating_point_nonlinear_relationship(self): lut_label = '1' lut_explanation = 'Feature 1' unit = codes.UCUM.NoUnits - value_range = [0.0, 1.0] + value_range = (0.0, 1.0) lut_data = [ v**2 for v in np.arange(value_range[0], value_range[1], 0.1) diff --git a/tests/test_pr.py b/tests/test_pr.py index b77007da..402a0162 100644 --- a/tests/test_pr.py +++ b/tests/test_pr.py @@ -1222,7 +1222,7 @@ def test_construction_with_copy_modality_lut(self): ) assert gsps.RescaleIntercept == self._ct_series[0].RescaleIntercept assert gsps.RescaleSlope == self._ct_series[0].RescaleSlope - assert gsps.RescaleType == 'HU' + assert gsps.RescaleType == 'US' def test_construction_with_copy_modality_lut_multiframe(self): gsps = GrayscaleSoftcopyPresentationState( diff --git a/tests/test_seg.py b/tests/test_seg.py index 084db2e4..38b5a78c 100644 --- a/tests/test_seg.py +++ b/tests/test_seg.py @@ -8,6 +8,7 @@ import warnings import numpy as np +from pydicom.multival import MultiValue import pytest from PIL import Image @@ -22,6 +23,7 @@ JPEG2000Lossless, JPEGLSLossless, ) + from highdicom import ( PaletteColorLUT, PaletteColorLUTTransformation, @@ -36,6 +38,7 @@ from highdicom.enum import ( CoordinateSystemNames, DimensionOrganizationTypeValues, + PatientOrientationValuesBiped, ) from highdicom.seg import ( create_segmentation_pyramid, @@ -49,8 +52,10 @@ SegmentationFractionalTypeValues, ) from highdicom.seg.utils import iter_segments +from highdicom.spatial import VOLUME_INDEX_CONVENTION, sort_datasets from highdicom.sr.coding import CodedConcept from highdicom.uid import UID +from highdicom.volume import Volume from .utils import write_and_read_dataset @@ -649,6 +654,31 @@ def setUp(self): ) self._ct_pixel_array[1:5, 10:15] = True + self._ct_volume_position = [4.3, 1.4, 8.7] + self._ct_volume_orientation = [1., 0., 0, 0., -1., 0.] + self._ct_volume_pixel_spacing = [1., 1.5] + self._ct_volume_slice_spacing = 3.0 + self._ct_volume_array = np.zeros((4, 12, 12)) + self._ct_volume_array[0, 1:4, 8:9] = True + self._ct_volume_array[1, 5:7, 1:4] = True + self._ct_seg_volume = Volume.from_attributes( + array=self._ct_volume_array, + image_position=self._ct_volume_position, + image_orientation=self._ct_volume_orientation, + pixel_spacing=self._ct_volume_pixel_spacing, + spacing_between_slices=self._ct_volume_slice_spacing, + frame_of_reference_uid=self._ct_image.FrameOfReferenceUID, + ) + self._ct_seg_volume_with_channels = Volume.from_attributes( + array=self._ct_volume_array[:, :, :, None], + image_position=self._ct_volume_position, + image_orientation=self._ct_volume_orientation, + pixel_spacing=self._ct_volume_pixel_spacing, + spacing_between_slices=self._ct_volume_slice_spacing, + frame_of_reference_uid=self._ct_image.FrameOfReferenceUID, + channels={'SegmentNumber': [1]}, + ) + # A single CR image self._cr_image = dcmread( get_testdata_file('dicomdirtests/77654033/CR1/6154') @@ -719,10 +749,7 @@ def setUp(self): ] # Ensure the frames are in the right spatial order # (only 3rd dimension changes) - self._ct_series = sorted( - ct_series, - key=lambda x: x.ImagePositionPatient[2] - ) + self._ct_series = sort_datasets(ct_series) # Hack around the fact that the images are too small to be encoded by # openjpeg @@ -737,7 +764,7 @@ def setUp(self): (len(self._ct_series), ) + self._ct_series[0].pixel_array.shape, dtype=bool ) - nonempty_slice = slice(1, 3) + nonempty_slice = slice(1, 4) self._ct_series_mask_array[nonempty_slice, 1:5, 7:9] = True self._ct_series_nonempty = self._ct_series[nonempty_slice] @@ -819,16 +846,31 @@ def test_data(request): @staticmethod def sort_frames(sources, mask): src = sources[0] + orientation = None if hasattr(src, 'ImageOrientationSlide'): coordinate_system = CoordinateSystemNames.SLIDE else: coordinate_system = CoordinateSystemNames.PATIENT + if 'SharedFunctionalGroupsSequence' in src: + orientation = ( + src + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + else: + orientation = src.ImageOrientationPatient + dim_index = DimensionIndexSequence(coordinate_system) if hasattr(src, 'NumberOfFrames'): plane_positions = dim_index.get_plane_positions_of_image(src) else: plane_positions = dim_index.get_plane_positions_of_series(sources) - _, index = dim_index.get_index_values(plane_positions) + _, index = dim_index.get_index_values( + plane_positions, + image_orientation=orientation, + index_convention=VOLUME_INDEX_CONVENTION + ) return mask[index, ...] @staticmethod @@ -848,7 +890,33 @@ def get_array_after_writing(instance): def check_dimension_index_vals(seg): # Function to apply some checks (necessary but not sufficient for # correctness) to ensure that the dimension indices are correct - is_patient_coord_system = hasattr( + if seg.SegmentationType != "LABELMAP": + all_segment_numbers = [] + for f in seg.PerFrameFunctionalGroupsSequence: + dim_ind_vals = f.FrameContentSequence[0].DimensionIndexValues + + if isinstance(dim_ind_vals, MultiValue): + posn_index = dim_ind_vals[0] + else: + # VM=1 so this is an int + posn_index = dim_ind_vals + + seg_number = ( + f.SegmentIdentificationSequence[0].ReferencedSegmentNumber + ) + + assert seg_number == posn_index + all_segment_numbers.append(seg_number) + + # Probably this should be strict equality and we should adjust the + # dimension indices for unused segment numbers + assert ( + set(all_segment_numbers) <= + set(range(1, max(all_segment_numbers) + 1)) + ) + + has_frame_of_reference = 'FrameOfReferenceUID' in seg + is_patient_coord_system = has_frame_of_reference and hasattr( seg.PerFrameFunctionalGroupsSequence[0], 'PlanePositionSequence' ) @@ -856,18 +924,15 @@ def check_dimension_index_vals(seg): # Build up the mapping from index to value index_mapping = defaultdict(list) for f in seg.PerFrameFunctionalGroupsSequence: - if seg.SegmentationType == "LABELMAP": - # DimensionIndexValues has VM=1 in this case so returns int - posn_index = f.FrameContentSequence[0].DimensionIndexValues + dim_ind_vals = f.FrameContentSequence[0].DimensionIndexValues + + if isinstance(dim_ind_vals, MultiValue): + posn_index = dim_ind_vals[1] else: - # DimensionIndexValues has VM>1 in this case so returns - # list - posn_index = ( - f.FrameContentSequence[0].DimensionIndexValues[1] - ) - # This is not general, but all the tests run here use axial - # images so just check the z coordinate - posn_val = f.PlanePositionSequence[0].ImagePositionPatient[2] + # VM=1 so this is an int + posn_index = dim_ind_vals + + posn_val = f.PlanePositionSequence[0].ImagePositionPatient index_mapping[posn_index].append(posn_val) # Check that each index value found references a unique value @@ -878,12 +943,18 @@ def check_dimension_index_vals(seg): expected_keys = range(1, len(index_mapping) + 1) assert set(index_mapping.keys()) == set(expected_keys) - # Check that values are sorted - old_v = float('-inf') - for k in expected_keys: - assert index_mapping[k][0] > old_v - old_v = index_mapping[k][0] - else: + # Check all three spatial dimensions are sorted one way or the + # other + for d in range(3): + values_array = np.array( + [index_mapping[k][0][d] for k in expected_keys] + ) + assert ( + (values_array[1:] >= values_array[:-1]).all() or + (values_array[1:] <= values_array[:-1]).all() + ) + + elif has_frame_of_reference: # Build up the mapping from index to value for dim_kw, dim_ind in zip( [ @@ -894,8 +965,16 @@ def check_dimension_index_vals(seg): ): index_mapping = defaultdict(list) for f in seg.PerFrameFunctionalGroupsSequence: - content_item = f.FrameContentSequence[0] - posn_index = content_item.DimensionIndexValues[dim_ind] + dim_ind_vals = f.FrameContentSequence[0].DimensionIndexValues + + if isinstance(dim_ind_vals, MultiValue): + posn_index = dim_ind_vals[dim_ind] + elif dim_ind == 0: + # VM=1 so this is an int + posn_index = dim_ind_vals + else: + assert False + posn_item = f.PlanePositionSlideSequence[0] posn_val = getattr(posn_item, dim_kw) index_mapping[posn_index].append(posn_val) @@ -1012,6 +1091,7 @@ def test_construction(self): SegmentsOverlapValues.NO with pytest.raises(AttributeError): frame_item.PlanePositionSlideSequence # noqa: B018 + assert not hasattr(instance, "DimensionOrganizationType") self.check_dimension_index_vals(instance) assert not hasattr(instance, 'ICCProfile') assert not hasattr(instance, 'RedPaletteColorLookupTableDescriptor') @@ -1092,6 +1172,7 @@ def test_construction_2(self): SegmentsOverlapValues.NO with pytest.raises(AttributeError): frame_item.PlanePositionSequence # noqa: B018 + assert instance.DimensionOrganizationType == "TILED_SPARSE" self.check_dimension_index_vals(instance) assert not hasattr(instance, 'ICCProfile') assert not hasattr(instance, 'RedPaletteColorLookupTableDescriptor') @@ -1171,6 +1252,8 @@ def test_construction_3(self): source_image_item, 'PurposeOfReferenceCodeSequence' ) + with pytest.raises(AttributeError): + frame_item.PlanePositionSlideSequence # noqa: B018 uid_to_plane_position = {} for fm in instance.PerFrameFunctionalGroupsSequence: src_img_item = fm.DerivationImageSequence[0].SourceImageSequence[0] @@ -1184,8 +1267,7 @@ def test_construction_3(self): assert source_uid_to_plane_position == uid_to_plane_position assert SegmentsOverlapValues[instance.SegmentsOverlap] == \ SegmentsOverlapValues.NO - with pytest.raises(AttributeError): - frame_item.PlanePositionSlideSequence # noqa: B018 + assert not hasattr(instance, 'DimensionOrganizationType') self.check_dimension_index_vals(instance) assert not hasattr(instance, 'ICCProfile') assert not hasattr(instance, 'RedPaletteColorLookupTableDescriptor') @@ -1274,6 +1356,8 @@ def test_construction_4(self): SegmentsOverlapValues.NO with pytest.raises(AttributeError): frame_item.PlanePositionSlideSequence # noqa: B018 + + assert hasattr(instance, 'DimensionOrganizationType') self.check_dimension_index_vals(instance) assert not hasattr(instance, 'ICCProfile') assert not hasattr(instance, 'RedPaletteColorLookupTableDescriptor') @@ -1367,6 +1451,7 @@ def test_construction_5(self): SegmentsOverlapValues.NO with pytest.raises(AttributeError): frame_item.PlanePositionSlideSequence # noqa: B018 + assert not hasattr(instance, 'DimensionOrganizationType') self.check_dimension_index_vals(instance) assert not hasattr(instance, 'ICCProfile') assert not hasattr(instance, 'RedPaletteColorLookupTableDescriptor') @@ -1462,6 +1547,7 @@ def test_construction_6(self): assert len(derivation_image_item.SourceImageSequence) == 1 assert SegmentsOverlapValues[instance.SegmentsOverlap] == \ SegmentsOverlapValues.NO + assert not hasattr(instance, 'DimensionOrganizationType') assert not hasattr(instance, 'ICCProfile') assert not hasattr(instance, 'RedPaletteColorLookupTableDescriptor') assert not hasattr(instance, 'RedPaletteColorLookupTableData') @@ -1470,6 +1556,7 @@ def test_construction_6(self): assert not hasattr(instance, 'BluePaletteColorLookupTableDescriptor') assert not hasattr(instance, 'BluePaletteColorLookupTableData') assert not hasattr(instance, 'PixelPaddingValue') + self.check_dimension_index_vals(instance) def test_construction_7(self): # A chest X-ray with no frame of reference and multiple segments @@ -1561,6 +1648,7 @@ def test_construction_7(self): assert len(derivation_image_item.SourceImageSequence) == 1 assert SegmentsOverlapValues[instance.SegmentsOverlap] == \ SegmentsOverlapValues.NO + assert not hasattr(instance, 'DimensionOrganizationType') assert not hasattr(instance, 'ICCProfile') assert not hasattr(instance, 'RedPaletteColorLookupTableDescriptor') assert not hasattr(instance, 'RedPaletteColorLookupTableData') @@ -1569,6 +1657,7 @@ def test_construction_7(self): assert not hasattr(instance, 'BluePaletteColorLookupTableDescriptor') assert not hasattr(instance, 'BluePaletteColorLookupTableData') assert not hasattr(instance, 'PixelPaddingValue') + self.check_dimension_index_vals(instance) def test_construction_8(self): # A chest X-ray with no frame of reference, LABELMAP @@ -1608,6 +1697,7 @@ def test_construction_8(self): assert not hasattr(instance, 'BluePaletteColorLookupTableDescriptor') assert not hasattr(instance, 'BluePaletteColorLookupTableData') assert instance.PixelPaddingValue == 0 + self.check_dimension_index_vals(instance) def test_construction_9(self): # A label with a palette color LUT @@ -1636,6 +1726,7 @@ def test_construction_9(self): assert hasattr(instance, 'BluePaletteColorLookupTableDescriptor') assert hasattr(instance, 'BluePaletteColorLookupTableData') assert instance.PixelPaddingValue == 0 + self.check_dimension_index_vals(instance) def test_construction_10(self): # A labelmap with a palette color LUT and ICC Profile @@ -1665,6 +1756,7 @@ def test_construction_10(self): assert hasattr(instance, 'BluePaletteColorLookupTableDescriptor') assert hasattr(instance, 'BluePaletteColorLookupTableData') assert instance.PixelPaddingValue == 0 + self.check_dimension_index_vals(instance) def test_construction_large_labelmap_monochrome(self): n_classes = 300 # force 16 bit @@ -1709,6 +1801,7 @@ def test_construction_large_labelmap_monochrome(self): assert not hasattr(instance, 'BluePaletteColorLookupTableDescriptor') assert not hasattr(instance, 'BluePaletteColorLookupTableData') assert instance.pixel_array.dtype == np.uint16 + self.check_dimension_index_vals(instance) arr = self.get_array_after_writing(instance) assert arr.dtype == np.uint16 @@ -1772,9 +1865,447 @@ def test_construction_large_labelmap_palettecolor(self): assert hasattr(instance, 'BluePaletteColorLookupTableDescriptor') assert hasattr(instance, 'BluePaletteColorLookupTableData') assert instance.pixel_array.dtype == np.uint16 + self.check_dimension_index_vals(instance) arr = self.get_array_after_writing(instance) assert arr.dtype == np.uint16 + def test_construction_volume(self): + # Segmentation instance from a series of single-frame CT images + # with empty frames kept in + instance = Segmentation( + [self._ct_image], + self._ct_seg_volume, + SegmentationTypeValues.BINARY.value, + self._segment_descriptions, + self._series_instance_uid, + self._series_number, + self._sop_instance_uid, + self._instance_number, + self._manufacturer, + self._manufacturer_model_name, + self._software_versions, + self._device_serial_number, + omit_empty_frames=False + ) + assert np.array_equal( + instance.pixel_array, + self._ct_seg_volume.array, + ) + + self.check_dimension_index_vals(instance) + assert instance.DimensionOrganizationType == '3D' + shared_item = instance.SharedFunctionalGroupsSequence[0] + assert len(shared_item.PixelMeasuresSequence) == 1 + pm_item = shared_item.PixelMeasuresSequence[0] + assert pm_item.PixelSpacing == self._ct_volume_pixel_spacing + assert pm_item.SliceThickness == self._ct_volume_slice_spacing + assert len(shared_item.PlaneOrientationSequence) == 1 + po_item = shared_item.PlaneOrientationSequence[0] + assert po_item.ImageOrientationPatient == \ + self._ct_volume_orientation + for plane_item, pp in zip( + instance.PerFrameFunctionalGroupsSequence, + self._ct_seg_volume.get_plane_positions(), + ): + assert ( + plane_item.PlanePositionSequence[0].ImagePositionPatient == + pp[0].ImagePositionPatient + ) + + def test_construction_volume_channels(self): + # Segmentation instance from a series of single-frame CT images + # with empty frames kept in, as volume with channels + instance = Segmentation( + [self._ct_image], + self._ct_seg_volume_with_channels, + SegmentationTypeValues.BINARY.value, + self._segment_descriptions, + self._series_instance_uid, + self._series_number, + self._sop_instance_uid, + self._instance_number, + self._manufacturer, + self._manufacturer_model_name, + self._software_versions, + self._device_serial_number, + omit_empty_frames=False + ) + assert np.array_equal( + instance.pixel_array, + self._ct_seg_volume.array, + ) + + self.check_dimension_index_vals(instance) + assert instance.DimensionOrganizationType == '3D' + shared_item = instance.SharedFunctionalGroupsSequence[0] + assert len(shared_item.PixelMeasuresSequence) == 1 + pm_item = shared_item.PixelMeasuresSequence[0] + assert pm_item.PixelSpacing == self._ct_volume_pixel_spacing + assert pm_item.SliceThickness == self._ct_volume_slice_spacing + assert len(shared_item.PlaneOrientationSequence) == 1 + po_item = shared_item.PlaneOrientationSequence[0] + assert po_item.ImageOrientationPatient == \ + self._ct_volume_orientation + for plane_item, pp in zip( + instance.PerFrameFunctionalGroupsSequence, + self._ct_seg_volume.get_plane_positions(), + ): + assert ( + plane_item.PlanePositionSequence[0].ImagePositionPatient == + pp[0].ImagePositionPatient + ) + + def test_construction_volume_channels_invalid_channel_id(self): + # Make a new correctly shaped volume whose channels do not represent + # segments + new_volume = self._ct_seg_volume_with_channels.with_array( + self._ct_seg_volume_with_channels.array, + channels={'FrameLabel': ['label']} + ) + msg = ( + "Input volume should have no channels other than 'SegmentNumber'." + ) + with pytest.raises(ValueError, match=msg): + Segmentation( + [self._ct_image], + new_volume, + SegmentationTypeValues.BINARY.value, + self._segment_descriptions, + self._series_instance_uid, + self._series_number, + self._sop_instance_uid, + self._instance_number, + self._manufacturer, + self._manufacturer_model_name, + self._software_versions, + self._device_serial_number, + omit_empty_frames=False + ) + + def test_construction_volume_channels_invalid_channel_values(self): + # Make a new correctly shaped volume whose segment numbers do not + # match the descriptions + new_volume = self._ct_seg_volume_with_channels.with_array( + self._ct_seg_volume_with_channels.array, + channels={'SegmentNumber': [15]} + ) + msg = ( + "Segment numbers in the input volume do not match " + "the described segments." + ) + with pytest.raises(ValueError, match=msg): + Segmentation( + [self._ct_image], + new_volume, + SegmentationTypeValues.BINARY.value, + self._segment_descriptions, + self._series_instance_uid, + self._series_number, + self._sop_instance_uid, + self._instance_number, + self._manufacturer, + self._manufacturer_model_name, + self._software_versions, + self._device_serial_number, + omit_empty_frames=False + ) + + def test_construction_volume_fractional(self): + # Segmentation instance from a series of single-frame CT images + # with empty frames kept in + instance = Segmentation( + [self._ct_image], + self._ct_seg_volume, + SegmentationTypeValues.FRACTIONAL.value, + self._segment_descriptions, + self._series_instance_uid, + self._series_number, + self._sop_instance_uid, + self._instance_number, + self._manufacturer, + self._manufacturer_model_name, + self._software_versions, + self._device_serial_number, + max_fractional_value=1, + omit_empty_frames=False + ) + assert np.array_equal( + instance.pixel_array, + self._ct_seg_volume.array, + ) + self.check_dimension_index_vals(instance) + + assert instance.DimensionOrganizationType == '3D' + shared_item = instance.SharedFunctionalGroupsSequence[0] + assert len(shared_item.PixelMeasuresSequence) == 1 + pm_item = shared_item.PixelMeasuresSequence[0] + assert pm_item.PixelSpacing == self._ct_volume_pixel_spacing + assert pm_item.SliceThickness == self._ct_volume_slice_spacing + assert len(shared_item.PlaneOrientationSequence) == 1 + po_item = shared_item.PlaneOrientationSequence[0] + assert po_item.ImageOrientationPatient == \ + self._ct_volume_orientation + for plane_item, pp in zip( + instance.PerFrameFunctionalGroupsSequence, + self._ct_seg_volume.get_plane_positions(), + ): + assert ( + plane_item.PlanePositionSequence[0].ImagePositionPatient == + pp[0].ImagePositionPatient + ) + + def test_construction_volume_fractional_channels(self): + # Segmentation instance from a series of single-frame CT images + # with empty frames kept in, as a volume with channels + instance = Segmentation( + [self._ct_image], + self._ct_seg_volume_with_channels, + SegmentationTypeValues.FRACTIONAL.value, + self._segment_descriptions, + self._series_instance_uid, + self._series_number, + self._sop_instance_uid, + self._instance_number, + self._manufacturer, + self._manufacturer_model_name, + self._software_versions, + self._device_serial_number, + max_fractional_value=1, + omit_empty_frames=False + ) + assert np.array_equal( + instance.pixel_array, + self._ct_seg_volume.array, + ) + + assert instance.DimensionOrganizationType == '3D' + shared_item = instance.SharedFunctionalGroupsSequence[0] + assert len(shared_item.PixelMeasuresSequence) == 1 + pm_item = shared_item.PixelMeasuresSequence[0] + assert pm_item.PixelSpacing == self._ct_volume_pixel_spacing + assert pm_item.SliceThickness == self._ct_volume_slice_spacing + assert len(shared_item.PlaneOrientationSequence) == 1 + po_item = shared_item.PlaneOrientationSequence[0] + assert po_item.ImageOrientationPatient == \ + self._ct_volume_orientation + for plane_item, pp in zip( + instance.PerFrameFunctionalGroupsSequence, + self._ct_seg_volume.get_plane_positions(), + ): + assert ( + plane_item.PlanePositionSequence[0].ImagePositionPatient == + pp[0].ImagePositionPatient + ) + self.check_dimension_index_vals(instance) + + def test_construction_volume_labelmap(self): + # Segmentation instance from a series of single-frame CT images + # with empty frames kept in + instance = Segmentation( + [self._ct_image], + self._ct_seg_volume, + SegmentationTypeValues.LABELMAP, + self._segment_descriptions, + self._series_instance_uid, + self._series_number, + self._sop_instance_uid, + self._instance_number, + self._manufacturer, + self._manufacturer_model_name, + self._software_versions, + self._device_serial_number, + max_fractional_value=1, + omit_empty_frames=False + ) + assert np.array_equal( + instance.pixel_array, + self._ct_seg_volume.array, + ) + + assert instance.DimensionOrganizationType == '3D' + shared_item = instance.SharedFunctionalGroupsSequence[0] + assert len(shared_item.PixelMeasuresSequence) == 1 + pm_item = shared_item.PixelMeasuresSequence[0] + assert pm_item.PixelSpacing == self._ct_volume_pixel_spacing + assert pm_item.SliceThickness == self._ct_volume_slice_spacing + assert len(shared_item.PlaneOrientationSequence) == 1 + po_item = shared_item.PlaneOrientationSequence[0] + assert po_item.ImageOrientationPatient == \ + self._ct_volume_orientation + for plane_item, pp in zip( + instance.PerFrameFunctionalGroupsSequence, + self._ct_seg_volume.get_plane_positions(), + ): + assert ( + plane_item.PlanePositionSequence[0].ImagePositionPatient == + pp[0].ImagePositionPatient + ) + self.check_dimension_index_vals(instance) + + def test_construction_volume_labelmap_channels(self): + # Segmentation instance from a series of single-frame CT images + # with empty frames kept in, as a volume with channels + instance = Segmentation( + [self._ct_image], + self._ct_seg_volume_with_channels, + SegmentationTypeValues.LABELMAP, + self._segment_descriptions, + self._series_instance_uid, + self._series_number, + self._sop_instance_uid, + self._instance_number, + self._manufacturer, + self._manufacturer_model_name, + self._software_versions, + self._device_serial_number, + max_fractional_value=1, + omit_empty_frames=False + ) + assert np.array_equal( + instance.pixel_array, + self._ct_seg_volume.array, + ) + + assert instance.DimensionOrganizationType == '3D' + shared_item = instance.SharedFunctionalGroupsSequence[0] + assert len(shared_item.PixelMeasuresSequence) == 1 + pm_item = shared_item.PixelMeasuresSequence[0] + assert pm_item.PixelSpacing == self._ct_volume_pixel_spacing + assert pm_item.SliceThickness == self._ct_volume_slice_spacing + assert len(shared_item.PlaneOrientationSequence) == 1 + po_item = shared_item.PlaneOrientationSequence[0] + assert po_item.ImageOrientationPatient == \ + self._ct_volume_orientation + for plane_item, pp in zip( + instance.PerFrameFunctionalGroupsSequence, + self._ct_seg_volume.get_plane_positions(), + ): + assert ( + plane_item.PlanePositionSequence[0].ImagePositionPatient == + pp[0].ImagePositionPatient + ) + self.check_dimension_index_vals(instance) + + + def test_construction_3d_multiframe(self): + # The CT multiframe image is already a volume, but the frames are + # ordered the wrong way + volume_multiframe = deepcopy(self._ct_multiframe) + positions = [ + fm.PlanePositionSequence[0].ImagePositionPatient + for fm in volume_multiframe.PerFrameFunctionalGroupsSequence + ] + positions = positions[::-1] + for pos, fm in zip( + positions, + volume_multiframe.PerFrameFunctionalGroupsSequence + ): + fm.PlanePositionSequence[0].ImagePositionPatient = pos + + # Segmentation instance from an enhanced (multi-frame) CT image + instance = Segmentation( + [volume_multiframe], + self._ct_multiframe_mask_array, + SegmentationTypeValues.FRACTIONAL.value, + self._segment_descriptions, + self._series_instance_uid, + self._series_number, + self._sop_instance_uid, + self._instance_number, + self._manufacturer, + self._manufacturer_model_name, + self._software_versions, + self._device_serial_number + ) + # This is a "volume" image, so the output instance should have + # the DimensionOrganizationType set correctly and should have deduced + # the spacing between slices + assert instance.DimensionOrganizationType == "3D" + spacing = ( + instance + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .SpacingBetweenSlices + ) + assert spacing == 10.0 + self.check_dimension_index_vals(instance) + + def test_construction_3d_singleframe(self): + # The CT single frame series is a volume if you omit one of the images + ct_series = self._ct_series[:3] + + # Segmentation instance series of CT images + instance = Segmentation( + ct_series, + self._ct_series_mask_array[:3], + SegmentationTypeValues.FRACTIONAL.value, + self._segment_descriptions, + self._series_instance_uid, + self._series_number, + self._sop_instance_uid, + self._instance_number, + self._manufacturer, + self._manufacturer_model_name, + self._software_versions, + self._device_serial_number, + ) + # This is a "volume" image, so the output instance should have + # the DimensionOrganizationType set correctly and should have deduced + # the spacing between slices + assert instance.DimensionOrganizationType == "3D" + spacing = ( + instance + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .SpacingBetweenSlices + ) + assert spacing == 1.25 + self.check_dimension_index_vals(instance) + + def test_construction_3d_singleframe_multisegment(self): + # The CT single frame series is a volume, but with multiple segments, + # it should no longer have "3D" dimension organization unless using + # LABELMAP + ct_series = self._ct_series[:3] + + mask = self._ct_series_mask_array[:3] + multi_segment_exc = np.stack([mask, np.logical_not(mask)], axis=-1) + + for segmentation_type in ["BINARY", "FRACTIONAL", "LABELMAP"]: + # Segmentation instance series of CT images + instance = Segmentation( + ct_series, + multi_segment_exc, + segmentation_type, + self._both_segment_descriptions, + self._series_instance_uid, + self._series_number, + self._sop_instance_uid, + self._instance_number, + self._manufacturer, + self._manufacturer_model_name, + self._software_versions, + self._device_serial_number, + ) + # This is a "volume" image, so the output instance should have + # the DimensionOrganizationType set correctly and should have deduced + # the spacing between slices + spacing = ( + instance + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .SpacingBetweenSlices + ) + assert spacing == 1.25 + if segmentation_type == "LABELMAP": + # Since there is no segment dimension, a labelmap seg is 3D + assert instance.DimensionOrganizationType == "3D" + else: + # The segment dimension means that otherwise the segmentation + # is not a simple spatial stack + assert 'DimensionOrganizationType' not in instance + self.check_dimension_index_vals(instance) + def test_construction_workers(self): # Create a segmentation with multiple workers Segmentation( @@ -1885,6 +2416,7 @@ def test_construction_further_source_images(self): assert len(instance.ReferencedSeriesSequence) == 2 further_item = instance.ReferencedSeriesSequence[1] assert further_item.SeriesInstanceUID == series_uid + self.check_dimension_index_vals(instance) @staticmethod @pytest.fixture( @@ -2028,11 +2560,6 @@ def test_construction_autotile( self.get_array_after_writing(instance) assert len(w) == 0 - # TODO remove this after implementing full "reconstruction" - # of LABELMAP segmentation arrays - if segmentation_type == SegmentationTypeValues.LABELMAP: - continue - # Check that full reconstructed array matches the input reconstructed_array = instance.get_total_pixel_matrix( combine_segments=True, @@ -2045,6 +2572,8 @@ def test_construction_autotile( reconstructed_array, pixel_array, ) + if dimension_organization_type.value != "TILED_FULL": + self.check_dimension_index_vals(instance) def to_numpy(c): # Move from our 1-based convention to numpy zero based @@ -2265,6 +2794,7 @@ def test_pixel_types_binary( multi_segment_exc = multi_segment_exc[np.newaxis, ...] additional_mask = 1 - mask + additional_mask = (1 - mask) # Find the expected encodings for the masks if mask.ndim > 2: # Expected encoding of the mask @@ -2638,7 +3168,7 @@ def test_construction_empty_source_seg_sparse(self): # Encoding an empty segmentation with omit_empty_frames=True issues # a warning and encodes the full segmentation empty_pixel_array = np.zeros_like(self._ct_pixel_array) - seg = Segmentation( + instance = Segmentation( source_images=[self._ct_image], pixel_array=empty_pixel_array, segmentation_type=SegmentationTypeValues.FRACTIONAL.value, @@ -2656,7 +3186,8 @@ def test_construction_empty_source_seg_sparse(self): omit_empty_frames=True, ) - assert seg.pixel_array.shape == empty_pixel_array.shape + assert instance.pixel_array.shape == empty_pixel_array.shape + self.check_dimension_index_vals(instance) def test_construction_empty_seg_image(self): # Can encode an empty segmentation with omit_empty_frames=False @@ -2787,7 +3318,11 @@ def test_construction_stacked_label_map(self): ) def test_construction_segment_numbers_start_wrong(self): - with pytest.raises(ValueError): + msg = ( + 'Segment descriptions should be numbered starting from 1. ' + 'Found 2.' + ) + with pytest.raises(ValueError, match=msg): Segmentation( source_images=[self._ct_image], pixel_array=self._ct_pixel_array, @@ -2805,6 +3340,48 @@ def test_construction_segment_numbers_start_wrong(self): device_serial_number=self._device_serial_number ) + def test_construction_segment_numbers_start_wrong_labelmap(self): + # Labelmaps have fewer restrictions on segment numbers + array = (self._ct_pixel_array * 2).astype(np.uint8) + instance = Segmentation( + source_images=[self._ct_image], + pixel_array=array, + segmentation_type=SegmentationTypeValues.LABELMAP, + segment_descriptions=( + self._additional_segment_descriptions # seg num 2 + ), + series_instance_uid=self._series_instance_uid, + series_number=self._series_number, + sop_instance_uid=self._sop_instance_uid, + instance_number=self._instance_number, + manufacturer=self._manufacturer, + manufacturer_model_name=self._manufacturer_model_name, + software_versions=self._software_versions, + device_serial_number=self._device_serial_number + ) + assert len(instance.SegmentSequence) == 2 + self.check_dimension_index_vals(instance) + + array_nonmatching = (self._ct_pixel_array * 4).astype(np.uint8) + msg = 'Pixel array contains segments that lack descriptions.' + with pytest.raises(ValueError, match=msg): + Segmentation( + source_images=[self._ct_image], + pixel_array=array_nonmatching, + segmentation_type=SegmentationTypeValues.LABELMAP, + segment_descriptions=( + self._additional_segment_descriptions # seg num 2 + ), + series_instance_uid=self._series_instance_uid, + series_number=self._series_number, + sop_instance_uid=self._sop_instance_uid, + instance_number=self._instance_number, + manufacturer=self._manufacturer, + manufacturer_model_name=self._manufacturer_model_name, + software_versions=self._software_versions, + device_serial_number=self._device_serial_number + ) + def test_construction_empty_invalid_floats(self): # Floats outside the range 0.0 to 1.0 are invalid with pytest.raises(ValueError): @@ -2906,7 +3483,8 @@ def test_construction_duplicate_segment_number(self): ) def test_construction_non_described_segment(self): - with pytest.raises(ValueError): + msg = 'Pixel array contains segments that lack descriptions.' + with pytest.raises(ValueError, match=msg): Segmentation( source_images=[self._ct_image], pixel_array=(self._ct_pixel_array * 3).astype(np.uint8), @@ -3334,6 +3912,20 @@ def setUp(self): self._sm_control_seg_ds ) + self._sm_control_labelmap_seg_ds = dcmread( + 'data/test_files/seg_image_sm_control_labelmap.dcm' + ) + self._sm_control_labelmap_seg = Segmentation.from_dataset( + self._sm_control_labelmap_seg_ds + ) + + self._sm_control_labelmap_palette_color_seg_ds = dcmread( + 'data/test_files/seg_image_sm_control_labelmap_palette_color.dcm' + ) + self._sm_control_labelmap_palette_color_seg = Segmentation.from_dataset( + self._sm_control_labelmap_palette_color_seg_ds + ) + self._ct_binary_seg_ds = dcmread( 'data/test_files/seg_image_ct_binary.dcm' ) @@ -3379,6 +3971,7 @@ def setUp(self): @staticmethod @pytest.fixture( params=[ + bool, np.int8, np.uint8, np.int16, @@ -3405,6 +3998,11 @@ def combine_segments(request): def relabel(request): return request.param + @staticmethod + @pytest.fixture(params=['_sm_control_seg', '_sm_control_labelmap_seg']) + def seg_attr_name(request): + return request.param + def test_from_dataset(self): assert isinstance(self._sm_control_seg, Segmentation) @@ -3424,6 +4022,12 @@ def test_segread(self): assert isinstance(seg, Segmentation) seg = segread('data/test_files/seg_image_sm_dots_tiled_full.dcm') assert isinstance(seg, Segmentation) + seg = segread('data/test_files/seg_image_sm_control_labelmap.dcm') + assert isinstance(seg, Segmentation) + seg = segread( + 'data/test_files/seg_image_sm_control_labelmap_palette_color.dcm' + ) + assert isinstance(seg, Segmentation) def test_properties(self): # SM segs @@ -3478,6 +4082,19 @@ def test_get_segment_description(self): assert isinstance(desc20, SegmentDescription) assert desc20.segment_number == 20 + def test_get_segment_description_non_consecutive(self): + out_of_order = deepcopy(self._sm_control_seg) + out_of_order.SegmentSequence = [ + out_of_order.SegmentSequence[i] + for i in range(out_of_order.number_of_segments -1, -1, -1) + ] + desc1 = out_of_order.get_segment_description(1) + desc20 = out_of_order.get_segment_description(20) + assert isinstance(desc1, SegmentDescription) + assert desc1.segment_number == 1 + assert isinstance(desc20, SegmentDescription) + assert desc20.segment_number == 20 + def test_get_segment_numbers_no_filters(self): seg_nums = self._sm_control_seg.get_segment_numbers() assert seg_nums == list(self._sm_control_seg.segment_numbers) @@ -3655,6 +4272,7 @@ def test_get_pixels_by_source_frames_combine(self): def test_get_pixels_with_dtype( self, + seg_attr_name, numpy_dtype, combine_segments, relabel, @@ -3662,28 +4280,106 @@ def test_get_pixels_with_dtype( source_sop_uid = self._sm_control_seg.get_source_image_uids()[0][-1] source_frames_valid = [1, 2, 4, 5] - seg = self._sm_control_seg - pixels = seg.get_pixels_by_source_frame( - source_sop_instance_uid=source_sop_uid, - source_frame_numbers=source_frames_valid, - segment_numbers=[1, 4, 9], - combine_segments=combine_segments, - relabel=relabel, - dtype=numpy_dtype, - ) - assert pixels.dtype == numpy_dtype - if combine_segments: - expected_shape = (len(source_frames_valid), seg.Rows, seg.Columns) - if relabel: - expected_vals = np.array([0, 3]) # only seg 9 in these frames + seg = getattr(self, seg_attr_name) + + if numpy_dtype == bool and combine_segments: + max_val = 3 if relabel else 9 + msg = ( + "The maximum output value of the segmentation array is " + f"{max_val}, which is too large be represented using dtype " + f"bool." + ) + with pytest.raises(ValueError, match=msg): + seg.get_pixels_by_source_frame( + source_sop_instance_uid=source_sop_uid, + source_frame_numbers=source_frames_valid, + segment_numbers=[1, 4, 9], + combine_segments=combine_segments, + relabel=relabel, + dtype=numpy_dtype, + ) + else: + pixels = seg.get_pixels_by_source_frame( + source_sop_instance_uid=source_sop_uid, + source_frame_numbers=source_frames_valid, + segment_numbers=[1, 4, 9], + combine_segments=combine_segments, + relabel=relabel, + dtype=numpy_dtype, + ) + assert pixels.dtype == numpy_dtype + if combine_segments: + expected_shape = ( + len(source_frames_valid), seg.Rows, seg.Columns + ) + if relabel: + # only seg 9 in these frames + expected_vals = np.array([0, 3]) + else: + # only seg 9 in these frames + expected_vals = np.array([0, 9]) else: - expected_vals = np.array([0, 9]) # only seg 9 in these frames + expected_shape = ( + len(source_frames_valid), seg.Rows, seg.Columns, 3 + ) + expected_vals = np.array([0, 1]) + assert pixels.shape == expected_shape assert np.array_equal(np.unique(pixels), expected_vals) + + def test_get_total_pixel_matrix_with_dtype( + self, + seg_attr_name, + numpy_dtype, + combine_segments, + relabel, + ): + seg = getattr(self, seg_attr_name) + subregion_rows = 30 + subregion_columns = 30 + + if numpy_dtype == bool and combine_segments: + max_val = 3 if relabel else 9 + msg = ( + "The maximum output value of the segmentation array is " + f"{max_val}, which is too large be represented using dtype " + f"bool." + ) + with pytest.raises(ValueError, match=msg): + seg.get_total_pixel_matrix( + segment_numbers=[1, 4, 9], + row_end=1 + subregion_rows, + column_end=1 + subregion_columns, + combine_segments=combine_segments, + relabel=relabel, + dtype=numpy_dtype, + ) else: - expected_shape = ( - len(source_frames_valid), seg.Rows, seg.Columns, 3 + pixels = seg.get_total_pixel_matrix( + row_end=1 + subregion_rows, + column_end=1 + subregion_columns, + segment_numbers=[1, 4, 9], + combine_segments=combine_segments, + relabel=relabel, + dtype=numpy_dtype, ) - assert pixels.shape == expected_shape + assert pixels.dtype == numpy_dtype + if combine_segments: + expected_shape = ( + subregion_rows, subregion_columns, + ) + if relabel: + # only seg 9 in these frames + expected_vals = np.array([0, 3]) + else: + # only seg 9 in these frames + expected_vals = np.array([0, 9]) + else: + expected_shape = ( + subregion_rows, subregion_columns, 3 + ) + expected_vals = np.array([0, 1]) + assert pixels.shape == expected_shape + assert np.array_equal(np.unique(pixels), expected_vals) def test_get_default_dimension_index_pointers(self): ptrs = self._sm_control_seg.get_default_dimension_index_pointers() @@ -4144,6 +4840,532 @@ def test_get_pixels_by_source_instances_overlap_no_checks(self): ) assert np.array_equal(expected_array, out) + def test_allow_missing_frames(self): + all_source_sop_uids = [ + tup[-1] for tup in + self._ct_binary_overlap_seg.get_source_image_uids() + ] + source_sop_uids = all_source_sop_uids + + # There are no missing frames when indexing with this limited number of + # source UIDs + pixels = self._ct_binary_overlap_seg.get_pixels_by_source_instance( + source_sop_instance_uids=source_sop_uids, + allow_missing_frames=False, + ) + + # There are missing frames when indexing by volume position + msg = ( + 'The requested set of frames includes frames that ' + 'are missing from the image. You may need to allow ' + 'missing frames or add additional filters.' + ) + with pytest.raises(RuntimeError, match=msg): + self._ct_binary_overlap_seg.get_volume( + allow_missing_frames=False, + ) + + def test_get_volume_binary(self): + vol = self._ct_binary_seg.get_volume() + assert isinstance(vol, Volume) + assert vol.spatial_shape == (3, 16, 16) + assert vol.shape == (3, 16, 16, 1) + assert vol.pixel_spacing == tuple( + self._ct_binary_seg + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + assert vol.spacing_between_slices == ( + self._ct_binary_seg.volume_geometry.spacing_between_slices + ) + assert vol.direction_cosines == tuple( + self._ct_binary_seg + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + assert vol.get_closest_patient_orientation() == ( + PatientOrientationValuesBiped.F, + PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.L, + ) + + def test_get_volume_binary_multisegments(self): + vol = self._ct_binary_overlap_seg.get_volume() + assert isinstance(vol, Volume) + # Note that this segmentation has a large number of missing slices + assert vol.spatial_shape == (165, 16, 16) + assert vol.shape == (165, 16, 16, 2) + assert vol.pixel_spacing == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + assert vol.spacing_between_slices == ( + self._ct_binary_overlap_seg.volume_geometry.spacing_between_slices + ) + assert vol.direction_cosines == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + assert vol.get_closest_patient_orientation() == ( + PatientOrientationValuesBiped.F, + PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.L, + ) + + def test_get_volume_binary_multisegment2(self): + vol = self._ct_binary_overlap_seg.get_volume(segment_numbers=[2]) + assert isinstance(vol, Volume) + # Note that this segmentation has a large number of missing slices + assert vol.spatial_shape == (165, 16, 16) + assert vol.shape == (165, 16, 16, 1) + assert vol.pixel_spacing == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + assert vol.spacing_between_slices == ( + self._ct_binary_overlap_seg.volume_geometry.spacing_between_slices + ) + assert vol.direction_cosines == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + assert vol.get_closest_patient_orientation() == ( + PatientOrientationValuesBiped.F, + PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.L, + ) + + def test_get_volume_binary_multisegment_combine(self): + vol = self._ct_binary_overlap_seg.get_volume( + combine_segments=True, + skip_overlap_checks=True, + ) + assert isinstance(vol, Volume) + # Note that this segmentation has a large number of missing slices + assert vol.spatial_shape == (165, 16, 16) + assert vol.shape == (165, 16, 16) + assert vol.pixel_spacing == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + assert vol.spacing_between_slices == ( + self._ct_binary_overlap_seg.volume_geometry.spacing_between_slices + ) + assert vol.direction_cosines == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + assert vol.get_closest_patient_orientation() == ( + PatientOrientationValuesBiped.F, + PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.L, + ) + + def test_get_volume_binary_multisegment_slice_start(self): + vol = self._ct_binary_overlap_seg.get_volume( + slice_start=160, + ) + assert isinstance(vol, Volume) + # Note that this segmentation has a large number of missing slices + assert vol.spatial_shape == (5, 16, 16) + assert vol.shape == (5, 16, 16, 2) + assert vol.pixel_spacing == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + assert vol.spacing_between_slices == ( + self._ct_binary_overlap_seg.volume_geometry.spacing_between_slices + ) + assert vol.direction_cosines == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + assert vol.get_closest_patient_orientation() == ( + PatientOrientationValuesBiped.F, + PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.L, + ) + + def test_get_volume_binary_multisegment_slice_start_negative(self): + vol = self._ct_binary_overlap_seg.get_volume( + slice_start=-6, + ) + assert isinstance(vol, Volume) + # Note that this segmentation has a large number of missing slices + assert vol.spatial_shape == (6, 16, 16) + assert vol.shape == (6, 16, 16, 2) + assert vol.pixel_spacing == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + assert vol.spacing_between_slices == ( + self._ct_binary_overlap_seg.volume_geometry.spacing_between_slices + ) + assert vol.direction_cosines == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + assert vol.get_closest_patient_orientation() == ( + PatientOrientationValuesBiped.F, + PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.L, + ) + + def test_get_volume_binary_multisegment_slice_end(self): + vol = self._ct_binary_overlap_seg.get_volume( + slice_end=17, + ) + assert isinstance(vol, Volume) + # Note that this segmentation has a large number of missing slices + assert vol.spatial_shape == (17, 16, 16) + assert vol.shape == (17, 16, 16, 2) + assert vol.pixel_spacing == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + assert vol.spacing_between_slices == ( + self._ct_binary_overlap_seg.volume_geometry.spacing_between_slices + ) + assert vol.direction_cosines == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + assert vol.get_closest_patient_orientation() == ( + PatientOrientationValuesBiped.F, + PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.L, + ) + + def test_get_volume_binary_multisegment_slice_end_negative(self): + vol = self._ct_binary_overlap_seg.get_volume( + slice_end=-10, + ) + assert isinstance(vol, Volume) + # Note that this segmentation has a large number of missing slices + assert vol.spatial_shape == (155, 16, 16) + assert vol.shape == (155, 16, 16, 2) + assert vol.pixel_spacing == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + assert vol.spacing_between_slices == ( + self._ct_binary_overlap_seg.volume_geometry.spacing_between_slices + ) + assert vol.direction_cosines == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + assert vol.get_closest_patient_orientation() == ( + PatientOrientationValuesBiped.F, + PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.L, + ) + + def test_get_volume_binary_multisegment_center(self): + vol = self._ct_binary_overlap_seg.get_volume( + slice_start=50, + slice_end=57, + ) + assert isinstance(vol, Volume) + # Note that this segmentation has a large number of missing slices + assert vol.spatial_shape == (7, 16, 16) + assert vol.shape == (7, 16, 16, 2) + assert vol.pixel_spacing == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + assert vol.spacing_between_slices == ( + self._ct_binary_seg.volume_geometry.spacing_between_slices + ) + assert vol.direction_cosines == tuple( + self._ct_binary_overlap_seg + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + assert vol.get_closest_patient_orientation() == ( + PatientOrientationValuesBiped.F, + PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.L, + ) + + def test_get_volume_binary_combine(self): + vol = self._ct_binary_seg.get_volume(combine_segments=True) + assert isinstance(vol, Volume) + assert vol.spatial_shape == (3, 16, 16) + assert vol.shape == (3, 16, 16) + assert vol.pixel_spacing == tuple( + self._ct_binary_seg + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + assert vol.spacing_between_slices == ( + self._ct_binary_seg.volume_geometry.spacing_between_slices + ) + assert vol.direction_cosines == tuple( + self._ct_binary_seg + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + assert vol.get_closest_patient_orientation() == ( + PatientOrientationValuesBiped.F, + PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.L, + ) + + def test_get_volume_fractional(self): + vol = self._ct_true_fractional_seg.get_volume() + assert isinstance(vol, Volume) + assert vol.spatial_shape == (3, 16, 16) + assert vol.shape == (3, 16, 16, 1) + assert vol.pixel_spacing == tuple( + self._ct_true_fractional_seg + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + assert vol.spacing_between_slices == ( + self._ct_true_fractional_seg.volume_geometry.spacing_between_slices + ) + assert vol.direction_cosines == tuple( + self._ct_true_fractional_seg + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + assert vol.get_closest_patient_orientation() == ( + PatientOrientationValuesBiped.F, + PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.L, + ) + assert vol.dtype == np.float32 + + def test_get_volume_fractional_noscale(self): + vol = self._ct_true_fractional_seg.get_volume(rescale_fractional=False) + assert isinstance(vol, Volume) + assert vol.spatial_shape == (3, 16, 16) + assert vol.shape == (3, 16, 16, 1) + assert vol.pixel_spacing == tuple( + self._ct_true_fractional_seg + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + assert vol.spacing_between_slices == ( + self._ct_true_fractional_seg.volume_geometry.spacing_between_slices + ) + assert vol.direction_cosines == tuple( + self._ct_true_fractional_seg + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + assert vol.get_closest_patient_orientation() == ( + PatientOrientationValuesBiped.F, + PatientOrientationValuesBiped.P, + PatientOrientationValuesBiped.L, + ) + assert vol.dtype == np.uint8 + + def test_total_pixel_matrix_palette_color(self): + seg = self._sm_control_labelmap_palette_color_seg + + pixels = seg.get_total_pixel_matrix( + combine_segments=True, + apply_palette_color_lut=True, + ) + assert pixels.shape == ( + seg.TotalPixelMatrixRows, + seg.TotalPixelMatrixColumns, + 3 + ) + + msg = ( + "apply_palette_color_lut' requires that 'combine_segments' is " + "True and relabel is False." + ) + with pytest.raises(ValueError, match=msg): + # Requires combine_segments + seg.get_total_pixel_matrix(apply_palette_color_lut=True) + + msg = ( + "apply_palette_color_lut' requires that 'combine_segments' is " + "True and relabel is False." + ) + with pytest.raises(ValueError, match=msg): + # Requires relabel false + seg.get_total_pixel_matrix( + apply_palette_color_lut=True, + relabel=True, + combine_segments=True, + ) + + monochrome_seg = self._sm_control_labelmap_seg + msg = ( + 'Palette color transform is required but the image is not a palette ' + 'color image.' + ) + with pytest.raises(ValueError, match=msg): + # Requires combine_segments + monochrome_seg.get_total_pixel_matrix( + combine_segments=True, + apply_palette_color_lut=True, + ) + + def test_parsing_nonconsecutive_segment_numbers(self): + # parsing a labelmap segmentation with non-consective segment numbers + seg_num = 4 + other_seg_num = 8 + file_path = Path(__file__) + data_dir = file_path.parent.parent.joinpath('data') + ct_image = dcmread( + str(data_dir.joinpath('test_files', 'ct_image.dcm')) + ) + array = np.zeros( + (ct_image.Rows, ct_image.Columns), + dtype=np.uint8, + ) + array[20:30, 20:30] = seg_num + array[70:80, 70:80] = other_seg_num + desc = SegmentDescription( + segment_number=seg_num, + segment_label=f'Segment #{seg_num}', + segmented_property_category=codes.SCT.MorphologicallyAbnormalStructure, + segmented_property_type=codes.SCT.Neoplasm, + algorithm_type=SegmentAlgorithmTypeValues.AUTOMATIC.value, + algorithm_identification=AlgorithmIdentificationSequence( + name='foo', + family=codes.DCM.ArtificialIntelligence, + version='v1' + ) + ) + other_desc = SegmentDescription( + segment_number=other_seg_num, + segment_label=f'Segment #{other_seg_num}', + segmented_property_category=codes.SCT.MorphologicallyAbnormalStructure, + segmented_property_type=codes.SCT.Neoplasm, + algorithm_type=SegmentAlgorithmTypeValues.AUTOMATIC.value, + algorithm_identification=AlgorithmIdentificationSequence( + name='foo', + family=codes.DCM.ArtificialIntelligence, + version='v1' + ) + ) + + seg = Segmentation( + source_images=[ct_image], + pixel_array=array, + segmentation_type=SegmentationTypeValues.LABELMAP, + segment_descriptions=[desc, other_desc], + series_instance_uid=UID(), + series_number=1, + sop_instance_uid=UID(), + instance_number=1, + manufacturer='manufacturer', + manufacturer_model_name='model_name', + software_versions='123', + device_serial_number='456', + ) + assert len(seg.SegmentSequence) == 3 # includes bg + assert seg.segment_numbers == [seg_num, other_seg_num] + + assert isinstance(seg.get_segment_description(4), SegmentDescription) + msg = '1 is an invalid segment number for this dataset.' + with pytest.raises(IndexError, match=msg): + seg.get_segment_description(1) + + assert seg.get_segment_numbers() == [seg_num, other_seg_num] + assert seg.get_segment_numbers(segment_label=f'Segment #{seg_num}') == [seg_num] + assert seg.get_segment_numbers(segment_label='not existing') == [] + + out = seg.get_pixels_by_source_instance( + source_sop_instance_uids=[ct_image.SOPInstanceUID], + combine_segments=True, + ) + assert out.shape == (1, ct_image.Rows, ct_image.Columns) + assert np.array_equal(out[0], array) + + out = seg.get_pixels_by_source_instance( + source_sop_instance_uids=[ct_image.SOPInstanceUID], + combine_segments=True, + relabel=True, + ) + assert out.shape == (1, ct_image.Rows, ct_image.Columns) + assert np.array_equal(np.unique(out), np.array([0, 1, 2])) + + out = seg.get_pixels_by_source_instance( + source_sop_instance_uids=[ct_image.SOPInstanceUID], + combine_segments=True, + relabel=True, + segment_numbers=[seg_num] + ) + assert out.shape == (1, ct_image.Rows, ct_image.Columns) + assert np.array_equal(out[0], array == seg_num) + + out = seg.get_pixels_by_source_instance( + source_sop_instance_uids=[ct_image.SOPInstanceUID], + ) + assert out.shape == (1, ct_image.Rows, ct_image.Columns, 2) + assert np.array_equal(out[0, :, :, 0], array == seg_num) + assert np.array_equal(out[0, :, :, 1], array == other_seg_num) + + out = seg.get_pixels_by_source_instance( + source_sop_instance_uids=[ct_image.SOPInstanceUID], + segment_numbers=[other_seg_num, seg_num] + ) + assert out.shape == (1, ct_image.Rows, ct_image.Columns, 2) + assert np.array_equal(out[0, :, :, 0], array == other_seg_num) + assert np.array_equal(out[0, :, :, 1], array == seg_num) + + out = seg.get_pixels_by_source_instance( + source_sop_instance_uids=[ct_image.SOPInstanceUID], + segment_numbers=[other_seg_num] + ) + assert out.shape == (1, ct_image.Rows, ct_image.Columns, 1) + assert np.array_equal(out[0, :, :, 0], array == other_seg_num) + + msg = ( + 'Segment numbers array contains invalid values.' + ) + with pytest.raises(ValueError, match=msg): + seg.get_pixels_by_source_instance( + source_sop_instance_uids=[ct_image.SOPInstanceUID], + segment_numbers=[5] # not found + ) class TestSegUtilities(unittest.TestCase): @@ -4493,10 +5715,8 @@ def test_multiple_source_single_pixel_array_multisegment(self): for pix, seg in zip(self._downsampled_pix_arrays_multisegment, segs): assert hasattr(seg, 'PyramidUID') seg_pix = seg.get_total_pixel_matrix() - print("pixel array", pix.shape, seg_pix.shape) - print("pixel array", pix.max(), seg_pix.max()) assert np.array_equal( - seg.get_total_pixel_matrix(), + seg_pix, pix[0] ) @@ -4598,7 +5818,9 @@ def test_multiple_source_multiple_pixel_arrays_multisegment(self): pix[0] ) - def test_multiple_source_multiple_pixel_arrays_multisegment_labelmap(self): + def test_multiple_source_multiple_pixel_arrays_multisegment_from_labelmap( + self + ): # Test construction when given multiple source images and multiple # segmentation images mask = np.argmax(self._seg_pix_multisegment, axis=3).astype(np.uint8) @@ -4623,3 +5845,29 @@ def test_multiple_source_multiple_pixel_arrays_multisegment_labelmap(self): seg.get_total_pixel_matrix(combine_segments=True), mask[0] ) + + def test_multiple_source_multiple_pixel_arrays_multisegment_labelmap(self): + # Test construction when given multiple source images and multiple + # segmentation images + mask = np.argmax(self._seg_pix_multisegment, axis=3).astype(np.uint8) + segs = create_segmentation_pyramid( + source_images=self._source_pyramid, + pixel_arrays=mask, + segmentation_type=SegmentationTypeValues.LABELMAP, + segment_descriptions=self._segment_descriptions_multi, + series_instance_uid=UID(), + series_number=1, + manufacturer='Foo', + manufacturer_model_name='Bar', + software_versions='1', + device_serial_number='123', + ) + + assert len(segs) == len(self._source_pyramid) + for pix, seg in zip(self._downsampled_pix_arrays_multisegment, segs): + mask = np.argmax(pix, axis=3).astype(np.uint8) + assert hasattr(seg, 'PyramidUID') + assert np.array_equal( + seg.get_total_pixel_matrix(combine_segments=True), + mask[0] + ) diff --git a/tests/test_spatial.py b/tests/test_spatial.py index 1d53cdc6..e28f3778 100644 --- a/tests/test_spatial.py +++ b/tests/test_spatial.py @@ -1,7 +1,7 @@ from pathlib import Path import numpy as np import pydicom -from pydicom.data import get_testdata_file +from pydicom.data import get_testdata_file, get_testdata_files import pytest from highdicom.spatial import ( @@ -11,8 +11,15 @@ PixelToReferenceTransformer, ReferenceToImageTransformer, ReferenceToPixelTransformer, - is_tiled_image, _are_images_coplanar, + _normalize_patient_orientation, + _transform_affine_matrix, + create_rotation_matrix, + get_closest_patient_orientation, + get_series_volume_positions, + get_volume_positions, + is_tiled_image, + rotation_for_patient_orientation, ) @@ -719,6 +726,71 @@ def test_map_coordinates_between_images(params, inputs, expected_outputs): np.testing.assert_array_almost_equal(outputs, expected_outputs) +@pytest.mark.parametrize( + 'image_orientation,orientation_str', + [ + ([1, 0, 0, 0, 1, 0], 'LPH'), + ([0, 1, 0, 1, 0, 0], 'PLF'), + ([-1, 0, 0, 0, 1, 0], 'RPF'), + ([0, 0, -1, 1, 0, 0], 'FLA'), + ( + [ + np.cos(np.pi / 4), + -np.sin(np.pi / 4), + 0, + np.sin(np.pi / 4), + np.cos(np.pi / 4), + 0 + ], + 'LPH' + ), + ] +) +def test_get_closest_patient_orientation( + image_orientation, + orientation_str, +): + codes = _normalize_patient_orientation(orientation_str) + rotation_matrix = create_rotation_matrix(image_orientation) + assert get_closest_patient_orientation( + rotation_matrix + ) == codes + + +@pytest.mark.parametrize( + 'orientation_str', + ['LPH', 'PLF', 'RPF', 'FLA'] +) +def test_rotation_from_patient_orientation( + orientation_str, +): + codes = _normalize_patient_orientation(orientation_str) + rotation_matrix = rotation_for_patient_orientation( + orientation_str + ) + assert get_closest_patient_orientation( + rotation_matrix + ) == codes + + +def test_rotation_from_patient_orientation_spacing(): + rotation_matrix = rotation_for_patient_orientation( + ['F', 'P', 'L'], + spacing=(1.0, 2.0, 2.5) + ) + expected = np.array( + [ + [0.0, 0.0, 2.5], + [0.0, 2.0, 0.0], + [-1.0, 0.0, 0.0], + ] + ) + assert np.array_equal( + rotation_matrix, + expected, + ) + + all_single_image_transformer_classes = [ ImageToReferenceTransformer, PixelToReferenceTransformer, @@ -865,3 +937,220 @@ def test_are_images_coplanar(pos_a, ori_a, pos_b, ori_b, result): image_position_b=pos_b, image_orientation_b=ori_b, ) == result + + +def test_get_series_slice_spacing_irregular(): + # A series of single frame CT images + ct_series = [ + pydicom.dcmread(f) + for f in get_testdata_files('dicomdirtests/77654033/CT2/*') + ] + spacing, _ = get_series_volume_positions(ct_series) + assert spacing is None + + +def test_get_series_slice_spacing_regular(): + # Use a subset of this test series that does have regular spacing + 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] + spacing, _ = get_series_volume_positions(ct_series) + assert spacing == 1.25 + + +def test_get_spacing_duplicates(): + # Test ability to determine spacing and volume positions with duplicate + # positions + position_indices = np.array( + [0, 1, 2, 3, 4, 5, 2, 5, 5, 3, 1, 1, 2, 4, 1, 2, 0] + ) + expected_spacing = 0.2 + positions = [ + [0.0, 0.0, i * expected_spacing] for i in position_indices + ] + orientation = [1, 0, 0, 0, -1, 0] + + spacing, volume_positions = get_volume_positions( + positions, + orientation, + allow_duplicates=False, + ) + assert spacing is None + assert volume_positions is None + + spacing, volume_positions = get_volume_positions( + positions, + orientation, + allow_duplicates=True, + ) + assert np.isclose(spacing, expected_spacing) + assert volume_positions == position_indices.tolist() + + +def test_get_spacing_missing(): + # Test ability to determine spacing and volume positions with missing + # slices + position_indices = np.array( + [1, 3, 0, 9], # an incomplete list of indices from 0 to 9 + ) + expected_spacing = 0.125 + positions = [ + [0.0, 0.0, i * expected_spacing] for i in position_indices + ] + orientation = [1, 0, 0, 0, -1, 0] + + spacing, volume_positions = get_volume_positions( + positions, + orientation, + allow_missing=True + ) + + assert np.isclose(spacing, expected_spacing) + assert volume_positions == position_indices.tolist() + + +def test_get_spacing_missing_duplicates(): + # Test ability to determine spacing and volume positions with missing + # slices and duplicate positions + position_indices = np.array( + [1, 3, 0, 9, 3], + ) + expected_spacing = 0.125 + positions = [ + [0.0, 0.0, i * expected_spacing] for i in position_indices + ] + orientation = [1, 0, 0, 0, -1, 0] + + spacing, volume_positions = get_volume_positions( + positions, + orientation, + allow_missing=True, + ) + assert spacing is None + assert volume_positions is None + + spacing, volume_positions = get_volume_positions( + positions, + orientation, + allow_missing=True, + allow_duplicates=True, + ) + assert np.isclose(spacing, expected_spacing) + assert volume_positions == position_indices.tolist() + + +def test_get_spacing_missing_duplicates_non_consecutive(): + # Test ability to determine spacing and volume positions with missing + # slices and duplicate positions, with no two positions from consecutive + # slices + position_indices = np.array([7, 3, 0, 9, 3]) + expected_spacing = 0.125 + positions = [ + [0.0, 0.0, i * expected_spacing] for i in position_indices + ] + orientation = [1, 0, 0, 0, -1, 0] + + # Without the spacing_hint, the positions do not appear to be a volume + spacing, volume_positions = get_volume_positions( + positions, + orientation, + allow_missing=True, + allow_duplicates=True, + ) + assert spacing is None + assert volume_positions is None + + # With the hint, the positions should be correctly calculated + spacing, volume_positions = get_volume_positions( + positions, + orientation, + allow_missing=True, + allow_duplicates=True, + spacing_hint=expected_spacing, + ) + assert np.isclose(spacing, expected_spacing) + assert volume_positions == position_indices.tolist() + + +def test_transform_affine_matrix(): + affine = np.array( + [ + [np.cos(np.radians(30)), -np.sin(np.radians(30)), 0.0, -34.0], + [np.sin(np.radians(30)), np.cos(np.radians(30)), 0.0, 45.2], + [0.0, 0.0, 1.0, -1.2], + [0.0, 0.0, 0.0, 1.0], + ] + ) + + transformed = _transform_affine_matrix( + affine, + permute_indices=[1, 2, 0], + shape=[10, 10, 10], + ) + expected = np.array( + [ + [-np.sin(np.radians(30)), 0.0, np.cos(np.radians(30)), -34.0], + [np.cos(np.radians(30)), 0.0, np.sin(np.radians(30)), 45.2], + [0.0, 1.0, 0.0, -1.2], + [0.0, 0.0, 0.0, 1.0], + ] + ) + assert np.array_equal(transformed, expected) + + transformed = _transform_affine_matrix( + affine, + permute_reference=[1, 2, 0], + shape=[10, 10, 10], + ) + expected = np.array( + [ + [np.sin(np.radians(30)), np.cos(np.radians(30)), 0.0, 45.2], + [0.0, 0.0, 1.0, -1.2], + [np.cos(np.radians(30)), -np.sin(np.radians(30)), 0.0, -34.0], + [0.0, 0.0, 0.0, 1.0], + ] + ) + assert np.array_equal(transformed, expected) + + transformed = _transform_affine_matrix( + affine, + flip_indices=[True, False, True], + shape=[10, 10, 10], + ) + expected = np.array( + [ + [ + -np.cos(np.radians(30)), + -np.sin(np.radians(30)), + 0.0, + -26.20577137, + ], + [ + -np.sin(np.radians(30)), + np.cos(np.radians(30)), + 0.0, + 40.7, + ], + [0.0, 0.0, -1.0, 7.8], + [0.0, 0.0, 0.0, 1.0], + ] + ) + assert np.allclose(transformed, expected) + + transformed = _transform_affine_matrix( + affine, + flip_reference=[True, False, True], + shape=[10, 10, 10], + ) + expected = np.array( + [ + [-np.cos(np.radians(30)), np.sin(np.radians(30)), 0.0, 34.0], + [np.sin(np.radians(30)), np.cos(np.radians(30)), 0.0, 45.2], + [0.0, 0.0, -1.0, 1.2], + [0.0, 0.0, 0.0, 1.0], + ] + ) + assert np.array_equal(transformed, expected) diff --git a/tests/test_volume.py b/tests/test_volume.py new file mode 100644 index 00000000..76c60552 --- /dev/null +++ b/tests/test_volume.py @@ -0,0 +1,665 @@ +from pathlib import Path +import numpy as np +import pydicom +from pydicom.data import get_testdata_file +import pytest + + +from highdicom.spatial import ( + _normalize_patient_orientation, + _translate_affine_matrix, +) +from highdicom.image import ( + imread, + volume_from_image_series, +) +from highdicom.volume import ( + ChannelIdentifier, + Volume, + VolumeGeometry, + VolumeToVolumeTransformer, +) + +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 volume_from_image_series(ct_series), ct_series + + +def test_transforms(): + array = np.zeros((25, 50, 50)) + 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=10.0, + ) + plane_positions = volume.get_plane_positions() + for i, pos in enumerate(plane_positions): + assert np.array_equal( + pos[0].ImagePositionPatient, + [0.0, 0.0, -10.0 * i] + ) + + indices = np.array([[1, 2, 3]]) + coords = volume.map_indices_to_reference(indices) + assert np.array_equal(coords, np.array([[3.0, 2.0, -10.0]])) + round_trip = volume.map_reference_to_indices(coords) + assert np.array_equal(round_trip, indices) + index_center = volume.get_center_index() + assert np.array_equal(index_center, [12.0, 24.5, 24.5]) + index_center = volume.get_center_index(round_output=True) + assert np.array_equal(index_center, [12, 24, 24]) + coord_center = volume.get_center_coordinate() + assert np.array_equal(coord_center, [24.5, 24.5, -120]) + + +@pytest.mark.parametrize( + 'image_position,image_orientation,pixel_spacing,spacing_between_slices', + [ + ( + (67.0, 32.4, -45.2), + (1.0, 0.0, 0.0, 0.0, -1.0, 0.0), + (3.2, 1.6), + 1.25, + ), + ( + [67.0, 32.4, -45.2], + (-1.0, 0.0, 0.0, 0.0, -1.0, 0.0), + (3.2, 1.6), + 1.25, + ), + ( + (-67.0, 132.4, -5.2), + (0.0, 0.0, -1.0, 1.0, 0.0, 0.0), + (0.25, 0.25), + 3.5, + ), + ( + (-67.0, 132.4, -5.2), + ( + np.cos(np.radians(30)), -np.sin(np.radians(30)), 0.0, + np.sin(np.radians(30)), np.cos(np.radians(30)), 0.0, + ), + (0.75, 0.25), + 3.5, + ), + ], +) +def test_volume_from_attributes( + image_position, + image_orientation, + pixel_spacing, + spacing_between_slices, +): + array = np.zeros((10, 10, 10)) + volume = Volume.from_attributes( + array=array, + image_position=image_position, + image_orientation=image_orientation, + pixel_spacing=pixel_spacing, + spacing_between_slices=spacing_between_slices, + ) + assert volume.position == tuple(image_position) + assert volume.direction_cosines == tuple(image_orientation) + assert volume.pixel_spacing == tuple(pixel_spacing) + assert volume.spacing_between_slices == spacing_between_slices + assert volume.shape == (10, 10, 10) + assert volume.spatial_shape == (10, 10, 10) + assert volume.channel_shape == () + assert volume.channel_identifiers == () + + +def test_volume_with_channels(): + 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']} + ) + assert volume.shape == (10, 10, 10, 2) + assert volume.spatial_shape == (10, 10, 10) + assert volume.channel_shape == (2, ) + assert isinstance(volume.channel_identifiers, tuple) + assert len(volume.channel_identifiers) == 1 + assert isinstance(volume.channel_identifiers[0], ChannelIdentifier) + expected = ChannelIdentifier('OpticalPathIdentifier') + assert volume.channel_identifiers[0] == expected + assert volume.get_channel_values(expected) == ['path1', 'path2'] + + +def test_with_array(): + array = np.zeros((10, 10, 10)) + volume = Volume.from_attributes( + array=array, + image_position=(0.0, 0.0, 0.0), + image_orientation=(1.0, 0.0, 0.0, 0.0, 1.0, 0.0), + pixel_spacing=(1.0, 1.0), + spacing_between_slices=2.0, + ) + assert volume.channel_shape == () + new_array = np.zeros((10, 10, 10, 2), dtype=np.uint8) + new_volume = volume.with_array( + new_array, + channels={'OpticalPathIdentifier': ['path1', 'path2']}, + ) + assert new_volume.channel_shape == (2, ) + assert isinstance(new_volume, Volume) + assert volume.spatial_shape == new_volume.spatial_shape + assert np.array_equal(volume.affine, new_volume.affine) + assert volume.affine is not new_volume.affine + assert new_volume.dtype == np.uint8 + + +def test_volume_single_frame(): + volume, ct_series = read_ct_series_volume() + assert isinstance(volume, Volume) + rows, columns = ct_series[0].Rows, ct_series[0].Columns + assert volume.shape == (len(ct_series), rows, columns) + assert volume.spatial_shape == volume.shape + assert volume.channel_shape == () + orientation = ct_series[0].ImageOrientationPatient + assert volume.direction_cosines == tuple(orientation) + direction = volume.direction + assert np.array_equal(direction[:, 1], orientation[3:]) + assert np.array_equal(direction[:, 2], orientation[:3]) + # Check third direction is normal to others + assert direction[:, 0] @ direction[:, 1] == 0.0 + assert direction[:, 0] @ direction[:, 2] == 0.0 + assert (direction[:, 0] ** 2).sum() == 1.0 + + assert volume.position == tuple(ct_series[0].ImagePositionPatient) + + assert volume.pixel_spacing == tuple(ct_series[0].PixelSpacing) + slice_spacing = 1.25 + assert volume.spacing == (slice_spacing, *ct_series[0].PixelSpacing[::-1]) + pixel_spacing = ct_series[0].PixelSpacing + expected_voxel_volume = ( + pixel_spacing[0] * pixel_spacing[1] * slice_spacing + ) + expected_volume = expected_voxel_volume * np.prod(volume.spatial_shape) + assert np.allclose(volume.voxel_volume, expected_voxel_volume) + assert np.allclose(volume.physical_volume, expected_volume) + u1, u2, u3 = volume.unit_vectors() + for u in [u1, u2, u3]: + assert u.shape == (3, ) + assert np.linalg.norm(u) == 1.0 + assert np.allclose(u3, orientation[:3]) + assert np.allclose(u2, orientation[3:]) + + v1, v2, v3 = volume.spacing_vectors() + for v, spacing in zip([v1, v2, v3], volume.spacing): + assert v.shape == (3, ) + assert np.linalg.norm(v) == spacing + + +def test_volume_multiframe(): + volume, dcm = read_multiframe_ct_volume() + assert isinstance(volume, Volume) + rows, columns = dcm.Rows, dcm.Columns + assert volume.shape == (dcm.NumberOfFrames, rows, columns) + assert volume.spatial_shape == volume.shape + orientation = ( + dcm + .SharedFunctionalGroupsSequence[0] + .PlaneOrientationSequence[0] + .ImageOrientationPatient + ) + pixel_spacing = ( + dcm + .SharedFunctionalGroupsSequence[0] + .PixelMeasuresSequence[0] + .PixelSpacing + ) + assert volume.direction_cosines == tuple(orientation) + direction = volume.direction + assert np.array_equal(direction[:, 1], orientation[3:]) + assert np.array_equal(direction[:, 2], orientation[:3]) + # Check third direction is normal to others + assert direction[:, 0] @ direction[:, 1] == 0.0 + assert direction[:, 0] @ direction[:, 2] == 0.0 + assert (direction[:, 0] ** 2).sum() == 1.0 + first_frame_pos = ( + dcm + .PerFrameFunctionalGroupsSequence[0] + .PlanePositionSequence[0] + .ImagePositionPatient + ) + assert volume.position == tuple(first_frame_pos) + assert volume.pixel_spacing == tuple(pixel_spacing) + slice_spacing = 10.0 + assert volume.spacing == (slice_spacing, *pixel_spacing[::-1]) + assert volume.channel_shape == () + expected_voxel_volume = ( + pixel_spacing[0] * pixel_spacing[1] * slice_spacing + ) + expected_volume = expected_voxel_volume * np.prod(volume.spatial_shape) + assert np.allclose(volume.voxel_volume, expected_voxel_volume) + assert np.allclose(volume.physical_volume, expected_volume) + u1, u2, u3 = volume.unit_vectors() + for u in [u1, u2, u3]: + assert u.shape == (3, ) + assert np.linalg.norm(u) == 1.0 + assert np.allclose(u3, orientation[:3]) + assert np.allclose(u2, orientation[3:]) + + v1, v2, v3 = volume.spacing_vectors() + for v, spacing in zip([v1, v2, v3], volume.spacing): + assert v.shape == (3, ) + assert np.linalg.norm(v) == spacing + + +def test_indexing(): + array = np.random.randint(0, 100, (25, 50, 50)) + 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=10.0, + ) + + # Single integer index + subvolume = volume[3] + assert subvolume.shape == (1, 50, 50) + expected_affine = np.array([ + [0.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [-10.0, 0.0, 0.0, -30.0], + [0.0, 0.0, 0.0, 1.0], + ]) + assert np.array_equal(subvolume.affine, expected_affine) + assert np.array_equal(subvolume.array, array[3:4]) + + # With colons + subvolume = volume[3, :] + assert subvolume.shape == (1, 50, 50) + assert np.array_equal(subvolume.affine, expected_affine) + assert np.array_equal(subvolume.array, array[3:4]) + subvolume = volume[3, :, :] + assert subvolume.shape == (1, 50, 50) + assert np.array_equal(subvolume.affine, expected_affine) + assert np.array_equal(subvolume.array, array[3:4]) + + # Single slice index + subvolume = volume[3:13] + assert subvolume.shape == (10, 50, 50) + assert np.array_equal(subvolume.affine, expected_affine) + assert np.array_equal(subvolume.array, array[3:13]) + + # Multiple integer indices + subvolume = volume[3, 7] + assert subvolume.shape == (1, 1, 50) + expected_affine = np.array([ + [0.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 7.0], + [-10.0, 0.0, 0.0, -30.0], + [0.0, 0.0, 0.0, 1.0], + ]) + assert np.array_equal(subvolume.affine, expected_affine) + assert np.array_equal(subvolume.array, array[3:4, 7:8]) + + # Multiple integer indices in sequence (should be the same as above) + subvolume = volume[:, 7][3, :] + assert subvolume.shape == (1, 1, 50) + assert np.array_equal(subvolume.affine, expected_affine) + assert np.array_equal(subvolume.array, array[3:4, 7:8]) + subvolume = volume[3, :][:, 7] + assert subvolume.shape == (1, 1, 50) + assert np.array_equal(subvolume.affine, expected_affine) + assert np.array_equal(subvolume.array, array[3:4, 7:8]) + + # Negative index + subvolume = volume[-4] + assert subvolume.shape == (1, 50, 50) + expected_affine = np.array([ + [0.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [-10.0, 0.0, 0.0, -210.0], + [0.0, 0.0, 0.0, 1.0], + ]) + assert np.array_equal(subvolume.affine, expected_affine) + assert np.array_equal(subvolume.array, array[-4:-3]) + + # Negative index range + subvolume = volume[-4:-2, :, :] + assert subvolume.shape == (2, 50, 50) + assert np.array_equal(subvolume.affine, expected_affine) + assert np.array_equal(subvolume.array, array[-4:-2]) + + # Non-zero steps + subvolume = volume[12:16:2, ::-1, :] + assert subvolume.shape == (2, 50, 50) + expected_affine = np.array([ + [0.0, 0.0, 1.0, 0.0], + [0.0, -1.0, 0.0, 49.0], + [-20.0, 0.0, 0.0, -120.0], + [0.0, 0.0, 0.0, 1.0], + ]) + assert np.array_equal(subvolume.affine, expected_affine) + assert np.array_equal(subvolume.array, array[12:16:2, ::-1]) + + +def test_indexing_source_dimension_2(): + array = np.random.randint(0, 100, (50, 50, 25)) + affine = np.array([ + [0.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [10.0, 0.0, 0.0, 30.0], + [0.0, 0.0, 0.0, 1.0], + ]) + volume = Volume( + array=array, + affine=affine, + ) + + subvolume = volume[12:14, :, 12:6:-2] + assert np.array_equal(subvolume.array, array[12:14, :, 12:6:-2]) + + +def test_array_setter(): + array = np.random.randint(0, 100, (50, 50, 25)) + affine = np.array([ + [0.0, 0.0, 1.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [10.0, 0.0, 0.0, 30.0], + [0.0, 0.0, 0.0, 1.0], + ]) + + volume = Volume( + array=array, + affine=affine, + ) + + new_array = np.random.randint(0, 100, (50, 50, 25)) + volume.array = new_array + assert np.array_equal(volume.array, new_array) + + new_array = np.random.randint(0, 100, (25, 50, 50)) + with pytest.raises(ValueError): + volume.array = new_array + + +@pytest.mark.parametrize( + 'desired', + [ + 'RAF', + 'RAH', + 'RPF', + 'RPH', + 'LAF', + 'LAH', + 'LPF', + 'LPH', + 'HLP', + 'FPR', + 'HRP', + ] +) +def test_to_patient_orientation(desired): + array = np.random.randint(0, 100, (25, 50, 50)) + 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=10.0, + ) + desired_tup = _normalize_patient_orientation(desired) + + flipped = volume.to_patient_orientation(desired) + assert isinstance(flipped, Volume) + assert flipped.get_closest_patient_orientation() == desired_tup + + flipped = volume.to_patient_orientation(desired_tup) + assert isinstance(flipped, Volume) + assert flipped.get_closest_patient_orientation() == desired_tup + + +def test_volume_transformer(): + + geometry = VolumeGeometry( + np.eye(4), + [32, 32, 32], + ) + + indices = np.array( + [ + [0, 0, 0], + [0, 0, 1], + ] + ) + + expected = np.array( + [ + [1, 5, 8], + [1, 5, 9], + ] + ) + + geometry2 = geometry[1:11, 5:15, 8:18] + + for round_output in [False, True]: + for check_bounds in [False, True]: + transformer = VolumeToVolumeTransformer( + geometry2, + geometry, + check_bounds=check_bounds, + round_output=round_output, + ) + + outputs = transformer(indices) + if round_output: + assert outputs.dtype == np.int64 + else: + assert outputs.dtype == np.float64 + assert np.array_equal(outputs, expected) + + transformer = VolumeToVolumeTransformer( + geometry2, + geometry, + check_bounds=True, + ) + out_of_bounds_indices = np.array([[31, 0, 0]]) + with pytest.raises(ValueError): + transformer(out_of_bounds_indices) + + expected = np.array( + [ + [-1, -5, -8], + [-1, -5, -7], + ] + ) + for round_output in [False, True]: + transformer = VolumeToVolumeTransformer( + geometry, + geometry2, + round_output=round_output, + ) + + outputs = transformer(indices) + if round_output: + assert outputs.dtype == np.int64 + else: + assert outputs.dtype == np.float64 + assert np.array_equal(outputs, expected) + + transformer = VolumeToVolumeTransformer( + geometry, + geometry2, + check_bounds=True, + ) + for oob_indices in [ + [0, 5, 8], + [0, 0, 1], + [11, 5, 8], + ]: + with pytest.raises(ValueError): + transformer(np.array([oob_indices])) + + geometry3 = geometry2.permute_spatial_axes([2, 1, 0]) + expected = np.array( + [ + [1, 5, 8], + [2, 5, 8], + ] + ) + + for round_output in [False, True]: + for check_bounds in [False, True]: + transformer = VolumeToVolumeTransformer( + geometry3, + geometry, + check_bounds=check_bounds, + round_output=round_output, + ) + + outputs = transformer(indices) + if round_output: + assert outputs.dtype == np.int64 + else: + assert outputs.dtype == np.float64 + assert np.array_equal(outputs, expected) + + +@pytest.mark.parametrize( + 'crop,pad,permute,reversible', + [ + ( + (slice(None), slice(14, None), slice(None, None, -1)), + ((0, 0), (0, 32), (3, 3)), + (1, 0, 2), + True, + ), + ( + (1, slice(256, 320), slice(256, 320)), + ((0, 0), (0, 0), (0, 0)), + (0, 2, 1), + True, + ), + ( + (slice(None), slice(None, None, -1), slice(None)), + ((12, 31), (1, 23), (5, 7)), + (0, 2, 1), + True, + ), + ( + (slice(None, None, -1), slice(None, None, -2), slice(None)), + ((0, 0), (0, 0), (0, 0)), + (2, 1, 0), + False, + ), + ], +) +def test_match_geometry(crop, pad, permute, reversible): + vol, _ = read_multiframe_ct_volume() + + transformed = ( + vol[crop] + .pad(pad) + .permute_spatial_axes(permute) + ) + + forward_matched = vol.match_geometry(transformed) + assert forward_matched.geometry_equal(transformed) + assert np.array_equal(forward_matched.array, transformed.array) + + if reversible: + reverse_matched = transformed.match_geometry(vol) + assert reverse_matched.geometry_equal(vol) + + # Perform the transform again on the recovered image to ensure that we + # end up with the transformed + inverted_transformed = ( + vol[crop] + .pad(pad) + .permute_spatial_axes(permute) + ) + assert inverted_transformed.geometry_equal(transformed) + assert np.array_equal(transformed.array, inverted_transformed.array) + + +def test_match_geometry_nonintersecting(): + vol, _ = read_multiframe_ct_volume() + + new_affine = _translate_affine_matrix( + vol.affine, + [0, -32, 32] + ) + + # This geometry has no overlap with the original volume + geometry = VolumeGeometry( + new_affine, + [2, 16, 16] + ) + + transformed = vol.match_geometry(geometry) + + # Result should be an empty array with the requested geometry + assert transformed.geometry_equal(geometry) + assert transformed.array.min() == 0 + assert transformed.array.max() == 0 + + +def test_match_geometry_failure_translation(): + vol, _ = read_multiframe_ct_volume() + + new_affine = _translate_affine_matrix( + vol.affine, + [0.0, 0.5, 0.0] + ) + geometry = VolumeGeometry( + new_affine, + vol.shape, + ) + + with pytest.raises(RuntimeError): + vol.match_geometry(geometry) + + +def test_match_geometry_failure_spacing(): + vol, _ = read_multiframe_ct_volume() + + new_affine = vol.affine.copy() + new_affine[:3, 2] *= 0.33 + geometry = VolumeGeometry( + new_affine, + vol.shape, + ) + + with pytest.raises(RuntimeError): + vol.match_geometry(geometry) + + +def test_match_geometry_failure_rotation(): + vol, _ = read_multiframe_ct_volume() + + # Geometry that is rotated with respect to input volume + geometry = VolumeGeometry.from_attributes( + image_orientation=( + np.cos(np.radians(30)), -np.sin(np.radians(30)), 0.0, + np.sin(np.radians(30)), np.cos(np.radians(30)), 0.0, + ), + image_position=vol.position, + pixel_spacing=vol.pixel_spacing, + spacing_between_slices=vol.spacing_between_slices, + number_of_frames=vol.shape[0], + columns=vol.shape[2], + rows=vol.shape[1], + ) + + with pytest.raises(RuntimeError): + vol.match_geometry(geometry) diff --git a/tests/utils.py b/tests/utils.py index f70233a8..a0d92193 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,9 +1,16 @@ from io import BytesIO +from pathlib import Path +from pydicom.data import get_testdata_files from pydicom.dataset import Dataset, FileMetaDataset from pydicom.filereader import dcmread +from highdicom._module_utils import ( + does_iod_have_pixel_data, +) + + def write_and_read_dataset(dataset: Dataset): """Write DICOM dataset to buffer and read it back from buffer.""" clone = Dataset(dataset) @@ -21,3 +28,64 @@ def write_and_read_dataset(dataset: Dataset): little_endian=little_endian, ) return dcmread(fp, force=True) + + +def find_readable_images() -> list[str]: + """Get a list of all images in highdicom and pydicom test data that should + be expected to work with image reading routines. + + """ + # All pydicom test files + all_files = get_testdata_files() + + # Add highdicom test files + file_path = Path(__file__) + data_dir = file_path.parent.parent.joinpath('data/test_files') + hd_files = [str(f) for f in data_dir.glob("*.dcm")] + + all_files.extend(hd_files) + + # Various files are not expected to work and should be excluded + exclusions = [ + "badVR.dcm", # cannot be read due to bad VFR + "MR_truncated.dcm", # pixel data is truncated + "liver_1frame.dcm", # missing number of frames + "JPEG2000-embedded-sequence-delimiter.dcm", # pydicom cannot decode pixels + "image_dfl.dcm", # deflated transfer syntax cannot be read lazily + "JPEG-lossy.dcm", # pydicom cannot decode pixels + "TINY_ALPHA", # no pixels + "SC_rgb_jpeg.dcm", # messed up transder syntax + ] + + files_to_use = [] + + for f in all_files: + try: + # Skip image files that can't even be opened (the test files + # include some deliberately corrupted files) + dcm = dcmread(f) + except: + continue + + excluded = False + if 'SOPClassUID' not in dcm: + # Some are missing this... + continue + if not does_iod_have_pixel_data(dcm.SOPClassUID): + # Exclude non images + continue + if not dcm.file_meta.TransferSyntaxUID.is_little_endian: + # We don't support little endian + continue + + for exc in exclusions: + if exc in f: + excluded = True + break + + if excluded: + continue + + files_to_use.append(f) + + return files_to_use