diff --git a/setup.py b/setup.py index 70ba30b2..91dd0ceb 100644 --- a/setup.py +++ b/setup.py @@ -40,6 +40,10 @@ install_requires=[ 'pydicom>=2.2.2', 'numpy>=1.19', - 'pillow>=8.3' + 'pillow>=8.3', + 'pillow-jpls>=1.0', + 'pylibjpeg>=1.3', + 'pylibjpeg-libjpeg>=1.2', + 'pylibjpeg-openjpeg>=1.1', ], ) diff --git a/src/highdicom/frame.py b/src/highdicom/frame.py index 376e9b6e..c5456ae9 100644 --- a/src/highdicom/frame.py +++ b/src/highdicom/frame.py @@ -3,6 +3,7 @@ from typing import Optional, Union import numpy as np +import pillow_jpls # noqa from PIL import Image from pydicom.dataset import Dataset, FileMetaDataset from pydicom.encaps import encapsulate @@ -13,6 +14,7 @@ ImplicitVRLittleEndian, JPEG2000Lossless, JPEGBaseline8Bit, + JPEGLSLossless, UID, RLELossless, ) @@ -23,7 +25,6 @@ PlanarConfigurationValues, ) - logger = logging.getLogger(__name__) @@ -101,6 +102,7 @@ def encode_frame( compressed_transfer_syntaxes = { JPEGBaseline8Bit, JPEG2000Lossless, + JPEGLSLossless, RLELossless, } supported_transfer_syntaxes = uncompressed_transfer_syntaxes.union( @@ -114,6 +116,12 @@ def encode_frame( ) ) if transfer_syntax_uid in uncompressed_transfer_syntaxes: + if samples_per_pixel > 1: + if planar_configuration != 0: + raise ValueError( + 'Planar configuration must be 0 for color image frames ' + 'with native encoding.' + ) if bits_allocated == 1: if (rows * cols * samples_per_pixel) % 8 != 0: raise ValueError( @@ -140,6 +148,12 @@ def encode_frame( 'irreversible': False, }, ), + JPEGLSLossless: ( + 'JPEG-LS', + { + 'near_lossless': 0, + } + ) } if transfer_syntax_uid == JPEGBaseline8Bit: @@ -185,7 +199,7 @@ def encode_frame( 'encoding of image frames with JPEG Baseline codec.' ) - if transfer_syntax_uid == JPEG2000Lossless: + elif transfer_syntax_uid == JPEG2000Lossless: if samples_per_pixel == 1: if planar_configuration is not None: raise ValueError( @@ -223,6 +237,49 @@ def encode_frame( 'encoding of image frames with Lossless JPEG2000 codec.' ) + elif transfer_syntax_uid == JPEGLSLossless: + if samples_per_pixel == 1: + if planar_configuration is not None: + raise ValueError( + 'Planar configuration must be absent for encoding of ' + 'monochrome image frames with Lossless JPEG-LS codec.' + ) + if photometric_interpretation not in ( + 'MONOCHROME1', 'MONOCHROME2' + ): + raise ValueError( + 'Photometric intpretation must be either "MONOCHROME1" ' + 'or "MONOCHROME2" for encoding of monochrome image ' + 'frames with Lossless JPEG-LS codec.' + ) + elif samples_per_pixel == 3: + if photometric_interpretation != 'YBR_FULL': + raise ValueError( + 'Photometric interpretation must be "YBR_FULL" for ' + 'encoding of color image frames with ' + 'Lossless JPEG-LS codec.' + ) + if planar_configuration != 0: + raise ValueError( + 'Planar configuration must be 0 for encoding of ' + 'color image frames with Lossless JPEG-LS codec.' + ) + if bits_allocated != 8: + raise ValueError( + 'Bits Allocated must be 8 for encoding of ' + 'color image frames with Lossless JPEG-LS codec.' + ) + else: + raise ValueError( + 'Samples per pixel must be 1 or 3 for ' + 'encoding of image frames with Lossless JPEG-LS codec.' + ) + if pixel_representation != 0: + raise ValueError( + 'Pixel representation must be 0 for ' + 'encoding of image frames with Lossless JPEG-LS codec.' + ) + if transfer_syntax_uid in compression_lut.keys(): image_format, kwargs = compression_lut[transfer_syntax_uid] if samples_per_pixel == 3: diff --git a/src/highdicom/legacy/sop.py b/src/highdicom/legacy/sop.py index c85f03fb..c37d9436 100644 --- a/src/highdicom/legacy/sop.py +++ b/src/highdicom/legacy/sop.py @@ -6,8 +6,16 @@ from pydicom.datadict import tag_for_keyword from pydicom.dataset import Dataset +from pydicom.encaps import encapsulate +from pydicom.uid import ( + ImplicitVRLittleEndian, + ExplicitVRLittleEndian, + JPEG2000Lossless, + JPEGLSLossless, +) from highdicom.base import SOPClass +from highdicom.frame import encode_frame from highdicom._iods import IOD_MODULE_MAP, SOP_CLASS_UID_IOD_KEY_MAP from highdicom._modules import MODULE_ATTRIBUTE_MAP @@ -26,9 +34,9 @@ def _convert_legacy_to_enhanced( - sf_datasets: Sequence[Dataset], - mf_dataset: Optional[Dataset] = None - ) -> Dataset: + sf_datasets: Sequence[Dataset], + mf_dataset: Optional[Dataset] = None +) -> Dataset: """Converts one or more MR, CT or PET Image instances into one Legacy Converted Enhanced MR/CT/PET Image instance by copying information from `sf_datasets` into `mf_dataset`. @@ -383,15 +391,25 @@ def _convert_legacy_to_enhanced( mf_dataset.AcquisitionContextSequence = [] - # TODO: Encapsulated Pixel Data with compressed frame items. - # Create the Pixel Data element of the mulit-frame image instance using # native encoding (simply concatenating pixels of individual frames) # Sometimes there may be numpy types such as ">i2". The (* 1) hack # ensures that pixel values have the correct integer type. - mf_dataset.PixelData = b''.join([ - (ds.pixel_array * 1).data for ds in sf_datasets - ]) + encoded_frames = [ + encode_frame( + ds.pixel_array * 1, + transfer_syntax_uid=mf_dataset.file_meta.TransferSyntaxUID, + bits_allocated=ds.BitsAllocated, + bits_stored=ds.BitsStored, + photometric_interpretation=ds.PhotometricInterpretation, + pixel_representation=ds.PixelRepresentation + ) + for ds in sf_datasets + ] + if mf_dataset.file_meta.TransferSyntaxUID.is_encapsulated: + mf_dataset.PixelData = encapsulate(encoded_frames) + else: + mf_dataset.PixelData = b''.join(encoded_frames) return mf_dataset @@ -407,6 +425,7 @@ def __init__( series_number: int, sop_instance_uid: str, instance_number: int, + transfer_syntax_uid: str = ExplicitVRLittleEndian, **kwargs: Any ) -> None: """ @@ -423,6 +442,11 @@ def __init__( UID that should be assigned to the instance instance_number: int Number that should be assigned to the instance + transfer_syntax_uid: str, optional + UID of transfer syntax that should be used for encoding of + data elements. The following compressed transfer syntaxes + are supported: JPEG 2000 Lossless (``"1.2.840.10008.1.2.4.90"``) + and JPEG-LS Lossless (``"1.2.840.10008.1.2.4.80"``). **kwargs: Any, optional Additional keyword arguments that will be passed to the constructor of `highdicom.base.SOPClass` @@ -445,6 +469,17 @@ def __init__( sop_class_uid = LEGACY_ENHANCED_SOP_CLASS_UID_MAP[ref_ds.SOPClassUID] + supported_transfer_syntaxes = { + ImplicitVRLittleEndian, + ExplicitVRLittleEndian, + JPEG2000Lossless, + JPEGLSLossless, + } + if transfer_syntax_uid not in supported_transfer_syntaxes: + raise ValueError( + f'Transfer syntax "{transfer_syntax_uid}" is not supported' + ) + super().__init__( study_instance_uid=ref_ds.StudyInstanceUID, series_instance_uid=series_instance_uid, @@ -454,7 +489,7 @@ def __init__( instance_number=instance_number, manufacturer=ref_ds.Manufacturer, modality=ref_ds.Modality, - transfer_syntax_uid=None, # FIXME: frame encoding + transfer_syntax_uid=transfer_syntax_uid, patient_id=ref_ds.PatientID, patient_name=ref_ds.PatientName, patient_birth_date=ref_ds.PatientBirthDate, @@ -483,6 +518,7 @@ def __init__( series_number: int, sop_instance_uid: str, instance_number: int, + transfer_syntax_uid: str = ExplicitVRLittleEndian, **kwargs: Any ) -> None: """ @@ -499,6 +535,11 @@ def __init__( UID that should be assigned to the instance instance_number: int Number that should be assigned to the instance + transfer_syntax_uid: str, optional + UID of transfer syntax that should be used for encoding of + data elements. The following compressed transfer syntaxes + are supported: JPEG 2000 Lossless (``"1.2.840.10008.1.2.4.90"``) + and JPEG-LS Lossless (``"1.2.840.10008.1.2.4.80"``). **kwargs: Any, optional Additional keyword arguments that will be passed to the constructor of `highdicom.base.SOPClass` @@ -530,7 +571,7 @@ def __init__( instance_number=instance_number, manufacturer=ref_ds.Manufacturer, modality=ref_ds.Modality, - transfer_syntax_uid=None, # FIXME: frame encoding + transfer_syntax_uid=transfer_syntax_uid, patient_id=ref_ds.PatientID, patient_name=ref_ds.PatientName, patient_birth_date=ref_ds.PatientBirthDate, @@ -550,14 +591,15 @@ class LegacyConvertedEnhancedPETImage(SOPClass): """SOP class for Legacy Converted Enhanced PET Image instances.""" def __init__( - self, - legacy_datasets: Sequence[Dataset], - series_instance_uid: str, - series_number: int, - sop_instance_uid: str, - instance_number: int, - **kwargs: Any - ) -> None: + self, + legacy_datasets: Sequence[Dataset], + series_instance_uid: str, + series_number: int, + sop_instance_uid: str, + instance_number: int, + transfer_syntax_uid: str = ExplicitVRLittleEndian, + **kwargs: Any + ) -> None: """ Parameters ---------- @@ -572,6 +614,11 @@ def __init__( UID that should be assigned to the instance instance_number: int Number that should be assigned to the instance + transfer_syntax_uid: str, optional + UID of transfer syntax that should be used for encoding of + data elements. The following compressed transfer syntaxes + are supported: JPEG 2000 Lossless (``"1.2.840.10008.1.2.4.90"``) + and JPEG-LS Lossless (``"1.2.840.10008.1.2.4.80"``). **kwargs: Any, optional Additional keyword arguments that will be passed to the constructor of `highdicom.base.SOPClass` @@ -594,6 +641,17 @@ def __init__( sop_class_uid = LEGACY_ENHANCED_SOP_CLASS_UID_MAP[ref_ds.SOPClassUID] + supported_transfer_syntaxes = { + ImplicitVRLittleEndian, + ExplicitVRLittleEndian, + JPEG2000Lossless, + JPEGLSLossless, + } + if transfer_syntax_uid not in supported_transfer_syntaxes: + raise ValueError( + f'Transfer syntax "{transfer_syntax_uid}" is not supported' + ) + super().__init__( study_instance_uid=ref_ds.StudyInstanceUID, series_instance_uid=series_instance_uid, @@ -603,7 +661,7 @@ def __init__( instance_number=instance_number, manufacturer=ref_ds.Manufacturer, modality=ref_ds.Modality, - transfer_syntax_uid=None, # FIXME: frame encoding + transfer_syntax_uid=transfer_syntax_uid, patient_id=ref_ds.PatientID, patient_name=ref_ds.PatientName, patient_birth_date=ref_ds.PatientBirthDate, diff --git a/src/highdicom/pm/sop.py b/src/highdicom/pm/sop.py index e8140dc1..ecbda732 100644 --- a/src/highdicom/pm/sop.py +++ b/src/highdicom/pm/sop.py @@ -21,6 +21,7 @@ ExplicitVRLittleEndian, ImplicitVRLittleEndian, JPEG2000Lossless, + JPEGLSLossless, RLELossless, ) from pydicom.valuerep import format_number_as_ds @@ -175,8 +176,8 @@ def __init__( stored values to be displayed on 8-bit monitors. transfer_syntax_uid: Union[str, None], optional UID of transfer syntax that should be used for encoding of - data elements. Defaults to Implicit VR Little Endian - (UID ``"1.2.840.10008.1.2"``) + data elements. Defaults to Explicit VR Little Endian + (UID ``"1.2.840.10008.1.2.1"``) content_description: Union[str, None], optional Brief description of the parametric map image content_creator_name: Union[str, None], optional @@ -275,11 +276,10 @@ def __init__( # If pixel data has unsigned or signed integer data type, then it # can be lossless compressed. The standard does not specify any # compression codecs for floating-point data types. - # In case of signed integer data type, values will be rescaled to - # a signed integer range prior to compression. supported_transfer_syntaxes.update( { JPEG2000Lossless, + JPEGLSLossless, RLELossless, } ) diff --git a/src/highdicom/sc/sop.py b/src/highdicom/sc/sop.py index 53ce5082..8fdc0e28 100644 --- a/src/highdicom/sc/sop.py +++ b/src/highdicom/sc/sop.py @@ -17,6 +17,7 @@ RLELossless, JPEGBaseline8Bit, JPEG2000Lossless, + JPEGLSLossless, ) from highdicom.base import SOPClass @@ -95,7 +96,7 @@ def __init__( specimen_descriptions: Optional[ Sequence[SpecimenDescription] ] = None, - transfer_syntax_uid: str = ImplicitVRLittleEndian, + transfer_syntax_uid: str = ExplicitVRLittleEndian, **kwargs: Any ): """ @@ -172,7 +173,8 @@ def __init__( UID of transfer syntax that should be used for encoding of data elements. The following compressed transfer syntaxes are supported: RLE Lossless (``"1.2.840.10008.1.2.5"``), JPEG - 2000 Lossless (``"1.2.840.10008.1.2.4.90"``), JPEG Baseline + 2000 Lossless (``"1.2.840.10008.1.2.4.90"``), JPEG-LS Lossless + (``"1.2.840.10008.1.2.4.80"``), and JPEG Baseline (``"1.2.840.10008.1.2.4.50"``). Note that JPEG Baseline is a lossy compression method that will lead to a loss of detail in the image. @@ -187,6 +189,7 @@ def __init__( RLELossless, JPEGBaseline8Bit, JPEG2000Lossless, + JPEGLSLossless, } if transfer_syntax_uid not in supported_transfer_syntaxes: raise ValueError( diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index 0b70b330..8342381a 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -18,6 +18,7 @@ ExplicitVRLittleEndian, ImplicitVRLittleEndian, JPEG2000Lossless, + JPEGLSLossless, RLELossless, UID, ) @@ -290,6 +291,7 @@ def __init__( ImplicitVRLittleEndian, ExplicitVRLittleEndian, JPEG2000Lossless, + JPEGLSLossless, RLELossless, } if transfer_syntax_uid not in supported_transfer_syntaxes: diff --git a/tests/test_frame.py b/tests/test_frame.py index 6d1a6d79..3a5162e6 100644 --- a/tests/test_frame.py +++ b/tests/test_frame.py @@ -5,6 +5,7 @@ import pytest from pydicom.uid import ( JPEG2000Lossless, + JPEGLSLossless, JPEGBaseline8Bit, ) @@ -148,7 +149,7 @@ def test_jpeg2000_monochrome(self): transfer_syntax_uid=JPEG2000Lossless, bits_allocated=bits_allocated, bits_stored=bits_allocated, - photometric_interpretation='MONOCHROME1', + photometric_interpretation='MONOCHROME2', pixel_representation=0, ) assert compressed_frame.startswith(b'\x00\x00\x00\x0C\x6A\x50\x20') @@ -161,7 +162,63 @@ def test_jpeg2000_monochrome(self): samples_per_pixel=1, bits_allocated=bits_allocated, bits_stored=bits_allocated, - photometric_interpretation='MONOCHROME1', + photometric_interpretation='MONOCHROME2', + pixel_representation=0, + planar_configuration=0 + ) + np.testing.assert_array_equal(frame, decoded_frame) + + def test_jpegls_rgb(self): + bits_allocated = 8 + frame = np.ones((16, 32, 3), dtype=np.dtype(f'uint{bits_allocated}')) + frame *= 255 + compressed_frame = encode_frame( + frame, + transfer_syntax_uid=JPEGLSLossless, + bits_allocated=bits_allocated, + bits_stored=bits_allocated, + photometric_interpretation='YBR_FULL', + pixel_representation=0, + planar_configuration=0 + ) + assert compressed_frame.startswith(b'\xFF\xD8') + assert compressed_frame.endswith(b'\xFF\xD9') + decoded_frame = decode_frame( + value=compressed_frame, + transfer_syntax_uid=JPEGLSLossless, + rows=frame.shape[0], + columns=frame.shape[1], + samples_per_pixel=frame.shape[2], + bits_allocated=bits_allocated, + bits_stored=bits_allocated, + photometric_interpretation='YBR_FULL', + pixel_representation=0, + planar_configuration=0 + ) + np.testing.assert_array_equal(frame, decoded_frame) + + def test_jpegls_monochrome(self): + bits_allocated = 16 + frame = np.zeros((16, 32), dtype=np.dtype(f'uint{bits_allocated}')) + compressed_frame = encode_frame( + frame, + transfer_syntax_uid=JPEGLSLossless, + bits_allocated=bits_allocated, + bits_stored=bits_allocated, + photometric_interpretation='MONOCHROME2', + pixel_representation=0, + ) + assert compressed_frame.startswith(b'\xFF\xD8') + assert compressed_frame.endswith(b'\xFF\xD9') + decoded_frame = decode_frame( + value=compressed_frame, + transfer_syntax_uid=JPEG2000Lossless, + rows=frame.shape[0], + columns=frame.shape[1], + samples_per_pixel=1, + bits_allocated=bits_allocated, + bits_stored=bits_allocated, + photometric_interpretation='MONOCHROME2', pixel_representation=0, planar_configuration=0 ) diff --git a/tests/test_pm.py b/tests/test_pm.py index 36bea7ed..b41489d9 100644 --- a/tests/test_pm.py +++ b/tests/test_pm.py @@ -9,6 +9,10 @@ from pydicom.data import get_testdata_files from pydicom.sr.codedict import codes from pydicom.sr.coding import Code +from pydicom.uid import ( + JPEG2000Lossless, + JPEGLSLossless, +) from highdicom.content import ( PlanePositionSequence, @@ -410,7 +414,7 @@ def test_multi_frame_sm_image_ushort_native(self): assert instance.ImageType[2] == 'WHOLE_BODY' assert instance.ImageType[3] == 'NONE' - def test_multi_frame_sm_image_ushort_encapsulated(self): + def test_multi_frame_sm_image_ushort_encapsulated_jpeg2000(self): pixel_array = np.random.randint( low=0, high=2**8, @@ -443,11 +447,49 @@ def test_multi_frame_sm_image_ushort_encapsulated(self): real_world_value_mappings=[real_world_value_mapping], window_center=window_center, window_width=window_width, - transfer_syntax_uid='1.2.840.10008.1.2.4.90' + transfer_syntax_uid=JPEG2000Lossless ) assert pmap.BitsAllocated == 8 assert np.array_equal(pmap.pixel_array, pixel_array) + def test_multi_frame_sm_image_ushort_encapsulated_jpegls(self): + pixel_array = np.random.randint( + low=0, + high=2**8, + size=self._sm_image.pixel_array.shape[:3], + dtype=np.uint16 + ) + window_center = 128 + window_width = 256 + + real_world_value_mapping = RealWorldValueMapping( + lut_label='1', + lut_explanation='feature_001', + unit=codes.UCUM.NoUnits, + value_range=[0, 255], + intercept=0, + slope=1 + ) + pmap = ParametricMap( + [self._sm_image], + pixel_array, + 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, + contains_recognizable_visual_features=False, + real_world_value_mappings=[real_world_value_mapping], + window_center=window_center, + window_width=window_width, + transfer_syntax_uid=JPEGLSLossless + ) + assert pmap.BitsAllocated == 16 + assert np.array_equal(pmap.pixel_array, pixel_array) + def test_single_frame_ct_image_double(self): pixel_array = np.random.uniform(-1, 1, self._ct_image.pixel_array.shape) window_center = 0.0 diff --git a/tests/test_sc.py b/tests/test_sc.py index 51ffdff1..3e9c9faa 100644 --- a/tests/test_sc.py +++ b/tests/test_sc.py @@ -8,7 +8,8 @@ from pydicom.uid import ( RLELossless, JPEGBaseline8Bit, - JPEG2000Lossless + JPEG2000Lossless, + JPEGLSLossless, ) from pydicom.valuerep import DA, TM @@ -407,6 +408,61 @@ def test_rgb_jpeg2000(self): frame ) + def test_monochrome_jpegls(self): + bits_allocated = 16 + photometric_interpretation = 'MONOCHROME2' + coordinate_system = 'PATIENT' + frame = np.random.randint(0, 2**16, size=(256, 256), dtype=np.uint16) + instance = SCImage( + pixel_array=frame, + photometric_interpretation=photometric_interpretation, + bits_allocated=bits_allocated, + coordinate_system=coordinate_system, + study_instance_uid=self._study_instance_uid, + series_instance_uid=self._series_instance_uid, + sop_instance_uid=self._sop_instance_uid, + series_number=self._series_number, + instance_number=self._instance_number, + manufacturer=self._manufacturer, + patient_orientation=self._patient_orientation, + transfer_syntax_uid=JPEGLSLossless + ) + + assert instance.file_meta.TransferSyntaxUID == JPEGLSLossless + + assert np.array_equal( + self.get_array_after_writing(instance), + frame + ) + + def test_rgb_jpegls(self): + bits_allocated = 8 + photometric_interpretation = 'YBR_FULL' + coordinate_system = 'PATIENT' + frame = np.random.randint(0, 256, size=(256, 256, 3), dtype=np.uint8) + instance = SCImage( + pixel_array=frame, + photometric_interpretation=photometric_interpretation, + bits_allocated=bits_allocated, + coordinate_system=coordinate_system, + study_instance_uid=self._study_instance_uid, + series_instance_uid=self._series_instance_uid, + sop_instance_uid=self._sop_instance_uid, + series_number=self._series_number, + instance_number=self._instance_number, + manufacturer=self._manufacturer, + patient_orientation=self._patient_orientation, + transfer_syntax_uid=JPEGLSLossless + ) + + assert instance.file_meta.TransferSyntaxUID == JPEGLSLossless + + assert np.array_equal( + self.get_array_after_writing(instance), + frame + ) + + def test_construct_rgb_from_ref_dataset(self): bits_allocated = 8 photometric_interpretation = 'RGB' diff --git a/tests/test_seg.py b/tests/test_seg.py index 76a3458e..762503a9 100644 --- a/tests/test_seg.py +++ b/tests/test_seg.py @@ -14,7 +14,8 @@ ExplicitVRLittleEndian, ImplicitVRLittleEndian, RLELossless, - JPEG2000Lossless + JPEG2000Lossless, + JPEGLSLossless, ) from highdicom.content import ( @@ -1249,7 +1250,8 @@ def test_pixel_types(self): ExplicitVRLittleEndian, ImplicitVRLittleEndian, RLELossless, - JPEG2000Lossless + JPEG2000Lossless, + JPEGLSLossless, ] max_fractional_value = 255