diff --git a/CHANGELOG.md b/CHANGELOG.md index db7942c..3ccec12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 2b570c0..9126eed 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) | ✅ **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) | diff --git a/Package.swift b/Package.swift index 50e9c0a..b1f95d9 100644 --- a/Package.swift +++ b/Package.swift @@ -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 diff --git a/Sources/JXLSwift/Codec/ImageFrame.swift b/Sources/JXLSwift/Codec/ImageFrame.swift index e054770..9d6c9df 100644 --- a/Sources/JXLSwift/Codec/ImageFrame.swift +++ b/Sources/JXLSwift/Codec/ImageFrame.swift @@ -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 @@ -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) } @@ -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 @@ -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 diff --git a/Sources/JXLSwift/Codec/JXLDecoder.swift b/Sources/JXLSwift/Codec/JXLDecoder.swift index b821949..262e319 100644 --- a/Sources/JXLSwift/Codec/JXLDecoder.swift +++ b/Sources/JXLSwift/Codec/JXLDecoder.swift @@ -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 @@ -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). @@ -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..> 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.. EncodedImage { + // Signed 16-bit is level-shifted to unsigned offset-binary up + // front (JPEG convention) so both the VarDCT and Modular paths + // handle it unchanged; `JXLDecoder.decode(_:signedOutput:)` + // recovers the sign. Unsigned frames pass through untouched. + let frame = frame.pixelType == .int16 + ? frame.levelShiftedToUnsigned16() : frame if options.useM0Placeholder { let start = Date() let data: Data @@ -98,11 +108,13 @@ public struct JXLEncoder: Sendable { } let start = Date() - // Lossy modes encode through the VarDCT codec. When the frame - // 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. + // Lossy modes encode through the VarDCT codec. Grayscale (1), + // grayscale+alpha (2), RGB (3), and RGBA (4) frames are all + // accepted. When the frame is one VarDCT can't take (an + // unsupported colour-channel count, or dimensions beyond the + // writer's size cap) `VarDCTBitstreamWriter` / `VarDCTEncoder` + // throw their `unsupported` case — caught here so the encode + // falls back to the lossless Modular path below. if case .lossless = options.mode { // Lossless — skip VarDCT, use Modular directly. } else { @@ -382,6 +394,11 @@ public struct JXLEncoder: Sendable { /// `VarDCTBitstreamWriter.encodeAnimation`, with `isLast` /// flipped on the final frame only. public func encode(_ frames: [ImageFrame]) throws -> EncodedImage { + // Level-shift any signed 16-bit frames up front (see the + // single-frame `encode(_:)`); unsigned frames are untouched. + let frames = frames.map { + $0.pixelType == .int16 ? $0.levelShiftedToUnsigned16() : $0 + } switch frames.count { case 0: throw EncoderError.unsupportedFrame( @@ -433,7 +450,8 @@ public struct JXLEncoder: Sendable { stats: CompressionStats( originalSize: originalSize, compressedSize: wrapped.count, - encodingTime: Date().timeIntervalSince(start) + encodingTime: Date().timeIntervalSince(start), + wasLossless: false // multi-frame VarDCT (lossy) ) ) } diff --git a/Sources/JXLSwift/Codec/VarDCTBitstreamWriter.swift b/Sources/JXLSwift/Codec/VarDCTBitstreamWriter.swift index e0ad6a1..cc97641 100644 --- a/Sources/JXLSwift/Codec/VarDCTBitstreamWriter.swift +++ b/Sources/JXLSwift/Codec/VarDCTBitstreamWriter.swift @@ -10,12 +10,14 @@ // then `ZeroDensityContext`-routed coefficient tokens — so the // frame is a genuine lossy compression, not just block averages. // -// Scope: frames up to one DC group (≤ 2048 px) — single-section -// when ≤ 256 px, otherwise a multi-section codestream of one AC -// group per 256-px tile. RGB and RGBA (one lossless modular alpha -// extra channel). DCT8×8 only, one pass, default colour-correlation -// / quant matrices / block-context map, `used_orders = 0`, -// `num_histograms = 1`. +// Scope: single-section when ≤ 256 px, otherwise a multi-section +// codestream of one AC group per 256-px tile and one DC group per +// 2048-px tile — up to the 16384-px resource-safety cap. Grayscale, +// grayscale+alpha, RGB and RGBA (the trailing channel of an even +// channel count is one lossless modular alpha extra channel; +// grayscale is a 3-channel XYB frame carrying grayscale colour +// metadata). One pass, default colour-correlation / quant matrices / +// block-context map, `used_orders = 0`, `num_histograms = 1`. // // Spec reference: ISO/IEC 18181-1 §K. libjxl: `enc_frame.cc`. @@ -47,13 +49,20 @@ package enum VarDCTBitstreamWriter { /// `frame.pixelType.bitsPerSample` (8 or 16) — threaded into /// the outer codestream's `ImageMetadata.bitDepth`. let bitsPerSample: Int + /// True when the source frame is grayscale (1 colour channel). + /// The frame body is still a 3-channel XYB codestream (with + /// X ≈ 0); this flag only drives the outer codestream's + /// `ImageMetadata.colorEncoding` (grayscale vs sRGB), matching + /// how libjxl stores grayscale lossy. + let isGrayscale: Bool } - /// 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. + /// Encode an 8-bit or 16-bit grayscale / grayscale+alpha / RGB / + /// RGBA `ImageFrame` as a VarDCT JPEG XL file (naked codestream). + /// Frames up to the 16384-px resource-safety 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 @@ -67,7 +76,8 @@ package enum VarDCTBitstreamWriter { bitDepth: BitDepth( floatingPoint: false, bitsPerSample: UInt32(chunk.bitsPerSample)), - sections: chunk.sections) + sections: chunk.sections, + isGrayscale: chunk.isGrayscale) } /// Run the encoder pipeline for one frame, returning the TOC @@ -83,7 +93,16 @@ package enum VarDCTBitstreamWriter { frame: frame, distance: distance, gaborish: gaborish, adaptiveQF: adaptiveQF) let groupDim = 256 - let sizeCap = 8192 + // Resource-safety cap (not a correctness/spec limit): the + // multi-AC-group + multi-DC-group machinery below indexes + // groups with `Int`, so it is correct for arbitrarily many + // groups; the cap just bounds worst-case memory (three + // padded `Float` planes ≈ 12·w·h bytes). Matched to the + // lossless `SpecModularEncoder`'s 16384-px ceiling so the + // two codecs share the same dimension envelope. Raised from + // the earlier conservative 8192 (v0.11.0i) once the group + // indexing was audited for large `numGroups`. + let sizeCap = 16384 guard q.xsize <= sizeCap, q.ysize <= sizeCap else { throw WriterError.unsupported( "VarDCT encode: \(q.xsize)×\(q.ysize) exceeds the " @@ -102,21 +121,32 @@ package enum VarDCTBitstreamWriter { // frames spanning more than one 256-px group the channel is // larger than a modular group, so it defers per-AC-group // (the decoder's `extraGiImage` deferred path). - let hasAlpha = frame.channels >= 4 + // Colour type + alpha inferred from the channel count (the + // pre-grayscale writer keyed alpha off `channels >= 4`; this + // extends the same channel-count rule to grayscale, so it + // stays backward-compatible with callers that leave + // `alphaChannels` at its default): + // 1 → gray, 2 → gray + alpha, + // 3 → RGB, 4 → RGB + alpha. + // Alpha, when present, is the trailing channel. + let hasAlpha = frame.channels == 2 || frame.channels == 4 + let alphaChannelIndex = frame.channels - 1 var alphaPix = [Int32]() if hasAlpha { // `getPixel` is bit-depth-generic (unlike a raw - // `frame.data[i * ch + 3]` byte read, which only gives + // `frame.data[i * ch + alpha]` 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 y in 0.. Data { var w = BitWriter() w.write(bits: 8, value: 0xFF) @@ -802,7 +834,12 @@ package enum VarDCTBitstreamWriter { modular16BitBufferSufficient: true, extraChannels: extraChannels, xybEncoded: true, - colorEncoding: .srgb, + // Grayscale lossy is a 3-channel XYB frame with a grayscale + // colour encoding — exactly what libjxl emits. The decoder + // reconstructs 3 XYB planes (X ≈ 0 → R = G = B) then + // collapses to one luma channel because the colour space is + // grayscale. `xybEncoded` stays true either way. + colorEncoding: isGrayscale ? .grayscaleD65 : .srgb, intensityTarget: 255.0, minNits: 0.0, relativeToMaxDisplay: false, linearBelow: 0.0) try meta.write(to: &w) @@ -870,11 +907,13 @@ package enum VarDCTBitstreamWriter { xsize: Int, ysize: Int, hasAlpha: Bool, gaborish: Bool, bitDepth: BitDepth = BitDepth(floatingPoint: false, bitsPerSample: 8), - sections: [Data] + sections: [Data], + isGrayscale: Bool = false ) throws -> Data { var out = try writeCodestreamPrelude( xsize: xsize, ysize: ysize, - hasAlpha: hasAlpha, animation: nil, bitDepth: bitDepth) + hasAlpha: hasAlpha, animation: nil, bitDepth: bitDepth, + isGrayscale: isGrayscale) out.append(try writeFrameChunk( hasAlpha: hasAlpha, gaborish: gaborish, isLast: true, animationFrame: .default, haveAnimation: false, @@ -2111,14 +2150,16 @@ package enum VarDCTBitstreamWriter { for (i, c) in chunks.enumerated() where i > 0 { guard c.xsize == first.xsize, c.ysize == first.ysize, c.hasAlpha == first.hasAlpha, - c.bitsPerSample == first.bitsPerSample else { + c.bitsPerSample == first.bitsPerSample, + c.isGrayscale == first.isGrayscale else { throw WriterError.unsupported( "encodeAnimation: frame \(i) " + "\(c.xsize)×\(c.ysize)/\(c.hasAlpha)/" - + "\(c.bitsPerSample)bpp " + + "\(c.bitsPerSample)bpp/gray=\(c.isGrayscale) " + "differs from frame 0 " + "\(first.xsize)×\(first.ysize)/" - + "\(first.hasAlpha)/\(first.bitsPerSample)bpp") + + "\(first.hasAlpha)/\(first.bitsPerSample)bpp/" + + "gray=\(first.isGrayscale)") } } // libjxl-default 100 tps timestamp resolution, infinite @@ -2131,7 +2172,8 @@ package enum VarDCTBitstreamWriter { hasAlpha: first.hasAlpha, animation: animation, bitDepth: BitDepth( floatingPoint: false, - bitsPerSample: UInt32(first.bitsPerSample))) + bitsPerSample: UInt32(first.bitsPerSample)), + isGrayscale: first.isGrayscale) 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 fd24ba2..fd93602 100644 --- a/Sources/JXLSwift/Codec/VarDCTEncoder.swift +++ b/Sources/JXLSwift/Codec/VarDCTEncoder.swift @@ -108,11 +108,34 @@ package enum VarDCTEncoder { let globalScale = globalScale(forDistance: distance) let quantDC: UInt32 = 17 let qf: Int32 = 5 - guard frame.pixelType == .uint8 || frame.pixelType == .uint16, - frame.channels >= 3 else { + guard frame.pixelType == .uint8 || frame.pixelType == .uint16 else { throw EncoderError.unsupported( - "VarDCT encode: only 8-bit/16-bit RGB/RGBA frames " - + "(got \(frame.pixelType)/\(frame.channels)ch)") + "VarDCT encode: only 8-bit/16-bit samples " + + "(got \(frame.pixelType))") + } + // Colour type is inferred from the total channel count — + // matching the bitstream writer's alpha handling and the + // pre-grayscale behaviour, which keyed alpha off the channel + // count (`>= 4`) rather than the `alphaChannels` field: + // 1 → grayscale, 2 → grayscale + alpha, + // 3 → RGB, 4 → RGB + alpha. + // Grayscale (1 colour channel) is broadcast to R=G=B and + // encoded as a standard 3-channel XYB frame — for a neutral + // pixel the opsin transform yields X = 0, so the frame is a + // normal XYB codestream whose only distinguishing mark is a + // grayscale `ImageMetadata.colorEncoding` (the representation + // libjxl itself uses for grayscale lossy; the frame stays + // 3-channel XYB internally). The trailing channel of an even + // count is a lossless alpha extra channel handled by the + // bitstream layer, not read here. + let isGrayscale: Bool + switch frame.channels { + case 1, 2: isGrayscale = true + case 3, 4: isGrayscale = false + default: + throw EncoderError.unsupported( + "VarDCT encode: channel count \(frame.channels) " + + "unsupported (expected 1…4)") } let xsize = frame.width let ysize = frame.height @@ -133,41 +156,78 @@ package enum VarDCTEncoder { var planeY = [Float](repeating: 0, count: pw * ph) var planeB = [Float](repeating: 0, count: pw * ph) if frame.pixelType == .uint8 { - for y in 0.. [Int32] { + precondition(y.width == cb.width && cb.width == cr.width + && y.height == cb.height && cb.height == cr.height, + "ycbcrToRGB: all three planes must share dimensions") + let n = y.width * y.height + let pivot = Float(1 << (precision - 1)) + let maxSample = Int32((1 << precision) - 1) + var rgb = [Int32](repeating: 0, count: n * 3) + for i in 0.. Int32 { + let r = v.rounded(.toNearestOrAwayFromZero) + if r < 0 { return 0 } + let iv = Int32(r) + return iv > maxSample ? maxSample : iv + } + /// Single grayscale plane → row-major grayscale byte buffer. /// Just a typed convenience over the `Int32 → UInt8` clamp. package static func grayscaleToBuffer( diff --git a/Sources/JXLSwift/JPEG/JPEGDecoder.swift b/Sources/JXLSwift/JPEG/JPEGDecoder.swift index 43c45a0..585ed39 100644 --- a/Sources/JXLSwift/JPEG/JPEGDecoder.swift +++ b/Sources/JXLSwift/JPEG/JPEGDecoder.swift @@ -9,16 +9,17 @@ // to `PNM.write`, `ImageMetrics.compute`, the JXL encoder, etc. // // Scope: -// - Baseline-sequential JPEGs (SOF0) with arbitrary sampling -// factors (4:4:4, 4:2:2, 4:2:0, etc.). +// - Baseline (SOF0), extended-sequential (SOF1), and progressive +// (SOF2) DCT JPEGs with arbitrary sampling factors (4:4:4, +// 4:2:2, 4:2:0, etc.), plus lossless (SOF3) via +// `JPEGLosslessDecoder` (routed automatically). // - 1-component grayscale (returned as `channels: 1`) and // 3-component YCbCr (returned as `channels: 3, ColorSpace.sRGB`, // YCbCr → RGB via JFIF BT.601 full-range). -// - 8-bit precision. +// - 8-bit precision (`.uint8` output) and 12-bit / 16-bit +// precision (`.uint16` output carrying the raw sample values). // // Not yet supported (throws `JPEGDecoderError.unsupported`): -// - Progressive / extended-sequential / lossless (SOF1/2/3+) -// - 12-bit precision // - 4-component CMYK / YCCK (Adobe APP14) // - Arithmetic coding (DAC segments) // @@ -58,142 +59,135 @@ package enum JPEGDecoderError: Error, Sendable, Equatable, /// High-level "JPEG bytes → ImageFrame" decoder. package enum JPEGDecoder { - /// Decode a JPEG file. Returns an 8-bit ImageFrame: - /// `channels: 1` for grayscale, `channels: 3` for YCbCr. + /// Decode a JPEG file to an `ImageFrame`. + /// + /// Handles baseline (SOF0), extended-sequential (SOF1), and + /// progressive (SOF2) DCT frames, 1-component grayscale and + /// 3-component YCbCr, at 8-bit and 12-bit precision. 8-bit frames + /// return a `.uint8` frame; 12-bit frames return a `.uint16` frame + /// carrying raw `0…4095` sample values (djpeg's convention — the + /// values are not rescaled to the 16-bit range). SOF3 lossless is + /// decoded by ``decodeLossless(_:)``; this entry point routes to it + /// automatically. package static func decode(_ data: Data) throws -> ImageFrame { - // Walk every segment, collecting state. - var reader = JPEGSegmentReader(data) - var dcMap = JPEGHuffmanCodebookMap() - var acMap = JPEGHuffmanCodebookMap() - var quantTables: [JPEGQuantTable] = [] - var frameComponents: [JPEGFrameComponent] = [] - var width = 0, height = 0 - var precision = 0 - var frameKind: JPEGStructure.FrameKind = .baselineDCT - var scanHeader: JPEGScanHeader? - var restartInterval = 0 - var entropyStartOffset = 0 - - while let seg = try reader.next() { - switch seg.kind { - case .startOfFrame(let nibble): - frameKind = JPEGStructure.FrameKind( - nibble: nibble) - precision = Int(seg.payload[0]) - height = (Int(seg.payload[1]) << 8) - | Int(seg.payload[2]) - width = (Int(seg.payload[3]) << 8) - | Int(seg.payload[4]) - frameComponents = try JPEGFrameComponent - .parseSOFComponents(sofPayload: seg.payload) - case .defineQuantizationTable: - quantTables.append(contentsOf: - try JPEGQuantTable.parse( - dqtPayload: seg.payload)) - case .defineHuffmanTable: - for t in try JPEGHuffmanTable.parse( - dhtPayload: seg.payload) - { - let book = try t.buildCodebook() - if t.class == .dc { - dcMap[t.tableId] = (book, t.huffvals) - } else { - acMap[t.tableId] = (book, t.huffvals) - } - } - case .defineArithmeticConditioning: - throw JPEGDecoderError.unsupported( - "arithmetic-coded JPEG") - case .defineRestartInterval: - if seg.payload.count == 2 { - restartInterval = (Int(seg.payload[0]) << 8) - | Int(seg.payload[1]) - } - case .startOfScan: - scanHeader = try JPEGScanHeader.parse( - sosPayload: seg.payload) - entropyStartOffset = reader.byteOffset - default: - break - } - if seg.kind == .startOfScan { break } - if seg.kind == .endOfImage { break } + // SOF3 (lossless) is a predictive codec with no DCT — route it + // to the dedicated decoder rather than the DCT coefficient path. + if try frameKind(of: data) == .lossless { + return try decodeLossless(data) } - - guard !frameComponents.isEmpty else { - throw JPEGDecoderError.missingFrame - } - guard let scan = scanHeader else { - throw JPEGDecoderError.missingScan - } - guard precision == 8 else { - throw JPEGDecoderError.unsupported( - "\(precision)-bit precision (only 8-bit supported)") - } - guard frameKind == .baselineDCT else { - throw JPEGDecoderError.unsupported( - "non-baseline frame: \(frameKind.label)") - } - let nComponents = frameComponents.count + // Decode to quantised DCT coefficients — this covers baseline + // (SOF0), extended-sequential (SOF1), and progressive (SOF2) at + // 8-bit and 12-bit. The pixel path then dequantises, runs the + // IDCT, and colour-converts. + let coef = try decodeToCoefficients(data) + let nComponents = coef.frameComponents.count guard nComponents == 1 || nComponents == 3 else { throw JPEGDecoderError.unsupported( "\(nComponents)-component frame " + "(only 1 or 3 supported)") } - - // Drive the scan. - var bitReader = JPEGBitReader(data, - startingAt: entropyStartOffset) - let comps = try JPEGScanDecoder.decodeBaselineSequential( - from: &bitReader, - scanHeader: scan, - frameComponents: frameComponents, - imageWidth: width, imageHeight: height, - dcCodebooks: dcMap, acCodebooks: acMap, - restartInterval: restartInterval) - // Dequantise + IDCT every block per component, producing // per-component sample planes at their native (possibly // chroma-subsampled) resolution. let planes = try JPEGPixelAssembler.assemble( - componentBlocks: comps, - frameComponents: frameComponents, - quantTables: quantTables, - precision: precision) + componentBlocks: coef.quantisedComponents, + frameComponents: coef.frameComponents, + quantTables: coef.quantTables, + precision: coef.precision) + let width = coef.width, height = coef.height - // For 3-component YCbCr, upsample chroma to luma's - // resolution, then run YCbCr → RGB. For 1-component - // grayscale, just convert the plane to UInt8. - if nComponents == 1 { - let plane = planes[0] - let buf = JPEGColorConversion - .grayscaleToBuffer(plane) + if coef.precision <= 8 { + // ---- 8-bit output (unchanged from the baseline path) ---- + if nComponents == 1 { + let buf = JPEGColorConversion + .grayscaleToBuffer(planes[0]) + return try cropToFrame( + rawBuffer: buf, planeWidth: planes[0].width, + planeHeight: planes[0].height, + visibleWidth: width, visibleHeight: height, + channels: 1) + } + // 3-component YCbCr. Scan order matches frame order (the + // JFIF Y, Cb, Cr convention). + let yPlane = planes[0] + let cb = JPEGPixelAssembler.upsampleNearest( + planes[1], toWidth: yPlane.width, height: yPlane.height) + let cr = JPEGPixelAssembler.upsampleNearest( + planes[2], toWidth: yPlane.width, height: yPlane.height) + let rgb = JPEGColorConversion.ycbcrToRGB8( + y: yPlane, cb: cb, cr: cr) return try cropToFrame( - rawBuffer: buf, planeWidth: plane.width, - planeHeight: plane.height, + rawBuffer: rgb, planeWidth: yPlane.width, + planeHeight: yPlane.height, visibleWidth: width, visibleHeight: height, - channels: 1) + channels: 3) } - // 3-component case. We assume scan order matches frame - // order, which is the JFIF convention (Y, Cb, Cr); a - // strict implementation would re-order by component ID. + // ---- 12-bit output → uint16 frame (raw 0…4095 values) ---- + if nComponents == 1 { + return cropToFrame16( + samples: planes[0].samples, channels: 1, + planeWidth: planes[0].width, planeHeight: planes[0].height, + visibleWidth: width, visibleHeight: height) + } let yPlane = planes[0] - let cbRaw = planes[1] - let crRaw = planes[2] let cb = JPEGPixelAssembler.upsampleNearest( - cbRaw, toWidth: yPlane.width, - height: yPlane.height) + planes[1], toWidth: yPlane.width, height: yPlane.height) let cr = JPEGPixelAssembler.upsampleNearest( - crRaw, toWidth: yPlane.width, - height: yPlane.height) - let rgb = JPEGColorConversion.ycbcrToRGB8( - y: yPlane, cb: cb, cr: cr) - return try cropToFrame( - rawBuffer: rgb, planeWidth: yPlane.width, - planeHeight: yPlane.height, - visibleWidth: width, visibleHeight: height, - channels: 3) + planes[2], toWidth: yPlane.width, height: yPlane.height) + let rgb = JPEGColorConversion.ycbcrToRGB( + y: yPlane, cb: cb, cr: cr, precision: coef.precision) + return cropToFrame16( + samples: rgb, channels: 3, + planeWidth: yPlane.width, planeHeight: yPlane.height, + visibleWidth: width, visibleHeight: height) + } + + /// Peek at the SOFn marker to classify the frame without a full + /// decode — used to route SOF3 (lossless) away from the DCT path. + static func frameKind( + of data: Data + ) throws -> JPEGStructure.FrameKind { + var reader = JPEGSegmentReader(data) + while let seg = try reader.next() { + if case .startOfFrame(let nibble) = seg.kind { + return JPEGStructure.FrameKind(nibble: nibble) + } + if seg.kind == .startOfScan || seg.kind == .endOfImage { + break + } + } + throw JPEGDecoderError.missingFrame + } + + /// Decode a SOF3 lossless JPEG (ITU-T T.81 §H — predictive, no + /// DCT) to an `ImageFrame`. Implemented in `JPEGLosslessDecoder`. + static func decodeLossless(_ data: Data) throws -> ImageFrame { + try JPEGLosslessDecoder.decode(data) + } + + /// Pack interleaved `Int32` samples (range `0…65535`, `channels` + /// values per pixel) into a little-endian `.uint16` `ImageFrame` + /// cropped to the visible dimensions. + private static func cropToFrame16( + samples: [Int32], channels: Int, + planeWidth pw: Int, planeHeight ph: Int, + visibleWidth vw: Int, visibleHeight vh: Int + ) -> ImageFrame { + var frame = ImageFrame( + width: vw, height: vh, channels: channels, + pixelType: .uint16, + colorSpace: channels == 1 ? .grayscale : .sRGB) + for y in 0..> 8) & 0xFF) + } + } + return frame } /// Wrap a buffer-aligned-to-block-grid sample array into an diff --git a/Sources/JXLSwift/JPEG/JPEGLosslessDecoder.swift b/Sources/JXLSwift/JPEG/JPEGLosslessDecoder.swift new file mode 100644 index 0000000..3a7426e --- /dev/null +++ b/Sources/JXLSwift/JPEG/JPEGLosslessDecoder.swift @@ -0,0 +1,394 @@ +// JPEGLosslessDecoder — SOF3 lossless JPEG (ITU-T T.81 Annex H). +// +// Lossless JPEG is a *predictive* codec, not DCT-based. Each sample +// `s(row,col)` is predicted from already-reconstructed neighbours +// +// Ra = s(row, col-1) (left) +// Rb = s(row-1, col) (above) +// Rc = s(row-1, col-1) (above-left) +// +// via one of seven selection-value predictors (SOS `Ss` field, +// §H.1.2.1): +// +// 1: Ra 5: Ra + ((Rb − Rc) >> 1) +// 2: Rb 6: Rb + ((Ra − Rc) >> 1) +// 3: Rc 7: (Ra + Rb) >> 1 +// 4: Ra + Rb − Rc +// +// The Huffman-coded difference (DC-class tables, same receive-and- +// extend primitive as the DCT DC term, plus an SSSS = 16 → 32768 +// special case) is added back modulo 2^16 (§H.1.2.1). Edge rules: +// the first sample of the scan / of each restart interval uses the +// constant `2^(P − Pt − 1)`; the first row otherwise uses Ra; the +// first column of subsequent rows uses Rb. +// +// This shares the JPEG segment framing, Huffman tables, and bit +// reader with the DCT path, but none of the DCT / quantisation / +// zig-zag machinery. Scope: 1-component (grayscale — the dominant +// DICOM lossless case) and 3-component (stored components, no colour +// transform) frames at 2…16-bit precision, interleaved or +// non-interleaved scans, with restart intervals. Point transform +// (near-lossless, `Pt > 0`) is honoured via a final left shift. + +import Foundation + +package enum JPEGLosslessDecoder { + + /// Decode a SOF3 lossless JPEG to an `ImageFrame`. 8-bit output + /// returns a `.uint8` frame; higher precision returns a `.uint16` + /// frame carrying the raw sample values. + package static func decode(_ data: Data) throws -> ImageFrame { + var reader = JPEGSegmentReader(data) + var dcMap = JPEGHuffmanCodebookMap() + var frameComponents: [JPEGFrameComponent] = [] + var width = 0, height = 0, precision = 0 + var restartInterval = 0 + + // Per-component reconstructed sample planes (built lazily once + // the SOF is seen). Index parallels `frameComponents`. + var planes: [[Int]] = [] + var planeW: [Int] = [], planeH: [Int] = [] + var hMax = 1, vMax = 1 + + while let seg = try reader.next() { + switch seg.kind { + case .startOfFrame: + precision = Int(seg.payload[seg.payload.startIndex]) + height = (Int(seg.payload[seg.payload.startIndex + 1]) << 8) + | Int(seg.payload[seg.payload.startIndex + 2]) + width = (Int(seg.payload[seg.payload.startIndex + 3]) << 8) + | Int(seg.payload[seg.payload.startIndex + 4]) + frameComponents = try JPEGFrameComponent + .parseSOFComponents(sofPayload: seg.payload) + guard precision >= 2 && precision <= 16 else { + throw JPEGDecoderError.unsupported( + "lossless JPEG precision \(precision) " + + "(supported: 2…16)") + } + guard width > 0, height > 0 else { + throw JPEGDecoderError.unsupported( + "lossless JPEG bad dimensions " + + "\(width)×\(height)") + } + let nC = frameComponents.count + guard nC == 1 || nC == 3 else { + throw JPEGDecoderError.unsupported( + "lossless JPEG \(nC)-component frame " + + "(only 1 or 3 supported)") + } + hMax = frameComponents.map(\.hSamplingFactor).max() ?? 1 + vMax = frameComponents.map(\.vSamplingFactor).max() ?? 1 + for fc in frameComponents { + let w = ceilDiv(width * fc.hSamplingFactor, hMax) + let h = ceilDiv(height * fc.vSamplingFactor, vMax) + planeW.append(w) + planeH.append(h) + planes.append([Int](repeating: 0, count: w * h)) + } + case .defineHuffmanTable: + for t in try JPEGHuffmanTable.parse( + dhtPayload: seg.payload) where t.class == .dc { + dcMap[t.tableId] = (try t.buildCodebook(), t.huffvals) + } + case .defineArithmeticConditioning: + throw JPEGDecoderError.unsupported( + "arithmetic-coded lossless JPEG") + case .defineRestartInterval: + if seg.payload.count == 2 { + restartInterval = + (Int(seg.payload[seg.payload.startIndex]) << 8) + | Int(seg.payload[seg.payload.startIndex + 1]) + } + case .startOfScan: + guard !frameComponents.isEmpty else { + throw JPEGDecoderError.missingFrame + } + let scan = try JPEGScanHeader.parse( + sosPayload: seg.payload) + var br = JPEGBitReader( + data, startingAt: reader.byteOffset) + try decodeScan( + scan: scan, reader: &br, + frameComponents: frameComponents, + imageWidth: width, imageHeight: height, + precision: precision, + restartInterval: restartInterval, + hMax: hMax, vMax: vMax, + dcMap: dcMap, + planes: &planes, planeW: planeW, planeH: planeH) + continue // resume the segment walk for the next scan + case .endOfImage: + break + default: + break + } + } + + guard !frameComponents.isEmpty else { + throw JPEGDecoderError.missingFrame + } + return try assemble( + frameComponents: frameComponents, + planes: planes, planeW: planeW, planeH: planeH, + width: width, height: height, precision: precision, + hMax: hMax, vMax: vMax) + } + + // MARK: - Scan decode + + private static func decodeScan( + scan: JPEGScanHeader, + reader: inout JPEGBitReader, + frameComponents: [JPEGFrameComponent], + imageWidth: Int, imageHeight: Int, + precision: Int, + restartInterval: Int, + hMax: Int, vMax: Int, + dcMap: JPEGHuffmanCodebookMap, + planes: inout [[Int]], planeW: [Int], planeH: [Int] + ) throws { + let psv = scan.spectralSelectionStart // Ss = predictor + let pt = scan.successiveApproximationLow // Al = point xform + let defaultPred = 1 << (precision - pt - 1) + + // Resolve each scan component → (frame index, DC codebook). + struct SC { + let frameIndex: Int + let hi: Int, vi: Int + let book: JPEGHuffmanCodebook + let huffvals: [UInt8] + } + var scs: [SC] = [] + for c in scan.components { + guard let fi = frameComponents.firstIndex( + where: { $0.componentId == c.componentId }) else { + throw JPEGDecoderError.unsupported( + "lossless scan references unknown component " + + "\(c.componentId)") + } + guard let dc = dcMap[c.dcTableId] else { + throw JPEGDecoderError.unsupported( + "lossless scan missing DC Huffman table " + + "\(c.dcTableId)") + } + scs.append(SC( + frameIndex: fi, + hi: frameComponents[fi].hSamplingFactor, + vi: frameComponents[fi].vSamplingFactor, + book: dc.codebook, huffvals: dc.huffvals)) + } + + // Restart bookkeeping. Per §H.2.1 the *first line* of each + // restart interval (and of the scan) uses horizontal (Ra) + // prediction — its first sample the constant `defaultPred`, + // the rest Ra — because the "row above" from the previous + // interval is not a valid predictor after a reset. + // `intervalStartRow[fi]` is the sample row on which the + // current interval began for component `fi`; a sample on that + // row is a first-line sample. `restartPending` marks the + // components whose next sample opens a fresh interval (so its + // row becomes the new interval-start row). Restart intervals + // are constrained to whole MCU rows, so an interval always + // opens at column 0. + var restartCountdown = restartInterval + var intervalStartRow = [Int]( + repeating: 0, count: frameComponents.count) + var restartPending = Set() + + // Decode one sample of component `sc` at plane position + // (row, col), writing the reconstructed value. + func decodeSample(_ sc: SC, row: Int, col: Int) throws { + let fi = sc.frameIndex + let w = planeW[fi] + if restartPending.contains(fi) { + intervalStartRow[fi] = row + restartPending.remove(fi) + } + let diff = try decodeDifference( + book: sc.book, huffvals: sc.huffvals, reader: &reader) + let firstLine = (row == intervalStartRow[fi]) + let pred: Int + if firstLine { + // First line: default for the first sample, Ra after. + pred = col == 0 ? defaultPred + : planes[fi][row * w + col - 1] // Ra + } else if col == 0 { + pred = planes[fi][(row - 1) * w] // Rb + } else { + let ra = planes[fi][row * w + col - 1] + let rb = planes[fi][(row - 1) * w + col] + let rc = planes[fi][(row - 1) * w + col - 1] + switch psv { + case 1: pred = ra + case 2: pred = rb + case 3: pred = rc + case 4: pred = ra + rb - rc + case 5: pred = ra + ((rb - rc) >> 1) + case 6: pred = rb + ((ra - rc) >> 1) + case 7: pred = (ra + rb) >> 1 + default: pred = defaultPred // Psv 0 (differential) + } + } + planes[fi][row * w + col] = (pred + diff) & 0xFFFF + } + + func maybeRestart() { + guard restartInterval > 0 else { return } + restartCountdown -= 1 + if restartCountdown == 0 { + restartCountdown = restartInterval + reader.alignToByte() + restartPending = Set(scs.map { $0.frameIndex }) + } + } + + if scs.count == 1 { + // Non-interleaved: raster scan of the one component's plane. + let sc = scs[0] + let fi = sc.frameIndex + let w = planeW[fi], h = planeH[fi] + for row in 0.. Int { + guard let s = JPEGBlockDecoder.decodeSymbol( + using: book, huffvals: huffvals, reader: &reader) else { + throw JPEGBlockDecodeError.malformedDCSymbol + } + let ssss = Int(s) + if ssss == 0 { return 0 } + if ssss == 16 { return 32768 } + guard ssss <= 15 else { + throw JPEGBlockDecodeError.dcSizeOutOfRange(ssss) + } + return Int(try JPEGBlockDecoder.readExtendedMagnitude( + bits: ssss, from: &reader)) + } + + // MARK: - Assembly + + private static func assemble( + frameComponents: [JPEGFrameComponent], + planes: [[Int]], planeW: [Int], planeH: [Int], + width: Int, height: Int, precision: Int, + hMax: Int, vMax: Int + ) throws -> ImageFrame { + let nC = frameComponents.count + let maxSample = (1 << precision) - 1 + + if nC == 1 { + if precision <= 8 { + var frame = ImageFrame( + width: width, height: height, channels: 1, + pixelType: .uint8, colorSpace: .grayscale) + let w = planeW[0] + for y in 0..> 8) + } + } + return frame + } + + // 3-component: stored components, no colour transform. Require + // no subsampling (all Hi = Vi = 1) — the common lossless-RGB + // layout; subsampled lossless colour is out of scope. + guard frameComponents.allSatisfy({ + $0.hSamplingFactor == 1 && $0.vSamplingFactor == 1 }) else { + throw JPEGDecoderError.unsupported( + "subsampled 3-component lossless JPEG") + } + if precision <= 8 { + var frame = ImageFrame( + width: width, height: height, channels: 3, + pixelType: .uint8, colorSpace: .sRGB) + for y in 0..> 8) + } + } + } + return frame + } + + // MARK: - Helpers + + @inline(__always) + private static func ceilDiv(_ a: Int, _ b: Int) -> Int { + (a + b - 1) / b + } +} diff --git a/Sources/JXLTool/JXLTool.swift b/Sources/JXLTool/JXLTool.swift index 1459c0f..1432196 100644 --- a/Sources/JXLTool/JXLTool.swift +++ b/Sources/JXLTool/JXLTool.swift @@ -30,7 +30,7 @@ struct JXLTool: ParsableCommand { ) } -let JXLToolVersion = "1.3.0" +let JXLToolVersion = "1.4.0" enum JXLExitCode: Int32, Error { case generalError = 1 diff --git a/Tests/JXLSwiftTests/IntegrationTests.swift b/Tests/JXLSwiftTests/IntegrationTests.swift index b62eb83..67a945e 100644 --- a/Tests/JXLSwiftTests/IntegrationTests.swift +++ b/Tests/JXLSwiftTests/IntegrationTests.swift @@ -13649,25 +13649,39 @@ extension FoundationTests { // (lossy→VarDCT, lossless→Modular) and per-mode correctness only. } - /// `JXLEncoder.encode(_:)` lossy-mode **fallback**: VarDCT can't - /// take an 8-bit grayscale frame, so a lossy request falls back - /// to the lossless Modular path rather than failing. The output - /// is a Modular frame that round-trips bit-exact. - func testJXLEncoder_LossyGrayscaleFallsBackToModular() throws { + /// **Regression (grayscale VarDCT).** A lossy request on an 8-bit + /// grayscale frame used to silently fall back to lossless Modular + /// (`VarDCTEncoder.forward` required ≥ 3 channels). Grayscale is + /// now encoded as a 3-channel XYB VarDCT frame (X ≈ 0) carrying + /// grayscale colour metadata — the representation libjxl uses — + /// and the decoder collapses it back to one luma channel. This + /// pins the routing (VarDCT, not Modular), a single-channel + /// grayscale decode, and a bounded lossy round-trip. Would have + /// caught the previous silent Modular fallback. + func testJXLEncoder_LossyGrayscaleRoutesToVarDCT() throws { + let dim = 32 var frame = ImageFrame( - width: 24, height: 24, channels: 1, + width: dim, height: dim, channels: 1, pixelType: .uint8, colorSpace: .grayscale) - for i in 0..= 4` + /// alpha check and fall back to Modular. It now routes to VarDCT + /// with the luma carried lossily in the XYB frame and the alpha + /// carried as a lossless modular extra channel. Pins the routing, + /// a 2-channel decode, a bounded luma round-trip, and — critically + /// — a **bit-exact** alpha channel. + func testJXLEncoder_LossyGrayscaleAlphaRoutesToVarDCT() throws { + let dim = 40 + var frame = ImageFrame( + width: dim, height: dim, channels: 2, + pixelType: .uint8, colorSpace: .grayscale, alphaChannels: 1) + for y in 0.. ImageFrame { + var f = ImageFrame( + width: dim, height: dim, channels: 1, + pixelType: .uint8, colorSpace: .grayscale) + for y in 0.. old 8192 cap + var frame = ImageFrame( + width: w, height: h, channels: 3, colorSpace: .sRGB) + for y in 0..> 3) & 0xff)) + frame.setPixel(x: x, y: y, channel: 2, + value: UInt16((x + y) & 0xff)) + } + } + let encoded = try JXLEncoder().encode(frame) + XCTAssertFalse(encoded.stats.wasLossless, + "oversized frame must still encode as lossy VarDCT") + let inspect = JXLDecoder().inspectFrameStructure(encoded.data) + XCTAssertEqual(inspect.encoding, FrameEncoding.varDCT, + "a >8192-px frame must route to VarDCT, not fall back") + XCTAssertGreaterThan(inspect.tocSizes?.count ?? 0, 1, + "oversized frame must be a multi-section codestream") + let decoded = try JXLDecoder().decode(encoded.data) + XCTAssertEqual(decoded.width, w) + XCTAssertEqual(decoded.height, h) + let m = ImageMetrics.compute(reference: frame, test: decoded) + XCTAssertGreaterThan(m.overallPSNR, 30.0, + "oversized VarDCT round-trip PSNR too low (\(m.overallPSNR) dB)") + } + + /// **Regression (oversized + grayscale VarDCT).** Combines item-1 + /// (grayscale) and item-2 (oversized): a tall grayscale frame + /// beyond the old cap (64×8448) must route to VarDCT and decode + /// back to a single luma channel. + func testVarDCT_OversizedTallGrayscale_RoutesToVarDCT() throws { + let w = 64, h = 8448 // h > old 8192 cap + var frame = ImageFrame( + width: w, height: h, channels: 1, + pixelType: .uint8, colorSpace: .grayscale) + for y in 0.. ImageFrame { + var f = ImageFrame( + width: w, height: h, channels: 3, colorSpace: .sRGB) + for y in 0..> 2) & 0xff)) + f.setPixel(x: x, y: y, channel: 2, + value: UInt16((y + shift) & 0xff)) + } + } + return f + } + let encoded = try JXLEncoder().encode([makeFrame(0), makeFrame(20)]) + XCTAssertFalse(encoded.stats.wasLossless) + let summaries = try JXLDecoder().inspectFrames(encoded.data) + XCTAssertEqual(summaries.count, 2) + for s in summaries { + XCTAssertEqual(s.encoding, FrameEncoding.varDCT, + "oversized animation frame \(s.index) must be VarDCT") + } + } + + /// **Item 3 (int16 storage).** The offset-binary level shift in + /// `ImageFrame.getPixel`/`setPixel` for `.int16` must be an exact + /// involution across the full signed range, including the + /// extremes (−32768, 0, 32767) where two's-complement ↔ + /// offset-binary is most likely to be off by one. + func testPixelType_Int16_OffsetBinaryRoundTrip() throws { + var frame = ImageFrame( + width: 5, height: 1, channels: 1, pixelType: .int16) + let signed: [Int] = [-32768, -1000, -1, 0, 32767] + for (x, s) in signed.enumerated() { + frame.setPixel(x: x, y: 0, channel: 0, + value: UInt16(s + 32768)) // offset-binary in + } + for (x, s) in signed.enumerated() { + let ob = Int(frame.getPixel(x: x, y: 0, channel: 0)) + XCTAssertEqual(ob - 32768, s, + "int16 offset-binary round-trip wrong at x=\(x)") + } + // Reinterpret helpers must compose to the identity. + let asU16 = frame.levelShiftedToUnsigned16() + XCTAssertEqual(asU16.pixelType, .uint16) + let back = asU16.reinterpretedAsSignedInt16() + XCTAssertEqual(back.pixelType, .int16) + XCTAssertEqual(back.data, frame.data, + "int16 → uint16 → int16 must be byte-exact") + } + + /// **Item 3 (int16 lossless, DICOM CT case).** A signed 16-bit + /// grayscale frame (Hounsfield-like values including negatives) + /// encoded losslessly (Modular) and decoded with + /// `signedOutput: true` must round-trip **byte-exact** — the + /// caller no longer needs to pre-shift into an unsigned range. + func testInt16_GrayscaleLossless_RoundTripExact() throws { + let w = 40, h = 40 + var frame = ImageFrame( + width: w, height: h, channels: 1, + pixelType: .int16, colorSpace: .grayscale) + for y in 0.. Bool { + let p = Process() + p.launchPath = path + p.arguments = args + p.standardOutput = Pipe(); p.standardError = Pipe() + do { try p.run() } catch { return false } + p.waitUntilExit() + return p.terminationStatus == 0 + } + + /// Parse a binary PNM (P5 grayscale / P6 RGB). Returns width, + /// height, maxval, and sample values (big-endian 16-bit when + /// maxval > 255 — the PNM convention libjpeg's `djpeg` emits). + private func parsePNM(_ d: Data) + -> (w: Int, h: Int, maxval: Int, samples: [Int]) + { + let bytes = [UInt8](d) + var i = 2, vals: [Int] = [] + while vals.count < 3 { + while i < bytes.count, + bytes[i] == 0x20 || bytes[i] == 0x0a + || bytes[i] == 0x09 || bytes[i] == 0x0d { i += 1 } + var s = i + while i < bytes.count, + !(bytes[i] == 0x20 || bytes[i] == 0x0a + || bytes[i] == 0x09 || bytes[i] == 0x0d) { i += 1 } + vals.append(Int(String(decoding: bytes[s.. 255 { + var k = i + while k + 1 < bytes.count { + samples.append((Int(bytes[k]) << 8) | Int(bytes[k + 1])) + k += 2 } + } else { + samples = bytes[i...].map { Int($0) } } - XCTAssertThrowsError(try JPEGDecoder.decode(data)) { err in - guard let e = err as? JPEGDecoderError else { - XCTFail("expected JPEGDecoderError, got \(err)") - return + return (w, h, mx, samples) + } + + /// **Item 4a (DCT-family pixel decode).** `JPEGDecoder.decode` + /// now decodes progressive (SOF2) and 12-bit (SOF1) JPEGs, not + /// just baseline 8-bit. Each variant is generated with `cjpeg`, + /// decoded through our pure-Swift path, and compared pixel-for- + /// pixel against libjpeg's `djpeg -nosmooth` (nearest-neighbour + /// chroma upsampling, matching ours). A tiny bound absorbs IDCT + /// float-rounding differences between the two decoders. Replaces + /// the old `testJPEGDecoder_RejectsProgressive` (progressive is + /// now supported). + func testJPEGDecoder_DCTFamilyPixelDecode_MatchesDjpeg() throws { + let cjpeg = "/opt/homebrew/bin/cjpeg" + let djpeg = "/opt/homebrew/bin/djpeg" + guard FileManager.default.isExecutableFile(atPath: cjpeg), + FileManager.default.isExecutableFile(atPath: djpeg) + else { throw XCTSkip("cjpeg/djpeg required") } + let tmp = NSTemporaryDirectory() + + struct V { + let label: String; let rgb: Bool + let cjpegArgs: [String]; let bit16: Bool; let bound: Int + } + let variants: [V] = [ + V(label: "progressive gray", rgb: false, + cjpegArgs: ["-progressive"], bit16: false, bound: 4), + V(label: "progressive rgb 4:2:0", rgb: true, + cjpegArgs: ["-progressive", "-sample", "2x2,1x1,1x1"], + bit16: false, bound: 4), + V(label: "12-bit gray", rgb: false, + cjpegArgs: ["-precision", "12"], bit16: true, bound: 4), + V(label: "12-bit rgb", rgb: true, + cjpegArgs: ["-precision", "12", "-sample", "1x1,1x1,1x1"], + bit16: true, bound: 6), + ] + let w = 48, h = 48 + for v in variants { + let srcP = tmp + "src-\(UUID().uuidString)." + + (v.rgb ? "ppm" : "pgm") + let jpgP = tmp + "j-\(UUID().uuidString).jpg" + let refP = tmp + "r-\(UUID().uuidString).pnm" + defer { + for p in [srcP, jpgP, refP] { + try? FileManager.default.removeItem(atPath: p) + } + } + var src = Data("\(v.rgb ? "P6" : "P5")\n\(w) \(h)\n255\n".utf8) + for y in 0..> 8) & 0xff)) + src.append(UInt8(val & 0xff)) + } else { + src.append(UInt8(val & 0xff)) + } + } + if v.rgb { + put((x * 5) & 0xff); put((y * 5) & 0xff) + put(((x + y) * 3) & 0xff) + } else { + put(v.bit16 ? ((x * 263 + y * 517) & 0xffff) + : ((x * 7 + y * 13) & 0xff)) + } + } } + try src.write(to: URL(fileURLWithPath: srcP)) + guard runProc(cjpeg, + v.cjpegArgs + ["-outfile", jpgP, srcP]) else { + throw XCTSkip("cjpeg \(v.label) failed") + } + let jpg = try Data(contentsOf: URL(fileURLWithPath: jpgP)) + + let frame = try JPEGDecoder.decode(jpg) + XCTAssertEqual(frame.width, w, "\(v.label) width") + XCTAssertEqual(frame.height, h, "\(v.label) height") + XCTAssertEqual(frame.channels, v.rgb ? 3 : 1, + "\(v.label) channels") + XCTAssertEqual(frame.pixelType, + v.bit16 ? .uint16 : .uint8, "\(v.label) pixelType") + + guard runProc(djpeg, + ["-pnm", "-outfile", refP, jpgP]) else { + throw XCTSkip("djpeg \(v.label) failed") + } + let ref = parsePNM( + try Data(contentsOf: URL(fileURLWithPath: refP))) + let ch = v.rgb ? 3 : 1 + XCTAssertEqual(ref.samples.count, w * h * ch, + "\(v.label) djpeg sample count") + var maxErr = 0 + for idx in 0..<(w * h * ch) { + let ours = Int(frame.getPixel( + x: (idx / ch) % w, y: (idx / ch) / w, + channel: idx % ch)) + maxErr = max(maxErr, abs(ours - ref.samples[idx])) + } + XCTAssertEqual(maxErr, 0, + "\(v.label): lossless decode must be byte-exact vs " + + "djpeg (maxErr=\(maxErr))") } }