Skip to content

Added DICOM text SR assets and updated all operator test functions. #201

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Nov 23, 2021
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions docs/source/release_notes/v0.2.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Version 0.2.0 (November 23, 2021)

This is a new and enhanced version of MONAI Deploy App SDK, just in time for Thanksgiving and RSNA 2021!🎉

Please visit [GETTING STARTED](/getting_started/index) guide and follow tutorials.

You can learn more about SDK usage through [DEVELOPING WITH SDK](/developing_with_sdk/index).

Please let us know how you like it and what could be improved by [submitting an issue](https://github.com/Project-MONAI/monai-deploy-app-sdk/issues/new/choose) or [asking questions](https://github.com/Project-MONAI/monai-deploy-app-sdk/discussions)😀

## What's new in this version 0.2.0
### Series Selection Operator
This is to support the use case where whole DICOM studies are used as input to an AI inference application even though only specific series are applicable.

The selection rules are defined in JSON, allowing multiple selections, each with a set of matching conditions. The rules processing engine is implemented in the `DICOMSeriesSelectorOperator`, which itself is regarded as a base class with default implementation. More advanced rules and processing engine can be implemented in derived classes.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The selection rules are defined in JSON, allowing multiple selections, each with a set of matching conditions. The rules processing engine is implemented in the `DICOMSeriesSelectorOperator`, which itself is regarded as a base class with default implementation. More advanced rules and processing engine can be implemented in derived classes.
The selection rules are defined in JSON, allowing multiple selections, each with a set of matching conditions. The rules processing engine is implemented in the `DICOMSeriesSelectorOperator`, which itself is regarded as a base class with the default implementation. More advanced rules and processing engines can be implemented in derived classes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was meant to say "rules processing engine"

Copy link
Collaborator Author

@MMelQin MMelQin Nov 23, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One second thought, rules can be redefined too, if the base class is not used for the new definition of rules.


Multiple instances of the series selection operators, each having its own rules, can be chained in a MONAI Deploy application. In part this is made possible by the new App SDK Domain classes which encapsulate the selected series in a DICOM study, and are used as the output of each series selection operator.

### DICOM Comprehensive Structured Report Writer
This is introduced to support generating DICOM SR SOP instances for AI classification result, and as such, the DICOM SR writer is limited to support textual results.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
This is introduced to support generating DICOM SR SOP instances for AI classification result, and as such, the DICOM SR writer is limited to support textual results.
This is introduced to support generating DICOM SR SOP instances for the AI classification result, and as such, the DICOM SR writer is limited to support textual results.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed result to results.


The DICOM SR writer is implemented in `DICOMTextSRWriterOperator`, it
- loads AI result from in-memory object as well as from file path, with memory taking precedence
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- loads AI result from in-memory object as well as from file path, with memory taking precedence
- loads the AI result from the in-memory object as well as from file path, with memory taking precedence

- copies applicable DICOM tags from the original DICOM series used as input for the inference application, as well as generating tags anew when there is no DICOM series provided.
- supports assigning DICOM tags via a dictionary with DICOM keywords and value, so that an application can customize the tags in the DICOM SR instance
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
- supports assigning DICOM tags via a dictionary with DICOM keywords and value, so that an application can customize the tags in the DICOM SR instance
- supports assigning DICOM tags via a dictionary with DICOM keywords and values, so that an application can customize the tags in the DICOM SR instance

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

value to their respective values, just to be specific.

- provides classes for an application to encapsulate the AI model information as well as DICOM equipment information, per [IHE Radiology Technical Framework Supplement AI Results (AIR)](https://www.ihe.net/uploadedFiles/Documents/Radiology/IHE_RAD_Suppl_AIR.pdf)

### Updated example applications
- [The AI Spleen Segmentation](https://github.com/Project-MONAI/monai-deploy-app-sdk/tree/main/examples/apps/ai_spleen_seg_app) application updated to demonstrate the use of series selection rules
- [The MedNIST Classifier application](https://github.com/Project-MONAI/monai-deploy-app-sdk/tree/main/examples/apps/mednist_classifier_monaideploy) updated to demonstrate the use of DCIOM SR writing (without initial DICOM input)
- Also updated are the main functions of the built-in [operators](https://github.com/Project-MONAI/monai-deploy-app-sdk/tree/main/monai/deploy/operators), which serve as examples on parsing the output objects
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from typing import Text

import monai.deploy.core as md
from monai.deploy.core import (
Application,
Expand All @@ -20,6 +22,7 @@
Operator,
OutputContext,
)
from monai.deploy.operators.dicom_text_sr_writer_operator import DICOMTextSRWriterOperator, EquipmentInfo, ModelInfo
from monai.transforms import AddChannel, Compose, EnsureType, ScaleIntensity

MEDNIST_CLASSES = ["AbdomenCT", "BreastMRI", "CXR", "ChestCT", "Hand", "HeadCT"]
Expand Down Expand Up @@ -48,7 +51,8 @@ def compute(self, op_input: InputContext, op_output: OutputContext, context: Exe


@md.input("image", Image, IOType.IN_MEMORY)
@md.output("output", DataPath, IOType.DISK)
@md.output("result_text", Text, IOType.IN_MEMORY)
# @md.output("output", DataPath, IOType.DISK)
@md.env(pip_packages=["monai"])
class MedNISTClassifierOperator(Operator):
"""Classifies the given image and returns the class name."""
Expand Down Expand Up @@ -80,9 +84,11 @@ def compute(self, op_input: InputContext, op_output: OutputContext, context: Exe

result = MEDNIST_CLASSES[output_classes[0]] # get the class name
print(result)
op_output.set(result, "result_text")

# Get output (folder) path and create the folder if not exists
output_folder = op_output.get().path
# The following gets the App context's output path, instead the operator's.
output_folder = context.output.get().path
output_folder.mkdir(parents=True, exist_ok=True)

# Write result to "output.json"
Expand All @@ -99,7 +105,15 @@ def compose(self):
load_pil_op = LoadPILOperator()
classifier_op = MedNISTClassifierOperator()

my_model_info = ModelInfo("MONAI WG Trainer", "MEDNIST Classifier", "0.1", "xyz")
my_equipment = EquipmentInfo(manufacturer="MOANI Deploy App SDK", manufacturer_model="DICOM SR Writer")
my_special_tags = {"SeriesDescription": "Not for clinical use. The result is for research use only."}
dicom_sr_operator = DICOMTextSRWriterOperator(
copy_tags=False, model_info=my_model_info, equipment_info=my_equipment, custom_tags=my_special_tags
)

self.add_flow(load_pil_op, classifier_op)
self.add_flow(classifier_op, dicom_sr_operator, {"result_text": "classification_result"})


if __name__ == "__main__":
Expand Down
6 changes: 3 additions & 3 deletions monai/deploy/core/domain/dicom_sop_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
Dataset_, dataset_ok_ = optional_import("pydicom", name="Dataset")
# Dynamic class is not handled so make it Any for now: https://github.com/python/mypy/issues/2477
Dataset: Any = Dataset_ if dataset_ok_ else Any
TagType_, tagtype_ok_ = optional_import("pydicom.tag", name="TagType")
Tag_, tag_ok_ = optional_import("pydicom.tag", name="Tag")
# Dynamic class is not handled so make it Any for now: https://github.com/python/mypy/issues/2477
TagType: Any = TagType_ if tagtype_ok_ else Any
Tag: Any = Tag_ if tag_ok_ else Any


class DICOMSOPInstance(Domain):
Expand All @@ -39,7 +39,7 @@ def __init__(self, native_sop):
def get_native_sop_instance(self):
return self._sop

def __getitem__(self, key: Union[int, slice, TagType]) -> Union[Dataset, DataElement]:
def __getitem__(self, key: Union[int, slice, Tag]) -> Union[Dataset, DataElement]:
return self._sop.__getitem__(key)

def get_pixel_array(self):
Expand Down
1 change: 1 addition & 0 deletions monai/deploy/operators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
from .dicom_seg_writer_operator import DICOMSegmentationWriterOperator
from .dicom_series_selector_operator import DICOMSeriesSelectorOperator
from .dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator
from .dicom_text_sr_writer_operator import DICOMTextSRWriterOperator, EquipmentInfo, ModelInfo
from .inference_operator import InferenceOperator
from .monai_seg_inference_operator import MonaiSegInferenceOperator
from .png_converter_operator import PNGConverterOperator
Expand Down
38 changes: 36 additions & 2 deletions monai/deploy/operators/dicom_data_loader_operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,16 +245,50 @@ def populate_series_attributes(self, series, sop_instance):


def test():
data_path = "../../../examples/ai_spleen_seg_data/dcm"
current_file_dir = Path(__file__).parent.resolve()
data_path = current_file_dir.joinpath("../../../examples/ai_spleen_seg_data/dcm")

loader = DICOMDataLoaderOperator()
study_list = loader.load_data_to_studies(Path(data_path).absolute())
study_list = loader.load_data_to_studies(data_path.absolute())

for study in study_list:
print("###############################")
print(study)
for series in study.get_all_series():
print(series)
for series in study.get_all_series():
for sop in series.get_sop_instances():
print("Demonstrating ways to access DICOM attributes in a SOP instance.")
# No need to get the native_ds = sop.get_native_sop_instance()
# sop = sop.get_native_sop_instance()
print(f" 'StudyInstanceUID': {sop['StudyInstanceUID'].repval}")
print(f" (0x0020, 0x000D): {sop[0x0020, 0x000D].repval}")
print(f" 'SeriesInstanceUID': {sop['SeriesInstanceUID'].value.name}")
print(f" (0x0020, 0x000E): {sop[0x0020, 0x000E].value.name}")
print(f" 'SOPInstanceUID': {sop['SOPInstanceUID'].value.name}")
print(f" (0008,0018): {sop[0x0008, 0x0018].value.name}")
try:
print(f" 'InstanceNumber': {sop['InstanceNumber'].repval}")
print(f" (0020, 0013): {sop[0x0020, 0x0013].repval}")
except KeyError:
pass
# Need to get pydicom dataset to use properties and get method of a dict.
ds = sop.get_native_sop_instance()
print(f" 'StudyInstanceUID': {ds.StudyInstanceUID if ds.StudyInstanceUID else ''}")
print(f" 'SeriesDescription': {ds.SeriesDescription if ds.SeriesDescription else ''}")
print(
f" 'IssuerOfPatientID': {ds.get('IssuerOfPatientID', '').repval if ds.get('IssuerOfPatientID', '') else '' }"
)
try:
print(f" 'IssuerOfPatientID': {ds.IssuerOfPatientID if ds.IssuerOfPatientID else '' }")
except AttributeError:
print(
" If the IssuerOfPatientID does not exist, ds.IssuerOfPatientID would throw AttributeError."
)
print(" Use ds.get('IssuerOfPatientID', '') instead.")

break
break


if __name__ == "__main__":
Expand Down
14 changes: 7 additions & 7 deletions monai/deploy/operators/dicom_seg_writer_operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -660,17 +660,17 @@ def test():
from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator
from monai.deploy.operators.dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator

data_path = "../../../examples/ai_spleen_seg_data/dcm"
out_path = "../../../examples/output_seg_op/dcm_seg_test.dcm"
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")

files = []
loader = DICOMDataLoaderOperator()
series_selector = DICOMSeriesSelectorOperator()
dcm_to_volume_op = DICOMSeriesToVolumeOperator()
seg_writer = DICOMSegmentationWriterOperator()

# Testing with more granular functions
study_list = loader.load_data_to_studies(Path(data_path).absolute())
study_list = loader.load_data_to_studies(data_path.absolute())
series = study_list[0].get_all_series()[0]

dcm_to_volume_op.prepare_series(series)
Expand All @@ -682,10 +682,10 @@ def test():
seg_writer.create_dicom_seg(image_numpy, series, Path(out_path).absolute())

# Testing with the main entry functions
study_list = loader.load_data_to_studies(Path(data_path).absolute())
_, study_selected_series_list = series_selector.filter(None, study_list)
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)
seg_writer.process_images(image, study_selected_series_list, Path(out_path).parent.absolute())
seg_writer.process_images(image, study_selected_series_list, out_path.parent.absolute())


if __name__ == "__main__":
Expand Down
9 changes: 5 additions & 4 deletions monai/deploy/operators/dicom_series_selector_operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,14 +277,15 @@ def test():

from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator

data_path = "../../../examples/ai_spleen_seg_data/dcm-multi"
current_file_dir = Path(__file__).parent.resolve()
data_path = current_file_dir.joinpath("../../../examples/ai_spleen_seg_data/dcm-multi")

loader = DICOMDataLoaderOperator()
study_list = loader.load_data_to_studies(Path(data_path).absolute())
study_list = loader.load_data_to_studies(data_path.absolute())
selector = DICOMSeriesSelectorOperator()
sample_selection_rule = json_loads(Sample_Rules_Text)
print(f"Selection rules in JSON:\n{sample_selection_rule}")
series_list, study_selected_seriee_list = selector.filter(sample_selection_rule, study_list)
study_selected_seriee_list = selector.filter(sample_selection_rule, study_list)

for sss_obj in study_selected_seriee_list:
_print_instance_properties(sss_obj, pre_fix="", print_val=False)
Expand Down Expand Up @@ -314,7 +315,7 @@ def test():
_print_instance_properties(ss_obj, pre_fix)
print(f"{pre_fix}===============================")

print(f"Total # of series selected: {len(series_list)}")
print(f" A total of {len(sss_obj.selected_series)} series selected for study {study.StudyInstanceUID}")


# Sample rule used for testing
Expand Down
5 changes: 3 additions & 2 deletions monai/deploy/operators/dicom_series_to_volume_operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,12 +346,13 @@ def test():
from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator
from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator

data_path = "../../../examples/ai_spleen_seg_data/dcm"
current_file_dir = Path(__file__).parent.resolve()
data_path = current_file_dir.joinpath("../../../examples/ai_spleen_seg_data/dcm")
loader = DICOMDataLoaderOperator()
study_list = loader.load_data_to_studies(Path(data_path).absolute())

series_selector = DICOMSeriesSelectorOperator()
_, study_selected_series_list = series_selector.filter(None, study_list)
study_selected_series_list = series_selector.filter(None, study_list)

op = DICOMSeriesToVolumeOperator()
image = op.convert_to_image(study_selected_series_list)
Expand Down
Loading