Skip to content

Releases: Raster-Lab/J2KSwift

Release v11.0.0

10 Jun 15:41
aa06ae8

Choose a tag to compare

J2KSwift v11.0.0

Dead-weight removal + packaging — ~125K deleted lines, an unsafeFlags-free manifest, and J2KSwift finally consumable as a versioned SwiftPM dependency.

This is a MAJOR release because exported products are removed. It contains
zero codec behaviour change: codestream bytes and decoded pixels are identical
to v10.25.0 (no codec source file's logic was touched — the only codec-adjacent
edits delete never-referenced code), and the warm A/B confirms performance is
unchanged. Every removal candidate was verified reference-free by a 25-agent
sweep with adversarial re-checks before deletion.


Summary

The 2026-06-10 optimization audit identified ~48K lines of production-dead code
accumulated over the project's research arcs. v11.0.0 removes it — and fixes the
packaging defect that prevented any package from depending on J2KSwift by version.

Metric (Apple M2) v10.25.0 v11.0.0 Δ
Clean debug build (wall) 52.8 s 39.8 s −24.6 %
Clean debug build (CPU) 196.7 s 165.1 s −16.1 %
Sources/ lines 178,274 144,353 −33,921 (−19 %)
Whole-repo diff −124,718 lines (incl. tests + 23 MB artifacts)
Tracked repo size 286.7 MB 261.7 MB −25.0 MB
j2k release binary 14.69 MB 12.02 MB −18.2 %
j2kd release binary 10.05 MB 6.38 MB −36.5 %
Versioned SwiftPM consumption ❌ rejected (unsafeFlags) verified working

Removed products (the MAJOR part)

