Skip to content

Commit 4f263be

Browse files
committed
Refactor Image Pixel and General Image modules into _init_multiframe
1 parent 5a73d07 commit 4f263be

11 files changed

Lines changed: 242 additions & 223 deletions

File tree

src/highdicom/__init__.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
PatientSexValues,
4949
PhotometricInterpretationValues,
5050
PixelIndexDirections,
51+
PixelDataKeywords,
5152
PixelRepresentationValues,
5253
PlanarConfigurationValues,
5354
PatientOrientationValuesBiped,
@@ -80,7 +81,6 @@
8081

8182

8283
__all__ = [
83-
'RGB_COLOR_CHANNEL_DESCRIPTOR',
8484
'AlgorithmIdentificationSequence',
8585
'AnatomicalOrientationTypeValues',
8686
'AxisHandedness',
@@ -104,18 +104,20 @@
104104
'PatientOrientationValuesQuadruped',
105105
'PatientSexValues',
106106
'PhotometricInterpretationValues',
107-
'PixelMeasuresSequence',
107+
'PixelDataKeywords',
108108
'PixelIndexDirections',
109+
'PixelMeasuresSequence',
109110
'PixelRepresentationValues',
110111
'PlanarConfigurationValues',
111112
'PlaneOrientationSequence',
112113
'PlanePositionSequence',
113114
'PresentationLUT',
114115
'PresentationLUTShapeValues',
115116
'PresentationLUTTransformation',
117+
'RGBColorChannels',
118+
'RGB_COLOR_CHANNEL_DESCRIPTOR',
116119
'ReferencedImageSequence',
117120
'RescaleTypeValues',
118-
'RGBColorChannels',
119121
'SOPClass',
120122
'SegmentedPaletteColorLUT',
121123
'SpecimenCollection',
@@ -136,6 +138,7 @@
136138
'ann',
137139
'color',
138140
'frame',
141+
'get_volume_from_series',
139142
'imread',
140143
'io',
141144
'ko',
@@ -148,5 +151,4 @@
148151
'spatial',
149152
'sr',
150153
'utils',
151-
'get_volume_from_series',
152154
]

src/highdicom/content.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
from collections import Counter
33
import datetime
44
from copy import deepcopy
5+
from io import BytesIO
56
from typing import cast
67
from collections.abc import Sequence
78
from typing_extensions import Self
89

910
import numpy as np
1011
from PIL import ImageColor
12+
from PIL.ImageCms import ImageCmsProfile
1113
from pydicom.dataset import Dataset
1214
from pydicom import DataElement
1315
from pydicom.sequence import Sequence as DataElementSequence
@@ -3706,3 +3708,73 @@ def _add_content_information(
37063708
)
37073709
dataset.ContentCreatorIdentificationCodeSequence = \
37083710
content_creator_identification
3711+
3712+
3713+
def _add_icc_profile_attributes(
3714+
dataset: Dataset,
3715+
icc_profile: bytes
3716+
) -> None:
3717+
"""Add attributes of module ICC Profile.
3718+
3719+
Parameters
3720+
----------
3721+
dataset: pydicom.Dataset
3722+
Dataset to which attributes should be added
3723+
icc_profile: bytes
3724+
ICC color profile to include in the presentation state.
3725+
The profile must follow the constraints listed in :dcm:`C.11.15
3726+
<part03/sect_C.11.15.html>`.
3727+
3728+
"""
3729+
if icc_profile is None:
3730+
raise TypeError('Argument "icc_profile" is required.')
3731+
3732+
cms_profile = ImageCmsProfile(BytesIO(icc_profile))
3733+
device_class = cms_profile.profile.device_class.strip()
3734+
if device_class not in ('scnr', 'spac'):
3735+
raise ValueError(
3736+
'The device class of the ICC Profile must be "scnr" or "spac", '
3737+
f'got "{device_class}".'
3738+
)
3739+
color_space = cms_profile.profile.xcolor_space.strip()
3740+
if color_space != 'RGB':
3741+
raise ValueError(
3742+
'The color space of the ICC Profile must be "RGB", '
3743+
f'got "{color_space}".'
3744+
)
3745+
pcs = cms_profile.profile.connection_space.strip()
3746+
if pcs not in ('Lab', 'XYZ'):
3747+
raise ValueError(
3748+
'The profile connection space of the ICC Profile must '
3749+
f'be "Lab" or "XYZ", got "{pcs}".'
3750+
)
3751+
3752+
dataset.ICCProfile = icc_profile
3753+
3754+
3755+
def _add_palette_color_lookup_table_attributes(
3756+
dataset: Dataset,
3757+
palette_color_lut_transformation: PaletteColorLUTTransformation
3758+
) -> None:
3759+
"""Add attributes from the Palette Color Lookup Table module.
3760+
3761+
Parameters
3762+
----------
3763+
dataset: pydicom.Dataset
3764+
Dataset to which attributes should be added
3765+
palette_color_lut_transformation: highdicom.PaletteColorLUTTransformation
3766+
Description of the Palette Color LUT Transformation for transforming
3767+
grayscale into RGB color pixel values
3768+
3769+
""" # noqa: E501
3770+
if not isinstance(
3771+
palette_color_lut_transformation,
3772+
PaletteColorLUTTransformation
3773+
):
3774+
raise TypeError(
3775+
'Argument "palette_color_lut_transformation" must be of type '
3776+
'PaletteColorLUTTransformation.'
3777+
)
3778+
3779+
for element in palette_color_lut_transformation:
3780+
dataset[element.tag] = element

