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_spleen_seg_app/app.py b/examples/apps/ai_spleen_seg_app/app.py index d98034c7..2595bf75 100644 --- a/examples/apps/ai_spleen_seg_app/app.py +++ b/examples/apps/ai_spleen_seg_app/app.py @@ -11,11 +11,14 @@ 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 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 @@ -56,13 +59,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 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( + 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. diff --git a/examples/apps/ai_unetr_seg_app/app.py b/examples/apps/ai_unetr_seg_app/app.py index 44b8ed48..ee471323 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,50 @@ 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, [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",), + ("Right Kidney", "Kidney"), + ("Left Kideny", "Kidney"), + ("Gallbladder",), + ("Esophagus",), + ("Liver",), + ("Stomach",), + ("Aorta",), + ("Inferior vena cava", "InferiorVenaCava"), + ("Portal and Splenic Veins", "SplenicVein"), + ("Pancreas",), + ("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]), + 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 +114,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__}") diff --git a/monai/deploy/operators/dicom_seg_writer_operator.py b/monai/deploy/operators/dicom_seg_writer_operator.py index a35cc5d6..befb4eb0 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 TYPE_CHECKING, 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,14 @@ 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") sitk, _ = optional_import("SimpleITK") +codes, _ = optional_import("pydicom.sr.codedict", name="codes") +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 @@ -37,10 +40,123 @@ 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 + 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 + 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") + 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 + 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 + + 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: + 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 +169,27 @@ 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 +262,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 +341,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 +350,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 +358,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 +382,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 +391,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/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 8581d1e0..6818512a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -23,9 +23,10 @@ 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 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 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