Skip to content

Commit 5d7c7d8

Browse files
committed
Migrated the breast density classification app, and fixed a bug.
Signed-off-by: M Q <mingmelvinq@nvidia.com>
1 parent 7626078 commit 5d7c7d8

File tree

6 files changed

+142
-31
lines changed

6 files changed

+142
-31
lines changed
Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1+
import logging
2+
13
from app import BreastClassificationApp
24

5+
from monai.deploy.logger import load_env_log_level
6+
37
if __name__ == "__main__":
4-
BreastClassificationApp(do_run=True)
8+
load_env_log_level()
9+
logging.info(f"Begin {__name__}")
10+
BreastClassificationApp().run()
11+
logging.info(f"End {__name__}")

examples/apps/breast_density_classifer_app/app.py

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,78 @@
1+
import logging
2+
from pathlib import Path
3+
14
from breast_density_classifier_operator import ClassifierOperator
25

3-
from monai.deploy.core import Application, env
6+
from monai.deploy.conditions import CountCondition
7+
from monai.deploy.core import AppContext, Application
8+
from monai.deploy.logger import load_env_log_level
49
from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator
510
from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator
611
from monai.deploy.operators.dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator
712
from monai.deploy.operators.dicom_text_sr_writer_operator import DICOMTextSRWriterOperator, EquipmentInfo, ModelInfo
813

914

10-
@env(pip_packages=["highdicom>=0.18.2"])
15+
# @env(pip_packages=["monai~=1.1.0", "highdicom>=0.18.2", "pydicom >= 2.3.0"])
1116
class BreastClassificationApp(Application):
17+
"""This is an AI breast density classification application.
18+
19+
The DL model was trained by Center for Augmented Intelligence in Imaging, Mayo Clinic, Florida,
20+
and published on MONAI Model Zoo at
21+
https://github.com/Project-MONAI/model-zoo/tree/dev/models/breast_density_classification
22+
"""
23+
1224
def __init__(self, *args, **kwargs):
25+
self._logger = logging.getLogger("{}.{}".format(__name__, type(self).__name__))
1326
super().__init__(*args, **kwargs)
1427

1528
def compose(self):
29+
"""Creates the app specific operators and chain them up in the processing DAG."""
30+
logging.info(f"Begin {self.compose.__name__}")
31+
32+
app_context = AppContext({}) # Let it figure out all the attributes without overriding
33+
app_input_path = Path(app_context.input_path)
34+
app_output_path = Path(app_context.output_path)
35+
model_path = Path(app_context.model_path)
36+
1637
model_info = ModelInfo(
1738
"MONAI Model for Breast Density",
1839
"BreastDensity",
1940
"0.1",
2041
"Center for Augmented Intelligence in Imaging, Mayo Clinic, Florida",
2142
)
43+
2244
my_equipment = EquipmentInfo(manufacturer="MONAI Deploy App SDK", manufacturer_model="DICOM SR Writer")
2345
my_special_tags = {"SeriesDescription": "Not for clinical use"}
24-
study_loader_op = DICOMDataLoaderOperator()
25-
series_selector_op = DICOMSeriesSelectorOperator(rules=Sample_Rules_Text)
26-
series_to_vol_op = DICOMSeriesToVolumeOperator()
27-
classifier_op = ClassifierOperator()
46+
study_loader_op = DICOMDataLoaderOperator(
47+
self, CountCondition(self, 1), input_folder=app_input_path, name="study_loader_op"
48+
)
49+
series_selector_op = DICOMSeriesSelectorOperator(
50+
self, rules_json_str=Sample_Rules_Text, name="series_selector_op"
51+
)
52+
series_to_vol_op = DICOMSeriesToVolumeOperator(self, name="series_to_vol_op")
53+
classifier_op = ClassifierOperator(
54+
self, output_folder=app_output_path, model_path=model_path, name="classifier_op"
55+
)
2856
sr_writer_op = DICOMTextSRWriterOperator(
29-
copy_tags=True, model_info=model_info, equipment_info=my_equipment, custom_tags=my_special_tags
57+
self,
58+
copy_tags=True,
59+
model_info=model_info,
60+
equipment_info=my_equipment,
61+
custom_tags=my_special_tags,
62+
output_folder=app_output_path,
63+
name="sr_writer_op",
3064
) # copy_tags=True to use Study and Patient modules of the original input
3165

32-
self.add_flow(study_loader_op, series_selector_op, {"dicom_study_list": "dicom_study_list"})
66+
self.add_flow(study_loader_op, series_selector_op, {("dicom_study_list", "dicom_study_list")})
3367
self.add_flow(
34-
series_selector_op, series_to_vol_op, {"study_selected_series_list": "study_selected_series_list"}
68+
series_selector_op, series_to_vol_op, {("study_selected_series_list", "study_selected_series_list")}
3569
)
36-
self.add_flow(series_to_vol_op, classifier_op, {"image": "image"})
37-
self.add_flow(classifier_op, sr_writer_op, {"result_text": "classification_result"})
70+
self.add_flow(series_to_vol_op, classifier_op, {("image", "image")})
71+
self.add_flow(classifier_op, sr_writer_op, {("result_text", "text")})
3872
# Pass the Study series to the SR writer for copying tags
39-
self.add_flow(series_selector_op, sr_writer_op, {"study_selected_series_list": "study_selected_series_list"})
73+
self.add_flow(series_selector_op, sr_writer_op, {("study_selected_series_list", "study_selected_series_list")})
74+
75+
logging.info(f"End {self.compose.__name__}")
4076

4177

4278
# This is a sample series selection rule in JSON, simply selecting a MG series.
@@ -69,4 +105,7 @@ def test():
69105

70106

71107
if __name__ == "__main__":
72-
app = BreastClassificationApp(do_run=True)
108+
load_env_log_level()
109+
logging.info(f"Begin {__name__}")
110+
BreastClassificationApp().run()
111+
logging.info(f"End {__name__}")

examples/apps/breast_density_classifer_app/breast_density_classifier_operator.py

Lines changed: 79 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import json
2-
from typing import Dict, Text
2+
import os
3+
from pathlib import Path
4+
from typing import Any, Dict, List, Optional, Sequence, Text, Tuple, Union
35

46
import torch
57

6-
import monai.deploy.core as md
78
from monai.data import DataLoader, Dataset
8-
from monai.deploy.core import ExecutionContext, Image, InputContext, IOType, Operator, OutputContext
9+
from monai.deploy.core import ConditionType, Fragment, Image, Operator, OperatorSpec
910
from monai.deploy.operators.monai_seg_inference_operator import InMemImageReader
1011
from monai.transforms import (
1112
Activations,
@@ -20,14 +21,70 @@
2021
)
2122

2223

23-
@md.input("image", Image, IOType.IN_MEMORY)
24-
@md.output("result_text", Text, IOType.IN_MEMORY)
24+
# @md.input("image", Image, IOType.IN_MEMORY)
25+
# @md.output("result_text", Text, IOType.IN_MEMORY)
26+
# @env(pip_packages=["monai~=1.1.0"])
2527
class ClassifierOperator(Operator):
26-
def __init__(self):
27-
super().__init__()
28+
"""Performs breast density classification using a DL model with an image converted from a DICOM MG series.
29+
30+
Named inputs:
31+
image: Image object for which to generate the classification.
32+
output_folder: Optional, the path to save the results JSON file, overridingthe the one set on __init__
33+
34+
Named output:
35+
result_text: The classification results in text.
36+
"""
37+
38+
DEFAULT_OUTPUT_FOLDER = Path.cwd() / "classification_results"
39+
# For testing the app directly, the model should be at the following path.
40+
MODEL_LOCAL_PATH = Path(os.environ.get("HOLOSCAN_MODEL_PATH", Path.cwd() / "model/model.ts"))
41+
42+
def __init__(
43+
self,
44+
frament: Fragment,
45+
*args,
46+
model_name: Optional[str] = "",
47+
model_path: Path = MODEL_LOCAL_PATH,
48+
output_folder: Path = DEFAULT_OUTPUT_FOLDER,
49+
**kwargs,
50+
):
51+
"""Creates an instance with the reference back to the containing application/fragment.
52+
53+
fragment (Fragment): An instance of the Application class which is derived from Fragment.
54+
model_name (str, optional): Name of the model. Default to "" for single model app.
55+
model_path (Path): Path to the model file. Defaults to model/models.ts of current working dir.
56+
output_folder (Path, optional): output folder for saving the classification results JSON file.
57+
"""
58+
59+
# the names used for the model inference input and output
2860
self._input_dataset_key = "image"
2961
self._pred_dataset_key = "pred"
3062

