Skip to content

Commit ce6210e

Browse files
authored
Fix code and improve unit tests to ensure nvimgcodec based decoder is tested alone (#573)
* Added saving decoded pixels for in deepth review if needed Signed-off-by: M Q <mingmelvinq@nvidia.com> * Fixed linting complaints Signed-off-by: M Q <mingmelvinq@nvidia.com> * Fixed the code and improve the tests with failed tests to be addressed. Signed-off-by: M Q <mingmelvinq@nvidia.com> * Force YBR for JEPG baseline, and test nvimgcodec without any decault decoders Signed-off-by: M Q <mingmelvinq@nvidia.com> * Critical changes make uncompressed images matching pydicom default decoders. Signed-off-by: M Q <mingmelvinq@nvidia.com> * Removed support for 12bit "JPEG Extended, Process 2+4" Signed-off-by: M Q <mingmelvinq@nvidia.com> * Address review comments including from AI agent Signed-off-by: M Q <mingmelvinq@nvidia.com> * Added reason for ignoring dcm files known to fail to uncompress Signed-off-by: M Q <mingmelvinq@nvidia.com> * Updated the notes on perf test results Signed-off-by: M Q <mingmelvinq@nvidia.com> * Explicitly minimized lazy loading impact and added comments on it. Signed-off-by: M Q <mingmelvinq@nvidia.com> * Updated doc sentences Signed-off-by: M Q <mingmelvinq@nvidia.com> * Editorial changes made to comments Signed-off-by: M Q <mingmelvinq@nvidia.com> --------- Signed-off-by: M Q <mingmelvinq@nvidia.com>
1 parent 428f778 commit ce6210e

3 files changed

Lines changed: 264 additions & 83 deletions

File tree

monai/deploy/operators/decoder_nvimgcodec.py

Lines changed: 59 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright 2025 MONAI Consortium
1+
# Copyright 2025-2026 MONAI Consortium
22
# Licensed under the Apache License, Version 2.0 (the "License");
33
# you may not use this file except in compliance with the License.
44
# You may obtain a copy of the License at
@@ -13,7 +13,6 @@
1313
This decoder plugin for nvimgcodec <https://github.com/NVIDIA/nvImageCodec> decompresses
1414
encoded Pixel Data for the following transfer syntaxes:
1515
JPEGBaseline8Bit, 1.2.840.10008.1.2.4.50, JPEG Baseline (Process 1)
16-
JPEGExtended12Bit, 1.2.840.10008.1.2.4.51, JPEG Extended (Process 2 & 4)
1716
JPEGLossless, 1.2.840.10008.1.2.4.57, JPEG Lossless, Non-Hierarchical (Process 14)
1817
JPEGLosslessSV1, 1.2.840.10008.1.2.4.70, JPEG Lossless, Non-Hierarchical, First-Order Prediction
1918
JPEG2000Lossless, 1.2.840.10008.1.2.4.90, JPEG 2000 Image Compression (Lossless Only)
@@ -75,7 +74,6 @@ def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes
7574
JPEG2000Decoder,
7675
JPEG2000LosslessDecoder,
7776
JPEGBaseline8BitDecoder,
78-
JPEGExtended12BitDecoder,
7977
JPEGLosslessDecoder,
8078
JPEGLosslessSV1Decoder,
8179
)
@@ -118,7 +116,6 @@ def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes
118116
# Supported decoder classes of the corresponding transfer syntaxes by this decoder plugin.
119117
SUPPORTED_DECODER_CLASSES = [
120118
JPEGBaseline8BitDecoder, # 1.2.840.10008.1.2.4.50, JPEG Baseline (Process 1)
121-
JPEGExtended12BitDecoder, # 1.2.840.10008.1.2.4.51, JPEG Extended (Process 2 & 4)
122119
JPEGLosslessDecoder, # 1.2.840.10008.1.2.4.57, JPEG Lossless, Non-Hierarchical (Process 14)
123120
JPEGLosslessSV1Decoder, # 1.2.840.10008.1.2.4.70, JPEG Lossless, Non-Hierarchical, First-Order Prediction
124121
JPEG2000LosslessDecoder, # 1.2.840.10008.1.2.4.90, JPEG 2000 Image Compression (Lossless Only)
@@ -146,6 +143,8 @@ def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes
146143
for x in SUPPORTED_TRANSFER_SYNTAXES
147144
}
148145

