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
71 changes: 71 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,77 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

---

## [1.4.0] — 2026-07-06 (release)

**VarDCT coverage (grayscale, oversized, signed 16-bit) + broader JPEG decode.**
Three lossy-encode gaps that previously caused a supported request to silently
fall back to lossless Modular are closed — grayscale, images beyond the old
8192-px cap, and signed 16-bit input — and the pure-Swift JPEG decoder is
widened to progressive (SOF2), extended-sequential (SOF1), 12-bit precision,
and SOF3 lossless. Existing 8-/16-bit RGB/RGBA VarDCT and all lossless paths are
unchanged (byte-for-byte regression-checked); the full test suite stays green
(717 tests). Verified against reference libjxl 0.11.2 (`djxl`) and libjpeg-turbo
3.1.4 (`djpeg`).

### Added

- **Grayscale lossy VarDCT** — 1-channel grayscale and 2-channel
grayscale+alpha, at 8-bit and 16-bit. Grayscale is encoded the way libjxl
does it: a 3-channel XYB frame whose X plane is ≈ 0 (so R = G = B after the
inverse opsin), carrying a grayscale `ColorEncoding`. `VarDCTEncoder.forward`
broadcasts the luma to R = G = B; `VarDCTBitstreamWriter` writes
`colorEncoding = .grayscaleD65`; and `JXLDecoder`'s VarDCT output stage
collapses the reconstructed RGB back to a single luma channel (plus optional
alpha) when the colour space is grayscale. Multi-group and multi-frame
(animation) grayscale are covered. Output decodes as grayscale under `djxl`.
- **Oversized VarDCT** — the encoder's per-frame size limit is raised from 8192
to 16384 px/side (matching the lossless `SpecModularEncoder` envelope). The
8192 cap was a conservative resource-safety limit, not a correctness one; the
multi-AC-group + multi-DC-group machinery indexes groups with `Int` and is
correct for arbitrarily many groups. Verified wide (8704×256), tall
(256×8448), and square (8256×8256) beyond the old cap.
- **Signed 16-bit pixels (`PixelType.int16`)** — the primary DICOM CT case.
Callers no longer need to pre-shift signed data into an unsigned range: the
encoder level-shifts `.int16` into unsigned offset-binary (sample + 32768,
the JPEG convention) up front, so both the VarDCT and Modular paths handle it
unchanged, and the codestream stays a standard unsigned-16 JPEG XL that any
decoder (including `djxl`) reads. `JXLDecoder.decode(_:signedOutput:)`
reconstructs an `.int16` frame for a full int16 → int16 round-trip within
JXLSwift (byte-exact through the lossless Modular path). `ImageFrame` gains
`levelShiftedToUnsigned16()` / `reinterpretedAsSignedInt16()` helpers.
- **Broader JPEG decode** (`JPEGDecoder.decode`) — the pixel decoder now covers
progressive (SOF2) and extended-sequential (SOF1) DCT frames and 12-bit
precision (returned as a `.uint16` frame with raw 0…4095 values), in addition
to baseline 8-bit. `decode(_:)` now delegates to `decodeToCoefficients` (which
already handled SOF1/SOF2) plus dequant/IDCT/colour, with a precision-
generalised YCbCr→RGB. Verified pixel-for-pixel against libjpeg's
`djpeg -nosmooth` (≤ 6 LSB, pure IDCT rounding). NB: the JPEG→JXL *lossless
recompression* bridge remains 8-bit-DCT-only — 12-bit and lossless JPEGs
cannot be jbrd-recompressed (a JXL-format limitation libjxl shares), so this
is pixel decode, not recompression.
- **Lossless JPEG (SOF3) decode** (`JPEGLosslessDecoder`) — a new predictive
decoder (ITU-T T.81 Annex H) for DICOM Lossless JPEG (.70): all seven
selection-value predictors, 2…16-bit precision, 1-component grayscale and
3-component (stored-component, no colour transform) frames, interleaved and
non-interleaved scans, and restart intervals (including the subtle
first-line-after-reset horizontal-prediction rule). `JPEGDecoder.decode`
routes SOF3 frames to it automatically. Reconstruction is **byte-exact**
against `djpeg` across the full predictor / precision / restart sweep.

