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
1313This decoder plugin for nvimgcodec <https://github.com/NVIDIA/nvImageCodec> decompresses
1414encoded 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.
119117SUPPORTED_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
151150def 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
291324def _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-
344340def _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
0 commit comments