146+
DEFAULT_PI_NAME = "nvimgcodec_default_photometric_interpretation"
147+
149148

150149
# Required for decoder plugin
151150
def is_available(uid: UID) -> bool:
@@ -180,16 +179,18 @@ def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes:
180179
if not is_available(tsyntax):
181180
raise ValueError(f"Transfer syntax {tsyntax} not supported; see details in the debug log.")
182181

183-
runner.set_frame_option(runner.index, "decoding_plugin", "nvimgcodec") # type: ignore[attr-defined]
184-
182+
# runner.set_frame_option(runner.index, "decoding_plugin", NVIMGCODEC_PLUGIN_LABEL) # type: ignore[attr-defined]
183+
# in pydicom v3.1.0 can use the above call, but do we want to limit to this plugin?
185184
is_jpeg2k = tsyntax in JPEG2000TransferSyntaxes
186185
samples_per_pixel = runner.samples_per_pixel
187186
photometric_interpretation = runner.photometric_interpretation
188187

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

195196
# Check if RGB conversion requested (following Pillow decoder logic)
@@ -199,16 +200,18 @@ def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes:
199200

200201
decoder = _get_decoder_resources()
201202
params = _get_decode_params(runner)
202-
decoded_surface = decoder.decode(src, params=params).cpu()
203-
np_surface = np.ascontiguousarray(np.asarray(decoded_surface))
204-
205-
# Handle JPEG2000-specific postprocessing separately
206-
if is_jpeg2k:
207-
np_surface = _jpeg2k_postprocess(np_surface, runner)
203+
decoded_data = decoder.decode(src, params=params)
204+
if decoded_data:
205+
decoded_data = decoded_data.cpu()
206+
else:
207+
raise RuntimeError(f"Decoding failed: decoder.decode() returned a falsy value of type {type(decoded_data)}")
208+
np_surface = np.ascontiguousarray(np.asarray(decoded_data))
208209

