From 1fd64e921b4208bec9593f03ba51bfd7c1bfac7b Mon Sep 17 00:00:00 2001 From: Chris Bridge Date: Sun, 14 Aug 2022 20:57:45 -0400 Subject: [PATCH 1/9] Implement highdicom seg operator Signed-off-by: Chris Bridge --- .../operators/dicom_seg_writer_operator.py | 616 +++++------------- requirements-dev.txt | 3 +- 2 files changed, 160 insertions(+), 459 deletions(-) diff --git a/monai/deploy/operators/dicom_seg_writer_operator.py b/monai/deploy/operators/dicom_seg_writer_operator.py index a35cc5d6..48042156 100644 --- a/monai/deploy/operators/dicom_seg_writer_operator.py +++ b/monai/deploy/operators/dicom_seg_writer_operator.py @@ -9,16 +9,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -import copy -import datetime -import json -import logging import os from pathlib import Path from random import randint -from typing import List, Optional, Union +from typing import List, Optional, Sequence, Union import numpy as np +from typeguard import typechecked from monai.deploy.utils.importutil import optional_import from monai.deploy.utils.version import get_sdk_semver @@ -28,8 +25,10 @@ ImplicitVRLittleEndian, _ = optional_import("pydicom.uid", name="ImplicitVRLittleEndian") Dataset, _ = optional_import("pydicom.dataset", name="Dataset") FileDataset, _ = optional_import("pydicom.dataset", name="FileDataset") -Sequence, _ = optional_import("pydicom.sequence", name="Sequence") +hd, _ = optional_import("highdicom") sitk, _ = optional_import("SimpleITK") +codes, _ = optional_import("pydicom.sr.codedict", name="codes") +Code, _ = optional_import("pydicom.sr.coding", name="Code") import monai.deploy.core as md from monai.deploy.core import DataPath, ExecutionContext, Image, InputContext, IOType, Operator, OutputContext @@ -37,10 +36,114 @@ from monai.deploy.core.domain.dicom_series_selection import StudySelectedSeries +class SegmentDescription: + + @typechecked + def __init__( + self, + segment_label: str, + segmented_property_category: Code, + segmented_property_type: Code, + algorithm_name: str, + algorithm_version: str, + algorithm_family: Code = codes.DCM.ArtificialIntelligence, + tracking_id: Optional[str] = None, + tracking_uid: Optional[str] = None, + anatomic_regions: Optional[Sequence[Code]] = None, + primary_anatomic_structures: Optional[Sequence[Code]] = None + ): + """Class encapsulating the description of a segment within the segmentation. + + Args: + segment_label: str + Label of the segment. + segmented_property_category: pydicom.sr.coding.Code + Category of the property the segment represents, + e.g. ``Code("49755003", "SCT", "Morphologically Abnormal + Structure")`` (see CID 7150 + http://dicom.nema.org/medical/dicom/current/output/chtml/part16/sect_CID_7150.html + "Segmentation Property Categories") + segmented_property_type: pydicom.sr.coding.Code + Property the segment represents, + e.g. ``Code("108369006", "SCT", "Neoplasm")`` (see CID 7151 + http://dicom.nema.org/medical/dicom/current/output/chtml/part16/sect_CID_7151.html + "Segmentation Property Types") + tracking_id: Optional[str], optional + Tracking identifier (unique only with the domain of use). + tracking_uid: Optional[str], optional + Unique tracking identifier (universally unique) in the DICOM format + for UIDs. This is only permissible if a ``tracking_id`` is also + supplied. You may use ``pydicom.uid.generate_uid`` to generate a + suitable UID. If ``tracking_id`` is supplied but ``tracking_uid`` is + not supplied, a suitable UID will be generated for you. + anatomic_regions: Optional[Sequence[pydicom.sr.coding.Code]], optional + Anatomic region(s) into which segment falls, + e.g. ``Code("41216001", "SCT", "Prostate")`` (see CID 4 + http://dicom.nema.org/medical/dicom/current/output/chtml/part16/sect_CID_4.html + "Anatomic Region", CID 403 + http://dicom.nema.org/medical/dicom/current/output/chtml/part16/sect_CID_4031.html + "Common Anatomic Regions", as as well as other CIDs for + domain-specific anatomic regions) + primary_anatomic_structures: Optional[Sequence[pydicom.sr.coding.Code]], optional + Anatomic structure(s) the segment represents + (see CIDs for domain-specific primary anatomic structures) + """ + self._segment_label = segment_label + self._segmented_property_category = segmented_property_category + self._segmented_property_type = segmented_property_type + self._tracking_id = tracking_id + + if anatomic_regions is not None: + self._anatomic_regions = anatomic_regions + else: + self._anatomic_regions = None + if primary_anatomic_structures is not None: + self._primary_anatomic_structures = primary_anatomic_structures + else: + self._primary_anatomic_structures = None + + # Generate a UID if one was not provided + if tracking_id is not None and tracking_uid is None: + tracking_uid = hd.UID() + self._tracking_uid = tracking_uid + + self._algorithm_identification = hd.AlgorithmIdentificationSequence( + name=algorithm_name, + family=algorithm_family, + version=algorithm_version, + ) + + def to_segment_description(self, segment_number: int) -> hd.seg.SegmentDescription: + """Get a corresponding highdicom Segment Description object. + + Args: + segment_number: int + Number of the segment. Must start at 1 and increase by 1 within a + given segmentation object. + + Returns + highdicom.seg.SegmentDescription: + highdicom Segment Description containing the information in this + object. + """ + return hd.seg.SegmentDescription( + segment_number=segment_number, + segment_label=self._segment_label, + segmented_property_category=self._segmented_property_category, + segmented_property_type=self._segmented_property_type, + algorithm_identification=self._algorithm_identification, + algorithm_type="AUTOMATIC", + tracking_uid=self._tracking_uid, + tracking_id=self._tracking_id, + anatomic_regions=self._anatomic_regions, + primary_anatomic_structures=self._primary_anatomic_structures, + ) + + @md.input("seg_image", Image, IOType.IN_MEMORY) @md.input("study_selected_series_list", List[StudySelectedSeries], IOType.IN_MEMORY) @md.output("dicom_seg_instance", DataPath, IOType.DISK) -@md.env(pip_packages=["pydicom >= 1.4.2", "SimpleITK >= 2.0.0"]) +@md.env(pip_packages=["pydicom >= 2.3.0", "highdicom >= 0.18.2"]) class DICOMSegmentationWriterOperator(Operator): """ This operator writes out a DICOM Segmentation Part 10 file to disk @@ -53,38 +156,30 @@ class DICOMSegmentationWriterOperator(Operator): # Suffix to add to file name to indicate DICOM Seg dcm file. DICOMSEG_SUFFIX = "-DICOMSEG" - def __init__(self, seg_labels: Optional[Union[List[str], str]] = None, *args, **kwargs): + def __init__(self, segment_descriptions: List[SegmentDescription], *args, **kwargs): super().__init__(*args, **kwargs) """Instantiates the DICOM Seg Writer instance with optional list of segment label strings. - A string can be used instead of a numerical value for a segment in the segmentation image. - As of now, integer values are supported for segment mask, and it is further required that the named - segment will start with 1 and increment sequentially if there are additional segments, while the - background is of value 0. The caller needs to pass in a string list, whose length corresponds - to the number of actual segments. The position index + 1 would be the corresponding segment's - numerical value. + Each unique, non-zero integer value in the segmentation image represents a segment that must be + described by an item of the segment descriptions list with the corresponding segment number. + Items in the list must be arranged starting at segment number 1 and increasing by 1. For example, in the CT Spleen Segmentation application, the whole image background has a value of 0, and the Spleen segment of value 1. This then only requires the caller to pass in a list - containing a single string, which is used as label for the Spleen in the DICOM Seg instance. + containing a segment description, which is used as label for the Spleen in the DICOM Seg instance. Note: this interface is subject to change. It is planned that a new object will encapsulate the segment label information, including label value, name, description etc. Args: - seg_labels: The string name for each segment + segment_descriptions: Object encapsulating the description of each segment present in the + segmentation. """ - self._seg_labels = ["SegmentLabel-default"] - if isinstance(seg_labels, str): - self._seg_labels = [seg_labels] - elif isinstance(seg_labels, list): - self._seg_labels = [] - for label in seg_labels: - if isinstance(label, str) or isinstance(label, int): - self._seg_labels.append(label) - else: - raise ValueError(f"List of strings expected, but contains {label} of type {type(label)}.") + self._seg_descs = [ + sd.to_segment_description(n) + for n, sd in enumerate(segment_descriptions, 1) + ] def compute(self, op_input: InputContext, op_output: OutputContext, context: ExecutionContext): """Performs computation for this operator and handles I/O. @@ -157,12 +252,29 @@ def create_dicom_seg(self, image: np.ndarray, dicom_series: DICOMSeries, file_pa file_path.parent.absolute().mkdir(parents=True, exist_ok=True) dicom_dataset_list = [i.get_native_sop_instance() for i in dicom_series.get_sop_instances()] - # DICOM Seg creation - self._seg_writer = DICOMSegWriter() + try: - self._seg_writer.write(image, dicom_dataset_list, str(file_path), self._seg_labels) - # TODO: get a class to encapsulate the seg label information. + version_str = get_sdk_semver() # SDK Version + except Exception: + version_str = "0.1" # Fall back to the initial version + + seg = hd.seg.Segmentation( + source_images=dicom_dataset_list, + pixel_array=image, + segmentation_type=hd.seg.SegmentationTypeValues.BINARY, + segment_descriptions=self._seg_descs, + series_instance_uid=hd.UID(), + series_number=random_with_n_digits(4), + sop_instance_uid=hd.UID(), + instance_number=1, + manufacturer="The MONAI Consortium", + manufacturer_model_name="MONAI Deploy App SDK", + software_versions=version_str, + device_serial_number="0000", + ) + seg.save_as(file_path) + try: # Test reading back _ = self._read_from_dcm(str(file_path)) except Exception as ex: @@ -219,99 +331,6 @@ def _image_file_to_numpy(self, input_path: str): raise RuntimeError("Failed to convert image file to numpy: {}".format(input_path)) return data_np.astype(np.uint8) - def _get_label_list(self, stringfied_list_of_labels: str = ""): - """Parse the string to get the label list. - - If empty string is provided, a list of a single element is returned. - - Args: - stringfied_list_of_labels (str): string representing the list of segmentation labels. - - Returns: - list of label strings - """ - - # Use json.loads as a convenience method to convert string to list of strings - assert isinstance(stringfied_list_of_labels, str), "Expected stringfied list pf labels." - - label_list = ["default-label"] # Use this as default if empty string - if stringfied_list_of_labels: - label_list = json.loads(stringfied_list_of_labels) - - return label_list - - -class DICOMSegWriter(object): - def __init__(self): - """Class to write DICOM SEG with the segmentation image and DICOM dataset.""" - - self._logger = logging.getLogger("{}.{}".format(__name__, type(self).__name__)) - - def write(self, seg_img, input_ds, outfile, seg_labels): - """Write DICOM Segmentation object for the segmentation image - - Args: - seg_img (numpy array): numpy array of the segmentation image. - input_ds (list): list of Pydicom datasets of the original DICOM instances. - outfile (str): path for the output DICOM instance file. - seg_labels: list of labels for the segments - """ - - if seg_img is None: - raise ValueError("Argument seg_img cannot be None.") - if not isinstance(input_ds, list) or len(input_ds) < 1: - raise ValueError("Argument input_ds must not be empty.") - if not outfile: - raise ValueError("Argument outfile must not be a valid string.") - if not isinstance(seg_labels, list) or len(seg_labels) < 1: - raise ValueError("Argument seg_labels must not be empty.") - - # Find out the number of DICOM instance datasets - num_of_dcm_ds = len(input_ds) - self._logger.info("Number of DICOM instance datasets in the list: {}".format(num_of_dcm_ds)) - - # Find out the number of slices in the numpy array - num_of_img_slices = seg_img.shape[0] - self._logger.info("Number of slices in the numpy image: {}".format(num_of_img_slices)) - - # Find out the labels - self._logger.info("Labels of the segments: {}".format(seg_labels)) - - # Find out the unique values in the seg image - unique_elements = np.unique(seg_img, return_counts=False) - self._logger.info("Unique values in seg image: {}".format(unique_elements)) - - dcm_out = create_multiframe_metadata(outfile, input_ds[0]) - create_label_segments(dcm_out, seg_labels) - set_pixel_meta(dcm_out, input_ds[0]) - segslice_from_mhd(dcm_out, seg_img, input_ds, len(seg_labels)) - - self._logger.info("Saving output file {}".format(outfile)) - dcm_out.save_as(outfile, False) - self._logger.info("File saved.") - - -# The following functions are mostly based on the implementation demo'ed at RSNA 2019. -# They can be further refactored and made into class methods, but work for now. - - -def safe_get(ds, key): - """Safely gets the tag value if present from the Dataset and logs failure. - - The safe get method of dict works for str, but not the hex key. The added - benefit of this function is that it logs the failure to get the keyed value. - - Args: - ds (Dataset): pydicom Dataset - key (hex | str): Hex code or string name for a key. - """ - - try: - return ds[key].value - except KeyError as e: - logging.error("Failed to get value for key: {}".format(e)) - return "" - def random_with_n_digits(n): assert isinstance(n, int), "Argument n must be a int." @@ -321,339 +340,6 @@ def random_with_n_digits(n): return randint(range_start, range_end) -def create_multiframe_metadata(dicom_file, input_ds): - """Creates the DICOM metadata for the multiframe object, e.g. SEG - - Args: - dicom_file (str or object): The filename or the object type of the file-like the FileDataset was read from. - input_ds (Dataset): pydicom dataset of original DICOM instance. - - Returns: - FileDataset: The object with metadata assigned. - """ - - currentDateRaw = datetime.datetime.now() - currentDate = currentDateRaw.strftime("%Y%m%d") - currentTime = currentDateRaw.strftime("%H%M%S.%f") # long format with micro seconds - segmentationSeriesInstanceUID = generate_uid(prefix=None) - segmentationSOPInstanceUID = generate_uid(prefix=None) - - # Populate required values for file meta information - - file_meta = Dataset() - file_meta.MediaStorageSOPClassUID = "1.2.840.10008.5.1.4.1.1.66.4" - file_meta.MediaStorageSOPInstanceUID = segmentationSOPInstanceUID - file_meta.ImplementationClassUID = "1.2.840.10008.5.1.4.1.1.66.4" - file_meta.TransferSyntaxUID = ImplicitVRLittleEndian - # create dicom global metadata - dicomOutput = FileDataset(dicom_file, {}, file_meta=file_meta, preamble=b"\0" * 128) - - # It is important to understand the Types of DICOM attributes when getting from the original - # dataset, and creating/setting them in the new dataset, .e.g Type 1 is mandatory, though - # non-conformant instance may not have them, Type 2 present but maybe blank, and Type 3 may - # be absent. - - # None of Patient module attributes are mandatory. - # The following are Type 2, present though could be blank - dicomOutput.PatientName = input_ds.get("PatientName", "") # name is actual suppoted - dicomOutput.add_new(0x00100020, "LO", safe_get(input_ds, 0x00100020)) # PatientID - dicomOutput.add_new(0x00100030, "DA", safe_get(input_ds, 0x00100030)) # PatientBirthDate - dicomOutput.add_new(0x00100040, "CS", safe_get(input_ds, 0x00100040)) # PatientSex - dicomOutput.add_new(0x00104000, "LT", safe_get(input_ds, "0x00104000")) # PatientComments - - # For Study module, copy original StudyInstanceUID and other Type 2 study attributes - # Only Study Instance UID is Type 1, though still may be absent, so try to get - dicomOutput.add_new(0x0020000D, "UI", safe_get(input_ds, 0x0020000D)) # StudyInstanceUID - dicomOutput.add_new(0x00080020, "DA", input_ds.get("StudyDate", currentDate)) # StudyDate - dicomOutput.add_new(0x00080030, "TM", input_ds.get("StudyTime", currentTime)) # StudyTime - dicomOutput.add_new(0x00080090, "PN", safe_get(input_ds, 0x00080090)) # ReferringPhysicianName - dicomOutput.add_new(0x00200010, "SH", safe_get(input_ds, 0x00200010)) # StudyID - dicomOutput.add_new(0x00080050, "SH", safe_get(input_ds, 0x00080050)) # AccessionNumber - - # Series module with new attribute values, only Modality and SeriesInstanceUID are Type 1 - dicomOutput.add_new(0x00080060, "CS", "SEG") # Modality - dicomOutput.add_new(0x0020000E, "UI", segmentationSeriesInstanceUID) # SeriesInstanceUID - dicomOutput.add_new(0x00200011, "IS", random_with_n_digits(4)) # SeriesNumber (randomized) - descr = "CAUTION: Research Use Only. MONAI Deploy App SDK generated DICOM SEG" - if safe_get(input_ds, 0x0008103E): - descr += " for " + safe_get(input_ds, 0x0008103E) - dicomOutput.add_new(0x0008103E, "LO", descr) # SeriesDescription - dicomOutput.add_new(0x00080021, "DA", currentDate) # SeriesDate - dicomOutput.add_new(0x00080031, "TM", currentTime) # SeriesTime - - # General Equipment module, only Manufacturer is Type 2, the rest Type 3 - dicomOutput.add_new(0x00181000, "LO", "0000") # DeviceSerialNumber - dicomOutput.add_new(0x00080070, "LO", "MONAI Deploy") # Manufacturer - dicomOutput.add_new(0x00081090, "LO", "App SDK") # ManufacturerModelName - try: - version_str = get_sdk_semver() # SDK Version - except Exception: - version_str = "0.1" # Fall back to the initial version - dicomOutput.add_new(0x00181020, "LO", version_str) # SoftwareVersions - - # SOP common, only SOPClassUID and SOPInstanceUID are Type 1 - dicomOutput.add_new(0x00200013, "IS", 1) # InstanceNumber - dicomOutput.add_new(0x00080016, "UI", "1.2.840.10008.5.1.4.1.1.66.4") # SOPClassUID, per DICOM. - dicomOutput.add_new(0x00080018, "UI", segmentationSOPInstanceUID) # SOPInstanceUID - dicomOutput.add_new(0x00080012, "DA", currentDate) # InstanceCreationDate - dicomOutput.add_new(0x00080013, "TM", currentTime) # InstanceCreationTime - - # General Image module. - dicomOutput.add_new(0x00080008, "CS", ["DERIVED", "PRIMARY"]) # ImageType - dicomOutput.add_new(0x00200020, "CS", "") # PatientOrientation, forced empty - # Set content date/time - dicomOutput.ContentDate = currentDate - dicomOutput.ContentTime = currentTime - - # Image Pixel - dicomOutput.add_new(0x00280002, "US", 1) # SamplesPerPixel - dicomOutput.add_new(0x00280004, "CS", "MONOCHROME2") # PhotometricInterpretation - - # Common Instance Reference module - dicomOutput.add_new(0x00081115, "SQ", [Dataset()]) # ReferencedSeriesSequence - # Set the referenced SeriesInstanceUID - dicomOutput.get(0x00081115)[0].add_new(0x0020000E, "UI", safe_get(input_ds, 0x0020000E)) - - # Multi-frame Dimension Module - dimensionID = generate_uid(prefix=None) - dimensionOragnizationSequence = Sequence() - dimensionOragnizationSequenceDS = Dataset() - dimensionOragnizationSequenceDS.add_new(0x00209164, "UI", dimensionID) # DimensionOrganizationUID - dimensionOragnizationSequence.append(dimensionOragnizationSequenceDS) - dicomOutput.add_new(0x00209221, "SQ", dimensionOragnizationSequence) # DimensionOrganizationSequence - - dimensionIndexSequence = Sequence() - dimensionIndexSequenceDS = Dataset() - dimensionIndexSequenceDS.add_new(0x00209164, "UI", dimensionID) # DimensionOrganizationUID - dimensionIndexSequenceDS.add_new(0x00209165, "AT", 0x00209153) # DimensionIndexPointer - dimensionIndexSequenceDS.add_new(0x00209167, "AT", 0x00209153) # FunctionalGroupPointer - dimensionIndexSequence.append(dimensionIndexSequenceDS) - dicomOutput.add_new(0x00209222, "SQ", dimensionIndexSequence) # DimensionIndexSequence - - return dicomOutput - - -def create_label_segments(dcm_output, seg_labels): - """ "Creates the segments with the given labels""" - - def create_label_segment(label, name): - """Creates segment labels""" - segment = Dataset() - segment.add_new(0x00620004, "US", int(label)) # SegmentNumber - segment.add_new(0x00620005, "LO", name) # SegmentLabel - segment.add_new(0x00620009, "LO", "AI Organ Segmentation") # SegmentAlgorithmName - segment.SegmentAlgorithmType = "AUTOMATIC" # SegmentAlgorithmType - segment.add_new(0x0062000D, "US", [128, 174, 128]) # RecommendedDisplayCIELabValue - # Create SegmentedPropertyCategoryCodeSequence - segmentedPropertyCategoryCodeSequence = Sequence() - segmentedPropertyCategoryCodeSequenceDS = Dataset() - segmentedPropertyCategoryCodeSequenceDS.add_new(0x00080100, "SH", "T-D0050") # CodeValue - segmentedPropertyCategoryCodeSequenceDS.add_new(0x00080102, "SH", "SRT") # CodingSchemeDesignator - segmentedPropertyCategoryCodeSequenceDS.add_new(0x00080104, "LO", "Anatomical Structure") # CodeMeaning - segmentedPropertyCategoryCodeSequence.append(segmentedPropertyCategoryCodeSequenceDS) - segment.SegmentedPropertyCategoryCodeSequence = segmentedPropertyCategoryCodeSequence - # Create SegmentedPropertyTypeCodeSequence - segmentedPropertyTypeCodeSequence = Sequence() - segmentedPropertyTypeCodeSequenceDS = Dataset() - segmentedPropertyTypeCodeSequenceDS.add_new(0x00080100, "SH", "T-D0050") # CodeValue - segmentedPropertyTypeCodeSequenceDS.add_new(0x00080102, "SH", "SRT") # CodingSchemeDesignator - segmentedPropertyTypeCodeSequenceDS.add_new(0x00080104, "LO", "Organ") # CodeMeaning - segmentedPropertyTypeCodeSequence.append(segmentedPropertyTypeCodeSequenceDS) - segment.SegmentedPropertyTypeCodeSequence = segmentedPropertyTypeCodeSequence - return segment - - segments = Sequence() - # Assumes the label starts at 1 and increment sequentially. - # TODO: This part needs to be more deterministic, e.g. with a dict. - for lb, name in enumerate(seg_labels, 1): - segment = create_label_segment(lb, name) - segments.append(segment) - dcm_output.add_new(0x00620002, "SQ", segments) # SegmentSequence - - -def create_frame_meta(input_ds, label, ref_instances, dimIdxVal, instance_num): - """Creates the metadata for the each frame""" - - sop_inst_uid = safe_get(input_ds, 0x00080018) # SOPInstanceUID - sourceInstanceSOPClass = safe_get(input_ds, 0x00080016) # SOPClassUID - - # add frame to Referenced Image Sequence - frame_ds = Dataset() - referenceInstance = Dataset() - referenceInstance.add_new(0x00081150, "UI", sourceInstanceSOPClass) # ReferencedSOPClassUID - referenceInstance.add_new(0x00081155, "UI", sop_inst_uid) # ReferencedSOPInstanceUID - - ref_instances.append(referenceInstance) - ############################ - # CREATE METADATA - ############################ - # Create DerivationImageSequence within Per-frame Functional Groups sequence - derivationImageSequence = Sequence() - derivationImage = Dataset() - # Create SourceImageSequence within DerivationImageSequence - sourceImageSequence = Sequence() - sourceImage = Dataset() - # TODO if CT multi-frame - # sourceImage.add_new(0x00081160, 'IS', inputFrameCounter + 1) # Referenced Frame Number - sourceImage.add_new(0x00081150, "UI", sourceInstanceSOPClass) # ReferencedSOPClassUID - sourceImage.add_new(0x00081155, "UI", sop_inst_uid) # ReferencedSOPInstanceUID - # Create PurposeOfReferenceCodeSequence within SourceImageSequence - purposeOfReferenceCodeSequence = Sequence() - purposeOfReferenceCode = Dataset() - purposeOfReferenceCode.add_new(0x00080100, "SH", "121322") # CodeValue - purposeOfReferenceCode.add_new(0x00080102, "SH", "DCM") # CodingSchemeDesignator - purposeOfReferenceCode.add_new(0x00080104, "LO", "Anatomical Stucture") # CodeMeaning - purposeOfReferenceCodeSequence.append(purposeOfReferenceCode) - sourceImage.add_new(0x0040A170, "SQ", purposeOfReferenceCodeSequence) # PurposeOfReferenceCodeSequence - sourceImageSequence.append(sourceImage) # AEH Beck commentout - # Create DerivationCodeSequence within DerivationImageSequence - derivationCodeSequence = Sequence() - derivationCode = Dataset() - derivationCode.add_new(0x00080100, "SH", "113076") # CodeValue - derivationCode.add_new(0x00080102, "SH", "DCM") # CodingSchemeDesignator - derivationCode.add_new(0x00080104, "LO", "Segmentation") # CodeMeaning - derivationCodeSequence.append(derivationCode) - derivationImage.add_new(0x00089215, "SQ", derivationCodeSequence) # DerivationCodeSequence - derivationImage.add_new(0x00082112, "SQ", sourceImageSequence) # SourceImageSequence - derivationImageSequence.append(derivationImage) - frame_ds.add_new(0x00089124, "SQ", derivationImageSequence) # DerivationImageSequence - # Create FrameContentSequence within Per-frame Functional Groups sequence - frameContent = Sequence() - dimensionIndexValues = Dataset() - dimensionIndexValues.add_new(0x00209157, "UL", [dimIdxVal, instance_num]) # DimensionIndexValues - frameContent.append(dimensionIndexValues) - frame_ds.add_new(0x00209111, "SQ", frameContent) # FrameContentSequence - # Create PlanePositionSequence within Per-frame Functional Groups sequence - planePositionSequence = Sequence() - imagePositionPatient = Dataset() - imagePositionPatient.add_new(0x00200032, "DS", safe_get(input_ds, 0x00200032)) # ImagePositionPatient - planePositionSequence.append(imagePositionPatient) - frame_ds.add_new(0x00209113, "SQ", planePositionSequence) # PlanePositionSequence - # Create PlaneOrientationSequence within Per-frame Functional Groups sequence - planeOrientationSequence = Sequence() - imageOrientationPatient = Dataset() - imageOrientationPatient.add_new(0x00200037, "DS", safe_get(input_ds, 0x00200037)) # ImageOrientationPatient - planeOrientationSequence.append(imageOrientationPatient) - frame_ds.add_new(0x00209116, "SQ", planeOrientationSequence) # PlaneOrientationSequence - # Create SegmentIdentificationSequence within Per-frame Functional Groups sequence - segmentIdentificationSequence = Sequence() - referencedSegmentNumber = Dataset() - # TODO lop over label and only get pixel with that value - referencedSegmentNumber.add_new(0x0062000B, "US", label) # ReferencedSegmentNumber, which label is this frame - segmentIdentificationSequence.append(referencedSegmentNumber) - frame_ds.add_new(0x0062000A, "SQ", segmentIdentificationSequence) # SegmentIdentificationSequence - return frame_ds - - -def set_pixel_meta(dicomOutput, input_ds): - """Sets the pixel metadata in the DICOM object""" - - dicomOutput.Rows = input_ds.Rows - dicomOutput.Columns = input_ds.Columns - dicomOutput.BitsAllocated = 1 # add_new(0x00280100, 'US', 8) # Bits allocated - dicomOutput.BitsStored = 1 - dicomOutput.HighBit = 0 - dicomOutput.PixelRepresentation = 0 - # dicomOutput.PixelRepresentation = input_ds.PixelRepresentation - dicomOutput.SamplesPerPixel = 1 - dicomOutput.ImageType = "DERIVED\\PRIMARY" - dicomOutput.ContentLabel = "SEGMENTATION" - dicomOutput.ContentDescription = "" - dicomOutput.ContentCreatorName = "" - dicomOutput.LossyImageCompression = "00" - dicomOutput.SegmentationType = "BINARY" - dicomOutput.MaximumFractionalValue = 1 - dicomOutput.SharedFunctionalGroupsSequence = Sequence() - dicomOutput.PixelPaddingValue = 0 - # Try to get the attributes from the original. - # Even though they are Type 1 and 2, can still be absent - dicomOutput.PixelSpacing = copy.deepcopy(input_ds.get("PixelSpacing", None)) - dicomOutput.SliceThickness = input_ds.get("SliceThickness", "") - dicomOutput.RescaleSlope = 1 - dicomOutput.RescaleIntercept = 0 - # Set the transfer syntax - dicomOutput.is_little_endian = False # True - dicomOutput.is_implicit_VR = False # True - - -def segslice_from_mhd(dcm_output, seg_img, input_ds, num_labels): - """Sets the pixel data from the input numpy image""" - - if np.amax(seg_img) == 0 and np.amin(seg_img) == 0: - raise ValueError("Seg mask is not detected; all 0's.") - - # add frames - out_frame_counter = 0 - out_frames = Sequence() - - out_pixels = None - - referenceInstances = Sequence() - - for img_slice in range(seg_img.shape[0]): - - dimIdxVal = 0 - - for label in range(1, num_labels + 1): - - # Determine if frame gets output - if np.count_nonzero(seg_img[img_slice, ...] == label) == 0: # no label for this frame --> skip - continue - - dimIdxVal += 1 - - frame_meta = create_frame_meta(input_ds[img_slice], label, referenceInstances, dimIdxVal, img_slice) - - out_frames.append(frame_meta) - logging.debug( - "img slice {}, label {}, frame {}, img pos {}".format( - img_slice, label, out_frame_counter, safe_get(input_ds[img_slice], 0x00200032) - ) - ) - seg_slice = np.zeros((1, seg_img.shape[1], seg_img.shape[2]), dtype=bool) - - seg_slice[np.expand_dims(seg_img[img_slice, ...] == label, 0)] = 1 - - if out_pixels is None: - out_pixels = seg_slice - else: - out_pixels = np.concatenate((out_pixels, seg_slice), axis=0) - - out_frame_counter = out_frame_counter + 1 - - dcm_output.add_new(0x52009230, "SQ", out_frames) # PerFrameFunctionalGroupsSequence - dcm_output.NumberOfFrames = out_frame_counter - dcm_output.PixelData = np.packbits(np.flip(np.reshape(out_pixels.astype(bool), (-1, 8)), 1)).tobytes() - - dcm_output.get(0x00081115)[0].add_new(0x0008114A, "SQ", referenceInstances) # ReferencedInstanceSequence - - # Create shared Functional Groups sequence - sharedFunctionalGroups = Sequence() - sharedFunctionalGroupsDS = Dataset() - - planeOrientationSeq = Sequence() - planeOrientationDS = Dataset() - planeOrientationDS.add_new("0x00200037", "DS", safe_get(input_ds[0], 0x00200037)) # ImageOrientationPatient - planeOrientationSeq.append(planeOrientationDS) - sharedFunctionalGroupsDS.add_new("0x00209116", "SQ", planeOrientationSeq) # PlaneOrientationSequence - - pixelMeasuresSequence = Sequence() - pixelMeasuresDS = Dataset() - pixelMeasuresDS.add_new("0x00280030", "DS", safe_get(input_ds[0], "0x00280030")) # PixelSpacing - if input_ds[0].get("SpacingBetweenSlices", ""): - pixelMeasuresDS.add_new("0x00180088", "DS", input_ds[0].get("SpacingBetweenSlices", "")) # SpacingBetweenSlices - pixelMeasuresDS.add_new("0x00180050", "DS", safe_get(input_ds[0], "0x00180050")) # SliceThickness - pixelMeasuresSequence.append(pixelMeasuresDS) - sharedFunctionalGroupsDS.add_new("0x00289110", "SQ", pixelMeasuresSequence) # PixelMeasuresSequence - - sharedFunctionalGroups.append(sharedFunctionalGroupsDS) - - dcm_output.add_new(0x52009229, "SQ", sharedFunctionalGroups) # SharedFunctionalGroupsSequence - - -# End DICOM Seg Writer temp - - def test(): from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator @@ -662,11 +348,21 @@ def test(): current_file_dir = Path(__file__).parent.resolve() data_path = current_file_dir.joinpath("../../../examples/ai_spleen_seg_data/dcm") out_path = current_file_dir.joinpath("../../../examples/output_seg_op/dcm_seg_test.dcm") + segment_descriptions = [ + SegmentDescription( + segment_label="Spleen", + segmented_property_category=codes.SCT.Organ, + segmented_property_type=codes.SCT.Spleen, + algorithm_name="Test algorithm", + algorithm_family=codes.DCM.ArtificialIntelligence, + algorithm_version="0.0.2", + ) + ] loader = DICOMDataLoaderOperator() series_selector = DICOMSeriesSelectorOperator() dcm_to_volume_op = DICOMSeriesToVolumeOperator() - seg_writer = DICOMSegmentationWriterOperator() + seg_writer = DICOMSegmentationWriterOperator(segment_descriptions) # Testing with more granular functions study_list = loader.load_data_to_studies(data_path.absolute()) @@ -676,7 +372,8 @@ def test(): voxels = dcm_to_volume_op.generate_voxel_data(series) metadata = dcm_to_volume_op.create_metadata(series) image = dcm_to_volume_op.create_volumetric_image(voxels, metadata) - image_numpy = image.asnumpy() + # Very crude thresholding + image_numpy = (image.asnumpy() > 400).astype(np.uint8) seg_writer.create_dicom_seg(image_numpy, series, Path(out_path).absolute()) @@ -684,6 +381,9 @@ def test(): study_list = loader.load_data_to_studies(data_path.absolute()) study_selected_series_list = series_selector.filter(None, study_list) image = dcm_to_volume_op.convert_to_image(study_selected_series_list) + # Very crude thresholding + image_numpy = (image.asnumpy() > 400).astype(np.uint8) + image = Image(image_numpy) seg_writer.process_images(image, study_selected_series_list, out_path.parent.absolute()) diff --git a/requirements-dev.txt b/requirements-dev.txt index 8581d1e0..1d68feda 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -26,6 +26,7 @@ cucim~=21.06; platform_system == "Linux" monai>=0.9.0 docker>=5.0.0 pydicom>=1.4.2 +highdicom>=0.18.2 SimpleITK>=2.0.0 Pillow>=8.0.0 bump2version==1.0.1 @@ -33,4 +34,4 @@ scikit-image>=0.17.2 nibabel>=3.2.1 numpy-stl>=2.12.0 trimesh>=3.8.11 -torch>=1.10.0 \ No newline at end of file +torch>=1.10.0 From 5ce872c001cddc097a8af54727bf06719ea54ae8 Mon Sep 17 00:00:00 2001 From: Chris Bridge Date: Sun, 14 Aug 2022 21:11:35 -0400 Subject: [PATCH 2/9] Formatting fixes Signed-off-by: Chris Bridge --- monai/deploy/operators/dicom_seg_writer_operator.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/monai/deploy/operators/dicom_seg_writer_operator.py b/monai/deploy/operators/dicom_seg_writer_operator.py index 48042156..c0872176 100644 --- a/monai/deploy/operators/dicom_seg_writer_operator.py +++ b/monai/deploy/operators/dicom_seg_writer_operator.py @@ -37,7 +37,6 @@ class SegmentDescription: - @typechecked def __init__( self, @@ -50,7 +49,7 @@ def __init__( tracking_id: Optional[str] = None, tracking_uid: Optional[str] = None, anatomic_regions: Optional[Sequence[Code]] = None, - primary_anatomic_structures: Optional[Sequence[Code]] = None + primary_anatomic_structures: Optional[Sequence[Code]] = None, ): """Class encapsulating the description of a segment within the segmentation. @@ -176,10 +175,7 @@ def __init__(self, segment_descriptions: List[SegmentDescription], *args, **kwar segmentation. """ - self._seg_descs = [ - sd.to_segment_description(n) - for n, sd in enumerate(segment_descriptions, 1) - ] + self._seg_descs = [sd.to_segment_description(n) for n, sd in enumerate(segment_descriptions, 1)] def compute(self, op_input: InputContext, op_output: OutputContext, context: ExecutionContext): """Performs computation for this operator and handles I/O. From 8d2afe8c09272f44625af0300daca4d543a06c4c Mon Sep 17 00:00:00 2001 From: Chris Bridge Date: Sun, 14 Aug 2022 22:05:34 -0400 Subject: [PATCH 3/9] Typing fixes Signed-off-by: Chris Bridge --- .../operators/dicom_seg_writer_operator.py | 20 +++++++++---------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/monai/deploy/operators/dicom_seg_writer_operator.py b/monai/deploy/operators/dicom_seg_writer_operator.py index c0872176..1972affe 100644 --- a/monai/deploy/operators/dicom_seg_writer_operator.py +++ b/monai/deploy/operators/dicom_seg_writer_operator.py @@ -12,7 +12,7 @@ import os from pathlib import Path from random import randint -from typing import List, Optional, Sequence, Union +from typing import TYPE_CHECKING, List, Optional, Sequence, Union import numpy as np from typeguard import typechecked @@ -25,10 +25,14 @@ ImplicitVRLittleEndian, _ = optional_import("pydicom.uid", name="ImplicitVRLittleEndian") Dataset, _ = optional_import("pydicom.dataset", name="Dataset") FileDataset, _ = optional_import("pydicom.dataset", name="FileDataset") -hd, _ = optional_import("highdicom") sitk, _ = optional_import("SimpleITK") codes, _ = optional_import("pydicom.sr.codedict", name="codes") -Code, _ = optional_import("pydicom.sr.coding", name="Code") +if TYPE_CHECKING: + import highdicom as hd + from pydicom.sr.coding import Code +else: + Code, _ = optional_import("pydicom.sr.coding", name="Code") + hd, _ = optional_import("highdicom") import monai.deploy.core as md from monai.deploy.core import DataPath, ExecutionContext, Image, InputContext, IOType, Operator, OutputContext @@ -92,14 +96,8 @@ def __init__( self._segmented_property_type = segmented_property_type self._tracking_id = tracking_id - if anatomic_regions is not None: - self._anatomic_regions = anatomic_regions - else: - self._anatomic_regions = None - if primary_anatomic_structures is not None: - self._primary_anatomic_structures = primary_anatomic_structures - else: - self._primary_anatomic_structures = None + self._anatomic_regions = anatomic_regions + self._primary_anatomic_structures = primary_anatomic_structures # Generate a UID if one was not provided if tracking_id is not None and tracking_uid is None: From dd02bb89d3e1f71ff1005b5c495f1c7e085c5b90 Mon Sep 17 00:00:00 2001 From: mmelqin Date: Mon, 15 Aug 2022 16:17:56 -0700 Subject: [PATCH 4/9] Update the Spleen App to use HighDicom Seg Writer. The app is still not compatible with monai v0.9.1 as the app testing revealed that to its Invert transform failed resample the predicted image back to input image spacings. Also, the new Seg Writer impl is strict on DICOM attribute VR conformance, and would throw exception when the input DICOM instances have non-conformant attribute VR values. Signed-off-by: mmelqin --- examples/apps/ai_spleen_seg_app/app.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/examples/apps/ai_spleen_seg_app/app.py b/examples/apps/ai_spleen_seg_app/app.py index d98034c7..db32d17e 100644 --- a/examples/apps/ai_spleen_seg_app/app.py +++ b/examples/apps/ai_spleen_seg_app/app.py @@ -15,13 +15,15 @@ from monai.deploy.core.domain import Image from monai.deploy.core.io_type import IOType from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator -from monai.deploy.operators.dicom_seg_writer_operator import DICOMSegmentationWriterOperator +from monai.deploy.operators.dicom_seg_writer_operator import DICOMSegmentationWriterOperator, SegmentDescription from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator from monai.deploy.operators.dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator from monai.deploy.operators.monai_bundle_inference_operator import IOMapping, MonaiBundleInferenceOperator # from monai.deploy.operators.stl_conversion_operator import STLConversionOperator # import as needed. +# Required for setting SegmentDescription attributes. Direct import as this is not part of App SDK package. +from pydicom.sr.codedict import codes @resource(cpu=1, gpu=1, memory="7Gi") # pip_packages can be a string that is a path(str) to requirements.txt file or a list of packages. @@ -56,13 +58,29 @@ def compose(self): # during init to provide the optional packages info, parsed from the bundle, to the packager # for it to install the packages in the MAP docker image. # Setting output IOType to DISK only works only for leaf operators, not the case in this example. + # + # Pertinent MONAI Bundle: + # https://github.com/Project-MONAI/model-zoo/tree/dev/models/spleen_ct_segmentation bundle_spleen_seg_op = MonaiBundleInferenceOperator( input_mapping=[IOMapping("image", Image, IOType.IN_MEMORY)], output_mapping=[IOMapping("pred", Image, IOType.IN_MEMORY)], ) - # Create DICOM Seg writer with segment label name in a string list - dicom_seg_writer = DICOMSegmentationWriterOperator(seg_labels=["Spleen"]) + # Create DICOM Seg writer providing the required segment description for each segment. + # The actual algorithm and the pertinent organ/tissue need to be used, also + # algorithm_name and algorithm_version are of DICOM VR LO type, limited to 64 chars, per + # https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.2.html + segment_descriptions = [ + SegmentDescription( + segment_label="Spleen", + segmented_property_category=codes.SCT.Organ, + segmented_property_type=codes.SCT.Spleen, + algorithm_name="volumetric (3D) segmentation of the spleen from CT image", + algorithm_family=codes.DCM.ArtificialIntelligence, + algorithm_version="0.1.0", + ) + ] + dicom_seg_writer = DICOMSegmentationWriterOperator(segment_descriptions=segment_descriptions) # Create the processing pipeline, by specifying the source and destination operators, and # ensuring the output from the former matches the input of the latter, in both name and type. From f2832f51dccb1240a6501414d5dc9a79b0117ecc Mon Sep 17 00:00:00 2001 From: mmelqin Date: Mon, 15 Aug 2022 16:50:34 -0700 Subject: [PATCH 5/9] Fix isort error for ordering of imports Signed-off-by: mmelqin --- examples/apps/ai_spleen_seg_app/app.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/apps/ai_spleen_seg_app/app.py b/examples/apps/ai_spleen_seg_app/app.py index db32d17e..1677e4b7 100644 --- a/examples/apps/ai_spleen_seg_app/app.py +++ b/examples/apps/ai_spleen_seg_app/app.py @@ -11,6 +11,9 @@ import logging +# Required for setting SegmentDescription attributes. Direct import as this is not part of App SDK package. +from pydicom.sr.codedict import codes + from monai.deploy.core import Application, resource from monai.deploy.core.domain import Image from monai.deploy.core.io_type import IOType @@ -22,8 +25,6 @@ # from monai.deploy.operators.stl_conversion_operator import STLConversionOperator # import as needed. -# Required for setting SegmentDescription attributes. Direct import as this is not part of App SDK package. -from pydicom.sr.codedict import codes @resource(cpu=1, gpu=1, memory="7Gi") # pip_packages can be a string that is a path(str) to requirements.txt file or a list of packages. @@ -78,7 +79,7 @@ def compose(self): algorithm_name="volumetric (3D) segmentation of the spleen from CT image", algorithm_family=codes.DCM.ArtificialIntelligence, algorithm_version="0.1.0", - ) + ) ] dicom_seg_writer = DICOMSegmentationWriterOperator(segment_descriptions=segment_descriptions) From 0cbf368e01a812777d0755f621b61530ef9c5b5d Mon Sep 17 00:00:00 2001 From: mmelqin Date: Mon, 15 Aug 2022 18:26:34 -0700 Subject: [PATCH 6/9] Update doc strings and comments for seg label and algorithm name and version Signed-off-by: mmelqin --- examples/apps/ai_spleen_seg_app/app.py | 6 +++--- .../operators/dicom_seg_writer_operator.py | 18 +++++++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/examples/apps/ai_spleen_seg_app/app.py b/examples/apps/ai_spleen_seg_app/app.py index 1677e4b7..2595bf75 100644 --- a/examples/apps/ai_spleen_seg_app/app.py +++ b/examples/apps/ai_spleen_seg_app/app.py @@ -67,9 +67,9 @@ def compose(self): output_mapping=[IOMapping("pred", Image, IOType.IN_MEMORY)], ) - # Create DICOM Seg writer providing the required segment description for each segment. - # The actual algorithm and the pertinent organ/tissue need to be used, also - # algorithm_name and algorithm_version are of DICOM VR LO type, limited to 64 chars, per + # Create DICOM Seg writer providing the required segment description for each segment with + # the actual algorithm and the pertinent organ/tissue. The segment_label, algorithm_name, + # and algorithm_version are of DICOM VR LO type, limited to 64 chars. # https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.2.html segment_descriptions = [ SegmentDescription( diff --git a/monai/deploy/operators/dicom_seg_writer_operator.py b/monai/deploy/operators/dicom_seg_writer_operator.py index 1972affe..befb4eb0 100644 --- a/monai/deploy/operators/dicom_seg_writer_operator.py +++ b/monai/deploy/operators/dicom_seg_writer_operator.py @@ -59,7 +59,10 @@ def __init__( Args: segment_label: str - Label of the segment. + User-defined label identifying this segment, + DICOM VR Long String (LO) (see C.8.20-4 + https://dicom.nema.org/medical/Dicom/current/output/chtml/part03/sect_C.8.20.4.html + "Segment Description Macro Attributes") segmented_property_category: pydicom.sr.coding.Code Category of the property the segment represents, e.g. ``Code("49755003", "SCT", "Morphologically Abnormal @@ -71,6 +74,19 @@ def __init__( e.g. ``Code("108369006", "SCT", "Neoplasm")`` (see CID 7151 http://dicom.nema.org/medical/dicom/current/output/chtml/part16/sect_CID_7151.html "Segmentation Property Types") + algorithm_name: str + Name of algorithm used to generate the segment, also as the name assigned by a + manufacturer to a specific software algorithm, + DICOM VR Long String (LO) (see C.8.20-2 + https://dicom.nema.org/medical/dicom/2019a/output/chtml/part03/sect_C.8.20.2.html + "Segmentation Image Module Attribute", and see 10-19 + https://dicom.nema.org/medical/dicom/2020b/output/chtml/part03/sect_10.16.html + "Algorithm Identification Macro Attributes") + algorithm_version: str + The software version identifier assigned by a manufacturer to a specific software algorithm, + DICOM VR Long String (LO) (see 10-19 + https://dicom.nema.org/medical/dicom/2020b/output/chtml/part03/sect_10.16.html + "Algorithm Identification Macro Attributes") tracking_id: Optional[str], optional Tracking identifier (unique only with the domain of use). tracking_uid: Optional[str], optional From d1bb6cff9c145be9ba483496a98acca514f95019 Mon Sep 17 00:00:00 2001 From: mmelqin Date: Mon, 15 Aug 2022 19:31:05 -0700 Subject: [PATCH 7/9] Pin moani==0.9.0 for now as 0.9.1 causes issues. Also pydicom to 2.3.0 as the use of highdicom require pydicom>=2.3.0 Signed-off-by: mmelqin --- monai/deploy/operators/monai_bundle_inference_operator.py | 2 +- monai/deploy/operators/monai_seg_inference_operator.py | 2 +- requirements-dev.txt | 4 ++-- requirements-examples.txt | 5 +++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/monai/deploy/operators/monai_bundle_inference_operator.py b/monai/deploy/operators/monai_bundle_inference_operator.py index 96c703b7..e351c6bc 100644 --- a/monai/deploy/operators/monai_bundle_inference_operator.py +++ b/monai/deploy/operators/monai_bundle_inference_operator.py @@ -198,7 +198,7 @@ def _ensure_str_list(config_names): # operator may choose to pass in a accessible bundle path at development and packaging stage. Ideally, # the bundle path should be passed in by the Packager, e.g. via env var, when the App is initialized. # As of now, the Packager only passes in the model path after the App including all operators are init'ed. -@md.env(pip_packages=["monai>=0.9.0", "torch>=1.10.02", "numpy>=1.21", "nibabel>=3.2.1"]) +@md.env(pip_packages=["monai==0.9.0", "torch>=1.10.02", "numpy>=1.21", "nibabel>=3.2.1"]) class MonaiBundleInferenceOperator(InferenceOperator): """This inference operator automates the inference operation for a given MONAI Bundle. diff --git a/monai/deploy/operators/monai_seg_inference_operator.py b/monai/deploy/operators/monai_seg_inference_operator.py index 5f368f2b..82a38097 100644 --- a/monai/deploy/operators/monai_seg_inference_operator.py +++ b/monai/deploy/operators/monai_seg_inference_operator.py @@ -42,7 +42,7 @@ @md.input("image", Image, IOType.IN_MEMORY) @md.output("seg_image", Image, IOType.IN_MEMORY) -@md.env(pip_packages=["monai>=0.8.1", "torch>=1.5", "numpy>=1.21"]) +@md.env(pip_packages=["monai==0.9.0", "torch>=1.5", "numpy>=1.21"]) class MonaiSegInferenceOperator(InferenceOperator): """This segmentation operator uses MONAI transforms and Sliding Window Inference. diff --git a/requirements-dev.txt b/requirements-dev.txt index 1d68feda..6818512a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -23,9 +23,9 @@ pytest==6.2.4 pytest-cov==2.12.1 pytest-lazy-fixture==0.6.3 cucim~=21.06; platform_system == "Linux" -monai>=0.9.0 +monai==0.9.0 docker>=5.0.0 -pydicom>=1.4.2 +pydicom>=2.3.0 highdicom>=0.18.2 SimpleITK>=2.0.0 Pillow>=8.0.0 diff --git a/requirements-examples.txt b/requirements-examples.txt index abbf0a97..7d7bd818 100644 --- a/requirements-examples.txt +++ b/requirements-examples.txt @@ -1,5 +1,6 @@ scikit-image >= 0.17.2 -pydicom >= 1.4.2 +pydicom >= 2.3.0 +highdicom>=0.18.2 SimpleITK >= 2.0.0 Pillow >= 8.0.0 numpy-stl>=2.12.0 @@ -8,4 +9,4 @@ nibabel >= 3.2.1 numpy-stl >= 2.12.0 trimesh >= 3.8.11 torch >= 1.10.0 -monai >= 0.9.0 \ No newline at end of file +monai == 0.9.0 \ No newline at end of file From f9b151db71288e62790190078c44edf70ba12fc8 Mon Sep 17 00:00:00 2001 From: mmelqin Date: Tue, 16 Aug 2022 01:27:39 -0700 Subject: [PATCH 8/9] Updated apps that have multiple segments Signed-off-by: mmelqin --- examples/apps/ai_livertumor_seg_app/app.py | 51 ++++++++++++++++----- examples/apps/ai_unetr_seg_app/app.py | 52 ++++++++++++++++++++++ 2 files changed, 91 insertions(+), 12 deletions(-) diff --git a/examples/apps/ai_livertumor_seg_app/app.py b/examples/apps/ai_livertumor_seg_app/app.py index a5fe7cfb..acca14d2 100644 --- a/examples/apps/ai_livertumor_seg_app/app.py +++ b/examples/apps/ai_livertumor_seg_app/app.py @@ -13,9 +13,12 @@ from livertumor_seg_operator import LiverTumorSegOperator +# Required for setting SegmentDescription attributes. Direct import as this is not part of App SDK package. +from pydicom.sr.codedict import codes + from monai.deploy.core import Application, resource from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator -from monai.deploy.operators.dicom_seg_writer_operator import DICOMSegmentationWriterOperator +from monai.deploy.operators.dicom_seg_writer_operator import DICOMSegmentationWriterOperator, SegmentDescription from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator from monai.deploy.operators.dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator from monai.deploy.operators.publisher_operator import PublisherOperator @@ -46,33 +49,57 @@ def compose(self): series_selector_op = DICOMSeriesSelectorOperator() series_to_vol_op = DICOMSeriesToVolumeOperator() # Model specific inference operator, supporting MONAI transforms. - unetr_seg_op = LiverTumorSegOperator() + liver_tumor_seg_op = LiverTumorSegOperator() # Create the publisher operator publisher_op = PublisherOperator() - # Creates DICOM Seg writer with segment label name in a string list - dicom_seg_writer = DICOMSegmentationWriterOperator( - seg_labels=[ - "Liver", - "Tumor", - ] - ) + # Create DICOM Seg writer providing the required segment description for each segment with + # the actual algorithm and the pertinent organ/tissue. + # The segment_label, algorithm_name, and algorithm_version are limited to 64 chars. + # https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.2.html + # User can Look up SNOMED CT codes at, e.g. + # https://bioportal.bioontology.org/ontologies/SNOMEDCT + + _algorithm_name = "3D segmentation of the liver and tumor from CT image" + _algorithm_family = codes.DCM.ArtificialIntelligence + _algorithm_version = "0.1.0" + + segment_descriptions = [ + SegmentDescription( + segment_label="Liver", + segmented_property_category=codes.SCT.Organ, + segmented_property_type=codes.SCT.Liver, + algorithm_name=_algorithm_name, + algorithm_family=_algorithm_family, + algorithm_version=_algorithm_version, + ), + SegmentDescription( + segment_label="Tumor", + segmented_property_category=codes.SCT.Tumor, + segmented_property_type=codes.SCT.Tumor, + algorithm_name=_algorithm_name, + algorithm_family=_algorithm_family, + algorithm_version=_algorithm_version, + ), + ] + + dicom_seg_writer = DICOMSegmentationWriterOperator(segment_descriptions) # Create the processing pipeline, by specifying the source and destination operators, and # ensuring the output from the former matches the input of the latter, in both name and type. self.add_flow(study_loader_op, series_selector_op, {"dicom_study_list": "dicom_study_list"}) self.add_flow( series_selector_op, series_to_vol_op, {"study_selected_series_list": "study_selected_series_list"} ) - self.add_flow(series_to_vol_op, unetr_seg_op, {"image": "image"}) + self.add_flow(series_to_vol_op, liver_tumor_seg_op, {"image": "image"}) # Add the publishing operator to save the input and seg images for Render Server. # Note the PublisherOperator has temp impl till a proper rendering module is created. - self.add_flow(unetr_seg_op, publisher_op, {"saved_images_folder": "saved_images_folder"}) + self.add_flow(liver_tumor_seg_op, publisher_op, {"saved_images_folder": "saved_images_folder"}) # Note below the dicom_seg_writer requires two inputs, each coming from a source operator. self.add_flow( series_selector_op, dicom_seg_writer, {"study_selected_series_list": "study_selected_series_list"} ) - self.add_flow(unetr_seg_op, dicom_seg_writer, {"seg_image": "seg_image"}) + self.add_flow(liver_tumor_seg_op, dicom_seg_writer, {"seg_image": "seg_image"}) self._logger.debug(f"End {self.compose.__name__}") diff --git a/examples/apps/ai_unetr_seg_app/app.py b/examples/apps/ai_unetr_seg_app/app.py index 44b8ed48..031ff6df 100644 --- a/examples/apps/ai_unetr_seg_app/app.py +++ b/examples/apps/ai_unetr_seg_app/app.py @@ -11,10 +11,13 @@ import logging +# Required for setting SegmentDescription attributes. Direct import as this is not part of App SDK package. +from pydicom.sr.codedict import codes from unetr_seg_operator import UnetrSegOperator from monai.deploy.core import Application, resource from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator +from monai.deploy.operators.dicom_seg_writer_operator import DICOMSegmentationWriterOperator, SegmentDescription from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator from monai.deploy.operators.dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator from monai.deploy.operators.publisher_operator import PublisherOperator @@ -54,6 +57,49 @@ def compose(self): output_file="stl/multi-organs.stl", keep_largest_connected_component=False ) + # Create DICOM Seg writer providing the required segment description for each segment with + # the actual algorithm and the pertinent organ/tissue. + # The segment_label, algorithm_name, and algorithm_version are limited to 64 chars. + # https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.2.html + + _algorithm_name = "3D multi-organ segmentation from CT image" + _algorithm_family = codes.DCM.ArtificialIntelligence + _algorithm_version = "0.1.0" + + # List of (Segment name, [SNOMED CT Code]), not including background which is value of 0. + # User can look up SNOMED CT codes at, e.g. + # https://bioportal.bioontology.org/ontologies/SNOMEDCT + + organs = [ + ("Spleen",), + ("Right Kidney", "Kidney"), + ("Left Kideny", "Kidney"), + ("Gallbladder",), + ("Esophagus",), + ("Liver",), + ("Stomach",), + ("Aorta",), + ("Inferior vena cava", "Organ"), # Cannot find SNOMED CT code + ("Portal and Splenic Veins", "Vein"), + ("Pancreas",), + ("Right adrenal gland", "Organ"), # Cannot find SNOMED CT code + ("Left adrenal gland", "Organ"), + ] + + segment_descriptions = [ + SegmentDescription( + segment_label=organ[0], + segmented_property_category=codes.SCT.Organ, + segmented_property_type=codes.SCT.__getattr__(organ[1] if len(organ) > 1 else organ[0]), # Not ideal. + algorithm_name=_algorithm_name, + algorithm_family=_algorithm_family, + algorithm_version=_algorithm_version, + ) + for organ in organs + ] + + dicom_seg_writer = DICOMSegmentationWriterOperator(segment_descriptions) + # Create the processing pipeline, by specifying the source and destination operators, and # ensuring the output from the former matches the input of the latter, in both name and type. self.add_flow(study_loader_op, series_selector_op, {"dicom_study_list": "dicom_study_list"}) @@ -67,6 +113,12 @@ def compose(self): # Note the PublisherOperator has temp impl till a proper rendering module is created. self.add_flow(unetr_seg_op, publisher_op, {"saved_images_folder": "saved_images_folder"}) + # Note below the dicom_seg_writer requires two inputs, each coming from a source operator. + self.add_flow( + series_selector_op, dicom_seg_writer, {"study_selected_series_list": "study_selected_series_list"} + ) + self.add_flow(unetr_seg_op, dicom_seg_writer, {"seg_image": "seg_image"}) + self._logger.debug(f"End {self.compose.__name__}") From b4eb36de6661d969bbca5b7131120d3c00a82cf6 Mon Sep 17 00:00:00 2001 From: mmelqin Date: Wed, 17 Aug 2022 17:44:47 -0700 Subject: [PATCH 9/9] Found the few missing codes, so avoided use of generic "Organ" Signed-off-by: mmelqin --- examples/apps/ai_unetr_seg_app/app.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/examples/apps/ai_unetr_seg_app/app.py b/examples/apps/ai_unetr_seg_app/app.py index 031ff6df..ee471323 100644 --- a/examples/apps/ai_unetr_seg_app/app.py +++ b/examples/apps/ai_unetr_seg_app/app.py @@ -66,9 +66,10 @@ def compose(self): _algorithm_family = codes.DCM.ArtificialIntelligence _algorithm_version = "0.1.0" - # List of (Segment name, [SNOMED CT Code]), not including background which is value of 0. - # User can look up SNOMED CT codes at, e.g. + # List of (Segment name, [Code menaing str]), not including background which is value of 0. + # User must provide correct codes, which can be looked at, e.g. # https://bioportal.bioontology.org/ontologies/SNOMEDCT + # Alternatively, consult the concept and code dictionaries in PyDicom organs = [ ("Spleen",), @@ -79,18 +80,18 @@ def compose(self): ("Liver",), ("Stomach",), ("Aorta",), - ("Inferior vena cava", "Organ"), # Cannot find SNOMED CT code - ("Portal and Splenic Veins", "Vein"), + ("Inferior vena cava", "InferiorVenaCava"), + ("Portal and Splenic Veins", "SplenicVein"), ("Pancreas",), - ("Right adrenal gland", "Organ"), # Cannot find SNOMED CT code - ("Left adrenal gland", "Organ"), + ("Right adrenal gland", "AdrenalGland"), + ("Left adrenal gland", "AdrenalGland"), ] segment_descriptions = [ SegmentDescription( segment_label=organ[0], segmented_property_category=codes.SCT.Organ, - segmented_property_type=codes.SCT.__getattr__(organ[1] if len(organ) > 1 else organ[0]), # Not ideal. + segmented_property_type=codes.SCT.__getattr__(organ[1] if len(organ) > 1 else organ[0]), algorithm_name=_algorithm_name, algorithm_family=_algorithm_family, algorithm_version=_algorithm_version,