63+
# The names used for the operator input and output
64+
self.input_name_image = "image"
65+
self.output_name_result = "result_text"
66+
67+
# The name of the optional input port for passing data to override the output folder path.
68+
self.input_name_output_folder = "output_folder"
69+
70+
# The output folder set on the object can be overriden at each compute by data in the optional named input
71+
self.output_folder = output_folder
72+
73+
# Need the name when there are multiple models loaded
74+
self._model_name = model_name.strip() if isinstance(model_name, str) else ""
75+
# Need the path to load the models when they are not loaded in the execution context
76+
self.model_path = model_path
77+
78+
# This needs to be at the end of the constructor.
79+
super().__init__(frament, *args, **kwargs)
80+
81+
def setup(self, spec: OperatorSpec):
82+
"""Set up the operator named input and named output, both are in-memory objects."""
83+
84+
spec.input(self.input_name_image)
85+
spec.input(self.input_name_output_folder).condition(ConditionType.NONE) # Optional for overriding.
86+
spec.output(self.output_name_result).condition(ConditionType.NONE) # Not forcing a downstream receiver.
87+
3188
def _convert_dicom_metadata_datatype(self, metadata: Dict):
3289
if not metadata:
3390
return metadata
@@ -55,16 +112,26 @@ def _convert_dicom_metadata_datatype(self, metadata: Dict):
55112

56113
return metadata
57114

58-
def compute(self, op_input: InputContext, op_output: OutputContext, context: ExecutionContext):
59-
input_image = op_input.get("image")
115+
def compute(self, op_input, op_output, context):
116+
input_image = op_input.receive(self.input_name_image)
117+
if not input_image:
118+
raise ValueError("Input image is not found.")
119+
if not isinstance(input_image, Image):
120+
raise ValueError(f"Input is not the required type: {type(Image)!r}")
121+
60122
_reader = InMemImageReader(input_image)
61123
input_img_metadata = self._convert_dicom_metadata_datatype(input_image.metadata())
62124
img_name = str(input_img_metadata.get("SeriesInstanceUID", "Img_in_context"))
63125

64-
output_path = context.output.get().path
126+
output_folder_on_compute = op_input.receive(self.input_name_output_folder)
127+
output_folder = output_folder_on_compute if output_folder_on_compute else self.output_folder
128+
Path.mkdir(output_folder, parents=True, exist_ok=True) # Let exception bubble up if raised.
65129

66130
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
67-
model = context.models.get()
131+
132+
# Need to get the model from context, when it is re-implemented, and for now, load it directly here.
133+
# model = context.models.get()
134+
model = torch.jit.load(self.model_path, map_location=device)
68135

69136
pre_transforms = self.pre_process(_reader)
70137
post_transforms = self.post_process()
@@ -82,15 +149,12 @@ def compute(self, op_input: InputContext, op_output: OutputContext, context: Exe
82149
result_dict = (
83150
"A " + ":" + str(out[0]) + " B " + ":" + str(out[1]) + " C " + ":" + str(out[2]) + " D " + ":" + str(out[3])
84151
)
85-
result_dict_out = {"A": str(out[0]), "B": str(out[1]), "C": str(out[2]), "D": str(out[3])}
86-
output_folder = context.output.get().path
87-
output_folder.mkdir(parents=True, exist_ok=True)
88152

89153
output_path = output_folder / "output.json"
90154
with open(output_path, "w") as fp:
91155
json.dump(result_dict, fp)
92156

93-
op_output.set(result_dict, "result_text")
157+
op_output.emit(result_dict, "result_text")
94158

95159
def pre_process(self, image_reader) -> Compose:
96160
return Compose(

monai/deploy/operators/dicom_encapsulated_pdf_writer_operator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def __init__(
8686

8787
# Need to init the output folder until the execution context supports dynamic FS path
8888
# Not trying to create the folder to avoid exception on init
89-
self.output_dir = (
89+
self.output_folder = (
9090
Path(output_folder) if output_folder else DICOMEncapsulatedPDFWriterOperator.DEFAULT_OUTPUT_FOLDER
9191
)
9292
self.copy_tags = copy_tags

monai/deploy/operators/dicom_text_sr_writer_operator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def __init__(
8282

8383
# Need to init the output folder until the execution context supports dynamic FS path
8484
# Not trying to create the folder to avoid exception on init
85-
self.output_dir = Path(output_folder) if output_folder else DICOMTextSRWriterOperator.DEFAULT_OUTPUT_FOLDER
85+
self.output_folder = Path(output_folder) if output_folder else DICOMTextSRWriterOperator.DEFAULT_OUTPUT_FOLDER
8686
self.copy_tags = copy_tags
8787
self.model_info = model_info if model_info else ModelInfo()
8888
self.equipment_info = equipment_info if equipment_info else EquipmentInfo()

monai/deploy/operators/monai_seg_inference_operator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def __init__(
8484
pre_transforms (Compose): MONAI Compose object used for pre-transforms.
8585
post_transforms (Compose): MONAI Compose object used for post-transforms.
8686
model_name (str, optional): Name of the model. Default to "" for single model app.
87+
model_path (Path): Path to the model file. Defaults to model/models.ts of current working dir.
8788
overlap (float): The overlap used in sliding window inference.
8889
"""
8990

0 commit comments

Comments
 (0)