Skip to content

Commit f4c1ba4

Browse files
MMelQinCopilot
andauthored
Make affine and space metadata consistent as well as updating support of latest holoscan SDK CUDA 12 version (#565)
* Make affine and space consistent as space is properly parse by MONAI transforms Signed-off-by: M Q <[email protected]> * Typing improvements Signed-off-by: M Q <[email protected]> * Make image metadata Affine and Space consistent, either LPS or RAS Signed-off-by: M Q <[email protected]> * Update monai/deploy/operators/monai_seg_inference_operator.py Co-authored-by: Copilot <[email protected]> Signed-off-by: Ming M Qin <[email protected]> * Correct CoPilot suggested code which failed liniting Signed-off-by: M Q <[email protected]> * Fix complaint from new version of mypy Signed-off-by: M Q <[email protected]> * Support the latest holoscan SDK CUDA 12 version, holoscan-cu12 Signed-off-by: M Q <[email protected]> * Docs gen works with Python 3.10+ Signed-off-by: M Q <[email protected]> * Fix complaints on single quote vs dhouble quote for string Signed-off-by: M Q <[email protected]> * Making docs gen require python >= 3.10 Signed-off-by: M Q <[email protected]> * Fix docs build error on readthedocs, although local builds had no issues Signed-off-by: M Q <[email protected]> --------- Signed-off-by: M Q <[email protected]> Signed-off-by: Ming M Qin <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent 5711a93 commit f4c1ba4

File tree

15 files changed

+1574
-303
lines changed

15 files changed

+1574
-303
lines changed

.readthedocs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ version: 2
88
build:
99
os: ubuntu-22.04
1010
tools:
11-
python: "3.9"
11+
python: "3.10"
1212
# You can also specify other tool versions:
1313
# nodejs: "20"
1414
# rust: "1.70"

CONTRIBUTING.md

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,6 @@ MONAI Deploy App SDK's code coverage report is available at [CodeCov](https://co
137137

138138
#### Building the documentation
139139

140-
:::{note}
141-
Please note that the documentation builds successfully in Python 3.9 environment, but fails with Python 3.10.
142-
:::
143-
144140
MONAI's documentation is located at `docs/`.
145141

146142
```bash

docs/requirements.txt

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
1-
Sphinx==4.1.2
2-
sphinx-autobuild==2021.3.14
3-
myst-nb==0.17.2 # this version is fine in python 3.9 and avoids pulling in multiple nbformat packages
4-
myst-parser==0.18.0
1+
Sphinx>=4.5.0
2+
sphinx-autobuild
3+
myst-nb>=0.17.2
4+
myst-parser>=0.18.0
55
lxml_html_clean # needed by myst-nb
6-
linkify-it-py==1.0.1 # https://myst-parser.readthedocs.io/en/latest/syntax/optional.html?highlight=linkify#linkify
7-
sphinx-togglebutton==0.2.3
8-
sphinx-copybutton==0.4.0
9-
sphinxcontrib-bibtex<2.0.0 # https://github.com/executablebooks/jupyter-book/issues/1137
10-
sphinxcontrib-spelling==7.2.1 # https://sphinxcontrib-spelling.readthedocs.io/en/latest/index.html
11-
sphinx-thebe==0.0.10
12-
sphinx-panels==0.6.0
13-
ablog==0.10.19
14-
docutils==0.16 # 0.17 causes error. https://github.com/executablebooks/MyST-Parser/issues/343
15-
pydata_sphinx_theme==0.6.3
16-
sphinxemoji==0.1.8
6+
linkify-it-py>=1.0.1 # https://myst-parser.readthedocs.io/en/latest/syntax/optional.html?highlight=linkify#linkify
7+
sphinx-togglebutton
8+
sphinx-copybutton
9+
sphinxcontrib-bibtex>=2.4.1 # Version 2.4.1+ supports Python 3.10
10+
sphinxcontrib-spelling # https://sphinxcontrib-spelling.readthedocs.io/en/latest/index.html
11+
sphinx-thebe
12+
sphinx-design
13+
ablog
14+
pydata_sphinx_theme>=0.13.0
15+
sphinxemoji
1716
torch>=1.12.0
18-
sphinx-autodoc-typehints==1.12.0
19-
sphinxcontrib-applehelp==1.0.2
20-
sphinxcontrib-devhelp==1.0.2
21-
sphinxcontrib-htmlhelp==2.0.0
22-
sphinxcontrib-jsmath==1.0.1
23-
sphinxcontrib-qthelp==1.0.3
24-
sphinxcontrib-serializinghtml==1.1.5
25-
sphinxcontrib-mermaid==0.7.1
17+
sphinx-autodoc-typehints
18+
sphinxcontrib-applehelp
19+
sphinxcontrib-devhelp
20+
sphinxcontrib-htmlhelp
21+
sphinxcontrib-jsmath
22+
sphinxcontrib-qthelp
23+
sphinxcontrib-serializinghtml
24+
sphinxcontrib-mermaid

docs/source/conf.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,15 @@
8383
"myst_nb",
8484
"sphinx_copybutton",
8585
"sphinx_togglebutton",
86-
"sphinx_panels", # https://sphinx-panels.readthedocs.io/en/latest/
86+
"sphinx_design", # https://sphinx-design.readthedocs.io/en/latest/
8787
"ablog",
8888
"sphinxemoji.sphinxemoji",
8989
# https://myst-parser.readthedocs.io/en/latest/sphinx/use.html#automatically-create-targets-for-section-headers
9090
# "sphinx.ext.autosectionlabel", <== don't need anymore from v0.13.0
9191
"sphinx_autodoc_typehints",
9292
"sphinxcontrib.mermaid",
9393
]
94+
bibtex_bibfiles = ["refs.bib"]
9495

9596
autoclass_content = "both"
9697
add_module_names = True
@@ -180,10 +181,10 @@
180181
html_css_files = ["custom.css"]
181182
html_title = f"{project} {version} Documentation"
182183

183-
# -- Options for sphinx-panels -------------------------------------------------
184+
# -- Options for sphinx-design -------------------------------------------------
184185
#
185-
# (reference: https://sphinx-panels.readthedocs.io/en/latest/)
186-
panels_add_bootstrap_css = False # pydata-sphinx-theme already loads bootstrap css
186+
# (reference: https://sphinx-design.readthedocs.io/en/latest/)
187+
# No additional configuration needed - sphinx-design works with pydata-sphinx-theme
187188

188189
# -- Options for linkcheck builder -------------------------------------------------
189190
#
@@ -222,16 +223,17 @@
222223
# -- Options for myst-nb -------------------------------------------------
223224
#
224225
# (reference: https://myst-nb.readthedocs.io/en/latest/)
225-
# Prevent the following error
226-
# MyST NB Configuration Error:
227-
# `nb_render_priority` not set for builder: doctest
228-
nb_render_priority = {"doctest": ()}
229226
# Prevent creating jupyter_execute folder in dist
230227
# https://myst-nb.readthedocs.io/en/latest/use/execute.html#executing-in-temporary-folders # noqa
231228
execution_in_temp = True
232229
jupyter_execute_notebooks = "off"
233230

234231

232+
# -- Options for sphinxcontrib.bibtex -------------------------------------------------
233+
#
234+
# (reference: https://sphinxcontrib-bibtex.readthedocs.io/)
235+
bibtex_bibfiles = [] # Add bibliography files here if needed
236+
235237
# -- Options for sphinxcontrib.spelling -------------------------------------------------
236238
#
237239
# (reference: https://sphinxcontrib-spelling.readthedocs.io/en/latest/customize.html)

examples/apps/ai_remote_infer_app/spleen_seg_operator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def pre_process(self, img_reader, out_dir: str = "./input_images") -> Compose:
127127
resample=False,
128128
output_ext=".nii",
129129
),
130-
Orientationd(keys=my_key, axcodes="LPS"),
130+
Orientationd(keys=my_key, axcodes="RAS"),
131131
Spacingd(keys=my_key, pixdim=[1.5, 1.5, 2.9], mode=["bilinear"]),
132132
ScaleIntensityRanged(keys=my_key, a_min=-57, a_max=164, b_min=0.0, b_max=1.0, clip=True),
133133
EnsureTyped(keys=my_key),

examples/apps/ai_unetr_seg_app/unetr_seg_operator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ def pre_process(self, img_reader, out_dir: str = "./input_images") -> Compose:
143143
output_ext=".nii",
144144
),
145145
Spacingd(keys=my_key, pixdim=(1.5, 1.5, 2.0), mode=("bilinear")),
146-
Orientationd(keys=my_key, axcodes="LPS"),
146+
Orientationd(keys=my_key, axcodes="RAS"),
147147
ScaleIntensityRanged(my_key, a_min=-175, a_max=250, b_min=0.0, b_max=1.0, clip=True),
148148
CropForegroundd(my_key, source_key=my_key),
149149
]

examples/apps/cchmc_ped_abd_ct_seg_app/abdomen_seg_operator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ def pre_process(self, img_reader) -> Compose:
226226
# img_reader: specialized InMemImageReader, derived from MONAI ImageReader
227227
LoadImaged(keys=my_key, reader=img_reader),
228228
EnsureChannelFirstd(keys=my_key),
229-
Orientationd(keys=my_key, axcodes="LPS"),
229+
Orientationd(keys=my_key, axcodes="RAS"),
230230
Spacingd(keys=my_key, pixdim=[1.5, 1.5, 3.0], mode=["bilinear"]),
231231
ScaleIntensityRanged(keys=my_key, a_min=-250, a_max=400, b_min=0.0, b_max=1.0, clip=True),
232232
CropForegroundd(keys=my_key, source_key=my_key, mode="minimum"),

monai/deploy/operators/dicom_seg_writer_operator.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
ImplicitVRLittleEndian, _ = optional_import("pydicom.uid", name="ImplicitVRLittleEndian")
2828
Dataset, _ = optional_import("pydicom.dataset", name="Dataset")
2929
FileDataset, _ = optional_import("pydicom.dataset", name="FileDataset")
30+
DA, _ = optional_import("pydicom.valuerep", name="DA")
31+
TM, _ = optional_import("pydicom.valuerep", name="TM")
3032
PyDicomSequence, _ = optional_import("pydicom.sequence", name="Sequence")
3133
sitk, _ = optional_import("SimpleITK")
3234
codes, _ = optional_import("pydicom.sr.codedict", name="codes")
@@ -341,8 +343,8 @@ def create_dicom_seg(self, image: np.ndarray, dicom_series: DICOMSeries, output_
341343
# Adding a few tags that are not in the Dataset
342344
# Also try to set the custom tags that are of string type
343345
dt_now = datetime.datetime.now()
344-
seg.SeriesDate = dt_now.strftime("%Y%m%d") # type: ignore[assignment]
345-
seg.SeriesTime = dt_now.strftime("%H%M%S") # type: ignore[assignment]
346+
seg.SeriesDate = DA(dt_now.strftime("%Y%m%d"))
347+
seg.SeriesTime = TM(dt_now.strftime("%H%M%S"))
346348
seg.TimezoneOffsetFromUTC = (
347349
dt_now.astimezone().isoformat()[-6:].replace(":", "")
348350
) # '2022-09-27T22:36:20.143857-07:00'

monai/deploy/operators/dicom_series_to_volume_operator.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,26 @@ class DICOMSeriesToVolumeOperator(Operator):
4040
"""
4141

4242
# Use constants instead of enums in monai to avoid dependency at this level.
43+
MONAI_UTIL_ENUMS_SPACEKEYS_RAS = "RAS"
4344
MONAI_UTIL_ENUMS_SPACEKEYS_LPS = "LPS"
4445
MONAI_TRANSFORMS_SPATIAL_METADATA_NAME = "space"
46+
METADATA_SPACE_RAS = {MONAI_TRANSFORMS_SPATIAL_METADATA_NAME: MONAI_UTIL_ENUMS_SPACEKEYS_RAS}
4547
METADATA_SPACE_LPS = {MONAI_TRANSFORMS_SPATIAL_METADATA_NAME: MONAI_UTIL_ENUMS_SPACEKEYS_LPS}
48+
ATTRIBUTE_NIFTI_AFFINE = "nifti_affine_transform"
49+
ATTRIBUTE_DICOM_AFFINE = "dicom_affine_transform"
4650

47-
def __init__(self, fragment: Fragment, *args, **kwargs):
51+
def __init__(self, fragment: Fragment, *args, affine_lps_to_ras: bool = True, **kwargs):
4852
"""Create an instance for a containing application object.
4953
5054
Args:
5155
fragment (Fragment): An instance of the Application class which is derived from Fragment.
56+
affine_lps_to_ras (bool): If true, the affine transform in the image metadata is RAS oriented,
57+
otherwise it is LPS oriented. Default is True.
5258
"""
5359

5460
self.input_name_series = "study_selected_series_list"
5561
self.output_name_image = "image"
62+
self.affine_lps_to_ras = affine_lps_to_ras
5663
# Need to call the base class constructor last
5764
super().__init__(fragment, *args, **kwargs)
5865

@@ -89,18 +96,16 @@ def convert_to_image(self, study_selected_series_list: List[StudySelectedSeries]
8996
metadata.update(self._get_instance_properties(study_selected_series.study))
9097
selection_metadata = {"selection_name": selection_name}
9198
metadata.update(selection_metadata)
92-
# Add the metadata to specify LPS.
93-
# Previously, this was set in ImageReader class, but moving it here allows other loaders
94-
# to determine this value on its own, e.g. NIfTI loader but it does not set this
95-
# resulting in the MONAI Orientation transform to default the labels to RAS.
96-
# It is assumed that the ImageOrientationPatient will be set accordingly if the
97-
# PatientPosition is other than HFS.
98-
# NOTE: This value is properly parsed by MONAI Orientation transform from v1.5.1 onwards.
99-
# Some early MONAI model inference configs incorrectly specify orientation to RAS
100-
# due part to previous MONAI versions did not correctly parse this metadata from
101-
# the input MetaTensor and defaulting to RAS. Now with LPS properly set, the inference
102-
# configs then need to be updated to specify LPS, to achieve the same result.
103-
metadata.update(self.METADATA_SPACE_LPS)
99+
# The affine transform and the coordinate space are set based on the flag affine_lps_to_ras.
100+
# If the flag is true, the NIFTI affine (RAS) is used, otherwise the DICOM affine (LPS) is used.
101+
if self.affine_lps_to_ras:
102+
if hasattr(dicom_series, self.ATTRIBUTE_NIFTI_AFFINE):
103+
metadata["affine"] = getattr(dicom_series, self.ATTRIBUTE_NIFTI_AFFINE)
104+
metadata.update(self.METADATA_SPACE_RAS)
105+
else:
106+
if hasattr(dicom_series, self.ATTRIBUTE_DICOM_AFFINE):
107+
metadata["affine"] = getattr(dicom_series, self.ATTRIBUTE_DICOM_AFFINE)
108+
metadata.update(self.METADATA_SPACE_LPS)
104109

105110
voxel_data = self.generate_voxel_data(dicom_series)
106111
image = self.create_volumetric_image(voxel_data, metadata)
@@ -366,7 +371,7 @@ def compute_affine_transform(self, s_1, s_n, n, series):
366371
zn = 0.0
367372

368373
ip1 = None
369-
ip2 = None
374+
ipn = None
370375
try:
371376
ip1_de = s_1[0x0020, 0x0032]
372377
ipn_de = s_n[0x0020, 0x0032]
@@ -404,7 +409,7 @@ def compute_affine_transform(self, s_1, s_n, n, series):
404409
m1[3, 2] = 0
405410
m1[3, 3] = 1
406411

407-
series.dicom_affine_transform = m1
412+
setattr(series, self.ATTRIBUTE_DICOM_AFFINE, m1)
408413

409414
m2[0, 0] = -rx * vr
410415
m2[0, 1] = -cx * vc
@@ -426,7 +431,7 @@ def compute_affine_transform(self, s_1, s_n, n, series):
426431
m2[3, 2] = 0
427432
m2[3, 3] = 1
428433

429-
series.nifti_affine_transform = m2
434+
setattr(series, self.ATTRIBUTE_NIFTI_AFFINE, m2)
430435

431436
def create_metadata(self, series) -> Dict:
432437
"""Collects all relevant metadata from the DICOM Series and creates a dictionary.

monai/deploy/operators/monai_seg_inference_operator.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,16 +31,20 @@
3131
ImageReader: Any = ImageReader_
3232
if not image_reader_ok_:
3333
ImageReader = object # for 'class InMemImageReader(ImageReader):' to work
34+
is_no_channel, _ = optional_import("monai.data.utils", name="is_no_channel")
3435
decollate_batch, _ = optional_import("monai.data", name="decollate_batch")
3536
sliding_window_inference, _ = optional_import("monai.inferers", name="sliding_window_inference")
3637
simple_inference, _ = optional_import("monai.inferers", name="SimpleInferer")
3738
ensure_tuple, _ = optional_import(MONAI_UTILS, name="ensure_tuple")
3839
MetaKeys, _ = optional_import(MONAI_UTILS, name="MetaKeys")
3940
SpaceKeys, _ = optional_import(MONAI_UTILS, name="SpaceKeys")
41+
TraceKeys, _ = optional_import(MONAI_UTILS, name="TraceKeys")
4042
Compose_, _ = optional_import("monai.transforms", name="Compose")
4143
# Dynamic class is not handled so make it Any for now: https://github.com/python/mypy/issues/2477
4244
Compose: Any = Compose_
4345

46+
cp, has_cp = optional_import("cupy")
47+
4448
from monai.deploy.core import AppContext, Condition, ConditionType, Fragment, Image, OperatorSpec, Resource
4549

4650
from .inference_operator import InferenceOperator
@@ -362,6 +366,7 @@ def compute(self, op_input, op_output, context):
362366
self._executing = True
363367
try:
364368
input_image = op_input.receive(self._input_name_image)
369+
365370
if not input_image:
366371
raise ValueError("Input is None.")
367372
op_output.emit(self.compute_impl(input_image, context), self._output_name_seg)
@@ -592,7 +597,7 @@ def _get_meta_dict(self, img: Image) -> Dict:
592597
return meta_dict
593598

594599

595-
# Reuse MONAI code for the derived ImageReader
600+
# Reuse MONAI code for the derived ImageReader as it is not exposed
596601
def _copy_compatible_dict(from_dict: Dict, to_dict: Dict):
597602
if not isinstance(to_dict, dict):
598603
raise ValueError(f"to_dict must be a Dict, got {type(to_dict)}.")
@@ -601,7 +606,9 @@ def _copy_compatible_dict(from_dict: Dict, to_dict: Dict):
601606
datum = from_dict[key]
602607
if isinstance(datum, np.ndarray) and np_str_obj_array_pattern.search(datum.dtype.str) is not None:
603608
continue
604-
to_dict[key] = datum
609+
to_dict[key] = (
610+
str(TraceKeys.NONE) if datum is None else datum
611+
) # PyTorch's default_collate cannot handle None values directly
605612
else:
606613
affine_key, shape_key = MetaKeys.AFFINE, MetaKeys.SPATIAL_SHAPE
607614
if affine_key in from_dict and not np.allclose(from_dict[affine_key], to_dict[affine_key]):
@@ -616,12 +623,16 @@ def _copy_compatible_dict(from_dict: Dict, to_dict: Dict):
616623
)
617624

618625

619-
def _stack_images(image_list: List, meta_dict: Dict):
626+
def _stack_images(image_list: list, meta_dict: Dict, to_cupy: bool = False):
620627
if len(image_list) <= 1:
621628
return image_list[0]
622-
if meta_dict.get(MetaKeys.ORIGINAL_CHANNEL_DIM, None) not in ("no_channel", None):
629+
if not is_no_channel(meta_dict.get(MetaKeys.ORIGINAL_CHANNEL_DIM, None)):
623630
channel_dim = int(meta_dict[MetaKeys.ORIGINAL_CHANNEL_DIM])
631+
if to_cupy and has_cp:
632+
return cp.concatenate(image_list, axis=channel_dim)
624633
return np.concatenate(image_list, axis=channel_dim)
625634
# stack at a new first dim as the channel dim, if `'original_channel_dim'` is unspecified
626635
meta_dict[MetaKeys.ORIGINAL_CHANNEL_DIM] = 0
636+
if to_cupy and has_cp:
637+
return cp.stack(image_list, axis=0)
627638
return np.stack(image_list, axis=0)

0 commit comments

Comments
 (0)