Skip to content

Commit 6540265

Browse files
committed
Add custom pydicom decoder using nvimgcodec
Signed-off-by: M Q <mingmelvinq@nvidia.com>
1 parent 0e36819 commit 6540265

File tree

3 files changed

+361
-0
lines changed

3 files changed

+361
-0
lines changed

monai/deploy/operators/__init__.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@
3737
# from holoscan.operators import *
3838
from holoscan.operators import PingRxOp, PingTxOp, VideoStreamRecorderOp, VideoStreamReplayerOp
3939

40+
from monai.deploy.operators import nvimgcodec_handler
41+
4042
from .clara_viz_operator import ClaraVizOperator
4143
from .dicom_data_loader_operator import DICOMDataLoaderOperator
4244
from .dicom_encapsulated_pdf_writer_operator import DICOMEncapsulatedPDFWriterOperator
@@ -52,3 +54,24 @@
5254
from .png_converter_operator import PNGConverterOperator
5355
from .publisher_operator import PublisherOperator
5456
from .stl_conversion_operator import STLConversionOperator, STLConverter
57+
58+
__all__ = [
59+
"nvimgcodec_handler",
60+
"ClaraVizOperator",
61+
"DICOMDataLoaderOperator",
62+
"DICOMEncapsulatedPDFWriterOperator",
63+
"DICOMSegmentationWriterOperator",
64+
"DICOMSeriesSelectorOperator",
65+
"DICOMSeriesToVolumeOperator",
66+
"DICOMTextSRWriterOperator",
67+
"EquipmentInfo",
68+
"InferenceOperator",
69+
"IOMapping",
70+
"MonaiBundleInferenceOperator",
71+
"MonaiSegInferenceOperator",
72+
"NiftiDataLoader",
73+
"PNGConverterOperator",
74+
"PublisherOperator",
75+
"STLConversionOperator",
76+
"STLConverter",
77+
]
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
# Copyright 2025 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+
# This decoder plugin for nvimgcodec <https://github.com/NVIDIA/nvImageCodec> decompresses
13+
# encoded Pixel Data for the following transfer syntaxes:
14+
# JPEGBaseline8Bit, # 1.2.840.10008.1.2.4.50, JPEG Baseline (Process 1)
15+
# JPEGExtended12Bit, # 1.2.840.10008.1.2.4.51, JPEG Extended (Process 2 & 4)
16+
# JPEGLossless, # 1.2.840.10008.1.2.4.57, JPEG Lossless, Non-Hierarchical (Process 14)
17+
# JPEGLosslessSV1, # 1.2.840.10008.1.2.4.70, JPEG Lossless, Non-Hierarchical, First-Order Prediction
18+
# JPEG2000Lossless, # 1.2.840.10008.1.2.4.90, JPEG 2000 Image Compression (Lossless Only)
19+
# JPEG2000, # 1.2.840.10008.1.2.4.91, JPEG 2000 Image Compression
20+
# HTJ2KLossless, # 1.2.840.10008.1.2.4.201, HTJ2K Image Compression (Lossless Only)
21+
# HTJ2KLosslessRPCL, # 1.2.840.10008.1.2.4.202, HTJ2K with RPCL Options Image Compression (Lossless Only)
22+
# HTJ2K, # 1.2.840.10008.1.2.4.203, HTJ2K Image Compression
23+
#
24+
# There are two ways to add a custom decoding plugin to pydicom:
25+
# 1. Using the pixel_data_handlers backend, though pydicom.pixel_data_handlers module is deprecated
26+
# and will be removed in v4.0.
27+
# 2. Using the pixels backend by adding a decoder plugin to existing decoders with the add_plugin method,
28+
# see https://pydicom.github.io/pydicom/stable/guides/decoding/decoder_plugins.html
29+
#
30+
# It is noted that pydicom.dataset.Dataset.pixel_array changed in version 3.0 where the backend used for
31+
# pixel data decoding changed from the pixel_data_handlers module to the pixels module.
32+
#
33+
# So, this implementation uses the pixels backend.
34+
#
35+
# Plugin Requirements:
36+
# A custom decoding plugin must implement three objects within the same module:
37+
# - A function named is_available with the following signature:
38+
# def is_available(uid: pydicom.uid.UID) -> bool:
39+
# Where uid is the Transfer Syntax UID for the corresponding decoder as a UID
40+
# - A dict named DECODER_DEPENDENCIES with the type dict[pydicom.uid.UID, tuple[str, ...], such as:
41+
# DECODER_DEPENDENCIES = {JPEG2000Lossless: ('numpy', 'pillow', 'imagecodecs'),}
42+
# This will be used to provide the user with a list of dependencies required by the plugin.
43+
# - A function that performs the decoding with the following function signature as in Github repo:
44+
# def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes
45+
# src is a single frame’s worth of raw compressed data to be decoded, and
46+
# runner is a DecodeRunner instance that manages the decoding process.
47+
#
48+
# Adding plugins to a Decoder:
49+
# Additional plugins can be added to an existing decoder with the add_plugin() method
50+
# ```python
51+
# from pydicom.pixels.decoders import RLELosslessDecoder
52+
# RLELosslessDecoder.add_plugin(
53+
# 'my_decoder', # the plugin's label
54+
# ('my_package.decoders', 'my_decoder_func') # the import paths
55+
# )
56+
# ```
57+
58+
59+
import inspect
60+
import logging
61+
import sys
62+
from pathlib import Path
63+
from typing import Any, Callable, Iterable
64+
65+
from pydicom.pixels.decoders import (
66+
HTJ2KDecoder,
67+
HTJ2KLosslessDecoder,
68+
HTJ2KLosslessRPCLDecoder,
69+
JPEG2000Decoder,
70+
JPEG2000LosslessDecoder,
71+
JPEGBaseline8BitDecoder,
72+
JPEGExtended12BitDecoder,
73+
JPEGLosslessDecoder,
74+
JPEGLosslessSV1Decoder,
75+
)
76+
from pydicom.pixels.decoders.base import DecodeRunner
77+
from pydicom.pixels.utils import _passes_version_check
78+
from pydicom.uid import UID
79+
80+
try:
81+
import cupy as cp
82+
except ImportError:
83+
cp = None
84+
85+
try:
86+
from nvidia import nvimgcodec as nvimgcodec
87+
88+
nvimgcodec_version = tuple(int(x) for x in nvimgcodec.__version__.split("."))
89+
except ImportError:
90+
nvimgcodec = None
91+
92+
# nvimgcodec pypi package name, minimum version required and the label for this decoder plugin.
93+
NVIMGCODEC_MODULE_NAME = "nvidia.nvimgcodec" # from nvidia-nvimgcodec-cu12 or or other variant
94+
NVIMGCODEC_MIN_VERSION = "0.6"
95+
NVIMGCODEC_MIN_VERSION_TUPLE = tuple(int(x) for x in NVIMGCODEC_MIN_VERSION.split("."))
96+
NVIMGCODEC_PLUGIN_LABEL = "0.6+nvimgcodec" # helps sorting to be the first in the plugin list
97+
NVIMGCODEC_PLUGIN_FUNC_NAME = "_decode_frame"
98+
99+
# Supported decoder classes of the corresponding transfer syntaxes by this decoder plugin.
100+
SUPPORTED_DECODER_CLASSES = [
101+
JPEGBaseline8BitDecoder, # 1.2.840.10008.1.2.4.50, JPEG Baseline (Process 1)
102+
JPEGExtended12BitDecoder, # 1.2.840.10008.1.2.4.51, JPEG Extended (Process 2 & 4)
103+
JPEGLosslessDecoder, # 1.2.840.10008.1.2.4.57, JPEG Lossless, Non-Hierarchical (Process 14)
104+
JPEGLosslessSV1Decoder, # 1.2.840.10008.1.2.4.70, JPEG Lossless, Non-Hierarchical, First-Order Prediction
105+
JPEG2000LosslessDecoder, # 1.2.840.10008.1.2.4.90, JPEG 2000 Image Compression (Lossless Only)
106+
JPEG2000Decoder, # 1.2.840.10008.1.2.4.91, JPEG 2000 Image Compression
107+
HTJ2KLosslessDecoder, # 1.2.840.10008.1.2.4.201, HTJ2K Image Compression (Lossless Only)
108+
HTJ2KLosslessRPCLDecoder, # 1.2.840.10008.1.2.4.202, HTJ2K with RPCL Options Image Compression (Lossless Only)
109+
HTJ2KDecoder, # 1.2.840.10008.1.2.4.203, HTJ2K Image Compression
110+
]
111+
112+
SUPPORTED_TRANSFER_SYNTAXES: Iterable[UID] = [x.UID for x in SUPPORTED_DECODER_CLASSES]
113+
114+
_logger = logging.getLogger(__name__)
115+
116+
# Required for decoder plugin
117+
DECODER_DEPENDENCIES = {
118+
x: ("numpy", "cupy", f"{NVIMGCODEC_MODULE_NAME}>={NVIMGCODEC_MIN_VERSION}") for x in SUPPORTED_TRANSFER_SYNTAXES
119+
}
120+
121+
122+
# Required for decoder plugin
123+
def is_available(uid: UID) -> bool:
124+
"""Return ``True`` if a pixel data decoder for ``uid`` is available.
125+
126+
Args:
127+
uid (UID): The transfer syntax UID to check.
128+
129+
Returns:
130+
bool: ``True`` if a pixel data decoder for ``uid`` is available,
131+
``False`` otherwise.
132+
"""
133+
134+
_logger.debug(f"Checking if CUDA and nvimgcodec available for transfer syntax: {uid}")
135+
136+
if uid not in SUPPORTED_TRANSFER_SYNTAXES:
137+
_logger.debug(f"Transfer syntax {uid} not supported by nvimgcodec.")
138+
return False
139+
if not _is_nvimgcodec_available():
140+
_logger.debug(f"Module {NVIMGCODEC_MODULE_NAME} is not available.")
141+
return False
142+
143+
return True
144+
145+
146+
# Required function for decoder plugin (specific signature but flexible name to be registered to a decoder)
147+
# see also https://github.com/pydicom/pydicom/blob/v3.0.1/src/pydicom/pixels/decoders/base.py#L334
148+
def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes:
149+
"""Return the decoded image data in `src` as a :class:`bytearray` or :class:`bytes`.
150+
151+
This function is called by the pydicom.pixels.decoders.base.DecodeRunner.decode method.
152+
153+
Args:
154+
src (bytes): An encoded frame of pixel data to be passed to the decoding plugins.
155+
runner (DecodeRunner): The runner instance that manages the decoding process.
156+
157+
Returns:
158+
bytearray | bytes: The decoded frame as a :class:`bytearray` or :class:`bytes`.
159+
"""
160+
161+
# The frame data bytes object is passed in by the runner, which it gets via pydicom.encaps.get_frame
162+
# and other pydicom.encaps functions, e.g. pydicom.encaps.generate_frames, generate_fragmented_frames, etc.
163+
# So we can directly decode the frame using nvimgcodec.
164+
165+
# Though a fragment may not contain encoded data from more than one frame, the encoded data from one frame
166+
# may span multiple fragments to support buffering during compression or to avoid exceeding the maximum size
167+
# of a fixed length fragment, see https://dicom.nema.org/dicom/2013/output/chtml/part05/sect_8.2.html.
168+
# In this case, pydicom.encaps.generate_fragmented_frames yields a tuple of bytes for each frame and
169+
# each tuple element is passed in as the src argument.
170+
171+
# Doublec check if the transfer syntax is supported although the runner should be correct.
172+
tsyntax = runner.transfer_syntax
173+
if not is_available(tsyntax):
174+
raise ImportError(f"Transfer syntax {tsyntax} not supported; see details in the debug log.")
175+
176+
nvimgcodec_decoder = nvimgcodec.Decoder()
177+
decode_params = nvimgcodec.DecodeParams(allow_any_depth=True, color_spec=nvimgcodec.ColorSpec.UNCHANGED)
178+
179+
decoded_data = nvimgcodec_decoder.decode(src, params=decode_params)
180+
return bytearray(decoded_data.cpu()) # HWC layout, interleaved format, and contiguous array in C-style
181+
182+
183+
# End of required function for decoder plugin
184+
185+
186+
def register_as_decoder_plugin(module_path: str | None = None) -> bool:
187+
"""Register as a preferred decoder plugin with supported decoder classes.
188+
189+
The Decoder class does not support sorting the plugins and uses the order in which plugins were added.
190+
Further more, the properties of ``available_plugins`` returns sorted labels only but not the Callables or
191+
their module and function names, and the function ``remove_plugin`` only returns a boolean.
192+
So there is no way to remove the available plugins before adding them back after this plugin is added.
193+
194+
For now, have to access the ``private`` property ``_available`` of the Decoder class to sort the available
195+
plugins and make sure this custom plugin is the first in the sorted list by its label. It is known that the
196+
first plugin in the default list is always ``gdcm`` for the supported decoder classes, so label name needs
197+
to be lexicographically greater than ``gdcm`` to be the first in the sorted list.
198+
199+
Args:
200+
module_path (str | None): The importable module path for this plugin.
201+
When ``None`` or ``"__main__"``, search the loaded modules for an entry whose ``__file__`` resolves
202+
to the current file, e.g. module paths that start with ``monai.deploy.operators`` or ``monai.data``.
203+
204+
Returns:
205+
bool: ``True`` if the decoder plugin is registered successfully, ``False`` otherwise.
206+
"""
207+
208+
if not _is_nvimgcodec_available():
209+
_logger.info(f"Module {NVIMGCODEC_MODULE_NAME} is not available.")
210+
return False
211+
212+
func_name = NVIMGCODEC_PLUGIN_FUNC_NAME
213+
214+
if module_path is None:
215+
module_path = _find_module_path(__name__)
216+
else:
217+
# Double check if the module path exists and if it is the same as the one for the callable origin.
218+
module_path_found, func_name = _get_callable_origin(_decode_frame) # get the func's module path.
219+
if module_path_found:
220+
if module_path.casefold() != module_path_found.casefold():
221+
_logger.info(f"Module path {module_path} does not match {module_path_found} for decoder plugin.")
222+
else:
223+
_logger.info(f"Module path {module_path} not found for decoder plugin.")
224+
return False
225+
226+
if func_name != NVIMGCODEC_PLUGIN_FUNC_NAME:
227+
_logger.warning(
228+
f"Function name {func_name} does not match {NVIMGCODEC_PLUGIN_FUNC_NAME} for decoder plugin."
229+
)
230+
231+
for decoder_class in SUPPORTED_DECODER_CLASSES:
232+
_logger.info(
233+
f"Adding plugin {NVIMGCODEC_PLUGIN_LABEL} with module path {module_path} and func name {func_name}"
234+
f"for transfer syntax {decoder_class.UID}"
235+
)
236+
decoder_class.add_plugin(NVIMGCODEC_PLUGIN_LABEL, (module_path, func_name))
237+
238+
# Need to sort the plugins to make sure the custom plugin is the first in items() of
239+
# the decoder class search for the plugin to be used.
240+
decoder_class._available = dict(sorted(decoder_class._available.items(), key=lambda item: item[0]))
241+
_logger.info(
242+
f"Registered decoder plugin {NVIMGCODEC_PLUGIN_LABEL} for transfer syntax {decoder_class.UID}:"
243+
f"{decoder_class.available_plugins}"
244+
)
245+
_logger.info(f"Registered nvimgcodec decoder plugin with {len(SUPPORTED_DECODER_CLASSES)} decoder classes.")
246+
247+
return True
248+
249+
250+
def _find_module_path(module_name: str | None) -> str:
251+
"""Return the importable module path for this file.
252+
253+
When *module_name* is ``None`` or ``"__main__"``, search the loaded modules
254+
for an entry whose ``__file__`` resolves to the current file.
255+
Likely to be in module paths that start with ``monai.deploy.operators`` or ``monai.data``.
256+
"""
257+
258+
current_file = Path(__file__).resolve()
259+
candidates: list[str] = []
260+
261+
for name, module in sys.modules.items():
262+
if not name or name == "__main__":
263+
continue
264+
module_file = getattr(module, "__file__", None)
265+
if not module_file:
266+
continue
267+
try:
268+
if Path(module_file).resolve() == current_file:
269+
candidates.append(name)
270+
except (OSError, RuntimeError):
271+
continue
272+
273+
preferred_prefixes = ("monai.deploy.operators", "monai.data")
274+
for prefix in preferred_prefixes:
275+
for name in candidates:
276+
if name.startswith(prefix):
277+
return name
278+
279+
if candidates:
280+
# deterministic fallback
281+
return sorted(candidates)[0]
282+
283+
return __name__
284+
285+
286+
def _get_callable_origin(obj: Callable[..., Any]) -> tuple[str | None, str | None]:
287+
"""Return the importable module path and attribute name for *obj*.
288+
289+
Can be used to get the importable module path and func name of existing loaded functions.
290+
291+
Args:
292+
obj: Callable retrieved via :func:`getattr` or similar.
293+
294+
Returns:
295+
tuple[str | None, str | None]: ``(module_path, attr_name)``; each element
296+
is ``None`` if it cannot be determined. When both values are available,
297+
the same callable can be re-imported using
298+
:func:`importlib.import_module` followed by :func:`getattr`.
299+
"""
300+
301+
if not callable(obj):
302+
return None, None
303+
304+
target = inspect.unwrap(obj)
305+
attr_name = getattr(target, "__name__", None)
306+
module = inspect.getmodule(target)
307+
module_path = getattr(module, "__name__", None)
308+
309+
# If the callable is defined in a different module, find the attribute name in the module.
310+
if module_path and attr_name:
311+
module_obj = sys.modules.get(module_path)
312+
if module_obj and getattr(module_obj, attr_name, None) is not target:
313+
for name in dir(module_obj):
314+
try:
315+
if getattr(module_obj, name) is target:
316+
attr_name = name
317+
break
318+
except AttributeError:
319+
continue
320+
321+
return module_path, attr_name
322+
323+
324+
def _is_nvimgcodec_available() -> bool:
325+
"""Return ``True`` if nvimgcodec is available, ``False`` otherwise."""
326+
327+
if not nvimgcodec or not _passes_version_check(NVIMGCODEC_MODULE_NAME, NVIMGCODEC_MIN_VERSION_TUPLE) or not cp:
328+
_logger.debug(f"nvimgcodec (version >= {NVIMGCODEC_MIN_VERSION}) or CuPy missing.")
329+
return False
330+
if not cp.cuda.is_available():
331+
_logger.debug("CUDA device not found.")
332+
return False
333+
334+
return True

monai/deploy/operators/dicom_series_to_volume_operator.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from monai.deploy.core import ConditionType, Fragment, Operator, OperatorSpec
2525
from monai.deploy.core.domain.dicom_series_selection import StudySelectedSeries
2626
from monai.deploy.core.domain.image import Image
27+
from monai.deploy.operators import nvimgcodec_handler
2728

2829

2930
class DICOMSeriesToVolumeOperator(Operator):
@@ -60,6 +61,9 @@ def __init__(self, fragment: Fragment, *args, affine_lps_to_ras: bool = True, **
6061
self.input_name_series = "study_selected_series_list"
6162
self.output_name_image = "image"
6263
self.affine_lps_to_ras = affine_lps_to_ras
64+
if not nvimgcodec_handler.register_as_decoder_plugin():
65+
logging.warning("Failed to register nvimgcodec decoder plugin.")
66+
6367
# Need to call the base class constructor last
6468
super().__init__(fragment, *args, **kwargs)
6569

0 commit comments

Comments
 (0)