Skip to content

nvImageCodec JPEGLossless 9-15 bit Guard#582

Open
bluna301 wants to merge 1 commit intoProject-MONAI:mainfrom
bluna301:bl/nvimgcodec_jpeglossless_bugfix
Open

nvImageCodec JPEGLossless 9-15 bit Guard#582
bluna301 wants to merge 1 commit intoProject-MONAI:mainfrom
bluna301:bl/nvimgcodec_jpeglossless_bugfix

Conversation

@bluna301
Copy link
Copy Markdown
Contributor

@bluna301 bluna301 commented May 4, 2026

Tighten JPEGLossless fallback guard to 9 <= BitsStored <= 15

Background

When testing MAPs built with monai-deploy-app-sdk==3.5.0, two cases (one CT, one MR) were identified that produced zero-segmentations after model processing. Both cases are from Philips scanners with BitsStored = 12 and Transfer Syntax JPEGLosslessSV1 (1.2.840.10008.1.2.4.70). Further investigation of these cases showed that decoder_nvimgcodec.py produced a zero-filled output image buffer - essentially a silent decoding error which propagates and manifests as an empty segmentation.

When testing older MAPs built with monai-deploy-app-sdk==3.1.0 on these cases, non-zero segmentations are produced. Non-nvImageCodec decoders (e.g. GDCM) produce a non-zero-filled output image buffer.

See below existing decoder_nvimgcodec.py vs. GDCM behavior for one of the files in the CT case - the decoder pipeline log shows that nvimgcodec processes the file and produces a zero output buffer, instead of falling back to subsequent decoders like GDCM:

File  : IMG00233.dcm
       TS=1.2.840.10008.1.2.4.70  BitsAllocated=16  BitsStored=12
       [nvimgcodec decoder] decoded  shape=(512, 512, 1)  dtype=uint16  all_zero=True  <-- SILENT ZERO-FILL BUG
       [gdcm decoder     ] decoded  shape=(512, 512)  dtype=uint16  all_zero=False
       [decoder pipeline ] decoded  shape=(512, 512)  dtype=uint16  all_zero=True  [via nvimgcodec]  <-- ALL ZERO
       [comparison       ] MISMATCH  <-- PIXEL CORRUPTION  max_diff=2550  mean_diff=468.1005

There is a file that meets this same criteria (BitsStored = 12, Transfer Syntax JPEGLosslessSV1 [1.2.840.10008.1.2.4.70]) in the decoder_nvimgcodec unit test suite - bad_sequence.dcm. decoder_nvimgcodec.py produces a non-zero image buffer after decoding.

Per-File Analysis

All three files share BitsStored=12 in their DICOM tags, yet produce different outcomes. The difference is entirely in the JPEG byte stream — specifically the SOF3 P byte and segment ordering — neither of which is visible to the Python decoder via runner.bits_stored.

bad_sequence.dcm MR.dcm CT.dcm
Result ✅ nvimgcodec OK ❌ Silent zeros ❌ Silent zeros
Bug triggered None Bug 1: P≠16 Bug 2: DHT before SOF3
Manufacturer Siemens Philips Philips
Model SOMATOM Definition AS+ Ingenia iCT SP
BitsStored (DICOM tag) 12 12 12
SOF3 P byte (actual) 16 12 16
Segment order SOI → SOF3(P=16) → DHT → SOS SOI → SOF3(P=12) → DHT → SOS SOI → COM → DHT → SOF3(P=16) → SOS

bad_sequence.dcm (Siemens — works): Although BitsStored=12 in the DICOM tag, Siemens writes P=16 in the SOF3 header and uses standard segment ordering. From nvjpeg's perspective this is a valid 16-bit lossless stream; it decodes correctly and produces a non-zero image. Note that writing P=16 when BitsStored=12 is itself non-conformant per DICOM (SOF3 P should equal BitsStored, but it happens to match what nvjpeg expects.)

MR.dcm (Philips — Bug 1): Philips writes P=12 in the SOF3 header, correctly matching BitsStored=12 per the DICOM standard. The nvImageCodec JPEG parser maps P=12 to UINT16 (any precision 9–16 maps to UINT16), so canDecode accepts the stream. However, nvjpegDecodeBatched internally assumes all UINT16 lossless streams are P=16. With P=12 the Huffman-coded bitstream uses 12-bit samples; the GPU kernel misreads the entropy-coded data and silently writes zeros.

