Skip to content

Commit b1f28c5

Browse files
committed
cchmc_ped_abd_ct_seg example app
Signed-off-by: bluna301 <[email protected]>
1 parent 192bc70 commit b1f28c5

18 files changed

+2075
-0
lines changed

docs/source/getting_started/examples.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@
1313
- ai_unetr_seg_app
1414
- dicom_series_to_image_app
1515
- breast_density_classifer_app
16+
- cchmc_ped_abd_ct_seg_app
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# MONAI Application Package (MAP) for CCHMC Pediatric Abdominal CT Segmentation MONAI Bundle
2+
3+
This MAP is based on the [CCHMC Pediatric Abdominal CT Segmentation MONAI Bundle](https://github.com/cchmc-dll/pediatric_abdominal_segmentation_bundle/tree/original). This model was developed at Cincinnati Children's Hospital Medical Center by the Department of Radiology.
4+
5+
The PyTorch and TorchScript DynUNet models can be downloaded from the [MONAI Bundle Repository](https://github.com/cchmc-dll/pediatric_abdominal_segmentation_bundle/tree/original/models).
6+
7+
For questions, please feel free to contact Elan Somasundaram ([email protected]) and Bryan Luna ([email protected]).
8+
9+
## Unique Features
10+
11+
Some unique features of this MAP pipeline include:
12+
- **Custom Inference Operator:** custom `AbdomenSegOperator` enables either PyTorch or TorchScript model loading as desired
13+
- **DICOM Secondary Capture Output:** custom `DICOMSecondaryCaptureWriterOperator` writes a DICOM SC with organ contours
14+
- **Output Filtering:** model produces Liver-Spleen-Pancreas segmentations, but seg visibility in the outputs (DICOM SEG, SC, SR) can be controlled in `app.py`
15+
- **MONAI Deploy Express MongoDB Write:** custom operators (`MongoDBEntryCreatorOperator` and `MongoDBWriterOperator`) allow for writing to the MongoDB database associated with MONAI Deploy Express
16+
17+
## Scripts
18+
Several scripts have been compiled that quickly execute useful actions (such as running the app code locally with Python interpreter, MAP packaging, MAP execution, etc.). Some scripts require the input of command line arguments; review the `scripts` folder for more details.
19+
20+
## Notes
21+
The DICOM Series selection criteria has been customized based on the model's training and CCHMC use cases. By default, Axial CT series with Slice Thickness between 3.0 - 5.0 mm (inclusive) will be selected for.
22+
23+
If PyTorch model loading is desired, please uncomment the "PyTorch Model Loading" section in the `AbdomenSegOperator`.
24+
25+
If MongoDB writing is desired, please uncomment the relevant sections in `app.py` and the `AbdomenSegOperator`. Please note that MongoDB connection values (username, password, and port) are the default values pulled from the v0.6.0 MONAI Deploy Express [.env](https://github.com/Project-MONAI/monai-deploy/blob/main/deploy/monai-deploy-express/.env) and [docker-compose.yaml](https://github.com/Project-MONAI/monai-deploy/blob/main/deploy/monai-deploy-express/docker-compose.yml) files; these default values are harcoded into the `MongoDBWriterOperator`. If your instance of MONAI Deploy Express has modified values for these fields, the `MongoDBWriterOperator` will need to be udpated accordingly.
26+
27+
The MONAI Deploy Express MongoDB Docker container (`mdl-mongodb`) needs to be connected to the Docker bridge network in order for the MongoDB write to be successful. Executing the following command in a MONAI Deploy Express terminal will establish this connection:
28+
29+
```bash
30+
docker network connect bridge mdl-mongodb
31+
```
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Copyright 2021-2024 MONAI Consortium
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
# Unless required by applicable law or agreed to in writing, software
7+
# distributed under the License is distributed on an "AS IS" BASIS,
8+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
# See the License for the specific language governing permissions and
10+
# limitations under the License.
11+
12+
# __init__.py is used to initialize a Python package
13+
# ensures that the directory __init__.py resides in is included at the start of the sys.path
14+
# this is useful when you want to import modules from this directory, even if it’s not the
15+
# directory where your Python script is running.
16+
17+
# give access to operating system and Python interpreter
18+
import os
19+
import sys
20+
21+
# grab absolute path of directory containing __init__.py
22+
_current_dir = os.path.abspath(os.path.dirname(__file__))
23+
24+
# if sys.path is not the same as the directory containing the __init__.py file
25+
if sys.path and os.path.abspath(sys.path[0]) != _current_dir:
26+
# insert directory containing __init__.py file at the beginning of sys.path
27+
sys.path.insert(0, _current_dir)
28+
# delete variable
29+
del _current_dir
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Copyright 2021-2024 MONAI Consortium
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
# Unless required by applicable law or agreed to in writing, software
7+
# distributed under the License is distributed on an "AS IS" BASIS,
8+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
# See the License for the specific language governing permissions and
10+
# limitations under the License.
11+
12+
# __main__.py is needed for MONAI Application Packager to detect the main app code (app.py) when
13+
# app.py is executed in the application folder path
14+
# e.g., python my_app
15+
16+
import logging
17+
18+
# import AIAbdomenSegApp class from app.py
19+
from app import AIAbdomenSegApp
20+
21+
# if __main__.py is being run directly
22+
if __name__ == "__main__":
23+
logging.info(f"Begin {__name__}")
24+
# create and run an instance of AIAbdomenSegApp
25+
AIAbdomenSegApp().run()
26+
logging.info(f"End {__name__}")
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
# Copyright 2021-2024 MONAI Consortium
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
# http://www.apache.org/licenses/LICENSE-2.0
6+
# Unless required by applicable law or agreed to in writing, software
7+
# distributed under the License is distributed on an "AS IS" BASIS,
8+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
9+
# See the License for the specific language governing permissions and
10+
# limitations under the License.
11+
12+
import logging
13+
from pathlib import Path
14+
from typing import List
15+
16+
from numpy import float32, int16
17+
18+
# import custom transforms from post_transforms.py
19+
from post_transforms import CalculateVolumeFromMaskd, ExtractVolumeToTextd, LabelToContourd, OverlayImageLabeld
20+
21+
from monai.deploy.core import AppContext, Fragment, Operator, OperatorSpec
22+
from monai.deploy.operators.monai_seg_inference_operator import InfererType, InMemImageReader, MonaiSegInferenceOperator
23+
from monai.transforms import (
24+
Activationsd,
25+
AsDiscreted,
26+
CastToTyped,
27+
Compose,
28+
CropForegroundd,
29+
EnsureChannelFirstd,
30+
EnsureTyped,
31+
Invertd,
32+
LoadImaged,
33+
Orientationd,
34+
SaveImaged,
35+
ScaleIntensityRanged,
36+
Spacingd,
37+
)
38+
39+
# # PyTorch model pipeline dependencies
40+
# import torch
41+
# import monai
42+
# from monai.deploy.core import Model
43+
44+
45+
# this operator performs inference with the new version of the bundle
46+
class AbdomenSegOperator(Operator):
47+
"""Performs segmentation inference with a custom model architecture."""
48+
49+
DEFAULT_OUTPUT_FOLDER = Path.cwd() / "output"
50+
51+
def __init__(
52+
self,
53+
fragment: Fragment,
54+
*args,
55+
app_context: AppContext,
56+
model_path: Path,
57+
output_folder: Path = DEFAULT_OUTPUT_FOLDER,
58+
output_labels: List[int],
59+
**kwargs,
60+
):
61+
62+
self._logger = logging.getLogger(f"{__name__}.{type(self).__name__}")
63+
self._input_dataset_key = "image"
64+
self._pred_dataset_key = "pred"
65+
66+
# self.model_path is compatible with TorchScript and PyTorch model workflows (pythonic and MAP)
67+
self.model_path = self._find_model_file_path(model_path)
68+
69+
self.output_folder = output_folder
70+
self.output_folder.mkdir(parents=True, exist_ok=True)
71+
self.output_labels = output_labels
72+
self.app_context = app_context
73+
self.input_name_image = "image"
74+
self.output_name_seg = "seg_image"
75+
self.output_name_text_dicom_sr = "result_text_dicom_sr"
76+
self.output_name_text_mongodb = "result_text_mongodb"
77+
self.output_name_sc_path = "dicom_sc_dir"
78+
79+
# the base class has an attribute called fragment to hold the reference to the fragment object
80+
super().__init__(fragment, *args, **kwargs)
81+
82+
# find model path; supports TorchScript and PyTorch model workflows (pythonic and MAP)
83+
def _find_model_file_path(self, model_path: Path):
84+
# when executing pythonically, model_path is a file
85+
# when executing as MAP, model_path is a directory (/opt/holoscan/models)
86+
# torch.load() from PyTorch workflow needs file path; can't load model from directory
87+
# returns first found file in directory in this case
88+
if model_path:
89+
if model_path.is_file():
90+
return model_path
91+
elif model_path.is_dir():
92+
for file in model_path.rglob("*"):
93+
if file.is_file():
94+
return file
95+
96+
raise ValueError(f"Model file not found in the provided path: {model_path}")
97+
98+
def setup(self, spec: OperatorSpec):
99+
spec.input(self.input_name_image)
100+
101+
# DICOM SEG
102+
spec.output(self.output_name_seg)
103+
104+
# DICOM SR
105+
spec.output(self.output_name_text_dicom_sr)
106+
107+
# # MongoDB
108+
# spec.output(self.output_name_text_mongodb)
109+
110+
# DICOM SC
111+
spec.output(self.output_name_sc_path)
112+
113+
def compute(self, op_input, op_output, context):
114+
input_image = op_input.receive(self.input_name_image)
115+
if not input_image:
116+
raise ValueError("Input image is not found.")
117+
118+
# this operator gets an in-memory Image object, so a specialized ImageReader is needed.
119+
_reader = InMemImageReader(input_image)
120+
121+
# preprocessing and postprocessing
122+
pre_transforms = self.pre_process(_reader)
123+
post_transforms = self.post_process(pre_transforms)
124+
125+
##########
126+
127+
# # PyTorch Model Loading:
128+
129+
# _device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
130+
# _kernel_size: tuple = (3, 3, 3, 3, 3, 3)
131+
# _strides: tuple = (1, 2, 2, 2, 2, (2, 2, 1))
132+
# _upsample_kernel_size: tuple = (2, 2, 2, 2, (2, 2, 1))
133+
134+
# # create DynUNet model with the specified architecture parameters + move to computational device (GPU or CPU)
135+
# # parameters pulled from inference.yaml file of the MONAI bundle
136+
# model = monai.networks.nets.dynunet.DynUNet(
137+
# spatial_dims=3,
138+
# in_channels=1,
139+
# out_channels=4,
140+
# kernel_size=_kernel_size,
141+
# strides=_strides,
142+
# upsample_kernel_size=_upsample_kernel_size,
143+
# norm_name="INSTANCE",
144+
# deep_supervision=False,
145+
# res_block=True
146+
# ).to(_device)
147+
148+
# # load model state dictionary (i.e. mapping param names to tensors) via torch.load
149+
# # weights_only=True to avoid arbitrary code execution during unpickling
150+
# state_dict = torch.load(self.model_path, weights_only=True)
151+
152+
# # assign loaded weights to model architecture via load_state_dict
153+
# model.load_state_dict(state_dict)
154+
155+
# # set model in evaluation (inference) mode
156+
# model.eval()
157+
158+
# # create a MONAI Model object to encapsulate the PyTorch model and metadata
159+
# loaded_model = Model(self.model_path, name="ped_abd_ct_seg")
160+
161+
# # assign loaded PyTorch model as the predictor for the Model object
162+
# loaded_model.predictor = model
163+
164+
# # register the loaded Model object in the application context so other operators can access it
165+
# # MonaiSegInferenceOperator uses _get_model method to load models; looks at app_context.models first
166+
# self.app_context.models = loaded_model
167+
168+
##########
169+
170+
# delegates inference and saving output to the built-in operator.
171+
infer_operator = MonaiSegInferenceOperator(
172+
self.fragment,
173+
roi_size=(96, 96, 96),
174+
pre_transforms=pre_transforms,
175+
post_transforms=post_transforms,
176+
overlap=0.75,
177+
app_context=self.app_context,
178+
model_name="",
179+
inferer=InfererType.SLIDING_WINDOW,
180+
sw_batch_size=4,
181+
model_path=self.model_path,
182+
name="monai_seg_inference_op",
183+
)
184+
185+
# setting the keys used in the dictionary-based transforms
186+
infer_operator.input_dataset_key = self._input_dataset_key
187+
infer_operator.pred_dataset_key = self._pred_dataset_key
188+
189+
seg_image = infer_operator.compute_impl(input_image, context)
190+
191+
# DICOM SEG
192+
op_output.emit(seg_image, self.output_name_seg)
193+
194+
# grab result_text_dicom_sr and result_text_mongodb from ExractVolumeToTextd transform
195+
result_text_dicom_sr, result_text_mongodb = self.get_result_text_from_transforms(post_transforms)
196+
if not result_text_dicom_sr or not result_text_mongodb:
197+
raise ValueError("Result text could not be generated.")
198+
199+
# only log volumes for target organs so logs reflect MAP behavior
200+
self._logger.info(f"Calculated Organ Volumes: {result_text_dicom_sr}")
201+
202+
# DICOM SR
203+
op_output.emit(result_text_dicom_sr, self.output_name_text_dicom_sr)
204+
205+
# # MongoDB
206+
# op_output.emit(result_text_mongodb, self.output_name_text_mongodb)
207+
208+
# DICOM SC
209+
# temporary DICOM SC (w/o source DICOM metadata) saved in output_folder / temp directory
210+
dicom_sc_dir = self.output_folder / "temp"
211+
212+
self._logger.info(f"Temporary DICOM SC saved at: {dicom_sc_dir}")
213+
214+
op_output.emit(dicom_sc_dir, self.output_name_sc_path)
215+
216+
def pre_process(self, img_reader) -> Compose:
217+
"""Composes transforms for preprocessing the input image before predicting on a model."""
218+
219+
my_key = self._input_dataset_key
220+
221+
return Compose(
222+
[
223+
# img_reader: specialized InMemImageReader, derived from MONAI ImageReader
224+
LoadImaged(keys=my_key, reader=img_reader),
225+
EnsureChannelFirstd(keys=my_key),
226+
Orientationd(keys=my_key, axcodes="RAS"),
227+
Spacingd(keys=my_key, pixdim=[1.5, 1.5, 3.0], mode=["bilinear"]),
228+
ScaleIntensityRanged(keys=my_key, a_min=-250, a_max=400, b_min=0.0, b_max=1.0, clip=True),
229+
CropForegroundd(keys=my_key, source_key=my_key, mode="minimum"),
230+
EnsureTyped(keys=my_key),
231+
CastToTyped(keys=my_key, dtype=float32),
232+
]
233+
)
234+
235+
def post_process(self, pre_transforms: Compose) -> Compose:
236+
"""Composes transforms for postprocessing the prediction results."""
237+
238+
pred_key = self._pred_dataset_key
239+
240+
labels = {"background": 0, "liver": 1, "spleen": 2, "pancreas": 3}
241+
242+
return Compose(
243+
[
244+
Activationsd(keys=pred_key, softmax=True),
245+
Invertd(
246+
keys=[pred_key, self._input_dataset_key],
247+
transform=pre_transforms,
248+
orig_keys=[self._input_dataset_key, self._input_dataset_key],
249+
meta_key_postfix="meta_dict",
250+
nearest_interp=[False, False],
251+
to_tensor=True,
252+
),
253+
AsDiscreted(keys=pred_key, argmax=True),
254+
# custom post-processing steps
255+
CalculateVolumeFromMaskd(keys=pred_key, label_names=labels),
256+
# optional code for saving segmentation masks as a NIfTI
257+
# SaveImaged(
258+
# keys=pred_key,
259+
# output_ext=".nii.gz",
260+
# output_dir=self.output_folder / "NIfTI",
261+
# meta_keys="pred_meta_dict",
262+
# separate_folder=False,
263+
# output_dtype=int16
264+
# ),
265+
# volume data stored in dictionary under pred_key + '_volumes' key
266+
ExtractVolumeToTextd(
267+
keys=[pred_key + "_volumes"], label_names=labels, output_labels=self.output_labels
268+
),
269+
# comment out LabelToContourd for seg masks instead of contours; organ filtering will be lost
270+
LabelToContourd(keys=pred_key, output_labels=self.output_labels),
271+
OverlayImageLabeld(image_key=self._input_dataset_key, label_key=pred_key, overlay_key="overlay"),
272+
SaveImaged(
273+
keys="overlay",
274+
output_ext=".dcm",
275+
# save temporary DICOM SC (w/o source DICOM metadata) in output_folder / temp directory
276+
output_dir=self.output_folder / "temp",
277+
separate_folder=False,
278+
output_dtype=int16,
279+
),
280+
]
281+
)
282+
283+
# grab volume data from ExtractVolumeToTextd transform
284+
def get_result_text_from_transforms(self, post_transforms: Compose):
285+
"""Extracts the result_text variables from post-processing transforms output."""
286+
287+
# grab the result_text variables from ExractVolumeToTextd transfor
288+
for transform in post_transforms.transforms:
289+
if isinstance(transform, ExtractVolumeToTextd):
290+
return transform.result_text_dicom_sr, transform.result_text_mongodb
291+
return None

0 commit comments

Comments
 (0)