@@ -75,9 +75,11 @@ def _decode_frame(src: bytes, runner: DecodeRunner) -> bytearray | bytes
7575 JPEGLosslessDecoder ,
7676 JPEGLosslessSV1Decoder ,
7777)
78+
7879from pydicom .pixels .decoders .base import DecodeRunner
7980from 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
8284try :
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
132136if nvimgcodec :
133137 _NVIMGCODEC_DECODER : Any = None
134- _NVIMGCODEC_DECODE_PARAMS : Any = None
135138else : # pragma: no cover - nvimgcodec not installed
136139 _NVIMGCODEC_DECODER = None
137- _NVIMGCODEC_DECODE_PARAMS = None
138140
139141# Required for decoder plugin
140142DECODER_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+
226352def _is_nvimgcodec_available () -> bool :
227353 """Return ``True`` if nvimgcodec is available, ``False`` otherwise."""
228354
0 commit comments