Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,53 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

---

## [1.3.0] — 2026-07-01 (release)

**16-bit lossy VarDCT.** The lossy VarDCT codec now accepts 16-bit RGB/RGBA
input (previously 8-bit only), matching the bit-depth range the lossless
Modular codec has supported since v1.0. A lossy encode request on a 16-bit
frame now routes to VarDCT instead of silently falling back to lossless
Modular. Encoded bytes for existing 8-bit lossy VarDCT and all lossless paths
are unchanged — byte-for-byte regression-checked against the pre-change build.

### Added

- **16-bit lossy VarDCT encode + decode**, RGB and RGBA. `VarDCTEncoder.forward`
accepts `.uint16` frames; the sRGB↔linear conversion generalises to any bit
depth via a `maxValue` parameter, with the 8-bit code path left untouched (no
per-pixel branch added to the default hot path). `VarDCTBitstreamWriter`
threads the real bit depth into `ImageMetadata`/`ExtraChannelInfo` instead of
hardcoding 8; `JXLDecoder`'s VarDCT reconstruction reads the declared bit
depth and emits a `uint16` `ImageFrame` when appropriate. Grayscale VarDCT
remains unsupported at any bit depth (falls back to lossless Modular,
unchanged from before).

### Fixed

- **16-bit RGBA alpha-channel corruption in VarDCT encode** — `buildFrameSections`
read the alpha channel with a byte stride only correct for 8-bit samples; a
16-bit RGBA frame would read the wrong bytes entirely. Alpha now round-trips
bit-exact (verified against `djxl`).
- `VarDCTBitstreamWriter.encodeAnimation` now rejects frames that disagree on
bit depth, extending the pre-existing xsize/ysize/hasAlpha agreement guard,
instead of silently misdeclaring the shared `ImageMetadata`.

### Verification

- `djxl 0.11.2` oracle: our 16-bit RGB/RGBA/multi-group VarDCT bitstreams
decode via djxl within 3–16 (out of 65535) max-diff of our own decoder on
the same bytes, with the alpha channel bit-exact; djxl accepts every
generated bitstream (exit 0).
- Byte-for-byte regression check against a pre-change build (isolated git
worktree): 8-bit lossy VarDCT, 8/16-bit lossless Modular, and multi-frame
animation encode all produce **identical** output — zero drift on the
existing default path.
- `swift build -c release` clean. The full XCTest suite needs an Xcode host
(this dev env has only CommandLineTools); the djxl byte-exact spec gate was
exercised directly via CLI round-trips, and the new tests' logic was
additionally verified by running the identical API calls through a
throwaway executable target linked against the built library (then removed).

## [1.2.0] — 2026-06-30 (release)

**JPEG-XL transfer-syntax completion + lossless ratio.** Three additions: the
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ swift test -c release # ~688 tests, ~70 s (many shell out to djxl)
| E6 | LZ77 hybrid header (§C.6.5) | ✅ header; back-references decoded by the JPEG bridge entropy path (`djxl`-validated via the `lz77_flower` conformance vector) |
| M0 | Project-internal vertical slice via `MinimalLosslessCodec` | ✅ |
| M | Modular sub-codec (lossless path, real frame header §C.8.1) | ✅ — `SpecModularEncoder`: 8/16-bit gray / gray+alpha / RGB / RGBA, arbitrary dims ≤ 16384 (multi-group + multi-DC-group), multi-property MA-trees + learned thresholds, effort knob, `djxl`-byte-exact |
| V | VarDCT (lossy path) | ✅ **decode** (`djxl`-matching, Phase R filters incl.); lossy *encode* deferred to the last phase (project focus is lossless) |
| V | VarDCT (lossy path) | ✅ **encode + decode**, 8-bit and 16-bit RGB/RGBA (`djxl`-matching, Phase R filters incl.); lossy VarDCT is the CLI/API default. Grayscale VarDCT not yet wired (falls back to lossless Modular) |
| R | Restoration filters (Gaborish + EPF) | ✅ (decode) |
| J | JPEG-XL ↔ JPEG reversible transcoding (no generational loss) | ✅ forward (JPEG→JXL, ~1.03–1.05× cjxl, ≤ 2048 px/side) + reverse (JXL→JPEG, byte-identical: baseline + progressive + ICC) |

Expand Down
9 changes: 4 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ import PackageDescription
// amended 2026-05) — currently scaffolding only; functions added there must
// be byte-equivalent to a scalar Swift reference and gate-tested.
//
// Status: v1.2.0 production-ready lossless JPEG XL codec — medical-grade
// Status: v1.3.0 production-ready lossless JPEG XL codec — medical-grade
// (DICOM + CID22 byte-exact via djxl), Swift-first with the `JXLPerfC`
// boundary scaffolded for future C/C++ hot-path work. v1.2.0 adds the
// public reverse JPEG-recompression API (`JXLDecoder.decodeLosslessJPEG`)
// and the YCoCg-R lossless colour transform (21–66 % smaller RGB/RGBA,
// cost-gated, djxl-byte-exact — see CHANGELOG.md).
// boundary scaffolded for future C/C++ hot-path work. v1.3.0 extends the
// lossy VarDCT codec to 16-bit RGB/RGBA (previously 8-bit only), matching
// the lossless Modular path's bit-depth range — see CHANGELOG.md.

// JXLSwift is fully self-contained: no shared-protocol package. The only
// dependency is swift-argument-parser, and that is CLI-only (the JXLSwift
Expand Down
192 changes: 144 additions & 48 deletions Sources/JXLSwift/Codec/JXLDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3514,52 +3514,131 @@ extension JXLDecoder {
}
}

// Per-pixel XYB → linear RGB → sRGB → 8-bit. Crop the W*H plane
// back to the frame's actual `xsize × ysize` (the plane is
// padded out to a multiple of 8).
var rgb8 = [UInt8](repeating: 0, count: xsize * ysize * 3)
// Bit depth drives the output sample width. Mirrors
// `assembleImageFrame`'s existing (bps<=8 ? .uint8 : .uint16)
// split for the Modular path — VarDCT frames declare the
// same `ImageMetadata.bitDepth` field.
let bps = metadata.bitDepth.bitsPerSample
guard !metadata.bitDepth.floatingPoint else {
throw DecoderError.notImplemented(
"VarDCT decode: float-sample bit depth not supported"
)
}
guard bps >= 1 && bps <= 16 else {
throw DecoderError.notImplemented(
"VarDCT decode of \(bps)-bit samples "
+ "(only 1..16 supported today)"
)
}
let pixelType: PixelType = (bps <= 8) ? .uint8 : .uint16
let _ = (kRequiredSizeX, kRequiredSizeY)

