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
1 change: 1 addition & 0 deletions monai/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ def filter(self, record):
# *.so files and folder "_C" may not exist when the cpp extensions are not compiled
excludes = "|".join(
[
"(.*(nvimgcodec_pydicom_plugin)$)",
"(^(monai.handlers))",
"(^(monai.bundle))",
"(^(monai.fl))",
Expand Down
32 changes: 27 additions & 5 deletions monai/data/image_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -1087,7 +1087,7 @@ 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
decoder plugin lazily on first use. 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
Expand Down Expand Up @@ -1117,8 +1117,8 @@ class NvImgCodecPydicomReader(PydicomReader):

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.
the reader; nvImageCodec, CUDA, and CuPy availability is checked when a DICOM file is verified or read.
If the plugin is unavailable or registration fails, the reader falls back to the default pydicom decoders.

Args:
channel_dim: the channel dimension of the input image, default is None.
Expand Down Expand Up @@ -1159,6 +1159,14 @@ def __init__(
to_gpu=False,
**kwargs,
)

self._nvimgcodec_available = False
self._setup_done = False

def _ensure_setup(self) -> None:
"""Import and register the nvImageCodec plugin on first use only."""
if self._setup_done:
return
from monai.data.nvimgcodec_pydicom_plugin import is_nvimgcodec_available, register_as_decoder_plugin

self._nvimgcodec_available = is_nvimgcodec_available()
Expand All @@ -1167,14 +1175,28 @@ def __init__(
"NvImgCodecPydicomReader: nvImageCodec decoder plugin did not register successfully. "
"Falling back to default pydicom decoders."
)
self._setup_done = True

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 or not is_dicom_path(filename):
return False
return is_dicom_path(filename)
self._ensure_setup()
return self._nvimgcodec_available

def read(self, data: Sequence[PathLike] | PathLike, **kwargs):
"""Read image data using the configured reader.

Args:
data: Input image data or file path.
**kwargs: Additional arguments passed to the underlying reader.

Returns:
The image data returned by the underlying reader."""
self._ensure_setup()
return super().read(data, **kwargs)


@require_pkg(pkg_name="nibabel")
Expand Down
2 changes: 1 addition & 1 deletion monai/data/nvimgcodec_pydicom_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def is_nvimgcodec_available() -> bool:
def register_as_decoder_plugin(module_path: str | None = None) -> bool:
"""Register the nvImageCodec pydicom decoder plugin."""
if not is_nvimgcodec_available():
_logger.warning("nvImageCodec is not available; skipping pydicom decoder plugin registration.")
_logger.debug("nvImageCodec is not available; skipping pydicom decoder plugin registration.")
return False
if not has_pydicom_plugin:
return False
Expand Down
42 changes: 42 additions & 0 deletions tests/data/test_init_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
from __future__ import annotations

import os
import subprocess
import sys
import tempfile
import unittest
from importlib.machinery import PathFinder

import numpy as np

Expand Down Expand Up @@ -65,6 +68,45 @@ def test_load_image(self):
inst = LoadImaged("image", reader="NvImgCodecPydicomReader")
self.assertIsInstance(inst, LoadImaged)

def test_nvimgcodec_not_imported_on_monai_import(self):
"""Verify importing MONAI does not eagerly load the optional NVIDIA package."""
nvidia_spec = PathFinder.find_spec("nvidia")
if nvidia_spec is None or nvidia_spec.submodule_search_locations is None:
self.skipTest("nvidia.nvimgcodec is not installed")

nvimgcodec_spec = PathFinder.find_spec("nvimgcodec", nvidia_spec.submodule_search_locations)
if nvimgcodec_spec is None:
self.skipTest("nvidia.nvimgcodec is not installed")

code = "import monai, sys; print('nvidia.nvimgcodec' in sys.modules)"
result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)

self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "False")
self.assertEqual(result.stderr, "")

def test_nvimgcodec_not_imported_on_loadimage_init(self):
"""Verify initializing LoadImage does not eagerly load nvimgcodec."""
nvidia_spec = PathFinder.find_spec("nvidia")
if nvidia_spec is None or nvidia_spec.submodule_search_locations is None:
self.skipTest("nvidia.nvimgcodec is not installed")

nvimgcodec_spec = PathFinder.find_spec("nvimgcodec", nvidia_spec.submodule_search_locations)
if nvimgcodec_spec is None:
self.skipTest("nvidia.nvimgcodec is not installed")

code = (
"import monai, sys; "
"from monai.transforms import LoadImage; "
"LoadImage(); "
"print('nvidia.nvimgcodec' in sys.modules)"
)
result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)

self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout.strip(), "False")
self.assertEqual(result.stderr, "")

@SkipIfNoModule("nibabel")
@SkipIfNoModule("cupy")
@SkipIfNoModule("kvikio")
Expand Down
17 changes: 16 additions & 1 deletion tests/data/test_nvimgcodec_pydicom_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,28 @@ 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):
def test_reader_registers_plugin_lazily(self, _mock_available, mock_register):
from monai.data import NvImgCodecPydicomReader

reader = NvImgCodecPydicomReader()
self.assertIsInstance(reader, NvImgCodecPydicomReader)

mock_register.assert_not_called()

reader.verify_suffix("x.dcm")

mock_register.assert_called_once()

@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_does_not_register_plugin_for_non_dicom(self, _mock_available, mock_register):
from monai.data import NvImgCodecPydicomReader

reader = NvImgCodecPydicomReader()
self.assertFalse(reader.verify_suffix("x.png"))
mock_register.assert_not_called()

@SkipIfNoModule("pydicom")
@patch("monai.data.nvimgcodec_pydicom_plugin.is_nvimgcodec_available", return_value=False)
def test_verify_suffix_without_nvimgcodec(self, _mock_available):
Expand Down
Loading