From 18fc8f454a307c33437c3bd00693b44426a24389 Mon Sep 17 00:00:00 2001 From: hackermd Date: Mon, 22 Aug 2022 12:29:17 -0400 Subject: [PATCH 01/13] Allow more than one multi-frame source images --- src/highdicom/seg/sop.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index 4e9ce557..29f99ed1 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -292,11 +292,6 @@ def __init__( src_img = source_images[0] is_multiframe = hasattr(src_img, 'NumberOfFrames') - if is_multiframe and len(source_images) > 1: - raise ValueError( - 'Only one source image should be provided in case images ' - 'are multi-frame images.' - ) is_tiled = hasattr(src_img, 'TotalPixelMatrixRows') supported_transfer_syntaxes = { ImplicitVRLittleEndian, From 15a71691a1622fc93814a88d998662d14093b2c0 Mon Sep 17 00:00:00 2001 From: hackermd Date: Tue, 30 Aug 2022 09:11:39 -0400 Subject: [PATCH 02/13] Add checks to ensure similarity of source images --- src/highdicom/seg/sop.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index 29f99ed1..a9e8821d 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -280,14 +280,29 @@ def __init__( image.SeriesInstanceUID, image.Rows, image.Columns, + int(getattr(image, 'NumberOfFrames', '1')), + hasattr(image, 'FrameOfReferenceUID'), getattr(image, 'FrameOfReferenceUID', None), + hasattr(image, 'TotalPixelMatrixRows'), + getattr(image, 'TotalPixelMatrixRows', None), + hasattr(image, 'TotalPixelMatrixColumns'), + getattr(image, 'TotalPixelMatrixColumns', None), + hasattr(image, 'TotalPixelMatrixFocalPlanes'), + getattr(image, 'TotalPixelMatrixFocalPlanes', None), + tuple(getattr(image, 'ImageOrientation', [])), + tuple(getattr(image, 'ImageOrientationSlide', [])), + hasattr(image, 'DimensionOrganizationType'), + getattr(image, 'DimensionOrganizationType', None), + len(getattr(image, 'PerFrameFunctionalGroupsSequence', [])), + len(getattr(image, 'SharedFunctionalGroupsSequence', [])), ) for image in source_images ) if len(uniqueness_criteria) > 1: raise ValueError( - 'Source images must all be part of the same series and must ' - 'have the same image dimensions (number of rows/columns).' + 'Source images must all be part of the same series, must ' + 'have the same image dimensions (number of rows/columns), and ' + 'must have the same image orientation.' ) src_img = source_images[0] From ddc14c5b6e373bd27265f0dcaaff793d72ccab71 Mon Sep 17 00:00:00 2001 From: hackermd Date: Tue, 30 Aug 2022 09:12:09 -0400 Subject: [PATCH 03/13] Simplify formatting of log messages --- src/highdicom/seg/sop.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index a9e8821d..3749734b 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -797,17 +797,11 @@ def __init__( # absent. Such frames should be removed if omit_empty_frames and np.sum(planes[j]) == 0: logger.info( - 'skip empty plane {} of segment #{}'.format( - j, segment_number - ) + f'skip empty plane {j} of segment #{segment_number}' ) continue contained_plane_index.append(j) - logger.info( - 'add plane #{} for segment #{}'.format( - j, segment_number - ) - ) + logger.info(f'add plane #{j} for segment #{segment_number}') pffp_item = Dataset() frame_content_item = Dataset() @@ -847,9 +841,9 @@ def __init__( ] except IndexError as error: raise IndexError( - 'Could not determine position of plane #{} in ' + f'Could not determine position of plane #{j} in ' 'three dimensional coordinate system based on ' - 'dimension index values: {}'.format(j, error) + f'dimension index values: {error}' ) frame_content_item.DimensionIndexValues = ( [segment_number] + index_values From ead8a30afd80f36bd41b44fdeaff23e50a7d509b Mon Sep 17 00:00:00 2001 From: hackermd Date: Tue, 30 Aug 2022 09:12:22 -0400 Subject: [PATCH 04/13] Include references to all source image frames --- src/highdicom/seg/sop.py | 59 ++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index 3749734b..9ee8d4c9 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -867,38 +867,51 @@ def __init__( derivation_image_item.DerivationCodeSequence = [ CodedConcept.from_code(derivation_code) ] + derivation_image_item.SourceImageSequence = [] - derivation_src_img_item = Dataset() - if hasattr(source_images[0], 'NumberOfFrames'): - # A single multi-frame source image - src_img_item = self.SourceImageSequence[0] - # Frame numbers are one-based - derivation_src_img_item.ReferencedFrameNumber = ( - source_image_index + 1 - ) + purpose_code = \ + codes.cid7202.SourceImageForImageProcessingOperation + if is_multiframe: + for src_img_item in self.SourceImageSequence: + drv_src_img_item = Dataset() + drv_src_img_item.ReferencedFrameNumber = ( + source_image_index + 1 + ) + drv_src_img_item.ReferencedSOPClassUID = \ + src_img_item.ReferencedSOPClassUID + drv_src_img_item.ReferencedSOPInstanceUID = \ + src_img_item.ReferencedSOPInstanceUID + drv_src_img_item.PurposeOfReferenceCodeSequence = [ + CodedConcept.from_code(purpose_code) + ] + drv_src_img_item.SpatialLocationsPreserved = 'YES' + derivation_image_item.SourceImageSequence.append( + drv_src_img_item + ) else: - # Multiple single-frame source images src_img_item = self.SourceImageSequence[ source_image_index ] - derivation_src_img_item.ReferencedSOPClassUID = \ - src_img_item.ReferencedSOPClassUID - derivation_src_img_item.ReferencedSOPInstanceUID = \ - src_img_item.ReferencedSOPInstanceUID - purpose_code = \ - codes.cid7202.SourceImageForImageProcessingOperation - derivation_src_img_item.PurposeOfReferenceCodeSequence = [ - CodedConcept.from_code(purpose_code) - ] - derivation_src_img_item.SpatialLocationsPreserved = 'YES' - derivation_image_item.SourceImageSequence = [ - derivation_src_img_item, - ] + drv_src_img_item = Dataset() + drv_src_img_item.ReferencedFrameNumber = ( + source_image_index + 1 + ) + drv_src_img_item.ReferencedSOPClassUID = \ + src_img_item.ReferencedSOPClassUID + drv_src_img_item.ReferencedSOPInstanceUID = \ + src_img_item.ReferencedSOPInstanceUID + drv_src_img_item.PurposeOfReferenceCodeSequence = [ + CodedConcept.from_code(purpose_code) + ] + drv_src_img_item.SpatialLocationsPreserved = 'YES' + derivation_image_item.SourceImageSequence.append( + drv_src_img_item + ) pffp_item.DerivationImageSequence.append( derivation_image_item ) else: - logger.warning('spatial locations not preserved') + logger.warning('spatial locations are not preserved') identification = Dataset() identification.ReferencedSegmentNumber = segment_number From fc5b2d16785836e44d21338045dd3c72f9e60fb6 Mon Sep 17 00:00:00 2001 From: hackermd Date: Tue, 30 Aug 2022 09:37:04 -0400 Subject: [PATCH 05/13] Add test for multiple multi-frame source images --- tests/test_seg.py | 97 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/tests/test_seg.py b/tests/test_seg.py index c7547ba8..8168140b 100644 --- a/tests/test_seg.py +++ b/tests/test_seg.py @@ -1,6 +1,7 @@ from collections import defaultdict import unittest from pathlib import Path +from copy import deepcopy import numpy as np import pytest @@ -644,7 +645,7 @@ def setUp(self): axis=2 )[None, :] - # A microscopy image + # A microscopy (color) image self._sm_image = dcmread( str(data_dir.joinpath('test_files', 'sm_image.dcm')) ) @@ -656,6 +657,16 @@ def setUp(self): ) self._sm_pixel_array[2:3, 1:5, 7:9] = True + # A microscopy (grayscale) image + self._sm_image_grayscale = dcmread( + str(data_dir.joinpath('test_files', 'sm_image_grayscale.dcm')) + ) + self._sm_pixel_array_grayscale = np.zeros( + self._sm_image_grayscale.pixel_array.shape, + dtype=bool + ) + self._sm_pixel_array_grayscale[2:3, 1:5, 7:9] = True + # A series of single frame CT images ct_series = [ dcmread(f) @@ -1365,6 +1376,90 @@ def test_construction_7(self): assert SegmentsOverlapValues[instance.SegmentsOverlap] == \ SegmentsOverlapValues.NO + def test_construction_8(self): + sm_image_one = deepcopy(self._sm_image_grayscale) + sm_image_two = deepcopy(self._sm_image_grayscale) + sm_image_two.SOPInstanceUID = UID() + instance = Segmentation( + [sm_image_one, sm_image_two], + self._sm_pixel_array_grayscale, + 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 + ) + assert len(instance.SegmentSequence) == 1 + assert len(instance.SourceImageSequence) == 2 + ref_item_one = instance.SourceImageSequence[0] + assert ref_item_one.ReferencedSOPInstanceUID == \ + sm_image_one.SOPInstanceUID + ref_item_two = instance.SourceImageSequence[1] + assert ref_item_two.ReferencedSOPInstanceUID == \ + sm_image_two.SOPInstanceUID + + num_frames = (self._sm_pixel_array.sum(axis=(1, 2)) > 0).sum() + assert instance.NumberOfFrames == num_frames + assert len(instance.PerFrameFunctionalGroupsSequence) == num_frames + frame_item = instance.PerFrameFunctionalGroupsSequence[0] + for derivation_image_item in frame_item.DerivationImageSequence: + assert len(derivation_image_item.SourceImageSequence) == 2 + source_image_item_one = derivation_image_item.SourceImageSequence[0] + assert source_image_item_one.ReferencedSOPInstanceUID == \ + sm_image_one.SOPInstanceUID + assert hasattr(source_image_item_one, 'ReferencedFrameNumber') + source_image_item_two = derivation_image_item.SourceImageSequence[1] + assert source_image_item_two.ReferencedSOPInstanceUID == \ + sm_image_two.SOPInstanceUID + self.check_dimension_index_vals(instance) + + def test_construction_9(self): + sm_image_one = deepcopy(self._sm_image_grayscale) + sm_image_two = deepcopy(self._sm_image_grayscale) + sm_image_two.SOPInstanceUID = UID() + sm_image_two.Rows = sm_image_one.Rows - 1 + with pytest.raises(ValueError): + Segmentation( + [sm_image_one, sm_image_two], + self._sm_pixel_array_grayscale, + 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 + ) + + def test_construction_10(self): + sm_image_one = deepcopy(self._sm_image_grayscale) + sm_image_two = deepcopy(self._sm_image_grayscale) + sm_image_two.SOPInstanceUID = UID() + sm_image_two.NumberOfFrames = str(int(sm_image_one.NumberOfFrames) + 5) + with pytest.raises(ValueError): + Segmentation( + [sm_image_one, sm_image_two], + self._sm_pixel_array_grayscale, + 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 + ) + def test_pixel_types(self): # A series of tests on different types of image tests = [ From 194b1d37decf1de91a0929300325d7ea3bfede78 Mon Sep 17 00:00:00 2001 From: hackermd Date: Tue, 30 Aug 2022 09:44:38 -0400 Subject: [PATCH 06/13] Include references for multiple single-frame images --- src/highdicom/seg/sop.py | 82 ++++++++++++++++++++-------------------- 1 file changed, 41 insertions(+), 41 deletions(-) diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index 9ee8d4c9..93779a83 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -274,6 +274,11 @@ def __init__( if len(source_images) == 0: raise ValueError('At least one source image is required.') + if len(set([image.SOPInstanceUID for image in source_images])) > 1: + raise ValueError( + 'Source images must all have unique SOP Instance UID values.' + ) + uniqueness_criteria = set( ( image.StudyInstanceUID, @@ -752,6 +757,8 @@ def __init__( # bitpacking at the end full_pixel_array = np.array([], np.bool_) + derivation_code = codes.cid7203.Segmentation + purpose_code = codes.cid7202.SourceImageForImageProcessingOperation for i, segment_number in enumerate(described_segment_numbers): # Pixel array for just this segment if pixel_array.dtype in (np.float_, np.float32, np.float64): @@ -862,53 +869,46 @@ def __init__( pffp_item.DerivationImageSequence = [] if are_spatial_locations_preserved: - derivation_image_item = Dataset() - derivation_code = codes.cid7203.Segmentation - derivation_image_item.DerivationCodeSequence = [ + derivation_img_item = Dataset() + derivation_img_item.DerivationCodeSequence = [ CodedConcept.from_code(derivation_code) ] - derivation_image_item.SourceImageSequence = [] - - purpose_code = \ - codes.cid7202.SourceImageForImageProcessingOperation - if is_multiframe: - for src_img_item in self.SourceImageSequence: - drv_src_img_item = Dataset() - drv_src_img_item.ReferencedFrameNumber = ( - source_image_index + 1 - ) - drv_src_img_item.ReferencedSOPClassUID = \ - src_img_item.ReferencedSOPClassUID - drv_src_img_item.ReferencedSOPInstanceUID = \ - src_img_item.ReferencedSOPInstanceUID - drv_src_img_item.PurposeOfReferenceCodeSequence = [ + derivation_img_item.SourceImageSequence = [] + + for _, referenced_images in referenced_series.items(): + if is_multiframe: + for src_item in referenced_images: + drv_src_item = Dataset() + drv_src_item.ReferencedFrameNumber = ( + source_image_index + 1 + ) + drv_src_item.ReferencedSOPClassUID = \ + src_item.ReferencedSOPClassUID + drv_src_item.ReferencedSOPInstanceUID = \ + src_item.ReferencedSOPInstanceUID + drv_src_item.PurposeOfReferenceCodeSequence = [ + CodedConcept.from_code(purpose_code) + ] + drv_src_item.SpatialLocationsPreserved = 'YES' + derivation_img_item.SourceImageSequence.append( + drv_src_item + ) + else: + src_item = referenced_images[source_image_index] + drv_src_item = Dataset() + drv_src_item.ReferencedSOPClassUID = \ + src_item.ReferencedSOPClassUID + drv_src_item.ReferencedSOPInstanceUID = \ + src_item.ReferencedSOPInstanceUID + drv_src_item.PurposeOfReferenceCodeSequence = [ CodedConcept.from_code(purpose_code) ] - drv_src_img_item.SpatialLocationsPreserved = 'YES' - derivation_image_item.SourceImageSequence.append( - drv_src_img_item + drv_src_item.SpatialLocationsPreserved = 'YES' + derivation_img_item.SourceImageSequence.append( + drv_src_item ) - else: - src_img_item = self.SourceImageSequence[ - source_image_index - ] - drv_src_img_item = Dataset() - drv_src_img_item.ReferencedFrameNumber = ( - source_image_index + 1 - ) - drv_src_img_item.ReferencedSOPClassUID = \ - src_img_item.ReferencedSOPClassUID - drv_src_img_item.ReferencedSOPInstanceUID = \ - src_img_item.ReferencedSOPInstanceUID - drv_src_img_item.PurposeOfReferenceCodeSequence = [ - CodedConcept.from_code(purpose_code) - ] - drv_src_img_item.SpatialLocationsPreserved = 'YES' - derivation_image_item.SourceImageSequence.append( - drv_src_img_item - ) pffp_item.DerivationImageSequence.append( - derivation_image_item + derivation_img_item ) else: logger.warning('spatial locations are not preserved') From 0b01806261b252e922d69ec3920230dafdfe9ff3 Mon Sep 17 00:00:00 2001 From: hackermd Date: Tue, 30 Aug 2022 09:56:25 -0400 Subject: [PATCH 07/13] Fix uniqueness check for source images --- src/highdicom/seg/sop.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index 93779a83..b58c9904 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -274,9 +274,12 @@ def __init__( if len(source_images) == 0: raise ValueError('At least one source image is required.') - if len(set([image.SOPInstanceUID for image in source_images])) > 1: + unique_sop_instance_uids = set( + [image.SOPInstanceUID for image in source_images] + ) + if len(source_images) != len(unique_sop_instance_uids): raise ValueError( - 'Source images must all have unique SOP Instance UID values.' + 'Source images must all have a unique SOP Instance UID.' ) uniqueness_criteria = set( From 88d03c35b9c0777f043337336bfea5a05b7ff1ae Mon Sep 17 00:00:00 2001 From: hackermd Date: Mon, 10 Oct 2022 15:56:55 -0400 Subject: [PATCH 08/13] Allow reference of images from multiple series --- src/highdicom/seg/sop.py | 86 +++++++++++++++++++++++++++------------- tests/test_seg.py | 1 + 2 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index b58c9904..7290cda3 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -285,7 +285,6 @@ def __init__( uniqueness_criteria = set( ( image.StudyInstanceUID, - image.SeriesInstanceUID, image.Rows, image.Columns, int(getattr(image, 'NumberOfFrames', '1')), @@ -308,11 +307,17 @@ def __init__( ) if len(uniqueness_criteria) > 1: raise ValueError( - 'Source images must all be part of the same series, must ' - 'have the same image dimensions (number of rows/columns), and ' - 'must have the same image orientation.' + 'Source images must all have the same image dimensions ' + '(number of rows/columns) and image orientation, ' + 'have the same frame of reference, ' + 'and contain the same number of frames.' ) + 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.') + src_img = source_images[0] is_multiframe = hasattr(src_img, 'NumberOfFrames') is_tiled = hasattr(src_img, 'TotalPixelMatrixRows') @@ -328,11 +333,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.') - super().__init__( study_instance_uid=src_img.StudyInstanceUID, series_instance_uid=series_instance_uid, @@ -360,6 +360,56 @@ def __init__( **kwargs ) + # General Reference + self.SourceImageSequence: List[Dataset] = [] + referenced_series: Dict[str, List[Dataset]] = defaultdict(list) + for s_img in source_images: + ref = Dataset() + ref.ReferencedSOPClassUID = s_img.SOPClassUID + ref.ReferencedSOPInstanceUID = s_img.SOPInstanceUID + self.SourceImageSequence.append(ref) + referenced_series[s_img.SeriesInstanceUID].append(ref) + + if len(referenced_series) > 1: + if is_multiframe and not is_tiled: + raise ValueError( + 'If source images are multiple-frame images that are ' + 'not tiled, then only a single source image from a single ' + 'series must be provided.' + ) + elif not is_multiframe: + raise ValueError( + 'If source images are single-frame images, then all ' + 'source images must be from a single series.' + ) + + # Common Instance Reference + self.ReferencedSeriesSequence: List[Dataset] = [] + for series_instance_uid, referenced_images in referenced_series.items(): + if is_multiframe and not is_tiled: + if len(referenced_images) > 1: + raise ValueError( + 'If source images are multiple-frame images that are ' + 'not tiled, then only a single source image must be ' + f'provided. However, n={len(referenced_images)} images ' + f'were provided for series "{series_instance_uid}".' + ) + elif not is_multiframe: + if len(referenced_images) != pixel_array.shape[0]: + raise ValueError( + 'If source images are single-frame images, then ' + f'then n={pixel_array.shape[0]} source images must be ' + 'provided per series. ' + f'However, n={len(referenced_images)} images were ' + f'provided for series "{series_instance_uid}".' + ) + + ref = Dataset() + ref.SeriesInstanceUID = series_instance_uid + ref.ReferencedInstanceSequence = list(referenced_images) + self.ReferencedSeriesSequence.append(ref) + + # Frame of Reference has_ref_frame_uid = hasattr(src_img, 'FrameOfReferenceUID') if has_ref_frame_uid: @@ -413,24 +463,6 @@ def __init__( ) self._coordinate_system = None - # General Reference - self.SourceImageSequence: List[Dataset] = [] - referenced_series: Dict[str, List[Dataset]] = defaultdict(list) - for s_img in source_images: - ref = Dataset() - ref.ReferencedSOPClassUID = s_img.SOPClassUID - ref.ReferencedSOPInstanceUID = s_img.SOPInstanceUID - self.SourceImageSequence.append(ref) - referenced_series[s_img.SeriesInstanceUID].append(ref) - - # Common Instance Reference - self.ReferencedSeriesSequence: List[Dataset] = [] - for series_instance_uid, referenced_images in referenced_series.items(): - ref = Dataset() - ref.SeriesInstanceUID = series_instance_uid - ref.ReferencedInstanceSequence = referenced_images - self.ReferencedSeriesSequence.append(ref) - # Image Pixel self.Rows = pixel_array.shape[1] self.Columns = pixel_array.shape[2] diff --git a/tests/test_seg.py b/tests/test_seg.py index 8168140b..ab92ff3b 100644 --- a/tests/test_seg.py +++ b/tests/test_seg.py @@ -1379,6 +1379,7 @@ def test_construction_7(self): def test_construction_8(self): sm_image_one = deepcopy(self._sm_image_grayscale) sm_image_two = deepcopy(self._sm_image_grayscale) + sm_image_one.SOPInstanceUID = UID() sm_image_two.SOPInstanceUID = UID() instance = Segmentation( [sm_image_one, sm_image_two], From 7d5a4b19eb51c399f6d09b166c6c41460fe593be Mon Sep 17 00:00:00 2001 From: hackermd Date: Mon, 10 Oct 2022 16:02:19 -0400 Subject: [PATCH 09/13] Fix coding style issue --- src/highdicom/seg/sop.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index 7290cda3..d0c7e9b9 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -409,7 +409,6 @@ def __init__( ref.ReferencedInstanceSequence = list(referenced_images) self.ReferencedSeriesSequence.append(ref) - # Frame of Reference has_ref_frame_uid = hasattr(src_img, 'FrameOfReferenceUID') if has_ref_frame_uid: From 29f5389f0bc056361b19e5b9d115345699b6c875 Mon Sep 17 00:00:00 2001 From: hackermd Date: Mon, 10 Oct 2022 16:07:35 -0400 Subject: [PATCH 10/13] Clarify requirements for source images --- src/highdicom/seg/sop.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index d0c7e9b9..0a071006 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -99,9 +99,14 @@ def __init__( """ Parameters ---------- - source_images: Sequence[Dataset] + source_images: Sequence[pydicom.dataset.Dataset] One or more single- or multi-frame images (or metadata of images) - from which the segmentation was derived + from which the segmentation was derived. The images must have the + same dimensions (rows, columns) and orientation, have the same frame + of reference, and contain the same number of frames. + In case of multi-frame images that are tiled (e.g., VL Whole Slide + Microscopy Image instances), the images may be from more multiple + series as long as the other requirements are satisfied. pixel_array: numpy.ndarray Array of segmentation pixel data of boolean, unsigned integer or floating point data type representing a mask image. The array may From 8f154d8d1a254f729b00a40b0e6144f829151482 Mon Sep 17 00:00:00 2001 From: "Markus D. Herrmann" Date: Tue, 11 Oct 2022 08:19:00 -0400 Subject: [PATCH 11/13] Update src/highdicom/seg/sop.py Co-authored-by: Chris Bridge --- src/highdicom/seg/sop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index 0a071006..e9ba3122 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -105,7 +105,7 @@ def __init__( same dimensions (rows, columns) and orientation, have the same frame of reference, and contain the same number of frames. In case of multi-frame images that are tiled (e.g., VL Whole Slide - Microscopy Image instances), the images may be from more multiple + Microscopy Image instances), the images may be from multiple series as long as the other requirements are satisfied. pixel_array: numpy.ndarray Array of segmentation pixel data of boolean, unsigned integer or From a2c0d1d6342db23b20a3c3fab35056aceb8320f9 Mon Sep 17 00:00:00 2001 From: hackermd Date: Tue, 11 Oct 2022 09:03:51 -0400 Subject: [PATCH 12/13] Handle multi-frame images consistently --- src/highdicom/seg/sop.py | 97 +++++++++++++++++----------------------- 1 file changed, 42 insertions(+), 55 deletions(-) diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index 0a071006..e449826d 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -99,61 +99,53 @@ def __init__( """ Parameters ---------- - source_images: Sequence[pydicom.dataset.Dataset] + source_images: Union[Sequence[pydicom.dataset.Dataset], Sequence[Sequence[pydicom.dataset.Dataset]] One or more single- or multi-frame images (or metadata of images) from which the segmentation was derived. The images must have the same dimensions (rows, columns) and orientation, have the same frame of reference, and contain the same number of frames. - In case of multi-frame images that are tiled (e.g., VL Whole Slide - Microscopy Image instances), the images may be from more multiple - series as long as the other requirements are satisfied. pixel_array: numpy.ndarray Array of segmentation pixel data of boolean, unsigned integer or floating point data type representing a mask image. The array may be a 2D, 3D or 4D numpy array. - If it is a 2D numpy array, it represents the segmentation of a - single frame image, such as a planar x-ray or single instance from - a CT or MR series. + If it is a 2D numpy array, it represents the segmentation of an + individual image frame (such as a planar x-ray image, a single + plane of a CT or MR image, or a single tile of a SM image). If it is a 3D array, it represents the segmentation of either a - series of source images (such as a series of CT or MR images) a - single 3D multi-frame image (such as a multi-frame CT/MR image), or - a single 2D tiled image (such as a slide microscopy image). - - If ``pixel_array`` represents the segmentation of a 3D image, the - first dimension represents individual 2D planes. Unless the - ``plane_positions`` parameter is provided, the frame in - ``pixel_array[i, ...]`` should correspond to either - ``source_images[i]`` (if ``source_images`` is a list of single - frame instances) or source_images[0].pixel_array[i, ...] if - ``source_images`` is a single multiframe instance. - - Similarly, if ``pixel_array`` is a 3D array representing the - segmentation of a tiled 2D image, the first dimension represents - individual 2D tiles (for one channel and z-stack) and these tiles - correspond to the frames in the source image dataset. - - If ``pixel_array`` is an unsigned integer or boolean array with + series of single-frame images or a multi-frame image (such as a 3D + CT/MR image or a 2D tiled SM image). If it represents the + segmentation of a 3D image, the first dimension represents + individual 2D planes. Similarly, if it represents the segmentation + of a tiled 2D image, the first dimension represents individual 2D + tiles. Unless the ``plane_positions`` parameter is provided, the + frame in ``pixel_array[i, ...]`` should correspond to either + ``source_images[i]`` (if `source_images` contains single-frame + images) or ``source_images[0].pixel_array[i, ...]`` (if + `source_images` contains multi-frame images). + + If `pixel_array` is an unsigned integer or boolean array with binary data (containing only the values ``True`` and ``False`` or ``0`` and ``1``) or a floating-point array, it represents a single segment. In the case of a floating-point array, values must be in the range 0.0 to 1.0. - Otherwise, if ``pixel_array`` is a 2D or 3D array containing multiple - unsigned integer values, each value is treated as a different - segment whose segment number is that integer value. This is - referred to as a *label map* style segmentation. In this case, all - 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. + Otherwise, if `pixel_array` is a 2D or 3D array containing + multiple unsigned integer values, each value is treated as a + different segment whose segment number is that integer value. This + is referred to as a *label map* style segmentation. In this case, + all 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. Note that that a 2D numpy array and a 3D numpy array with a single frame along the first dimension may be used interchangeably as segmentations of a single frame, regardless of their data type. - If ``pixel_array`` is a 4D numpy array, the first three dimensions + If `pixel_array` is a 4D numpy array, the first three dimensions are used in the same way as the 3D case and the fourth dimension represents multiple segments. In this case ``pixel_array[:, :, :, i]`` represents segment number ``i + 1`` @@ -264,6 +256,7 @@ def __init__( and series. * Items of `source_images` have different number of rows and columns. + * Items of `source_images` have different image orientation. * Length of `plane_positions` does not match number of segments encoded in `pixel_array`. * Length of `plane_positions` does not match number of 2D planes @@ -368,21 +361,24 @@ def __init__( # General Reference self.SourceImageSequence: List[Dataset] = [] referenced_series: Dict[str, List[Dataset]] = defaultdict(list) - for s_img in source_images: + for img in source_images: + if is_multiframe: + num_frames = int(getattr(img, 'NumberOfFrames', '1')) + if num_frames != pixel_array.shape[0]: + raise ValueError( + 'If source images are multiple-frame images, then ' + f'each image must contain n={pixel_array.shape[0]} ' + f'frames. However, image "{img.SOPInstanceUID}" ' + f'contains n={num_frames} frames.' + ) ref = Dataset() - ref.ReferencedSOPClassUID = s_img.SOPClassUID - ref.ReferencedSOPInstanceUID = s_img.SOPInstanceUID + ref.ReferencedSOPClassUID = img.SOPClassUID + ref.ReferencedSOPInstanceUID = img.SOPInstanceUID self.SourceImageSequence.append(ref) - referenced_series[s_img.SeriesInstanceUID].append(ref) + referenced_series[img.SeriesInstanceUID].append(ref) if len(referenced_series) > 1: - if is_multiframe and not is_tiled: - raise ValueError( - 'If source images are multiple-frame images that are ' - 'not tiled, then only a single source image from a single ' - 'series must be provided.' - ) - elif not is_multiframe: + if not is_multiframe: raise ValueError( 'If source images are single-frame images, then all ' 'source images must be from a single series.' @@ -391,15 +387,7 @@ def __init__( # Common Instance Reference self.ReferencedSeriesSequence: List[Dataset] = [] for series_instance_uid, referenced_images in referenced_series.items(): - if is_multiframe and not is_tiled: - if len(referenced_images) > 1: - raise ValueError( - 'If source images are multiple-frame images that are ' - 'not tiled, then only a single source image must be ' - f'provided. However, n={len(referenced_images)} images ' - f'were provided for series "{series_instance_uid}".' - ) - elif not is_multiframe: + if not is_multiframe: if len(referenced_images) != pixel_array.shape[0]: raise ValueError( 'If source images are single-frame images, then ' @@ -408,7 +396,6 @@ def __init__( f'However, n={len(referenced_images)} images were ' f'provided for series "{series_instance_uid}".' ) - ref = Dataset() ref.SeriesInstanceUID = series_instance_uid ref.ReferencedInstanceSequence = list(referenced_images) From 5889b6bdf1706bf97f1cbdcb7b29042107246f33 Mon Sep 17 00:00:00 2001 From: "Markus D. Herrmann" Date: Fri, 21 Oct 2022 13:30:58 -0400 Subject: [PATCH 13/13] Apply suggestions from code review Co-authored-by: Chris Bridge --- src/highdicom/seg/sop.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/highdicom/seg/sop.py b/src/highdicom/seg/sop.py index c82969d7..81d6a841 100644 --- a/src/highdicom/seg/sop.py +++ b/src/highdicom/seg/sop.py @@ -362,7 +362,7 @@ def __init__( self.SourceImageSequence: List[Dataset] = [] referenced_series: Dict[str, List[Dataset]] = defaultdict(list) for img in source_images: - if is_multiframe: + if is_multiframe and plane_positions is None: num_frames = int(getattr(img, 'NumberOfFrames', '1')) if num_frames != pixel_array.shape[0]: raise ValueError( @@ -387,7 +387,7 @@ def __init__( # Common Instance Reference self.ReferencedSeriesSequence: List[Dataset] = [] for series_instance_uid, referenced_images in referenced_series.items(): - if not is_multiframe: + if not is_multiframe and plane_positions is None: if len(referenced_images) != pixel_array.shape[0]: raise ValueError( 'If source images are single-frame images, then '