CT.dcm (Philips — Bug 2): Although BitsStored=12 in the DICOM tag, Philips writes P=16 in the SOF3 header but inserts a COM (comment) marker and a DHT (Huffman table) segment before SOF3 — non-standard ordering that is technically valid per ITU-T T.81 but not handled by nvjpeg. The nvImageCodec parser scans past COM and DHT to find SOF3, correctly reads P=16, and canDecode accepts the stream. However, nvjpegDecodeBatched has its own internal parser that expects SOF3 before DHT. Encountering DHT first, it fails to build the decode tables correctly and silently writes zeros.

Critically, nvjpegDecodeBatchedSupported returns "supported" for both Bug 1 and Bug 2 streams — this is the core defect that makes both failures silent.

Root Cause

The nvimgcodec lossless JPEG decoder (NVJPEG_BACKEND_LOSSLESS_JPEG) silently produces zero-filled output buffers for certain JPEG Lossless (SOF3) DICOM frames. Investigation of the nvImageCodec source identified two independent root causes, both in the closed-source nvjpegDecodeBatched binary:

  1. Sub-16-bit SOF3 precision (Bug 1): When the SOF3 P byte encodes a precision of 9–15 (e.g. Philips scanners writing P=12 for a 12-bit series), nvjpegDecodeBatched silently zero-fills the output buffer.

  2. DHT-before-SOF3 segment ordering (Bug 2): When a Huffman table (DHT) segment appears before the SOF3 marker — non-standard but valid per ITU-T T.81, and emitted by some Philips scanners — nvjpegDecodeBatched again silently zero-fills. nvjpegDecodeBatchedSupported incorrectly returns "supported" in both cases, which is why the failures are silent.

Both failing files have BitsStored=12 in the DICOM tag.

Why BitsStored=8 does not need a guard

Source code analysis of extensions/nvjpeg/lossless_decoder.cpp and src/parsers/jpeg.cpp shows that for P=8, the JPEG parser maps precision to NVIMGCODEC_SAMPLE_DATA_TYPE_UINT8. The lossless decoder's canDecode function then explicitly rejects UINT8 output (it only supports UINT16), returning SAMPLE_TYPE_UNSUPPORTED before nvjpegDecodeBatched is ever called. The framework falls through cleanly to libjpeg-turbo, which decodes correctly. No silent zeros — no guard needed.

Changes

  • Guard condition: runner.bits_stored < 169 <= runner.bits_stored <= 15. This reflects the actual vulnerable range: only precision values that map to UINT16 (so canDecode accepts them) but are not 16-bit (so nvjpegDecodeBatched zero-fills).
  • _JPEG_LOSSLESS_SYNTAXES: Promoted from a per-call tuple to a module-level constant.
  • Module docstring and warning messages: Updated to accurately document the 9–15 range and explain why BitsStored=8 is handled correctly without a guard.
  • Updated nvimgcodec test set: Removed bad_sequence.dcm until nvImageCodec fix implemented

Upstream fix

The correct long-term fix is in extensions/nvjpeg/lossless_decoder.cpp in the nvImageCodec repo: canDecode should reject streams where plane_info[0].precision is not 0 or 16, and pre-scan for DHT-before-SOF3 ordering before calling nvjpegJpegStreamParse. This Python-layer guard is a workaround until that upstream fix is available.

Signed-off-by: bluna301 <luna.bryanr@gmail.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 4, 2026

Walkthrough

This PR adds handling for a limitation in the nvimgcodec decoder: JPEG Lossless (SOF3) streams with BitsStored between 9–15 are unsupported and require fallback to other decoders. The operator now detects this condition, issues a single warning, suppresses pydicom's repeated error logs, and raises NotImplementedError to trigger fallback. Tests conditionally skip affected DICOM inputs to avoid failing on unsupported configurations.

Changes

JPEG Lossless BitsStored Fallback Handling

