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
48 changes: 34 additions & 14 deletions monai/deploy/operators/decoder_nvimgcodec.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,16 +180,19 @@ def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes:
if not is_available(tsyntax):
raise ValueError(f"Transfer syntax {tsyntax} not supported; see details in the debug log.")

runner.set_frame_option(runner.index, "decoding_plugin", "nvimgcodec") # type: ignore[attr-defined]

# runner.set_frame_option(runner.index, "decoding_plugin", "nvimgcodec") # type: ignore[attr-defined]
# in pydicom v3.1.0 can use the above call
runner.set_option("decoding_plugin", "nvimgcodec")
is_jpeg2k = tsyntax in JPEG2000TransferSyntaxes
samples_per_pixel = runner.samples_per_pixel
photometric_interpretation = runner.photometric_interpretation

# --- JPEG 2000: Precision/Bit depth ---
if is_jpeg2k:
precision, bits_allocated = _jpeg2k_precision_bits(runner)
runner.set_frame_option(runner.index, "bits_allocated", bits_allocated) # type: ignore[attr-defined]
# runner.set_frame_option(runner.index, "bits_allocated", bits_allocated) # type: ignore[attr-defined]
# in pydicom v3.1.0 can use the abover call
runner.set_option("bits_allocated", bits_allocated)
_logger.debug(f"Set bits_allocated to {bits_allocated} for J2K precision {precision}")

# Check if RGB conversion requested (following Pillow decoder logic)
Expand All @@ -199,16 +202,22 @@ def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes:

decoder = _get_decoder_resources()
params = _get_decode_params(runner)
decoded_surface = decoder.decode(src, params=params).cpu()
np_surface = np.ascontiguousarray(np.asarray(decoded_surface))
decoded_data = decoder.decode(src, params=params)
if decoded_data:
decoded_data = decoded_data.cpu()
else:
raise RuntimeError(f"Decoded data is None: {type(decoded_data)}")
np_surface = np.ascontiguousarray(np.asarray(decoded_data))

# Handle JPEG2000-specific postprocessing separately
if is_jpeg2k:
np_surface = _jpeg2k_postprocess(np_surface, runner)

