|
| 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 |
0 commit comments