Layer / File(s) Summary
Constants & Documentation
monai/deploy/operators/decoder_nvimgcodec.py (lines 17–24, 139–158)
Module docstring clarified with BitsStored limitations for JPEG Lossless; internal constants _JPEG_LOSSLESS_SYNTAXES, _BITS_STORED_FALLBACK_WARNED, _DECODE_SUCCESS_LOGGED, and _SuppressFallbackFilter class added to identify affected syntaxes, deduplicate warnings, and suppress pydicom logs.
Decoder Guard Logic
monai/deploy/operators/decoder_nvimgcodec.py (lines 209–235)
_decode_frame detects unsupported BitsStored range (9–15) for affected lossless transfer syntaxes, logs a single warning per case, installs a logging filter on pydicom.pixels.decoders.base, and raises NotImplementedError to trigger fallback.
Post-Decode Logging
monai/deploy/operators/decoder_nvimgcodec.py (lines 275–279)
Deduplicated "decoding active" INFO log emitted once per (transfer_syntax, dtype) pair; per-frame DEBUG logs retained with decoded shape and dtype.
Test Skip Logic
tests/unit/test_decoder_nvimgcodec.py (lines 12, 166–178)
Test imports _JPEG_LOSSLESS_SYNTAXES and conditionally skips DICOM files when transfer syntax is lossless and BitsStored is in 9–15 range, preventing test failures on unsupported configurations.

Type Annotation Fix

Layer / File(s) Summary
Operator Wiring
monai/deploy/operators/monet_bundle_inference_operator.py (line 92)
Added # type: ignore[arg-type] to suppress static type-checking errors on ConcatItemsd multimodal concatenation assignment.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A rabbit hops through decoder's den,
Where JPEG lossless can't play pretend,
With warnings deduped and fallbacks planned,
Nine through fifteen now rerouted—grand!
The logging filter keeps the noise at bay,
While tests skip what they cannot sway. 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title 'nvImageCodec JPEGLossless 9-15 bit Guard' directly and specifically describes the main change: handling JPEG Lossless streams with 9-15 bits in nvImageCodec decoder with protective guards/fallback logic.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 4, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
monai/deploy/operators/monet_bundle_inference_operator.py (1)

1-99: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Critical inconsistency: File contents don't match PR objectives.

The PR is titled "nvImageCodecJPEGLossless 9-15 bit Guard" and describes adding JPEG Lossless SOF3 decoder fallback logic, warning messages, pydicom error suppression, and NotImplementedError handling. However, this file (monet_bundle_inference_operator.py) contains only:

  1. Import statement modifications
  2. A type-checking suppression comment on a ConcatItemsd call

There is no JPEG, DICOM, nvimgcodec, image codec, or decoder-related logic in this file. This suggests either:

  • The wrong file was included in this review
  • Critical files implementing the PR objectives are missing from the review
  • The PR metadata/description is incorrect

Please verify that all files related to the JPEG Lossless guard are included in this pull request.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@monai/deploy/operators/monet_bundle_inference_operator.py` around lines 1 -
99, The PR description and title reference nvimgcodec/jpeg lossless fallback
logic but this file (MONetBundleInferenceOperator, methods _init_config,
_set_model_network, predict, and attribute _nnunet_predictor) contains unrelated
MONet/nnUNet inference code; ensure the intended JPEG/DICOM guard code files are
actually added to the branch or update the PR to target the correct files:
either add the missing decoder/fallback implementation files (the
nvimgcodec/jpeglossless and pydicom suppression modules) and include tests, or
correct the PR metadata to reference the MONet operator changes only; verify
branch commits include the new decoder logic and that no wrong files (like
monet_bundle_inference_operator.py) were accidentally staged for this PR.
🧹 Nitpick comments (2)
tests/unit/test_decoder_nvimgcodec.py (1)

166-177: 💤 Low value

Consider logging exceptions instead of silently passing.

The bare except Exception: pass suppresses all errors during the pre-check. While the intent is to fall through to the main test logic when metadata can't be read, silently swallowing exceptions makes debugging harder if files unexpectedly fail. At minimum, log at DEBUG level.

🔧 Proposed fix
     try:
         _ds_meta = dcmread(path, stop_before_pixels=True)
         _ts = _ds_meta.file_meta.TransferSyntaxUID
         _bits_stored = getattr(_ds_meta, "BitsStored", 16)
         if _ts in _JPEG_LOSSLESS_SYNTAXES and 9 <= _bits_stored <= 15:
             pytest.skip(
                 f"Skipping {Path(path).name}: JPEG Lossless with BitsStored={_bits_stored} (9-15) "
                 "is not supported by nvimgcodec (intentional fallback)"
             )
-    except Exception:
-        pass
+    except Exception as exc:
+        _logger.debug(f"Could not pre-check {Path(path).name} for skip condition: {exc}")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/test_decoder_nvimgcodec.py` around lines 166 - 177, Replace the
bare except in the pre-check block with a logged debug-level exception so
failures to read metadata aren't silently swallowed; specifically, catch
Exception around the dcmread(...) call and use a logger (e.g. module logger) to
log the exception details (including path and the exception) at DEBUG before
falling through to the main test, keeping the existing logic that checks
_ds_meta, _ts, _bits_stored and calls pytest.skip when appropriate.
monai/deploy/operators/monet_bundle_inference_operator.py (1)

