From f7a384e42741479828196d92c864152f367959f0 Mon Sep 17 00:00:00 2001 From: M Q Date: Fri, 26 Jun 2026 12:13:39 -0700 Subject: [PATCH 01/13] 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 2ad171abb1b..56e2778a65f 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 63d5e0e23d5..034ec360044 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 ef04160425a..562289edcbc 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 6859dca62f4..7142292532c 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 00000000000..9968b20ed4b --- /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 aadd96763d9..4232e62e1d9 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 4927450c7d0..228ace869f5 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 ed48d4b37d7..e5b31cc574b 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 325622b66a7..a0e38e7c6f0 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 b2c36f8de62..bbfd40452c9 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 169fd20a5fc..8ccd16b0895 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 00000000000..ec98a145a7b --- /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 9c4d813daee613d33cac6bbbaa2f2a32872e93dd Mon Sep 17 00:00:00 2001 From: M Q Date: Fri, 26 Jun 2026 12:16:02 -0700 Subject: [PATCH 02/13] 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 034ec360044..6e2b55b994c 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 7142292532c..844a3d4c835 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 ec98a145a7b..c3aad28b5c2 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 7eb485e488f26935eb8dd7c26d55cc2413745b10 Mon Sep 17 00:00:00 2001 From: M Q Date: Fri, 26 Jun 2026 14:43:29 -0700 Subject: [PATCH 03/13] 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 844a3d4c835..0bac1994ced 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 9968b20ed4b..b0d7e5b8ea9 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 8ccd16b0895..aecb7c980c4 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 1aebb734641b791456dd31987e9b5abbdb0e15f7 Mon Sep 17 00:00:00 2001 From: M Q Date: Fri, 26 Jun 2026 19:09:20 -0700 Subject: [PATCH 04/13] 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 b0d7e5b8ea9..1b3003bd040 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 77ac541f55b69b5ae0411c737ccd78f92f6a05aa Mon Sep 17 00:00:00 2001 From: M Q Date: Fri, 26 Jun 2026 19:53:22 -0700 Subject: [PATCH 05/13] 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 0bac1994ced..f845b52f474 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 423574fbf22b8bd6a3987f9d39fc076b6786ca7c 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 06/13] [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 1b3003bd040..573dc715d4d 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 6fbac5ae0a7c268c6c4eb2fb61e6681fe8d39e65 Mon Sep 17 00:00:00 2001 From: M Q Date: Fri, 26 Jun 2026 22:16:14 -0700 Subject: [PATCH 07/13] =?UTF-8?q?Fix=20test=20failures=20after=20this=20br?= =?UTF-8?q?anch=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 4232e62e1d9..c9575dbbe80 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: From 7fbef1bb2e1efeaa884eb4c6e73a23a1836417d6 Mon Sep 17 00:00:00 2001 From: M Q Date: Wed, 1 Jul 2026 12:56:42 -0700 Subject: [PATCH 08/13] Add explicit failure mode test. Signed-off-by: M Q --- tests/data/test_nvimgcodec_pydicom_reader.py | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/data/test_nvimgcodec_pydicom_reader.py b/tests/data/test_nvimgcodec_pydicom_reader.py index c3aad28b5c2..60cbcfa9e27 100644 --- a/tests/data/test_nvimgcodec_pydicom_reader.py +++ b/tests/data/test_nvimgcodec_pydicom_reader.py @@ -53,6 +53,13 @@ def test_get_default_reader_registration_order(self): self.assertNotIn("itkreader", order) self.assertNotIn("nvimgcodecpydicomreader", order) + def test_get_default_reader_registration_order_nvimgcodec_env(self): + with patch.dict(os.environ, {"MONAI_DICOM_READER": "nvimgcodec"}): + order = get_default_reader_registration_order() + self.assertEqual(order[-1], "nvimgcodecpydicomreader") + self.assertNotIn("pydicomreader", order) + self.assertNotIn("itkreader", order) + def test_dicom_reader_env_map_values(self): self.assertEqual(set(DICOM_READER_ENV_MAP.keys()), {"itk", "pydicom", "nvimgcodec"}) @@ -118,6 +125,28 @@ def test_load_image_nvimgcodec_env(self, _mock_register, _mock_available): reader_types = [type(r).__name__ for r in loader.readers] self.assertEqual(reader_types[-1], "NvImgCodecPydicomReader") + @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_image_nvimgcodec_env_unavailable_auto_select(self, _mock_available, _mock_register): + """When nvimgcodec is unavailable, verify_suffix blocks auto-selection and no DICOM reader remains.""" + with patch.dict(os.environ, {"MONAI_DICOM_READER": "nvimgcodec"}): + order = get_default_reader_registration_order() + self.assertEqual(order[-1], "nvimgcodecpydicomreader") + + loader = LoadImage(image_only=True) + reader_types = [type(r).__name__ for r in loader.readers] + self.assertEqual(reader_types[-1], "NvImgCodecPydicomReader") + self.assertNotIn("ITKReader", reader_types) + self.assertNotIn("PydicomReader", reader_types) + + nv_reader = loader.readers[-1] + self.assertFalse(nv_reader.verify_suffix("tests/testing_data/CT_DICOM")) + + with self.assertRaises(RuntimeError) as err_ctx: + loader("tests/testing_data/CT_DICOM") + self.assertIn("cannot find a suitable reader", str(err_ctx.exception)) + class TestNvImgCodecPluginRegistration(unittest.TestCase): @SkipIfNoModule("pydicom") From edca43faf1d0948c59eb37cc56a14c7eb3957846 Mon Sep 17 00:00:00 2001 From: M Q Date: Sat, 25 Jul 2026 23:31:45 -0700 Subject: [PATCH 09/13] Addressed review comments, e.g. intent of SUPPORTED_READERS and fallback Signed-off-by: M Q --- monai/data/image_reader.py | 30 ++++----- monai/transforms/io/array.py | 62 ++++++++++++------ monai/transforms/io/dictionary.py | 6 +- monai/utils/misc.py | 5 +- requirements-dev.txt | 4 +- tests/data/test_nvimgcodec_pydicom_reader.py | 69 ++++++++++++++++---- 6 files changed, 116 insertions(+), 60 deletions(-) diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index f845b52f474..d65e0176598 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -74,16 +74,13 @@ "NvImgCodecPydicomReader", "NrrdReader", "DICOM_READER_ENV_MAP", - "NON_DICOM_READERS", "get_preferred_dicom_reader_key", - "get_default_reader_registration_order", "is_dicom_path", ] +# Maps ``MONAI_DICOM_READER`` env values to keys in :py:data:`monai.transforms.io.array.SUPPORTED_READERS`. DICOM_READER_ENV_MAP = {"itk": "itkreader", "pydicom": "pydicomreader", "nvimgcodec": "nvimgcodecpydicomreader"} -NON_DICOM_READERS = ["nrrdreader", "numpyreader", "pilreader", "nibabelreader"] - class ImageReader(ABC): """ @@ -1037,28 +1034,23 @@ def is_dicom_path(filename: Sequence[PathLike] | PathLike) -> bool: def get_preferred_dicom_reader_key() -> str: """ - Return the :py:class:`LoadImage` registration key for the preferred DICOM reader. + Return the :py:class:`~monai.transforms.LoadImage` registration key for the preferred DICOM reader. Controlled by the ``MONAI_DICOM_READER`` environment variable. Supported values are - ``itk`` (default), ``pydicom``, and ``nvimgcodec``. + ``itk``, ``pydicom``, and ``nvimgcodec``. Returns an empty string when the variable is + unset or set to an unsupported value (in which case :py:data:`~monai.transforms.io.array.SUPPORTED_READERS` + dict order is used unchanged). """ - pref = os.environ.get("MONAI_DICOM_READER", "itk").lower() + pref = os.environ.get("MONAI_DICOM_READER") + if pref is None: + return "" + pref = pref.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"] + warnings.warn(f"Unknown MONAI_DICOM_READER='{pref}', ignoring preference.") + return "" 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): """ diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index c9575dbbe80..005ca4ef954 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -39,7 +39,7 @@ NvImgCodecPydicomReader, PILReader, PydicomReader, - get_default_reader_registration_order, + get_preferred_dicom_reader_key, ) from monai.data.meta_tensor import MetaTensor from monai.data.utils import is_no_channel @@ -61,8 +61,12 @@ nrrd, _ = optional_import("nrrd") FileLock, has_filelock = optional_import("filelock", name="FileLock") -__all__ = ["LoadImage", "SaveImage", "SUPPORTED_READERS"] +__all__ = ["LoadImage", "SaveImage", "SUPPORTED_READERS", "get_default_reader_registration_order"] +# Default readers for :py:class:`LoadImage`. Dict insertion order is the registration order +# (auto-select tries registered readers from last to first). Users may add custom readers here. +# DICOM readers are listed first so that, by default, ``itkreader`` is tried before the other +# DICOM readers; ``MONAI_DICOM_READER`` can promote a preferred DICOM reader to last (tried first). SUPPORTED_READERS = { "pydicomreader": PydicomReader, "nvimgcodecpydicomreader": NvImgCodecPydicomReader, @@ -74,6 +78,22 @@ } +def get_default_reader_registration_order() -> list[str]: + """ + Return the default reader registration order for :py:class:`LoadImage`. + + Uses :py:data:`SUPPORTED_READERS` insertion order so user-added entries are included. + If ``MONAI_DICOM_READER`` resolves to a non-empty preferred key present in + ``SUPPORTED_READERS``, that key is moved to the end of the list so auto-selection + tries it first. + """ + order = list(SUPPORTED_READERS) + preferred = get_preferred_dicom_reader_key() + if preferred and preferred in order: + order = [key for key in order if key != preferred] + [preferred] + return order + + def switch_endianness(data, new="<"): """ Convert the input `data` endianness to `new`. @@ -119,9 +139,10 @@ 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 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). + (npz, npy -> NumpyReader), (nrrd -> NrrdReader), + (DICOM file -> ITKReader first among DICOM readers by default). + - Optionally set ``MONAI_DICOM_READER`` to ``itk``, ``pydicom``, or ``nvimgcodec`` + (GPU-accelerated decoding) to try that DICOM reader first. 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 @@ -263,21 +284,22 @@ def __call__(self, filename: Sequence[PathLike] | PathLike, reader: ImageReader img = reader.read(filename) # runtime specified reader else: for reader in self.readers[::-1]: - # 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 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 if img is None or reader is None: if isinstance(filename, Sequence) and len(filename) == 1: diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 228ace869f5..645c8959048 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -52,9 +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 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). + (npz, npy -> NumpyReader), (dcm, DICOM series and others -> ITKReader first among DICOM readers). + - Optionally set ``MONAI_DICOM_READER`` to ``itk``, ``pydicom``, or ``nvimgcodec`` + (GPU-accelerated decoding) to try that DICOM reader first. 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 e5b31cc574b..9327a9fc88c 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -578,9 +578,10 @@ def allow_pickle() -> bool: def dicom_reader() -> str: """Preferred DICOM reader for :py:class:`monai.transforms.LoadImage`. - Supported values: ``itk`` (default), ``pydicom``, ``nvimgcodec``. + Supported values: ``itk``, ``pydicom``, ``nvimgcodec``. + Returns an empty string when unset or unsupported. """ - return os.environ.get("MONAI_DICOM_READER", "itk").lower() + return os.environ.get("MONAI_DICOM_READER", "").lower() class ImageMetaKey: diff --git a/requirements-dev.txt b/requirements-dev.txt index bbfd40452c9..0f81a6ccee5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -67,5 +67,5 @@ segment_anything @ https://github.com/facebookresearch/segment-anything/archive/ 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 +cupy-cuda13x[ctk]; platform_system == "Linux" +nvidia-nvimgcodec-cu13[all]>=0.8.0; platform_system == "Linux" diff --git a/tests/data/test_nvimgcodec_pydicom_reader.py b/tests/data/test_nvimgcodec_pydicom_reader.py index 60cbcfa9e27..636488c77ea 100644 --- a/tests/data/test_nvimgcodec_pydicom_reader.py +++ b/tests/data/test_nvimgcodec_pydicom_reader.py @@ -17,11 +17,11 @@ 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 monai.transforms.io.array import SUPPORTED_READERS, get_default_reader_registration_order from tests.test_utils import SkipIfNoModule @@ -34,31 +34,72 @@ def test_is_dicom_path(self): 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") + self.assertEqual(get_preferred_dicom_reader_key(), "") 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") + with patch.dict(os.environ, {"MONAI_DICOM_READER": "itk"}): + self.assertEqual(get_preferred_dicom_reader_key(), "itkreader") 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") + self.assertEqual(get_preferred_dicom_reader_key(), "") + + def test_get_default_reader_registration_order_default(self): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("MONAI_DICOM_READER", None) + order = get_default_reader_registration_order() + self.assertEqual( + order, + [ + "pydicomreader", + "nvimgcodecpydicomreader", + "itkreader", + "nrrdreader", + "numpyreader", + "pilreader", + "nibabelreader", + ], + ) 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) + self.assertIn("itkreader", order) + self.assertIn("nvimgcodecpydicomreader", order) + self.assertEqual( + order[:-1], + [ + "nvimgcodecpydicomreader", + "itkreader", + "nrrdreader", + "numpyreader", + "pilreader", + "nibabelreader", + ], + ) def test_get_default_reader_registration_order_nvimgcodec_env(self): with patch.dict(os.environ, {"MONAI_DICOM_READER": "nvimgcodec"}): order = get_default_reader_registration_order() self.assertEqual(order[-1], "nvimgcodecpydicomreader") - self.assertNotIn("pydicomreader", order) - self.assertNotIn("itkreader", order) + self.assertIn("pydicomreader", order) + self.assertIn("itkreader", order) + + def test_custom_supported_reader_included_in_order(self): + class _DummyReader: + pass + + with patch.dict(SUPPORTED_READERS, {"dummyreader": _DummyReader}): + with patch.dict(os.environ, {"MONAI_DICOM_READER": "itk"}): + order = get_default_reader_registration_order() + self.assertIn("dummyreader", order) + self.assertEqual(order[-1], "itkreader") + self.assertLess(order.index("dummyreader"), order.index("itkreader")) def test_dicom_reader_env_map_values(self): self.assertEqual(set(DICOM_READER_ENV_MAP.keys()), {"itk", "pydicom", "nvimgcodec"}) @@ -114,7 +155,8 @@ def test_load_image_respects_dicom_reader_env(self): 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) + # Other DICOM readers from SUPPORTED_READERS remain when their deps are available. + self.assertTrue(any(name != "PydicomReader" for name in reader_types)) @SkipIfNoModule("pydicom") @patch("monai.data.nvimgcodec_pydicom_plugin.is_nvimgcodec_available", return_value=True) @@ -129,7 +171,7 @@ def test_load_image_nvimgcodec_env(self, _mock_register, _mock_available): @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_image_nvimgcodec_env_unavailable_auto_select(self, _mock_available, _mock_register): - """When nvimgcodec is unavailable, verify_suffix blocks auto-selection and no DICOM reader remains.""" + """When nvimgcodec is unavailable, verify_suffix skips it and another DICOM reader can load.""" with patch.dict(os.environ, {"MONAI_DICOM_READER": "nvimgcodec"}): order = get_default_reader_registration_order() self.assertEqual(order[-1], "nvimgcodecpydicomreader") @@ -137,15 +179,14 @@ def test_load_image_nvimgcodec_env_unavailable_auto_select(self, _mock_available loader = LoadImage(image_only=True) reader_types = [type(r).__name__ for r in loader.readers] self.assertEqual(reader_types[-1], "NvImgCodecPydicomReader") - self.assertNotIn("ITKReader", reader_types) - self.assertNotIn("PydicomReader", reader_types) + self.assertIn("PydicomReader", reader_types) nv_reader = loader.readers[-1] self.assertFalse(nv_reader.verify_suffix("tests/testing_data/CT_DICOM")) - with self.assertRaises(RuntimeError) as err_ctx: - loader("tests/testing_data/CT_DICOM") - self.assertIn("cannot find a suitable reader", str(err_ctx.exception)) + # Preferred reader is skipped; a later DICOM reader in the list should succeed. + img = loader("tests/testing_data/CT_DICOM") + self.assertIsNotNone(img) class TestNvImgCodecPluginRegistration(unittest.TestCase): From ee9f5cddcc9203d46faf3154441ca3e2004563ca Mon Sep 17 00:00:00 2001 From: M Q Date: Sun, 26 Jul 2026 16:22:58 -0700 Subject: [PATCH 10/13] Address comments on test's skipping conditions Signed-off-by: M Q --- tests/data/test_nvimgcodec_pydicom_reader.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/data/test_nvimgcodec_pydicom_reader.py b/tests/data/test_nvimgcodec_pydicom_reader.py index 636488c77ea..23e9e8dec66 100644 --- a/tests/data/test_nvimgcodec_pydicom_reader.py +++ b/tests/data/test_nvimgcodec_pydicom_reader.py @@ -228,12 +228,14 @@ def test_is_nvimgcodec_available_with_cuda(self): class TestNvImgCodecPydicomReaderIntegration(unittest.TestCase): @SkipIfNoModule("pydicom") + @SkipIfNoModule("nvidia.nvimgcodec.tools.dicom.pydicom_plugin") 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") + @SkipIfNoModule("nvidia.nvimgcodec.tools.dicom.pydicom_plugin") @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 65c1161c8193e087ab2197b18545ba875dfbce43 Mon Sep 17 00:00:00 2001 From: M Q Date: Sun, 26 Jul 2026 16:27:06 -0700 Subject: [PATCH 11/13] Autofix of formatting errors Signed-off-by: M Q --- tests/data/test_nvimgcodec_pydicom_reader.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/tests/data/test_nvimgcodec_pydicom_reader.py b/tests/data/test_nvimgcodec_pydicom_reader.py index 23e9e8dec66..6478482da73 100644 --- a/tests/data/test_nvimgcodec_pydicom_reader.py +++ b/tests/data/test_nvimgcodec_pydicom_reader.py @@ -15,11 +15,7 @@ import unittest from unittest.mock import patch -from monai.data.image_reader import ( - DICOM_READER_ENV_MAP, - get_preferred_dicom_reader_key, - is_dicom_path, -) +from monai.data.image_reader import DICOM_READER_ENV_MAP, get_preferred_dicom_reader_key, is_dicom_path from monai.transforms import LoadImage from monai.transforms.io.array import SUPPORTED_READERS, get_default_reader_registration_order from tests.test_utils import SkipIfNoModule @@ -73,14 +69,7 @@ def test_get_default_reader_registration_order(self): self.assertIn("nvimgcodecpydicomreader", order) self.assertEqual( order[:-1], - [ - "nvimgcodecpydicomreader", - "itkreader", - "nrrdreader", - "numpyreader", - "pilreader", - "nibabelreader", - ], + ["nvimgcodecpydicomreader", "itkreader", "nrrdreader", "numpyreader", "pilreader", "nibabelreader"], ) def test_get_default_reader_registration_order_nvimgcodec_env(self): From 7ffd22ec54781ddfb97b658a01e94867019ace6b Mon Sep 17 00:00:00 2001 From: M Q Date: Sun, 26 Jul 2026 17:57:15 -0700 Subject: [PATCH 12/13] Added a test to compare decoded data between CPU and GPU decoding if deps are installed Signed-off-by: M Q --- tests/data/test_nvimgcodec_pydicom_reader.py | 83 ++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/data/test_nvimgcodec_pydicom_reader.py b/tests/data/test_nvimgcodec_pydicom_reader.py index 6478482da73..46520dc3200 100644 --- a/tests/data/test_nvimgcodec_pydicom_reader.py +++ b/tests/data/test_nvimgcodec_pydicom_reader.py @@ -15,6 +15,8 @@ import unittest from unittest.mock import patch +import numpy as np + from monai.data.image_reader import DICOM_READER_ENV_MAP, get_preferred_dicom_reader_key, is_dicom_path from monai.transforms import LoadImage from monai.transforms.io.array import SUPPORTED_READERS, get_default_reader_registration_order @@ -216,6 +218,87 @@ def test_is_nvimgcodec_available_with_cuda(self): class TestNvImgCodecPydicomReaderIntegration(unittest.TestCase): + @SkipIfNoModule("pydicom") + @SkipIfNoModule("pylibjpeg") + @SkipIfNoModule("openjpeg") # pylibjpeg-openjpeg + @SkipIfNoModule("jpeg_ls") # pyjpegls + @SkipIfNoModule("gdcm") # python-gdcm + @SkipIfNoModule("nvidia.nvimgcodec.tools.dicom.pydicom_plugin") + def test_pixels_match_pydicom_reader(self): + from pydicom import dcmread + from pydicom.data import get_testdata_file + + from monai.data import NvImgCodecPydicomReader, PydicomReader + from monai.data.nvimgcodec_pydicom_plugin import ( + NVIMGCODEC_PLUGIN_LABEL, + SUPPORTED_DECODER_CLASSES, + SUPPORTED_TRANSFER_SYNTAXES, + is_nvimgcodec_available, + unregister_as_decoder_plugin, + ) + + if not is_nvimgcodec_available(): + self.skipTest("nvImageCodec with CUDA support is not available.") + + # One bundled pydicom decoder test file for each nvImageCodec-supported + # transfer syntax for which pydicom ships a local sample. + test_files = ( + "SC_rgb_jpeg_lossy_gdcm.dcm", # JPEG Baseline + "SC_rgb_jpeg_gdcm.dcm", # JPEG Lossless SV1 + "MR_small_jp2klossless.dcm", # JPEG 2000 Lossless + "JPEG2000.dcm", # JPEG 2000 Lossy + ) + supported_uids = {str(uid) for uid in SUPPORTED_TRANSFER_SYNTAXES} + tested = 0 + pydicom_failures = [] + + for filename in test_files: + path = get_testdata_file(filename, download=False) + self.assertIsNotNone(path, f"{filename} is not bundled with pydicom") + transfer_syntax = str(dcmread(path, stop_before_pixels=True).file_meta.TransferSyntaxUID) + if transfer_syntax not in supported_uids: + continue + + with self.subTest(filename=filename, transfer_syntax=transfer_syntax): + # Decode the reference before registering nvImageCodec, ensuring + # pydicom uses one of its standard compressed-pixel decoders. + unregister_as_decoder_plugin() + pydicom_reader = PydicomReader() + try: + expected, _ = pydicom_reader.get_data(pydicom_reader.read(path)) + except Exception as error: + pydicom_failures.append(f"{filename}: {error}") + continue + + # Force nvImageCodec to be the only available plugin for its + # supported decoder classes, then restore the registry afterward. + old_available = {decoder.UID: decoder._available for decoder in SUPPORTED_DECODER_CLASSES} + try: + for decoder in SUPPORTED_DECODER_CLASSES: + decoder._available = {} + + nvimgcodec_reader = NvImgCodecPydicomReader() + decoder = next( + decoder for decoder in SUPPORTED_DECODER_CLASSES if str(decoder.UID) == transfer_syntax + ) + self.assertIn(NVIMGCODEC_PLUGIN_LABEL, decoder._available) + actual, _ = nvimgcodec_reader.get_data(nvimgcodec_reader.read(path)) + + self.assertEqual(actual.shape, expected.shape) + np.testing.assert_allclose(actual, expected, rtol=0.0, atol=2) + finally: + unregister_as_decoder_plugin() + for decoder in SUPPORTED_DECODER_CLASSES: + decoder._available = old_available[decoder.UID] + tested += 1 + + if not tested: + failure_details = "; ".join(pydicom_failures) + self.skipTest( + "pydicom could not decode any bundled samples for nvImageCodec-supported transfer syntaxes." + f" Failures: {failure_details}" + ) + @SkipIfNoModule("pydicom") @SkipIfNoModule("nvidia.nvimgcodec.tools.dicom.pydicom_plugin") def test_load_dicom_with_pydicom_env(self): From 8889bea857e871047aaf74736ccda31b4e3398d7 Mon Sep 17 00:00:00 2001 From: M Q Date: Thu, 10 Sep 2026 00:32:01 -0700 Subject: [PATCH 13/13] Addressed latest review comments and tested code post merge commits. Signed-off-by: M Q --- docs/source/data.rst | 3 ++- docs/source/installation.md | 12 ++++++++---- monai/data/image_reader.py | 9 +++++---- monai/transforms/io/array.py | 2 +- monai/transforms/io/dictionary.py | 2 +- pyproject.toml | 7 +++++++ 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/docs/source/data.rst b/docs/source/data.rst index 6e2b55b994c..f53588f4236 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -168,7 +168,8 @@ 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. +is not bypassed by GPU direct loading. The optional dependency ``nvimgcodec`` needs to be installed for this +feature (``pip install 'monai[nvimgcodec]'``). .. autoclass:: NvImgCodecPydicomReader :members: diff --git a/docs/source/installation.md b/docs/source/installation.md index 0ce37ffbc6c..31c99a794c1 100644 --- a/docs/source/installation.md +++ b/docs/source/installation.md @@ -77,13 +77,17 @@ pip install monai MONAI supports the extras syntax such as `pip install 'monai[nibabel]'`. The options are ```text -clearml, cucim, cupy, einops, fire, gdown, h5py, huggingface_hub, hyena, ignite, imagecodecs, itk, jsonschema, lmdb, lpips, matplotlib, metrics_reloaded, mlflow, nibabel, nni, onnx, openslide, optuna, pandas, pillow, polygraphy, psutil, pyamg, pybind11, pydicom, pynrrd, pynvml, pyyaml, requests, segment_anything, scipy, skimage, tensorboard, tensorboardX, tifffile, torchio, torchvision, tqdm, transformers, zarr +clearml, cucim, cupy, einops, fire, gdown, h5py, huggingface_hub, hyena, ignite, imagecodecs, itk, jsonschema, lmdb, lpips, matplotlib, metrics_reloaded, mlflow, nibabel, nni, nvimgcodec, onnx, openslide, optuna, pandas, pillow, polygraphy, psutil, pyamg, pybind11, pydicom, pynrrd, pynvml, pyyaml, requests, segment_anything, scipy, skimage, tensorboard, tensorboardX, tifffile, torchio, torchvision, tqdm, transformers, zarr ``` -which correspond to the packages: `clearml`, `cucim` (`cucim-cu12` or `cucim-cu13`), `cupy-cuda13x`, `einops`, `fire`, `gdown`, `h5py`, `huggingface_hub`, `nvsubquadratic`, `omegaconf`, `pytorch-ignite`, `imagecodecs`, `itk`, `jsonschema`, `lmdb`, `lpips`, `matplotlib`, `MetricsReloaded`, `mlflow`, `nibabel`, `nni`, `filelock`, `onnx`, `onnxruntime`, `onnx_graphsurgeon`, `onnxscript`, `openslide-python`, `openslide-bin`, `optuna`, `pandas`, `pillow`, `polygraphy`, `psutil`, `pyamg`, `pybind11`, `pydicom`, `pynrrd`, `nvidia-ml-py`, `pyyaml`, `requests`, `segment_anything`, `scipy`, `scikit-image`, `tensorboard`, `tensorboardX`, `tifffile`, `torchio`, `torchvision`, `tqdm`, `transformers`, `zarr`. +which correspond to the packages: `clearml`, `cucim` (`cucim-cu12` or `cucim-cu13`), `cupy-cuda13x`, `einops`, `fire`, `gdown`, `h5py`, `huggingface_hub`, `nvsubquadratic`, `omegaconf`, `pytorch-ignite`, `imagecodecs`, `itk`, `jsonschema`, `lmdb`, `lpips`, `matplotlib`, `MetricsReloaded`, `mlflow`, `nibabel`, `nni`, `filelock`, `nvidia-nvimgcodec-cu13`, `onnx`, `onnxruntime`, `onnx_graphsurgeon`, `onnxscript`, `openslide-python`, `openslide-bin`, `optuna`, `pandas`, `pillow`, `polygraphy`, `psutil`, `pyamg`, `pybind11`, `pydicom`, `pynrrd`, `nvidia-ml-py`, `pyyaml`, `requests`, `segment_anything`, `scipy`, `scikit-image`, `tensorboard`, `tensorboardX`, `tifffile`, `torchio`, `torchvision`, `tqdm`, `transformers`, `zarr`. -Almost all of these can be installed together with the `all` option. For development on MONAI, this should be accompanied by `testing` which will install the testing static checking packages. Cupy is omitted from `all` since the choice between -Cuda 12 and 13 versions of the library can't be resolved when installing and must be manually installed. +Almost all of these can be installed together with the `all` option. For development on MONAI, this should be accompanied by `testing` which will install the testing static checking packages. Cupy and `nvimgcodec` are omitted from `all` since the choice between +Cuda 12 and 13 versions of the libraries can't be resolved when installing and must be manually installed. + +The `nvimgcodec` extra installs GPU-accelerated DICOM decoding for `NvImgCodecPydicomReader` +(`pip install 'monai[nvimgcodec]'`). It is Linux-only in the extra definition; CUDA 13 is the +default. CUDA 12 users should install matching `cupy-cuda12x` and `nvidia-nvimgcodec-cu12` wheels. The `hyena` extra pulls in [`nvsubquadratic`](https://github.com/NVIDIA-BioNeMo/nvSubquadratic), required by `HyenaNDUNETR` / `HyenaMixer` / `HyenaTransformerBlock` (subquadratic diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 7e23a8afd4b..0a853372f43 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -1093,10 +1093,11 @@ class NvImgCodecPydicomReader(PydicomReader): 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+. + Requires the optional extra ``nvimgcodec`` (``pip install 'monai[nvimgcodec]'``), which installs ``pydicom``, + CuPy, and ``nvidia-nvimgcodec-cu13`` on Linux. GPU decompression uses + ``nvidia.nvimgcodec.tools.dicom.pydicom_plugin`` from the nvImageCodec package. CUDA 13 is strongly + recommended because the nvJPEG library has addressed a known issue with JPEG lossless decoding in + CUDA 13.2.0+. For CUDA 12, install matching ``cupy-cuda12x`` and ``nvidia-nvimgcodec-cu12`` wheels instead. Set environment variable ``MONAI_DICOM_READER=nvimgcodec`` to use this reader by default with :py:class:`monai.transforms.LoadImage` without explicit configuration. diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index 7fbe107cb44..6fc278211f6 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -142,7 +142,7 @@ class LoadImage(Transform): (npz, npy -> NumpyReader), (nrrd -> NrrdReader), (DICOM file -> ITKReader first among DICOM readers by default). - Optionally set ``MONAI_DICOM_READER`` to ``itk``, ``pydicom``, or ``nvimgcodec`` - (GPU-accelerated decoding) to try that DICOM reader first. + (GPU-accelerated decoding; requires ``pip install 'monai[nvimgcodec]'``) to try that DICOM reader first. 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/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 645c8959048..18f9db5f5b7 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -54,7 +54,7 @@ class LoadImaged(MapTransform): - Current default readers: (nii, nii.gz -> NibabelReader), (png, jpg, bmp -> PILReader), (npz, npy -> NumpyReader), (dcm, DICOM series and others -> ITKReader first among DICOM readers). - Optionally set ``MONAI_DICOM_READER`` to ``itk``, ``pydicom``, or ``nvimgcodec`` - (GPU-accelerated decoding) to try that DICOM reader first. + (GPU-accelerated decoding; requires ``pip install 'monai[nvimgcodec]'``) to try that DICOM reader first. 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/pyproject.toml b/pyproject.toml index 3472a78f65d..aa434b364fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -134,6 +134,13 @@ nni = [ "filelock<3.12.0", # https://github.com/microsoft/nni/issues/5523 "typeguard<3" # https://github.com/microsoft/nni/issues/5457 ] +# omitted from all: CUDA 13 Linux packages; CUDA 12 users should install matching cupy/nvimgcodec wheels +# required for GPU accelerated decoding of compressed DICOM images +nvimgcodec = [ + "pydicom", + "cupy-cuda13x[ctk]!=14.1.0; platform_system == 'Linux'", + "nvidia-nvimgcodec-cu13[all]>=0.8.0; platform_system == 'Linux'" +] onnx = ["onnx>=1.13.0", "onnxruntime; python_version <= '3.10'", "onnx_graphsurgeon", "onnxscript"] openslide = ["openslide-python", "openslide-bin"] optuna = ["optuna"]