Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/source/data.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion monai/data/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
161 changes: 159 additions & 2 deletions monai/data/image_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Comment on lines +1148 to +1154

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

No DICOM fallback when nvImageCodec is unavailable — contradicts the documented behavior.

verify_suffix returns False whenever self._nvimgcodec_available is False. Combined with get_default_reader_registration_order() (which registers only the single preferred DICOM reader, not ITK/pydicom), this means: with MONAI_DICOM_READER=nvimgcodec on a host lacking CUDA/nvimgcodec, the only registered DICOM reader rejects every DICOM in auto-selection, and LoadImage finds no suitable reader. That directly contradicts the class docstring promise that it "falls back to the default pydicom decoders (same behavior as PydicomReader)."

The plugin-registration fallback only affects decoding after selection; verify_suffix gating blocks selection entirely. Suggest dropping the _nvimgcodec_available gate so the reader can still serve DICOM via pydicom decoders.

🔧 Proposed fix
     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:
+        if not has_pydicom:
             return False
         return is_dicom_path(filename)

Confirm the intended behavior; if rejection is deliberate, the docstring fallback claim should be removed instead.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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)
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:
return False
return is_dicom_path(filename)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@monai/data/image_reader.py` around lines 1150 - 1156, verify_suffix in
ImageReader currently blocks DICOM selection whenever _nvimgcodec_available is
false, which prevents the documented pydicom fallback from ever being used.
Update verify_suffix to accept DICOM paths based on is_dicom_path(filename) and
has_pydicom alone, without gating on _nvimgcodec_available, so ImageReader can
still be selected and decode through the pydicom fallback path when nvImageCodec
is unavailable.



@require_pkg(pkg_name="nibabel")
class NibabelReader(ImageReader):
"""
Expand Down Expand Up @@ -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.")
Expand Down
80 changes: 80 additions & 0 deletions monai/data/nvimgcodec_pydicom_plugin.py
Original file line number Diff line number Diff line change
@@ -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())
40 changes: 22 additions & 18 deletions monai/transforms/io/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -63,6 +65,7 @@

SUPPORTED_READERS = {
"pydicomreader": PydicomReader,
"nvimgcodecpydicomreader": NvImgCodecPydicomReader,
"itkreader": ITKReader,
"nrrdreader": NrrdReader,
"numpyreader": NumpyReader,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading