def assert_img_properties(img: SimpleITK.Image, internal_image: SimpleITKImage): color_space = { 1: ColorSpace.GRAY, 3: ColorSpace.RGB, 4: ColorSpace.RGBA, } assert internal_image.color_space == color_space.get( img.GetNumberOfComponentsPerPixel()) if img.GetDimension() == 4: assert internal_image.timepoints == img.GetSize()[-1] else: assert internal_image.timepoints is None if img.GetDepth(): assert internal_image.depth == img.GetDepth() assert internal_image.voxel_depth_mm == img.GetSpacing()[2] else: assert internal_image.depth is None assert internal_image.voxel_depth_mm is None assert internal_image.width == img.GetWidth() assert internal_image.height == img.GetHeight() assert internal_image.voxel_width_mm == approx(img.GetSpacing()[0]) assert internal_image.voxel_height_mm == approx(img.GetSpacing()[1])
def assert_img_properties(img: SimpleITK.Image): assert img.GetDimension() == 4 assert img.GetWidth() == 10 assert img.GetHeight() == 11 assert img.GetDepth() == 12 assert img.GetSize()[-1] == 13
def write(self, segmentation: sitk.Image, source_images: List[pydicom.Dataset]) -> pydicom.Dataset: """Writes a DICOM-SEG dataset from a segmentation image and the corresponding DICOM source images. Args: segmentation: A `SimpleITK.Image` with integer labels and a single component per spatial location. source_images: A list of `pydicom.Dataset` which are the source images for the segmentation image. Returns: A `pydicom.Dataset` instance with all necessary information and meta information for writing the dataset to disk. """ if segmentation.GetDimension() != 3: raise ValueError("Only 3D segmentation data is supported") if segmentation.GetNumberOfComponentsPerPixel() > 1: raise ValueError("Multi-class segmentations can only be " "represented with a single component per voxel") if segmentation.GetPixelID() not in [ sitk.sitkUInt8, sitk.sitkUInt16, sitk.sitkUInt32, sitk.sitkUInt64, ]: raise ValueError("Unsigned integer data type required") # TODO Add further checks if source images are from the same series slice_to_source_images = self._map_source_images_to_segmentation( segmentation, source_images) # Compute unique labels and their respective bounding boxes label_statistics_filter = sitk.LabelStatisticsImageFilter() label_statistics_filter.Execute(segmentation, segmentation) unique_labels = set( [x for x in label_statistics_filter.GetLabels() if x != 0]) if len(unique_labels) == 0: raise ValueError("Segmentation does not contain any labels") # Check if all present labels where declared in the DICOM template declared_segments = set( [x.SegmentNumber for x in self._template.SegmentSequence]) missing_declarations = unique_labels.difference(declared_segments) if missing_declarations: missing_segment_numbers = ", ".join( [str(x) for x in missing_declarations]) message = ( f"Skipping segment(s) {missing_segment_numbers}, since their " "declaration is missing in the DICOM template") if not self._skip_missing_segment: raise ValueError(message) logger.warning(message) labels_to_process = unique_labels.intersection(declared_segments) if not labels_to_process: raise ValueError("No segments found for encoding as DICOM-SEG") # Compute bounding boxes for each present label and optionally restrict # the volume to serialize to the joined maximum extent bboxs = { x: label_statistics_filter.GetBoundingBox(x) for x in labels_to_process } if self._inplane_cropping: min_x, min_y, _ = np.min([x[::2] for x in bboxs.values()], axis=0).tolist() max_x, max_y, _ = ( np.max([x[1::2] for x in bboxs.values()], axis=0) + 1).tolist() logger.info( "Serializing cropped image planes starting at coordinates " f"({min_x}, {min_y}) with size ({max_x - min_x}, {max_y - min_y})" ) else: min_x, min_y = 0, 0 max_x, max_y = segmentation.GetWidth(), segmentation.GetHeight() logger.info( f"Serializing image planes at full size ({max_x}, {max_y})") # Create target dataset for storing serialized data result = SegmentationDataset( reference_dicom=source_images[0] if source_images else None, rows=max_y - min_y, columns=max_x - min_x, segmentation_type=SegmentationType.BINARY, ) dimension_organization = DimensionOrganizationSequence() dimension_organization.add_dimension("ReferencedSegmentNumber", "SegmentIdentificationSequence") dimension_organization.add_dimension("ImagePositionPatient", "PlanePositionSequence") result.add_dimension_organization(dimension_organization) writer_utils.copy_segmentation_template( target=result, template=self._template, segments=labels_to_process, skip_missing_segment=self._skip_missing_segment, ) writer_utils.set_shared_functional_groups_sequence( target=result, segmentation=segmentation) # FIX - Use ImageOrientationPatient value from DICOM source rather than the segmentation result.SharedFunctionalGroupsSequence[0].PlaneOrientationSequence[ 0].ImageOrientationPatient = source_images[ 0].ImageOrientationPatient buffer = sitk.GetArrayFromImage(segmentation) for segment in labels_to_process: logger.info(f"Processing segment {segment}") if self._skip_empty_slices: bbox = bboxs[segment] min_z, max_z = bbox[4], bbox[5] + 1 else: min_z, max_z = 0, segmentation.GetDepth() logger.info( "Total number of slices that will be processed for segment " f"{segment} is {max_z - min_z} (inclusive from {min_z} to {max_z})" ) skipped_slices = [] for slice_idx in range(min_z, max_z): frame_index = (min_x, min_y, slice_idx) frame_position = segmentation.TransformIndexToPhysicalPoint( frame_index) frame_data = np.equal( buffer[slice_idx, min_y:max_y, min_x:max_x], segment) if self._skip_empty_slices and not frame_data.any(): skipped_slices.append(slice_idx) continue frame_fg_item = result.add_frame( data=frame_data.astype(np.uint8), referenced_segment=segment, referenced_images=slice_to_source_images[slice_idx], ) frame_fg_item.FrameContentSequence = [pydicom.Dataset()] frame_fg_item.FrameContentSequence[0].DimensionIndexValues = [ segment, # Segment number slice_idx - min_z + 1, # Slice index within cropped volume ] frame_fg_item.PlanePositionSequence = [pydicom.Dataset()] frame_fg_item.PlanePositionSequence[0].ImagePositionPatient = [ f"{x:e}" for x in frame_position ] if skipped_slices: logger.info(f"Skipped empty slices for segment {segment}: " f'{", ".join([str(x) for x in skipped_slices])}') # Encode all frames into a bytearray if self._inplane_cropping or self._skip_empty_slices: num_encoded_bytes = len(result.PixelData) max_encoded_bytes = (segmentation.GetWidth() * segmentation.GetHeight() * segmentation.GetDepth() * len(result.SegmentSequence) // 8) savings = (1 - num_encoded_bytes / max_encoded_bytes) * 100 logger.info( f"Optimized frame data length is {num_encoded_bytes:,}B " f"instead of {max_encoded_bytes:,}B (saved {savings:.2f}%)") result.SegmentsOverlap = "NO" return result