diff --git a/monai/deploy/operators/__init__.py b/monai/deploy/operators/__init__.py index 75176dab..e3009868 100644 --- a/monai/deploy/operators/__init__.py +++ b/monai/deploy/operators/__init__.py @@ -37,6 +37,7 @@ # from holoscan.operators import * from holoscan.operators import PingRxOp, PingTxOp, VideoStreamRecorderOp, VideoStreamReplayerOp +from . import decoder_nvimgcodec from .clara_viz_operator import ClaraVizOperator from .dicom_data_loader_operator import DICOMDataLoaderOperator from .dicom_encapsulated_pdf_writer_operator import DICOMEncapsulatedPDFWriterOperator @@ -52,3 +53,29 @@ from .png_converter_operator import PNGConverterOperator from .publisher_operator import PublisherOperator from .stl_conversion_operator import STLConversionOperator, STLConverter + +__all__ = [ + "decoder_nvimgcodec", + "BundleConfigNames", + "ClaraVizOperator", + "DICOMDataLoaderOperator", + "DICOMEncapsulatedPDFWriterOperator", + "DICOMSegmentationWriterOperator", + "DICOMSeriesSelectorOperator", + "DICOMSeriesToVolumeOperator", + "DICOMTextSRWriterOperator", + "EquipmentInfo", + "InferenceOperator", + "IOMapping", + "ModelInfo", + "MonaiBundleInferenceOperator", + "MonaiSegInferenceOperator", + "NiftiDataLoader", + "PNGConverterOperator", + "PublisherOperator", + "random_with_n_digits", + "save_dcm_file", + "write_common_modules", + "STLConversionOperator", + "STLConverter", +] diff --git a/monai/deploy/operators/decoder_nvimgcodec.py b/monai/deploy/operators/decoder_nvimgcodec.py new file mode 100644 index 00000000..74d0d7ad --- /dev/null +++ b/monai/deploy/operators/decoder_nvimgcodec.py @@ -0,0 +1,541 @@ +# Copyright 2025 MONAI Consortium +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# http://www.apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +This decoder plugin for nvimgcodec decompresses +encoded Pixel Data for the following transfer syntaxes: + JPEGBaseline8Bit, 1.2.840.10008.1.2.4.50, JPEG Baseline (Process 1) + JPEGExtended12Bit, 1.2.840.10008.1.2.4.51, JPEG Extended (Process 2 & 4) + JPEGLossless, 1.2.840.10008.1.2.4.57, JPEG Lossless, Non-Hierarchical (Process 14) + JPEGLosslessSV1, 1.2.840.10008.1.2.4.70, JPEG Lossless, Non-Hierarchical, First-Order Prediction + JPEG2000Lossless, 1.2.840.10008.1.2.4.90, JPEG 2000 Image Compression (Lossless Only) + JPEG2000, 1.2.840.10008.1.2.4.91, JPEG 2000 Image Compression + HTJ2KLossless, 1.2.840.10008.1.2.4.201, HTJ2K Image Compression (Lossless Only) + HTJ2KLosslessRPCL, 1.2.840.10008.1.2.4.202, HTJ2K with RPCL Options Image Compression (Lossless Only) + HTJ2K, 1.2.840.10008.1.2.4.203, HTJ2K Image Compression + +There are two ways to add a custom decoding plugin to pydicom: +1. Using the pixel_data_handlers backend, though pydicom.pixel_data_handlers module is deprecated + and will be removed in v4.0. +2. Using the pixels backend by adding a decoder plugin to existing decoders with the add_plugin method, + see https://pydicom.github.io/pydicom/stable/guides/decoding/decoder_plugins.html + +It is noted that pydicom.dataset.Dataset.pixel_array changed in version 3.0 where the backend used for +pixel data decoding changed from the pixel_data_handlers module to the pixels module. + +So, this implementation uses the pixels backend. + +Plugin Requirements: +A custom decoding plugin must implement three objects within the same module: + - A function named is_available with the following signature: + def is_available(uid: pydicom.uid.UID) -> bool: + Where uid is the Transfer Syntax UID for the corresponding decoder as a UID + - A dict named DECODER_DEPENDENCIES with the type dict[pydicom.uid.UID, tuple[str, ...], such as: + DECODER_DEPENDENCIES = {JPEG2000Lossless: ('numpy', 'pillow', 'imagecodecs'),} + This will be used to provide the user with a list of dependencies required by the plugin. + - A function that performs the decoding with the following function signature as in Github repo: + def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes + src is a single frame’s worth of raw compressed data to be decoded, and + runner is a DecodeRunner instance that manages the decoding process. + +Adding plugins to a Decoder: +Additional plugins can be added to an existing decoder with the add_plugin() method + ```python + from pydicom.pixels.decoders import RLELosslessDecoder + RLELosslessDecoder.add_plugin( + 'my_decoder', the plugin's label + ('my_package.decoders', 'my_decoder_func') the import paths + ) + ``` +""" + +import inspect +import logging +import sys +from pathlib import Path +from typing import Any, Callable, Iterable + +import numpy as np +from pydicom.pixels.common import PhotometricInterpretation as PI # noqa: N817 +from pydicom.pixels.common import ( + RunnerBase, +) +from pydicom.pixels.decoders import ( + HTJ2KDecoder, + HTJ2KLosslessDecoder, + HTJ2KLosslessRPCLDecoder, + JPEG2000Decoder, + JPEG2000LosslessDecoder, + JPEGBaseline8BitDecoder, + JPEGExtended12BitDecoder, + JPEGLosslessDecoder, + JPEGLosslessSV1Decoder, +) +from pydicom.pixels.decoders.base import DecodeRunner +from pydicom.pixels.utils import _passes_version_check +from pydicom.uid import UID, JPEG2000TransferSyntaxes + +try: + import cupy as cp +except ImportError: + cp = None + +try: + from nvidia import nvimgcodec + + # Parse version string, extracting only numeric components to handle suffixes like "0.6.0rc1" + try: + import re + + version_parts = [] + for part in nvimgcodec.__version__.split("."): + # Extract leading digits from each version component + match = re.match(r"^(\d+)", part) + if match: + version_parts.append(int(match.group(1))) + else: + break # Stop at first non-numeric component + nvimgcodec_version = tuple(version_parts) if version_parts else (0,) + except (AttributeError, ValueError): + nvimgcodec_version = (0,) +except ImportError: + nvimgcodec = None + +# nvimgcodec pypi package name, minimum version required and the label for this decoder plugin. +NVIMGCODEC_MODULE_NAME = "nvidia.nvimgcodec" # from nvidia-nvimgcodec-cu12 or other variants +NVIMGCODEC_MIN_VERSION = "0.6" +NVIMGCODEC_MIN_VERSION_TUPLE = tuple(int(x) for x in NVIMGCODEC_MIN_VERSION.split(".")) +NVIMGCODEC_PLUGIN_LABEL = "0.6+nvimgcodec" # to be sorted to first in ascending order of plugins + +# Supported decoder classes of the corresponding transfer syntaxes by this decoder plugin. +SUPPORTED_DECODER_CLASSES = [ + JPEGBaseline8BitDecoder, # 1.2.840.10008.1.2.4.50, JPEG Baseline (Process 1) + JPEGExtended12BitDecoder, # 1.2.840.10008.1.2.4.51, JPEG Extended (Process 2 & 4) + JPEGLosslessDecoder, # 1.2.840.10008.1.2.4.57, JPEG Lossless, Non-Hierarchical (Process 14) + JPEGLosslessSV1Decoder, # 1.2.840.10008.1.2.4.70, JPEG Lossless, Non-Hierarchical, First-Order Prediction + JPEG2000LosslessDecoder, # 1.2.840.10008.1.2.4.90, JPEG 2000 Image Compression (Lossless Only) + JPEG2000Decoder, # 1.2.840.10008.1.2.4.91, JPEG 2000 Image Compression + HTJ2KLosslessDecoder, # 1.2.840.10008.1.2.4.201, HTJ2K Image Compression (Lossless Only) + HTJ2KLosslessRPCLDecoder, # 1.2.840.10008.1.2.4.202, HTJ2K with RPCL Options Image Compression (Lossless Only) + HTJ2KDecoder, # 1.2.840.10008.1.2.4.203, HTJ2K Image Compression +] + +SUPPORTED_TRANSFER_SYNTAXES: Iterable[UID] = [x.UID for x in SUPPORTED_DECODER_CLASSES] + +_logger = logging.getLogger(__name__) + + +# Lazy singleton for nvimgcodec decoder; initialized on first use +# Decode params are created per-decode based on image characteristics +if nvimgcodec: + _NVIMGCODEC_DECODER: Any = None +else: # pragma: no cover - nvimgcodec not installed + _NVIMGCODEC_DECODER = None + +# Required for decoder plugin +DECODER_DEPENDENCIES = { + x: ("numpy", "cupy", f"{NVIMGCODEC_MODULE_NAME}>={NVIMGCODEC_MIN_VERSION}, nvidia-nvjpeg2k-cu12>=0.9.1,") + for x in SUPPORTED_TRANSFER_SYNTAXES +} + + +# Required for decoder plugin +def is_available(uid: UID) -> bool: + """Return ``True`` if a pixel data decoder for ``uid`` is available. + + Args: + uid (UID): The transfer syntax UID to check. + + Returns: + bool: ``True`` if a pixel data decoder for ``uid`` is available, + ``False`` otherwise. + """ + + _logger.debug(f"Checking if CUDA and nvimgcodec available for transfer syntax: {uid}") + + if uid not in SUPPORTED_TRANSFER_SYNTAXES: + _logger.debug(f"Transfer syntax {uid} not supported by nvimgcodec.") + return False + if not _is_nvimgcodec_available(): + _logger.debug(f"Module {NVIMGCODEC_MODULE_NAME} is not available.") + return False + + return True + + +# Required for decoder plugin +def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes: + """Return the decoded image data in `src` as a :class:`bytearray` or :class:`bytes`.""" + tsyntax = runner.transfer_syntax + _logger.debug(f"transfer_syntax: {tsyntax}") + + if not is_available(tsyntax): + raise ValueError(f"Transfer syntax {tsyntax} not supported; see details in the debug log.") + + runner.set_frame_option(runner.index, "decoding_plugin", "nvimgcodec") # type: ignore[attr-defined] + + is_jpeg2k = tsyntax in JPEG2000TransferSyntaxes + samples_per_pixel = runner.samples_per_pixel + photometric_interpretation = runner.photometric_interpretation + + # --- JPEG 2000: Precision/Bit depth --- + if is_jpeg2k: + precision, bits_allocated = _jpeg2k_precision_bits(runner) + runner.set_frame_option(runner.index, "bits_allocated", bits_allocated) # type: ignore[attr-defined] + _logger.debug(f"Set bits_allocated to {bits_allocated} for J2K precision {precision}") + + # Check if RGB conversion requested (following Pillow decoder logic) + convert_to_rgb = ( + samples_per_pixel > 1 and runner.get_option("as_rgb", False) and "YBR" in photometric_interpretation + ) + + decoder = _get_decoder_resources() + params = _get_decode_params(runner) + decoded_surface = decoder.decode(src, params=params).cpu() + np_surface = np.ascontiguousarray(np.asarray(decoded_surface)) + + # Handle JPEG2000-specific postprocessing separately + if is_jpeg2k: + np_surface = _jpeg2k_postprocess(np_surface, runner) + + # Update photometric interpretation if we converted to RGB, or JPEG 2000 YBR* + if convert_to_rgb or photometric_interpretation in (PI.YBR_ICT, PI.YBR_RCT): + runner.set_frame_option(runner.index, "photometric_interpretation", PI.RGB) # type: ignore[attr-defined] + _logger.debug( + "Set photometric_interpretation to RGB after conversion" + if convert_to_rgb + else f"Set photometric_interpretation to RGB for {photometric_interpretation}" + ) + + return np_surface.tobytes() + + +def _get_decoder_resources() -> Any: + """Return cached nvimgcodec decoder (parameters are created per decode).""" + + if not _is_nvimgcodec_available(): + raise RuntimeError("nvimgcodec package is not available.") + + global _NVIMGCODEC_DECODER + + if _NVIMGCODEC_DECODER is None: + _NVIMGCODEC_DECODER = nvimgcodec.Decoder() + + return _NVIMGCODEC_DECODER + + +def _get_decode_params(runner: RunnerBase) -> Any: + """Create decode parameters based on DICOM image characteristics. + + Mimics the behavior of pydicom's Pillow decoder: + - By default, keeps JPEG data in YCbCr format (no conversion) + - If as_rgb option is True and photometric interpretation is YBR*, converts to RGB + + This matches the logic in pydicom.pixels.decoders.pillow._decode_frame() + + Args: + runner: The DecodeRunner or RunnerBase instance with access to DICOM metadata. + + Returns: + nvimgcodec.DecodeParams: Configured decode parameters. + """ + if not _is_nvimgcodec_available(): + raise RuntimeError("nvimgcodec package is not available.") + + # Access DICOM metadata from the runner + samples_per_pixel = runner.samples_per_pixel + photometric_interpretation = runner.photometric_interpretation + + # Default: keep color space unchanged + color_spec = nvimgcodec.ColorSpec.UNCHANGED + + # For multi-sample (color) images, check if RGB conversion is requested + if samples_per_pixel > 1: + # JPEG 2000 color transformations are always returned as RGB (matches Pillow) + if photometric_interpretation in (PI.YBR_ICT, PI.YBR_RCT): + color_spec = nvimgcodec.ColorSpec.RGB + _logger.debug( + f"Using RGB color spec for JPEG 2000 color transformation " f"(PI: {photometric_interpretation})" + ) + else: + # Check the as_rgb option - same as Pillow decoder + convert_to_rgb = runner.get_option("as_rgb", False) and "YBR" in photometric_interpretation + + if convert_to_rgb: + # Convert YCbCr to RGB as requested + color_spec = nvimgcodec.ColorSpec.RGB + _logger.debug(f"Using RGB color spec (as_rgb=True, PI: {photometric_interpretation})") + else: + # Keep YCbCr unchanged - matches Pillow's image.draft("YCbCr") behavior + _logger.debug( + f"Using UNCHANGED color spec to preserve YCbCr " f"(as_rgb=False, PI: {photometric_interpretation})" + ) + else: + # Grayscale image - keep unchanged + _logger.debug(f"Using UNCHANGED color spec for grayscale image " f"(samples_per_pixel: {samples_per_pixel})") + + return nvimgcodec.DecodeParams( + allow_any_depth=True, + color_spec=color_spec, + ) + + +def _jpeg2k_precision_bits(runner: DecodeRunner) -> tuple[int, int]: + precision = runner.get_frame_option(runner.index, "j2k_precision", runner.bits_stored) # type: ignore[attr-defined] + if 0 < precision <= 8: + return precision, 8 + elif 8 < precision <= 16: + if runner.samples_per_pixel > 1: + _logger.warning( + f"JPEG 2000 with {precision}-bit multi-sample data may have precision issues with some decoders" + ) + return precision, 16 + else: + raise ValueError(f"Only 'Bits Stored' values up to 16 are supported, got {precision}") + + +def _jpeg2k_sign_correction(arr, dtype, bits_allocated): + arr = arr.view(dtype) + arr -= np.int32(2 ** (bits_allocated - 1)) + _logger.debug("Applied J2K sign correction") + return arr + + +def _jpeg2k_bitshift(arr, bit_shift): + np.right_shift(arr, bit_shift, out=arr) + _logger.debug(f"Applied J2K bit shift: {bit_shift} bits") + return arr + + +def _jpeg2k_postprocess(np_surface, runner): + """Handle JPEG 2000 postprocessing: sign correction and bit shifts.""" + precision = runner.get_frame_option(runner.index, "j2k_precision", runner.bits_stored) + bits_allocated = runner.get_frame_option(runner.index, "bits_allocated", runner.bits_allocated) + is_signed = runner.pixel_representation + if runner.get_option("apply_j2k_sign_correction", False): + is_signed = runner.get_frame_option(runner.index, "j2k_is_signed", is_signed) + + # Sign correction for signed data + if is_signed and runner.pixel_representation == 1: + dtype = runner.frame_dtype(runner.index) + buffer = bytearray(np_surface.tobytes()) + arr = np.frombuffer(buffer, dtype=f" precision + bit_shift = bits_allocated - precision + if bit_shift: + buffer = bytearray(np_surface.tobytes() if isinstance(np_surface, np.ndarray) else np_surface) + dtype = runner.frame_dtype(runner.index) + arr = np.frombuffer(buffer, dtype=dtype) + np_surface = _jpeg2k_bitshift(arr, bit_shift) + + return np_surface + + +def _is_nvimgcodec_available() -> bool: + """Return ``True`` if nvimgcodec is available, ``False`` otherwise.""" + + if not nvimgcodec or not _passes_version_check(NVIMGCODEC_MODULE_NAME, NVIMGCODEC_MIN_VERSION_TUPLE) or not cp: + _logger.debug(f"nvimgcodec (version >= {NVIMGCODEC_MIN_VERSION}) or CuPy missing.") + return False + try: + if not cp.cuda.is_available(): + _logger.debug("CUDA device not found.") + return False + except Exception as exc: # pragma: no cover - environment specific + _logger.debug(f"CUDA availability check failed: {exc}") + return False + + return True + + +# Helper functions for an application to register/unregister this decoder plugin with Pydicom at application startup. + + +def register_as_decoder_plugin(module_path: str | None = None) -> bool: + """Register as a preferred decoder plugin with supported decoder classes. + + The Decoder class does not support sorting the plugins and uses the order in which plugins were added. + Furthermore, the properties of ``available_plugins`` returns sorted labels only but not the Callables or + their module and function names, and the function ``remove_plugin`` only returns a boolean. + So there is no way to remove the available plugins before adding them back after this plugin is added. + + For now, have to access the ``private`` property ``_available`` of the Decoder class to sort the available + plugins and make sure this custom plugin is the first in the sorted list by its label. It is known that the + first plugin in the default list is always ``gdcm`` for the supported decoder classes, so label name needs + to be lexicographically less than ``gdcm`` to be the first in the sorted list. + + Args: + module_path (str | None): The importable module path for this plugin. + When ``None`` or ``"__main__"``, search the loaded modules for an entry whose ``__file__`` resolves + to the current file, e.g. module paths that start with ``monai.deploy.operators`` or ``monai.data``. + + Returns: + bool: ``True`` if the decoder plugin is registered successfully, ``False`` otherwise. + """ + + if not _is_nvimgcodec_available(): + _logger.warning(f"Module {NVIMGCODEC_MODULE_NAME} is not available.") + return False + + try: + func_name = getattr(_decode_frame, "__name__", None) + except NameError: + _logger.error("Decoder function `_decode_frame` not found.") + return False + + if module_path is None: + module_path = _find_module_path(__name__) + else: + # Double check if the module path exists and if it is the same as the one for the callable origin. + module_path_found, func_name_found = _get_callable_origin(_decode_frame) # get the func's module path. + if module_path_found: + if module_path.casefold() != module_path_found.casefold(): + _logger.warning(f"Module path {module_path} does not match {module_path_found} for decoder plugin.") + else: + _logger.error(f"Module path {module_path} not found for decoder plugin.") + return False + + if func_name != func_name_found: + _logger.warning( + f"Function {func_name_found} in {module_path_found} instead of {func_name} used for decoder plugin." + ) + + for decoder_class in SUPPORTED_DECODER_CLASSES: + if NVIMGCODEC_PLUGIN_LABEL in decoder_class.available_plugins: + _logger.debug(f"{NVIMGCODEC_PLUGIN_LABEL} already registered for transfer syntax {decoder_class.UID}.") + continue + + decoder_class.add_plugin(NVIMGCODEC_PLUGIN_LABEL, (module_path, str(func_name))) + _logger.info( + f"Added plugin for transfer syntax {decoder_class.UID}: " + f"{NVIMGCODEC_PLUGIN_LABEL} with {func_name} in module path {module_path}." + ) + + # Need to sort the plugins to make sure the custom plugin is the first in items() of + # the decoder class search for the plugin to be used. + decoder_class._available = dict(sorted(decoder_class._available.items(), key=lambda item: item[0])) + _logger.debug(f"Sorted plugins for transfer syntax {decoder_class.UID}: {decoder_class._available}") + + _logger.info(f"{NVIMGCODEC_MODULE_NAME} registered with {len(SUPPORTED_DECODER_CLASSES)} decoder classes.") + + return True + + +def unregister_as_decoder_plugin() -> bool: + """Unregister the decoder plugin from the supported decoder classes.""" + + for decoder_class in SUPPORTED_DECODER_CLASSES: + if NVIMGCODEC_PLUGIN_LABEL in decoder_class.available_plugins: + decoder_class.remove_plugin(NVIMGCODEC_PLUGIN_LABEL) + _logger.info(f"Unregistered plugin for transfer syntax {decoder_class.UID}: {NVIMGCODEC_PLUGIN_LABEL}") + + return True + + +def _find_module_path(module_name: str | None) -> str: + """Return the importable module path for *module_name* file. + + When *module_name* is ``None`` or ``"__main__"``, search the loaded modules + for an entry whose ``__file__`` resolves to the current file. + + When *module_name* is provided and not ``"__main__"``, validate it exists in + loaded modules and corresponds to the current file, returning it if valid. + + When used in MONAI, likely in module paths ``monai.deploy.operators`` or ``monai.data``. + """ + + current_file = Path(__file__).resolve() + + # If a specific module name is provided (not None or "__main__"), validate it + if module_name and module_name != "__main__": + module = sys.modules.get(module_name) + if module: + module_file = getattr(module, "__file__", None) + if module_file: + try: + if Path(module_file).resolve() == current_file: + return module_name + else: + _logger.warning(f"Module {module_name} found but its file path does not match current file.") + except (OSError, RuntimeError): + _logger.warning(f"Could not resolve file path for module {module_name}.") + else: + _logger.warning(f"Module {module_name} has no __file__ attribute.") + else: + _logger.warning(f"Module {module_name} not found in loaded modules.") + # Fall through to search for the correct module + + # Search for modules that correspond to the current file + candidates: list[str] = [] + + for name, module in sys.modules.items(): + if not name or name == "__main__": + continue + module_file = getattr(module, "__file__", None) + if not module_file: + continue + try: + if Path(module_file).resolve() == current_file: + candidates.append(name) + except (OSError, RuntimeError): + continue + + preferred_prefixes = ("monai.deploy.operators", "monai.data") + for prefix in preferred_prefixes: + for name in candidates: + if name.startswith(prefix): + return name + + if candidates: + # deterministic fallback + return sorted(candidates)[0] + + return __name__ + + +def _get_callable_origin(obj: Callable[..., Any]) -> tuple[str | None, str | None]: + """Return the importable module path and attribute(function) name for *obj*. + + Can be used to get the importable module path and func name of existing callables. + + Args: + obj: Callable retrieved via :func:`getattr` or similar. + + Returns: + tuple[str | None, str | None]: ``(module_path, attr_name)``; each element + is ``None`` if it cannot be determined. When both values are available, + the same callable can be re-imported using + :func:`importlib.import_module` followed by :func:`getattr`. + """ + + if not callable(obj): + return None, None + + target = inspect.unwrap(obj) + attr_name = getattr(target, "__name__", None) + module = inspect.getmodule(target) + module_path = getattr(module, "__name__", None) + + # If the callable is defined in a different module, find the attribute name in the module. + if module_path and attr_name: + module_obj = sys.modules.get(module_path) + if module_obj and getattr(module_obj, attr_name, None) is not target: + for name in dir(module_obj): + try: + if getattr(module_obj, name) is target: + attr_name = name + break + except AttributeError: + continue + + return module_path, attr_name diff --git a/monai/deploy/operators/dicom_encapsulated_pdf_writer_operator.py b/monai/deploy/operators/dicom_encapsulated_pdf_writer_operator.py index b7a4465a..24a85dda 100644 --- a/monai/deploy/operators/dicom_encapsulated_pdf_writer_operator.py +++ b/monai/deploy/operators/dicom_encapsulated_pdf_writer_operator.py @@ -15,7 +15,12 @@ from pathlib import Path from typing import Dict, Optional, Union +from monai.deploy.core import ConditionType, Fragment, Operator, OperatorSpec +from monai.deploy.core.domain.dicom_series import DICOMSeries +from monai.deploy.core.domain.dicom_series_selection import StudySelectedSeries +from monai.deploy.operators.dicom_utils import EquipmentInfo, ModelInfo, save_dcm_file, write_common_modules from monai.deploy.utils.importutil import optional_import +from monai.deploy.utils.version import get_sdk_semver dcmread, _ = optional_import("pydicom", name="dcmread") dcmwrite, _ = optional_import("pydicom.filewriter", name="dcmwrite") @@ -24,13 +29,7 @@ Dataset, _ = optional_import("pydicom.dataset", name="Dataset") FileDataset, _ = optional_import("pydicom.dataset", name="FileDataset") Sequence, _ = optional_import("pydicom.sequence", name="Sequence") -PdfReader, _ = optional_import("PyPDF2", name="PdfReader") - -from monai.deploy.core import ConditionType, Fragment, Operator, OperatorSpec -from monai.deploy.core.domain.dicom_series import DICOMSeries -from monai.deploy.core.domain.dicom_series_selection import StudySelectedSeries -from monai.deploy.operators.dicom_utils import EquipmentInfo, ModelInfo, save_dcm_file, write_common_modules -from monai.deploy.utils.version import get_sdk_semver +PdfReader, _ = optional_import("pypdf", name="PdfReader") # @md.env(pip_packages=["pydicom >= 1.4.2", "PyPDF2 >= 2.11.1", "monai"]) @@ -58,7 +57,7 @@ def __init__( fragment: Fragment, *args, output_folder: Union[str, Path], - model_info: ModelInfo, + model_info: Optional[ModelInfo] = None, equipment_info: Optional[EquipmentInfo] = None, copy_tags: bool = True, custom_tags: Optional[Dict[str, str]] = None, @@ -249,52 +248,51 @@ def _is_pdf_bytes(self, content: bytes): return True -# Commenting out the following as pttype complains about the constructor for no reason -# def test(test_copy_tags: bool = True): -# from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator -# from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator - -# current_file_dir = Path(__file__).parent.resolve() -# dcm_folder = current_file_dir.joinpath("../../../inputs/livertumor_ct/dcm/1-CT_series_liver_tumor_from_nii014") -# pdf_file = current_file_dir.joinpath("../../../inputs/pdf/TestPDF.pdf") -# out_path = Path("output_pdf_op").absolute() -# pdf_bytes = b"Not PDF bytes." - -# fragment = Fragment() -# loader = DICOMDataLoaderOperator(fragment, name="loader_op") -# series_selector = DICOMSeriesSelectorOperator(fragment, name="selector_op") -# sr_writer = DICOMEncapsulatedPDFWriterOperator( -# fragment, -# output_folder=out_path, -# copy_tags=test_copy_tags, -# model_info=None, -# equipment_info=EquipmentInfo(), -# custom_tags={"SeriesDescription": "Report from AI algorithm. Not for clinical use."}, -# name="writer_op", -# ) - -# # Testing with the main entry functions -# dicom_series = None -# if test_copy_tags: -# study_list = loader.load_data_to_studies(Path(dcm_folder).absolute()) -# study_selected_series_list = series_selector.filter(None, study_list) -# # Get the first DICOM Series, as for now, only expecting this. -# if not study_selected_series_list or len(study_selected_series_list) < 1: -# raise ValueError("Missing input, list of 'StudySelectedSeries'.") -# for study_selected_series in study_selected_series_list: -# if not isinstance(study_selected_series, StudySelectedSeries): -# raise ValueError("Element in input is not expected type, 'StudySelectedSeries'.") -# for selected_series in study_selected_series.selected_series: -# print(type(selected_series)) -# dicom_series = selected_series.series -# print(type(dicom_series)) - -# with open(pdf_file, "rb") as f: -# pdf_bytes = f.read() - -# sr_writer.write(pdf_bytes, dicom_series, out_path) - - -# if __name__ == "__main__": -# test(test_copy_tags=True) -# test(test_copy_tags=False) +def test(test_copy_tags: bool = True): + from monai.deploy.operators.dicom_data_loader_operator import DICOMDataLoaderOperator + from monai.deploy.operators.dicom_series_selector_operator import DICOMSeriesSelectorOperator + + current_file_dir = Path(__file__).parent.resolve() + dcm_folder = current_file_dir.joinpath("../../../inputs/livertumor_ct/dcm/1-CT_series_liver_tumor_from_nii014") + pdf_file = current_file_dir.joinpath("../../../inputs/pdf/TestPDF.pdf") + out_path = Path("output_pdf_op").absolute() + pdf_bytes = b"Not PDF bytes." + + fragment = Fragment() + loader = DICOMDataLoaderOperator(fragment, name="loader_op") + series_selector = DICOMSeriesSelectorOperator(fragment, name="selector_op") + sr_writer = DICOMEncapsulatedPDFWriterOperator( + fragment, + output_folder=out_path, + copy_tags=test_copy_tags, + model_info=None, + equipment_info=EquipmentInfo(), + custom_tags={"SeriesDescription": "Report from AI algorithm. Not for clinical use."}, + name="writer_op", + ) + + # Testing with the main entry functions + dicom_series = None + if test_copy_tags: + study_list = loader.load_data_to_studies(Path(dcm_folder).absolute()) + study_selected_series_list = series_selector.filter(None, study_list) + # Get the first DICOM Series, as for now, only expecting this. + if not study_selected_series_list or len(study_selected_series_list) < 1: + raise ValueError("Missing input, list of 'StudySelectedSeries'.") + for study_selected_series in study_selected_series_list: + if not isinstance(study_selected_series, StudySelectedSeries): + raise ValueError("Element in input is not expected type, 'StudySelectedSeries'.") + for selected_series in study_selected_series.selected_series: + print(type(selected_series)) + dicom_series = selected_series.series + print(type(dicom_series)) + + with open(pdf_file, "rb") as f: + pdf_bytes = f.read() + + sr_writer.write(pdf_bytes, dicom_series, out_path) + + +if __name__ == "__main__": + test(test_copy_tags=True) + test(test_copy_tags=False) diff --git a/monai/deploy/operators/dicom_series_to_volume_operator.py b/monai/deploy/operators/dicom_series_to_volume_operator.py index 5a06ec52..dacb59ce 100644 --- a/monai/deploy/operators/dicom_series_to_volume_operator.py +++ b/monai/deploy/operators/dicom_series_to_volume_operator.py @@ -25,6 +25,8 @@ from monai.deploy.core.domain.dicom_series_selection import StudySelectedSeries from monai.deploy.core.domain.image import Image +from . import decoder_nvimgcodec + class DICOMSeriesToVolumeOperator(Operator): """This operator converts an instance of DICOMSeries into an Image object. @@ -51,6 +53,25 @@ class DICOMSeriesToVolumeOperator(Operator): def __init__(self, fragment: Fragment, *args, affine_lps_to_ras: bool = True, **kwargs): """Create an instance for a containing application object. + This operator converts instances of DICOMSeries into an Image object. + The loaded Image Object can be used for further processing via other operators. + The data array will be a 3D image NumPy array with index order of `DHW`. + Channel is limited to 1 as of now, and `C` is absent in the NumPy array. + + This operator registers `nvimgcodec` based compressed pixel data decoder plugin with Pydicom + at application startup to support and improve the performance of decoding DICOM files with compressed + pixel data of in JPEG, JPEG 2000, and HTJ2K, irrespective of if python-gdcm, Python libjpg and openjpeg + based decoder plugins are available at runtime. + + Registering the decoder plugin is all automatic and does not require any additional change in user's application + except for adding a dependency on the `nvimgcodec-cu12` and `nvidia-nvjpeg2k-cu12` packages (suffix of cu12 means + CUDA 12.0 though cu13 is also supported). + + Named Input: + study_selected_series_list: List of StudySelectedSeries. + Named Output: + image: Image object. + Args: fragment (Fragment): An instance of the Application class which is derived from Fragment. affine_lps_to_ras (bool): If true, the affine transform in the image metadata is RAS oriented, @@ -60,6 +81,9 @@ def __init__(self, fragment: Fragment, *args, affine_lps_to_ras: bool = True, ** self.input_name_series = "study_selected_series_list" self.output_name_image = "image" self.affine_lps_to_ras = affine_lps_to_ras + if not decoder_nvimgcodec.register_as_decoder_plugin(): + logging.warning("The nvimgcodec decoder plugin did not register successfully.") + # Need to call the base class constructor last super().__init__(fragment, *args, **kwargs) diff --git a/monai/deploy/operators/dicom_utils.py b/monai/deploy/operators/dicom_utils.py index 6df12fe7..b2f903a5 100644 --- a/monai/deploy/operators/dicom_utils.py +++ b/monai/deploy/operators/dicom_utils.py @@ -15,19 +15,19 @@ from random import randint from typing import Any, Optional +from monai.deploy.core.domain.dicom_series import DICOMSeries from monai.deploy.utils.importutil import optional_import +from monai.deploy.utils.version import get_sdk_semver dcmread, _ = optional_import("pydicom", name="dcmread") dcmwrite, _ = optional_import("pydicom.filewriter", name="dcmwrite") generate_uid, _ = optional_import("pydicom.uid", name="generate_uid") ImplicitVRLittleEndian, _ = optional_import("pydicom.uid", name="ImplicitVRLittleEndian") +UID, _ = optional_import("pydicom.uid", name="UID") Dataset_, _ = optional_import("pydicom.dataset", name="Dataset") FileDataset, _ = optional_import("pydicom.dataset", name="FileDataset") Sequence, _ = optional_import("pydicom.sequence", name="Sequence") -from monai.deploy.core.domain.dicom_series import DICOMSeries -from monai.deploy.utils.version import get_sdk_semver - # To address mypy complaint Dataset: Any = Dataset_ @@ -113,7 +113,30 @@ def save_dcm_file(data_set: Dataset, file_path: Path, validate_readable: bool = if not str(file_path).strip(): raise ValueError("file_path to save dcm file not provided.") - dcmwrite(str(file_path).strip(), data_set, write_like_original=False) + implicit_vr = True + little_endian = True + + transfer_syntax = getattr(getattr(data_set, "file_meta", None), "TransferSyntaxUID", None) + if transfer_syntax is not None: + ts_uid = None + if hasattr(transfer_syntax, "is_implicit_VR"): + ts_uid = transfer_syntax + elif UID is not None: + try: + ts_uid = UID(str(transfer_syntax)) + except Exception: + ts_uid = None + if ts_uid is not None: + implicit_vr = getattr(ts_uid, "is_implicit_VR", implicit_vr) + little_endian = getattr(ts_uid, "is_little_endian", little_endian) + + dcmwrite( + str(file_path).strip(), + data_set, + enforce_file_format=True, + implicit_vr=implicit_vr, + little_endian=little_endian, + ) logging.info(f"Finished writing DICOM instance to file {file_path}") if validate_readable: @@ -187,8 +210,6 @@ def write_common_modules( # Write modules to data set ds = Dataset() ds.file_meta = file_meta - ds.is_implicit_VR = True - ds.is_little_endian = True # Content Date (0008,0023) and Content Time (0008,0033) are defined to be the date and time that # the document content creation started. In the context of analysis results, these may be considered diff --git a/monai/deploy/operators/monai_seg_inference_operator.py b/monai/deploy/operators/monai_seg_inference_operator.py index 261be8c0..bebb7c75 100644 --- a/monai/deploy/operators/monai_seg_inference_operator.py +++ b/monai/deploy/operators/monai_seg_inference_operator.py @@ -576,6 +576,10 @@ def _get_meta_dict(self, img: Image) -> Dict: # Referring to the MONAI ITKReader, the spacing is simply a NumPy array from the ITK image # GetSpacing, in WHD. + img_meta_dict["row_pixel_spacing"] = img_meta_dict.get("row_pixel_spacing", 1.0) + img_meta_dict["col_pixel_spacing"] = img_meta_dict.get("col_pixel_spacing", 1.0) + img_meta_dict["depth_pixel_spacing"] = img_meta_dict.get("depth_pixel_spacing", 1.0) + meta_dict["spacing"] = np.asarray( [ img_meta_dict["row_pixel_spacing"], diff --git a/requirements-dev.txt b/requirements-dev.txt index 8bd97b5b..b725c8d3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -23,8 +23,8 @@ pytest-lazy-fixture==0.6.3 cucim~=21.06; platform_system == "Linux" monai>=1.3.0 docker>=5.0.0 -pydicom>=2.3.0 -PyPDF2>=2.11.1 +pydicom>=3.0.0 +pypdf>=4.0.0 highdicom>=0.18.2 SimpleITK>=2.0.0 Pillow>=8.0.0 @@ -34,3 +34,9 @@ nibabel>=3.2.1 numpy-stl>=2.12.0 trimesh>=3.8.11 torch>=2.6.0 +nvidia-nvimgcodec-cu12>=0.6.1 +nvidia-nvjpeg2k-cu12>=0.9.1 +python-gdcm>=3.0.10 +pylibjpeg>=2.0 +pylibjpeg-libjpeg>=2.1 +pylibjpeg-openjpeg>=2.0 diff --git a/requirements-examples.txt b/requirements-examples.txt index 63523b4e..bc9754eb 100644 --- a/requirements-examples.txt +++ b/requirements-examples.txt @@ -1,6 +1,6 @@ scikit-image>=0.17.2 -pydicom>=2.3.0 -PyPDF2>=2.11.1 +pydicom>=3.0.0 +pypdf>=4.0.0 types-pytz>=2024.1.0.20240203 highdicom>=0.18.2 SimpleITK>=2.0.0 @@ -9,4 +9,10 @@ nibabel>=3.2.1 numpy-stl>=2.12.0 trimesh>=3.8.11 torch>=2.6.0 -monai>=1.3.0 \ No newline at end of file +monai>=1.3.0 +nvidia-nvimgcodec-cu12>=0.6.1 +nvidia-nvjpeg2k-cu12>=0.9.1 +python-gdcm>=3.0.10 +pylibjpeg>=2.0 +pylibjpeg-libjpeg>=2.1 +pylibjpeg-openjpeg>=2.0 \ No newline at end of file diff --git a/tests/unit/test_decoder_nvimgcodec.py b/tests/unit/test_decoder_nvimgcodec.py new file mode 100644 index 00000000..7556f00f --- /dev/null +++ b/tests/unit/test_decoder_nvimgcodec.py @@ -0,0 +1,177 @@ +import time +from pathlib import Path +from typing import Any + +import numpy as np +import pytest +from pydicom import dcmread +from pydicom.data import get_testdata_files + +from monai.deploy.operators.decoder_nvimgcodec import ( + SUPPORTED_TRANSFER_SYNTAXES, + _is_nvimgcodec_available, + register_as_decoder_plugin, + unregister_as_decoder_plugin, +) + + +def get_test_dicoms(folder_path: str | None = None): + """Use pydicom package's embedded test DICOM files for testing or a custom folder of DICOM files.""" + + # function's own util function + def _is_supported_dicom_file(path: str) -> bool: + try: + dataset = dcmread(path, stop_before_pixels=True) # ignore non-compliant DICOM files + transfer_syntax = dataset.file_meta.TransferSyntaxUID + return transfer_syntax in SUPPORTED_TRANSFER_SYNTAXES + except Exception: + return False + + dcm_paths = [] + if folder_path is not None: + folder_path_p = Path(folder_path) + if folder_path_p.exists(): + dcm_paths = sorted(folder_path_p.glob("*.dcm")) + else: + raise FileNotFoundError(f"Custom folder {folder_path} does not exist") + else: + # use pydicom package's embedded test DICOM files for testing + dcm_paths = [Path(x) for x in get_testdata_files("*.dcm")] + + for dcm_path in dcm_paths: + if not _is_supported_dicom_file(str(dcm_path)): + continue + yield str(dcm_path) + + +@pytest.mark.skipif( + (not _is_nvimgcodec_available()), + reason="NVIDIA nvimgcodec must be available", +) +@pytest.mark.parametrize("path", list(get_test_dicoms())) +def test_nvimgcodec_decoder_matches_default(path: str) -> None: + """Ensure NVIDIA nvimgcodec decoder matches default decoding for supported syntaxes.""" + + rtol = 0.01 + atol = 1.0 + + baseline_pixels: np.ndarray = np.array([]) + nv_pixels: np.ndarray = np.array([]) + + # Baseline (default pydicom) decode + default_decoder_errored = False + nvimgcodec_decoder_errored = False + default_decoder_error_message = None + nvimgcodec_decoder_error_message = None + transfer_syntax = None + try: + ds_default = dcmread(path) + transfer_syntax = ds_default.file_meta.TransferSyntaxUID + baseline_pixels = ds_default.pixel_array + except Exception as e: + default_decoder_error_message = f"{e}" + default_decoder_errored = True + + # Register the nvimgcodec decoder plugin and unregister it after each use. + register_as_decoder_plugin() + try: + ds_custom = dcmread(path) + nv_pixels = ds_custom.pixel_array + except Exception as e: + nvimgcodec_decoder_error_message = f"{e}" + nvimgcodec_decoder_errored = True + finally: + unregister_as_decoder_plugin() + + if default_decoder_errored and nvimgcodec_decoder_errored: + print( + f"All decoders encountered errors for transfer syntax {transfer_syntax} in {Path(path).name}:\n" + f"Default decoder error: {default_decoder_error_message}\n" + f"nvimgcodec decoder error: {nvimgcodec_decoder_error_message}" + ) + return + elif nvimgcodec_decoder_errored and not default_decoder_errored: + raise AssertionError(f"nvimgcodec decoder errored: {nvimgcodec_decoder_errored} but default decoder succeeded") + + assert baseline_pixels.shape == nv_pixels.shape, f"Shape mismatch for {Path(path).name}" + assert baseline_pixels.dtype == nv_pixels.dtype, f"Dtype mismatch for {Path(path).name}" + np.testing.assert_allclose(baseline_pixels, nv_pixels, rtol=rtol, atol=atol) + + +def performance_test_nvimgcodec_decoder_against_defaults(folder_path: str | None = None) -> None: + """Test and compare the performance of the nvimgcodec decoder against the default decoders + with all DICOM files of supported transfer syntaxes in a custom folder or pidicom dataset""" + + total_baseline_time = 0.0 + total_nvimgcodec_time = 0.0 + + files_tested_with_perf: dict[str, dict[str, Any]] = {} # key: path, value: performance_metrics + files_with_errors = [] + + try: + unregister_as_decoder_plugin() # Make sure nvimgcodec decoder plugin is not registered + except Exception: + pass + + for path in get_test_dicoms(folder_path): + try: + ds_default = dcmread(path) + transfer_syntax = ds_default.file_meta.TransferSyntaxUID + start = time.perf_counter() + _ = ds_default.pixel_array + baseline_execution_time = time.perf_counter() - start + total_baseline_time += baseline_execution_time + + perf: dict[str, Any] = {} + perf["transfer_syntax"] = transfer_syntax + perf["baseline_execution_time"] = baseline_execution_time + files_tested_with_perf[path] = perf + except Exception: + files_with_errors.append(Path(path).name) + continue + + # Register the nvimgcodec decoder plugin and unregister it after each use. + register_as_decoder_plugin() + combined_perf = {} + for path, perf in files_tested_with_perf.items(): + try: + ds_custom = dcmread(path) + start = time.perf_counter() + _ = ds_custom.pixel_array + perf["nvimgcodec_execution_time"] = time.perf_counter() - start + total_nvimgcodec_time += perf["nvimgcodec_execution_time"] + combined_perf[path] = perf + except Exception: + continue + unregister_as_decoder_plugin() + + # Performance of the nvimgcodec decoder against the default decoders + # with all DICOM files of supported transfer syntaxes + print( + "## nvimgcodec decoder performance against Pydicom default decoders for all supported transfer syntaxes in the test dataset" + "\n" + "**Note:** nvImgCodec is well suited for multiple-frame DICOM files, where decoder initialization time is less of a" + " percentage of total execution time " + "\n\n" + "| Transfer Syntax | Default Decoder Execution Time | nvimgcodec Decoder Execution Time | File Name |" + "\n" + "| --- | --- | --- | --- |" + ) + + for path, perf in combined_perf.items(): + print( + f"| {perf['transfer_syntax']} | {perf['baseline_execution_time']:.4f} |" + f" {perf['nvimgcodec_execution_time']:.4f} | {Path(path).name}" + ) + print(f"| **TOTAL** | {total_baseline_time} | {total_nvimgcodec_time} | - |") + print(f"\n\n__Files not tested due to errors encountered by default decoders__: \n{files_with_errors}") + + +if __name__ == "__main__": + + # Use pytest to test the functionality with pydicom embedded DICOM files of supported transfer syntaxes individually + # python -m pytest test_decoder_nvimgcodec.py + # + # The following compares the performance of the nvimgcodec decoder against the default decoders + # with DICOM files in pidicom embedded dataset or an optional custom folder + performance_test_nvimgcodec_decoder_against_defaults() # e.g. "/tmp/multi-frame-dcm"