diff --git a/src/highdicom/legacy/sop.py b/src/highdicom/legacy/sop.py index 921b0051..c7d54719 100644 --- a/src/highdicom/legacy/sop.py +++ b/src/highdicom/legacy/sop.py @@ -190,18 +190,15 @@ def _read_ambiguous_vr(dataset: Dataset, kw: str) -> int: val = getattr(dataset, kw) if ( dataset.PixelRepresentation == 1 and - dataset[kw].VR == "US" + dataset[kw].VR == "US" and + # Value in this range indicates that correction is + # needed, since it is invalid for VR of 'SS' + val > 32767 ): - # Incorrect VR is used - - if val > 32767: - # Value in this range indicates that correction is - # needed, since it is invalid for VR of 'SS' - - # Find the negative number that has the same 16-bit binary - # representation as the value found and assume this is what was - # intended in the original file - return val - 65536 + # Incorrect VR is used - Find the negative number that has the same + # 16-bit binary representation as the value found and assume this is + # what was intended in the original file + return val - 65536 return val @@ -342,6 +339,7 @@ def __init__( use_extended_offset_table: bool = False, require_volume: bool = False, include_dimension_index: bool | None = None, + include_enhanced_groups: bool = True, strict: bool = False, skip_private_attributes: bool = False, workers: int | Executor = 0, @@ -399,6 +397,15 @@ def __init__( passed with no index. If ``False``, no attempt will be made to sort the datasets, they will be encoded in the order they are passed without including a dimension index. + include_enhanced_groups: bool, optional + Include multi-frame functional groups that are not part of the + Legacy Enhanced IOD, but are part of the corresponding Enhanced + IOD, if information exists in the legacy files to include in them. + If ``False``, only those groups explicitly included in the Legacy + Converted IOD are created in the new file, and any attributes that + do not belong in them are placed into the + UnassignedSharedConvertedAttributesSequence or + UnassignedPerFrameConvertedAttributesSequence. strict: bool, optional Whether to use strict requirements on the new converted dataset. If True and any attributes required to create a standard-compliant @@ -483,7 +490,7 @@ def __init__( "ImagesInAcquisition", ] self._unused_keywords = { - kw for kw in self._keyword_shared_dict.keys() + kw for kw in self._keyword_shared_dict if kw not in excluded_keywords } @@ -816,6 +823,181 @@ def __init__( required=False, ) + if include_enhanced_groups: + if ( + self._destination.SOPClassUID == + LegacyConvertedEnhancedCTImageStorage + ): + self._add_functional_group( + "CTAcquisitionDetailsSequence", + [], + optional_attributes=[ + _AttributeConfig("DataCollectionDiameter"), + _AttributeConfig("GantryDetectorTilt"), + _AttributeConfig("TableHeight"), + _AttributeConfig("RotationDirection"), + _AttributeConfig("RevolutionTime"), + _AttributeConfig("SingleCollimationWidth"), + _AttributeConfig("TotalCollimationWidth"), + ], + required=False, + ) + self._add_functional_group( + "CTReconstructionSequence", + [], + optional_attributes=[ + _AttributeConfig("ReconstructionDiameter"), + _AttributeConfig("ConvolutionKernel"), + ], + required=False, + ) + self._add_functional_group( + "CTPositionSequence", + [], + optional_attributes=[ + _AttributeConfig("DataCollectionCenterPatient"), + _AttributeConfig("ReconstructionTargetCenterPatient"), + ], + required=False, + ) + self._add_functional_group( + "CTXRayDetailsSequence", + [], + optional_attributes=[ + _AttributeConfig("KVP"), + _AttributeConfig("FilterType"), + _AttributeConfig("FocalSpots"), + _AttributeConfig("FilterMaterial"), + _AttributeConfig("CalciumScoringMassFactorPatient"), + _AttributeConfig("CalciumScoringMassFactorDevice"), + _AttributeConfig("EnergyWeightingFactor"), + ], + required=False, + ) + self._add_functional_group( + "CTExposureSequence", + [], + optional_attributes=[ + _AttributeConfig( + "ExposureTimeInms", + src_kws=["ExposureTimeInms", "ExposureTime"] + ), + _AttributeConfig( + "XRayTubeCurrentInmA", + src_kws=["XRayTubeCurrent", "XRayTubeCurrentInmA"], + ), + _AttributeConfig("ExposureInmAs"), + _AttributeConfig("ImageAndFluoroscopyAreaDoseProduct"), + _AttributeConfig("WaterEquivalentDiameter"), + _AttributeConfig( + "WaterEquivalentDiameterCalculationMethodCodeSequence" # noqa: E501 + ), + _AttributeConfig("ExposureModulationType"), + _AttributeConfig("CTDIvol"), + _AttributeConfig("CTDIPhantomTypeCodeSequence"), + ], + custom_logic_callback=( + self._ct_exposure_custom_logic + ), + required=False, + ) + self._add_functional_group( + "CTGeometrySequence", + [], + optional_attributes=[ + _AttributeConfig("DistanceSourceToDetector") + ], + required=False, + ) + self._add_functional_group( + "CTTableDynamicsSequence", + [], + optional_attributes=[ + _AttributeConfig("TableSpeed"), + _AttributeConfig("TableFeedPerRotation"), + _AttributeConfig("SpiralPitchFactor"), + ], + required=False, + ) + + self._copy_existing_sequence_to_functional_groups( + "CTAdditionalXRaySourceSequence" + ) + self._copy_existing_sequence_to_functional_groups( + "MultienergyCTCharacteristicsSequence" + ) + self._copy_existing_sequence_to_functional_groups( + "MultienergyCTProcessingSequence" + ) + + if ( + self._destination.SOPClassUID == + LegacyConvertedEnhancedMRImageStorage + ): + self._add_functional_group( + "MRFOVGeometrySequence", + [], + optional_attributes=[ + _AttributeConfig("PercentSampling"), + _AttributeConfig("PercentPhaseFieldOfView"), + _AttributeConfig("InPlanePhaseEncodingDirection"), + ], + required=False, + ) + self._add_functional_group( + "MRAveragesSequence", + [], + optional_attributes=[_AttributeConfig("NumberOfAverages")], + required=False, + ) + self._add_functional_group( + "MRTransmitCoilSequence", + [], + optional_attributes=[_AttributeConfig("TransmitCoilName")], + required=False, + ) + self._add_functional_group( + "MRTimingAndRelatedParametersSequence", + [], + optional_attributes=[ + _AttributeConfig("RepetitionTime"), + _AttributeConfig("EchoTrainLength"), + _AttributeConfig("FlipAngle"), + ], + required=False, + ) + self._add_functional_group( + "MRImagingModifierSequence", + [], + optional_attributes=[_AttributeConfig("PixelBandwidth")], + required=False, + ) + self._add_functional_group( + "MRReceiveCoilSequence", + [], + optional_attributes=[_AttributeConfig("ReceiveCoilName")], + required=False, + ) + + if ( + self._destination.SOPClassUID == + LegacyConvertedEnhancedPETImageStorage + ): + self._add_functional_group( + "PETReconstructionSequence", + [], + optional_attributes=[ + _AttributeConfig("ReconstructionDiameter") + ], + required=False, + ) + self._add_functional_group( + "PETFrameAcquisitionSequence", + [], + optional_attributes=[_AttributeConfig("TableHeight")], + required=False, + ) + # Functional grops where entire existing sequences are copied from the # source image self._copy_existing_sequence_to_functional_groups( @@ -1814,7 +1996,7 @@ def _frame_anatomy_custom_logic( "'FrameLaterality' will be left empty in the " "new legacy converted enhanced dataset." ) - logging.warning(msg) + logger.warning(msg) destination.FrameLaterality = src_laterality @@ -1865,6 +2047,30 @@ def _frame_content_custom_logic( destination.FrameAcquisitionDateTime = fa_dt + def _ct_exposure_custom_logic( + self, + source: Dataset, + destination: Dataset, + ) -> None: + """Custom logic for the CTExposureSequence. + + This is needed to handle unit conversion for exposure. + + Parameters + ---------- + source: pydicom.Dataset + Dataset to copy from. + destination: pydicom.Dataset + Dataset to copy to. + + """ + if ( + "ExposureInmAs" not in destination and + "ExposureInuAs" in source + ): + destination.ExposureInmAs = source.ExposureInuAs / 1000.0 + self._mark_keyword_used("ExposureInuAs") + def _add_series_description(self) -> None: """Add a default series description.""" suffix = " (enhanced conversion)" @@ -2528,13 +2734,14 @@ def __init__( instance_number: int, *, transfer_syntax_uid: str | None = None, - use_extended_offset_table: bool = False, require_volume: bool = False, strict: bool = False, skip_private_attributes: bool = False, + include_dimension_index: bool | None = None, + include_enhanced_groups: bool = True, contributing_equipment: Sequence[ContributingEquipment] | None = None, + use_extended_offset_table: bool = False, workers: int | Executor = 0, - include_dimension_index: bool | None = None, **kwargs: Any, ) -> None: """ @@ -2564,15 +2771,23 @@ def __init__( are JPEG 2000 (``"1.2.840.10008.1.2.4.91"``) , JPEG 2000 Lossless (``"1.2.840.10008.1.2.4.90"``), and JPEG-LS Lossless (``"1.2.840.10008.1.2.4.80"``). - use_extended_offset_table: bool, optional - Include an extended offset table instead of a basic offset table - for encapsulated transfer syntaxes. Extended offset tables avoid - size limitations on basic offset tables, and separate the offset - table from the pixel data by placing it into metadata. However, - they may be less widely supported than basic offset tables. This - parameter is ignored if using a native (uncompressed) transfer - syntax. The default value may change in a future release. - include_dimension_index: bool | None = None, optional + require_volume: bool, optional + If True, raise an error if the legacy datasets do not represent a + set of parallel, regularly-spaced frames from the same series. This + is not a requirement for the conversion to be valid according to + the standard, but users may wish to additionally impose this + stricter requirement to ensure the resulting files are easier to + work with. + strict: bool, optional + Whether to use strict requirements on the new converted dataset. If + True and any attributes required to create a standard-compliant + Legacy Converted Enhanced instance are missing or inconsistent in + the legacy source files, an error will be raised. If False, a + warning will be raised in the same situation and the attribute will + be skipped in the new Legacy Converted Enhanced file. + skip_private_attributes: bool, optional + Whether to skip copying private attributes to the converted file. + include_dimension_index: bool | None, optional Whether to include a dimension index within the DimensionIndexSequence in the new converted dataset. A dimension index describes how the frames are ordered within the instance. @@ -2599,25 +2814,26 @@ def __init__( passed with no index. If ``False``, no attempt will be made to sort the datasets, they will be encoded in the order they are passed without including a dimension index. - require_volume: bool, optional - If True, raise an error if the legacy datasets do not represent a - set of parallel, regularly-spaced frames from the same series. This - is not a requirement for the conversion to be valid according to - the standard, but users may wish to additionally impose this - stricter requirement to ensure the resulting files are easier to - work with. - strict: bool, optional - Whether to use strict requirements on the new converted dataset. If - True and any attributes required to create a standard-compliant - Legacy Converted Enhanced instance are missing or inconsistent in - the legacy source files, an error will be raised. If False, a - warning will be raised in the same situation and the attribute will - be skipped in the new Legacy Converted Enhanced file. - skip_private_attributes: bool, optional - Whether to skip copying private attributes to the converted file. + include_enhanced_groups: bool, optional + Include multi-frame functional groups that are not part of the + Legacy Enhanced IOD, but are part of the corresponding Enhanced + IOD, if information exists in the legacy files to include in them. + If ``False``, only those groups explicitly included in the Legacy + Converted IOD are created in the new file, and any attributes that + do not belong in them are placed into the + UnassignedSharedConvertedAttributesSequence or + UnassignedPerFrameConvertedAttributesSequence. contributing_equipment: Sequence[highdicom.ContributingEquipment] | None, optional Additional equipment that has contributed to the acquisition, creation or modification of this instance. + use_extended_offset_table: bool, optional + Include an extended offset table instead of a basic offset table + for encapsulated transfer syntaxes. Extended offset tables avoid + size limitations on basic offset tables, and separate the offset + table from the pixel data by placing it into metadata. However, + they may be less widely supported than basic offset tables. This + parameter is ignored if using a native (uncompressed) transfer + syntax. The default value may change in a future release. workers: int | concurrent.futures.Executor, optional Number of worker processes to use for frame compression, if compression or transcoding is needed. If 0, no workers are used and @@ -2731,13 +2947,14 @@ def __init__( _LegacyConversionRunner( legacy_datasets, destination=self, - workers=workers, + require_volume=require_volume, transfer_syntax_uid=transfer_syntax_uid, - use_extended_offset_table=use_extended_offset_table, include_dimension_index=include_dimension_index, - skip_private_attributes=skip_private_attributes, + include_enhanced_groups=include_enhanced_groups, strict=strict, - require_volume=require_volume, + skip_private_attributes=skip_private_attributes, + use_extended_offset_table=use_extended_offset_table, + workers=workers, ) self._build_luts() diff --git a/tests/test_legacy.py b/tests/test_legacy.py index 5cef33cd..19e78eb2 100644 --- a/tests/test_legacy.py +++ b/tests/test_legacy.py @@ -2084,6 +2084,80 @@ def test_transcode_custom_workers(): assert np.array_equal(converted.pixel_array, expected_pixels) +def test_enhanced_functional_groups(): + """Test functional groups from enhanced IODs.""" + # Some test files that have a lot of attributes that can be placed into the + # enhanced CT functional groups + ct_files = [ + get_testdata_file('dicomdirtests/77654033/CT2/17136', read=True), + get_testdata_file('dicomdirtests/77654033/CT2/17196', read=True), + get_testdata_file('dicomdirtests/77654033/CT2/17166', read=True), + ] + + converted = LegacyConvertedEnhancedCTImage( + ct_files, + series_instance_uid=UID(), + series_number=1, + sop_instance_uid=UID(), + instance_number=1, + ) + + sfgs = ( + converted + .SharedFunctionalGroupsSequence[0] + ) + unassigned_shared_seq = ( + sfgs + .UnassignedSharedConvertedAttributesSequence[0] + ) + + expected_attributes = [ + ('CTAcquisitionDetailsSequence', 'DataCollectionDiameter'), + ('CTAcquisitionDetailsSequence', 'TableHeight'), + ('CTGeometrySequence', 'DistanceSourceToDetector'), + ('CTReconstructionSequence', 'ConvolutionKernel'), + ('CTXRayDetailsSequence', 'KVP'), + ] + + for seq_kw, attr_kw in expected_attributes: + # Check the enhanced group is present and the element is not placed in + # the unassigned groups + seq = getattr(sfgs, seq_kw)[0] + val = getattr(seq, attr_kw) + orig_val = getattr(ct_files[0], attr_kw) + assert val == orig_val + + assert not hasattr(unassigned_shared_seq, seq_kw) + + # Now try without the enhanced functional IOD + converted = LegacyConvertedEnhancedCTImage( + ct_files, + series_instance_uid=UID(), + series_number=1, + sop_instance_uid=UID(), + instance_number=1, + include_enhanced_groups=False, + ) + + sfgs = ( + converted + .SharedFunctionalGroupsSequence[0] + ) + unassigned_shared_seq = ( + sfgs + .UnassignedSharedConvertedAttributesSequence[0] + ) + + for seq_kw, attr_kw in expected_attributes: + # Check the enhanced group is not present so the element is placed in + # the unassigned groups + assert not hasattr(sfgs, seq_kw) + + val = getattr(unassigned_shared_seq, attr_kw) + orig_val = getattr(ct_files[0], attr_kw) + assert val == orig_val + + def test_from_dataset(modality: Modality) -> None: LegacyConvertedClass = MODALITY_CLASS_MAP[modality] data_generator = DicomGenerator(5)