Product Lines (src+tests) Why it was dead
J2KAccelerate 16,451 Zero import J2KAccelerate anywhere in production. The one known downstream use (DICOMKit's backend-description string) is #if canImport-guarded with an #else fallback — see Migration.
J2KVulkan 4,153 Apple-Silicon-only product since v8.0.0; the module's own tests asserted Vulkan is unavailable.
J2KXS 2,305 JPEG XS stub untouched since v2.3.0; its J2KCore type leaks removed with it.

Removed internal dead code

  • x86 SSE/AVX2 (Sources/J2KCodec/x86 + Intel benchmark infra) — arm64-only product.
  • MJ2 container stack in J2KFileFormat (9 files, 33 public symbols, zero callers;
    the J2KCodec/J2KMetal MJ2 video-encoding pieces remain).
  • Dead allocator/pool modules: J2KBufferPool, J2KHTBlockCoderPooled,
    J2KMemoryPool, J2KOptimizedAllocator, J2KZeroCopyBuffer (the live
    J2KMetalBufferPool is untouched).
  • Dead concurrency infra: J2KConcurrencyTuning (work-stealing queue,
    contention analyzer, concurrent pipeline), J2KThreadPool, J2KGCDDispatcher
    (J2KQualityOfService kept — live via JPIP).
  • Benchmark/probe files compiled into production libraries:
    J2KRealWorldBenchmarks, J2KLosslessDecodingBenchmark, J2KReferenceBenchmark,
    both Metal HT dispatch probes + their kernels (metallib regenerated; the shader
    function inventory drops 74 → 72).
  • Conformance/interop scaffolding (~15K lines out of J2KCore): the Part-2/3-10/4/15
    one-shot conformance suites, ISO test-suite loader, OpenJPEG interop pipeline +
    benchmark harness, performance-validation report generator, and the Windows/cross-platform
    validation obsolete since the v8 Apple-only narrowing. Survivors, unchanged
    signatures
    : the Part-1 validators behind j2k validate
    (J2KDecoderConformanceClass, J2KMarkerSegmentValidator,
    J2KCodestreamSyntaxValidator), the DICOMKit-consumed HT conformance API
    (J2KErrorMetrics, J2KTestVector, J2KConformanceValidator,
    HTJ2KTestVectorGenerator, HTJ2KConformanceTestHarness,
    J2KHTInteroperabilityValidator — now in J2KHTConformanceAPI.swift), and the
    OpenJPEG CLI wrapper behind j2k benchmark --compare-openjpeg
    (now J2KOpenJPEGCLI.swift).
  • Orphan Sources/ directories never declared in the manifest
    (DiagTest, AppFlowTest, PipelineTest, LossyDiagnostic, J2KBenchApp).
  • ~23 MB of tracked artifacts: stale profiler outputs and result dumps deleted;
    release-cited benchmark captures moved to Documentation/Benchmarks/data/
    (links updated); .gitignore hardened against re-accretion.

Restructured

  • J2KTestAppModels moved from J2KCore to a new J2KTestAppCore library
    target. The audit recommended deletion; the verification sweep corrected it —
    the shipping j2k testapp --headless command consumes these models via
    J2KCLICore. J2KCore drops ~4.7K lines of SwiftUI view-models that every SDK
    consumer used to compile.

Packaging — versioned SwiftPM consumption unblocked

The manifest carried unsafeFlags on five targets, and any unsafeFlags
makes a package ineligible as a version-based SwiftPM dependency (path/branch
deps only). All are gone:

  • -O -whole-module-optimization on J2KCore/J2KCodec — redundant (SwiftPM release
    config already implies both).
  • -parse-as-library on the three executables — replaced by renaming the entry
    files (main.swiftEntry.swift / J2KDaemonMain.swift) so @main works bare.
  • The J2KCodecNEON -O3 -fno-* block — removed after an interleaved warm A/B
    against SwiftPM's default release C optimization: DX −0.44 ms, MG +1.35 ms,
    CT +0.06 ms — all inside the 3 ms acceptance gate (Clang's default -O handles
    the NEON intrinsics core fine; consistent with the v10.6 finding that Clang
    auto-optimizes this code well).

Verified end-to-end: a scratch consumer package depending on
.package(url:…, exact: "11.0.0") resolves, builds, and runs against the
J2KCodec product. Before this release SwiftPM rejected the manifest outright.

Deferred (documented, not removed)

  • The legacy non-conformant .custom HT block format (~4.3K lines + dual
    routing branches): three live consumer chains (the j2k transcode /
    batch-transcode CLI paths and JPIP) route through it. Removal needs a
    deprecation cycle, not a dead-code sweep.
  • Stale env-var experiment gates (J2K_HT_SIMD, J2K_RAW_POINTER_ENGINES,
    J2K_GPU_FORWARD_HT_ENTROPY, J2K_METAL_IDWT_2D, …) are inventoried in the audit
    report; code removal is a future arc.

Migration

  • DICOMKit: remove the J2KAccelerate product dependency from DICOMCore's
    manifest (one line). Its #if canImport(J2KAccelerate) falls to the #else
    branch automatically; only a backend-description string changes.
  • Consumers of removed products (J2KAccelerate, J2KVulkan, J2KXS) or the
    removed MJ2 container API: these had no functional production pathway in
    J2KSwift; there is no replacement. The MJ2 video encoding pieces in
    J2KCodec/J2KMetal are unaffected.
  • swift test users: the J2KAccelerateTests/J2KVulkanTests/J2KXSTests/
    J2KInteroperabilityTests/PerformanceTests targets no longer exist;
    Scripts/run-full-regression.sh is updated.

Backward compatibility

Aspect vs v10.25.0
Codestream bytes (encode, default config) byte-identical (no codec logic touched)
Decoded pixels bit-identical
Codec performance unchanged (NEON-flag A/B inside noise; all other removals are never-executed code)
Public API products/symbols REMOVED per the inventory above — MAJOR

Test Suite Results

  • Mandatory commit gate + every suite the removals touched: 271 tests,
    0 failures, exit 0
    (release mode).
  • Trimmed conformance suites: 77/77. J2KTestAppTests: 310/310.
  • CLI smoke: j2k validate --part1, j2k benchmark --compare-openjpeg,
    j2k testapp --headless --playlist "Quick Smoke Test" all green.
  • DICOMKit symbol cross-check: both consumed symbols
    (J2KHTInteroperabilityValidator, HTJ2KConformanceTestHarness) confirmed in
    the kept API, signatures unchanged.

Reproducing

swift test -c release --filter 'J2KMedicalCorpusEncodePerformanceTests|J2KMedicalCorpusPerformanceTests|J2KStrictCrossCodecValidationTests'
# versioned-consumption proof
mkdir consumer && cd consumer && swift package init --type executable
# add .package(url: "<J2KSwift repo>", exact: "11.0.0") + product J2KCodec, then:
swift build

Companion documents

  • OPTIMIZATION_AUDIT_2026-06-10.md — the audit that scoped this arc.

Release v10.25.0

10 Jun 10:50
70021ad

Choose a tag to compare

J2KSwift v10.25.0

Optimization-audit release — GPU iDWT stale-gate cluster, QoS, JP3D hot loops, CLI partial decode + two CLI defect fixes, multi-tile partial-resolution decode.

This is a MINOR release. Codestream bytes are byte-identical to v10.24.2 on
default configurations (verified directly on MG/DX/PX real-medical fixtures), and
decoded pixels are bit-identical on both the CPU and GPU decode paths. The public
API gains new surface; nothing is removed or changed in signature.


Summary

v10.25.0 is the product of a full-project, 8-dimension optimization audit
(60 adversarially-verified findings; OPTIMIZATION_AUDIT_2026-06-10.md) followed by
an implementation arc that fixed every confirmed stale-gate, QoS, and product-layer
item that cleared the acceptance bar. Highlights:

  1. MG decode −8 to −10 ms (−9 to −11 %) from the GPU iDWT stale-gate cluster:
    the multi-level fused Int32 iDWT had been dead-gated since v10.3.0 (it was
    coupled to the GPU-HT-entropy flag that release turned off), so every default
    DX/MG GPU decode paid a CPU readback + re-upload of the LL band at every
    decomposition level. The chain is now gated on session presence, per-tile iDWTs
    run on fresh command queues (concurrent tiles previously serialised on the one
    shared queue), per-level buffers come from the heap-backed pool, and the pool's
    power-of-two bucketing is linear above 16 MB (a 67 MB MG buffer previously
    rounded to 128 MB, exhausting the 256 MB budget with two buffers).

  2. CLI partial decode wired--level and --region were parsed and
    documented but never reached the v10.4–v10.7 partial-decode APIs; every decode
    ran at full resolution. --level 0 on a DX 2800×2288 now decodes in 10 ms vs
    57 ms
    full. In the process, the wiring surfaced a latent API defect: …

  3. Multi-tile partial-resolution decode fixeddecodeResolution produced
    corrupt output (junk pixels, full-size buffers) on multi-tile codestreams — the
    production .auto layout for every ≥3 MP image — since v10.5.0. The per-tile
    truncated iDWT now anchors parity origins at the true canvas depth
    (outputDepthOffset) and composites reduced-dimension tiles with ceil-div
    offsets per ISO/IEC 15444-1. The smoke tests that let this ship were
    strengthened to assert buffer sizes and content correlation on both single- and
    multi-tile fixtures.

  4. Two CLI defects fixed: the documented stdin/stdout piping (-i - / -o -)
    never worked (the parser treated the bare - pipe sentinel as a flag
    terminator); and daemon decode silently returned only component 0 of
    multi-component images (the client now detects this and falls back to a correct
    in-process decode).

  5. swift test exit code fixed — all CLI command logic moved to a new
    J2KCLICore library target with a thin @main wrapper. Previously, the
    executable entry point linked into the unified test binary hijacked SwiftPM's
    swift-testing pass, printing CLI usage and exiting 1 even when every suite
    passed — CI and scripts could not trust the exit code.

  6. QoS completion of the v10.24.2 arc — the daemon's RPC handlers ran the
    entire request (Task.detached) at unspecified priority, E-core-eligible; the
    encoder's forward-DWT component tasks (the dominant encode stage) and all
    multi-tile per-tile task groups likewise. All now run .high, matching the
    v10.24.2 decode-entropy fix.

  7. JP3D Foundation Data hot loops — the v10.10–v10.20 arc shipped per-voxel
    Data subscript loops on whole-volume paths (~67 M bounds-checked accesses per
    decode finalization of a 512×512×128 volume, 2 per 16-bit sample). All five
    sites now use bulk pointer conversion (with explicit byte-count guards), and the
    default batched decode no longer copies the whole compressed payload a second
    time.

  8. preWarm honestypreWarm() compiled a stale PSO list (the legacy scalar
    5/3 kernels, now only the odd-origin fallback) and dispatched only a lossy 9/7
    warmup; the tiled/fused/batched 5/3 Int kernels that production lossless decode
    actually uses compiled lazily inside the first real decode. The list now covers
    them, a lossless reversible 5/3 warmup dispatch was added, and j2kd pre-warms
    at startup (with warmup dispatches) instead of inside the first request.

One audit recommendation was empirically rejected during this release: excluding
MG 2x2 tiles from per-tile GPU forward DWT (per the documented design intent of the
threshold) regressed MG encode by +20 to +35 ms — the in-code measurement table that
justified the exclusion predates the fresh-per-call-queue fix and is stale. The
explicit routing knob and the .multiTilePerTile telemetry reason ship; the default
keeps v10.24.2 routing. This is the v10.7.0 lesson cutting in the opposite
direction: previously-correct routing tables go stale as the codec evolves —
re-measure before trusting them.

What's New — production-default

  • Multi-level fused GPU iDWT chaining on all session decodes (decoder-only,
    bit-exact).
  • Fresh per-call command queues + pooled buffers in the per-level GPU iDWT path.
  • Linear ≥16 MB buffer-pool bucketing.
  • .high task priority: daemon RPCs, encoder forward-DWT/tile tasks, decoder tile
    tasks.
  • preWarm covers production 5/3 kernels + lossless warmup dispatch; j2kd pre-warms
    at startup.
  • True multi-tile partial-resolution decode (decodeResolution /
    decodePartial).
  • CLI: working -i - / -o - piping; --leveldecodeResolution; --region x,y,w,hdecodeRegion(.direct); daemon multi-component fallback.
  • JP3D bulk serialization on all volume paths.
  • 11 remaining instances of the documented release-mode Metal readback deadlock
    pattern (withUnsafeMutableBytes { copyBytes } after cb.completed()) replaced
    with unsafeUninitializedCapacity + pointer update.

What's New — opt-in / tooling

  • EncoderPipeline._gpuForward53MultiTilePerTilePixelThreshold — explicit
    routing knob for multi-tile per-tile GPU forward DWT (default = single-tile
    threshold; production routing unchanged), with a new
    J2KGPUForward53Telemetry.SkipReason.multiTilePerTile case.
  • J2KMetalDWT.fusedKernelEligible(...) — public single source of truth for the
    fused-kernel routing predicate.
  • J2KCLICore library target (the j2k executable is now a thin wrapper; the
    executable product is unchanged).

Backward compatibility

Aspect vs v10.24.1/.2
Codestream bytes (encode, default config) byte-identical (verified: MG/DX/PX real-medical fixtures, baseline binary vs this release)
Decoded pixels (CPU and GPU paths) bit-identical (verified: MG/DX, both backends)
Public API additive only; encodeInverse2DInt32 public signature preserved via an internal optional-column-buffer variant
CLI behavioural changes --level/--region now take effect (previously silently ignored — note --level 0 now means smallest thumbnail per the API, not "full" as the stale v10.24.2 help text said); -i -/-o - now work; multi-component daemon decodes fall back in-process instead of silently dropping components

Cross-codec parity matrix (fresh, this release)

HTTileParityMatrixTests.testTileParityMatrixOnLargeFixtures — 12 cells
(MR 886², XA 1024², PX 2459×1316, DX 2800×2288 × 2x2 / 4x4 / strips4 tile modes,
spanning ALL-EVEN (9 cells) and ANY-ODD (3 cells) tile-origin parities). All 12
cells: self round-trip max diff = 0 (bit-exact) and OpenJPH 0.27 / Grok 20.3 /
Kakadu 8.4 cross-decode max diff = 0.

Modality Shape Mode Parity Self RT OpenJPH Grok Kakadu
MR 886×886 2x2 ANY-ODD 0 0 0 0
MR 886×886 4x4 ALL-EVEN 0 0 0 0
MR 886×886 strips4 ALL-EVEN 0 0 0 0
XA 1024×1024 2x2 ALL-EVEN 0 0 0 0
XA 1024×1024 4x4 ALL-EVEN 0 0 0 0
XA 1024×1024 strips4 ALL-EVEN 0 0 0 0
PX 2459×1316 2x2 ALL-EVEN 0 0 0 0
PX 2459×1316 4x4 ANY-ODD 0 0 0 0
PX 2459×1316 strips4 ANY-ODD 0 0 0 0
DX 2800×2288 2x2 ALL-EVEN 0 0 0 0
DX 2800×2288 4x4 ALL-EVEN 0 0 0 0
DX 2800×2288 strips4 ALL-EVEN 0 0 0 0

Performance (Apple M2, release, warm)

A/B vs v10.24.2 — in-process SDK (j2k inproc-bench, median-of-7+, interleaved)

Decode (HT-conformant lossless, 16-bit real medical):

Fixture v10.24.2 ms v10.25.0 ms Δ
MG small 3516×4784 89.1–93.4 82.6–84.8 −6.5 to −8.6
MG mid 3518×4784 86.9–88.2 75.3–78.5 −9.7 to −11.6
MG large 3521×4784 95.4–101.4 90.9–93.3 −4.5 to −8.1
DX small/mid/large 47.5–65.2 49.1–66.3 ±1.6 (noise)
CT / XA 2.3–6.9 2.3–7.1 ±0.2 (noise)

Encode: every fixture within the ±3 ms noise band; MG-large tiebreak (interleaved
11-run medians ×2) base 55.4/51.7 vs branch 50.9/51.2. Bytes byte-identical.

CLI partial decode (DX 2800×2288 multi-tile, cold one-shot): --level 0 10 ms vs
57 ms full decode; --region 100,100,512,512 output verified bit-exact against the
full-decode crop.

Canonical cross-codec benchmark

Scripts/benchmarks/cross_codec_warm_bench.py --in-proc (median-of-7 after 2
warmups; J2KSwift in-process SDK vs OpenJPH 0.27 / Grok 20.3 / Kakadu 8.4 CLIs;
data: benchmark-results-Mac142-10.25.0-warm-inproc-20260610.json).

Decode, real medical large fixtures (median ms):

Fixture J2KSwift OpenJPH Grok Kakadu
PX 2459×1316 (small) 31.08 48.22 23.69 23.21
PX 2793×1316 (mid) 30.18 82.79 23.68 22.75
PX 2812×1316 (large) 30.93 81.18 24.33 23.55
DX 2224×2798 (small) 48.23 81.66 43.91 44.34
DX 2800×2288 (mid) 49.56 86.06 46.79 47.15
DX 2544×3056 (large) 60.75 141.82 47.52 4...
Read more

Release v10.24.2

30 May 04:05
22100c1

Choose a tag to compare

J2KSwift v10.24.2

Decode performance — load-balanced + P-core-biased entropy scheduling.

This is a patch release. It is a decoder-only performance improvement;
codestreams are byte-identical to v10.24.1 and there are no public API changes.


Summary

The parallel tier-1 (code-block) entropy decode previously split blocks
into exactly coreCount contiguous chunks (chunkSize = blockCount / coreCount) with no load-balancing, no oversubscription, and no QoS. Because
code-block decode cost is highly skewed (dense LL / low-frequency vs near-empty
HH) and blocks arrive in resolution/packet order, the expensive blocks
clustered into a few chunks — the slowest chunk gated the whole stage while the
other cores idled (~2.9 of 8 cores effective on M2, measured), and the
default-priority tasks spilled onto the M-series E-cores (3–4× slower than
P-cores).

v10.24.2 distributes blocks across 2 × coreCount buckets using LPT
(longest-processing-time-first: sort by descending encoded byte length, greedily
assign each to the least-loaded bucket) and runs the decode tasks at .high
priority
(P-core bias). This is the decode-side analogue of the encoder's
Tier1ChunkPlan. Output is keyed by block index, so the redistribution is
bit-exact.

This work came out of a rigorous "can we be #1 vs Kakadu" investigation. It is
the genuine, shippable win from that effort; the other levers explored (encode
entropy rebalancing, row-band CPU inverse-DWT) were measured, found to be
washes on M2, and not shipped (see Known limitations).


What's New — production-default

  • Load-balanced, P-core-biased decode entropy scheduling. No flag — it is
    the default decode path. Bit-exact; codestream-agnostic (helps any J2K/HTJ2K
    codestream this decoder reads).

Backward compatibility

Aspect vs v10.24.1
Codestream bytes (encode) byte-identical (encoder untouched)
Decoded pixels bit-identical (work redistribution only)
Public API unchanged

Performance (M2, warm)

Workload v10.24.1 → v10.24.2
SDK / library decode (in-process), DX 6.4 MP 63.9 → 48.1 ms (−25%)
same — CT 6.2 → 2.3 ms (−63%)
effective cores (DX entropy stage) 2.9 → 4.4
Canonical decode (large medical geomean) 49.4 → 48.0 ms (−3%)
MG decode (canonical, multi-tile) −5 to −8%

The largest gains are on the in-process library decode path (the
recommended consumption shape) and on multi-tile MG; on MG-mid J2KSwift now
matches/edges Kakadu and Grok. Gains on single-tile DX/PX are smaller on the
canonical path because their inverse-DWT already runs on GPU and the remaining
wall is entropy-core + tier-2 parse, not the rebalanced scheduling.

Cross-codec parity (HTJ2K lossless, fresh)

Decoder change is bit-exact, so cross-codec conformance is unchanged from
v10.24.1: J2KSwift's HTJ2K output decodes bit-exactly under OpenJPH 0.27,
Grok 20.3, and Kakadu 8.4 (15/15 cells across CT/MR/DX/MG/PX).

Test Suite Results

Mandatory commit gate (release mode):

Suite Result
J2KMedicalCorpusEncodePerformanceTests 2/2 pass
J2KMedicalCorpusPerformanceTests 2/2 pass
J2KStrictCrossCodecValidationTests 3/3 pass

Bit-exact round-trip + OpenJPH cross-decode re-verified on DX / PX / MG.

API surface

No additions, removals, or signature changes.

Known limitations

  • The single-tile DX/PX decode gap to Kakadu (~1.1–1.3×) is not closed —
    it lives in the entropy-decode core (pointwise MagSgn/MEL/VLC, repeatedly
    confirmed at the M2 lever ceiling) and fixed per-decode overhead, not in any
    coarse-parallelizable stage. The encode-side rebalance (−0.3%) and the
    row-band CPU inverse-DWT probe (iDWT moves the wall <1 ms — not the
    bottleneck) were both measured as washes and intentionally not shipped.

Reproducing

swift build -c release --product j2k
.build/release/j2k inproc-bench <fixture.dcm> --mode decode --runs 40 --warmups 5
swift test -c release \
  --filter 'J2KMedicalCorpusEncodePerformanceTests|J2KMedicalCorpusPerformanceTests|J2KStrictCrossCodecValidationTests'

Companion documents

Release v10.24.1

30 May 00:55
176cab7

Choose a tag to compare

J2KSwift v10.24.1

HTJ2K lossless data-loss fix — reversible 5/3 magnitude-window sizing.

This is a patch release. It fixes a correctness defect in the HTJ2K
(Part-15) lossless encoder that could silently lose pixel data on
high-contrast content. The default Part-1 (EBCOT) encoder is unaffected and
its codestreams are byte-identical to v10.24.0.


Summary

Testing J2KSwift against the Cloudinary Image Dataset '22 (CID22) — a
non-DICOM, natural, full-colour corpus — exposed a latent bug that the medical
(DICOM) corpus never triggered: j2k encode --htj2k --lossless was not
always lossless.
On 4 of 49 CID22 reference images the HTJ2K codestream
decoded with up to 38,946 wrong pixels (max error 238), while the default
Part-1 lossless path was always bit-exact on the same images.

The defect was confirmed to be in the encoder, not the decoder: the
reference HTJ2K codec OpenJPH reproduced the loss from J2KSwift's
codestream, and J2KSwift's decoder reconstructs OpenJPH's reversible
codestreams bit-exactly. The 12/16-bit medical corpus was immune because that
content never approaches full scale; CID22's full-range 8-bit colour content
drives the reversible 5/3 transform to produce coefficients large enough to
overflow the encoder's magnitude window.

v10.24.1 sizes that window correctly. After the fix, all 49 CID22 images and
the medical corpus round-trip bit-exact in both Part-1 and HTJ2K, and OpenJPH,
Grok, and Kakadu all decode J2KSwift's HTJ2K output bit-exactly.


What's Fixed

HTJ2K conformant reversible encoder dropped the top magnitude bitplane of
any code-block whose coefficients exceeded an under-sized magnitude window.

The conformant HT encoder converts coefficients to OpenJPH sign-magnitude as
sign | (|v| << (31 - K_max)). K_max was derived from the single-level
subband gain {LL:0, HL/LH:1, HH:2}. A multi-level reversible 5/3 transform
expands the coefficient range — the deep LL band especially — so
high-contrast 8-bit content (hard 0↔255 edges) produced coefficients with
magnitude ≥ 2^K_max. |v| << shift then overflowed bit 31 (the sign
bit)
, silently corrupting magnitude and sign and dropping the top bitplane.

Instrumentation on the minimal reproducer: comp=1 sub=LL res=0 K_max=8 window=256 maxAbs=259 → OVERFLOW. Minimal reproducer: an 8×8 RGB tile
(results/htj2k_bug_repro/).

Fix: a centralized htConformantReversibleGain(subband:rctActive:) sizes
the window to match OpenJPH's proven-sufficient reversible K_max
(LL = B+1, detail = B+2, plus one bit when the reversible colour transform is
active, which widens the U/V components), taking max with the previous gain
so windows can only grow, never shrink. The same gain drives the QCD ε
signalling and the per-block shift, so encoder and decoder stay consistent;
the decoder needs no change (it derives K_max from the QCD ε it reads). The
change is scoped to the HT-conformant path — legacy EBCOT and custom-HT
codestreams are byte-identical to v10.24.0.


Backward compatibility

Path vs v10.24.0
Part-1 / EBCOT (default; useHTJ2K = false) codestream byte-identical
Custom HTJ2K block format byte-identical
HTJ2K conformant reversible (--htj2k --lossless) codestream bytes change (the fix) — now correct & OpenJPH/Grok/Kakadu-conformant

Old v10.24.0 HTJ2K codestreams remain decodable (the decoder reads K_max
from each codestream's QCD). No public API was removed or changed.


Cross-codec parity matrix (fresh, HTJ2K lossless)

Encoded with j2k --htj2k --lossless, decoded by three external reference
codecs, compared bit-exact to source pixels:

Fixture bits self OpenJPH 0.27 Grok 20.3 Kakadu 8.4
CT 16
MR 12
DX 12
MG 12
PX 12

External cross-codec cells bit-exact: 15/15. Plus all 4 previously-failing
CID22 RGB images now bit-exact under OpenJPH cross-decode (was max err 238).


Test Suite Results

Mandatory commit gate (release mode):

Suite Result
J2KMedicalCorpusEncodePerformanceTests 2/2 pass
J2KMedicalCorpusPerformanceTests 2/2 pass
J2KStrictCrossCodecValidationTests 3/3 pass

New regression suite:

Suite Result
V10_25_HTConformantReversibleWindowTests 3/3 pass (exact 8×8 reproducer + synthetic high-contrast RGB & greyscale across 1–5 decomposition levels)

Corpus validation:

  • CID22 (49 RGB references): 49/49 bit-exact in both Part-1 and HTJ2K (was 45/49 HTJ2K).
  • HT-conformant medical sample (CT/MR/DX/MG/PX, 8/12/16-bit): bit-exact + OpenJPH cross-decode clean.

API surface

No public additions, removals, or signature changes. One internal helper
(htConformantReversibleGain) added in J2KEncoderPipeline.

New developer tooling (not library API):

  • Scripts/image_corpus_roundtrip.py — non-DICOM (PNG/PPM/TIFF) encode/decode/
    bit-exact-verify harness with lossless, HTJ2K, and lossy PSNR-sweep modes.

Known limitations

  • The fix adds modest magnitude-window headroom, so HTJ2K lossless files are
    marginally larger than v10.24.0 (median CID22 ratio 2.65× → 2.55×). This is
    the cost of correctness and stays well within the normal HT-vs-EBCOT range.
  • CID22 coverage used the official 49-image validation reference set (512×512
    RGB 8-bit); the full 250-image set sits behind a 7.2 GB archive whose CDN
    truncated repeatedly. The 49 images were sufficient to expose and fix the bug.

Reproducing

swift build -c release --product j2k
# CID22 (non-DICOM) round-trip + lossy sweep
python3 Scripts/image_corpus_roundtrip.py \
  --dataset Datasets/cid22/CID22_validation_set/original \
  --bin .build/release/j2k --workers 8 --htj2k --qualities 0.95,0.85,0.50

# Mandatory gate
swift test -c release \
  --filter 'J2KMedicalCorpusEncodePerformanceTests|J2KMedicalCorpusPerformanceTests|J2KStrictCrossCodecValidationTests'

# Regression
swift test --filter V10_25_HTConformantReversibleWindowTests

Companion documents

Release v10.24.0

27 May 11:04
9156d84

Choose a tag to compare

J2KSwift v10.24.0

J2KDICOMHelpers Phase 3.1 — uncompressed pixel extraction.
v10.21.0's Phase 3 file parser returned pixel data ONLY for
J2K-tagged transfer syntaxes (.j2kCompressed(metadata, pixelDataBytes));
non-J2K transfer syntaxes returned metadata-only
(.uncompressed(metadata)). v10.24.0 ships the sibling
parseExtractingPixelData(_:) method that also returns pixel data
for the uncompressed case — for consumers who'd rather get all the
bytes in one call than reach for a separate DICOM library.

MINOR per RELEASING.md — entirely additive: a new sibling type
(J2KDICOMFileWithPixelData) and a new parser method
(parseExtractingPixelData(_:)). Existing
J2KDICOMFile + J2KDICOMFileParser/parse(_:) are unchanged.
Codestream bytes byte-identical to v10.23.0.

Summary

The Phase 3.1 candidate from v10.21.0's "Known limitations" section.
Phase 3 (v10.21.0) shipped the file parser but deliberately left
uncompressed pixel extraction out — the original framing was "consumers
should use their own DICOM library for non-J2K pixel data." On further
consideration this asymmetry is awkward: consumers parsing a .dcm
file via J2KDICOMFileParser.parse(_:) get pixel bytes for J2K-tagged
files but not for uncompressed ones, so they end up shipping two
parsers (J2KSwift's + their DICOM library's) just to read both kinds
uniformly.

v10.24.0 adds a SIBLING method (not a modification of the existing
one) that returns the richer shape:

import J2KDICOMHelpers

let bytes = try Data(contentsOf: someDICOMFileURL)

// Phase 3 path (v10.21.0): metadata only for uncompressed
let viaParse = try J2KDICOMFileParser.parse(bytes)
switch viaParse {
case .j2kCompressed(let m, let pixelData):
    // pixelData present — pass to v10.19's decapsulator
case .uncompressed(let m):
    // metadata only — use your DICOM library for the pixel data
}

// Phase 3.1 path (v10.24.0): pixel data for BOTH cases
let viaExtract = try J2KDICOMFileParser.parseExtractingPixelData(bytes)
switch viaExtract {
case .j2kCompressed(let m, let pixelData):
    // pixelData = encapsulated Item sequence; same bytes as parse(_:)
case .uncompressed(let m, let pixelData):
    // pixelData = raw bytes (rows × cols × samples × bytes × frames)
    //              in SOURCE byte order; no endianness conversion
}

The bytes returned for .uncompressed are exactly what the DICOM
file's (7FE0,0010) element contained — raw, no endianness swap, no
mosaic / planar-config interpretation. Consumers' DICOM library /
image-construction code handles those layout concerns.

What's New — production-default

Public API v10.23.0 v10.24.0
J2KDICOMFileWithPixelData enum not present NEW.j2kCompressed(metadata, pixelDataBytes) + .uncompressed(metadata, pixelDataBytes) with var metadata / var pixelDataBytes / var isJ2KCompressed accessors
J2KDICOMFileParser.parseExtractingPixelData(_:Data) not present NEW — sibling to existing parse(_:); returns J2KDICOMFileWithPixelData (richer shape)
J2KDICOMFile (v10.21.0) unchanged unchanged
J2KDICOMFileParser.parse(_:) (v10.21.0) unchanged unchanged
getVersion() 10.23.0 10.24.0
Every other public API unchanged unchanged

Backward compatibility

  • Codestream bytes: byte-identical to v10.23.0 on every input. The
    encoder + decoder hot paths are unchanged.
  • Existing parser API contract: J2KDICOMFileParser.parse(_:)
    continues to return J2KDICOMFile with the v10.21.0 shape. Consumers
    who pattern-match on .j2kCompressed(metadata, pixelDataBytes) /
    .uncompressed(metadata) are unaffected.
  • Two different return types is intentional: forking would have
    required adding a new case to J2KDICOMFile (e.g.,
    .uncompressedWithPixelData(metadata, pixelDataBytes)) which then
    warns on every exhaustive switch in consumer code without an
    unreachable default — disruptive without source-code changes by the
    consumer. The sibling-type approach keeps both APIs pure-additive.

Why the truncation detection matters

parseExtractingPixelData(_:) validates that the file's declared Pixel
Data length is at least as large as the metadata-derived expected size
(rows × columns × samplesPerPixel × bytesPerSample × numberOfFrames).
A source where the Image-Pixel-Module metadata says 16×16×2 = 512
bytes but the (7FE0,0010) element declares only 100 bytes is malformed
— the v10.24.0 parser throws
J2KDICOMFileError/truncatedFile(expectedAtLeast:got:) rather than
returning a truncated slice that would later mis-decode (mirrors the
CLI's loadDICOM truncation check in J2KCLI/DICOMSupport.swift:187).

Test Suite Results

Suite Tests Result Coverage
V10_36_ParseExtractingPixelDataTests 5/5 PASS J2K-tagged returns same .j2kCompressed(metadata, pixelDataBytes) as parse(_:); uncompressed returns .uncompressed(metadata, pixelDataBytes) with byte-identical source recovery; multi-frame uncompressed (3 frames) returns all bytes; truncated source (declared > metadata-expected) throws .truncatedFile; convenience accessors work for both variants
swift test --filter J2KDICOMHelpers (regression) 56/56 PASS 51 pre-existing + 5 new V10_36
swift test --filter JP3D (regression) 539/539 PASS 1 pre-existing skip
Mandatory commit gate (release mode) 7/7 PASS J2KMedicalCorpusEncodePerformanceTests 2/2 + J2KMedicalCorpusPerformanceTests 2/2 + J2KStrictCrossCodecValidationTests 3/3

API surface — additions only

import J2KDICOMHelpers

public enum J2KDICOMFileWithPixelData: Sendable, Equatable {
    case j2kCompressed(metadata: J2KDICOMFileMetadata, pixelDataBytes: Data)
    case uncompressed(metadata: J2KDICOMFileMetadata, pixelDataBytes: Data)

    public var metadata: J2KDICOMFileMetadata { get }
    public var pixelDataBytes: Data { get }
    public var isJ2KCompressed: Bool { get }
}

public enum J2KDICOMFileParser {
    /// v10.24.0 — parse + always extract pixel data (even for
    /// non-J2K transfer syntaxes). Sibling to v10.21.0's `parse(_:)`.
    public static func parseExtractingPixelData(
        _ data: Data
    ) throws -> J2KDICOMFileWithPixelData
}

No removals. No existing signatures changed.

Recommended usage

import J2KDICOMHelpers
import J2KCodec

// Read once, branch on transfer syntax:
let bytes = try Data(contentsOf: dicomURL)
let parsed = try J2KDICOMFileParser.parseExtractingPixelData(bytes)

switch parsed {
case .j2kCompressed(let metadata, let pixelData):
    // J2K-tagged: feed to v10.19's decapsulator + decoder
    let frames = try J2KDICOMPixelDataDecapsulator.extractFrames(pixelData)
    for codestream in frames {
        let image = try await J2KDecoder().decode(codestream)
        // …
    }

case .uncompressed(let metadata, let pixelData):
    // Uncompressed: pixelData is the raw bytes (rows × cols × samples ×
    // bytesPerSample × numberOfFrames) in source byte order. Consumer's
    // own DICOM library handles layout (planar configuration, mosaic
    // composition, photometric interpretation, etc.).
    let frameSize = metadata.frameSizeInBytes
    let frames = (0..<metadata.numberOfFrames).map { f in
        pixelData.subdata(in: (f * frameSize)..<((f + 1) * frameSize))
    }
    // … hand frames to your DICOM library / image construction code
}

Known limitations

  • No endianness conversion — uncompressed pixel bytes are returned
    in source byte order. For Big Endian transfer syntax
    (1.2.840.10008.1.2.2, retired but still present in legacy archives)
    with 16-bit samples, consumers must byte-swap to their target byte
    order. The metadata's transferSyntaxUID field carries enough info
    to decide.
  • No mosaic / planar-config handling — the bytes are exactly what
    the (7FE0,0010) element contained. Multi-frame data is
    concatenated frames; planar configuration (samples interleaved vs
    planar) is preserved as-is. Consumers handle these layout concerns.
  • Compressed non-J2K transfer syntaxes (RLE, JPEG baseline, etc.)
    return as .uncompressed(metadata, pixelDataBytes) even though the
    bytes are actually a non-J2K compressed payload. Consumers' DICOM
    library handles decoding via the Transfer Syntax UID. This is a
    naming compromise — there's no .otherCompressed case to keep the
    enum minimal.

Reproducing the test numbers

swift test -c release --filter "V10_36_ParseExtractingPixelDataTests"

Five tests covering J2K-tagged parity with parse(_:), uncompressed
single-frame + multi-frame extraction, truncation detection, and
convenience accessors — all PASS in ~0.06 s release mode.

Backward upgrade

swift package update won't auto-pick this release if your Package.swift
pins an exact version; bump the requirement to from: "10.24.0". No
source changes required for consumers — the new types + method are
strictly additive. Existing code using J2KDICOMFile +
J2KDICOMFileParser.parse(_:) continues to work unchanged.

Companion — Next release candidates

After v10.24.0 ships:

  1. JPIP Phase 1 — requestMetadata response parser (~150-300 LOC):
    closes one notImplemented in the JPIP module. Real JPIP work is
    2-3 weeks; a metadata-only Phase 1 could ship in 2 days.
  2. IncrementalJ2KDecoder Phase 1 (~200 LOC, 3 days): header-only
    probe; returns nil if pixel payload incomplete.
  3. Format-specific extensions to encodeToFormat (v10.23.0):
    multi-layer JP2 (jpch codestream profile boxes), JPX extended
    brands, JPM multi-page.

Release v10.23.0

27 May 09:52
0790703

Choose a tag to compare

J2KSwift v10.23.0

Encoder format-flexibility — J2KEncoder.encodeFile(_:to:format:) +
encodeToFormat(_:format:) + public J2KFileWriter.wrap(codestream:headerForImage:).

Write-side counterpart to v10.22.0's decoder-side ship. Before
v10.23.0, consumers using J2KEncoder with its rich
J2KEncodingConfiguration (HTJ2K, multi-tile, custom quality layers,
etc.) had to write to disk through J2KFileWriter.write which re-
encodes
via the simpler J2KConfiguration (quality + lossless
only). The rich encoding choices made via J2KEncoder were discarded
at write time. v10.23.0 closes the gap.

MINOR per RELEASING.md — additive only: two new J2KEncoder
extensions in the existing J2KFileFormat library + one new method
on J2KFileWriter. No signature changes elsewhere; codestream bytes
byte-identical to v10.22.0.

Summary

Before v10.23.0 the encode + write story had this awkward fork:

// Path A: Rich encoding via J2KEncoder, write codestream-only via Data.write
let cfg = J2KEncodingConfiguration(
    quality: 1.0, lossless: true,
    useHTJ2K: true, htj2kBlockFormat: .conformant)
let codestream = try await J2KEncoder(encodingConfiguration: cfg).encode(image)
try codestream.write(to: rawURL)
// → wrote a .j2k codestream file; can't wrap as .jp2 / .jph without
//   hand-rolling the box bytes

// Path B: JP2/JPH wrapping via J2KFileWriter, with simpler config
let writer = J2KFileWriter(format: .jp2)
try await writer.write(image, to: jp2URL, configuration: .lossless)
// → writes a .jp2 file, BUT re-encodes via J2KConfiguration (quality
//   + lossless only) — discards HTJ2K, tile-mode, custom-quality-layer
//   choices a consumer might have wanted to make.

After v10.23.0 it's one path:

import J2KCodec
import J2KFileFormat

let cfg = J2KEncodingConfiguration(
    quality: 1.0, lossless: true,
    useHTJ2K: true, htj2kBlockFormat: .conformant)
let encoder = J2KEncoder(encodingConfiguration: cfg)

// One-line encode + box-wrap + write to disk
try await encoder.encodeFile(image, to: url, format: .jph)

// Or get the bytes (e.g., for network upload, attachment, etc.)
let bytes = try await encoder.encodeToFormat(image, format: .jp2)

// Or wrap a pre-encoded codestream (no re-encode)
let codestream = try await encoder.encode(image)
let jp2Bytes = try J2KFileWriter(format: .jp2)
    .wrap(codestream: codestream, headerForImage: image)

Symmetric counterpart to v10.22.0's decoder ship.

What's New — production-default

Public API v10.22.0 v10.23.0
J2KEncoder.encodeToFormat(_:format:) not present NEW — encode + wrap in .j2k/.jp2/.jph/.jpx/.jpm bytes (extension in J2KFileFormat)
J2KEncoder.encodeFile(_:to:format:) not present NEW — encode + wrap + write to disk (extension in J2KFileFormat)
J2KFileWriter.wrap(codestream:headerForImage:) not present NEW — wrap a pre-encoded codestream in box format (no re-encoding)
J2KFileWriter.write(_:to:configuration:) unchanged unchanged (still re-encodes via J2KConfiguration — that's the documented behaviour)
getVersion() 10.22.0 10.23.0
Every other public API unchanged unchanged

Backward compatibility

  • Codestream bytes: byte-identical to v10.22.0 on every input. The
    encoder hot path is unchanged.
  • Existing API contracts: J2KFileWriter.write(_:to:configuration:)
    semantics preserved — still re-encodes the image via the simpler
    J2KConfiguration. Consumers calling that method are unaffected; the
    new wrap method + J2KEncoder extensions are the opt-in additive
    paths for richer control.
  • Module placement: the new J2KEncoder extensions live in the
    J2KFileFormat module (not J2KCodec) for the same reason as
    v10.22's decoder extensions — J2KFileFormat already depends on
    J2KCodec, so extensions that bridge encoder ↔ box wrappers belong
    here to avoid a J2KCodec → J2KFileFormat cycle.

Why this matters in practice

A medical-imaging consumer (typical J2KSwift use case) wants HTJ2K
lossless encoding for diagnostic archive — that means:

  • useHTJ2K: true (Part-15 block coding, 1.5-2× faster than EBCOT)
  • useReversibleFilter: true (5/3 DWT, bit-exact reconstruction)
  • htj2kBlockFormat: .conformant (interoperable with OpenJPH 0.26+)
  • Often custom decomposition levels, code-block sizes, or quality
    layers depending on the modality

None of these knobs are reachable via J2KConfiguration (which only
exposes quality + lossless). Before v10.23.0, getting any of them
into a .jp2 / .jph file required either:

  1. Encoding via J2KEncoder then hand-rolling the box bytes, OR
  2. Writing via J2KFileWriter and accepting whatever the re-encoded
    codestream looks like.

v10.23.0's encodeFile(_:to:format:) gives consumers the rich
encoder configuration AND the file-format wrapper in one call.

Test Suite Results

Suite Tests Result Coverage
V10_35_EncoderFileFormatTests 11/11 PASS wrap for .j2k returns codestream unchanged; for .jp2/.jph prepends correct signature box; rejects invalid (zero-dim) image; wrap → extractCodestream round-trip; encodeToFormat(.j2k) matches encode(_:); .jp2 / .jph encodeToFormat round-trips bit-exact through decodeAnyFormat; encodeFile writes to temp disk + decodeFile reads back bit-exact; default format is .jp2; unwritable path throws I/O error
swift test --filter J2KFileFormatTests (regression) 376/376 PASS 365 pre-existing + 11 new V10_35 + 10 pre-existing skips
swift test --filter JP3D (regression) 539/539 PASS 1 pre-existing skip
Mandatory commit gate (release mode) 7/7 PASS J2KMedicalCorpusEncodePerformanceTests 2/2 + J2KMedicalCorpusPerformanceTests 2/2 + J2KStrictCrossCodecValidationTests 3/3

API surface — additions only

// In J2KFileFormat library — import J2KFileFormat to gain these.
import J2KFileFormat

extension J2KEncoder {
    /// v10.23.0 — encode + wrap in target format's box structure.
    public func encodeToFormat(
        _ image: J2KImage,
        format: J2KFormat = .jp2
    ) async throws -> Data

    /// v10.23.0 — encode + wrap + write to disk.
    public func encodeFile(
        _ image: J2KImage,
        to url: URL,
        format: J2KFormat = .jp2
    ) async throws
}

extension J2KFileWriter {
    /// v10.23.0 — wrap a pre-encoded codestream (no re-encode).
    public func wrap(
        codestream: Data,
        headerForImage image: J2KImage
    ) throws -> Data
}

No removals. No existing signatures changed.

Recommended usage

import J2KCodec
import J2KFileFormat

// Configure encoder with rich options
var cfg = J2KEncodingConfiguration(
    quality: 1.0,
    lossless: true,
    useHTJ2K: true,
    useReversibleFilter: true,
    htj2kBlockFormat: .conformant)
cfg.bitrateMode = .constantQuality
let encoder = J2KEncoder(encodingConfiguration: cfg)

// Direct to file
try await encoder.encodeFile(image, to: outputURL, format: .jph)

// Or to bytes (e.g., upload to cloud / attach to network response)
let jp2Bytes = try await encoder.encodeToFormat(image, format: .jp2)

// Or split: encode separately, wrap separately (e.g., reuse codestream
// for multiple targets)
let codestream = try await encoder.encode(image)
let jpxBytes = try J2KFileWriter(format: .jpx)
    .wrap(codestream: codestream, headerForImage: image)
let dicomPixelData = J2KDICOMPixelDataEncapsulator
    .encapsulateFrames([codestream])

Round-trip with v10.22's decoder side

import J2KCodec
import J2KFileFormat

let encoder = J2KEncoder(...)
let decoder = J2KDecoder()

// Encode + write
try await encoder.encodeFile(image, to: url, format: .jph)
// Read + decode (auto-detects JPH wrapping)
let decoded = try await decoder.decodeFile(at: url)

// For lossless HTJ2K: decoded.components[0].data == image.components[0].data (bit-exact)

Known limitations

  • encodeFile is one-shot — encodes + writes serially. For very
    large images where you want to overlap encode + I/O, use
    encodeToFormat + your own async file-write. Not a real concern for
    typical medical-image sizes (1-10 MP).
  • JPX / JPM formats currently use the same box layout as JP2 with
    brand differences. Full Part 2 extensions (multi-layer images, ICC
    profiles, etc.) and Part 6 multi-page support are existing
    limitations of J2KFileWriter, not introduced by v10.23.
  • wrap(codestream:headerForImage:) reads only image header
    metadata
    (dimensions, components, bit depth) — not pixel data. The
    codestream is the source of truth for actual content; the image
    parameter just populates the JP2 Header box's ihdr sub-box. Pass
    any valid J2KImage whose header matches the codestream's encoded
    geometry.

Reproducing the test numbers

swift test -c release --filter "V10_35_EncoderFileFormatTests"

Eleven tests covering wrap per format + encodeToFormat + encodeFile
with round-trip via v10.22's decoder side — all PASS in ~0.07 s release mode.

Backward upgrade

swift package update won't auto-pick this release if your Package.swift
pins an exact version; bump the requirement to from: "10.23.0". To use
the new convenience methods on J2KEncoder, add J2KFileFormat to the
consuming target's product list (the methods live in extensions there;
same pattern as v10.22's decoder extensions):

.target(
    name: "YourApp",
    dependencies: [
        .product(name: "J2KCodec",      package: "J2KSwift"),
        .product(name: "J2KFileFormat", package: "J2KSwift"),  // for encode/decodeFile
    ])

Consumers not adding J2KFileFormat are completely unaffected.

Companion — Next release candidates

After v10.23.0 ships:

  1. JPIP Phase 1 — requestMetadata response parser (~150 LOC, 2 days):
    closes one notImplemented in the JPIP module.
  2. IncrementalJ2KDecoder Phase 1 (~...
Read more

Release v10.22.0

27 May 07:37
fcda5f6

Choose a tag to compare

J2KSwift v10.22.0

Decoder format-flexibility — J2KDecoder.decodeAnyFormat(_:) +
decodeFile(at:) + public J2KFileReader.extractCodestream(from:).

Before v10.22.0, J2KDecoder.decode(_:) only accepted raw J2K
codestream bytes — most .jp2 / .jph / .jpx files in the wild
are JP2-box-wrapped (the codestream lives inside a jp2c Contiguous
Codestream box), so consumers had to roll their own box walker or
use the read-only J2KFileReader.read(from:) (which returns
header-only metadata, not decoded pixels). v10.22.0 closes both gaps.

MINOR per RELEASING.md — additive only: two new J2KDecoder
extensions in the existing J2KFileFormat library + one
visibility change (private → public) on a helper that's been
shipping since v5.x. No signature changes elsewhere; codestream
bytes byte-identical to v10.21.0.

Summary

The end-to-end "I have a file on disk, give me a J2KImage" flow used
to require:

// Before v10.22.0 — three indirection steps
let data = try Data(contentsOf: url)
// (consumer rolls their own JP2 box walker, or...)
let reader = J2KFileReader()
// (which only returns header metadata, not decoded pixels)
let image = try reader.read(from: url)  // metadata-only J2KImage
// To get actual decoded pixels, consumer extracts codestream
// themselves + calls decoder

After v10.22.0:

import J2KFileFormat
import J2KCodec

// One-line decode from a URL — auto-handles JP2/JPX/JPH boxes
let image = try await J2KDecoder().decodeFile(at: url)

// Or from in-memory bytes (raw codestream OR box-wrapped)
let bytes = try Data(contentsOf: url)
let image2 = try await J2KDecoder().decodeAnyFormat(bytes)

// Or extract just the codestream for downstream use (DICOM re-wrap,
// JPIP, decodeRegion, etc.):
let codestream = try J2KFileReader().extractCodestream(from: jp2Bytes)

What's New — production-default

Public API v10.21.0 v10.22.0
J2KDecoder.decodeAnyFormat(_:Data) not present NEW — auto-detects format (raw J2K vs JP2/JPX/JPM/JPH) and decodes (extension in J2KFileFormat)
J2KDecoder.decodeFile(at:URL) not present NEW — reads bytes from URL + calls decodeAnyFormat (extension in J2KFileFormat)
J2KFileReader.extractCodestream(from:Data) private public — walk box hierarchy, return jp2c contents
J2KDecoder.decode(_:Data) unchanged unchanged (still raw-codestream-only — strict semantics preserved)
getVersion() 10.21.0 10.22.0
Every other public API unchanged unchanged

Backward compatibility

  • Codestream bytes: byte-identical to v10.21.0 on every input. Encoder
    • decoder hot paths unchanged.
  • Existing API contracts: J2KDecoder.decode(_:) semantics
    preserved — still throws if given JP2-boxed bytes (consumers who
    rely on this for input validation continue to work). The new
    decodeAnyFormat(_:) is an opt-in additive surface; no consumer
    is forced into the auto-detection path.
  • J2KFileReader.extractCodestream(from:) visibility change:
    the method was previously private and an implementation detail of
    J2KFileReader.read(from:). Making it public formalises the contract
    but doesn't change behaviour. Callers that previously walked JP2 boxes
    themselves can switch to this helper without semantic difference.

Why this is genuinely useful

Most J2K files on disk follow the JP2 box format (PS3-15441-2). The
jp2c Contiguous Codestream box wraps the raw codestream alongside
metadata boxes (signature, file-type, JP2 header). Before v10.22.0
the J2KDecoder API surface implicitly required consumers to know
which format they had and unwrap themselves — a real friction point
for "I just want to decode this file" use cases.

Also: this is the natural complement to v10.17 / v10.19 / v10.21's
DICOM-side work — both are "consumers have container-wrapped J2K
bytes; give them a one-call path to a J2KImage."

Test Suite Results

Suite Tests Result Coverage
V10_34_DecoderFormatFlexibilityTests 8/8 PASS extractCodestream extracts from JP2 + JPH (both start with SOC 0xFF 0x4F); rejects raw codestream (no jp2c box); decodeAnyFormat accepts raw + JP2 + JPH bytes with bit-exact lossless round-trip; decodeFile reads + decodes from a temp file URL; missing file throws I/O error
swift test --filter J2KFileFormatTests (regression) 365/365 PASS 357 pre-existing + 8 new V10_34 + 10 pre-existing skips
swift test --filter JP3D (regression) 539/539 PASS 1 pre-existing skip
Mandatory commit gate (release mode) 7/7 PASS J2KMedicalCorpusEncodePerformanceTests 2/2 + J2KMedicalCorpusPerformanceTests 2/2 + J2KStrictCrossCodecValidationTests 3/3

API surface — additions only

// In J2KFileFormat library — bring J2KDecoder convenience by importing it.
import J2KFileFormat

extension J2KDecoder {
    /// v10.22.0 — accepts raw J2K codestream OR JP2-box-wrapped bytes.
    public func decodeAnyFormat(_ data: Data) async throws -> J2KImage

    /// v10.22.0 — reads bytes from a file URL and decodes
    /// (auto-detects format).
    public func decodeFile(at url: URL) async throws -> J2KImage
}

// Previously private; now public (signature unchanged).
extension J2KFileReader {
    public func extractCodestream(from data: Data) throws -> Data
}

No removals. No existing signatures changed (only visibility on
extractCodestream).

Recommended usage

import J2KCodec
import J2KFileFormat

let decoder = J2KDecoder()

// Decode a file (any J2K-family format)
let image = try await decoder.decodeFile(at: someURL)

// Decode bytes (any J2K-family format) — e.g., from a network response
let image2 = try await decoder.decodeAnyFormat(networkBytes)

// Strict raw-codestream-only decode (existing API, unchanged)
let image3 = try await decoder.decode(rawCodestreamBytes)

// Extract J2K codestream from JP2-wrapped bytes (re-wrap into DICOM,
// feed to decodeRegion, etc.)
let codestream = try J2KFileReader().extractCodestream(from: jp2Bytes)

Known limitations

  • decodeFile(at:) uses Data(contentsOf:) internally —
    loads the whole file into memory. Not appropriate for very large
    files (>1 GB) without explicit memory-mapped variants. For large-
    file streaming, future IncrementalJ2KDecoder work is the path.
  • Auto-detection costs ~50 ns on a typical decode (read first
    12 bytes, classify). Negligible vs the decode itself but worth
    noting for ultra-tight benchmarking loops.
  • JPM (Part 6, multi-page) files: decodeAnyFormat follows the
    same extraction logic as JP2 — returns the first jp2c Contiguous
    Codestream box. Multi-page JPM iteration is a separate arc; for now
    consumers needing all pages should iterate boxes themselves.

Reproducing the test numbers

swift test -c release --filter "V10_34_DecoderFormatFlexibilityTests"

Eight tests covering extract / decodeAnyFormat / decodeFile across raw,
JP2, and JPH formats — all PASS in ~0.07 s release mode.

Backward upgrade

swift package update won't auto-pick this release if your Package.swift
pins an exact version; bump the requirement to from: "10.22.0". To use
the new convenience methods on J2KDecoder, add J2KFileFormat to the
consuming target's product list (the methods live in extensions there):

.target(
    name: "YourApp",
    dependencies: [
        .product(name: "J2KCodec",      package: "J2KSwift"),
        .product(name: "J2KFileFormat", package: "J2KSwift"),  // for decodeFile/decodeAnyFormat
    ])

Consumers not adding J2KFileFormat are completely unaffected.

Companion — Next release candidates

After v10.22.0 ships:

  1. J2KEncoder.encodeFile(_:to:format:) symmetric file-write convenience
    mirror what v10.22.0 does on the decode side. J2KFileWriter.write
    already exists but doesn't take a J2KEncodingConfiguration directly
    (uses the simpler J2KConfiguration). A J2KEncoder extension that
    bridges would close the symmetry.
  2. JPIP Phase 1 — requestMetadata response parser (~150 LOC, 2 days):
    closes one notImplemented in the JPIP module.
  3. IncrementalJ2KDecoder Phase 1 (~200 LOC, 3 days): header-only
    probe; returns nil if pixel payload incomplete.
  4. J2KDICOMHelpers Phase 3.1 — uncompressed pixel extraction
    variant on J2KDICOMFile.uncompressed.

Release v10.21.0

27 May 04:10
07d2647

Choose a tag to compare

J2KSwift v10.21.0

J2KDICOMHelpers Phase 3 — DICOM file parser. Library consumers can
now parse .dcm file bytes natively in J2KSwift to extract Transfer
Syntax UID + Image Pixel Module metadata + (for J2K-tagged transfer
syntaxes) the encapsulated Pixel Data byte stream. Completes the
DICOM read-side story: combined with v10.17.0 (Phase 1 UID-and-config
bridge) + v10.19.0 (Phase 2 Pixel Data encapsulation), consumers can go
from raw .dcm bytes to decoded J2KImage without leaving J2KSwift.

MINOR per RELEASING.md — entirely additive: new public types in the
existing J2KDICOMHelpers library, no signature changes elsewhere,
codestream bytes byte-identical to v10.20.0, no DICOM library
dependency added anywhere
(ADR-004 compliant).

Summary

Before v10.21.0, consumers parsing .dcm files had to use their own
DICOM library (pydicom, DICOMKit, dcm4che) to extract the J2K codestream
out of the DICOM container before feeding it to J2KDecoder — even
though J2KSwift's CLI has had the file-parsing logic since v8 (private
to the CLI target). v10.21.0 lifts the J2K-relevant subset of that
parser into the J2KDICOMHelpers library product so the end-to-end
read flow is self-contained:

import J2KDICOMHelpers
import J2KCodec

let dcmBytes = try Data(contentsOf: URL(fileURLWithPath: "study.dcm"))

let file = try J2KDICOMFileParser.parse(dcmBytes)

switch file {
case .j2kCompressed(let metadata, let pixelDataBytes):
    print("\(metadata.transferSyntaxUID)\(metadata.rows)×\(metadata.columns), \(metadata.numberOfFrames) frame(s)")
    let frames = try J2KDICOMPixelDataDecapsulator.extractFrames(pixelDataBytes)
    for frameBytes in frames {
        let img = try await J2KDecoder().decode(frameBytes)
        // …
    }

case .uncompressed(let metadata):
    // Not a J2K-tagged DICOM — consumer's own DICOM library reads the
    // pixel data; metadata still available here.
    print("Uncompressed: \(metadata.transferSyntaxUID)")
}

What's New — production-default

Public API v10.20.0 v10.21.0
J2KDICOMFileParser.parse(_:) not present NEW — parses a .dcm file's bytes; returns .j2kCompressed or .uncompressed based on Transfer Syntax UID
J2KDICOMFile enum not present NEW — discriminated union (.j2kCompressed(metadata, pixelDataBytes), .uncompressed(metadata)) with var metadata + var isJ2KCompressed convenience accessors
J2KDICOMFileMetadata struct not present NEW — image-pixel-module attributes (rows, columns, bits, photometric interpretation, etc.) + derived properties (bytesPerSample, effectiveBitsStored, frameSizeInBytes)
J2KDICOMFileError enum not present NEW — Sendable, Equatable error type: .invalidPreamble, .missingDICMMagic, .missingPixelDataTag, .truncatedFile, .invalidTransferSyntax, .sequenceParsingFailed
getVersion() 10.20.0 10.21.0
Every other public API unchanged unchanged

Backward compatibility

  • Codestream bytes: byte-identical to v10.20.0. Encoder + decoder
    paths unchanged.
  • Existing libraries: zero behaviour change. All previously-shipped
    J2KDICOMHelpers types (J2KDICOMTransferSyntax,
    J2KDICOMCodestreamDetector, J2KDICOMPhotometricInterpretation,
    J2KDICOMPixelDataEncapsulator, J2KDICOMPixelDataDecapsulator) and
    every JP3D / J2KCodec / J2KCore API are unchanged.
  • ADR-004 compliant: no DICOM library dependency added anywhere.
    The file-parsing logic is pure byte inspection per DICOM PS3.5 §7
    (File Meta Information) + §6 (Data Element Structure) + PS3.3 §C.7.6.3.1
    (Image Pixel Module).

Scope discipline — what Phase 3 deliberately does NOT include

  • Uncompressed pixel-data extraction.uncompressed carries
    metadata only. Consumers wanting to read raw pixel data should use
    their own DICOM library. Phase 3.1 / v10.22 candidate.
  • Compressed-non-J2K transfer syntaxes (RLE, JPEG baseline, etc.)
    — these stay CLI-only; the CLI handles them via a Python helper
    (macOS-only) that's not portable into the helpers library.
  • DICOM file writing — read-only Phase 3. Phase 4 candidate.
  • DICOM tag dictionary / IOD validation — consumer's responsibility;
    their DICOM library covers this.

Test Suite Results

Suite Tests Result Coverage
V10_33_FileParserPreambleTests 5/5 PASS empty / too-short / 131-byte / missing-DICM / garbage-after-DICM rejected with right typed error
V10_33_FileParserUncompressedFixtureTests 3/3 PASS Iterates the 13 synthetic .dcm fixtures (CT/MR/DX/XA/PX/MG/CR/NM modalities); all classify as .uncompressed with valid metadata; derived properties (bytesPerSample, frameSizeInBytes) compute correctly
V10_33_FileParserJ2KSynthesisTests 5/5 PASS End-to-end synthesis (J2KEncoder → J2KDICOMPixelDataEncapsulator → hand-built DICOM header) → parse → metadata round-trip → decapsulate → byte-identical recovery of original codestream(s). HTJ2K Lossless single + multi-frame with BOT, J2K Lossless, non-J2K UID classification, missing-Pixel-Data error
V10_33_* total 13/13 PASS
swift test --filter J2KDICOMHelpers (full regression) 51/51 PASS 26 V10_29 (Phase 1) + 12 V10_31 (Phase 2) + 13 V10_33 (Phase 3)
swift test --filter JP3D (regression) 539/539 PASS 1 pre-existing skip
Mandatory commit gate (release mode) 7/7 PASS J2KMedicalCorpusEncodePerformanceTests 2/2 + J2KMedicalCorpusPerformanceTests 2/2 + J2KStrictCrossCodecValidationTests 3/3

API surface — additions only

import J2KDICOMHelpers

public enum J2KDICOMFileParser {
    /// Parse a DICOM file's bytes; return Transfer Syntax UID +
    /// Image Pixel Module metadata + (for J2K-tagged files) the
    /// encapsulated Pixel Data byte stream.
    public static func parse(_ data: Data) throws -> J2KDICOMFile
}

public enum J2KDICOMFile: Sendable, Equatable {
    case j2kCompressed(metadata: J2KDICOMFileMetadata, pixelDataBytes: Data)
    case uncompressed(metadata: J2KDICOMFileMetadata)

    public var metadata: J2KDICOMFileMetadata { get }
    public var isJ2KCompressed: Bool { get }
}

public struct J2KDICOMFileMetadata: Sendable, Equatable, Hashable {
    public let transferSyntaxUID: String
    public let rows: Int
    public let columns: Int
    public let bitsAllocated: Int
    public let bitsStored: Int                // 0 ⇒ same as bitsAllocated
    public let pixelRepresentation: Int       // 0 = unsigned, 1 = signed
    public let samplesPerPixel: Int
    public let photometricInterpretation: String
    public let numberOfFrames: Int            // ≥ 1
    public let planarConfiguration: Int       // 0 = interleaved, 1 = planar

    // Derived properties
    public var effectiveBitsStored: Int { get }
    public var bytesPerSample: Int { get }
    public var frameSizeInBytes: Int { get }
}

public enum J2KDICOMFileError: Error, Sendable, Equatable {
    case invalidPreamble(size: Int)
    case missingDICMMagic(actual: String)
    case missingPixelDataTag
    case truncatedFile(expectedAtLeast: Int, got: Int)
    case invalidTransferSyntax(uid: String)
    case sequenceParsingFailed(offset: Int, reason: String)
}

No removals. No existing signatures changed.

Recommended usage

import J2KDICOMHelpers
import J2KCodec
import J2K3D

// === Single-frame J2K-tagged read ===
let bytes = try Data(contentsOf: URL(fileURLWithPath: "image.dcm"))
let file = try J2KDICOMFileParser.parse(bytes)

if case .j2kCompressed(_, let pixelData) = file {
    let codestream = try J2KDICOMPixelDataDecapsulator.extractFrames(pixelData)[0]
    let image = try await J2KDecoder().decode(codestream)
    // → use image
}

// === Multi-frame: decode every frame ===
guard case .j2kCompressed(let meta, let pixelData) = file else { return }
let frames = try J2KDICOMPixelDataDecapsulator.extractFrames(pixelData)
let images = try await withThrowingTaskGroup(of: J2KImage.self) { group in
    for f in frames {
        group.addTask { try await J2KDecoder().decode(f) }
    }
    var collected: [J2KImage] = []
    for try await img in group { collected.append(img) }
    return collected
}

Known limitations / process debt

  • DocC docs site staleness — the documentation.yml workflow was
    deleted 2026-05-27 as part of the cloud-cost reduction. The
    gh-pages site is currently frozen at v10.17. Restoring the workflow
    with a tag-push-only trigger (~$0.50 per release) would address it,
    but that reverses an explicit user decision. Mention here for
    visibility; no action taken in v10.21.0.
  • DICOMKit downstream verification — the dicomkit-downstream.yml
    workflow was also deleted in the same reduction. v10.21.0 (like
    v10.20.0 before it) ships without an automated downstream build
    check against the DICOMKit consumer.
  • Phase 3 doesn't extract uncompressed pixel data — for non-J2K
    transfer syntaxes, only metadata is returned. A consumer's own
    DICOM library handles pixel parsing for those cases. Phase 3.1 / v10.22
    candidate adds the uncompressed-pixel-bytes case.

Reproducing the test numbers

swift test -c release --filter "V10_33"

13 tests across 3 suites (preamble + uncompressed-fixture + J2K-synthesis) —
all PASS in ~0.07 s release mode.

Backward upgrade

swift package update won't auto-pick this release if your Package.swift
pins an exact version; bump the requirement to from: "10.21.0". The
new types are strictly additive; existing code using J2KDICOMHelpers
types (J2KDICOMTransferSyntax, J2KDICOMCodestreamDetector,
J2KDICOMPhotometricInterpretation, J2KDICOMPixelDataEncapsulator,
J2KDICOMPixelDataDecapsulator) continues to work without modification.

Companion — Next release candidates

After v10.21.0 ships:

  1. J2KDICOMHelpers Phase 3.1 — uncompressed pixel extraction
    (`case .uncompressed(metadata, pix...
Read more

Release v10.20.0

26 May 20:24
1ba2ca0

Choose a tag to compare

J2KSwift v10.20.0

JP3D preWarm() symmetric completion — discoverable from every JP3D
type.
v10.15.0 shipped JP3DDecoder.preWarm + JP3DROIDecoder.preWarm;
six sibling JP3D types (encoder, multi-spectral encoder/decoder, progressive
decoder, stream writer, transcoder) lacked the equivalent wrapper. v10.20.0
closes that gap. Pure additive surface — six 2-line static wrappers
delegating to the same underlying J2KDecoder.preWarm that warms the
process-wide J2KMetalSession.processShared.

MINOR per RELEASING.md — additive only, no signature changes elsewhere,
codestream bytes byte-identical to v10.19.0.

Summary

Today consumers using e.g. JP3DMultiSpectralEncoder had to know to
import J2KCodec separately and call J2KDecoder.preWarm themselves
(or call JP3DDecoder.preWarm from v10.15, which warms the same
shared session but is named for the wrong primary type). v10.20.0 makes
preWarm discoverable from every JP3D actor's own API surface:

import J2K3D  // just this — no need for J2KCodec

await JP3DEncoder.preWarm(includeWarmupDispatch: true)
await JP3DMultiSpectralDecoder.preWarm(includeWarmupDispatch: true)
await JP3DMultiSpectralEncoder.preWarm(includeWarmupDispatch: true)
await JP3DProgressiveDecoder.preWarm(includeWarmupDispatch: true)
await JP3DStreamWriter.preWarm(includeWarmupDispatch: true)
await JP3DTranscoder.preWarm(includeWarmupDispatch: true)

// Subsequent first operation through ANY JP3D type runs warm.

All six wrappers are 2-line statics that await J2KDecoder.preWarm(includeWarmupDispatch:). The shared
J2KMetalSession.processShared means warming once via any wrapper
covers every subsequent JP3D operation in the process; the discoverability
is the value.

What's New — production-default

Public API v10.19.0 v10.20.0
JP3DEncoder.preWarm(includeWarmupDispatch:) not present NEW — thin wrapper around J2KDecoder.preWarm
JP3DMultiSpectralDecoder.preWarm(includeWarmupDispatch:) not present NEW — same
JP3DMultiSpectralEncoder.preWarm(includeWarmupDispatch:) not present NEW — same
JP3DProgressiveDecoder.preWarm(includeWarmupDispatch:) not present NEW — same
JP3DStreamWriter.preWarm(includeWarmupDispatch:) not present NEW — same
JP3DTranscoder.preWarm(includeWarmupDispatch:) not present NEW — same
getVersion() 10.19.0 10.20.0
Every other public API unchanged unchanged

Backward compatibility

  • Codestream bytes: byte-identical to v10.19.0 on every input.
  • Existing APIs: zero behaviour change. JP3DDecoder.preWarm and
    JP3DROIDecoder.preWarm from v10.15.0 continue to work identically;
    the six new wrappers all delegate to the same J2KDecoder.preWarm
    • warm the same shared session.
  • API surface: additive only — six new public static functions, no
    existing signatures changed.

Why this is honest discoverability, not a perf claim

Per V10_27's v10.16-research probe, the encoder-side cold cost beyond
what JP3DDecoder.preWarm(includeWarmupDispatch: true) already amortises
is below the 3 ms acceptance threshold (+0.10 ms / −1.25 ms on 128×128×16
and 256×256×16 lossless HTJ2K fixtures). So calling JP3DEncoder.preWarm()
delivers the same cold-start savings as JP3DDecoder.preWarm() — the
underlying warmup is shared.

The value of v10.20.0 is discoverability + API symmetry: consumers
using any one of the six previously-uncovered types no longer have to
know to look at JP3DDecoder for the preWarm entry point. The release
notes do NOT claim new perf savings on warm paths.

Test Suite Results

Suite Tests Result Coverage
V10_32_PreWarmSymmetricCompletionTests 7/7 PASS All 6 new wrappers callable from import J2K3D alone, accept includeWarmupDispatch: parameter, don't throw or crash; cross-type warm test verifies JP3DEncoder.preWarm(includeWarmupDispatch: true) + subsequent decode end-to-end
swift test --filter JP3D (full regression) 539/539 PASS 532 pre-existing + 7 new V10_32 + 1 pre-existing skip
Mandatory commit gate (release mode) 7/7 PASS J2KMedicalCorpusEncodePerformanceTests 2/2 + J2KMedicalCorpusPerformanceTests 2/2 + J2KStrictCrossCodecValidationTests 3/3

API surface — additions only

extension JP3DEncoder {
    public static func preWarm(includeWarmupDispatch: Bool = false) async
}
extension JP3DMultiSpectralDecoder {
    public static func preWarm(includeWarmupDispatch: Bool = false) async
}
extension JP3DMultiSpectralEncoder {
    public static func preWarm(includeWarmupDispatch: Bool = false) async
}
extension JP3DProgressiveDecoder {
    public static func preWarm(includeWarmupDispatch: Bool = false) async
}
extension JP3DStreamWriter {
    public static func preWarm(includeWarmupDispatch: Bool = false) async
}
extension JP3DTranscoder {
    public static func preWarm(includeWarmupDispatch: Bool = false) async
}

No removals. No existing signatures changed.

Recommended usage

import J2K3D

// In your app / SDK startup (any one preWarm call covers all subsequent
// JP3D operations through the shared J2KMetalSession.processShared):
await JP3DEncoder.preWarm(includeWarmupDispatch: true)

// Later, anywhere — first operation through ANY JP3D type runs warm:
let result = try await JP3DMultiSpectralEncoder().encode(...)

Known limitations

  • Calling preWarm on multiple JP3D types is idempotent — the shared
    session is warmed once on the first call. Subsequent calls are
    cheap (Metal init is one-shot per process) but don't add additional
    savings.
  • On Linux (J2KMetalSession.isAvailable == false), all six wrappers
    silently no-op via the underlying J2KDecoder.preWarm's
    Linux-safe fallback. JP3D operations on Linux take the CPU path
    which doesn't pay the Metal init cost in the first place.

Reproducing the test numbers

swift test -c release --filter "V10_32_PreWarmSymmetricCompletionTests"

Seven tests covering surface availability per type + cross-type shared-
session verification — all PASS in ~0.1 s release mode.

Backward upgrade

swift package update won't auto-pick this release if your Package.swift
pins an exact version; bump the requirement to from: "10.20.0". No
source changes required for consumers — the new wrappers are strictly
additive. Existing code calling JP3DDecoder.preWarm() or
J2KDecoder.preWarm() continues to work unchanged.

Release v10.19.0

26 May 19:33
d294bc1

Choose a tag to compare

J2KSwift v10.19.0

J2KDICOMHelpers Phase 2 — DICOM Pixel Data encapsulation helpers.
Wrap J2K codestreams into DICOM Pixel Data Item bytes (PS3.5 §A.4)
and round-trip them back. Builds directly on v10.17.0's Phase 1 product
without introducing any DICOM library dependency.

MINOR per RELEASING.md — pure additive surface on the existing
J2KDICOMHelpers library: 2 new public enums, no signature changes
elsewhere, codestream bytes byte-identical to v10.18.0.

Summary

v10.17.0 (Phase 1) shipped the UID-and-config bridge:
J2KDICOMTransferSyntax + encodingConfiguration() +
J2KDICOMCodestreamDetector + J2KDICOMPhotometricInterpretation.
The natural follow-on is wire-format helpers — turning a J2K codestream
into DICOM Pixel Data bytes and back. v10.19.0 ships exactly that:

import J2KDICOMHelpers
import J2KCodec

// 1. Encode an image via J2KSwift
let ts = J2KDICOMTransferSyntax.htj2kLossless
let cfg = ts.encodingConfiguration(bitDepth: 16)
let codestream = try await J2KEncoder(encodingConfiguration: cfg).encode(image)

// 2. Wrap it as DICOM Pixel Data
let pixelDataBytes = J2KDICOMPixelDataEncapsulator
    .encapsulateFrames([codestream], includeBOT: false)

// 3. Hand pixelDataBytes off to your DICOM writer for insertion at
//    (7FE0,0010) with VR "OB" and undefined length.

// On the round-trip:
let extracted = try J2KDICOMPixelDataDecapsulator.extractFrames(pixelDataBytes)
assert(extracted.count == 1)
assert(extracted[0] == codestream)  // bit-exact

For multi-frame data (e.g., a CT volume encoded as one J2K codestream
per slice), pass the frame array and optionally request a populated
Basic Offset Table:

let frames: [Data] = sliceCodestreams  // one J2K codestream per Z-slice
let pixelDataBytes = J2KDICOMPixelDataEncapsulator
    .encapsulateFrames(frames, includeBOT: true)

The BOT contains one little-endian u32 per frame, giving the offset
of that frame's Item header measured from the start of the FIRST
FRAME ITEM (per PS3.5 §A.4) — populated BOTs let downstream consumers
seek directly to frame N without scanning.

What's New — production-default

Public API v10.18.0 v10.19.0
J2KDICOMPixelDataEncapsulator.encapsulateItem(_:) not present NEW — wraps one J2K codestream into a single DICOM Pixel Data Item (8-byte header + payload + optional pad)
J2KDICOMPixelDataEncapsulator.encapsulateFrames(_:includeBOT:) not present NEW — wraps multiple frames + (optional) BOT + Sequence Delimitation Item
J2KDICOMPixelDataDecapsulator.extractFrames(_:) not present NEW — parses a DICOM Pixel Data sequence back into one Data per frame; strips trailing pad bytes
J2KDICOMPixelDataError not present NEWSendable, Equatable error type for decapsulator failures (truncated, itemTagExpected, itemLengthOverrun, malformedSequenceDelimitation)
getVersion() 10.18.0 10.19.0
Every other public API unchanged unchanged

The new surface is in the J2KDICOMHelpers SwiftPM library that
v10.17.0 introduced. Consumers not importing J2KDICOMHelpers are
unaffected.

Backward compatibility

  • Codestream bytes: byte-identical to v10.18.0 on every input.
    Encoder unchanged.
  • Existing libraries: zero behaviour change. All previously-shipped
    J2KDICOMHelpers types (J2KDICOMTransferSyntax,
    J2KDICOMCodestreamDetector, J2KDICOMPhotometricInterpretation)
    unchanged. JP3D / J2KCodec / J2KCore unchanged.
  • API surface: additive only. Two new public enums + one new error
    type. No existing signatures changed.
  • ADR-004 compliant: no DICOM library dependency added anywhere.
    The encapsulator/decapsulator codify the byte-layout rules directly
    from DICOM PS3.5 §A.4 / §6.4.

Why this is the right Phase 2 scope

The original v10.17.0 plan called Phase 2 "DICOM file parser extraction
from J2KCLI/DICOMSupport.swift". On closer inspection, full file
parsing pulls in:

  • Group 0002 / dataset tag walking (~150 LOC)
  • All the byte-reading helpers (dcmReadU16LE, dcmReadString, etc.,
    ~50 LOC)
  • Multi-frame layout + photometric interpretation handling for the
    full uncompressed pixel-data → J2KImage conversion (~200 LOC)
  • Encapsulated pixel data parsing for the J2K-tagged case (~80 LOC)

The first three are "consumer should use their own DICOM library
anyway" — pydicom, DICOMKit, dcm4che, etc. all do the file parsing
correctly. What consumers actually need from us is the J2K-specific
wire format
for the Pixel Data element — exactly what v10.19.0
ships. Full file parsing stays in Sources/J2KCLI/DICOMSupport.swift
where it's been working since v8.

This narrower Phase 2 is also immediately useful for the write
side
: a consumer who encoded an image via J2KSwift and wants to
embed it in a DICOM file uses encapsulateFrames(_:). That use case
didn't exist before v10.19.0; consumers had to hand-roll the byte
layout (the pattern was demonstrated in
Tests/J2KCodecTests/J2KStrictCrossCodecValidationTests.swift:170-194
as test scaffolding).

Test Suite Results

Suite Tests Result Coverage
V10_31_PixelDataEncapsulationTests 12/12 PASS encapsulateItem even+odd length, padding correctness; encapsulateFrames single+multi frame with empty and populated BOT; round-trip byte-exact single + multi frame; pad stripping; truncated/invalid input rejection; error type Equatable
swift test --filter J2KDICOMHelpers (full regression) 38/38 PASS 26 V10_29 (Phase 1) + 12 V10_31 (Phase 2)
swift test --filter JP3D (full regression) 532/532 PASS 1 pre-existing skip
Mandatory commit gate (release mode) 7/7 PASS J2KMedicalCorpusEncodePerformanceTests 2/2 + J2KMedicalCorpusPerformanceTests 2/2 + J2KStrictCrossCodecValidationTests 3/3

API surface — additions only

public enum J2KDICOMPixelDataEncapsulator {
    /// Wrap one J2K codestream into a single DICOM Pixel Data Item.
    /// 8-byte header (FFFE,E000 tag + LE u32 length) + payload + optional 0x00 pad.
    public static func encapsulateItem(_ codestream: Data) -> Data

    /// Wrap multiple J2K codestreams into a DICOM Pixel Data Item sequence:
    /// BOT (optional contents) + per-frame Items + Sequence Delimitation Item.
    public static func encapsulateFrames(
        _ frames: [Data],
        includeBOT: Bool = false
    ) -> Data
}

public enum J2KDICOMPixelDataDecapsulator {
    /// Parse a DICOM Pixel Data Item sequence into per-frame J2K codestreams.
    /// Strips trailing 0x00 pad bytes when detected via EOC-then-pad pattern.
    public static func extractFrames(_ encapsulated: Data) throws -> [Data]
}

public enum J2KDICOMPixelDataError: Error, Sendable, Equatable {
    case truncated(needed: Int, got: Int)
    case itemTagExpected(offset: Int)
    case itemLengthOverrun(itemOffset: Int, declaredLength: Int)
    case malformedSequenceDelimitation(actualLength: UInt32)
}

No removals. No existing signatures changed.

Recommended usage

import J2KDICOMHelpers
import J2KCodec
import J2K3D

// === Encode-then-embed (write side) ===
let ts = J2KDICOMTransferSyntax.htj2kLossless
let cfg = ts.encodingConfiguration(bitDepth: 16)

// Single frame: 2D image
let codestream = try await J2KEncoder(encodingConfiguration: cfg).encode(image)
let pixelData = J2KDICOMPixelDataEncapsulator.encapsulateFrames([codestream])
// → hand pixelData off to your DICOM writer for (7FE0,0010)

// Multi-frame: JP3D volume as one J2K codestream per slice
let sliceData: [Data] = try await encodeJP3DPerSlice(volume)  // your own
let multiFramePixelData = J2KDICOMPixelDataEncapsulator
    .encapsulateFrames(sliceData, includeBOT: true)

// === Extract-then-decode (read side) ===
let pixelDataBytes = readPixelDataFromDICOM(...)  // your DICOM library
let frames = try J2KDICOMPixelDataDecapsulator.extractFrames(pixelDataBytes)
for frameBytes in frames {
    let decoded = try await J2KDecoder().decode(frameBytes)
    // …
}

Known limitations

  • No DICOM file parsing: Phase 2 still doesn't parse .dcm files
    — that stays in Sources/J2KCLI/DICOMSupport.swift (or your
    consumer-side DICOM library). Phase 2's encapsulator/decapsulator
    works on the Pixel Data element's BYTES, not the surrounding
    metadata.
  • BOT endianness: per the current DICOM Standard, BOT entries are
    little-endian u32. Some older interpretations specify big-endian;
    v10.19.0 follows the current (and dcm4che / pydicom-consistent)
    little-endian convention. If you're consuming legacy archives that
    used big-endian BOTs, the BOT contents will be wrong but the
    per-frame extraction (which scans Item-by-Item, not BOT-driven)
    still works correctly — extractFrames(_:) doesn't depend on BOT
    contents for correctness.
  • Pad-byte detection: extractFrames(_:) strips one trailing pad
    byte from each frame ONLY when the payload's last three bytes are
    0xFF 0xD9 0x00 (EOC + pad). This is the canonical layout for J2K
    / HTJ2K codestreams. Codestreams that don't end in EOC (truncated
    or non-standard) retain the trailing byte; this is conservative
    behaviour — strip the byte yourself if you know the source is
    intentionally pad-extended.

Reproducing the test numbers

swift test -c release --filter "V10_31_PixelDataEncapsulationTests"

12 tests covering Item / Frames / BOT layout + round-trip + pad
handling + error paths — all PASS in ~0.003 s release mode.

Backward upgrade

swift package update won't auto-pick this release if your Package.swift
pins an exact version; bump the requirement to from: "10.19.0". No
source changes required for consumers — the new types are strictly
additive. Existing code using J2KDICOMTransferSyntax,
J2KDICOMCodestreamDetector, or J2KDICOMPhotometricInterpretation
continues to work without modification.

Companion — Next release candidates

After v10.19.0 ships, the remaining ...

Read more