Skip to content

Commit 2131b05

Browse files
jantonguiraoMMelQin
authored andcommitted
Fix nvimgcodec decoder (#571)
* Fix handling of JPEG color space * Added JPEG2000 precision support * Updates PhotometricInterpretation to RGB when color space conversion is applied Passes all JPEG, JPEG2000 and HTJ2K tests Signed-off-by: Joaquin Anton Guirao <[email protected]> Signed-off-by: M Q <[email protected]>
1 parent 66ae78c commit 2131b05

File tree

2 files changed

+221
-204
lines changed

2 files changed

+221
-204
lines changed

monai/deploy/operators/decoder_nvimgcodec.py

Lines changed: 159 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,11 @@ def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes
7575
JPEGLosslessDecoder,
7676
JPEGLosslessSV1Decoder,
7777
)
78+
7879
from pydicom.pixels.decoders.base import DecodeRunner
7980
from pydicom.pixels.utils import _passes_version_check
80-
from pydicom.uid import UID
81+
from pydicom.uid import UID, JPEG2000TransferSyntaxes, JPEGTransferSyntaxes
82+
from pydicom.pixels.common import PhotometricInterpretation as PI
8183

8284
try:
8385
import cupy as cp
@@ -128,13 +130,13 @@ def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes
128130

129131
_logger = logging.getLogger(__name__)
130132

131-
# Lazy singletons for nvimgcodec resources; initialized on first use
133+
134+
# Lazy singleton for nvimgcodec decoder; initialized on first use
135+
# Decode params are created per-decode based on image characteristics
132136
if nvimgcodec:
133137
_NVIMGCODEC_DECODER: Any = None
134-
_NVIMGCODEC_DECODE_PARAMS: Any = None
135138
else: # pragma: no cover - nvimgcodec not installed
136139
_NVIMGCODEC_DECODER = None
137-
_NVIMGCODEC_DECODE_PARAMS = None
138140