// Per-pixel XYB → linear RGB → sRGB → output samples. Crop
// the W*H plane back to the frame's actual `xsize × ysize`
// (the plane is padded out to a multiple of 8).
//
// The 8-bit branch is the original, unmodified code path
// (byte-identical output — no regression risk to the
// existing default decode). The 16-bit branch is new: it
// shares `linearToSRGBCode`, the bit-depth-generalised form
// of `linearToSRGB8`, but keeps its own buffer/loop rather
// than unifying with the 8-bit path, so the 8-bit case never
// pays for the extra branch or the wider buffer.
if pixelType == .uint8 {
var rgb8 = [UInt8](repeating: 0, count: xsize * ysize * 3)
for y in 0..<ysize {
for x in 0..<xsize {
let pi = y * planeWidth + x
let lin = OpsinXYB.inverse(
(X: planeX[pi], Y: planeY[pi], B: planeB[pi])
)
let oi = (y * xsize + x) * 3
rgb8[oi + 0] = linearToSRGB8(lin.R)
rgb8[oi + 1] = linearToSRGB8(lin.G)
rgb8[oi + 2] = linearToSRGB8(lin.B)
}
}
if trace {
for y in 0..<min(3, ysize) {
var row = "TRACE RGB row \(y):"
for x in 0..<min(3, xsize) {
let i = (y * xsize + x) * 3
row += " (\(rgb8[i]),\(rgb8[i+1]),\(rgb8[i+2]))"
}
FileHandle.standardError.write(Data((row + "\n").utf8))
}
}

// (17) Wire RGB into ImageFrame. Multi-block (v0.7.0) — for
// frames that span multiple AC groups the AC decode loop
// would need to be repeated per group with separate ANS
// state; that's the v0.7.0+ multi-group milestone. For
// single-AC-group frames (xsize, ysize ≤ group_dim, default
// 256), the current pipeline is sufficient.
// Multi-AC-group and multi-DC-group (frames > ~2048 px) are
// both wired up — each DC group is decoded into its sub-region
// of the full-frame planes above.
if nbExtraChannels == 0 {
var frame = ImageFrame(width: xsize, height: ysize, channels: 3)
frame.data = rgb8
return frame
}
// A single alpha extra channel → RGBA output. The modular-
// decoded extra-channel samples are the alpha values directly
// (extra channels carry no colour transform); interleave them
// behind the VarDCT-decoded RGB.
guard nbExtraChannels == 1,
metadata.extraChannels[0].type == .alpha else {
throw DecoderError.notImplemented(
"VarDCT decode: \(nbExtraChannels) extra channel(s) of "
+ "types \(metadata.extraChannels.map { $0.type }) — "
+ "only a single alpha channel is wired to output")
}
let alpha = extraChannelPlanes[0]
var rgba = [UInt8](repeating: 0, count: xsize * ysize * 4)
for i in 0..<(xsize * ysize) {
rgba[i * 4 + 0] = rgb8[i * 3 + 0]
rgba[i * 4 + 1] = rgb8[i * 3 + 1]
rgba[i * 4 + 2] = rgb8[i * 3 + 2]
rgba[i * 4 + 3] = i < alpha.count
? UInt8(clamping: alpha[i]) : 255
}
var frame = ImageFrame(
width: xsize, height: ysize, channels: 4, alphaChannels: 1)
frame.data = rgba
return frame
}

// 16-bit path (bps 9...16). Little-endian sample pairs,
// matching `ImageFrame`'s internal `.uint16` convention
// (`getPixel`/`setPixel`).
let sampleMax = UInt32((1 << bps) - 1)
let maxValueF = Float(sampleMax)
var rgb16 = [UInt8](repeating: 0, count: xsize * ysize * 3 * 2)
for y in 0..<ysize {
for x in 0..<xsize {
let pi = y * planeWidth + x
let lin = OpsinXYB.inverse(
(X: planeX[pi], Y: planeY[pi], B: planeB[pi])
)
let oi = (y * xsize + x) * 3
rgb8[oi + 0] = linearToSRGB8(lin.R)
rgb8[oi + 1] = linearToSRGB8(lin.G)
rgb8[oi + 2] = linearToSRGB8(lin.B)
let oi = (y * xsize + x) * 3 * 2
let rv = linearToSRGBCode(lin.R, maxValue: maxValueF)
let gv = linearToSRGBCode(lin.G, maxValue: maxValueF)
let bv = linearToSRGBCode(lin.B, maxValue: maxValueF)
rgb16[oi + 0] = UInt8(rv & 0xff)
rgb16[oi + 1] = UInt8((rv >> 8) & 0xff)
rgb16[oi + 2] = UInt8(gv & 0xff)
rgb16[oi + 3] = UInt8((gv >> 8) & 0xff)
rgb16[oi + 4] = UInt8(bv & 0xff)
rgb16[oi + 5] = UInt8((bv >> 8) & 0xff)
}
}
if trace {
for y in 0..<min(3, ysize) {
var row = "TRACE RGB row \(y):"
for x in 0..<min(3, xsize) {
let i = (y * xsize + x) * 3
row += " (\(rgb8[i]),\(rgb8[i+1]),\(rgb8[i+2]))"
}
FileHandle.standardError.write(Data((row + "\n").utf8))
}
}

