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..6e2b55b994 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -159,6 +159,20 @@ PILReader .. autoclass:: PILReader :members: +PydicomReader +~~~~~~~~~~~~~ +.. autoclass:: PydicomReader + :members: + +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: + 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 6859dca62f..d65e017659 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -64,7 +64,22 @@ 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", + "get_preferred_dicom_reader_key", + "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"} class ImageReader(ABC): @@ -997,6 +1012,140 @@ 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:`~monai.transforms.LoadImage` registration key for the preferred DICOM reader. + + Controlled by the ``MONAI_DICOM_READER`` environment variable. Supported values are + ``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") + if pref is None: + return "" + pref = pref.lower() + if pref not in DICOM_READER_ENV_MAP: + warnings.warn(f"Unknown MONAI_DICOM_READER='{pref}', ignoring preference.") + return "" + return DICOM_READER_ENV_MAP[pref] + + +@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+. + + 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``) + 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. + + 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. + 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: accepted for API compatibility with :py:class:`PydicomReader` but always ignored (always ``False``). + 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, + ): + 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, + swap_ij=swap_ij, + prune_metadata=prune_metadata, + label_dict=label_dict, + fname_regex=fname_regex, + to_gpu=False, + **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): """ @@ -1205,7 +1354,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.") diff --git a/monai/data/nvimgcodec_pydicom_plugin.py b/monai/data/nvimgcodec_pydicom_plugin.py new file mode 100644 index 0000000000..573dc715d4 --- /dev/null +++ b/monai/data/nvimgcodec_pydicom_plugin.py @@ -0,0 +1,80 @@ +# 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 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: 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.") + return False + if not has_pydicom_plugin: + return False + 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 bool(pydicom_plugin.unregister()) diff --git a/monai/transforms/io/array.py b/monai/transforms/io/array.py index aadd96763d..005ca4ef95 100644 --- a/monai/transforms/io/array.py +++ b/monai/transforms/io/array.py @@ -36,8 +36,10 @@ NibabelReader, NrrdReader, NumpyReader, + NvImgCodecPydicomReader, PILReader, PydicomReader, + get_preferred_dicom_reader_key, ) from monai.data.meta_tensor import MetaTensor from monai.data.utils import is_no_channel @@ -59,10 +61,15 @@ 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, "itkreader": ITKReader, "nrrdreader": NrrdReader, "numpyreader": NumpyReader, @@ -71,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`. @@ -116,7 +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). + (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 @@ -185,7 +211,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..645c895904 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 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 ed48d4b37d..9327a9fc88 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -574,6 +574,15 @@ 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``, ``pydicom``, ``nvimgcodec``. + Returns an empty string when unset or unsupported. + """ + return os.environ.get("MONAI_DICOM_READER", "").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..0f81a6ccee 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[ctk]; platform_system == "Linux" +nvidia-nvimgcodec-cu13[all]>=0.8.0; platform_system == "Linux" diff --git a/tests/data/test_init_reader.py b/tests/data/test_init_reader.py index 169fd20a5f..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, 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", None]: + for r in [ + "NibabelReader", + "PILReader", + "ITKReader", + "NumpyReader", + "NrrdReader", + "PydicomReader", + "NvImgCodecPydicomReader", + None, + ]: inst = LoadImaged("image", reader=r) self.assertIsInstance(inst, LoadImaged) @@ -61,6 +78,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..46520dc320 --- /dev/null +++ b/tests/data/test_nvimgcodec_pydicom_reader.py @@ -0,0 +1,322 @@ +# 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 + +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 +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(), "") + + 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(), "") + + 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.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.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"}) + + +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")) + + @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") + 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") + # 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) + @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") + + @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 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") + + loader = LoadImage(image_only=True) + reader_types = [type(r).__name__ for r in loader.readers] + self.assertEqual(reader_types[-1], "NvImgCodecPydicomReader") + self.assertIn("PydicomReader", reader_types) + + nv_reader = loader.readers[-1] + self.assertFalse(nv_reader.verify_suffix("tests/testing_data/CT_DICOM")) + + # 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): + @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") + @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): + 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 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()