92-92: ⚡ Quick win

Tighten the data parameter type to improve type safety for this operation.

The # type: ignore[arg-type] suppression exists because the data parameter is typed as Any (line 81), making multimodal_data an implicitly typed Dict[str, Any]. ConcatItemsd expects a dict with concrete tensor types, which the type checker cannot verify.

Consider stricter typing for the data parameter (e.g., Tensor | MetaTensor) or an explicit type annotation on multimodal_data to narrow types properly. If the suppression remains necessary due to MONAI's type stubs, the current approach is reasonable since the code functions correctly at runtime.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@monai/deploy/operators/monet_bundle_inference_operator.py` at line 92,
Tighten the typing around the ConcatItemsd call by changing the loose data
parameter (currently annotated as Any) to a narrower type—e.g., annotate the
function parameter named data as Tensor | MetaTensor or annotate multimodal_data
as Dict[str, Tensor | MetaTensor]—so
ConcatItemsd(keys=list(multimodal_data.keys()),
name="image")(multimodal_data)["image"] no longer needs the # type:
ignore[arg-type] suppression; if MONAI stubs prevent that, explicitly annotate
multimodal_data with the concrete dict type (Dict[str, Tensor | MetaTensor]) to
satisfy the type checker while leaving runtime logic unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@monai/deploy/operators/monet_bundle_inference_operator.py`:
- Around line 1-99: The PR description and title reference nvimgcodec/jpeg
lossless fallback logic but this file (MONetBundleInferenceOperator, methods
_init_config, _set_model_network, predict, and attribute _nnunet_predictor)
contains unrelated MONet/nnUNet inference code; ensure the intended JPEG/DICOM
guard code files are actually added to the branch or update the PR to target the
correct files: either add the missing decoder/fallback implementation files (the
nvimgcodec/jpeglossless and pydicom suppression modules) and include tests, or
correct the PR metadata to reference the MONet operator changes only; verify
branch commits include the new decoder logic and that no wrong files (like
monet_bundle_inference_operator.py) were accidentally staged for this PR.

---

Nitpick comments:
In `@monai/deploy/operators/monet_bundle_inference_operator.py`:
- Line 92: Tighten the typing around the ConcatItemsd call by changing the loose
data parameter (currently annotated as Any) to a narrower type—e.g., annotate
the function parameter named data as Tensor | MetaTensor or annotate
multimodal_data as Dict[str, Tensor | MetaTensor]—so
ConcatItemsd(keys=list(multimodal_data.keys()),
name="image")(multimodal_data)["image"] no longer needs the # type:
ignore[arg-type] suppression; if MONAI stubs prevent that, explicitly annotate
multimodal_data with the concrete dict type (Dict[str, Tensor | MetaTensor]) to
satisfy the type checker while leaving runtime logic unchanged.

In `@tests/unit/test_decoder_nvimgcodec.py`:
- Around line 166-177: Replace the bare except in the pre-check block with a
logged debug-level exception so failures to read metadata aren't silently
swallowed; specifically, catch Exception around the dcmread(...) call and use a
logger (e.g. module logger) to log the exception details (including path and the
exception) at DEBUG before falling through to the main test, keeping the
existing logic that checks _ds_meta, _ts, _bits_stored and calls pytest.skip
when appropriate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f9be16e1-6a4a-4af8-8eca-ac30035dc812

📥 Commits

Reviewing files that changed from the base of the PR and between 37d319d and 45925ab.

📒 Files selected for processing (3)
  • monai/deploy/operators/decoder_nvimgcodec.py
  • monai/deploy/operators/monet_bundle_inference_operator.py
  • tests/unit/test_decoder_nvimgcodec.py

@bluna301 bluna301 changed the title nvImageCodecJPEGLossless 9-15 bit Guard nvImageCodec JPEGLossless 9-15 bit Guard May 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant