From 19ab709d9440472fa95b1dfb0591d32a364743c6 Mon Sep 17 00:00:00 2001 From: Ranjithkumar Date: Wed, 1 Jul 2026 17:23:08 +0530 Subject: [PATCH 1/2] feat(vardct): 16-bit lossy VarDCT encode/decode for RGB/RGBA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lossy VarDCT previously accepted only 8-bit RGB/RGBA and silently fell back to lossless Modular for 16-bit input, even though the lossless Modular path has supported 8- and 16-bit since v1.0. This closes that gap: - VarDCTEncoder.forward accepts .uint16 frames; srgb8ToLinear generalises to srgbToLinear(_:maxValue:), keeping the 8-bit pixel loop byte-for-byte unchanged (no per-pixel branch on the default hot path) and adding a separate 16-bit loop. - VarDCTBitstreamWriter threads the real bit depth into ImageMetadata/ExtraChannelInfo instead of hardcoding 8, and fixes a latent alpha-unpack stride bug (frame.data[i*ch+3] only gave the right byte for 1-byte samples) that would have corrupted 16-bit RGBA alpha. encodeAnimation now rejects frames that disagree on bit depth rather than silently misdeclaring the shared metadata. - JXLDecoder's VarDCT reconstruction reads metadata.bitDepth and emits a uint16 ImageFrame when appropriate, mirroring the pattern the Modular path already used in assembleImageFrame. Verified via the djxl 0.11.2 oracle (16-bit RGB/RGBA/multi-group bitstreams decode within max-diff 3-16/65535 of our own decoder; alpha bit-exact) and a byte-for-byte regression diff against a pre-change build (8-bit lossy, 8/16-bit lossless, and multi-frame animation all byte-identical — zero drift on the existing default path). Co-Authored-By: Claude Sonnet 5 --- Sources/JXLSwift/Codec/JXLDecoder.swift | 192 ++++++--- Sources/JXLSwift/Codec/JXLEncoder.swift | 22 +- .../Codec/VarDCTBitstreamWriter.swift | 67 ++- Sources/JXLSwift/Codec/VarDCTEncoder.swift | 84 +++- Sources/JXLTool/Encode.swift | 12 +- Tests/JXLSwiftTests/IntegrationTests.swift | 400 ++++++++++++++++++ 6 files changed, 670 insertions(+), 107 deletions(-) diff --git a/Sources/JXLSwift/Codec/JXLDecoder.swift b/Sources/JXLSwift/Codec/JXLDecoder.swift index a819f56..b821949 100644 --- a/Sources/JXLSwift/Codec/JXLDecoder.swift +++ b/Sources/JXLSwift/Codec/JXLDecoder.swift @@ -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.. ~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..> 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.. ~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( @@ -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 } @@ -3595,10 +3682,12 @@ 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 { @@ -3606,8 +3695,15 @@ extension JXLDecoder { } 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 diff --git a/Sources/JXLSwift/Codec/JXLEncoder.swift b/Sources/JXLSwift/Codec/JXLEncoder.swift index 1eb2763..6b9ec20 100644 --- a/Sources/JXLSwift/Codec/JXLEncoder.swift +++ b/Sources/JXLSwift/Codec/JXLEncoder.swift @@ -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`. @@ -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`. /// @@ -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 { @@ -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). } } diff --git a/Sources/JXLSwift/Codec/VarDCTBitstreamWriter.swift b/Sources/JXLSwift/Codec/VarDCTBitstreamWriter.swift index 24c97b7..e0ad6a1 100644 --- a/Sources/JXLSwift/Codec/VarDCTBitstreamWriter.swift +++ b/Sources/JXLSwift/Codec/VarDCTBitstreamWriter.swift @@ -44,13 +44,16 @@ package enum VarDCTBitstreamWriter { let ysize: Int let hasAlpha: Bool let gaborish: Bool + /// `frame.pixelType.bitsPerSample` (8 or 16) — threaded into + /// the outer codestream's `ImageMetadata.bitDepth`. + let bitsPerSample: Int } - /// Encode an 8-bit RGB/RGBA `ImageFrame` as a VarDCT JPEG XL - /// file (naked codestream). Frames up to the 8192-px encoder cap - /// are supported: ≤ 256 px as a single contiguous section, larger - /// frames as a multi-section codestream with one AC group per - /// 256-px tile and one DC group per 2048-px tile. + /// Encode an 8-bit or 16-bit RGB/RGBA `ImageFrame` as a VarDCT + /// JPEG XL file (naked codestream). Frames up to the 8192-px + /// encoder cap are supported: ≤ 256 px as a single contiguous + /// section, larger frames as a multi-section codestream with one + /// AC group per 256-px tile and one DC group per 2048-px tile. package static func encode( frame: ImageFrame, distance: Float = 1.0, gaborish: Bool = true, adaptiveQF: Bool = true @@ -61,6 +64,9 @@ package enum VarDCTBitstreamWriter { return try writeOuterCodestream( xsize: chunk.xsize, ysize: chunk.ysize, hasAlpha: chunk.hasAlpha, gaborish: chunk.gaborish, + bitDepth: BitDepth( + floatingPoint: false, + bitsPerSample: UInt32(chunk.bitsPerSample)), sections: chunk.sections) } @@ -99,10 +105,16 @@ package enum VarDCTBitstreamWriter { let hasAlpha = frame.channels >= 4 var alphaPix = [Int32]() if hasAlpha { - let ch = frame.channels + // `getPixel` is bit-depth-generic (unlike a raw + // `frame.data[i * ch + 3]` byte read, which only gives + // the right stride/width for 8-bit — for 16-bit it reads + // the wrong byte entirely since each sample is 2 bytes). alphaPix = [Int32](repeating: 0, count: q.xsize * q.ysize) - for i in 0..<(q.xsize * q.ysize) { - alphaPix[i] = Int32(frame.data[i * ch + 3]) + for y in 0.. Data { var w = BitWriter() w.write(bits: 8, value: 0xFF) w.write(bits: 8, value: 0x0A) try SizeHeader( xsize: UInt32(xsize), ysize: UInt32(ysize)).write(to: &w) - // One default 8-bit alpha extra channel when the frame is RGBA. + // The alpha extra channel matches the main image's bit depth + // (the alpha extra channel carries the frame's own samples, + // not a colour transform, so it shares the source precision). let extraChannels: [ExtraChannelInfo] = hasAlpha ? [ExtraChannelInfo( type: .alpha, - bitDepth: BitDepth(floatingPoint: false, bitsPerSample: 8), + bitDepth: bitDepth, dimShift: 0, name: "")] : [] let meta = ImageMetadata( allDefault: false, orientation: 1, intrinsicSize: nil, preview: nil, animation: animation, - bitDepth: BitDepth( - floatingPoint: false, bitsPerSample: 8), + bitDepth: bitDepth, modular16BitBufferSufficient: true, extraChannels: extraChannels, xybEncoded: true, @@ -853,11 +868,13 @@ package enum VarDCTBitstreamWriter { /// 8-bit alpha extra channel (RGBA frames). static func writeOuterCodestream( xsize: Int, ysize: Int, hasAlpha: Bool, - gaborish: Bool, sections: [Data] + gaborish: Bool, + bitDepth: BitDepth = BitDepth(floatingPoint: false, bitsPerSample: 8), + sections: [Data] ) throws -> Data { var out = try writeCodestreamPrelude( xsize: xsize, ysize: ysize, - hasAlpha: hasAlpha, animation: nil) + hasAlpha: hasAlpha, animation: nil, bitDepth: bitDepth) out.append(try writeFrameChunk( hasAlpha: hasAlpha, gaborish: gaborish, isLast: true, animationFrame: .default, haveAnimation: false, @@ -2086,17 +2103,22 @@ package enum VarDCTBitstreamWriter { frame: frame, distance: distance, gaborish: gaborish, adaptiveQF: adaptiveQF)) } - // All frames must agree on dimensions + alpha presence. + // All frames must agree on dimensions, alpha presence, and + // bit depth — the codestream declares one `ImageMetadata` + // shared by every frame, so a per-frame mismatch would be + // silently misdeclared rather than rejected. let first = chunks[0] for (i, c) in chunks.enumerated() where i > 0 { guard c.xsize == first.xsize, c.ysize == first.ysize, - c.hasAlpha == first.hasAlpha else { + c.hasAlpha == first.hasAlpha, + c.bitsPerSample == first.bitsPerSample else { throw WriterError.unsupported( "encodeAnimation: frame \(i) " - + "\(c.xsize)×\(c.ysize)/\(c.hasAlpha) " + + "\(c.xsize)×\(c.ysize)/\(c.hasAlpha)/" + + "\(c.bitsPerSample)bpp " + "differs from frame 0 " + "\(first.xsize)×\(first.ysize)/" - + "\(first.hasAlpha)") + + "\(first.hasAlpha)/\(first.bitsPerSample)bpp") } } // libjxl-default 100 tps timestamp resolution, infinite @@ -2106,7 +2128,10 @@ package enum VarDCTBitstreamWriter { numLoops: 0, haveTimecodes: false) var out = try writeCodestreamPrelude( xsize: first.xsize, ysize: first.ysize, - hasAlpha: first.hasAlpha, animation: animation) + hasAlpha: first.hasAlpha, animation: animation, + bitDepth: BitDepth( + floatingPoint: false, + bitsPerSample: UInt32(first.bitsPerSample))) for (i, c) in chunks.enumerated() { let isLast = (i == chunks.count - 1) let af = AnimationFrame( diff --git a/Sources/JXLSwift/Codec/VarDCTEncoder.swift b/Sources/JXLSwift/Codec/VarDCTEncoder.swift index 283fae7..fd24ba2 100644 --- a/Sources/JXLSwift/Codec/VarDCTEncoder.swift +++ b/Sources/JXLSwift/Codec/VarDCTEncoder.swift @@ -86,9 +86,9 @@ package enum VarDCTEncoder { case unsupported(String) } - /// Run the forward transform. `frame` must be 8-bit RGB or RGBA - /// (alpha is ignored by this DSP core — extra channels are a - /// bitstream-layer concern). + /// Run the forward transform. `frame` must be 8-bit or 16-bit + /// RGB or RGBA (alpha is ignored by this DSP core — extra + /// channels are a bitstream-layer concern). /// Map a quality `distance` to the frame's global quantiser. /// `distance` is a monotone quality knob in the spirit of cjxl's /// `-d` (0.5 ≈ near-lossless, larger = smaller/lossier) — but a @@ -108,9 +108,10 @@ package enum VarDCTEncoder { let globalScale = globalScale(forDistance: distance) let quantDC: UInt32 = 17 let qf: Int32 = 5 - guard frame.pixelType == .uint8, frame.channels >= 3 else { + guard frame.pixelType == .uint8 || frame.pixelType == .uint16, + frame.channels >= 3 else { throw EncoderError.unsupported( - "VarDCT encode: only 8-bit RGB/RGBA frames " + "VarDCT encode: only 8-bit/16-bit RGB/RGBA frames " + "(got \(frame.pixelType)/\(frame.channels)ch)") } let xsize = frame.width @@ -121,23 +122,53 @@ package enum VarDCTEncoder { let ph = blocksY * 8 let ch = frame.channels - // (1) sRGB8 → linear → XYB, into three padded planes. + // (1) sRGB → linear → XYB, into three padded planes. The + // 8-bit branch keeps its original straight-line byte + // indexing untouched (design priority #1 is speed on the + // default hot path — this is a call-site-once branch, not a + // per-pixel one, so the 8-bit case pays zero extra cost). + // The 16-bit branch reuses `ImageFrame.getPixel` — safe here + // since it's new code with no prior baseline to regress. var planeX = [Float](repeating: 0, count: pw * ph) var planeY = [Float](repeating: 0, count: pw * ph) var planeB = [Float](repeating: 0, count: pw * ph) - for y in 0.. Float { - let s = Float(v) / 255.0 + static func srgbToLinear(_ v: UInt32, maxValue: Float) -> Float { + let s = Float(v) / maxValue return s <= 0.04045 ? s / 12.92 : powf((s + 0.055) / 1.055, 2.4) } + /// IEC 61966-2-1 sRGB inverse OETF — 8-bit code → linear [0,1]. + @inline(__always) + static func srgb8ToLinear(_ v: UInt8) -> Float { + srgbToLinear(UInt32(v), maxValue: 255.0) + } + /// In-place 8×8 transpose. @inline(__always) static func transpose8(_ b: inout [Float]) { diff --git a/Sources/JXLTool/Encode.swift b/Sources/JXLTool/Encode.swift index 909374b..86bfe81 100644 --- a/Sources/JXLTool/Encode.swift +++ b/Sources/JXLTool/Encode.swift @@ -1,12 +1,12 @@ // `jxl-tool encode` — pure-Swift JPEG XL encoder front-end. // // Routes through `JXLEncoder`. The default mode is **lossy** VarDCT -// (`--quality`, default 90), which the encoder applies to 8-bit -// RGB/RGBA frames; for inputs VarDCT can't take (grayscale, 16-bit) +// (`--quality`, default 90), which the encoder applies to 8-bit and +// 16-bit RGB/RGBA frames; for inputs VarDCT can't take (grayscale) // `JXLEncoder` falls back to the lossless Modular path. `--lossless` -// forces the Modular path. Supported inputs: 8-bit grayscale (PGM), -// 8-bit RGB (PPM), 8-bit RGBA (PAM), 16-bit grayscale (PGM with -// maxval > 255). Output is a real `.jxl` codestream that +// forces the Modular path. Supported inputs: 8-bit/16-bit grayscale +// (PGM, maxval > 255 selects 16-bit), 8-bit/16-bit RGB (PPM), +// 8-bit/16-bit RGBA (PAM). Output is a real `.jxl` codestream that // round-trips through `djxl`. import ArgumentParser @@ -161,7 +161,7 @@ struct Encode: ParsableCommand { let pct = Double(encSize) * 100 / Double(rawTotal) // Report the mode that *actually* ran, not just what was asked. // The encoder falls back to lossless Modular for inputs the lossy - // VarDCT codec can't take (e.g. 16-bit) — important for medical + // VarDCT codec can't take (e.g. grayscale) — important for medical // users not to see a lossless result mislabelled "lossy". let modeLabel: String if encoded.stats.wasLossless { diff --git a/Tests/JXLSwiftTests/IntegrationTests.swift b/Tests/JXLSwiftTests/IntegrationTests.swift index 2c26368..b62eb83 100644 --- a/Tests/JXLSwiftTests/IntegrationTests.swift +++ b/Tests/JXLSwiftTests/IntegrationTests.swift @@ -8493,6 +8493,288 @@ extension FoundationTests { + "(got max diff \(maxA))") } + /// **16-bit lossy VarDCT — the gap this suite pins down.** + /// Cross-validates OUR encoder's 16-bit RGB VarDCT bitstream + /// against `djxl`: a spec-valid bitstream must decode to + /// (approximately) the same values whether read by djxl or by + /// our own decoder — the "our 16-bit bitstream is spec-valid" + /// gate, analogous to `testSpecModularEncoder_RGB16_DjxlRoundTrip` + /// for the lossless path. + func testVarDCT_16BitRGB_DjxlByteEquality() throws { + let djxl = "/opt/homebrew/bin/djxl" + guard FileManager.default.isExecutableFile(atPath: djxl) else { + throw XCTSkip("djxl not available at \(djxl)") + } + let w = 64, h = 64 + var frame = ImageFrame( + width: w, height: h, channels: 3, + pixelType: .uint16, colorSpace: .sRGB) + for y in 0.. Date: Wed, 1 Jul 2026 17:23:16 +0530 Subject: [PATCH 2/2] release v1.3.0: 16-bit lossy VarDCT for RGB/RGBA Bump JXLToolVersion 1.2.0 -> 1.3.0, Package.swift status, CLAUDE.md's implementation-status table, and add the CHANGELOG 1.3.0 entry. Highlights (full notes in CHANGELOG.md): - Lossy VarDCT now accepts 16-bit RGB/RGBA (previously 8-bit only), matching the lossless Modular path's bit-depth range. Existing 8-bit lossy and all lossless output is byte-for-byte unchanged. - Fixes a latent 16-bit RGBA alpha-channel corruption bug in the VarDCT bitstream writer's alpha-unpack stride. Verified via reference libjxl 0.11.2 (djxl accepts every generated 16-bit bitstream; our decoder agrees with djxl within a few LSBs) and a byte-for-byte regression diff against a pre-change build. The full XCTest suite needs an Xcode host (this dev env has only CommandLineTools); the djxl spec gate was exercised via CLI round-trips, and the new tests' logic was additionally verified through a throwaway executable target linked against the built library (removed before this commit). Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++ CLAUDE.md | 2 +- Package.swift | 9 +++---- Sources/JXLTool/JXLTool.swift | 2 +- 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3c4541..db7942c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 5e2edb6..2b570c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) | diff --git a/Package.swift b/Package.swift index 9456cfb..50e9c0a 100644 --- a/Package.swift +++ b/Package.swift @@ -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 diff --git a/Sources/JXLTool/JXLTool.swift b/Sources/JXLTool/JXLTool.swift index c683b02..1459c0f 100644 --- a/Sources/JXLTool/JXLTool.swift +++ b/Sources/JXLTool/JXLTool.swift @@ -30,7 +30,7 @@ struct JXLTool: ParsableCommand { ) } -let JXLToolVersion = "1.2.0" +let JXLToolVersion = "1.3.0" enum JXLExitCode: Int32, Error { case generalError = 1