Skip to content

fix: consolidated codec correctness fixes (supersedes #71)#73

Open
sedghi wants to merge 38 commits into
mainfrom
codec-correctness-fixes
Open

fix: consolidated codec correctness fixes (supersedes #71)#73
sedghi wants to merge 38 commits into
mainfrom
codec-correctness-fixes

Conversation

@sedghi

@sedghi sedghi commented Jul 8, 2026

Copy link
Copy Markdown
Member

All codec source fixes in one PR, stacked on the pixel-correctness test PR #72 (merge #72 first; GitHub retargets this to main automatically when its base branch is deleted). Consolidates the fixes formerly on the fixes branch (#71, closed in favor of this) with the second round found by #72's test suite. Each fix travels with the tests that fail without it — classification is empirical: the wasm packages were rebuilt from unfixed C++ (same emsdk 3.1.74 image CI uses) and the full workspace run against them; whatever failed belongs here, whatever passed stayed in #72.

First round (formerly #71)

libjpeg-turbo-12bit — decoder was unusable: it forced JCS_EXT_RGBA while sizing the output for 1 sample/pixel, so libjpeg wrote ~2x past the buffer (heap overflow), and the result went through Uint8ClampedArray, flattening 12-bit samples to 255. Also the package main field pointed to a dist file that does not exist, so require() failed outright. Now decodes single-component grayscale into 16-bit output, has working entry points, and is wired into dicom-codec's .51 dispatch (previously Decoder not found). Pinned by the 12-bit suite (asm.js + wasm variants), the dispatcher integration test, and the browser-smoke variants.

openjpeg decoder — rejects <4-byte input (OOB magic-number read) and unsupported component counts (freeing handles on the rejection path); BufferStream write/skip/seek callbacks are bounds-checked instead of writing/seeking past the buffer. Pinned by the small-buffer throw tests across all build variants.

dicom-codec codecFactory — reads encode/decode results before delete() and frees wasm instances in finally, so a throwing decode no longer leaks the instance. Pinned by the cleanup-on-throw test.

Overflow-checked decoded-buffer sizing on wasm32 in openjpeg, openjphjs, and libjpeg-turbo-8bit decoders (width*height*components*bytes capped and checked).

Second round (found by #72's suite)

openjpeg encoderJ2KEncoder::encode() returned silently on failure, leaving the full pre-sized allocation as the "encoded" result, and leaked codec/stream/image handles on every path including success (repeated encodes grow the wasm heap monotonically). Now throws with the encoded buffer zeroed, frees handles on every exit path, and sizes the output with headroom so clamped writes surface as errors. Pinned by the encoder-failure-throw tests, the encode/delete heap-stability test, and both dicom-codec J2K round-trips (encode .90, transcode .80.90), which fail byte-exactness without this.

openjphjs encoderbytesPerPixel = bitsPerSample / 8 truncated to 1 for 9..15-bit samples, halving the row stride so every row after the first was read from the wrong offset (12-bit encodes corrupted). Pinned by the 12-bit encoder round-trip.

libjpeg-turbo-12bit — fail closed on multi-component input: forcing JCS_GRAYSCALE on a color 12-bit JPEG silently discards chroma and reports componentCount=1. Pinned by the multi-component rejection test. Also adds the CodSpeed bench.

dicom-codecadaptImageInfo preserves planarConfiguration (decode8Planar was unreachable; PlanarConfiguration=1 RLE silently decoded interleaved). Pinned by the planar RLE test.

little-endian / big-endian — 32-bit PixelData decoded as Float32Array unconditionally; per review (@wayfarer3130) it is integer data, signed per pixelRepresentation, float only for float pixel data elements. Now Uint32Array/Int32Array with Float32Array as the no-pixelRepresentation fallback, matching cornerstone3D's decodeLittleEndian; same typing applied to dicom-codec's littleEndian.getPixelData. 32-bit views realign to 4-byte boundaries (the old offset % 2 check threw a RangeError at offset % 4 == 2); big-endian gains 1-bit passthrough and byte-swapped 32-bit support. Pinned by the typed-array and realignment tests in both packages.

Note the 32-bit typing change is behavior-visible: consumers relying on always-Float32Array for bitsAllocated: 32 now get integer arrays when pixelRepresentation is set.

Verification

  • Full workspace green with CI=1 (194/194) with wasm rebuilt from this branch's C++.
  • dist-size gate passes: the committed baseline was generated from builds that include these C++ changes.

sedghi added 30 commits July 7, 2026 09:51
…-skipped suites

Pixel correctness:
- libjpeg-turbo-8bit: byte-compare decode against the RAW reference
  (previously only length was checked) and bound encode round-trip error
  (measured maxAbsDiff 6 / meanAbsDiff 0.26; asserted <= 10 / <= 1)
- libjpeg-turbo-12bit: add RAW reference (verified bit-identical against
  DCMTK dcmdjpeg) and byte-compare decode against it
- charls: add CT1.RAW (cross-validated bit-for-bit against openjpeg and
  openjph decodes of the same slice) and a pinned golden for the
  near-lossless .81 path; byte-compare both
- openjpeg: pin the lossy .91 decode as a golden and byte-compare
- openjphjs: corpus test pinning SHA-256 of decoded pixels for all 13
  j2c fixtures (MG/MR/NM/RG/SC/XA modalities)
- dicom-codec: every transfer syntax now byte-compares decoded pixels
  against cross-validated references. RLE and JPEG Lossless Process 14
  outputs were verified identical to each other and to DCMTK.
- documents a real upstream bug found by these tests:
  jpeg-lossless-decoder-js decodes the final pixel of the SV1 fixture as
  0 instead of -2000 (DCMTK confirms the fixture is correct). Pinned via
  an all-but-last-pixel compare plus an it.fails() sentinel that flips
  when upstream fixes it.

Endian packages:
- big-endian: handle 32-bit (swap32 -> Float32Array) and 1-bit frames,
  mirroring little-endian; previously 32-bit fell through and left
  pixelData undefined
- little-endian: fix Float32Array alignment check (offset % 4, not % 2)
- exact-value tests for both, including unaligned byteOffset cases

CI:
- tests now fail loudly in CI when a dist is missing instead of
  silently skipping entire suites (it.runIf(process.env.CI) guards)
- any package change builds all packages: dicom-codec integration
  decodes through every sibling dist, so partial builds would trip the
  guards; docs-only changes still skip everything
- single test job for the whole vitest workspace (per-package test jobs
  were ~90% runner setup)
- CodSpeed benches only the packages the PR touched (main still
  re-benches everything for full baselines)
- node_modules cached keyed on yarn.lock; shallow checkout for builds
Independent implementations of JPEG-LS (T.87 regular+run mode, NEAR>=0),
JPEG Lossless (T.81 SOF3, predictors 1-7), DICOM RLE (PS3.5 Annex G) and
sequential DCT JPEG (SOF0/SOF1 with libjpeg's exact islow IDCT constants),
sharing no code with the codecs under test. run-all.js binary-compares
their output against every committed RAW reference: 12/12 byte-exact.

Also re-confirms the jpeg-lossless-decoder-js SV1 last-pixel bug from a
fourth independent decoder: the from-scratch SOF3 decoder produces -2000
for the final sample, matching DCMTK/RLE/Process-14 and the committed
reference.
Review findings on the 12-bit/openjpeg changes:

- libjpeg-turbo-12bit: decode() forced JCS_GRAYSCALE unconditionally, so
  a color (multi-component) 12-bit JPEG would silently drop its chroma
  channels and report componentCount=1. Now rejects num_components != 1
  after jpeg_read_header with cleanup + throw. Test splices the grayscale
  fixture into a syntactically valid 3-component JPEG and asserts the
  decoder throws.

- openjpeg: J2KEncoder::encode() swallowed opj_setup_encoder /
  opj_start_compress / opj_encode / opj_end_compress failures with bare
  returns. With the bounded BufferStream, an undersized output estimate
  surfaces through exactly those return values — and the JS caller would
  read back the full pre-sized allocation as a successful encode. All
  failure paths now free the codec/stream/image, empty encoded_, and
  throw. Also frees those handles on the success path, which previously
  leaked them on every encode. Test forces opj_setup_encoder failure
  (41 resolutions > OpenJPEG's max 33) and asserts encode() throws and
  the encoded buffer is empty.
'Different runtime environments detected' on PR comparisons: every
controllable axis already matched between BASE and HEAD (runner image
20260628.225.1, CodSpeedHQ/action v4.18.2, node 22.23.1 — verified from
the job logs), so the mismatch is GitHub's runner CPU lottery: standard
runners land randomly on Intel Xeon 8370C or AMD EPYC 7763, whose cache
sizes and ISA extensions make glibc execute different code paths,
shifting instruction counts even in simulation mode. See
https://codspeed.io/blog/unrelated-benchmark-regression

Mitigations:
- push trigger now fires on main only. PR branches were running the
  whole workflow twice per commit (push + pull_request) and uploading
  two CodSpeed measurements per commit, each on random hardware —
  doubling both CI usage and the odds of cross-CPU comparisons. main
  pushes still seed the baseline; PRs keep their pull_request runs.
- codspeed-bench logs lscpu before benching so any future environment
  warning is diagnosable from the job log.

The residual case (PR run and baseline run landing on different CPU
models) is inherent to shared runners; the durable fix would be CodSpeed
macro runners or GitHub larger runners.
…ation

Dual-instrument setup: simulation stays the blocking regression gate
(deterministic <1% drift, catches small algorithmic slips); a new
advisory codspeed-walltime job measures real wall-clock on CodSpeed's
ARM64 bare-metal macro runners, covering simulation's blind spots
(real cache/branch behavior, and the pure-JS endian packages where the
no-JIT simulation model diverges most from production V8).

- upgrade vitest 2.1.9 -> 3.2.7, @vitest/coverage-v8 -> 3.2.4 and
  @codspeed/vitest-plugin 4.0.1 -> 5.7.1 (walltime requires plugin >= 5,
  which requires vitest >= 3.2); all 123 tests pass on the new stack
- walltime benches run sequentially (--concurrency 1): parallel
  processes contend for cores and add wall-clock noise, unlike
  instruction counting
- walltime job is continue-on-error while the macro-runner setup beds
  in, with timeout-minutes: 30 in case runner pickup stalls
- cache keys now include runner.os/arch: macro runners are ARM64 and
  sharing keys with x64 jobs would restore broken native binaries
- BENCHMARKING.md documents the two-instrument model
vitest 3 pulls vite 7, whose engines check (>=20.19) rejects the node
20.18.0 bundled in emscripten/emsdk:3.1.74, failing yarn install in
every build job. setup-node puts node 22 on PATH for yarn/webpack;
emcc keeps using the node binary pinned in its own emsdk config.
- vitest 3 enforces a 60s worker RPC timeout; under valgrind with
  lerna --parallel, 8 bench processes on 4 cores starved the openjpeg
  and dicom-codec suites past it. Sequential benching changes only wall
  time — instruction counts are contention-immune.
- runs-on: codspeed-macro queues up to 24h when macro runners aren't
  provisioned for the org (job timeouts don't cover queue time), so the
  walltime job now requires CODSPEED_MACRO_ENABLED=true. Set it after
  enabling macro runners for the org on app.codspeed.io.
New dist-size job compares every shipped artifact (js/wasm/mem, both raw
and gzip level-9 sizes) against tools/dist-size/baseline.json and fails
when anything grows beyond max(1%, 1 KiB), so a PR cannot increase codec
binary size unintentionally. New or missing artifacts also fail, keeping
additions deliberate.

The baseline was generated from CI-built artifacts (gh run download of
run 28905144867), not local builds — the Debug-built wasm embeds source
paths, so only CI output is a stable ground truth. Intentional size
changes rerun 'node tools/dist-size/check.js --update' against CI
artifacts and commit the diff, making growth visible in review.
Under valgrind the entire vitest process (main and forked worker) runs
~60x slow while vitest 3's hard-coded 60s birpc timer counts real
seconds, so large suites structurally hit 'Timeout calling onTaskUpdate'
AFTER their benches complete — serializing lerna didn't help because the
timeout is intra-process. The benches finish and upload correctly; only
the exit code was polluted. Forward --dangerouslyIgnoreUnhandledErrors
to vitest for the simulation job only (walltime runs native speed and
stays strict) and restore --parallel, which was never the culprit.
yarn 1 mangles '--'-forwarded flags (lerna received a bare
--dangerouslyIgnoreUnhandledErrors and rejected it), so set the option
in each package's vitest config instead, gated on
CODSPEED_RUNNER_MODE === 'simulation' — the env var the CodSpeed runner
itself sets. Walltime and regular test runs stay strict.
…uppression

The CodSpeed runner exports CODSPEED_RUNNER_MODE as 'instrumentation' on
some versions and 'simulation' on newer ones (@codspeed/core accepts
both), so gate on CODSPEED_ENV being set and mode != walltime instead of
an exact 'simulation' match. Verified all three behaviors locally with a
synthetic unhandled error: suppressed under instrumentation/simulation,
strict under walltime, strict without CodSpeed.
…035)

Coverage added:
- openjpeg: corpus test activates 15 shipped-but-unused fixtures with
  committed RAW references — 8/10/12/15/16-bit, signed and unsigned,
  3-component color (US1, VL1, VL4, VL6) — all byte-exact, plus the
  0-decomposition CT1 variant.
- charls: 3-component interleaved color (ILV=sample), 8-bit and 16-bit
  unsigned grayscale, and the shipped 12-bit SC1 fixture (SHA-pinned).
- openjphjs: color with and without the reversible color transform (the
  RCT path was flagged untested in the source), 8-bit and 12-bit gray.
- libjpeg-turbo-8bit: color 4:2:0 YCbCr decode (DCMTK-verified golden)
  and a progressive SOF2 decode (Pillow-verified golden).
- dicom-codec: color JPEG .50, color RLE in both planar configurations,
  8-bit JPEG-LS .80.

Fixtures are generated deterministically from committed sources by
tools/fixture-verification/gen/ (US1.RAW RGB frame, CT2.RAW transforms);
lossless tests re-derive their references, so no golden files are needed
except the two lossy JPEG raws (DCMTK/Pillow verified).

Two real bugs found and fixed by these tests:
- HTJ2KEncoder.hpp: row stride computed as bitsPerSample/8, truncating
  to 1 byte for 9..15-bit samples — every row after the first was read
  from the wrong offset when encoding 12-bit data (openjphjs rebuilt).
- dicom-codec adaptImageInfo dropped planarConfiguration, which made the
  RLE plane-sequential decode path (decode8Planar) unreachable through
  the public decode() API.
An emsdk bump edits only this workflow; a vitest/plugin bump edits only
the root manifest and lockfile. Neither matched any packages/<pkg>/ path,
so detect-changes skipped the entire pipeline — the exact PRs that change
every compiled byte or every measurement ran zero CI. Toolchain paths now
force packages=ALL and bench=ALL (a full before/after sweep is precisely
what a toolchain bump needs). Docs-only changes still skip. The workflow
header documents the expected dist-size baseline / lossy-golden
regeneration procedure for such bumps.
- openjpeg: header/coding-parameter getters pinned after decode()
  (5 decomps, LRCP, 1 layer, 64x64 blocks, grayscale for CT1);
  decodeSubResolution levels 1-2 pinned — the level-1 output is
  cross-validated byte-identical with openjphjs' decodeSubResolution of
  the same slice (shared 5/3 LL band); setProgressionOrder and lossy
  setQuality/setCompressionRatio proven observable with a PSNR floor.
- openjphjs: readHeader-only introspection (populates frameInfo without
  decoding) and decodeSubResolution(1) pinned to the cross-validated hash.
- dicom-codec: encode() round-trips for .90/.80, transcode() .80->.90
  preserves pixels exactly, getPixelData typed-array contract pinned.

Three known defects documented as it.fails sentinels / comments that flip
when fixed:
- openjpeg readHeader() populates nothing — getters return zeros or
  uninitialized memory until decode() runs
- openjpeg getIsReversible() reports true for the irreversible 9-7 stream
- openjpeg encoder setters blockDimensions/tileSize/tileOffset/precincts/
  downSample are stored but never applied to opj_cparameters
…s 018)

- Size bounds on every lossless encode round-trip (floor 0.5x catches
  silent truncation, ceiling 1.10x catches compression-ratio
  regressions), with measured constants dated in comments: charls CT2
  115504, openjpeg CT1 174404, openjphjs CT1 185183, 8bit jpeg400 63975.
- PSNR floor (48 dB, measured 53.6) on the 8-bit JPEG round-trip.
- charls near-lossless: exact T.87 spec bound maxAbsDiff <= NEAR asserted
  for NEAR 1..3, plus proof it is actually lossy.
- libjpeg-turbo-12bit gets its first benches (cold/warm decode) and a
  bench script — previously invisible to CodSpeed, so a toolchain bump's
  full sweep measured nothing for it.
- dicom-codec: dispatcher-level encode (.80/.90) and transcode
  (.80->.90) benches.
Repeated decode/delete, encode/delete and failing-decode cycles must
leave HEAP8 capacity byte-identical after a settling warmup. Emscripten
arenas grow but never shrink, so any native leak (the class fixed in
plan 002, commit 3179998 and the PR #72 encoder cleanup) becomes
monotonic capacity growth. Loops are sized against the 50 MiB
INITIAL_MEMORY slack — verified to fail when a delete() call is removed
(100 leaked charls decoders grow the heap ~22 MiB). Error-path loops use
garbage headers (fast rejection) rather than truncated streams (seconds
of recovery each) so they can iterate enough to exceed the slack.
tools/browser-smoke/run.js serves the repo over local HTTP (correct
application/wasm MIME so streaming compilation runs), loads each of the
11 build variants in headless Chromium, decodes the reference fixture in
the page, and compares the decoded pixels' SHA-256 against the committed
RAW. Catches the emscripten glue regressions node tests cannot see (wasm
URL resolution, fetch loading, MIME fallbacks) — the first thing emsdk
bumps break. New advisory browser-smoke CI job (continue-on-error while
it beds in) with a cached Chromium install. All 11 variants pass
locally.
…as identical

Previously only files whose bytes drifted from baseline produced output,
so a fully-green run printed nothing per package and it looked like only
the drifting package had been checked. Every file now gets an ok line,
with byte-identical files labeled 'identical' to distinguish them from
sub-0.005% drift that rounds to +0.00%.

Claude-Session: https://claude.ai/code/session_017xiEYAJH7wzPnwNpnoGqet
A single instantiate+destroy (~60us) or typed-array-view decode (<1us)
is smaller than the fixed per-bench harness overhead (wrapper frames,
task attribution) plus the simulation cache model's CPU-to-CPU
variation, so those benches flagged spurious regressions on every
harness upgrade or runner-hardware change while the ms-scale decode
benches stayed clean through both. Loop the fragile bodies (x50 for
wasm lifecycle benches, x100 for the little-endian view benches and the
big-endian 8-bit passthrough) so the measured work dominates the noise
floor. The big-endian 16-bit benches do real per-pixel swap work on a
shared buffer (batching would just re-swap it) and stay unbatched.

Bench names carry the batch factor (x50/x100), so CodSpeed will report
these as new benchmarks and retire the old names - a one-time baseline
reset for the six fragile benches.

Claude-Session: https://claude.ai/code/session_017xiEYAJH7wzPnwNpnoGqet
sedghi added 3 commits July 8, 2026 12:12
…nly as fallback

Review feedback from wayfarer3130: BitsAllocated=32 PixelData is integer
data (signed per PixelRepresentation); float applies only to float pixel
data elements. Decode now returns Uint32Array/Int32Array accordingly and
falls back to Float32Array when pixelRepresentation is absent, matching
cornerstone3D's decodeLittleEndian. Applied to big-endian, little-endian,
and dicom-codec's littleEndian getPixelData.

Also documents that 1-bit data must be frame-extracted by the caller and
why 32-bit views may need realignment to a 4-byte boundary.
The little-endian package picked up V8 JIT dump files (12 MB) and local
CodSpeed run outputs that were never meant to be tracked.
Review follow-up: this PR mixed pixel-correctness tests with source
fixes the tests uncovered. To keep it reviewable as a pure test/CI
change, every source fix (JS decoders, dicom-codec factory/dispatch,
C++ decoder/encoder hardening) moves to a follow-up PR together with
the tests that require it.

Classification is empirical: the four wasm packages were rebuilt from
main's C++ (same emsdk 3.1.74 image as CI) and the whole workspace run
against unmodified sources; the 27 failing tests moved out with their
fixes, the 146 that pass stay (164 including CI-only guards, all green,
dist-size gate and browser smoke verified against those builds):

- big-endian: 1-bit passthrough and all 32-bit decode tests
- little-endian: 32-bit integer typing and 4-byte realignment tests
- dicom-codec: planar RLE output, codecFactory cleanup-on-throw, 12-bit
  dispatch, and both J2K encode round-trips (encode reads the result
  after Wasm memory is freed without the codecFactory fix)
- libjpeg-turbo-12bit: whole suite + bench + vitest config (decoder is
  unusable on main: RGBA heap overflow and a broken package entry point)
- openjpeg: tiny-buffer throw, encoder-failure throw, encoder heap leak
- openjphjs: 12-bit encoder round-trip (row-stride fix)
- browser-smoke: 12-bit variants (hash-compare needs the fixed decoder)

The 12-bit RAW reference stays: tools/fixture-verification/run-all.js
validates it independently of the wasm codecs.
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 31e862a0-239a-4ef4-9ce3-7a047083ad8d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codec-correctness-fixes

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.

…es), not main

The previous split commit reverted sources to main, but this PR is
stacked on the fixes branch (#71) which already carries the first round
of codec fixes — so the diff showed reversions of #71's work
(codecFactory, 12-bit decoder, openjpeg guards, dispatcher wiring).

Sources are now pinned to the fixes tree (zero src delta in this PR)
and the classification was re-run empirically against wasm rebuilt from
the fixes branch's C++: 17 tests depend on fixes this branch itself
introduced and move to the follow-up PR; everything that #71's fixes
already satisfy stays here, including the 12-bit decode suite, the
dispatcher 12-bit integration test, the codecFactory cleanup test, the
openjpeg small-buffer guard tests and the 12-bit browser-smoke variants.

Moved out (fail without this branch's own fixes):
- big-endian 1-bit/32-bit tests, little-endian 32-bit typing/realign
- dicom-codec planar RLE and both J2K encode round-trips
- openjpeg encoder-failure throw and encode/delete heap stability
- openjphjs 12-bit encoder round-trip
- libjpeg-turbo-12bit multi-component rejection + bench

Full workspace green with CI=1 (177/177); dist-size and browser smoke
pass against dists built from the fixes branch's C++.
@codspeed-hq

codspeed-hq Bot commented Jul 8, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ 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

⚡ 4 improved benchmarks
❌ 3 regressed benchmarks
✅ 99 untouched benchmarks
🆕 4 new benchmarks
⏩ 13 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
WallTime JPEG-LS Near-Lossless (.81) 20.6 ms 24.1 ms -14.47%
WallTime 32-bit float, 512x512 x100 13.2 µs 14.2 µs -6.76%
Simulation 32-bit float, 512x512 x100 135.5 µs 144.8 µs -6.43%
WallTime JPEG Lossless P14 SV1 (.70) 43.6 ms 39.8 ms +9.61%
Simulation decode jpeg400jfif.jpg (600x800x8bit) — warm 10.9 ms 10 ms +9.34%
WallTime instantiate+destroy J2KEncoder x50 162.9 µs 150.2 µs +8.43%
WallTime instantiate+destroy JPEGDecoder x50 148.4 µs 140.3 µs +5.8%
🆕 Simulation decode CT-512x512-12bit.jpg (512x512x12bit) — cold N/A 63.4 ms N/A
🆕 Simulation decode CT-512x512-12bit.jpg (512x512x12bit) — warm N/A 13.5 ms N/A
🆕 WallTime decode CT-512x512-12bit.jpg (512x512x12bit) — cold N/A 59.8 ms N/A
🆕 WallTime decode CT-512x512-12bit.jpg (512x512x12bit) — warm N/A 12.8 ms N/A

Tip

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


Comparing codec-correctness-fixes (0e28946) with test-pixel-correctness (aaa5154)

Open in CodSpeed

Footnotes

  1. 13 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.

sedghi added 2 commits July 8, 2026 12:56
Reverts the fixes-branch-relative classification: #71 is being closed in
favor of a single consolidated fixes PR stacked on this one, and this PR
retargets to main. With no fix PR below it, this branch must hold only
tests that pass against plain main sources — which is exactly the state
this restores (verified earlier against wasm rebuilt from main's C++
with CI's emsdk 3.1.74 image: 164/164 with CI=1, dist-size gate and
browser smoke green).
All source fixes for the codec packages in one PR, stacked on the
pixel-correctness test PR (#72): the first round formerly on the fixes
branch (#71, closed in favor of this) plus the second round found by
the new test suite. Each fix travels with the tests that fail without
it — classification was empirical, running the full workspace against
wasm rebuilt from unfixed C++ with CI's emsdk 3.1.74 image.

First round (formerly #71):
- libjpeg-turbo-12bit: decode as single-component grayscale into 16-bit
  output. Forcing JCS_EXT_RGBA sized the buffer for 1 sample/pixel
  while libjpeg wrote 4 (heap overflow), and Uint8ClampedArray
  flattened 12-bit samples to 255. Fix the package entry points
  (dist/libjpegturbo12js.js) so the package is requireable at all, and
  wire the decoder into dicom-codec's dispatcher (.51), which
  previously threw 'Decoder not found'.
- openjpeg: decoder rejects <4-byte input and unsupported component
  counts, frees handles on the rejection path; BufferStream
  write/skip/seek callbacks are bounds-checked.
- dicom-codec: codecFactory reads results before delete() and frees
  decoder/encoder instances in finally, so failures no longer leak
  wasm instances.
- overflow-checked decoded-buffer sizing on wasm32 (openjpeg,
  openjphjs, libjpeg-turbo-8bit).

Second round (found by the pixel-correctness suite):
- openjpeg: J2KEncoder throws on setup/compress failure instead of
  silently returning a garbage pre-sized buffer, frees
  codec/stream/image on every exit path (repeated encodes grew the
  wasm heap monotonically), and sizes the output buffer with headroom.
- openjphjs: HTJ2KEncoder rounds bytesPerPixel UP; bitsPerSample/8
  truncated to 1 for 9..15-bit samples, halving the row stride and
  corrupting every row after the first in 12-bit encodes.
- libjpeg-turbo-12bit: fail closed on multi-component input instead of
  silently discarding chroma; add the CodSpeed bench.
- dicom-codec: adaptImageInfo preserves planarConfiguration
  (decode8Planar was unreachable; PlanarConfiguration=1 RLE silently
  produced interleaved output).
- little-endian/big-endian: 32-bit pixel data decodes to
  Uint32Array/Int32Array per pixelRepresentation with Float32Array
  only as the no-pixelRepresentation fallback (review feedback from
  wayfarer3130, matching cornerstone3D's decodeLittleEndian); 32-bit
  views realign to 4-byte boundaries; big-endian gains 1-bit
  passthrough and byte-swapped 32-bit support. Same typing fix applied
  to dicom-codec's littleEndian getPixelData.
@sedghi
sedghi force-pushed the codec-correctness-fixes branch from 569b224 to a325750 Compare July 8, 2026 16:58
@sedghi sedghi changed the title fix: codec correctness fixes (split from #72) fix: consolidated codec correctness fixes (supersedes #71) Jul 8, 2026
sedghi added 2 commits July 9, 2026 09:36
# Conflicts:
#	.github/workflows/pr-checks.yml
#	packages/dicom-codec/test/color-and-depth.test.js
#	packages/dicom-codec/test/dispatch.test.js
#	packages/dicom-codec/test/transcode-and-pixeldata.test.js
#	packages/openjpeg/test/decode.test.js
#	packages/openjpeg/test/heap-stability.test.js
#	packages/openjphjs/test/matrix.test.js
#	tools/browser-smoke/run.js
@sedghi

sedghi commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

CodSpeed note (for reviewers)

The aggregate shows −2.8% with 3 "regressions", but only one is real — and it's an intended trade. Comparison is 0e28946 (head) vs a88a461 (main); no "Environment Differences" section, so base and head ran on matching runners — these numbers are trustworthy, not the CPU-lottery noise we've seen on other runs.

1 real, expected regression

  • little-endian decode :: 32-bit float, 512x512 x100−7.85% WallTime and −6.28% Simulation.
  • Genuine: it moved in both modes, and the deterministic Simulation number only shifts on a real instruction-count change. It maps directly to this PR's 32-bit fix — the path now branches on pixelRepresentation (Uint32/Int32 vs Float32) and realigns 32-bit views to a 4-byte boundary. That's strictly more work than the old path.
  • This is correctness over speed and worth keeping: the old (faster) path typed 32-bit integer pixel data as float and threw a RangeError at offset % 4 == 2. ~7% on an uncommon path (32-bit pixel data) buys correct output.

2 that are noise, not regressions

  • JPEG-LS Near-Lossless (.81) −14.56% (WallTime) — this drags the aggregate down but is wall-clock variance: the PR doesn't touch the charls decode path (charls C++ is unchanged here), and the deterministic Simulation twin is unchanged. A 20 ms WallTime dispatch bench swinging 14% while its Simulation counterpart is flat is jitter on the shared runner.
  • The two +8% "improvements" (instantiate+destroy … x50, WallTime) are the same jitter in the other direction.

New benchmarks (expected): the 4 libjpeg-turbo-12bit decode entries are new because this PR wires up and benches the 12-bit codec for the first time.

TL;DR: nothing to fix. The only real delta is the deliberate cost of the little-endian 32-bit correctness fix; the number inflating the aggregate is WallTime noise on an unchanged path.

sedghi added a commit that referenced this pull request Jul 9, 2026
…recision API)

3.x forbids add_subdirectory() and removed WITH_12BIT (one build is now
multi-precision). Build libjpeg-turbo standalone and link libjpeg.a as an
IMPORTED target (two-phase build.sh), and rewrite the decoder for 3.x:
- decode grayscale 12-bit via jpeg12_read_scanlines + J12SAMPARRAY (the 3.x
  per-precision API) instead of jpeg_read_scanlines (the old WITH_12BIT model)
- guard on num_components==1 and data_precision==12; overflow-checked sizing
- correct single-component int16 output (no JCS_EXT_RGBA overflow)

3.x headers moved under src/. No dependency on #73 (left untouched); the
decode-correctness fix here mirrors #73's grayscale logic but on the 3.x API.
@sedghi
sedghi changed the base branch from test-pixel-correctness to main July 20, 2026 16:46
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