diff --git a/monai/__init__.py b/monai/__init__.py index 2d6f5dfc37..db13712b35 100644 --- a/monai/__init__.py +++ b/monai/__init__.py @@ -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))", diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py index 0a853372f4..528a986228 100644 --- a/monai/data/image_reader.py +++ b/monai/data/image_reader.py @@ -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 @@ -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. @@ -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() @@ -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") diff --git a/monai/data/nvimgcodec_pydicom_plugin.py b/monai/data/nvimgcodec_pydicom_plugin.py index 573dc715d4..07ba5ca726 100644 --- a/monai/data/nvimgcodec_pydicom_plugin.py +++ b/monai/data/nvimgcodec_pydicom_plugin.py @@ -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 diff --git a/tests/data/test_init_reader.py b/tests/data/test_init_reader.py index 4cd9ac42c4..39347c9c47 100644 --- a/tests/data/test_init_reader.py +++ b/tests/data/test_init_reader.py @@ -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 @@ -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") diff --git a/tests/data/test_nvimgcodec_pydicom_reader.py b/tests/data/test_nvimgcodec_pydicom_reader.py index 46520dc320..f25aa71d82 100644 --- a/tests/data/test_nvimgcodec_pydicom_reader.py +++ b/tests/data/test_nvimgcodec_pydicom_reader.py @@ -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):