209210
# Update photometric interpretation if we converted to RGB, or JPEG 2000 YBR*
210211
if convert_to_rgb or photometric_interpretation in (PI.YBR_ICT, PI.YBR_RCT):
211-
runner.set_frame_option(runner.index, "photometric_interpretation", PI.RGB) # type: ignore[attr-defined]
212+
# runner.set_frame_option(runner.index, "photometric_interpretation", PI.RGB) # type: ignore[attr-defined]
213+
# in pydicom v3.1.0 can use the above call
214+
runner.set_option("photometric_interpretation", PI.RGB)
212215
_logger.debug(
213216
"Set photometric_interpretation to RGB after conversion"
214217
if convert_to_rgb
@@ -227,7 +230,7 @@ def _get_decoder_resources() -> Any:
227230
global _NVIMGCODEC_DECODER
228231

229232
if _NVIMGCODEC_DECODER is None:
230-
_NVIMGCODEC_DECODER = nvimgcodec.Decoder()
233+
_NVIMGCODEC_DECODER = nvimgcodec.Decoder(options=":fancy_upsampling=1")
231234

232235
return _NVIMGCODEC_DECODER
233236

@@ -252,7 +255,25 @@ def _get_decode_params(runner: RunnerBase) -> Any:
252255

253256
# Access DICOM metadata from the runner
254257
samples_per_pixel = runner.samples_per_pixel
255-
photometric_interpretation = runner.photometric_interpretation
258+
photometric_interpretation = runner.get_option(DEFAULT_PI_NAME, runner.photometric_interpretation)
259+
260+
# we will change the PI at the end of the function if we convert to rgb
261+
# but we need to have original PI to decide if we need to apply color transform for JPEG
262+
if runner.get_option(DEFAULT_PI_NAME, None) is None:
263+
runner.set_option(DEFAULT_PI_NAME, photometric_interpretation)
264+
265+
transfer_syntax = runner.transfer_syntax
266+
as_rgb = runner.get_option("as_rgb", False)
267+
force_rgb = runner.get_option("force_rgb", False)
268+
force_ybr = runner.get_option("force_ybr", False)
269+
270+
_logger.debug("DecodeRunner options:")
271+
_logger.debug(f"transfer_syntax: {transfer_syntax}")
272+
_logger.debug(f"photometric_interpretation: {photometric_interpretation}")
273+
_logger.debug(f"samples_per_pixel: {samples_per_pixel}")
274+
_logger.debug(f"as_rgb: {as_rgb}")
275+
_logger.debug(f"force_rgb: {force_rgb}")
276+
_logger.debug(f"force_ybr: {force_ybr}")
256277

257278
# Default: keep color space unchanged
258279
color_spec = nvimgcodec.ColorSpec.UNCHANGED
@@ -261,17 +282,26 @@ def _get_decode_params(runner: RunnerBase) -> Any:
261282
if samples_per_pixel > 1:
262283
# JPEG 2000 color transformations are always returned as RGB (matches Pillow)
263284
if photometric_interpretation in (PI.YBR_ICT, PI.YBR_RCT):
264-
color_spec = nvimgcodec.ColorSpec.RGB
285+
color_spec = nvimgcodec.ColorSpec.SRGB
265286
_logger.debug(
266287
f"Using RGB color spec for JPEG 2000 color transformation " f"(PI: {photometric_interpretation})"
267288
)
289+
elif transfer_syntax in (JPEGBaseline8BitDecoder.UID):
290+
# approach is similar to pylibjpeg from pydicom - for ybr full and 422 it needs conversion from ycbcr to rgb
291+
# for any other PI it just skips color conversion (ignoring what is inside jpeg header)
292+
if photometric_interpretation in (PI.YBR_FULL, PI.YBR_FULL_422):
293+
# we want to apply ycbcr -> rgb conversion
294+
color_spec = nvimgcodec.ColorSpec.SRGB
295+
else:
296+
# ignore color conversion as image should already be in rgb or grayscale (but jpeg header may contain wrong data)
297+
color_spec = nvimgcodec.ColorSpec.SYCC
268298
else:
269299
# Check the as_rgb option - same as Pillow decoder
270-
convert_to_rgb = runner.get_option("as_rgb", False) and "YBR" in photometric_interpretation
300+
convert_to_rgb = as_rgb or (force_rgb and "YBR" in photometric_interpretation)
271301

272302
if convert_to_rgb:
273303
# Convert YCbCr to RGB as requested
274-
color_spec = nvimgcodec.ColorSpec.RGB
304+
color_spec = nvimgcodec.ColorSpec.SRGB
275305
_logger.debug(f"Using RGB color spec (as_rgb=True, PI: {photometric_interpretation})")
276306
else:
277307
# Keep YCbCr unchanged - matches Pillow's image.draft("YCbCr") behavior
@@ -280,7 +310,10 @@ def _get_decode_params(runner: RunnerBase) -> Any:
280310
)
281311
else:
282312
# Grayscale image - keep unchanged
283-
_logger.debug(f"Using UNCHANGED color spec for grayscale image " f"(samples_per_pixel: {samples_per_pixel})")
313+
_logger.debug(
314+
f"Using UNCHANGED color spec for grayscale image (samples_per_pixel: {samples_per_pixel},"
315+
f" PI: {photometric_interpretation}, transfer_syntax: {transfer_syntax})"
316+
)
284317

