Skip to content

Commit 8c37598

Browse files
authored
Improve STL operator so the enclosing app obj can run repetitively (#337)
* Improve STL operator so the enclosing app obj can run repetitively with the new sample code Signed-off-by: M Q <[email protected]> * Fixing Flake8 complaints Signed-off-by: M Q <[email protected]> * Ignore Flake8 B024: abstract base class, but it has no abstract methods Signed-off-by: M Q <[email protected]> * Silence mypy complaint on types Signed-off-by: M Q <[email protected]> * Quiet another mypy error. Signed-off-by: M Q <[email protected]> * MyPy complaint fix Signed-off-by: M Q <[email protected]> Signed-off-by: M Q <[email protected]>
1 parent c1b9aa3 commit 8c37598

File tree

4 files changed

+131
-28
lines changed

4 files changed

+131
-28
lines changed
Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,55 @@
1+
import logging
2+
import shutil
3+
import traceback
4+
from pathlib import Path
5+
from typing import List
6+
17
from app import AIUnetrSegApp
28

39
if __name__ == "__main__":
4-
AIUnetrSegApp(do_run=True)
10+
logging.basicConfig(level=logging.DEBUG)
11+
# This main function is an example to show how a batch of input can be processed.
12+
# It assumes that in the app input folder there are a number of subfolders, each
13+
# containing a discrete input to be processed. Each discrete payload can have
14+
# multiple DICOM instances file, optionally organized in its own folder structure.
15+
# The application object is first created, and on its init the model network is
16+
# loaded as well as pre and post processing transforms. This app object is then
17+
# run multiple times, each time with a single discrete payload.
18+
19+
app = AIUnetrSegApp(do_run=False)
20+
21+
# Preserve the application top level input and output folder path, as the path
22+
# in the context may change on each run if the I/O arguments are passed in.
23+
app_input_path = Path(app.context.input_path)
24+
app_output_path = Path(app.context.output_path)
25+
26+
# Get subfolders in the input path, assume each one contains a discrete payload
27+
input_dirs = [path for path in app_input_path.iterdir() if path.is_dir()]
28+
29+
# Set the output path for each run under the app's output path, and do run
30+
work_dirs: List[str] = [] # strings resprenting folder path
31+
for idx, dir in enumerate(input_dirs):
32+
try:
33+
output_path = app_output_path / f"{dir.name}_output"
34+
# Note: the work_dir should be mapped to the host drive when used in
35+
# a container for better performance.
36+
work_dir = f".unetr_app_workdir{idx}"
37+
work_dirs.extend(work_dir)
38+
39+
logging.info(f"Start processing input in: {dir} with results in: {output_path}")
40+
41+
# Run app with specific input and output path.
42+
# Passing in the input and output do have the side effect of changing
43+
# app context. This side effect will likely be eliminated in later releases.
44+
app.run(input=dir, output=output_path, workdir=work_dir)
45+
46+
logging.info(f"Completed processing input in: {dir} with results in: {output_path}")
47+
except Exception as ex:
48+
logging.error(f"Failed processing input in {dir}, due to: {ex}\n")
49+
traceback.print_exc()
50+
finally:
51+
# Remove the workdir; alternatively do this later, if storage space is not a concern.
52+
shutil.rmtree(work_dir, ignore_errors=True)
53+
54+
# Alternative. Explicitly remove the working dirs at the end of main.
55+
# [shutil.rmtree(work_dir, ignore_errors=True) for work_dir in work_dirs]

examples/apps/ai_unetr_seg_app/app.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# limitations under the License.
1111

1212
import logging
13+
from typing import List
1314

1415
# Required for setting SegmentDescription attributes. Direct import as this is not part of App SDK package.
1516
from pydicom.sr.codedict import codes
@@ -131,6 +132,53 @@ def compose(self):
131132
# e.g.
132133
# python3 app.py -i input -m model/model.ts
133134
#
135+
import shutil
136+
import traceback
137+
from pathlib import Path
138+
134139
logging.basicConfig(level=logging.DEBUG)
135-
app_instance = AIUnetrSegApp() # Optional params' defaults are fine.
136-
app_instance.run()
140+
# This main function is an example to show how a batch of input can be processed.
141+
# It assumes that in the app input folder there are a number of subfolders, each
142+
# containing a discrete input to be processed. Each discrete payload can have
143+
# multiple DICOM instances file, optionally organized in its own folder structure.
144+
# The application object is first created, and on its init the model network is
145+
# loaded as well as pre and post processing transforms. This app object is then
146+
# run multiple times, each time with a single discrete payload.
147+
148+
app = AIUnetrSegApp(do_run=False)
149+
150+
# Preserve the application top level input and output folder path, as the path
151+
# in the context may change on each run if the I/O arguments are passed in.
152+
app_input_path = Path(app.context.input_path)
153+
app_output_path = Path(app.context.output_path)
154+
155+
# Get subfolders in the input path, assume each one contains a discrete payload
156+
input_dirs = [path for path in app_input_path.iterdir() if path.is_dir()]
157+
158+
# Set the output path for each run under the app's output path, and do run
159+
work_dirs: List[str] = [] # strings resprenting folder path
160+
for idx, dir in enumerate(input_dirs):
161+
try:
162+
output_path = app_output_path / f"{dir.name}_output"
163+
# Note: the work_dir should be mapped to the host drive when used in
164+
# a container for better performance.
165+
work_dir = f".unetr_app_workdir{idx}"
166+
work_dirs.extend(work_dir)
167+
168+
logging.info(f"Start processing input in: {dir} with results in: {output_path}")
169+
170+
# Run app with specific input and output path.
171+
# Passing in the input and output do have the side effect of changing
172+
# app context. This side effect will likely be eliminated in later releases.
173+
app.run(input=dir, output=output_path, workdir=work_dir)
174+
175+
logging.info(f"Completed processing input in: {dir} with results in: {output_path}")
176+
except Exception as ex:
177+
logging.error(f"Failed processing input in {dir}, due to: {ex}\n")
178+
traceback.print_exc()
179+
finally:
180+
# Remove the workdir; alternatively do this later, if storage space is not a concern.
181+
shutil.rmtree(work_dir, ignore_errors=True)
182+
183+
# Alternative. Explicitly remove the working dirs at the end of main.
184+
# [shutil.rmtree(work_dir, ignore_errors=True) for work_dir in work_dirs]

monai/deploy/operators/stl_conversion_operator.py

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import os
1414
import shutil
1515
import tempfile
16+
from ast import Bytes
1617
from pathlib import Path
1718
from typing import Dict, Optional
1819

@@ -29,19 +30,23 @@
2930
trimesh, _ = optional_import("trimesh")
3031

3132
import monai.deploy.core as md
32-
from monai.deploy.core import DataPath, ExecutionContext, Image, InputContext, IOType, Operator, OutputContext
33+
from monai.deploy.core import ExecutionContext, Image, InputContext, IOType, Operator, OutputContext
3334

3435
__all__ = ["STLConversionOperator", "STLConverter"]
3536

3637

3738
@md.input("image", Image, IOType.IN_MEMORY)
38-
@md.output("stl_output", DataPath, IOType.DISK)
39+
@md.output("stl_output", Bytes, IOType.IN_MEMORY) # Only available when run as non-leaf operator
3940
# nibabel is required by the dependent class STLConverter.
4041
@md.env(
4142
pip_packages=["numpy>=1.21", "nibabel >= 3.2.1", "numpy-stl>=2.12.0", "scikit-image>=0.17.2", "trimesh>=3.8.11"]
4243
)
4344
class STLConversionOperator(Operator):
44-
"""Converts volumetric image to surface mesh in STL format, file output only."""
45+
"""Converts volumetric image to surface mesh in STL format, file output only.
46+
47+
Only when used as a non-leaf operator is the output of STL binary stored in memory idenfied by the output label.
48+
If a file path is provided, the STL binary will be saved in the the application's output folder of the current run.
49+
"""
4550

4651
def __init__(
4752
self, output_file=None, class_id=None, is_smooth=True, keep_largest_connected_component=True, *args, **kwargs
@@ -59,16 +64,17 @@ def __init__(
5964
self._class_id = class_id
6065
self._is_smooth = is_smooth
6166
self._keep_largest_connected_component = keep_largest_connected_component
62-
self._output_file = output_file if output_file and len(output_file) > 0 else None
67+
self._output_file = output_file if output_file and len(str(output_file)) > 0 else None
6368

6469
self._converter = STLConverter(*args, **kwargs)
6570

6671
def compute(self, op_input: InputContext, op_output: OutputContext, context: ExecutionContext):
6772
"""Gets the input (image), processes it and sets results in the output.
6873
6974
When used in a leaf operator, this function cannot set its output as in-memory object due to
70-
current limitation, and only file output, for DataPath IOType_DISK, will be saved in the
71-
op_output path, which is mapped to the application's output path by the execution engine.
75+
current limitation.
76+
If a file path is provided, the STL binary will be saved in the the application's output
77+
folder of the current run.
7278
7379
Args:
7480
op_input (InputContext): An input context for the operator.
@@ -80,20 +86,21 @@ def compute(self, op_input: InputContext, op_output: OutputContext, context: Exe
8086
if not input_image:
8187
raise ValueError("Input is None.")
8288

83-
op_output_config = op_output.get()
84-
if self._output_file and len(self._output_file) > 0:
85-
# The file output folder is either the op_output or app's output depending on output types.
86-
output_folder = (
87-
op_output_config.path if isinstance(op_output_config, DataPath) else context.output.get().path
88-
)
89-
self._output_file = output_folder / self._output_file
90-
self._output_file.parent.mkdir(exist_ok=True)
91-
self._logger.info(f"Output will be saved in file {self._output_file}.")
89+
# Use the app's current run output folder as parent to the STL output path.
90+
if self._output_file and len(str(self._output_file)) > 0:
91+
_output_file = context.output.get().path / self._output_file
92+
_output_file.parent.mkdir(parents=True, exist_ok=True)
93+
self._logger.info(f"Output will be saved in file {_output_file}.")
9294

93-
stl_bytes = self._convert(input_image, self._output_file)
95+
stl_bytes = self._convert(input_image, _output_file)
9496

95-
if not isinstance(op_output_config, DataPath):
96-
op_output.set(stl_bytes)
97+
try:
98+
# TODO: Need a way to find if the operator is run as leaf node in order to
99+
# avoid setting in_memory object.
100+
if self.op_info.get_storage_type("output", "stl_output") == IOType.IN_MEMORY:
101+
op_output.set(stl_bytes)
102+
except Exception as ex:
103+
self._logger.warn(f"In_memory output cannot be used when run as non-leaf operator. {ex}")
97104

98105
def _convert(self, image: Image, output_file: Optional[Path] = None):
99106
"""
@@ -152,12 +159,8 @@ def convert(
152159
if not image or not isinstance(image, Image):
153160
raise ValueError("image is not a Image object.")
154161

155-
if not isinstance(output_file, Path):
156-
raise ValueError("output_file is not a Path")
157-
158-
# Ensure output file's folder exists
159-
if output_file.parent:
160-
output_file.parent.mkdir(exist_ok=True)
162+
if isinstance(output_file, Path):
163+
output_file.parent.mkdir(parents=True, exist_ok=True)
161164

162165
s_image = self.SpatialImage(image)
163166
nda = s_image.image_array

setup.cfg

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ max_line_length = 120
4242
ignore =
4343
E203,E305,E402,E501,E721,E741,F821,F841,F999,W503,W504,C408,E302,W291,E303,
4444
# N812 lowercase 'torch.nn.functional' imported as non lowercase 'F'
45-
N812
45+
N812,
46+
B024 #abstract base class, but it has no abstract methods
4647
per_file_ignores =
4748
__init__.py: F401
4849
# Allow using camel case for variable/argument names for the sake of readability.

0 commit comments

Comments
 (0)