src/highdicom/enum.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,3 +380,17 @@ class InterpolationMethods(Enum):
380380

381381
LINEAR = 'LINEAR'
382382
"""Linear (or bi-linear or tri-linear) interpolator."""
383+
384+
385+
class PixelDataKeywords(Enum):
386+
387+
"""Keywords used to store pixel data."""
388+
389+
PIXEL_DATA = 'PixelData'
390+
"""Integer-valued pixel data of any size (Image Pixel Module)."""
391+
392+
FLOAT_PIXEL_DATA = 'FloatPixelData'
393+
"""32-bit-float-valued pixel data (Floating Point Image Pixel Module)."""
394+
395+
DOUBLE_FLOAT_PIXEL_DATA = 'DoubleFloatPixelData'
396+
"""64-bit-float-valued pixel data (Double Floating Point Image Pixel Module).""" # noqa: E501

src/highdicom/frame.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
PhotometricInterpretationValues,
2525
PixelRepresentationValues,
2626
PlanarConfigurationValues,
27+
PixelDataKeywords,
2728
)
2829

2930
logger = logging.getLogger(__name__)
@@ -345,6 +346,7 @@ def encode_frame(
345346
planar_configuration=planar_configuration,
346347
**kwargs,
347348
)
349+
348350
return data
349351

350352