# Update photometric interpretation if we converted to RGB, or JPEG 2000 YBR*
if convert_to_rgb or photometric_interpretation in (PI.YBR_ICT, PI.YBR_RCT):
runner.set_frame_option(runner.index, "photometric_interpretation", PI.RGB) # type: ignore[attr-defined]
# runner.set_frame_option(runner.index, "photometric_interpretation", PI.RGB) # type: ignore[attr-defined]
# in pydicon v3.1.0 can use the above call
runner.set_option("photometric_interpretation", PI.RGB)
_logger.debug(
"Set photometric_interpretation to RGB after conversion"
if convert_to_rgb
Expand Down Expand Up @@ -261,7 +270,7 @@ def _get_decode_params(runner: RunnerBase) -> Any:
if samples_per_pixel > 1:
# JPEG 2000 color transformations are always returned as RGB (matches Pillow)
if photometric_interpretation in (PI.YBR_ICT, PI.YBR_RCT):
color_spec = nvimgcodec.ColorSpec.RGB
color_spec = nvimgcodec.ColorSpec.SRGB
_logger.debug(
f"Using RGB color spec for JPEG 2000 color transformation " f"(PI: {photometric_interpretation})"
)
Expand All @@ -271,7 +280,7 @@ def _get_decode_params(runner: RunnerBase) -> Any:

if convert_to_rgb:
# Convert YCbCr to RGB as requested
color_spec = nvimgcodec.ColorSpec.RGB
color_spec = nvimgcodec.ColorSpec.SRGB
_logger.debug(f"Using RGB color spec (as_rgb=True, PI: {photometric_interpretation})")
else:
# Keep YCbCr unchanged - matches Pillow's image.draft("YCbCr") behavior
Expand All @@ -289,7 +298,9 @@ def _get_decode_params(runner: RunnerBase) -> Any:


def _jpeg2k_precision_bits(runner: DecodeRunner) -> tuple[int, int]:
precision = runner.get_frame_option(runner.index, "j2k_precision", runner.bits_stored) # type: ignore[attr-defined]
# precision = runner.get_frame_option(runner.index, "j2k_precision", runner.bits_stored) # type: ignore[attr-defined]
# in pydicom v3.1.0 can use the above call
precision = runner.get_option("j2k_precision", runner.bits_stored)
if 0 < precision <= 8:
return precision, 8
elif 8 < precision <= 16:
Expand Down Expand Up @@ -317,15 +328,22 @@ def _jpeg2k_bitshift(arr, bit_shift):

def _jpeg2k_postprocess(np_surface, runner):
"""Handle JPEG 2000 postprocessing: sign correction and bit shifts."""
precision = runner.get_frame_option(runner.index, "j2k_precision", runner.bits_stored)
bits_allocated = runner.get_frame_option(runner.index, "bits_allocated", runner.bits_allocated)
# precision = runner.get_frame_option("j2k_precision", runner.bits_stored)
# bits_allocated = runner.get_frame_option(runner.index, "bits_allocated", runner.bits_allocated)
# in pydicom v3.1.0 can use the above calls
precision = runner.get_option("j2k_precision", runner.bits_stored)
bits_allocated = runner.get_option("bits_allocated", runner.bits_allocated)
is_signed = runner.pixel_representation
if runner.get_option("apply_j2k_sign_correction", False):
is_signed = runner.get_frame_option(runner.index, "j2k_is_signed", is_signed)
# is_signed = runner.get_frame_option(runner.index, "j2k_is_signed", is_signed)
# in pydicom v3.1.0 can use the above call
is_signed = runner.get_option("j2k_is_signed", is_signed)

# Sign correction for signed data
if is_signed and runner.pixel_representation == 1:
dtype = runner.frame_dtype(runner.index)
# dtype = runner.frame_dtype(runner.index)
# in pydicomv3.1.0 can use the above call
dtype = runner.pixel_dtype
buffer = bytearray(np_surface.tobytes())
arr = np.frombuffer(buffer, dtype=f"<u{dtype.itemsize}")
np_surface = _jpeg2k_sign_correction(arr, dtype, bits_allocated)
Expand All @@ -334,7 +352,9 @@ def _jpeg2k_postprocess(np_surface, runner):
bit_shift = bits_allocated - precision
if bit_shift:
buffer = bytearray(np_surface.tobytes() if isinstance(np_surface, np.ndarray) else np_surface)
dtype = runner.frame_dtype(runner.index)
# dtype = runner.frame_dtype(runner.index)
# in v3.1.0 can use the above call
dtype = runner.pixel_dtype
arr = np.frombuffer(buffer, dtype=dtype)
np_surface = _jpeg2k_bitshift(arr, bit_shift)

Expand Down
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ ignore =
B026
# B909 editing a loop's mutable iterable often leads to unexpected results/bugs
B909
# F824 global variable is unused: name is never assigned in scope
F824

per_file_ignores =
# e.g. F403 'from holoscan.conditions import *' used; unable to detect undefined names
Expand Down
180 changes: 167 additions & 13 deletions tests/unit/test_decoder_nvimgcodec.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,126 @@
import logging
import time
from pathlib import Path
from typing import Any
from typing import TYPE_CHECKING, Any, Iterator, cast

import numpy as np
import pytest
from pydicom import dcmread
from pydicom.data import get_testdata_files

from monai.deploy.operators.decoder_nvimgcodec import (
SUPPORTED_DECODER_CLASSES,
SUPPORTED_TRANSFER_SYNTAXES,
_is_nvimgcodec_available,
register_as_decoder_plugin,
unregister_as_decoder_plugin,
)

try:
from PIL import Image as PILImage
except Exception: # pragma: no cover - Pillow may be unavailable in some environments
PILImage = None # type: ignore[assignment]

if TYPE_CHECKING:
from PIL.Image import Image as PILImageType
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

Import of 'PILImageType' is not used.

Suggested change
from PIL.Image import Image as PILImageType
pass

Copilot uses AI. Check for mistakes.
else:
PILImageType = Any

_PNG_EXPORT_WARNING_EMITTED = False

_DEFAULT_PLUGIN_CACHE: dict[str, Any] = {}
_logger = logging.getLogger(__name__)


def _iter_frames(pixel_array: np.ndarray) -> Iterator[tuple[int, np.ndarray, bool]]:
"""Yield per-frame arrays and whether they represent color data."""
arr = np.asarray(pixel_array)
if arr.ndim == 2:
yield 0, arr, False
return

if arr.ndim == 3:
if arr.shape[-1] in (3, 4):
yield 0, arr, True
else:
for index in range(arr.shape[0]):
frame = arr[index]
is_color = frame.ndim == 3 and frame.shape[-1] in (3, 4)
Copy link

Choose a reason for hiding this comment

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

frame.ndim == 3 will always be false, right? Because arr.ndim is 3, you select a channel with arr[index], so you remove one dimension. So you can just set is_color to False.

One sanity check: can there be a multi channel image in pydicom with more that 4 channels? Because here you assumes that only if num channels is 3 or 4, then this is color image, and otherwise the first channel is the for frame indexing, and then there is width and height. But it could be as well width, height and many channels. Or such mutlichannel images are not allowed?

yield index, frame, is_color
return

if arr.ndim == 4:
for index in range(arr.shape[0]):
frame = arr[index]
is_color = frame.ndim == 3 and frame.shape[-1] in (3, 4)
Copy link

Choose a reason for hiding this comment

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

Similar to my comment above, frame.ndim == 3 will always be true, right? Due to how you extract frame. So you can just leave part with frame.shape check

yield index, frame, is_color
return

raise ValueError(f"Unsupported pixel array shape {arr.shape!r} for PNG export")


def _prepare_frame_for_png(frame: np.ndarray, is_color: bool) -> np.ndarray:

Check failure on line 62 in tests/unit/test_decoder_nvimgcodec.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=Project-MONAI_monai-deploy-app-sdk&issues=AZsA3nU-G7NRopOVvMnZ&open=AZsA3nU-G7NRopOVvMnZ&pullRequest=573
"""Convert a decoded frame into a dtype supported by PNG writers."""
arr = np.nan_to_num(np.asarray(frame), copy=False)

# Remove singleton channel dimension for grayscale data.
if not is_color and arr.ndim == 3 and arr.shape[-1] == 1:
arr = arr[..., 0]

if is_color:
if arr.dtype == np.uint8:
return arr
arr_float = arr.astype(np.float64, copy=False)
arr_min = float(arr_float.min())
arr_max = float(arr_float.max())
if arr_max == arr_min:
return np.zeros_like(arr, dtype=np.uint8)
scaled = (arr_float - arr_min) / (arr_max - arr_min)
return np.clip(np.round(scaled * 255.0), 0, 255).astype(np.uint8)

# Grayscale path
arr_min = float(arr.min())
arr_max = float(arr.max())

if np.issubdtype(arr.dtype, np.integer):
if arr_min >= 0 and arr_max <= 255:
return arr.astype(np.uint8, copy=False)
if arr_min >= 0 and arr_max <= 65535:
return arr.astype(np.uint16, copy=False)

arr_float = arr.astype(np.float64, copy=False)
arr_min = float(arr_float.min())
arr_max = float(arr_float.max())
Comment on lines +92 to +93
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

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

In the grayscale path, arr_min and arr_max are computed twice from different arrays (lines 77-78 from arr, then lines 87-88 from arr_float). The first computation (lines 77-78) is used only for the integer dtype checks (lines 81-84) but is then discarded. This is inefficient and potentially confusing. Consider removing the first computation or restructuring the logic to avoid redundant calculations.

Suggested change
arr_min = float(arr_float.min())
arr_max = float(arr_float.max())

Copilot uses AI. Check for mistakes.
if arr_max == arr_min:
return np.zeros_like(arr_float, dtype=np.uint8)

use_uint16 = arr_max - arr_min > 255.0
scale = 65535.0 if use_uint16 else 255.0
scaled = (arr_float - arr_min) / (arr_max - arr_min)
scaled = np.clip(np.round(scaled * scale), 0, scale)
target_dtype = np.uint16 if use_uint16 else np.uint8
return scaled.astype(target_dtype)


def _save_frames_as_png(pixel_array: np.ndarray, output_dir: Path, file_stem: str) -> None:
"""Persist each frame as a PNG image in the specified directory."""
global _PNG_EXPORT_WARNING_EMITTED

if PILImage is None:
if not _PNG_EXPORT_WARNING_EMITTED:
_logger.info("Skipping PNG export because Pillow is not installed.")
_PNG_EXPORT_WARNING_EMITTED = True
return

output_dir.mkdir(parents=True, exist_ok=True)
pil_image_cls = cast(Any, PILImage)

for frame_index, frame, is_color in _iter_frames(pixel_array):
frame_for_png = _prepare_frame_for_png(frame, is_color)
image = pil_image_cls.fromarray(frame_for_png)
filename = output_dir / f"{file_stem}_frame_{frame_index:04d}.png"
image.save(filename)


def get_test_dicoms(folder_path: str | None = None):
"""Use pydicom package's embedded test DICOM files for testing or a custom folder of DICOM files."""
Expand Down Expand Up @@ -72,6 +179,8 @@
default_decoder_error_message = f"{e}"
default_decoder_errored = True

# Remove and cache the other default decoder plugins first
_remove_default_plugins()
# Register the nvimgcodec decoder plugin and unregister it after each use.
register_as_decoder_plugin()
try:
Expand All @@ -82,31 +191,42 @@
nvimgcodec_decoder_errored = True
finally:
unregister_as_decoder_plugin()
_restore_default_plugins()

if default_decoder_errored and nvimgcodec_decoder_errored:
print(
_logger.info(
f"All decoders encountered errors for transfer syntax {transfer_syntax} in {Path(path).name}:\n"
f"Default decoder error: {default_decoder_error_message}\n"
f"nvimgcodec decoder error: {nvimgcodec_decoder_error_message}"
)
return
elif nvimgcodec_decoder_errored and not default_decoder_errored:
raise AssertionError(f"nvimgcodec decoder errored: {nvimgcodec_decoder_errored} but default decoder succeeded")
raise AssertionError(
f"nvimgcodec decoder errored but default decoder succeeded for transfer syntax {transfer_syntax}"
)

assert baseline_pixels.shape == nv_pixels.shape, f"Shape mismatch for {Path(path).name}"
assert baseline_pixels.dtype == nv_pixels.dtype, f"Dtype mismatch for {Path(path).name}"
np.testing.assert_allclose(baseline_pixels, nv_pixels, rtol=rtol, atol=atol)
assert baseline_pixels.shape == nv_pixels.shape, f"Shape mismatch with transfer syntax {transfer_syntax}"
assert baseline_pixels.dtype == nv_pixels.dtype, f"Dtype mismatch with transfer syntax {transfer_syntax}"
try:
np.testing.assert_allclose(baseline_pixels, nv_pixels, rtol=rtol, atol=atol)
except AssertionError as e:
raise AssertionError(f"Pixels values mismatch with transfer syntax {transfer_syntax}") from e


def performance_test_nvimgcodec_decoder_against_defaults(folder_path: str | None = None) -> None:
def performance_test_nvimgcodec_decoder_against_defaults(
folder_path: str | None = None, png_output_dir: str | None = None
) -> None:
"""Test and compare the performance of the nvimgcodec decoder against the default decoders
with all DICOM files of supported transfer syntaxes in a custom folder or pidicom dataset"""
with all DICOM files of supported transfer syntaxes in a custom folder or pydicom embedded dataset.

If `png_output_dir` is provided, decoded frames are saved as PNG files for both decoders."""

total_baseline_time = 0.0
total_nvimgcodec_time = 0.0

files_tested_with_perf: dict[str, dict[str, Any]] = {} # key: path, value: performance_metrics
files_with_errors = []
png_root = Path(png_output_dir).expanduser() if png_output_dir else None

try:
unregister_as_decoder_plugin() # Make sure nvimgcodec decoder plugin is not registered
Expand All @@ -118,32 +238,44 @@
ds_default = dcmread(path)
transfer_syntax = ds_default.file_meta.TransferSyntaxUID
start = time.perf_counter()
_ = ds_default.pixel_array
baseline_pixels = ds_default.pixel_array
baseline_execution_time = time.perf_counter() - start
total_baseline_time += baseline_execution_time

perf: dict[str, Any] = {}
perf["transfer_syntax"] = transfer_syntax
perf["baseline_execution_time"] = baseline_execution_time
files_tested_with_perf[path] = perf

if png_root is not None:
baseline_dir = png_root / Path(path).stem / "default"
_save_frames_as_png(baseline_pixels, baseline_dir, Path(path).stem)
except Exception:
files_with_errors.append(Path(path).name)
continue

_remove_default_plugins()
# Register the nvimgcodec decoder plugin and unregister it after each use.
register_as_decoder_plugin()

combined_perf = {}
for path, perf in files_tested_with_perf.items():
try:
ds_custom = dcmread(path)
start = time.perf_counter()
_ = ds_custom.pixel_array
nv_pixels = ds_custom.pixel_array
perf["nvimgcodec_execution_time"] = time.perf_counter() - start
total_nvimgcodec_time += perf["nvimgcodec_execution_time"]
combined_perf[path] = perf
except Exception:

if png_root is not None:
nv_dir = png_root / Path(path).stem / "nvimgcodec"
_save_frames_as_png(nv_pixels, nv_dir, Path(path).stem)
except Exception as e:
_logger.info(f"Error decoding {path} with nvimgcodec decoder: {e}")
continue
unregister_as_decoder_plugin()
_restore_default_plugins()

# Performance of the nvimgcodec decoder against the default decoders
# with all DICOM files of supported transfer syntaxes
Expand All @@ -167,11 +299,33 @@
print(f"\n\n__Files not tested due to errors encountered by default decoders__: \n{files_with_errors}")


def _remove_default_plugins():
"""Remove the default plugins from the supported decoder classes."""

global _DEFAULT_PLUGIN_CACHE
for decoder_class in SUPPORTED_DECODER_CLASSES:
_DEFAULT_PLUGIN_CACHE[decoder_class.UID.name] = (
decoder_class._available
) # while box, no API to get DecodeFunction
_logger.info(f"Removing default plugins of {decoder_class}: {decoder_class.available_plugins}.")
decoder_class._available = {} # remove all plugins, ref is still held by _DEFAULT_PLUGIN_CACHE
_logger.info(f"Removed default plugins of {decoder_class}: {decoder_class.available_plugins}.")


def _restore_default_plugins():
"""Restore the default plugins to the supported decoder classes."""
for decoder_class in SUPPORTED_DECODER_CLASSES:
decoder_class._available = _DEFAULT_PLUGIN_CACHE[decoder_class.UID.name] # restore all plugins
_logger.info(f"Restored default plugins of {decoder_class}: {decoder_class.available_plugins}.")


if __name__ == "__main__":

# Use pytest to test the functionality with pydicom embedded DICOM files of supported transfer syntaxes individually
# python -m pytest test_decoder_nvimgcodec.py
#
# The following compares the performance of the nvimgcodec decoder against the default decoders
# with DICOM files in pidicom embedded dataset or an optional custom folder
performance_test_nvimgcodec_decoder_against_defaults() # e.g. "/tmp/multi-frame-dcm"
# with DICOM files in pydicom embedded dataset or an optional custom folder
performance_test_nvimgcodec_decoder_against_defaults(
png_output_dir="decoded_png"
) # or use ("/tmp/multi-frame-dcm", png_output_dir="decoded_png")