Releases: Raster-Lab/J2KSwift
Release v11.0.0
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
J2KMetalBufferPoolis untouched). - Dead concurrency infra:
J2KConcurrencyTuning(work-stealing queue,
contention analyzer, concurrent pipeline),J2KThreadPool,J2KGCDDispatcher
(J2KQualityOfServicekept — 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 behindj2k validate
(J2KDecoderConformanceClass,J2KMarkerSegmentValidator,
J2KCodestreamSyntaxValidator), the DICOMKit-consumed HT conformance API
(J2KErrorMetrics,J2KTestVector,J2KConformanceValidator,
HTJ2KTestVectorGenerator,HTJ2KConformanceTestHarness,
J2KHTInteroperabilityValidator— now inJ2KHTConformanceAPI.swift), and the
OpenJPEG CLI wrapper behindj2k benchmark --compare-openjpeg
(nowJ2KOpenJPEGCLI.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 toDocumentation/Benchmarks/data/
(links updated);.gitignorehardened against re-accretion.
Restructured
J2KTestAppModelsmoved from J2KCore to a newJ2KTestAppCorelibrary
target. The audit recommended deletion; the verification sweep corrected it —
the shippingj2k testapp --headlesscommand 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-optimizationon J2KCore/J2KCodec — redundant (SwiftPM release
config already implies both).-parse-as-libraryon the three executables — replaced by renaming the entry
files (main.swift→Entry.swift/J2KDaemonMain.swift) so@mainworks 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-Ohandles
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
.customHT block format (~4.3K lines + dual
routing branches): three live consumer chains (thej2k transcode/
batch-transcodeCLI 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
J2KAccelerateproduct 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 testusers: the J2KAccelerateTests/J2KVulkanTests/J2KXSTests/
J2KInteroperabilityTests/PerformanceTests targets no longer exist;
Scripts/run-full-regression.shis 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 buildCompanion documents
OPTIMIZATION_AUDIT_2026-06-10.md— the audit that scoped this arc.
Release v10.25.0
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:
-
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). -
CLI partial decode wired —
--leveland--regionwere parsed and
documented but never reached the v10.4–v10.7 partial-decode APIs; every decode
ran at full resolution.--level 0on a DX 2800×2288 now decodes in 10 ms vs
57 ms full. In the process, the wiring surfaced a latent API defect: … -
Multi-tile partial-resolution decode fixed —
decodeResolutionproduced
corrupt output (junk pixels, full-size buffers) on multi-tile codestreams — the
production.autolayout 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. -
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). -
swift testexit code fixed — all CLI command logic moved to a new
J2KCLICorelibrary target with a thin@mainwrapper. 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. -
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. -
JP3D Foundation
Datahot loops — the v10.10–v10.20 arc shipped per-voxel
Datasubscript 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. -
preWarm honesty —
preWarm()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, andj2kdpre-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.
.hightask 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;--level→decodeResolution;--region x,y,w,h→decodeRegion(.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 }aftercb.completed()) replaced
withunsafeUninitializedCapacity+ 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.multiTilePerTilecase.J2KMetalDWT.fusedKernelEligible(...)— public single source of truth for the
fused-kernel routing predicate.J2KCLICorelibrary target (thej2kexecutable 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... |
Release v10.24.2
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
PERF_DECODE_ENTROPY_PARALLELISM.md— the optimization + honest A/B data.CROSS_CODEC_PERF_REPORT_v10.24.1.md— the cross-codec standing that motivated this work.
Release v10.24.1
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_HTConformantReversibleWindowTestsCompanion documents
CID22_COMPRESSION_TEST_REPORT.md— full test report, root-cause analysis, and fix verification.
Release v10.24.0
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 returnJ2KDICOMFilewith 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 toJ2KDICOMFile(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'stransferSyntaxUIDfield 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.otherCompressedcase 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:
- JPIP Phase 1 —
requestMetadataresponse 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. IncrementalJ2KDecoderPhase 1 (~200 LOC, 3 days): header-only
probe; returnsnilif pixel payload incomplete.- Format-specific extensions to
encodeToFormat(v10.23.0):
multi-layer JP2 (jpchcodestream profile boxes), JPX extended
brands, JPM multi-page.
Release v10.23.0
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
newwrapmethod +J2KEncoderextensions are the opt-in additive
paths for richer control. - Module placement: the new
J2KEncoderextensions live in the
J2KFileFormatmodule (notJ2KCodec) for the same reason as
v10.22's decoder extensions —J2KFileFormatalready 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:
- Encoding via
J2KEncoderthen hand-rolling the box bytes, OR - Writing via
J2KFileWriterand 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
encodeFileis 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 ofJ2KFileWriter, 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'sihdrsub-box. Pass
any validJ2KImagewhose 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:
- JPIP Phase 1 —
requestMetadataresponse parser (~150 LOC, 2 days):
closes one notImplemented in the JPIP module. IncrementalJ2KDecoderPhase 1 (~...
Release v10.22.0
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 decoderAfter 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 previouslyprivateand 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:)usesData(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, futureIncrementalJ2KDecoderwork 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:
decodeAnyFormatfollows the
same extraction logic as JP2 — returns the firstjp2cContiguous
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:
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 aJ2KEncodingConfigurationdirectly
(uses the simplerJ2KConfiguration). AJ2KEncoderextension that
bridges would close the symmetry.- JPIP Phase 1 —
requestMetadataresponse parser (~150 LOC, 2 days):
closes one notImplemented in the JPIP module. IncrementalJ2KDecoderPhase 1 (~200 LOC, 3 days): header-only
probe; returnsnilif pixel payload incomplete.J2KDICOMHelpersPhase 3.1 — uncompressed pixel extraction
variant onJ2KDICOMFile.uncompressed.
Release v10.21.0
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
J2KDICOMHelperstypes (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 —
.uncompressedcarries
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.ymlworkflow was
deleted 2026-05-27 as part of the cloud-cost reduction. The
gh-pagessite 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:
J2KDICOMHelpersPhase 3.1 — uncompressed pixel extraction
(`case .uncompressed(metadata, pix...
Release v10.20.0
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.preWarmand
JP3DROIDecoder.preWarmfrom v10.15.0 continue to work identically;
the six new wrappers all delegate to the sameJ2KDecoder.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
preWarmon 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 underlyingJ2KDecoder.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
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-exactFor 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 | NEW — Sendable, 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
J2KDICOMHelperstypes (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 →J2KImageconversion (~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
.dcmfiles
— that stays inSources/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 ...