Skip to content

Commit 6079064

Browse files
committed
Initial code for making a RESTful miscroservice with MONAI App SDK
Signed-off-by: M Q <mingmelvinq@nvidia.com>
1 parent dec9305 commit 6079064

12 files changed

Lines changed: 1164 additions & 0 deletions

File tree

platforms/aidoc/README.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# RESTful Wrapper Application for MONAI Deploy
2+
3+
This application provides a RESTful web interface to run MONAI Deploy applications.
4+
5+
It allows you to start a processing job, check the status, and receive a callback when the job is complete.
6+
7+
As it stands now, the callback message content is stubbed/generated in the wrapper app, and this will change to the design
8+
where the wrapper app will pass a static callback function to the MONAI Deploy app which will have a reporter operator
9+
that gathers the operations and domain specific info in the app's pipeline and then reports back the content via
10+
this callback. The wrapper app will then have a mapping function to transform the reported data to that expected by
11+
the external callback endpoint.
12+
13+
Also, the whole Restful application can be packaged into a container image using MONAI Deploy app packager, but not doner here.
14+
15+
## How to Run
16+
17+
Change working directory to the same level as this README.
18+
19+
1. **Install Dependencies**
20+
21+
Create and activate a Python virtual environment.
22+
23+
```bash
24+
pip install -r restful_app/requirements.txt
25+
```
26+
2. **Download Test Data and Set Env Vars**
27+
The model and test DICOM series are shared on Google Drive requiring first gaining access permission, and
28+
the zip file is [here](https://drive.google.com/uc?id=1IwWMpbo2fd38fKIqeIdL8SKTGvkn31tK).
29+
30+
Please make a request so that it can be shared to specific Gmail account.
31+
32+
`gdown` may also work.
33+
```
34+
pip install gdown
35+
gdown https://drive.google.com/uc?id=1IwWMpbo2fd38fKIqeIdL8SKTGvkn31tK
36+
```
37+
38+
Unzip the file to local folders. If deviating from the path noted below, please adjuest the env var values
39+
40+
```
41+
unzip -o "ai_spleen_seg_bundle_data.zip"
42+
rm -rf models && mkdir -p models/model && mv model.ts models/model && ls models/model
43+
```
44+
45+
Set the environment vars so that the model can be found by the Spleen Seg app. Also,
46+
the settings are consolidated in the `env_settings.sh`.
47+
48+
```
49+
export HOLOSCAN_MODEL_PATH=models
50+
```
51+
52+
3. **Run the Web Application**
53+
54+
```bash
55+
python restful_app/app.py
56+
```
57+
58+
The application will start on `http://127.0.0.1:5000`.
59+
60+
## Test API Endpoints
61+
62+
A simplest test client is provided, which makes call to the endpoint, as well as providing
63+
a callback endpoint to receives message content at the specidied port.
64+
65+
Open another console window and change directory to the same as this file.
66+
67+
Set the environment vars so that the test script can get the input DCM and write the callback contents.
68+
Also, once the Restful app completes each processing, the Spleen Seg app's output will also be saved in
69+
the output folder speficied below (the script passes the output folder via the Rest API).
70+
71+
```
72+
export HOLOSCAN_INPUT_PATH=dcm
73+
export HOLOSCAN_OUTPUT_PATH=output
74+
```
75+
76+
Run the test script, and examine its console output.
77+
78+
```
79+
source test_endpoints.sh
80+
```
81+
82+
Once the script completes, examine the `output` folder, which should conatain the following (dcm file
83+
name will be different)
84+
85+
```
86+
output
87+
├── 1.2.826.0.1.3680043.10.511.3.22611096892439837402906545708809852.dcm
88+
└── stl
89+
└── spleen.stl
90+
```
91+
92+
The script can run multiple times, or modified to loop with different output folder setting.
93+
94+
### Check Status
95+
96+
- **URL**: `/status`
97+
- **Method**: `GET`
98+
- **Description**: Checks the current status of the processor.
99+
- **Success Response**:
100+
- **Code**: 200 OK
101+
- **Content**: `{ "status": "IDLE" }` or `{ "status": "BUSY" }`
102+
103+
### Process Data
104+
105+
- **URL**: `/process`
106+
- **Method**: `POST`
107+
- **Description**: Starts a new processing job.
108+
- **Body**:
109+
110+
```json
111+
{
112+
"input_folder": "/path/to/your/input/data",
113+
"output_folder": "/path/to/your/output/folder",
114+
"callback_url": "http://your-service.com/callback"
115+
}
116+
```
117+
118+
- **Success Response**:
119+
- **Code**: 202 ACCEPTED
120+
- **Content**: `{ "message": "Processing started." }`
121+
- **Error Response**:
122+
- **Code**: 409 CONFLICT
123+
- **Content**: `{ "error": "Processor is busy." }`
124+
- **Code**: 400 BAD REQUEST
125+
- **Content**: `{ "error": "Missing required fields." }`
126+
127+
### Callback
128+
129+
When processing is complete, the application will send a `POST` request to the `callback_url` provided in the process request. The body of the callback will be:
130+
131+
```json
132+
{
133+
"run_success": true,
134+
"result": "Processing completed successfully.",
135+
"output_files": ["test.json", "seg.com"],
136+
"error_message": null,
137+
"error_code": null
138+
}
139+
```
140+
141+
Or in case of an error:
142+
143+
```json
144+
{
145+
"run_success": False,
146+
"error_message": "E.g., Model network is not load and model file not found.",
147+
"error_code": 500
148+
}
149+
```
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Copyright 2021-2023 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 os
13+
import sys
14+
15+
_current_dir = os.path.abspath(os.path.dirname(__file__))
16+
if sys.path and os.path.abspath(sys.path[0]) != _current_dir:
17+
sys.path.insert(0, _current_dir)
18+
del _current_dir
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Copyright 2021-2023 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+
14+
from app import AISpleenSegApp
15+
16+
if __name__ == "__main__":
17+
logging.info(f"Begin {__name__}")
18+
AISpleenSegApp().run()
19+
logging.info(f"End {__name__}")
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# Copyright 2021-2023 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+
15+
from dicom_series_to_volume_operator_local import DICOMSeriesToVolumeOperator
16+
17+
# Required for setting SegmentDescription attributes. Direct import as this is not part of App SDK package.
18+
from pydicom.sr.codedict import codes
19+
20+
from monai.deploy.conditions import CountCondition
21+
from monai.deploy.core import AppContext, Application
22+
from monai.deploy.core.domain import Image
23+
from monai.deploy.core.io_type import IOType
24+
from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator
25+
from monai.deploy.operators.dicom_seg_writer_operator import DICOMSegmentationWriterOperator, SegmentDescription
26+
from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator
27+
28+
# Use a local fixed version. from monai.deploy.operators.dicom_series_to_volume_operator import DICOMSeriesToVolumeOperator
29+
from monai.deploy.operators.monai_bundle_inference_operator import (
30+
BundleConfigNames,
31+
IOMapping,
32+
MonaiBundleInferenceOperator,
33+
)
34+
from monai.deploy.operators.stl_conversion_operator import STLConversionOperator
35+
from reporter_operator import ExecutionStatusReporterOperator
36+
37+
38+
# @resource(cpu=1, gpu=1, memory="7Gi")
39+
# pip_packages can be a string that is a path(str) to requirements.txt file or a list of packages.
40+
# The monai pkg is not required by this class, instead by the included operators.
41+
class AISpleenSegApp(Application):
42+
"""Demonstrates inference with built-in MONAI Bundle inference operator with DICOM files as input/output
43+
44+
This application loads a set of DICOM instances, select the appropriate series, converts the series to
45+
3D volume image, performs inference with the built-in MONAI Bundle inference operator, including pre-processing
46+
and post-processing, save the segmentation image in a DICOM Seg OID in an instance file, and optionally the
47+
surface mesh in STL format.
48+
49+
Pertinent MONAI Bundle:
50+
https://github.com/Project-MONAI/model-zoo/tree/dev/models/spleen_ct_segmentation
51+
52+
Execution Time Estimate:
53+
With a Nvidia GV100 32GB GPU, for an input DICOM Series of 515 instances, the execution time is around
54+
25 seconds with saving both DICOM Seg and surface mesh STL file, and 15 seconds with DICOM Seg only.
55+
"""
56+
57+
def __init__(self, *args, status_callback=None, **kwargs):
58+
"""Creates an application instance."""
59+
self._logger = logging.getLogger("{}.{}".format(__name__, type(self).__name__))
60+
self._status_callback = status_callback
61+
super().__init__(*args, **kwargs)
62+
63+
def run(self, *args, **kwargs):
64+
# This method calls the base class to run. Can be omitted if simply calling through.
65+
self._logger.info(f"Begin {self.run.__name__}")
66+
# The try...except block is removed as the reporter operator will handle status reporting.
67+
super().run(*args, **kwargs)
68+
self._logger.info(f"End {self.run.__name__}")
69+
70+
def compose(self):
71+
"""Creates the app specific operators and chain them up in the processing DAG."""
72+
73+
logging.info(f"Begin {self.compose.__name__}")
74+
75+
# Use Commandline options over environment variables to init context.
76+
app_context: AppContext = Application.init_app_context(self.argv)
77+
app_input_path = Path(app_context.input_path)
78+
app_output_path = Path(app_context.output_path)
79+
80+
# Create the custom operator(s) as well as SDK built-in operator(s).
81+
study_loader_op = DICOMDataLoaderOperator(
82+
self, CountCondition(self, 1), input_folder=app_input_path, name="study_loader_op"
83+
)
84+
series_selector_op = DICOMSeriesSelectorOperator(self, rules=Sample_Rules_Text, name="series_selector_op")
85+
series_to_vol_op = DICOMSeriesToVolumeOperator(self, name="series_to_vol_op")
86+
87+
# Create the inference operator that supports MONAI Bundle and automates the inference.
88+
# The IOMapping labels match the input and prediction keys in the pre and post processing.
89+
# The model_name is optional when the app has only one model.
90+
# The bundle_path argument optionally can be set to an accessible bundle file path in the dev
91+
# environment, so when the app is packaged into a MAP, the operator can complete the bundle parsing
92+
# during init.
93+
94+
config_names = BundleConfigNames(config_names=["inference"]) # Same as the default
95+
96+
bundle_spleen_seg_op = MonaiBundleInferenceOperator(
97+
self,
98+
input_mapping=[IOMapping("image", Image, IOType.IN_MEMORY)],
99+
output_mapping=[IOMapping("pred", Image, IOType.IN_MEMORY)],
100+
app_context=app_context,
101+
bundle_config_names=config_names,
102+
name="bundle_spleen_seg_op",
103+
)
104+
105+
# Create DICOM Seg writer providing the required segment description for each segment with
106+
# the actual algorithm and the pertinent organ/tissue. The segment_label, algorithm_name,
107+
# and algorithm_version are of DICOM VR LO type, limited to 64 chars.
108+
# https://dicom.nema.org/medical/dicom/current/output/chtml/part05/sect_6.2.html
109+
segment_descriptions = [
110+
SegmentDescription(
111+
segment_label="Spleen",
112+
segmented_property_category=codes.SCT.Organ,
113+
segmented_property_type=codes.SCT.Spleen,
114+
algorithm_name="volumetric (3D) segmentation of the spleen from CT image",
115+
algorithm_family=codes.DCM.ArtificialIntelligence,
116+
algorithm_version="0.3.2",
117+
)
118+
]
119+
120+
custom_tags = {"SeriesDescription": "AI generated Seg, not for clinical use."}
121+
122+
dicom_seg_writer = DICOMSegmentationWriterOperator(
123+
self,
124+
segment_descriptions=segment_descriptions,
125+
custom_tags=custom_tags,
126+
output_folder=app_output_path,
127+
name="dicom_seg_writer",
128+
)
129+
130+
reporter_op = ExecutionStatusReporterOperator(self, status_callback=self._status_callback)
131+
132+
# Create the processing pipeline, by specifying the source and destination operators, and
133+
# ensuring the output from the former matches the input of the latter, in both name and type.
134+
self.add_flow(study_loader_op, series_selector_op, {("dicom_study_list", "dicom_study_list")})
135+
self.add_flow(
136+
series_selector_op, series_to_vol_op, {("study_selected_series_list", "study_selected_series_list")}
137+
)
138+
self.add_flow(series_to_vol_op, bundle_spleen_seg_op, {("image", "image")})
139+
# Note below the dicom_seg_writer requires two inputs, each coming from a source operator.
140+
self.add_flow(
141+
series_selector_op, dicom_seg_writer, {("study_selected_series_list", "study_selected_series_list")}
142+
)
143+
self.add_flow(bundle_spleen_seg_op, dicom_seg_writer, {("pred", "seg_image")})
144+
# Create the surface mesh STL conversion operator and add it to the app execution flow, if needed, by
145+
# uncommenting the following couple lines.
146+
stl_conversion_op = STLConversionOperator(
147+
self, output_file=app_output_path.joinpath("stl/spleen.stl"), name="stl_conversion_op"
148+
)
149+
self.add_flow(bundle_spleen_seg_op, stl_conversion_op, {("pred", "image")})
150+
151+
# Connect the reporter operator to the end of the pipeline.
152+
# It will be triggered after the DICOM SEG file is written.
153+
self.add_flow(stl_conversion_op, reporter_op, {("stl_bytes", "data")})
154+
155+
logging.info(f"End {self.compose.__name__}")
156+
157+
158+
# This is a sample series selection rule in JSON, simply selecting CT series.
159+
# If the study has more than 1 CT series, then all of them will be selected.
160+
# Please see more detail in DICOMSeriesSelectorOperator.
161+
Sample_Rules_Text = """
162+
{
163+
"selections": [
164+
{
165+
"name": "CT Series",
166+
"conditions": {
167+
"StudyDescription": "(.*?)",
168+
"Modality": "(?i)CT",
169+
"SeriesDescription": "(.*?)"
170+
}
171+
}
172+
]
173+
}
174+
"""
175+
176+
if __name__ == "__main__":
177+
# Creates the app and test it standalone. When running is this mode, please note the following:
178+
# -m <model file>, for model file path
179+
# -i <DICOM folder>, for input DICOM CT series folder
180+
# -o <output folder>, for the output folder, default $PWD/output
181+
# e.g.
182+
# monai-deploy exec app.py -i input -m model/model.ts
183+
#
184+
logging.info(f"Begin {__name__}")
185+
AISpleenSegApp().run()
186+
logging.info(f"End {__name__}")

0 commit comments

Comments
 (0)