diff --git a/docs/source/data.rst b/docs/source/data.rst index 63d5e0e23d..f53588f423 100644 --- a/docs/source/data.rst +++ b/docs/source/data.rst @@ -159,6 +159,21 @@ 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. The optional dependency ``nvimgcodec`` needs to be installed for this +feature (``pip install 'monai[nvimgcodec]'``). + +.. autoclass:: NvImgCodecPydicomReader + :members: + NrrdReader ~~~~~~~~~~ .. autoclass:: NrrdReader diff --git a/docs/source/installation.md b/docs/source/installation.md index 0ce37ffbc6..31c99a794c 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/__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 53fcaa9545..0a853372f4 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): @@ -1027,6 +1042,141 @@ 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 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. + + 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): """ @@ -1235,7 +1385,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 e5d127c4b9..6fc278211f 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; 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 @@ -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..18f9db5f5b 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; 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/utils/misc.py b/monai/utils/misc.py index f11e070f9d..0546a6a63f 100644 --- a/monai/utils/misc.py +++ b/monai/utils/misc.py @@ -576,6 +576,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 a13758a9e7..aa434b364f 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"] @@ -340,6 +347,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/tests/data/test_init_reader.py b/tests/data/test_init_reader.py index 35a0b9f913..4cd9ac42c4 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 monai.utils import MetaKeys, OptionalImportError, optional_import from tests.test_utils import SkipIfNoModule @@ -52,6 +60,11 @@ def test_load_image(self): inst = LoadImaged("image", reader=None) self.assertIsInstance(inst, LoadImaged) + _, has_pydicom = optional_import("pydicom") + if has_pydicom: + inst = LoadImaged("image", reader="NvImgCodecPydicomReader") + self.assertIsInstance(inst, LoadImaged) + @SkipIfNoModule("nibabel") @SkipIfNoModule("cupy") @SkipIfNoModule("kvikio") @@ -80,6 +93,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()