From 3b7bd646eacc2a016e4df2774c2bf53914bf9a63 Mon Sep 17 00:00:00 2001 From: M Q Date: Fri, 26 Jun 2026 12:13:39 -0700 Subject: [PATCH 1/7] Add new DICOM reader using Pydicom and nvimgcode GPU accelerated decompression Signed-off-by: M Q --- CONTRIBUTING.md | 4 +- docs/source/data.rst | 10 ++ monai/data/__init__.py | 11 +- monai/data/image_reader.py | 155 +++++++++++++++++- monai/data/nvimgcodec_pydicom_plugin.py | 81 +++++++++ monai/transforms/io/array.py | 9 +- monai/transforms/io/dictionary.py | 4 +- monai/utils/misc.py | 8 + pyproject.toml | 3 + requirements-dev.txt | 11 +- tests/data/test_init_reader.py | 7 +- tests/data/test_nvimgcodec_pydicom_reader.py | 164 +++++++++++++++++++ 12 files changed, 455 insertions(+), 12 deletions(-) create mode 100644 monai/data/nvimgcodec_pydicom_plugin.py create mode 100644 tests/data/test_nvimgcodec_pydicom_reader.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2ad171abb1..56e2778a65 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,8 +56,8 @@ Before submitting a pull request, we recommend that all linting should pass, by ```bash # optionally update the dependencies and dev tools -python -m pip install -U pip -python -m pip install -U -r requirements-dev.txt +python -m pip install -U pip wheel +python -m pip install --no-build-isolation -r requirements-dev.txt # run the linting and type checking tools ./runtests.sh --codeformat diff --git a/docs/source/data.rst b/docs/source/data.rst index 63d5e0e23d..034ec36004 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -159,6 +159,16 @@ PILReader .. autoclass:: PILReader :members: +PydicomReader +~~~~~~~~~~~~~ +.. autoclass:: PydicomReader + :members: + +NvImgCodecPydicomReader +~~~~~~~~~~~~~~~~~~~~~~~ +.. autoclass:: NvImgCodecPydicomReader + :members: + NrrdReader ~~~~~~~~~~ .. autoclass:: NrrdReader diff --git a/monai/data/__init__.py b/monai/data/__init__.py index ef04160425..562289edcb 100644 --- a/monai/data/__init__.py +++ b/monai/data/__init__.py @@ -50,7 +50,16 @@ from .folder_layout import FolderLayout, FolderLayoutBase from .grid_dataset import GridPatchDataset, PatchDataset, PatchIter, PatchIterd from .image_dataset import ImageDataset -from .image_reader import ImageReader, ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader, PydicomReader +from .image_reader import ( + ImageReader, + ITKReader, + NibabelReader, + NrrdReader, + NumpyReader, + NvImgCodecPydicomReader, + PILReader, + PydicomReader, +) from .image_writer import ( SUPPORTED_WRITERS, ImageWriter, diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index a85eb95c20..d08c055696 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -64,7 +64,29 @@ else: NdarrayOrCupy: TypeAlias = Any -__all__ = ["ImageReader", "ITKReader", "NibabelReader", "NumpyReader", "PILReader", "PydicomReader", "NrrdReader"] +__all__ = [ + "ImageReader", + "ITKReader", + "NibabelReader", + "NumpyReader", + "PILReader", + "PydicomReader", + "NvImgCodecPydicomReader", + "NrrdReader", + "DICOM_READER_ENV_MAP", + "NON_DICOM_READERS", + "get_preferred_dicom_reader_key", + "get_default_reader_registration_order", + "is_dicom_path", +] + +DICOM_READER_ENV_MAP = { + "itk": "itkreader", + "pydicom": "pydicomreader", + "nvimgcodec": "nvimgcodecpydicomreader", +} + +NON_DICOM_READERS = ["nrrdreader", "numpyreader", "pilreader", "nibabelreader"] class ImageReader(ABC): @@ -997,6 +1019,137 @@ def _get_array_data(self, img, filename): return data +def is_dicom_path(filename: Sequence[PathLike] | PathLike) -> bool: + """ + Return ``True`` if ``filename`` refers to a DICOM file or a directory that may contain a DICOM series. + """ + for name in ensure_tuple(filename): + name = f"{name}" + path = Path(name) + if path.is_dir(): + return True + if path.suffix.lower() == ".dcm": + return True + if has_pydicom: + try: + if pydicom.misc.is_dicom(name): + return True + except Exception: + pass + return False + + +def get_preferred_dicom_reader_key() -> str: + """ + Return the :py:class:`LoadImage` registration key for the preferred DICOM reader. + + Controlled by the ``MONAI_DICOM_READER`` environment variable. Supported values are + ``itk`` (default), ``pydicom``, and ``nvimgcodec``. + """ + pref = os.environ.get("MONAI_DICOM_READER", "itk").lower() + if pref not in DICOM_READER_ENV_MAP: + warnings.warn(f"Unknown MONAI_DICOM_READER='{pref}', falling back to 'itk'.") + return DICOM_READER_ENV_MAP["itk"] + return DICOM_READER_ENV_MAP[pref] + + +def get_default_reader_registration_order() -> list[str]: + """ + Return the default reader registration order for :py:class:`LoadImage`. + + Non-DICOM readers are registered first; the preferred DICOM reader is registered last so that + it is tried first during automatic reader selection. + """ + return NON_DICOM_READERS + [get_preferred_dicom_reader_key()] + + +@require_pkg(pkg_name="pydicom") +class NvImgCodecPydicomReader(PydicomReader): + """ + Load DICOM images using Pydicom with GPU-accelerated decompression via nvImageCodec. + + This reader extends :py:class:`PydicomReader` and registers the nvImageCodec pydicom + decoder plugin on initialization. The plugin accelerates decoding of compressed pixel data + for JPEG, JPEG 2000, and HTJ2K transfer syntaxes when CUDA, CuPy and ``nvidia-nvimgcodec`` are available. + + If nvImageCodec is not available, a warning is issued and the reader falls back to the + default pydicom decoders (same behavior as :py:class:`PydicomReader`). + + Requires optional dependencies: ``pydicom``, ``cupy``, ``nvidia-nvimgcodec-cuXX`` (where XX is the CUDA version). + GPU decompression uses ``nvidia.nvimgcodec.tools.dicom.pydicom_plugin`` from the nvImageCodec package. CUDA13 is + strongly recommended because the dependency nvjpeg library has addressed a known issue with JPEGLossless decoding + in CUDA 13.2.0+. + + Note: + Enabling GPU direct loading disables GPU decompression as this bypasses any Pydicom pixel data interpretation. + In fact, the current implementation of GPU direct loading is error-prone as it simply loads the raw bytes of + the pixel data into GPU memory without any required processing, e.g. applying rescale slope and intercept, + `PhotometricInterpretation`, etc., let alone processing compressed pixel data. As such, the resulting + data array will not represent the original pixel data. + + Set environment variable ``MONAI_DICOM_READER=nvimgcodec`` to use this reader by default + with :py:class:`monai.transforms.LoadImage` without explicit configuration. + + Why NvImgCodecPydicomReader only has @require_pkg(pkg_name="pydicom") + That is intentional today: + pydicom is required to construct/use the reader at all. + nvimgcodec / CUDA / CuPy are checked later via is_nvimgcodec_available() in nvimgcodec_pydicom_plugin.py, + with a warning + fallback to normal pydicom decoders if missing. + That lets LoadImage register the reader without hard-failing when GPU deps aren't installed + + Args: + channel_dim: the channel dimension of the input image, default is None. + This is used to set original_channel_dim in the metadata, EnsureChannelFirstD reads this field. + If None, `original_channel_dim` will be either `no_channel` or `-1`. + affine_lps_to_ras: whether to convert the affine matrix from "LPS" to "RAS". Defaults to ``True``. + swap_ij: whether to swap the first two spatial axes. Default to ``True``. + prune_metadata: whether to prune the saved information in metadata. Default to ``True``. + label_dict: label of the dicom data for segmentation loading. + fname_regex: a regular expression to match file names when the input is a folder. + to_gpu: If True, load the image into GPU memory using CuPy and Kvikio. This disables GPU decompression and + in fact also bypasses any Pydicom pixel data interpretation. + kwargs: additional args for `pydicom.dcmread` API. + """ + + def __init__( + self, + channel_dim: str | int | None = None, + affine_lps_to_ras: bool = True, + swap_ij: bool = True, + prune_metadata: bool = True, + label_dict: dict | None = None, + fname_regex: str = "", + to_gpu: bool = False, + **kwargs, + ): + super().__init__( + channel_dim=channel_dim, + affine_lps_to_ras=affine_lps_to_ras, + swap_ij=swap_ij, + prune_metadata=prune_metadata, + label_dict=label_dict, + fname_regex=fname_regex, + to_gpu=to_gpu, + **kwargs, + ) + from monai.data.nvimgcodec_pydicom_plugin import is_nvimgcodec_available, register_as_decoder_plugin + + self._nvimgcodec_available = is_nvimgcodec_available() + if not register_as_decoder_plugin(): + warnings.warn( + "NvImgCodecPydicomReader: nvImageCodec decoder plugin did not register successfully. " + "Falling back to default pydicom decoders." + ) + + def verify_suffix(self, filename: Sequence[PathLike] | PathLike) -> bool: + """ + Verify whether the specified file or files are DICOM and nvImageCodec is available. + """ + if not has_pydicom or not self._nvimgcodec_available: + return False + return is_dicom_path(filename) + + @require_pkg(pkg_name="nibabel") class NibabelReader(ImageReader): """ diff --git a/monai/data/nvimgcodec_pydicom_plugin.py b/monai/data/nvimgcodec_pydicom_plugin.py new file mode 100644 index 0000000000..9968b20ed4 --- /dev/null +++ b/monai/data/nvimgcodec_pydicom_plugin.py @@ -0,0 +1,81 @@ +# Copyright (c) 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. + +""" +MONAI integration helpers for the nvImageCodec pydicom decoder plugin. + +The decoder implementation lives in ``nvidia.nvimgcodec.tools.dicom.pydicom_plugin`` +(shipped with ``nvidia-nvimgcodec-cuXX``). This module provides MONAI-facing helpers +and stable aliases for registration and availability checks. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from monai.utils import optional_import + +cp, has_cp = optional_import("cupy") +pydicom_plugin, has_pydicom_plugin = optional_import("nvidia.nvimgcodec.tools.dicom.pydicom_plugin") + +_logger = logging.getLogger(__name__) + +if has_pydicom_plugin: + DECODER_DEPENDENCIES = pydicom_plugin.DECODER_DEPENDENCIES + NVIMGCODEC_MIN_VERSION = pydicom_plugin.NVIMGCODEC_MIN_VERSION + NVIMGCODEC_MIN_VERSION_TUPLE = pydicom_plugin.NVIMGCODEC_MIN_VERSION_TUPLE + NVIMGCODEC_PLUGIN_LABEL = pydicom_plugin.NVIMGCODEC_PLUGIN_LABEL + SUPPORTED_DECODER_CLASSES = pydicom_plugin.SUPPORTED_DECODER_CLASSES + SUPPORTED_TRANSFER_SYNTAXES = pydicom_plugin.SUPPORTED_TRANSFER_SYNTAXES + is_available = pydicom_plugin.is_available +else: # pragma: no cover - optional dependency not installed + DECODER_DEPENDENCIES = {} + NVIMGCODEC_MIN_VERSION = "0.8.0" + NVIMGCODEC_MIN_VERSION_TUPLE = (0, 8, 0) + NVIMGCODEC_PLUGIN_LABEL = "0.8.0+nvimgcodec" + SUPPORTED_DECODER_CLASSES = [] + SUPPORTED_TRANSFER_SYNTAXES = [] + + def is_available(uid) -> bool: # type: ignore[no-redef] + return False + + +def is_nvimgcodec_available() -> bool: + """Return ``True`` if nvImageCodec with CUDA support is available.""" + if not has_pydicom_plugin or getattr(pydicom_plugin, "nvimgcodec", None) is None or not has_cp: + _logger.debug("nvimgcodec pydicom plugin, nvimgcodec module, 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 + + +def register_as_decoder_plugin(module_path: Optional[str] = None) -> bool: + """Register the nvImageCodec pydicom decoder plugin.""" + if not is_nvimgcodec_available(): + _logger.warning("nvImageCodec is not available; skipping pydicom decoder plugin registration.") + return False + if not has_pydicom_plugin: + return False + return pydicom_plugin.register(module_path) + + +def unregister_as_decoder_plugin() -> bool: + """Unregister the nvImageCodec pydicom decoder plugin.""" + if not has_pydicom_plugin: + return False + return pydicom_plugin.unregister() diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index aadd96763d..4232e62e1d 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -36,8 +36,10 @@ NibabelReader, NrrdReader, NumpyReader, + NvImgCodecPydicomReader, PILReader, PydicomReader, + get_default_reader_registration_order, ) from monai.data.meta_tensor import MetaTensor from monai.data.utils import is_no_channel @@ -63,6 +65,7 @@ SUPPORTED_READERS = { "pydicomreader": PydicomReader, + "nvimgcodecpydicomreader": NvImgCodecPydicomReader, "itkreader": ITKReader, "nrrdreader": NrrdReader, "numpyreader": NumpyReader, @@ -116,7 +119,9 @@ class LoadImage(Transform): - User-specified reader in the constructor of `LoadImage`. - Readers from the last to the first in the registered list. - Current default readers: (nii, nii.gz -> NibabelReader), (png, jpg, bmp -> PILReader), - (npz, npy -> NumpyReader), (nrrd -> NrrdReader), (DICOM file -> ITKReader). + (npz, npy -> NumpyReader), (nrrd -> NrrdReader), (DICOM file -> ITKReader by default). + - The default DICOM reader can be changed with the ``MONAI_DICOM_READER`` environment variable. + Supported values are ``itk`` (default), ``pydicom``, and ``nvimgcodec`` (GPU-accelerated decoding). Please note that for png, jpg, bmp, and other 2D formats, readers by default swap axis 0 and 1 after loading the array with ``reverse_indexing`` set to ``True`` because the spatial axes definition @@ -185,7 +190,7 @@ def __init__( self.expanduser = expanduser self.readers: list[ImageReader] = [] - for r in SUPPORTED_READERS: # set predefined readers as default + for r in get_default_reader_registration_order(): # set predefined readers as default try: self.register(SUPPORTED_READERS[r](*args, **kwargs)) except OptionalImportError: diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 4927450c7d..228ace869f 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -52,7 +52,9 @@ class LoadImaged(MapTransform): - User-specified reader in the constructor of `LoadImage`. - Readers from the last to the first in the registered list. - Current default readers: (nii, nii.gz -> NibabelReader), (png, jpg, bmp -> PILReader), - (npz, npy -> NumpyReader), (dcm, DICOM series and others -> ITKReader). + (npz, npy -> NumpyReader), (dcm, DICOM series and others -> ITKReader by default). + - The default DICOM reader can be changed with the ``MONAI_DICOM_READER`` environment variable. + Supported values are ``itk`` (default), ``pydicom``, and ``nvimgcodec`` (GPU-accelerated decoding). Please note that for png, jpg, bmp, and other 2D formats, readers by default swap axis 0 and 1 after loading the array with ``reverse_indexing`` set to ``True`` because the spatial axes definition diff --git a/monai/utils/misc.py b/monai/utils/misc.py index ed48d4b37d..e5b31cc574 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -574,6 +574,14 @@ def allow_pickle() -> bool: """ return str2bool(os.environ.get("MONAI_ALLOW_PICKLE", "0")) + @staticmethod + def dicom_reader() -> str: + """Preferred DICOM reader for :py:class:`monai.transforms.LoadImage`. + + Supported values: ``itk`` (default), ``pydicom``, ``nvimgcodec``. + """ + return os.environ.get("MONAI_DICOM_READER", "itk").lower() + class ImageMetaKey: """ diff --git a/pyproject.toml b/pyproject.toml index 325622b66a..a0e38e7c6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,9 @@ extend-ignore = [ [tool.ruff.lint.mccabe] max-complexity = 50 # todo lower this treshold when yesqa id replaced with Ruff's RUF100 +[tool.pytest.ini_options] +pythonpath = ["."] + [tool.pytype] # Space-separated list of files or directories to exclude. exclude = ["versioneer.py", "_version.py"] diff --git a/requirements-dev.txt b/requirements-dev.txt index b2c36f8de6..bbfd40452c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,6 @@ -# Full requirements for developments +# Full requirements for development. +# Install with: python -m pip install -U pip wheel && python -m pip install --no-build-isolation -r requirements-dev.txt +# (--no-build-isolation is required for MetricsReloaded; git+https URLs are avoided for public GitHub deps.) -r requirements-min.txt pytorch-ignite gdown>=4.7.3 @@ -49,7 +51,8 @@ pydicom h5py nni==2.10.1; platform_system == "Linux" and "arm" not in platform_machine and "aarch" not in platform_machine optuna -git+https://github.com/Project-MONAI/MetricsReloaded@monai-support#egg=MetricsReloaded +# Use GitHub archive URLs instead of git+https to avoid git credential helper issues on public repos. +MetricsReloaded @ https://github.com/Project-MONAI/MetricsReloaded/archive/refs/heads/monai-support.zip onnx>=1.13.0 onnxscript onnxruntime @@ -60,7 +63,9 @@ lpips==0.1.4 nvidia-ml-py huggingface_hub pyamg>=5.0.0, <5.3.0 -git+https://github.com/facebookresearch/segment-anything.git@6fdee8f2727f4506cfbbe553e23b895e27956588 +segment_anything @ https://github.com/facebookresearch/segment-anything/archive/6fdee8f2727f4506cfbbe553e23b895e27956588.zip onnx_graphsurgeon polygraphy pytest # FIXME: added to get around cupy 14.1.0 creating the requirement through polygraphy and trt_compiler somehow +cupy-cuda13x +nvidia-nvimgcodec-cu13[all]>=0.8.0 diff --git a/tests/data/test_init_reader.py b/tests/data/test_init_reader.py index 169fd20a5f..8ccd16b089 100644 --- a/tests/data/test_init_reader.py +++ b/tests/data/test_init_reader.py @@ -17,7 +17,7 @@ import numpy as np -from monai.data import ITKReader, NibabelReader, NrrdReader, NumpyReader, PILReader, PydicomReader +from monai.data import ITKReader, NibabelReader, NrrdReader, NumpyReader, NvImgCodecPydicomReader, PILReader, PydicomReader from monai.transforms import LoadImage, LoadImaged from tests.test_utils import SkipIfNoModule @@ -29,7 +29,7 @@ def test_load_image(self): self.assertIsInstance(instance1, LoadImage) self.assertIsInstance(instance2, LoadImage) - for r in ["NibabelReader", "PILReader", "ITKReader", "NumpyReader", "NrrdReader", "PydicomReader", None]: + for r in ["NibabelReader", "PILReader", "ITKReader", "NumpyReader", "NrrdReader", "PydicomReader", "NvImgCodecPydicomReader", None]: inst = LoadImaged("image", reader=r) self.assertIsInstance(inst, LoadImaged) @@ -61,6 +61,9 @@ def test_readers(self): inst = PydicomReader() self.assertIsInstance(inst, PydicomReader) + inst = NvImgCodecPydicomReader() + self.assertIsInstance(inst, NvImgCodecPydicomReader) + inst = NumpyReader() self.assertIsInstance(inst, NumpyReader) inst = NumpyReader(npz_keys="test") diff --git a/tests/data/test_nvimgcodec_pydicom_reader.py b/tests/data/test_nvimgcodec_pydicom_reader.py new file mode 100644 index 0000000000..ec98a145a7 --- /dev/null +++ b/tests/data/test_nvimgcodec_pydicom_reader.py @@ -0,0 +1,164 @@ +# Copyright (c) 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. + +from __future__ import annotations + +import os +import unittest +from unittest.mock import patch + +from monai.data.image_reader import ( + DICOM_READER_ENV_MAP, + get_default_reader_registration_order, + get_preferred_dicom_reader_key, + is_dicom_path, +) +from monai.transforms import LoadImage +from tests.test_utils import SkipIfNoModule + + +class TestNvImgCodecPydicomPlugin(unittest.TestCase): + @SkipIfNoModule("pydicom") + def test_is_dicom_path(self): + self.assertTrue(is_dicom_path("tests/testing_data/CT_DICOM")) + self.assertFalse(is_dicom_path("tests/testing_data/test_image.nii.gz")) + + def test_get_preferred_dicom_reader_key_default(self): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("MONAI_DICOM_READER", None) + self.assertEqual(get_preferred_dicom_reader_key(), "itkreader") + + def test_get_preferred_dicom_reader_key_env(self): + with patch.dict(os.environ, {"MONAI_DICOM_READER": "nvimgcodec"}): + self.assertEqual(get_preferred_dicom_reader_key(), "nvimgcodecpydicomreader") + with patch.dict(os.environ, {"MONAI_DICOM_READER": "pydicom"}): + self.assertEqual(get_preferred_dicom_reader_key(), "pydicomreader") + + def test_get_preferred_dicom_reader_key_invalid(self): + with patch.dict(os.environ, {"MONAI_DICOM_READER": "unknown"}): + self.assertEqual(get_preferred_dicom_reader_key(), "itkreader") + + def test_get_default_reader_registration_order(self): + with patch.dict(os.environ, {"MONAI_DICOM_READER": "pydicom"}): + order = get_default_reader_registration_order() + self.assertEqual(order[-1], "pydicomreader") + self.assertNotIn("itkreader", order) + self.assertNotIn("nvimgcodecpydicomreader", order) + + def test_dicom_reader_env_map_values(self): + self.assertEqual(set(DICOM_READER_ENV_MAP.keys()), {"itk", "pydicom", "nvimgcodec"}) + + +class TestNvImgCodecPydicomReader(unittest.TestCase): + @SkipIfNoModule("pydicom") + @patch("monai.data.nvimgcodec_pydicom_plugin.register_as_decoder_plugin", return_value=True) + @patch("monai.data.nvimgcodec_pydicom_plugin.is_nvimgcodec_available", return_value=True) + def test_reader_init_registers_plugin(self, _mock_available, mock_register): + from monai.data import NvImgCodecPydicomReader + + reader = NvImgCodecPydicomReader() + self.assertIsInstance(reader, NvImgCodecPydicomReader) + mock_register.assert_called_once() + + @SkipIfNoModule("pydicom") + @patch("monai.data.nvimgcodec_pydicom_plugin.is_nvimgcodec_available", return_value=False) + def test_verify_suffix_without_nvimgcodec(self, _mock_available): + from monai.data import NvImgCodecPydicomReader + + reader = NvImgCodecPydicomReader() + self.assertFalse(reader.verify_suffix("tests/testing_data/CT_DICOM")) + + @SkipIfNoModule("pydicom") + @patch("monai.data.nvimgcodec_pydicom_plugin.is_nvimgcodec_available", return_value=True) + def test_verify_suffix_with_nvimgcodec(self, _mock_available): + from monai.data import NvImgCodecPydicomReader + + reader = NvImgCodecPydicomReader() + self.assertTrue(reader.verify_suffix("tests/testing_data/CT_DICOM")) + self.assertFalse(reader.verify_suffix("tests/testing_data/test_image.nii.gz")) + + +class TestLoadImageDicomReaderEnv(unittest.TestCase): + @SkipIfNoModule("pydicom") + def test_load_image_respects_dicom_reader_env(self): + with patch.dict(os.environ, {"MONAI_DICOM_READER": "pydicom"}): + loader = LoadImage(image_only=True) + reader_types = [type(r).__name__ for r in loader.readers] + self.assertEqual(reader_types[-1], "PydicomReader") + self.assertNotIn("ITKReader", reader_types) + + @SkipIfNoModule("pydicom") + @patch("monai.data.nvimgcodec_pydicom_plugin.is_nvimgcodec_available", return_value=True) + @patch("monai.data.nvimgcodec_pydicom_plugin.register_as_decoder_plugin", return_value=True) + def test_load_image_nvimgcodec_env(self, _mock_register, _mock_available): + with patch.dict(os.environ, {"MONAI_DICOM_READER": "nvimgcodec"}): + loader = LoadImage(image_only=True) + reader_types = [type(r).__name__ for r in loader.readers] + self.assertEqual(reader_types[-1], "NvImgCodecPydicomReader") + + +class TestNvImgCodecPluginRegistration(unittest.TestCase): + @SkipIfNoModule("pydicom") + @SkipIfNoModule("nvidia.nvimgcodec.tools.dicom.pydicom_plugin") + @patch("monai.data.nvimgcodec_pydicom_plugin.is_nvimgcodec_available", return_value=True) + def test_register_as_decoder_plugin(self, _mock_available): + from pydicom.pixels.decoders import JPEGBaseline8BitDecoder + + from monai.data.nvimgcodec_pydicom_plugin import ( + NVIMGCODEC_PLUGIN_LABEL, + register_as_decoder_plugin, + unregister_as_decoder_plugin, + ) + + self.assertTrue(register_as_decoder_plugin()) + self.assertIn(NVIMGCODEC_PLUGIN_LABEL, JPEGBaseline8BitDecoder.available_plugins) + self.assertTrue(unregister_as_decoder_plugin()) + self.assertNotIn(NVIMGCODEC_PLUGIN_LABEL, JPEGBaseline8BitDecoder.available_plugins) + + @SkipIfNoModule("pydicom") + @patch("monai.data.nvimgcodec_pydicom_plugin.is_nvimgcodec_available", return_value=False) + def test_register_without_nvimgcodec(self, _mock_available): + from monai.data.nvimgcodec_pydicom_plugin import register_as_decoder_plugin + + self.assertFalse(register_as_decoder_plugin()) + + @SkipIfNoModule("pydicom") + @SkipIfNoModule("nvidia.nvimgcodec.tools.dicom.pydicom_plugin") + def test_is_nvimgcodec_available_with_cuda(self): + from monai.data.nvimgcodec_pydicom_plugin import is_nvimgcodec_available + + # When CUDA and nvimgcodec are present this should be True; otherwise skip-like behavior. + if is_nvimgcodec_available(): + from monai.data.nvimgcodec_pydicom_plugin import SUPPORTED_TRANSFER_SYNTAXES, is_available + + self.assertTrue(is_available(SUPPORTED_TRANSFER_SYNTAXES[0])) + + +class TestNvImgCodecPydicomReaderIntegration(unittest.TestCase): + @SkipIfNoModule("pydicom") + def test_load_dicom_with_pydicom_env(self): + with patch.dict(os.environ, {"MONAI_DICOM_READER": "pydicom"}): + result = LoadImage(image_only=True)("tests/testing_data/CT_DICOM") + self.assertEqual(tuple(result.shape), (16, 16, 4)) + + @SkipIfNoModule("pydicom") + @patch("monai.data.nvimgcodec_pydicom_plugin.register_as_decoder_plugin", return_value=False) + @patch("monai.data.nvimgcodec_pydicom_plugin.is_nvimgcodec_available", return_value=False) + def test_load_dicom_with_nvimgcodec_reader_fallback(self, _mock_available, _mock_register): + from monai.data import NvImgCodecPydicomReader + + reader = NvImgCodecPydicomReader() + result = LoadImage(image_only=True, reader=reader)("tests/testing_data/CT_DICOM") + self.assertEqual(tuple(result.shape), (16, 16, 4)) + + +if __name__ == "__main__": + unittest.main() From fb8fed24e68e0d8f25bdfb0f63c62071fa97732c Mon Sep 17 00:00:00 2001 From: M Q Date: Fri, 26 Jun 2026 12:16:02 -0700 Subject: [PATCH 2/7] Clarify why GPU direct loading not compatible with accelerated decompression Signed-off-by: M Q --- docs/source/data.rst | 4 +++ monai/data/image_reader.py | 26 ++++++++++++++------ tests/data/test_nvimgcodec_pydicom_reader.py | 14 +++++++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/docs/source/data.rst b/docs/source/data.rst index 034ec36004..6e2b55b994 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -166,6 +166,10 @@ PydicomReader NvImgCodecPydicomReader ~~~~~~~~~~~~~~~~~~~~~~~ +GPU-accelerated DICOM reader built on :py:class:`PydicomReader` and the nvImageCodec pydicom decoder plugin. +The ``to_gpu`` init argument is accepted for API compatibility but is always ignored so that GPU decompression +is not bypassed by GPU direct loading. + .. autoclass:: NvImgCodecPydicomReader :members: diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index d08c055696..c829bf032d 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -1081,11 +1081,17 @@ class NvImgCodecPydicomReader(PydicomReader): in CUDA 13.2.0+. Note: - Enabling GPU direct loading disables GPU decompression as this bypasses any Pydicom pixel data interpretation. - In fact, the current implementation of GPU direct loading is error-prone as it simply loads the raw bytes of - the pixel data into GPU memory without any required processing, e.g. applying rescale slope and intercept, - `PhotometricInterpretation`, etc., let alone processing compressed pixel data. As such, the resulting - data array will not represent the original pixel data. + GPU direct loading bypasses Pydicom pixel data interpretation mechanism hence disables GPU decompression + via Pydicom decoder plugin that is used by this reader. So, GPU direct loading (``to_gpu=True``) + cannot be supported by this reader. The ``to_gpu`` init argument is accepted for API compatibility + with :py:class:`PydicomReader` but is always ignored so that GPU-accelerated decompression via nvImageCodec + is not bypassed. + + Also noted is that the current implementation of GPU direct loading has a serious flaw as it simply loads + the raw bytes of pixel data into GPU memory and parses them into integers without any required processing, + e.g. applying rescale slope and intercept, `PhotometricInterpretation`, etc., and not processing compressed + pixel data. As such, the resulting data array will not represent the original pixel data faithfully except for + the simplest case of uncompressed pixel data. Set environment variable ``MONAI_DICOM_READER=nvimgcodec`` to use this reader by default with :py:class:`monai.transforms.LoadImage` without explicit configuration. @@ -1106,8 +1112,7 @@ class NvImgCodecPydicomReader(PydicomReader): prune_metadata: whether to prune the saved information in metadata. Default to ``True``. label_dict: label of the dicom data for segmentation loading. fname_regex: a regular expression to match file names when the input is a folder. - to_gpu: If True, load the image into GPU memory using CuPy and Kvikio. This disables GPU decompression and - in fact also bypasses any Pydicom pixel data interpretation. + to_gpu: accepted for API compatibility with :py:class:`PydicomReader` but always ignored (always ``False``). kwargs: additional args for `pydicom.dcmread` API. """ @@ -1122,6 +1127,11 @@ def __init__( to_gpu: bool = False, **kwargs, ): + if to_gpu: + warnings.warn( + "NvImgCodecPydicomReader ignores to_gpu=True; GPU direct loading is disabled to preserve " + "GPU-accelerated decompression." + ) super().__init__( channel_dim=channel_dim, affine_lps_to_ras=affine_lps_to_ras, @@ -1129,7 +1139,7 @@ def __init__( prune_metadata=prune_metadata, label_dict=label_dict, fname_regex=fname_regex, - to_gpu=to_gpu, + to_gpu=False, **kwargs, ) from monai.data.nvimgcodec_pydicom_plugin import is_nvimgcodec_available, register_as_decoder_plugin diff --git a/tests/data/test_nvimgcodec_pydicom_reader.py b/tests/data/test_nvimgcodec_pydicom_reader.py index ec98a145a7..c3aad28b5c 100644 --- a/tests/data/test_nvimgcodec_pydicom_reader.py +++ b/tests/data/test_nvimgcodec_pydicom_reader.py @@ -85,6 +85,20 @@ def test_verify_suffix_with_nvimgcodec(self, _mock_available): self.assertTrue(reader.verify_suffix("tests/testing_data/CT_DICOM")) self.assertFalse(reader.verify_suffix("tests/testing_data/test_image.nii.gz")) + @SkipIfNoModule("pydicom") + @patch("monai.data.nvimgcodec_pydicom_plugin.register_as_decoder_plugin", return_value=True) + @patch("monai.data.nvimgcodec_pydicom_plugin.is_nvimgcodec_available", return_value=True) + def test_to_gpu_ignored(self, _mock_available, _mock_register): + from monai.data import NvImgCodecPydicomReader + + with self.assertWarns(UserWarning) as warning_ctx: + reader = NvImgCodecPydicomReader(to_gpu=True) + self.assertFalse(reader.to_gpu) + self.assertIn("ignores to_gpu=True", str(warning_ctx.warning)) + + reader = NvImgCodecPydicomReader(to_gpu=False) + self.assertFalse(reader.to_gpu) + class TestLoadImageDicomReaderEnv(unittest.TestCase): @SkipIfNoModule("pydicom") From e576dc54b6ea431838c3016b2d3199ee636dedf4 Mon Sep 17 00:00:00 2001 From: M Q Date: Fri, 26 Jun 2026 14:43:29 -0700 Subject: [PATCH 3/7] Fix formatting complaints Signed-off-by: M Q --- monai/data/image_reader.py | 6 +----- monai/data/nvimgcodec_pydicom_plugin.py | 2 +- tests/data/test_init_reader.py | 21 +++++++++++++++++++-- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index c829bf032d..4dc334e1fd 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -80,11 +80,7 @@ "is_dicom_path", ] -DICOM_READER_ENV_MAP = { - "itk": "itkreader", - "pydicom": "pydicomreader", - "nvimgcodec": "nvimgcodecpydicomreader", -} +DICOM_READER_ENV_MAP = {"itk": "itkreader", "pydicom": "pydicomreader", "nvimgcodec": "nvimgcodecpydicomreader"} NON_DICOM_READERS = ["nrrdreader", "numpyreader", "pilreader", "nibabelreader"] diff --git a/monai/data/nvimgcodec_pydicom_plugin.py b/monai/data/nvimgcodec_pydicom_plugin.py index 9968b20ed4..b0d7e5b8ea 100644 --- a/monai/data/nvimgcodec_pydicom_plugin.py +++ b/monai/data/nvimgcodec_pydicom_plugin.py @@ -64,7 +64,7 @@ def is_nvimgcodec_available() -> bool: return True -def register_as_decoder_plugin(module_path: Optional[str] = None) -> bool: +def register_as_decoder_plugin(module_path: str | None = None) -> bool: """Register the nvImageCodec pydicom decoder plugin.""" if not is_nvimgcodec_available(): _logger.warning("nvImageCodec is not available; skipping pydicom decoder plugin registration.") diff --git a/tests/data/test_init_reader.py b/tests/data/test_init_reader.py index 8ccd16b089..aecb7c980c 100644 --- a/tests/data/test_init_reader.py +++ b/tests/data/test_init_reader.py @@ -17,7 +17,15 @@ import numpy as np -from monai.data import ITKReader, NibabelReader, NrrdReader, NumpyReader, NvImgCodecPydicomReader, PILReader, PydicomReader +from monai.data import ( + ITKReader, + NibabelReader, + NrrdReader, + NumpyReader, + NvImgCodecPydicomReader, + PILReader, + PydicomReader, +) from monai.transforms import LoadImage, LoadImaged from tests.test_utils import SkipIfNoModule @@ -29,7 +37,16 @@ def test_load_image(self): self.assertIsInstance(instance1, LoadImage) self.assertIsInstance(instance2, LoadImage) - for r in ["NibabelReader", "PILReader", "ITKReader", "NumpyReader", "NrrdReader", "PydicomReader", "NvImgCodecPydicomReader", None]: + for r in [ + "NibabelReader", + "PILReader", + "ITKReader", + "NumpyReader", + "NrrdReader", + "PydicomReader", + "NvImgCodecPydicomReader", + None, + ]: inst = LoadImaged("image", reader=r) self.assertIsInstance(inst, LoadImaged) From 9d040af4197d085145813a31d439991c2c254a14 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:37:35 +0000 Subject: [PATCH 4/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- monai/data/nvimgcodec_pydicom_plugin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/monai/data/nvimgcodec_pydicom_plugin.py b/monai/data/nvimgcodec_pydicom_plugin.py index b0d7e5b8ea..acdebab65c 100644 --- a/monai/data/nvimgcodec_pydicom_plugin.py +++ b/monai/data/nvimgcodec_pydicom_plugin.py @@ -20,7 +20,6 @@ from __future__ import annotations import logging -from typing import Optional from monai.utils import optional_import From 89b896a0b7e47b9b41645edd3a6c887da56202e5 Mon Sep 17 00:00:00 2001 From: M Q Date: Fri, 26 Jun 2026 19:09:20 -0700 Subject: [PATCH 5/7] Fix mypy complaints Signed-off-by: M Q --- monai/data/nvimgcodec_pydicom_plugin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monai/data/nvimgcodec_pydicom_plugin.py b/monai/data/nvimgcodec_pydicom_plugin.py index b0d7e5b8ea..1b3003bd04 100644 --- a/monai/data/nvimgcodec_pydicom_plugin.py +++ b/monai/data/nvimgcodec_pydicom_plugin.py @@ -71,11 +71,11 @@ def register_as_decoder_plugin(module_path: str | None = None) -> bool: return False if not has_pydicom_plugin: return False - return pydicom_plugin.register(module_path) + return bool(pydicom_plugin.register(module_path)) def unregister_as_decoder_plugin() -> bool: """Unregister the nvImageCodec pydicom decoder plugin.""" if not has_pydicom_plugin: return False - return pydicom_plugin.unregister() + return bool(pydicom_plugin.unregister()) From d805734dfe21c8b55923a72a7c55cb7b16441b4b Mon Sep 17 00:00:00 2001 From: M Q Date: Fri, 26 Jun 2026 19:53:22 -0700 Subject: [PATCH 6/7] Fixed and tested doc build warnings and nits Signed-off-by: M Q --- monai/data/image_reader.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 4dc334e1fd..5f864055de 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -1076,6 +1076,9 @@ class NvImgCodecPydicomReader(PydicomReader): strongly recommended because the dependency nvjpeg library has addressed a known issue with JPEGLossless decoding in CUDA 13.2.0+. + Set environment variable ``MONAI_DICOM_READER=nvimgcodec`` to use this reader by default + with :py:class:`monai.transforms.LoadImage` without explicit configuration. + Note: GPU direct loading bypasses Pydicom pixel data interpretation mechanism hence disables GPU decompression via Pydicom decoder plugin that is used by this reader. So, GPU direct loading (``to_gpu=True``) @@ -1089,15 +1092,10 @@ class NvImgCodecPydicomReader(PydicomReader): pixel data. As such, the resulting data array will not represent the original pixel data faithfully except for the simplest case of uncompressed pixel data. - Set environment variable ``MONAI_DICOM_READER=nvimgcodec`` to use this reader by default - with :py:class:`monai.transforms.LoadImage` without explicit configuration. - - Why NvImgCodecPydicomReader only has @require_pkg(pkg_name="pydicom") - That is intentional today: - pydicom is required to construct/use the reader at all. - nvimgcodec / CUDA / CuPy are checked later via is_nvimgcodec_available() in nvimgcodec_pydicom_plugin.py, - with a warning + fallback to normal pydicom decoders if missing. - That lets LoadImage register the reader without hard-failing when GPU deps aren't installed + This reader only declares ``@require_pkg(pkg_name="pydicom")`` so that :py:class:`monai.transforms.LoadImage` + can register it without hard-failing when GPU dependencies are missing. ``pydicom`` is required to construct + the reader; nvimgcodec, CUDA, and CuPy availability is checked at runtime with a warning issued and fallback to + default pydicom decoders if missing. Args: channel_dim: the channel dimension of the input image, default is None. @@ -1364,7 +1362,7 @@ def _get_array_data(self, img, filename): with kvikio.CuFile(filename, "r") as f: f.read(image) if filename.endswith(".nii.gz"): - # for compressed data, have to tansfer to CPU to decompress + # for compressed data, have to transfer to CPU to decompress # and then transfer back to GPU. It is not efficient compared to .nii file # and may be slower than CPU loading in some cases. warnings.warn("Loading compressed NIfTI file into GPU may not be efficient.") From 1781568ade989e6baae00ab6a00d017e1a6894c5 Mon Sep 17 00:00:00 2001 From: M Q Date: Fri, 26 Jun 2026 22:16:14 -0700 Subject: [PATCH 7/7] =?UTF-8?q?Fix=20test=20failures=20after=20this=20bran?= =?UTF-8?q?ch=E2=80=99s=20reader=20registration=20change,?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit though the underlying bug is in LoadImage’s auto-select path. Root cause test_nibabel_reader_5 is TEST_CASE_4_1 with {"mmap": False} — not the NibabelReader(mmap=False) case. What happens: 1. mmap=False is passed to all default readers, including ITKReader. 2. Your branch registers the DICOM reader last, so it is tried first in reverse auto-select order. 3. ITKReader.verify_suffix() returns True whenever ITK is installed (any file type). 4. LoadImage calls itk.imread(..., mmap=False), which ITK does not support → TypeError. 5. The auto-select path had no try/except, so it never fell back to NibabelReader. On dev branch, ITK was tried later (dict order), so Nibabel usually won first. Fix Unified reader selection in LoadImage.__call__ so auto-select also catches read failures and tries the next reader (same as the explicit-reader path) Verification - test_nibabel_reader_5 — passed - All 7 test_nibabel_reader cases — passed Signed-off-by: M Q --- monai/transforms/io/array.py | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 4232e62e1d..c9575dbbe8 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -263,22 +263,21 @@ def __call__(self, filename: Sequence[PathLike] | PathLike, reader: ImageReader img = reader.read(filename) # runtime specified reader else: for reader in self.readers[::-1]: - if self.auto_select: # rely on the filename extension to choose the reader - if reader.verify_suffix(filename): - img = reader.read(filename) - break - else: # try the user designated readers - try: - img = reader.read(filename) - except Exception as e: - err.append(traceback.format_exc()) - logging.getLogger(self.__class__.__name__).debug(e, exc_info=True) - logging.getLogger(self.__class__.__name__).info( - f"{reader.__class__.__name__}: unable to load {filename}.\n" - ) - else: - err = [] - break + # Unified reader selection so auto-select also catches read failures and tries the next reader + # (same as the explicit-reader path) + if self.auto_select and not reader.verify_suffix(filename): + continue + try: + img = reader.read(filename) + except Exception as e: + err.append(traceback.format_exc()) + logging.getLogger(self.__class__.__name__).debug(e, exc_info=True) + logging.getLogger(self.__class__.__name__).info( + f"{reader.__class__.__name__}: unable to load {filename}.\n" + ) + else: + err = [] + break if img is None or reader is None: if isinstance(filename, Sequence) and len(filename) == 1: