Skip to content

fix: Decode partial htj2k stream - #68

Open
wayfarer3130 wants to merge 2 commits into
mainfrom
fix/htj2k-partial
Open

fix: Decode partial htj2k stream#68
wayfarer3130 wants to merge 2 commits into
mainfrom
fix/htj2k-partial

Conversation

@wayfarer3130

@wayfarer3130 wayfarer3130 commented May 21, 2026

Copy link
Copy Markdown
Contributor

The previous version of the htj2k decoder could decode a partial htj2k stream IF it knew the exact full length beforehand. This change allows just giving the htj2k a partial input and allowing it to decode. Most of hte changes are in cornerstonejs/OpenJPH#3

Also added new tests for this and change the decoder to work repeatedly rather than getting slower and slower.

Summary by CodeRabbit

  • Performance

    • Improved HTJ2K decoding efficiency by reusing decoder resources across frames, reducing repeated setup costs.
    • Updated performance measurements to better reflect both first-use and ongoing decoding workloads.
  • Reliability

    • Added clearer diagnostics for decoding failures, including likely truncated image data.
    • Improved handling of incomplete or lossy HTJ2K streams while preserving frame dimensions and error limits.
  • Compatibility

    • Updated the underlying WebAssembly decoder integration for improved exception handling and HTJ2K support.

@codspeed-hq

codspeed-hq Bot commented May 21, 2026

Copy link
Copy Markdown

Merging this PR will regress 3 benchmarks

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

⚡ 3 improved benchmarks
❌ 3 regressed benchmarks
✅ 34 untouched benchmarks
⏩ 79 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation decode CT1.j2c (.201 lossless, 512x512x16bit) — warm 36.8 ms 40.3 ms -8.69%
Simulation decode CT2.j2c (.201 lossless, 512x512x16bit) — warm 36.6 ms 39.2 ms -6.76%
Simulation decode CT2.j2c (.201 lossless, 512x512x16bit) — cold 36.6 ms 39.2 ms -6.74%
WallTime HTJ2K Lossless (.201) 130.5 ms 31.5 ms ×4.1
Simulation HTJ2K Lossless (.201) 141.4 ms 40.6 ms ×3.5
WallTime instantiate+destroy HTJ2KEncoder x50 256.8 µs 232 µs +10.69%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing fix/htj2k-partial (9866b66) with main (a88a461)2

Open in CodSpeed

Footnotes

  1. 79 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (9c086c9) during the generation of this report, so a88a461 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

HTJ2K decoder reuse and resilience

Layer / File(s) Summary
Native decoder handling and WebAssembly linkage
packages/openjphjs/extern/openjph, packages/openjphjs/src/CMakeLists.txt, packages/openjphjs/src/HTJ2KDecoder.hpp
The WebAssembly target links against OpenJPH core headers, enables exception catching, and logs header and decode exceptions.
Decoder reuse through the codec path
packages/dicom-codec/src/codecs/codecFactory.js, packages/dicom-codec/src/codecs/htj2k.js, packages/openjphjs/bench/decode.bench.js
The codec factory supports lazy decoder reuse. HTJ2K decoding enables reuse. Benchmark documentation defines cold and warm decoder paths.
Node round-trip validation
packages/openjphjs/test/node/index.js
Node tests cover lossy and truncated-lossless in-memory round trips, frame dimensions, and bounded mean absolute error.
Reuse and decode performance validation
packages/openjphjs/test/truncated.test.js
Vitest tests cover round-trip accuracy, decode-time limits, 500 reused decodes, and reused-versus-new decoder timing.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: sedghi

Sequence Diagram(s)

sequenceDiagram
  participant HTJ2KDecode
  participant CodecFactory
  participant CodecConfig
  participant Decoder
  HTJ2KDecode->>CodecFactory: request decode with reuseDecoder true
  CodecFactory->>CodecConfig: read or create reusedDecoder
  CodecFactory->>Decoder: decode image frame
  Decoder-->>CodecFactory: decoded image data
  CodecFactory-->>HTJ2KDecode: return decoded image data
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: enabling HTJ2K decoding from partial streams.
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.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/htj2k-partial

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 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.

Inline comments:
In `@packages/openjphjs/src/HTJ2KDecoder.hpp`:
- Around line 163-169: Update decode() at
packages/openjphjs/src/HTJ2KDecoder.hpp#L156-L170 and decodeSubResolution() at
packages/openjphjs/src/HTJ2KDecoder.hpp#L178-L192 to clear frameInfo_, metadata,
and pDecoded_ before each attempt, initialize the destination buffer before
pulling lines, and return or expose an explicit failure/completion status when
exceptions occur so callers cannot observe partial pixels or stale results.

In `@packages/openjphjs/test/node/index.js`:
- Around line 90-91: Ensure the encoded lossless fixture exceeds the truncation
limit before creating partial streams, then always slice at the fixed limit. In
packages/openjphjs/test/node/index.js lines 90-91, assert encodedLossless.length
exceeds 10 KiB and replace the Math.min-based slice; apply the same assertion
and fixed TRUNCATED_BYTE_LIMIT slice in
packages/openjphjs/test/truncated.test.js lines 112-113 and 164-165 before the
benchmark.

In `@packages/openjphjs/test/truncated.test.js`:
- Around line 264-284: Update the benchmark around reusedDecoder and fresh to
use medianDecodeMs for both paths: warm up reusedDecoder before collecting
samples, keep it alive through all reused samples, and create and delete a fresh
HTJ2KDecoder within each fresh-path sample. Compare the resulting median timings
and retain cleanup after sampling.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 9b2492c5-07ef-48e5-869c-3388dc300beb

📥 Commits

Reviewing files that changed from the base of the PR and between 9c086c9 and 9866b66.

📒 Files selected for processing (8)
  • packages/dicom-codec/src/codecs/codecFactory.js
  • packages/dicom-codec/src/codecs/htj2k.js
  • packages/openjphjs/bench/decode.bench.js
  • packages/openjphjs/extern/openjph
  • packages/openjphjs/src/CMakeLists.txt
  • packages/openjphjs/src/HTJ2KDecoder.hpp
  • packages/openjphjs/test/node/index.js
  • packages/openjphjs/test/truncated.test.js

Comment on lines +163 to +169
readHeader_(codestream, mem_file);
decode_(codestream, frameInfo_, 0);
}
catch (const std::exception &e)
{
OJPH_INFO(0x00010021, "decode failed (likely truncated stream): %s", e.what());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)HTJ2KDecoder\.hpp$|openjphjs'

echo "== HTJ2KDecoder outline =="
ast-grep outline packages/openjphjs/src/HTJ2KDecoder.hpp || true

echo "== relevant HTJ2KDecoder.hpp lines =="
wc -l packages/openjphjs/src/HTJ2KDecoder.hpp
cat -n packages/openjphjs/src/HTJ2KDecoder.hpp | sed -n '1,240p'

echo "== search result state methods/usages =="
rg -n "pDecoded_|decoded|resize\\(|decode\\(|decodeSubResolution|readHeader_|OJPH_INFO\\(0x0001002" packages/openjphjs/src packages/openjphjs -g '!**/node_modules/**' || true

Repository: cornerstonejs/codecs

Length of output: 31773


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== readHeader_/decode_ implementation =="
cat -n packages/openjphjs/src/HTJ2KDecoder.hpp | sed -n '270,455p'

echo "== decoder class members before implementation =="
cat -n packages/openjphjs/src/HTJ2KDecoder.hpp | sed -n '236,290p'

echo "== tests using reuse/failure paths =="
cat -n packages/openjphjs/test/truncated.test.js | sed -n '140,285p'
cat -n packages/openjphjs/test/node/index.js | sed -n '84,105p'

Repository: cornerstonejs/codecs

Length of output: 16378


🌐 Web query:

C++ std::vector resize preserves elements unchanged when size stays same documentation

💡 Result:

Yes, the C++ standard explicitly states that std::vector::resize does nothing if the requested size equals the current size of the vector [1][2][3]. Because the function performs no operations when the size remains the same, all existing elements are preserved unchanged, and no iterators, references, or pointers to the elements are invalidated [4]. The C++ standard defines the effects of resize as follows: if the new size is less than the current size, it erases the excess elements at the end; if it is greater, it appends additional default-inserted or specified elements [5]. When the new size is identical to the current size, no elements are erased or appended, resulting in no changes to the container's state [5][4].

Citations:


Reset decoder result state after incomplete decode.

decode() and decodeSubResolution() catch failures and return normally, but they leave frameInfo_, metadata, and pDecoded_ from previous successful decodes unchanged or only resized. Reused decoders must clear result state before each decode attempt, initialize the destination buffer before line pulls, and report a failure/completion status so callers do not return partial pixels or stale metadata.

This applies to:

  • packages/openjphjs/src/HTJ2KDecoder.hpp#L156-L170
  • packages/openjphjs/src/HTJ2KDecoder.hpp#L178-L192
📍 Affects 1 file
  • packages/openjphjs/src/HTJ2KDecoder.hpp#L163-L169 (this comment)
  • packages/openjphjs/src/HTJ2KDecoder.hpp#L185-L191
🤖 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 `@packages/openjphjs/src/HTJ2KDecoder.hpp` around lines 163 - 169, Update
decode() at packages/openjphjs/src/HTJ2KDecoder.hpp#L156-L170 and
decodeSubResolution() at packages/openjphjs/src/HTJ2KDecoder.hpp#L178-L192 to
clear frameInfo_, metadata, and pDecoded_ before each attempt, initialize the
destination buffer before pulling lines, and return or expose an explicit
failure/completion status when exceptions occur so callers cannot observe
partial pixels or stale results.

Comment on lines +90 to +91
const truncatedSize = Math.min(10 * 1024, encodedLossless.length)
const truncatedBitstream = encodedLossless.slice(0, truncatedSize)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guarantee partial-stream coverage.

Math.min(limit, encodedLength) permits a full stream when the fixture is small. Require an encoded fixture larger than the limit, then slice at the fixed limit.

  • packages/openjphjs/test/node/index.js#L90-L91: assert that encodedLossless.length exceeds 10 KiB before slicing at 10 KiB.
  • packages/openjphjs/test/truncated.test.js#L112-L113: assert that encodedLossless.length exceeds TRUNCATED_BYTE_LIMIT before slicing.
  • packages/openjphjs/test/truncated.test.js#L164-L165: apply the same assertion and fixed slice before the truncated performance benchmark.
📍 Affects 2 files
  • packages/openjphjs/test/node/index.js#L90-L91 (this comment)
  • packages/openjphjs/test/truncated.test.js#L112-L113
  • packages/openjphjs/test/truncated.test.js#L164-L165
🤖 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 `@packages/openjphjs/test/node/index.js` around lines 90 - 91, Ensure the
encoded lossless fixture exceeds the truncation limit before creating partial
streams, then always slice at the fixed limit. In
packages/openjphjs/test/node/index.js lines 90-91, assert encodedLossless.length
exceeds 10 KiB and replace the Math.min-based slice; apply the same assertion
and fixed TRUNCATED_BYTE_LIMIT slice in
packages/openjphjs/test/truncated.test.js lines 112-113 and 164-165 before the
benchmark.

Comment on lines +264 to +284
const reusedDecoder = new codec.HTJ2KDecoder()
const t0 = performance.now()
reusedDecoder.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
reusedDecoder.decode()
reusedDecoder.getDecodedBuffer()
const reusedMs = performance.now() - t0
reusedDecoder.delete()

const t1 = performance.now()
const fresh = new codec.HTJ2KDecoder()
fresh.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
fresh.decode()
fresh.getDecodedBuffer()
fresh.delete()
const freshMs = performance.now() - t1

console.log(
`Single decode — reused decoder: ${reusedMs.toFixed(2)} ms, fresh decoder: ${freshMs.toFixed(2)} ms`
)

expect(reusedMs).toBeLessThan(freshMs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Measure steady-state decoder reuse.

The timed reusedDecoder path performs its first decode after construction. It does not reuse the decoder before measurement. A single wall-clock sample can also fail due to scheduler and WebAssembly warm-up noise.

Use medianDecodeMs for both paths. Keep the reused decoder alive across its warm-up and samples. Create and delete a fresh decoder inside each fresh-path sample.

Proposed change
 const reusedDecoder = new codec.HTJ2KDecoder()
-const t0 = performance.now()
-reusedDecoder.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
-reusedDecoder.decode()
-reusedDecoder.getDecodedBuffer()
-const reusedMs = performance.now() - t0
-reusedDecoder.delete()
+let reusedMs
+try {
+  reusedMs = medianDecodeMs(() => {
+    reusedDecoder.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
+    reusedDecoder.decode()
+    reusedDecoder.getDecodedBuffer()
+  })
+} finally {
+  reusedDecoder.delete()
+}
 
-const t1 = performance.now()
-const fresh = new codec.HTJ2KDecoder()
-fresh.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
-fresh.decode()
-fresh.getDecodedBuffer()
-fresh.delete()
-const freshMs = performance.now() - t1
+const freshMs = medianDecodeMs(() => {
+  const fresh = new codec.HTJ2KDecoder()
+  try {
+    fresh.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
+    fresh.decode()
+    fresh.getDecodedBuffer()
+  } finally {
+    fresh.delete()
+  }
+})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const reusedDecoder = new codec.HTJ2KDecoder()
const t0 = performance.now()
reusedDecoder.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
reusedDecoder.decode()
reusedDecoder.getDecodedBuffer()
const reusedMs = performance.now() - t0
reusedDecoder.delete()
const t1 = performance.now()
const fresh = new codec.HTJ2KDecoder()
fresh.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
fresh.decode()
fresh.getDecodedBuffer()
fresh.delete()
const freshMs = performance.now() - t1
console.log(
`Single decode — reused decoder: ${reusedMs.toFixed(2)} ms, fresh decoder: ${freshMs.toFixed(2)} ms`
)
expect(reusedMs).toBeLessThan(freshMs)
const reusedDecoder = new codec.HTJ2KDecoder()
let reusedMs
try {
reusedMs = medianDecodeMs(() => {
reusedDecoder.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
reusedDecoder.decode()
reusedDecoder.getDecodedBuffer()
})
} finally {
reusedDecoder.delete()
}
const freshMs = medianDecodeMs(() => {
const fresh = new codec.HTJ2KDecoder()
try {
fresh.getEncodedBuffer(ct1Encoded.length).set(ct1Encoded)
fresh.decode()
fresh.getDecodedBuffer()
} finally {
fresh.delete()
}
})
console.log(
`Single decode — reused decoder: ${reusedMs.toFixed(2)} ms, fresh decoder: ${freshMs.toFixed(2)} ms`
)
expect(reusedMs).toBeLessThan(freshMs)
🤖 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 `@packages/openjphjs/test/truncated.test.js` around lines 264 - 284, Update the
benchmark around reusedDecoder and fresh to use medianDecodeMs for both paths:
warm up reusedDecoder before collecting samples, keep it alive through all
reused samples, and create and delete a fresh HTJ2KDecoder within each
fresh-path sample. Compare the resulting median timings and retain cleanup after
sampling.

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