### Fixed

- **Multi-frame lossy `CompressionStats.wasLossless`** — the VarDCT animation
path (`encode(_ frames:)`) built its stats without `wasLossless: false`, so
the CLI mislabelled every VarDCT animation as a lossless encode. Now reports
lossy correctly.

### Changed

- `JXLEncoder`/`VarDCTEncoder`/`VarDCTBitstreamWriter` infer colour type + alpha
from the channel count (1 → gray, 2 → gray+alpha, 3 → RGB, 4 → RGBA),
extending the pre-existing `channels >= 4` alpha rule to grayscale. Backward
compatible with callers that leave `alphaChannels` at its default.

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

**16-bit lossy VarDCT.** The lossy VarDCT codec now accepts 16-bit RGB/RGBA
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) | ✅ **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) |
| V | VarDCT (lossy path) | ✅ **encode + decode**, 8-bit and 16-bit grayscale, grayscale+alpha, RGB and RGBA (`djxl`-matching, Phase R filters incl.); lossy VarDCT is the CLI/API default. Grayscale is encoded as a 3-channel XYB frame (X ≈ 0) with grayscale colour metadata — the representation libjxl uses — and the decoder collapses it to one luma channel |
| 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
10 changes: 6 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ 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.3.0 production-ready lossless JPEG XL codec — medical-grade
// Status: v1.4.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.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.
// boundary scaffolded for future C/C++ hot-path work. v1.4.0 widens the
// lossy VarDCT codec to grayscale / grayscale+alpha and to images beyond
// the old 8192-px cap, adds a signed 16-bit pixel type (`.int16`), and
// broadens the JPEG decoder to progressive / extended-sequential / 12-bit
// and SOF3 lossless — 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
77 changes: 71 additions & 6 deletions Sources/JXLSwift/Codec/ImageFrame.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,33 @@ import Foundation
public enum PixelType: Sendable, Equatable {
case uint8
case uint16
/// Signed 16-bit samples (two's-complement, little-endian in
/// `data`). JPEG XL codestreams have no native signed-sample
/// type, so the encoder level-shifts `.int16` into unsigned
/// offset-binary (sample + 32768, the JPEG convention) before
/// encoding; ``JXLDecoder/decode(_:signedOutput:)`` recovers the
/// signed frame. The primary DICOM use case is signed 16-bit CT.
case int16
case float32

public var bitsPerSample: Int {
switch self {
case .uint8: return 8
case .uint16: return 16
case .float32: return 32
case .uint8: return 8
case .uint16, .int16: return 16
case .float32: return 32
}
}

public var bytesPerSample: Int {
switch self {
case .uint8: return 1
case .uint16: return 2
case .float32: return 4
case .uint8: return 1
case .uint16, .int16: return 2
case .float32: return 4
}
}

/// True for signed sample types (currently only ``int16``).
public var isSigned: Bool { self == .int16 }
}

/// Colour space tag carried alongside the pixel data. JXLSwift currently
Expand Down Expand Up @@ -96,6 +106,14 @@ public struct ImageFrame: Sendable {
let lo = UInt16(data[i * 2])
let hi = UInt16(data[i * 2 + 1])
return (hi << 8) | lo
case .int16:
// Stored two's-complement; return offset-binary
// (sample + 32768) so the value is always 0…65535. XOR of
// the sign bit is exactly the two's-complement ↔
// offset-binary conversion.
let lo = UInt16(data[i * 2])
let hi = UInt16(data[i * 2 + 1])
return ((hi << 8) | lo) ^ 0x8000
case .float32:
let bytes = Array(data[(i * 4)..<(i * 4 + 4)])
let bits = bytes.withUnsafeBytes { $0.load(as: UInt32.self) }
Expand All @@ -117,6 +135,13 @@ public struct ImageFrame: Sendable {
case .uint16:
data[i * 2] = UInt8(value & 0xFF)
data[i * 2 + 1] = UInt8((value >> 8) & 0xFF)
case .int16:
// `value` is offset-binary (sample + 32768); store the
// two's-complement sample (XOR of the sign bit inverts
// the conversion applied in `getPixel`).
let raw = value ^ 0x8000
data[i * 2] = UInt8(raw & 0xFF)
data[i * 2 + 1] = UInt8((raw >> 8) & 0xFF)
case .float32:
let f = Float(value) / 65535.0
let bits = f.bitPattern
Expand All @@ -128,6 +153,46 @@ public struct ImageFrame: Sendable {
}

public var hasAlpha: Bool { alphaChannels > 0 }

/// Level-shift a signed ``int16`` frame into an equivalent
/// ``uint16`` frame whose samples are offset-binary
/// (sample + 32768). This is the JPEG-style level shift the
/// encoder applies so signed data flows through the unsigned
/// VarDCT / Modular pipeline unchanged. Every 16-bit sample's
/// sign bit is flipped — the two's-complement ↔ offset-binary
/// conversion — which also level-shifts any alpha channel
/// symmetrically (harmless: alpha is lossless, so it un-shifts
/// exactly). Geometry, channel count, colour space and alpha
/// count are preserved. No-op label change if already unsigned.
public func levelShiftedToUnsigned16() -> ImageFrame {
guard pixelType == .int16 else { return self }
var out = ImageFrame(
width: width, height: height, channels: channels,
pixelType: .uint16, colorSpace: colorSpace,
alphaChannels: alphaChannels, iccProfile: iccProfile)
out.data = data
var i = 1
while i < out.data.count { out.data[i] ^= 0x80; i += 2 }
return out
}

/// Inverse of ``levelShiftedToUnsigned16()``: reinterpret a
/// ``uint16`` frame's offset-binary samples as signed ``int16``
/// (sample − 32768). Used by
/// ``JXLDecoder/decode(_:signedOutput:)`` to recover a signed
/// frame from an unsigned decode. No-op label change if the frame
/// is not ``uint16``.
public func reinterpretedAsSignedInt16() -> ImageFrame {
guard pixelType == .uint16 else { return self }
var out = ImageFrame(
width: width, height: height, channels: channels,
pixelType: .int16, colorSpace: colorSpace,
alphaChannels: alphaChannels, iccProfile: iccProfile)
out.data = data
var i = 1
while i < out.data.count { out.data[i] ^= 0x80; i += 2 }
return out
}
}

/// Family-parity alias for ``ImageFrame``. JXLSwift is part of a Swift
Expand Down
118 changes: 118 additions & 0 deletions Sources/JXLSwift/Codec/JXLDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,31 @@ public struct JXLDecoder: Sendable {
)
}

/// Decode, optionally reinterpreting a 16-bit result as signed
/// ``PixelType/int16``.
///
/// JPEG XL codestreams have no native signed-sample type, so a
/// signed 16-bit image is stored as unsigned offset-binary
/// (sample + 32768 — the level shift the encoder applies when
/// handed an ``PixelType/int16`` frame). Passing
/// `signedOutput: true` un-shifts a 16-bit decode back to a
/// signed ``PixelType/int16`` frame, giving a full int16 → int16
/// round-trip within JXLSwift. `djxl` (and any other decoder)
/// still sees a standard unsigned-16 codestream.
///
/// The flag only affects 16-bit results; 8-bit / already-signed
/// frames are returned unchanged, so this is a strict superset of
/// ``decode(_:)`` — existing unsigned behaviour is untouched.
public func decode(
_ data: Data, signedOutput: Bool
) throws -> ImageFrame {
let frame = try decode(data)
guard signedOutput, frame.pixelType == .uint16 else {
return frame
}
return frame.reinterpretedAsSignedInt16()
}

/// Extract the **quantised** AC coefficient state from a JXL
/// VarDCT frame, returning a `JXLCoefficientPlanes` suitable for
/// the reverse-bridge (`JXLToJPEGAdapter.reconstruct`). This is
Expand Down Expand Up @@ -3533,6 +3558,15 @@ extension JXLDecoder {
let pixelType: PixelType = (bps <= 8) ? .uint8 : .uint16
let _ = (kRequiredSizeX, kRequiredSizeY)

// Grayscale lossy is a 3-channel XYB frame (X ≈ 0 → R = G = B
// after inverse opsin) carrying a grayscale colour encoding —
// the representation libjxl uses. The 3-channel XYB / CfL /
// IDCT / restoration machinery above is reused verbatim; here
// the RGB result is collapsed to a single luma channel (plus
// an optional alpha extra channel). Matches how `djxl`
// decodes the same codestream to grayscale output.
let isGray = metadata.colorEncoding.colorSpace == .grayscale

// 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).
Expand All @@ -3544,6 +3578,44 @@ extension JXLDecoder {
// 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 && isGray {
// Grayscale 8-bit: one luma sample per pixel (R = G = B).
var gray8 = [UInt8](repeating: 0, count: xsize * ysize)
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])
)
gray8[y * xsize + x] = linearToSRGB8(lin.R)
}
}
if nbExtraChannels == 0 {
var frame = ImageFrame(
width: xsize, height: ysize, channels: 1)
frame.data = gray8
return frame
}
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 ga = [UInt8](repeating: 0, count: xsize * ysize * 2)
for i in 0..<(xsize * ysize) {
ga[i * 2 + 0] = gray8[i]
ga[i * 2 + 1] = i < alpha.count
? UInt8(clamping: alpha[i]) : 255
}
var frame = ImageFrame(
width: xsize, height: ysize,
channels: 2, alphaChannels: 1)
frame.data = ga
return frame
}
if pixelType == .uint8 {
var rgb8 = [UInt8](repeating: 0, count: xsize * ysize * 3)
for y in 0..<ysize {
Expand Down Expand Up @@ -3614,6 +3686,52 @@ extension JXLDecoder {
// (`getPixel`/`setPixel`).
let sampleMax = UInt32((1 << bps) - 1)
let maxValueF = Float(sampleMax)
if isGray {
// Grayscale 16-bit: one luma sample per pixel (R = G = B),
// little-endian.
var gray16 = [UInt8](repeating: 0, count: xsize * ysize * 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 gv = linearToSRGBCode(lin.R, maxValue: maxValueF)
let oi = (y * xsize + x) * 2
gray16[oi + 0] = UInt8(gv & 0xff)
gray16[oi + 1] = UInt8((gv >> 8) & 0xff)
}
}
if nbExtraChannels == 0 {
var frame = ImageFrame(
width: xsize, height: ysize,
channels: 1, pixelType: .uint16)
frame.data = gray16
return frame
}
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 ga16 = [UInt8](repeating: 0, count: xsize * ysize * 2 * 2)
for i in 0..<(xsize * ysize) {
ga16[i * 4 + 0] = gray16[i * 2 + 0]
ga16[i * 4 + 1] = gray16[i * 2 + 1]
let av: UInt32 = i < alpha.count
? min(UInt32(max(0, alpha[i])), sampleMax) : sampleMax
ga16[i * 4 + 2] = UInt8(av & 0xff)
ga16[i * 4 + 3] = UInt8((av >> 8) & 0xff)
}
var frame = ImageFrame(
width: xsize, height: ysize,
channels: 2, pixelType: .uint16, alphaChannels: 1)
frame.data = ga16
return frame
}
var rgb16 = [UInt8](repeating: 0, count: xsize * ysize * 3 * 2)
for y in 0..<ysize {
for x in 0..<xsize {
Expand Down
Loading
Loading