// (17) Wire RGB into ImageFrame. Multi-block (v0.7.0) — for
// frames that span multiple AC groups the AC decode loop
// would need to be repeated per group with separate ANS
// state; that's the v0.7.0+ multi-group milestone. For
// single-AC-group frames (xsize, ysize ≤ group_dim, default
// 256), the current pipeline is sufficient.
// Multi-AC-group and multi-DC-group (frames > ~2048 px) are
// both wired up — each DC group is decoded into its sub-region
// of the full-frame planes above.
let _ = (kRequiredSizeX, kRequiredSizeY)
if nbExtraChannels == 0 {
var frame = ImageFrame(width: xsize, height: ysize, channels: 3)
frame.data = rgb8
var frame = ImageFrame(
width: xsize, height: ysize, channels: 3, pixelType: .uint16)
frame.data = rgb16
return frame
}
// A single alpha extra channel → RGBA output. The modular-
// decoded extra-channel samples are the alpha values directly
// (extra channels carry no colour transform); interleave them
// behind the VarDCT-decoded RGB.
guard nbExtraChannels == 1,
metadata.extraChannels[0].type == .alpha else {
throw DecoderError.notImplemented(
Expand All @@ -3568,17 +3647,25 @@ extension JXLDecoder {
+ "only a single alpha channel is wired to output")
}
let alpha = extraChannelPlanes[0]
var rgba = [UInt8](repeating: 0, count: xsize * ysize * 4)
var rgba16 = [UInt8](repeating: 0, count: xsize * ysize * 4 * 2)
for i in 0..<(xsize * ysize) {
rgba[i * 4 + 0] = rgb8[i * 3 + 0]
rgba[i * 4 + 1] = rgb8[i * 3 + 1]
rgba[i * 4 + 2] = rgb8[i * 3 + 2]
rgba[i * 4 + 3] = i < alpha.count
? UInt8(clamping: alpha[i]) : 255
let src = i * 3 * 2
let dst = i * 4 * 2
rgba16[dst + 0] = rgb16[src + 0]
rgba16[dst + 1] = rgb16[src + 1]
rgba16[dst + 2] = rgb16[src + 2]
rgba16[dst + 3] = rgb16[src + 3]
rgba16[dst + 4] = rgb16[src + 4]
rgba16[dst + 5] = rgb16[src + 5]
let av: UInt32 = i < alpha.count
? min(UInt32(max(0, alpha[i])), sampleMax) : sampleMax
rgba16[dst + 6] = UInt8(av & 0xff)
rgba16[dst + 7] = UInt8((av >> 8) & 0xff)
}
var frame = ImageFrame(
width: xsize, height: ysize, channels: 4, alphaChannels: 1)
frame.data = rgba
width: xsize, height: ysize, channels: 4,
pixelType: .uint16, alphaChannels: 1)
frame.data = rgba16
return frame
}

Expand All @@ -3595,19 +3682,28 @@ extension JXLDecoder {
}
}

