Skip to content

Commit 3331ea8

Browse files
MMelQinvikashg
authored andcommitted
Added DICOM text SR assets and updated all operator test functions. (#201)
* Added DICOM text SR assets and updated all operator test functions. Signed-off-by: mmelqin <mingmelvinq@nvidia.com> * Correcr style checker errors. Signed-off-by: mmelqin <mingmelvinq@nvidia.com> * More style checker fix Signed-off-by: mmelqin <mingmelvinq@nvidia.com> * More style checker fix to add Union[<type>, None] Signed-off-by: mmelqin <mingmelvinq@nvidia.com> * Why the checker is so dumb, None for the typed object should be fine, and is handled. Signed-off-by: mmelqin <mingmelvinq@nvidia.com> * Correct SR class name Signed-off-by: mmelqin <mingmelvinq@nvidia.com> * Integrated DICOM SR Writer Operator to MedNIST classifier example Signed-off-by: mmelqin <mingmelvinq@nvidia.com> * Correct typos in the comments Signed-off-by: mmelqin <mingmelvinq@nvidia.com> * Add release note Signed-off-by: mmelqin <mingmelvinq@nvidia.com> * Added updates to notebooks and fixed issues. Signed-off-by: mmelqin <mingmelvinq@nvidia.com> * Cleaned up comment lines per review comments Signed-off-by: mmelqin <mingmelvinq@nvidia.com> * Updated release_notes index.md, as well as release note itself Signed-off-by: mmelqin <mingmelvinq@nvidia.com>
1 parent 8361e9e commit 3331ea8

File tree

10 files changed

+578
-20
lines changed

10 files changed

+578
-20
lines changed

docs/source/release_notes/index.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
77
```
88

9+
## Version 0.2
10+
11+
```{toctree}
12+
:maxdepth: 1
13+
14+
v0.2.0
915
## Version 0.1
1016
1117
```{toctree}

docs/source/release_notes/v0.2.0.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Version 0.2.0 (November 23, 2021)
2+
3+
This is a new and enhanced version of MONAI Deploy App SDK, just in time for Thanksgiving and RSNA 2021!🎉
4+
5+
Please visit [GETTING STARTED](/getting_started/index) guide and follow tutorials.
6+
7+
You can learn more about SDK usage through [DEVELOPING WITH SDK](/developing_with_sdk/index).
8+
9+
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)😀
10+
11+
## What's new in this version 0.2.0
12+
### Series Selection Operator
13+
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.
14+
15+
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 a default implementation. More advanced rules and processing engines can be implemented in the derived classes.
16+
17+
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.
18+
19+
### DICOM Comprehensive Structured Report Writer
20+
This is introduced to support generating DICOM SR SOP instances for AI classification results, and as such, the DICOM SR writer is limited to supporting textual results only.
21+
22+
The DICOM SR writer is implemented in `DICOMTextSRWriterOperator`, it
23+
- loads the AI result from a in-memory object as well as from a file path, with the in-memory object taking precedence
24+
- 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.
25+
- supports assigning DICOM tags via a dictionary with DICOM keywords and their respective values, so that an application can customize the tags in the DICOM SR instance
26+
- 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)
27+
28+
### Updated example applications
29+
- [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
30+
- [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)
31+
- 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 how to parse the output objects
32+
- Updated also are [Jupyter notebook tutorials](https://github.com/Project-MONAI/monai-deploy-app-sdk/tree/main/notebooks/tutorials)
33+
- Multiple [issues](https://github.com/Project-MONAI/monai-deploy-app-sdk/issues?q=is%3Aissue+is%3Aclosed) were fixed and closed

examples/apps/mednist_classifier_monaideploy/mednist_classifier_monaideploy.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
# See the License for the specific language governing permissions and
1010
# limitations under the License.
1111

12+
from typing import Text
13+
1214
import monai.deploy.core as md
1315
from monai.deploy.core import (
1416
Application,
@@ -20,6 +22,7 @@
2022
Operator,
2123
OutputContext,
2224
)
25+
from monai.deploy.operators.dicom_text_sr_writer_operator import DICOMTextSRWriterOperator, EquipmentInfo, ModelInfo
2326
from monai.transforms import AddChannel, Compose, EnsureType, ScaleIntensity
2427

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

4952

5053
@md.input("image", Image, IOType.IN_MEMORY)
51-
@md.output("output", DataPath, IOType.DISK)
54+
@md.output("result_text", Text, IOType.IN_MEMORY)
5255
@md.env(pip_packages=["monai"])
5356
class MedNISTClassifierOperator(Operator):
5457
"""Classifies the given image and returns the class name."""
@@ -80,9 +83,11 @@ def compute(self, op_input: InputContext, op_output: OutputContext, context: Exe
8083

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

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

8893
# Write result to "output.json"
@@ -99,7 +104,15 @@ def compose(self):
99104
load_pil_op = LoadPILOperator()
100105
classifier_op = MedNISTClassifierOperator()
101106

107+
my_model_info = ModelInfo("MONAI WG Trainer", "MEDNIST Classifier", "0.1", "xyz")
108+
my_equipment = EquipmentInfo(manufacturer="MOANI Deploy App SDK", manufacturer_model="DICOM SR Writer")
109+
my_special_tags = {"SeriesDescription": "Not for clinical use. The result is for research use only."}
110+
dicom_sr_operator = DICOMTextSRWriterOperator(
111+
copy_tags=False, model_info=my_model_info, equipment_info=my_equipment, custom_tags=my_special_tags
112+
)
113+
102114
self.add_flow(load_pil_op, classifier_op)
115+
self.add_flow(classifier_op, dicom_sr_operator, {"result_text": "classification_result"})
103116

104117

105118
if __name__ == "__main__":

monai/deploy/core/domain/dicom_sop_instance.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@
2121
Dataset_, dataset_ok_ = optional_import("pydicom", name="Dataset")
2222
# Dynamic class is not handled so make it Any for now: https://github.com/python/mypy/issues/2477
2323
Dataset: Any = Dataset_ if dataset_ok_ else Any
24-
TagType_, tagtype_ok_ = optional_import("pydicom.tag", name="TagType")
24+
Tag_, tag_ok_ = optional_import("pydicom.tag", name="Tag")
2525
# Dynamic class is not handled so make it Any for now: https://github.com/python/mypy/issues/2477
26-
TagType: Any = TagType_ if tagtype_ok_ else Any
26+
Tag: Any = Tag_ if tag_ok_ else Any
2727

2828

2929
class DICOMSOPInstance(Domain):
@@ -39,7 +39,7 @@ def __init__(self, native_sop):
3939
def get_native_sop_instance(self):
4040
return self._sop
4141

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

4545
def get_pixel_array(self):

monai/deploy/operators/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
from .dicom_seg_writer_operator import DICOMSegmentationWriterOperator
2727
from .dicom_series_selector_operator import DICOMSeriesSelectorOperator
2828
from .dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator
29+
from .dicom_text_sr_writer_operator import DICOMTextSRWriterOperator, EquipmentInfo, ModelInfo
2930
from .inference_operator import InferenceOperator
3031
from .monai_seg_inference_operator import MonaiSegInferenceOperator
3132
from .png_converter_operator import PNGConverterOperator

monai/deploy/operators/dicom_data_loader_operator.py

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,16 +245,50 @@ def populate_series_attributes(self, series, sop_instance):
245245

246246

247247
def test():
248-
data_path = "../../../examples/ai_spleen_seg_data/dcm"
248+
current_file_dir = Path(__file__).parent.resolve()
249+
data_path = current_file_dir.joinpath("../../../examples/ai_spleen_seg_data/dcm")
249250

250251
loader = DICOMDataLoaderOperator()
251-
study_list = loader.load_data_to_studies(Path(data_path).absolute())
252+
study_list = loader.load_data_to_studies(data_path.absolute())
252253

253254
for study in study_list:
254255
print("###############################")
255256
print(study)
256257
for series in study.get_all_series():
257258
print(series)
259+
for series in study.get_all_series():
260+
for sop in series.get_sop_instances():
261+
print("Demonstrating ways to access DICOM attributes in a SOP instance.")
262+
# No need to get the native_ds = sop.get_native_sop_instance()
263+
# sop = sop.get_native_sop_instance()
264+
print(f" 'StudyInstanceUID': {sop['StudyInstanceUID'].repval}")
265+
print(f" (0x0020, 0x000D): {sop[0x0020, 0x000D].repval}")
266+
print(f" 'SeriesInstanceUID': {sop['SeriesInstanceUID'].value.name}")
267+
print(f" (0x0020, 0x000E): {sop[0x0020, 0x000E].value.name}")
268+
print(f" 'SOPInstanceUID': {sop['SOPInstanceUID'].value.name}")
269+
print(f" (0008,0018): {sop[0x0008, 0x0018].value.name}")
270+
try:
271+
print(f" 'InstanceNumber': {sop['InstanceNumber'].repval}")
272+
print(f" (0020, 0013): {sop[0x0020, 0x0013].repval}")
273+
except KeyError:
274+
pass
275+
# Need to get pydicom dataset to use properties and get method of a dict.
276+
ds = sop.get_native_sop_instance()
277+
print(f" 'StudyInstanceUID': {ds.StudyInstanceUID if ds.StudyInstanceUID else ''}")
278+
print(f" 'SeriesDescription': {ds.SeriesDescription if ds.SeriesDescription else ''}")
279+
print(
280+
f" 'IssuerOfPatientID': {ds.get('IssuerOfPatientID', '').repval if ds.get('IssuerOfPatientID', '') else '' }"
281+
)
282+
try:
283+
print(f" 'IssuerOfPatientID': {ds.IssuerOfPatientID if ds.IssuerOfPatientID else '' }")
284+
except AttributeError:
285+
print(
286+
" If the IssuerOfPatientID does not exist, ds.IssuerOfPatientID would throw AttributeError."
287+
)
288+
print(" Use ds.get('IssuerOfPatientID', '') instead.")
289+
290+
break
291+
break
258292

259293

260294
if __name__ == "__main__":

monai/deploy/operators/dicom_seg_writer_operator.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -660,17 +660,17 @@ def test():
660660
from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator
661661
from monai.deploy.operators.dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator
662662

663-
data_path = "../../../examples/ai_spleen_seg_data/dcm"
664-
out_path = "../../../examples/output_seg_op/dcm_seg_test.dcm"
663+
current_file_dir = Path(__file__).parent.resolve()
664+
data_path = current_file_dir.joinpath("../../../examples/ai_spleen_seg_data/dcm")
665+
out_path = current_file_dir.joinpath("../../../examples/output_seg_op/dcm_seg_test.dcm")
665666

666-
files = []
667667
loader = DICOMDataLoaderOperator()
668668
series_selector = DICOMSeriesSelectorOperator()
669669
dcm_to_volume_op = DICOMSeriesToVolumeOperator()
670670
seg_writer = DICOMSegmentationWriterOperator()
671671

672672
# Testing with more granular functions
673-
study_list = loader.load_data_to_studies(Path(data_path).absolute())
673+
study_list = loader.load_data_to_studies(data_path.absolute())
674674
series = study_list[0].get_all_series()[0]
675675

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

684684
# Testing with the main entry functions
685-
study_list = loader.load_data_to_studies(Path(data_path).absolute())
686-
_, study_selected_series_list = series_selector.filter(None, study_list)
685+
study_list = loader.load_data_to_studies(data_path.absolute())
686+
study_selected_series_list = series_selector.filter(None, study_list)
687687
image = dcm_to_volume_op.convert_to_image(study_selected_series_list)
688-
seg_writer.process_images(image, study_selected_series_list, Path(out_path).parent.absolute())
688+
seg_writer.process_images(image, study_selected_series_list, out_path.parent.absolute())
689689

690690

691691
if __name__ == "__main__":

monai/deploy/operators/dicom_series_selector_operator.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -277,14 +277,15 @@ def test():
277277

278278
from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator
279279

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

282283
loader = DICOMDataLoaderOperator()
283-
study_list = loader.load_data_to_studies(Path(data_path).absolute())
284+
study_list = loader.load_data_to_studies(data_path.absolute())
284285
selector = DICOMSeriesSelectorOperator()
285286
sample_selection_rule = json_loads(Sample_Rules_Text)
286287
print(f"Selection rules in JSON:\n{sample_selection_rule}")
287-
series_list, study_selected_seriee_list = selector.filter(sample_selection_rule, study_list)
288+
study_selected_seriee_list = selector.filter(sample_selection_rule, study_list)
288289

289290
for sss_obj in study_selected_seriee_list:
290291
_print_instance_properties(sss_obj, pre_fix="", print_val=False)
@@ -314,7 +315,7 @@ def test():
314315
_print_instance_properties(ss_obj, pre_fix)
315316
print(f"{pre_fix}===============================")
316317

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

319320

320321
# Sample rule used for testing

monai/deploy/operators/dicom_series_to_volume_operator.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,12 +346,13 @@ def test():
346346
from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator
347347
from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator
348348

349-
data_path = "../../../examples/ai_spleen_seg_data/dcm"
349+
current_file_dir = Path(__file__).parent.resolve()
350+
data_path = current_file_dir.joinpath("../../../examples/ai_spleen_seg_data/dcm")
350351
loader = DICOMDataLoaderOperator()
351352
study_list = loader.load_data_to_studies(Path(data_path).absolute())
352353

353354
series_selector = DICOMSeriesSelectorOperator()
354-
_, study_selected_series_list = series_selector.filter(None, study_list)
355+
study_selected_series_list = series_selector.filter(None, study_list)
355356

356357
op = DICOMSeriesToVolumeOperator()
357358
image = op.convert_to_image(study_selected_series_list)

0 commit comments

Comments
 (0)