@@ -360,7 +362,7 @@ def decode_frame(
360362
pixel_representation: PixelRepresentationValues | int | None = 0,
361363
planar_configuration: PlanarConfigurationValues | int | None = None,
362364
index: int = 0,
363-
pixel_keyword: str = 'PixelData',
365+
pixel_keyword: PixelDataKeywords | str = PixelDataKeywords.PIXEL_DATA,
364366
) -> np.ndarray:
365367
"""Decode pixel data of an individual frame.
366368
@@ -398,7 +400,7 @@ def decode_frame(
398400
the byte array. In all other situations, this parameter is not
399401
required and will have no effect (since decoding a frame does not
400402
depend on the index of the frame).
401-
pixel_keyword: str, optional
403+
pixel_keyword: highdicom.enum.PixelDataKeywords | str, optional
402404
Keyword of the attribute where the pixel data value was stored. Should be
403405
``'PixelData'``, ``'FloatPixelData'``, or ``'DoubleFloatPixelData'``.
404406
@@ -445,7 +447,8 @@ def decode_frame(
445447
pixel_array = unpacked_frame[pixel_offset:pixel_offset + n_pixels]
446448
return pixel_array.reshape(rows, columns)
447449

448-
if pixel_keyword == 'PixelData':
450+
pixel_keyword = PixelDataKeywords(pixel_keyword)
451+
if pixel_keyword == PixelDataKeywords.PIXEL_DATA:
449452
pixel_representation = PixelRepresentationValues(
450453
pixel_representation
451454
).value
@@ -481,7 +484,7 @@ def decode_frame(
481484
bits_stored=bits_stored,
482485
photometric_interpretation=photometric_interpretation,
483486
pixel_representation=pixel_representation,
484-
pixel_keyword=pixel_keyword,
487+
pixel_keyword=pixel_keyword.value,
485488
**kwargs,
486489
)
487490
return array

src/highdicom/image.py

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,10 @@
5555
from highdicom.base import SOPClass, _check_little_endian
5656
from highdicom.color import ColorManager
5757
from highdicom.content import (
58+
_add_icc_profile_attributes,
59+
_add_palette_color_lookup_table_attributes,
5860
LUT,
61+
PaletteColorLUTTransformation,
5962
PixelMeasuresSequence,
6063
PlaneOrientationSequence,
6164
PlanePositionSequence,
@@ -64,6 +67,10 @@
6467
from highdicom.enum import (
6568
CoordinateSystemNames,
6669
DimensionOrganizationTypeValues,
70+
PhotometricInterpretationValues,
71+
PixelDataKeywords,
72+
PixelRepresentationValues,
73+
PlanarConfigurationValues,
6774
)
6875
from highdicom.frame import decode_frame, encode_frame
6976
from highdicom.io import ImageFileReader, _wrapped_dcmread
@@ -485,11 +492,11 @@ def __init__(
485492

486493
# Determine what pixel data keyword is present in the image
487494
for kw in [
488-
'PixelData',
489-
'FloatPixelData',
490-
'DoubleFloatPixelData',
495+
PixelDataKeywords.PIXEL_DATA,
496+
PixelDataKeywords.FLOAT_PIXEL_DATA,
497+
PixelDataKeywords.DOUBLE_FLOAT_PIXEL_DATA,
491498
]:
492-
if kw in image:
499+
if kw.value in image:
493500
self.pixel_keyword = kw
494501
break
495502
else:
@@ -504,14 +511,16 @@ def __init__(
504511
):
505512
if image.BitsAllocated == 32:
506513
self.input_dtype = np.dtype(np.float32)
507-
self.pixel_keyword = 'FloatPixelData'
514+
self.pixel_keyword = PixelDataKeywords.FLOAT_PIXEL_DATA
508515
elif image.BitsAllocated == 64:
509516
self.input_dtype = np.dtype(np.float64)
510-
self.pixel_keyword = 'DoubleFloatPixelData'
517+
self.pixel_keyword = (
518+
PixelDataKeywords.DOUBLE_FLOAT_PIXEL_DATA
519+
)
511520
else:
512-
self.pixel_keyword = 'PixelData'
521+
self.pixel_keyword = PixelDataKeywords.PIXEL_DATA
513522

514-
if self.pixel_keyword == 'PixelData':
523+
if self.pixel_keyword == PixelDataKeywords.PIXEL_DATA:
515524
if image.PixelRepresentation == 1:
516525
if image.BitsAllocated == 8:
517526
self.input_dtype = np.dtype(np.int8)
@@ -1244,9 +1253,24 @@ def coordinate_system(self) -> CoordinateSystemNames | None:
12441253

12451254
def _init_multiframe_image(
12461255
self,
1247-
source_images: Sequence[Dataset],
12481256
pixel_array: np.ndarray | Volume,
12491257
*,
1258+
source_images: Sequence[Dataset],
1259+
image_type: Sequence[str],
1260+
photometric_interpretation: PhotometricInterpretationValues | str,
1261+
bits_allocated: int,
1262+
bits_stored: int | None = None,
1263+
samples_per_pixel: int = 1,
1264+
planar_configuration: (
1265+
PlanarConfigurationValues | int
1266+
) = PlanarConfigurationValues.COLOR_BY_PIXEL,
1267+
pixel_representation: (
1268+
PixelRepresentationValues | int
1269+
) = PixelRepresentationValues.UNSIGNED_INTEGER,
1270+
contains_recognizable_visual_features: bool | None = None,
1271+
burned_in_annotation: bool | None = None,
1272+
palette_color_lut_transformation: PaletteColorLUTTransformation | None,
1273+
icc_profile: bytes | None = None,
12501274
pixel_measures: PixelMeasuresSequence | None = None,
12511275
plane_orientation: PlaneOrientationSequence | None = None,
12521276
plane_positions: Sequence[PlanePositionSequence] | None = None,
@@ -1263,7 +1287,7 @@ def _init_multiframe_image(
12631287
pyramid_label: str | None = None,
12641288
pyramid_uid: str | None = None,
12651289
use_extended_offset_table: bool = False,
1266-
pixel_data_attr: str = 'PixelData',
1290+
pixel_data_keyword: PixelDataKeywords = PixelDataKeywords.PIXEL_DATA,
12671291
channel_values: Sequence[Any] | None = None, # TODO generalize
12681292
add_channel_callback: (
12691293
Callable[[Dataset, Any], Dataset] | None
@@ -1345,6 +1369,57 @@ def _init_multiframe_image(
13451369
"with the source images."
13461370
)
13471371

1372+
# (Float/DoubleFloat) Image Pixel module
1373+
self.BitsAllocated = bits_allocated
1374+
self.SamplesPerPixel = samples_per_pixel
1375+
if samples_per_pixel > 1:
1376+
self.PlanarConfiguration = PlanarConfigurationValues(
1377+
planar_configuration
1378+
).value
1379+
photometric_interpretation = PhotometricInterpretationValues(
1380+
photometric_interpretation
1381+
)
1382+
self.PhotometricInterpretation = photometric_interpretation.value
1383+
1384+
pixel_data_keyword = PixelDataKeywords(pixel_data_keyword)
1385+
1386+
if pixel_data_keyword == PixelDataKeywords.PIXEL_DATA:
1387+
# Attributes in the Image Pixel Module but not the
1388+
# Float/DoubleFloat Image Pixel modules
1389+
if bits_stored is None:
1390+
bits_stored = bits_allocated
1391+
self.BitsStored = bits_stored
1392+
self.HighBit = bits_allocated - 1
1393+
self.PixelRepresentation = PixelRepresentationValues(
1394+
pixel_representation
1395+
).value
1396+
1397+
# General Image module
1398+
self.ImageType = list(image_type)
1399+
if burned_in_annotation is not None:
1400+
self.BurnedInAnnotation = (
1401+
'YES' if burned_in_annotation else 'NO'
1402+
)
1403+
if contains_recognizable_visual_features is not None:
1404+
self.RecognizableVisualFeatures = (
1405+
'YES' if contains_recognizable_visual_features else 'NO'
1406+
)
1407+
self.PresentationLUTShape = (
1408+
'INVERSE'
1409+
if photometric_interpretation ==
1410+
PhotometricInterpretationValues.MONOCHROME1
1411+
else 'IDENTITY'
1412+
)
1413+
1414+
if palette_color_lut_transformation is not None:
1415+
_add_palette_color_lookup_table_attributes(
1416+
self,
1417+
palette_color_lut_transformation,
1418+
)
1419+
1420+
if icc_profile is not None:
1421+
_add_icc_profile_attributes(self, icc_profile)
1422+
13481423
self._add_source_image_references(
13491424
source_images=source_images,
13501425
further_source_images=further_source_images,
@@ -1916,7 +1991,7 @@ def preprocess_channel_callback(
19161991
if len(remainder_pixels) > 0:
19171992
frames.append(self._encode_pixels_native(remainder_pixels))
19181993

1919-
setattr(self, pixel_data_attr, b''.join(frames))
1994+
setattr(self, pixel_data_keyword.value, b''.join(frames))
19201995

19211996
# Add a null trailing byte if required (can't happen for floating pixel
19221997
# data)

0 commit comments

Comments
 (0)