285318
return nvimgcodec.DecodeParams(
286319
allow_any_depth=True,
@@ -289,7 +322,9 @@ def _get_decode_params(runner: RunnerBase) -> Any:
289322

290323

291324
def _jpeg2k_precision_bits(runner: DecodeRunner) -> tuple[int, int]:
292-
precision = runner.get_frame_option(runner.index, "j2k_precision", runner.bits_stored) # type: ignore[attr-defined]
325+
# precision = runner.get_frame_option(runner.index, "j2k_precision", runner.bits_stored) # type: ignore[attr-defined]
326+
# in pydicom v3.1.0 can use the above call
327+
precision = runner.get_option("j2k_precision", runner.bits_stored)
293328
if 0 < precision <= 8:
294329
return precision, 8
295330
elif 8 < precision <= 16:
@@ -302,45 +337,6 @@ def _jpeg2k_precision_bits(runner: DecodeRunner) -> tuple[int, int]:
302337
raise ValueError(f"Only 'Bits Stored' values up to 16 are supported, got {precision}")
303338

304339

305-
def _jpeg2k_sign_correction(arr, dtype, bits_allocated):
306-
arr = arr.view(dtype)
307-
arr -= np.int32(2 ** (bits_allocated - 1))
308-
_logger.debug("Applied J2K sign correction")
309-
return arr
310-
311-
312-
def _jpeg2k_bitshift(arr, bit_shift):
313-
np.right_shift(arr, bit_shift, out=arr)
314-
_logger.debug(f"Applied J2K bit shift: {bit_shift} bits")
315-
return arr
316-
317-
318-
def _jpeg2k_postprocess(np_surface, runner):
319-
"""Handle JPEG 2000 postprocessing: sign correction and bit shifts."""
320-
precision = runner.get_frame_option(runner.index, "j2k_precision", runner.bits_stored)
321-
bits_allocated = runner.get_frame_option(runner.index, "bits_allocated", runner.bits_allocated)
322-
is_signed = runner.pixel_representation
323-
if runner.get_option("apply_j2k_sign_correction", False):
324-
is_signed = runner.get_frame_option(runner.index, "j2k_is_signed", is_signed)
325-
326-
# Sign correction for signed data
327-
if is_signed and runner.pixel_representation == 1:
328-
dtype = runner.frame_dtype(runner.index)
329-
buffer = bytearray(np_surface.tobytes())
330-
arr = np.frombuffer(buffer, dtype=f"<u{dtype.itemsize}")
331-
np_surface = _jpeg2k_sign_correction(arr, dtype, bits_allocated)
332-
333-
# Bit shift if bits_allocated > precision
334-
bit_shift = bits_allocated - precision
335-
if bit_shift:
336-
buffer = bytearray(np_surface.tobytes() if isinstance(np_surface, np.ndarray) else np_surface)
337-
dtype = runner.frame_dtype(runner.index)
338-
arr = np.frombuffer(buffer, dtype=dtype)
339-
np_surface = _jpeg2k_bitshift(arr, bit_shift)
340-
341-
return np_surface
342-
343-
344340
def _is_nvimgcodec_available() -> bool:
345341
"""Return ``True`` if nvimgcodec is available, ``False`` otherwise."""
346342

@@ -416,7 +412,7 @@ def register_as_decoder_plugin(module_path: str | None = None) -> bool:
416412
continue
417413

418414
decoder_class.add_plugin(NVIMGCODEC_PLUGIN_LABEL, (module_path, str(func_name)))
419-
_logger.info(
415+
_logger.debug(
420416
f"Added plugin for transfer syntax {decoder_class.UID}: "
421417
f"{NVIMGCODEC_PLUGIN_LABEL} with {func_name} in module path {module_path}."
422418
)
@@ -437,7 +433,8 @@ def unregister_as_decoder_plugin() -> bool:
437433
for decoder_class in SUPPORTED_DECODER_CLASSES:
438434
if NVIMGCODEC_PLUGIN_LABEL in decoder_class.available_plugins:
439435
decoder_class.remove_plugin(NVIMGCODEC_PLUGIN_LABEL)
440-
_logger.info(f"Unregistered plugin for transfer syntax {decoder_class.UID}: {NVIMGCODEC_PLUGIN_LABEL}")
436+
_logger.debug(f"Unregistered plugin for transfer syntax {decoder_class.UID}: {NVIMGCODEC_PLUGIN_LABEL}")
437+
_logger.info(f"Unregistered plugin {NVIMGCODEC_PLUGIN_LABEL} for all supported transfer syntaxes.")
441438

442439
return True
443440

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ ignore =
5555
B026
5656
# B909 editing a loop's mutable iterable often leads to unexpected results/bugs
5757
B909
58+
# F824 global variable is unused: name is never assigned in scope
59+
F824
5860

5961
per_file_ignores =
6062
# e.g. F403 'from holoscan.conditions import *' used; unable to detect undefined names

0 commit comments

Comments
 (0)