/// Per-IEC 61966-2-1 sRGB OETF: linear-light [0,1] → 8-bit code
/// value. Clamps to [0, 255].
/// Per-IEC 61966-2-1 sRGB OETF: linear-light [0,1] → code value,
/// generalised over bit depth via `maxValue` (`255` for 8-bit,
/// `65535`/`(2^bps - 1)` for wider depths). Clamps to
/// `[0, maxValue]`.
@inline(__always)
private func linearToSRGB8(_ linear: Float) -> UInt8 {
private func linearToSRGBCode(_ linear: Float, maxValue: Float) -> UInt32 {
let clamped = max(0, min(linear, 1))
let encoded: Float
if clamped <= 0.0031308 {
encoded = 12.92 * clamped
} else {
encoded = 1.055 * powf(clamped, 1.0 / 2.4) - 0.055
}
let rounded = (encoded * 255.0).rounded()
return UInt8(max(0, min(rounded, 255)))
let rounded = (encoded * maxValue).rounded()
return UInt32(max(0, min(rounded, maxValue)))
}

/// Per-IEC 61966-2-1 sRGB OETF: linear-light [0,1] → 8-bit code
/// value. Clamps to [0, 255].
@inline(__always)
private func linearToSRGB8(_ linear: Float) -> UInt8 {
UInt8(linearToSRGBCode(linear, maxValue: 255.0))
}

/// Best-effort container unwrap: returns the naked codestream
Expand Down
22 changes: 12 additions & 10 deletions Sources/JXLSwift/Codec/JXLEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@
// STATUS: both halves of the codec are wired up.
// • Lossless Modular — 8-bit and 16-bit integer samples (grayscale,
// RGB, RGBA), single-pass, any size up to the encoder's 8K cap.
// • Lossy VarDCT — 8-bit RGB / RGBA via `VarDCTBitstreamWriter`
// (DCT8×8, multi-DC-group, ≤ 8192 px).
// • Lossy VarDCT — 8-bit AND 16-bit RGB / RGBA via
// `VarDCTBitstreamWriter` (DCT8×8, multi-DC-group, ≤ 8192 px).
// Grayscale (< 3 channels) is not yet wired to VarDCT.
// Output round-trips through `djxl 0.11.2`.
//
// Routing: `encode(_:)` picks the codec from `options.mode`.
// `.lossless` always uses the Modular path. Lossy modes
// (`.lossy` / `.distance`) use the VarDCT path when the frame is one
// VarDCT can take; for frames it can't (grayscale, 16-bit, oversized)
// the encoder **falls back to lossless Modular** so `encode` always
// VarDCT can take; for frames it can't (grayscale, oversized) the
// encoder **falls back to lossless Modular** so `encode` always
// yields a valid codestream rather than failing. The Modular path
// deinterleaves the caller's channel-interleaved `ImageFrame.data`
// into per-channel buffers and dispatches into `SpecModularEncoder`.
Expand Down Expand Up @@ -64,9 +65,10 @@ public struct JXLEncoder: Sendable {
/// - `EncodingOptions.useM0Placeholder == true` routes through
/// `MinimalLosslessCodec` (the legacy M0 vertical slice).
/// - A lossy `mode` (`.lossy` / `.distance`) routes to the VarDCT
/// encoder when the frame is 8-bit RGB/RGBA within VarDCT's
/// size limits; otherwise it **falls back** to the lossless
/// Modular path (so the call still produces a valid codestream).
/// encoder when the frame is 8-bit or 16-bit RGB/RGBA within
/// VarDCT's size limits; otherwise (grayscale, oversized) it
/// **falls back** to the lossless Modular path (so the call
/// still produces a valid codestream).
/// - `.lossless` always uses the Modular path, dispatched on the
/// frame's `pixelType`, `channels`, and `alphaChannels`.
///
Expand Down Expand Up @@ -97,8 +99,8 @@ public struct JXLEncoder: Sendable {
let start = Date()

// Lossy modes encode through the VarDCT codec. When the frame
// is one VarDCT can't take (non-8-bit, <3 or >4 channels,
// beyond the writer's size limits) `VarDCTBitstreamWriter` /
// is one VarDCT can't take (<3 or >4 channels, beyond the
// writer's size limits) `VarDCTBitstreamWriter` /
// `VarDCTEncoder` throw their `unsupported` case — caught here
// so the encode falls back to the lossless Modular path below.
if case .lossless = options.mode {
Expand All @@ -124,7 +126,7 @@ public struct JXLEncoder: Sendable {
// VarDCT can't take this frame — fall through to
// the lossless Modular dispatch below.
} catch is VarDCTEncoder.EncoderError {
// Likewise (non-8-bit / wrong channel count).
// Likewise (wrong channel count, e.g. grayscale).
}
}

Expand Down
Loading
Loading