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 a85eb95c20..5f864055de 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -64,7 +64,25 @@ 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 +1015,145 @@ 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+. + + 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 +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.") 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..c9575dbbe8 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: @@ -258,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: diff --git a/monai/transforms/io/dictionary.py b/monai/transforms/io/dictionary.py index 4927450c7d..228ace869f 100644 --- a/monai/transforms/io/dictionary.py +++ b/monai/transforms/io/dictionary.py @@ -52,7 +52,9 @@ class LoadImaged(MapTransform): - User-specified reader in the constructor of `LoadImage`. - Readers from the last to the first in the registered list. - Current default readers: (nii, nii.gz -> NibabelReader), (png, jpg, bmp -> PILReader), - (npz, npy -> NumpyReader), (dcm, DICOM series and others -> ITKReader). + (npz, npy -> NumpyReader), (dcm, DICOM series and others -> ITKReader by default). + - The default DICOM reader can be changed with the ``MONAI_DICOM_READER`` environment variable. + Supported values are ``itk`` (default), ``pydicom``, and ``nvimgcodec`` (GPU-accelerated decoding). Please note that for png, jpg, bmp, and other 2D formats, readers by default swap axis 0 and 1 after loading the array with ``reverse_indexing`` set to ``True`` because the spatial axes definition diff --git a/monai/utils/misc.py b/monai/utils/misc.py index ed48d4b37d..e5b31cc574 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -574,6 +574,14 @@ def allow_pickle() -> bool: """ return str2bool(os.environ.get("MONAI_ALLOW_PICKLE", "0")) + @staticmethod + def dicom_reader() -> str: + """Preferred DICOM reader for :py:class:`monai.transforms.LoadImage`. + + Supported values: ``itk`` (default), ``pydicom``, ``nvimgcodec``. + """ + return os.environ.get("MONAI_DICOM_READER", "itk").lower() + class ImageMetaKey: """ diff --git a/pyproject.toml b/pyproject.toml index 325622b66a..a0e38e7c6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -85,6 +85,9 @@ extend-ignore = [ [tool.ruff.lint.mccabe] max-complexity = 50 # todo lower this treshold when yesqa id replaced with Ruff's RUF100 +[tool.pytest.ini_options] +pythonpath = ["."] + [tool.pytype] # Space-separated list of files or directories to exclude. exclude = ["versioneer.py", "_version.py"] diff --git a/requirements-dev.txt b/requirements-dev.txt index b2c36f8de6..bbfd40452c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,6 @@ -# Full requirements for developments +# Full requirements for development. +# Install with: python -m pip install -U pip wheel && python -m pip install --no-build-isolation -r requirements-dev.txt +# (--no-build-isolation is required for MetricsReloaded; git+https URLs are avoided for public GitHub deps.) -r requirements-min.txt pytorch-ignite gdown>=4.7.3 @@ -49,7 +51,8 @@ pydicom h5py nni==2.10.1; platform_system == "Linux" and "arm" not in platform_machine and "aarch" not in platform_machine optuna -git+https://github.com/Project-MONAI/MetricsReloaded@monai-support#egg=MetricsReloaded +# Use GitHub archive URLs instead of git+https to avoid git credential helper issues on public repos. +MetricsReloaded @ https://github.com/Project-MONAI/MetricsReloaded/archive/refs/heads/monai-support.zip onnx>=1.13.0 onnxscript onnxruntime @@ -60,7 +63,9 @@ lpips==0.1.4 nvidia-ml-py huggingface_hub pyamg>=5.0.0, <5.3.0 -git+https://github.com/facebookresearch/segment-anything.git@6fdee8f2727f4506cfbbe553e23b895e27956588 +segment_anything @ https://github.com/facebookresearch/segment-anything/archive/6fdee8f2727f4506cfbbe553e23b895e27956588.zip onnx_graphsurgeon polygraphy pytest # FIXME: added to get around cupy 14.1.0 creating the requirement through polygraphy and trt_compiler somehow +cupy-cuda13x +nvidia-nvimgcodec-cu13[all]>=0.8.0 diff --git a/tests/data/test_init_reader.py b/tests/data/test_init_reader.py index 169fd20a5f..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..c3aad28b5c --- /dev/null +++ b/tests/data/test_nvimgcodec_pydicom_reader.py @@ -0,0 +1,178 @@ +# 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")) + + @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") + 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()