139141
# Required for decoder plugin
140142
DECODER_DEPENDENCIES = {
@@ -166,63 +168,187 @@ def is_available(uid: UID) -> bool:
166168
return True
167169

168170

169-
def _get_decoder_resources() -> tuple[Any, Any]:
170-
"""Return cached nvimgcodec decoder and decode parameters."""
171+
def _get_decoder_resources() -> Any:
172+
"""Return cached nvimgcodec decoder (parameters are created per decode)."""
171173

172174
if nvimgcodec is None:
173175
raise ImportError("nvimgcodec package is not available.")
174176

175-
global _NVIMGCODEC_DECODER, _NVIMGCODEC_DECODE_PARAMS
177+
global _NVIMGCODEC_DECODER
176178

177179
if _NVIMGCODEC_DECODER is None:
178180
_NVIMGCODEC_DECODER = nvimgcodec.Decoder()
179-
if _NVIMGCODEC_DECODE_PARAMS is None:
180-
_NVIMGCODEC_DECODE_PARAMS = nvimgcodec.DecodeParams(
181-
allow_any_depth=True,
182-
color_spec=nvimgcodec.ColorSpec.UNCHANGED,
181+
182+
return _NVIMGCODEC_DECODER
183+
184+
185+
def _get_decode_params(runner: DecodeRunner) -> Any:
186+
"""Create decode parameters based on DICOM image characteristics.
187+
188+
Mimics the behavior of pydicom's Pillow decoder:
189+
- By default, keeps JPEG data in YCbCr format (no conversion)
190+
- If as_rgb option is True and photometric interpretation is YBR*, converts to RGB
191+
192+
This matches the logic in pydicom.pixels.decoders.pillow._decode_frame()
193+
194+
Args:
195+
runner: The DecodeRunner instance with access to DICOM metadata.
196+
197+
Returns:
198+
nvimgcodec.DecodeParams: Configured decode parameters.
199+
"""
200+
if nvimgcodec is None:
201+
raise ImportError("nvimgcodec package is not available.")
202+
203+
# Access DICOM metadata from the runner
204+
samples_per_pixel = runner.samples_per_pixel
205+
photometric_interpretation = runner.photometric_interpretation
206+
207+
# Default: keep color space unchanged
208+
color_spec = nvimgcodec.ColorSpec.UNCHANGED
209+
210+
# Import PhotometricInterpretation enum for JPEG 2000 color transformations
211+
from pydicom.pixels.common import PhotometricInterpretation as PI
212+
213+
# For multi-sample (color) images, check if RGB conversion is requested
214+
if samples_per_pixel > 1:
215+
# JPEG 2000 color transformations are always returned as RGB (matches Pillow)
216+
if photometric_interpretation in (PI.YBR_ICT, PI.YBR_RCT):
217+
color_spec = nvimgcodec.ColorSpec.RGB
218+
_logger.debug(
219+
f"Using RGB color spec for JPEG 2000 color transformation "
220+
f"(PI: {photometric_interpretation})"
221+
)
222+
else:
223+
# Check the as_rgb option - same as Pillow decoder
224+
convert_to_rgb = runner.get_option("as_rgb", False) and "YBR" in photometric_interpretation
225+
226+
if convert_to_rgb:
227+
# Convert YCbCr to RGB as requested
228+
color_spec = nvimgcodec.ColorSpec.RGB
229+
_logger.debug(
230+
f"Using RGB color spec (as_rgb=True, PI: {photometric_interpretation})"
231+
)
232+
else:
233+
# Keep YCbCr unchanged - matches Pillow's image.draft("YCbCr") behavior
234+
_logger.debug(
235+
f"Using UNCHANGED color spec to preserve YCbCr "
236+
f"(as_rgb=False, PI: {photometric_interpretation})"
237+
)
238+
else:
239+
# Grayscale image - keep unchanged
240+
_logger.debug(
241+
f"Using UNCHANGED color spec for grayscale image "
242+
f"(samples_per_pixel: {samples_per_pixel})"
183243
)
244+
245+
return nvimgcodec.DecodeParams(
246+
allow_any_depth=True,
247+
color_spec=color_spec,
248+
)
249+
250+
251+
def _jpeg2k_precision_bits(runner):
252+
precision = runner.get_frame_option(runner.index, "j2k_precision", runner.bits_stored)
253+
if 0 < precision <= 8:
254+
return precision, 8
255+
elif 8 < precision <= 16:
256+
if runner.samples_per_pixel > 1:
257+
_logger.warning(
258+
f"JPEG 2000 with {precision}-bit multi-sample data may have precision issues with some decoders"
259+
)
260+
return precision, 16
261+
else:
262+
raise ValueError(f"Only 'Bits Stored' values up to 16 are supported, got {precision}")
184263

185-
return _NVIMGCODEC_DECODER, _NVIMGCODEC_DECODE_PARAMS
186264

265+
def _jpeg2k_sign_correction(arr, dtype, bits_allocated):
266+
arr = arr.view(dtype)
267+
arr -= np.int32(2 ** (bits_allocated - 1))
268+
_logger.debug("Applied J2K sign correction")
269+
return arr
187270

188-
# Required function for decoder plugin (specific signature but flexible name to be registered to a decoder)
189-
# see also https://github.com/pydicom/pydicom/blob/v3.0.1/src/pydicom/pixels/decoders/base.py#L334
190-
def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes:
191-
"""Return the decoded image data in `src` as a :class:`bytearray` or :class:`bytes`.
192271

193-
This function is called by the pydicom.pixels.decoders.base.DecodeRunner.decode method.
272+
def _jpeg2k_bitshift(arr, bit_shift):
273+
np.right_shift(arr, bit_shift, out=arr)
274+
_logger.debug(f"Applied J2K bit shift: {bit_shift} bits")
275+
return arr
194276

195-
Args:
196-
src (bytes): An encoded frame of pixel data to be passed to the decoding plugins.
197-
runner (DecodeRunner): The runner instance that manages the decoding process.
198277

199-
Returns:
200-
bytearray | bytes: The decoded frame as a :class:`bytearray` or :class:`bytes`.
201-
"""
278+
def _jpeg2k_postprocess(np_surface, runner):
279+
"""Handle JPEG 2000 postprocessing: sign correction and bit shifts."""
280+
precision = runner.get_frame_option(runner.index, "j2k_precision", runner.bits_stored)
281+
bits_allocated = runner.get_frame_option(runner.index, "bits_allocated", runner.bits_allocated)
282+
is_signed = runner.pixel_representation
283+
if runner.get_option("apply_j2k_sign_correction", False):
284+
is_signed = runner.get_frame_option(runner.index, "j2k_is_signed", is_signed)
285+
286+
# Sign correction for signed data
287+
if is_signed and runner.pixel_representation == 1:
288+
dtype = runner.frame_dtype(runner.index)
289+
buffer = bytearray(np_surface.tobytes())
290+
arr = np.frombuffer(buffer, dtype=f"<u{dtype.itemsize}")
291+
np_surface = _jpeg2k_sign_correction(arr, dtype, bits_allocated)
202292

203-
# The frame data bytes object is passed in by the runner, which it gets via pydicom.encaps.get_frame
204-
# and other pydicom.encaps functions, e.g. pydicom.encaps.generate_frames, generate_fragmented_frames, etc.
205-
# So we can directly decode the frame using nvimgcodec.
293+
# Bit shift if bits_allocated > precision
294+
bit_shift = bits_allocated - precision
295+
if bit_shift:
296+
buffer = bytearray(np_surface.tobytes() if isinstance(np_surface, np.ndarray) else np_surface)
297+
dtype = runner.frame_dtype(runner.index)
298+
arr = np.frombuffer(buffer, dtype=dtype)
299+
np_surface = _jpeg2k_bitshift(arr, bit_shift)
206300

207-
# Though a fragment may not contain encoded data from more than one frame, the encoded data from one frame
208-
# may span multiple fragments to support buffering during compression or to avoid exceeding the maximum size
209-
# of a fixed length fragment, see https://dicom.nema.org/dicom/2013/output/chtml/part05/sect_8.2.html.
210-
# In this case, pydicom.encaps.generate_fragmented_frames yields a tuple of bytes for each frame and
211-
# each tuple element is passed in as the src argument.
301+
return np_surface
212302

213-
# Double check if the transfer syntax is supported although the runner should be correct.
303+
def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes:
304+
"""Return the decoded image data in `src` as a :class:`bytearray` or :class:`bytes`."""
214305
tsyntax = runner.transfer_syntax
215306
_logger.debug(f"transfer_syntax: {tsyntax}")
216307

217308
if not is_available(tsyntax):
218309
raise ValueError(f"Transfer syntax {tsyntax} not supported; see details in the debug log.")
219310

220-
decoder, params = _get_decoder_resources()
311+
runner.set_frame_option(runner.index, "decoding_plugin", "nvimgcodec")
312+
313+
is_jpeg2k = tsyntax in JPEG2000TransferSyntaxes
314+
samples_per_pixel = runner.samples_per_pixel
315+
photometric_interpretation = runner.photometric_interpretation
316+
317+
# --- JPEG 2000: Precision/Bit depth ---
318+
if is_jpeg2k:
319+
precision, bits_allocated = _jpeg2k_precision_bits(runner)
320+
runner.set_frame_option(runner.index, "bits_allocated", bits_allocated)
321+
_logger.debug(f"Set bits_allocated to {bits_allocated} for J2K precision {precision}")
322+
323+
# Check if RGB conversion requested (following Pillow decoder logic)
324+
convert_to_rgb = (
325+
samples_per_pixel > 1
326+
and runner.get_option("as_rgb", False)
327+
and "YBR" in photometric_interpretation
328+
)
329+
330+
decoder = _get_decoder_resources()
331+
params = _get_decode_params(runner)
221332
decoded_surface = decoder.decode(src, params=params).cpu()
222333
np_surface = np.ascontiguousarray(np.asarray(decoded_surface))
334+
335+
# Handle JPEG2000-specific postprocessing separately
336+
if is_jpeg2k:
337+
np_surface = _jpeg2k_postprocess(np_surface, runner)
338+
339+
# Update photometric interpretation if we converted to RGB, or JPEG 2000 YBR*
340+
if convert_to_rgb or photometric_interpretation in (PI.YBR_ICT, PI.YBR_RCT):
341+
runner.set_frame_option(runner.index, "photometric_interpretation", PI.RGB)
342+
_logger.debug(
343+
"Set photometric_interpretation to RGB after conversion"
344+
if convert_to_rgb
345+
else f"Set photometric_interpretation to RGB for {photometric_interpretation}"
346+
)
347+
223348
return np_surface.tobytes()
224349

225350

351+
226352
def _is_nvimgcodec_available() -> bool:
227353
"""Return ``True`` if nvimgcodec is available, ``False`` otherwise."""
228354

0 commit comments

Comments
 (0)