From f7fa463d8e812239fe8f8aece1ad3001450c87da Mon Sep 17 00:00:00 2001 From: raster Date: Tue, 2 Jun 2026 11:08:41 +0530 Subject: [PATCH 1/2] feat(codec): integrate Raster-Lab codec family (J2KSwift 10.24.2, JLSwift, JLISwift, JXLSwift) - Bump J2KSwift 10.9.3 -> 10.24.2 (source-compatible across the jump) - Add JLSwift 0.8.0 and rewire DICOMCore.JPEGLSCodec to delegate to its JPEGLS module, retiring the in-tree JPEG-LS implementation - Add JLISwift 0.4.0 (native JPEG) and JXLSwift 1.0.1 (JPEG XL) dependencies - New DICOMCore bench adapters: JLICodec (lossless JPEG), JXLCodec (lossless JPEG XL), and DjpegCLICodec / DjxlCLICodec (libjpeg-turbo / libjxl peer decoders), with a shared PNM parser and planar<->interleaved pixel bridging - Tests: JPEG-LS suite updated for the JLSwift backing; new round-trip + djpeg/djxl interop tests (10/10 bit-exact) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../xcshareddata/swiftpm/Package.resolved | 31 +- Package.swift | 22 +- Sources/DICOMCore/CLICodecSupport.swift | 110 ++ Sources/DICOMCore/DjpegCLICodec.swift | 57 + Sources/DICOMCore/DjxlCLICodec.swift | 56 + Sources/DICOMCore/JLICodec.swift | 60 + Sources/DICOMCore/JPEGLSCodec.swift | 1301 ++--------------- Sources/DICOMCore/JXLCodec.swift | 61 + .../DICOMCore/PixelInterleaveSupport.swift | 58 + Tests/DICOMCoreTests/JPEGLSCodecTests.swift | 40 +- .../MultiCodecBenchAdaptersTests.swift | 148 ++ 11 files changed, 734 insertions(+), 1210 deletions(-) create mode 100644 Sources/DICOMCore/DjpegCLICodec.swift create mode 100644 Sources/DICOMCore/DjxlCLICodec.swift create mode 100644 Sources/DICOMCore/JLICodec.swift create mode 100644 Sources/DICOMCore/JXLCodec.swift create mode 100644 Sources/DICOMCore/PixelInterleaveSupport.swift create mode 100644 Tests/DICOMCoreTests/MultiCodecBenchAdaptersTests.swift diff --git a/DICOMStudio.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DICOMStudio.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index c2478a37..763a1993 100644 --- a/DICOMStudio.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DICOMStudio.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -15,8 +15,35 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Raster-Lab/J2KSwift.git", "state" : { - "revision" : "78492e68bd4ec8c93267b733ad44bedc07d87878", - "version" : "10.9.3" + "revision" : "22100c180eaf40064559484490cc3962223bbe40", + "version" : "10.24.2" + } + }, + { + "identity" : "jliswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Raster-Lab/JLISwift.git", + "state" : { + "revision" : "f50ae2247b588e52a17323880595782765235aa3", + "version" : "0.4.0" + } + }, + { + "identity" : "jlswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Raster-Lab/JLSwift.git", + "state" : { + "revision" : "bcf00ebbe3ded9d0cdd376fe18101be42a60d85a", + "version" : "0.8.0" + } + }, + { + "identity" : "jxlswift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Raster-Lab/JXLSwift.git", + "state" : { + "revision" : "2dd7c38702ce5bab4dbc0795501214940c94e08f", + "version" : "1.0.1" } }, { diff --git a/Package.swift b/Package.swift index 70268854..39675832 100644 --- a/Package.swift +++ b/Package.swift @@ -212,7 +212,20 @@ let package = Package( // J2KSwift — pure-Swift JPEG 2000 / HTJ2K / JP3D / JPIP codec. // URL-consumable as of v10.9.3 (Raster-Lab/J2KSwift#438): its manifest // always resolves CompressionFamily from its public Git URL. - .package(url: "https://github.com/Raster-Lab/J2KSwift.git", from: "10.9.3") + .package(url: "https://github.com/Raster-Lab/J2KSwift.git", from: "10.24.2"), + // JLSwift — pure-Swift JPEG-LS (ITU-T T.87 / ISO-IEC 14495-1) codec. + // Provides the `JPEGLS` product, which backs DICOMCore.JPEGLSCodec. + .package(url: "https://github.com/Raster-Lab/JLSwift.git", from: "0.8.0"), + // JLISwift — native-Swift JPEG codec (baseline/extended/progressive/ + // lossless SOF0–3, 8/12/16-bit, Accelerate-backed; jpegli-parity target). + // Distinct from JLSwift (JPEG-LS). Products: `JLISwift` (core codec) and + // `JLIDICOM` (DICOM bridge helpers). No external deps → URL-consumable. + .package(url: "https://github.com/Raster-Lab/JLISwift.git", from: "0.4.0"), + // JXLSwift — pure-Swift JPEG XL (ISO/IEC 18181) codec. URL-consumable as + // of v1.0.1: the library target dropped its CompressionFamily dependency + // (v1.0.0 referenced it via a local path, which SwiftPM rejected for a + // stable-versioned consumer). Provides the `JXLSwift` product. + .package(url: "https://github.com/Raster-Lab/JXLSwift.git", from: "1.0.1") ], targets: [ // OpenJPEG 2.x system library (https://www.openjpeg.org) @@ -238,6 +251,13 @@ let package = Package( // Phase 5: hardware acceleration backends .product(name: "J2KAccelerate", package: "J2KSwift"), .product(name: "J2KMetal", package: "J2KSwift"), + // JPEG-LS — JLSwift backs DICOMCore's JPEGLSCodec. + .product(name: "JPEGLS", package: "JLSwift"), + // JPEG — JLISwift native-Swift JPEG codec + DICOM bridge helpers. + .product(name: "JLISwift", package: "JLISwift"), + .product(name: "JLIDICOM", package: "JLISwift"), + // JPEG XL — JXLSwift pure-Swift JPEG XL codec. + .product(name: "JXLSwift", package: "JXLSwift"), // OpenJPEG — decode comparison codec (macOS only, requires brew install openjpeg) .target(name: "COpenJPEG", condition: .when(platforms: [.macOS])) ], diff --git a/Sources/DICOMCore/CLICodecSupport.swift b/Sources/DICOMCore/CLICodecSupport.swift index 9bf271ab..616c9cc2 100644 --- a/Sources/DICOMCore/CLICodecSupport.swift +++ b/Sources/DICOMCore/CLICodecSupport.swift @@ -89,4 +89,114 @@ final class TempWorkDir { try? FileManager.default.removeItem(at: url) } } + +/// Runs ` ` and returns the first output line containing `match` +/// (case-insensitive), trimmed — used to surface a CLI codec's version string. +func cliVersion(at path: String, flag: String, matching match: String) -> String { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: path) + proc.arguments = [flag] + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = pipe + do { try proc.run() } catch { return "unknown" } + proc.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + if let line = String(data: data, encoding: .utf8)? + .split(whereSeparator: \.isNewline) + .first(where: { $0.localizedCaseInsensitiveContains(match) || $0.localizedCaseInsensitiveContains("version") }) { + return String(line).trimmingCharacters(in: .whitespaces) + } + return "installed" +} + +/// Parses a binary PNM blob (`P5` PGM / `P6` PPM) emitted by a decoder CLI +/// (djpeg, djxl) and repacks it into the DICOM frame layout described by +/// `descriptor`: little-endian samples, channel order per Planar Configuration. +/// +/// PNM stores samples **big-endian** and channel-**interleaved**; a maxval > 255 +/// means 16-bit samples. DICOM stores 16-bit samples little-endian, and may be +/// planar (Planar Configuration 1) for multi-component frames. +func pnmToDICOMFrame(_ pnm: Data, descriptor: PixelDataDescriptor) throws -> Data { + let bytes = [UInt8](pnm) + func fail(_ m: String) -> CLICodecError { .unsupportedConfiguration("PNM parse: \(m)") } + + guard bytes.count >= 2, bytes[0] == 0x50 /* 'P' */ else { throw fail("not a PNM stream") } + let channels: Int + switch bytes[1] { + case 0x35: channels = 1 // 'P5' → PGM (grayscale) + case 0x36: channels = 3 // 'P6' → PPM (RGB) + default: throw fail("unsupported magic") + } + var i = 2 + + // Header integer reader — skips whitespace and `#` comment lines. + func nextInt() throws -> Int { + while i < bytes.count { + let c = bytes[i] + if c == 0x23 { // '#' comment to EOL + while i < bytes.count && bytes[i] != 0x0A { i += 1 } + } else if c == 0x20 || c == 0x09 || c == 0x0A || c == 0x0D { + i += 1 + } else { break } + } + var value = 0, digits = 0 + while i < bytes.count, bytes[i] >= 0x30, bytes[i] <= 0x39 { + value = value * 10 + Int(bytes[i] - 0x30); i += 1; digits += 1 + } + guard digits > 0 else { throw fail("expected integer in header") } + return value + } + + let width = try nextInt() + let height = try nextInt() + let maxval = try nextInt() + guard i < bytes.count else { throw fail("missing pixel data") } + i += 1 // single whitespace byte separates the header from binary data + + guard width == descriptor.columns, height == descriptor.rows else { + throw fail("dimensions \(width)x\(height) != \(descriptor.columns)x\(descriptor.rows)") + } + guard channels == descriptor.samplesPerPixel else { + throw fail("channels \(channels) != samplesPerPixel \(descriptor.samplesPerPixel)") + } + + let srcBytesPerSample = maxval > 255 ? 2 : 1 + let nSamples = width * height * channels + guard bytes.count - i >= nSamples * srcBytesPerSample else { + throw fail("short pixel data: have \(bytes.count - i), need \(nSamples * srcBytesPerSample)") + } + + let dstBytesPerSample = descriptor.bitsAllocated <= 8 ? 1 : 2 + let planar = descriptor.planarConfiguration == 1 && channels > 1 + var out = Data(count: nSamples * dstBytesPerSample) + + out.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress else { return } + let dst = base.assumingMemoryBound(to: UInt8.self) + var s = i + for idx in 0..> 8) & 0xFF) + } + } + } + return out +} #endif diff --git a/Sources/DICOMCore/DjpegCLICodec.swift b/Sources/DICOMCore/DjpegCLICodec.swift new file mode 100644 index 00000000..3fc942ce --- /dev/null +++ b/Sources/DICOMCore/DjpegCLICodec.swift @@ -0,0 +1,57 @@ +// DjpegCLICodec.swift +// DICOMCore +// +// Decodes JLISwift-produced JPEG via the libjpeg-turbo `djpeg` CLI, for +// head-to-head comparison in the DICOMStudio JPEG bench. Decode-only — +// JLISwift is the reference encoder. libjpeg-turbo 3.x decodes lossless +// (SOF3) JPEG, so JLISwift's lossless output round-trips through djpeg. +// macOS-only, mirrors the Kakadu/Grok CLI peers. + +#if os(macOS) +import Foundation + +public struct DjpegCLICodec: Sendable { + + public static let binaryName = "djpeg" + + public static let binaryPath: String? = locateBinary(name: binaryName) + + public static var version: String { + binaryPath.map { cliVersion(at: $0, flag: "-version", matching: "libjpeg") } ?? "unavailable" + } + + public init() {} + + public func decodeFrame(_ frameData: Data, descriptor: PixelDataDescriptor) throws -> Data { + guard let bin = Self.binaryPath else { + throw CLICodecError.binaryNotFound(Self.binaryName) + } + guard descriptor.samplesPerPixel == 1 || descriptor.samplesPerPixel == 3 else { + throw CLICodecError.unsupportedConfiguration("samplesPerPixel=\(descriptor.samplesPerPixel)") + } + + let work = try TempWorkDir(prefix: "djpeg") + defer { work.cleanup() } + + let inputURL = work.url.appendingPathComponent("in.jpg") + let outputURL = work.url.appendingPathComponent("out.pnm") + try frameData.write(to: inputURL) + + // `-pnm` emits P5 (grayscale) or P6 (RGB) automatically; 16-bit data + // yields a 16-bit PNM (maxval > 255). + try runProcess(executable: bin, arguments: [ + "-pnm", + "-outfile", outputURL.path, + inputURL.path + ]) + + let pnm = try Data(contentsOf: outputURL) + let frame = try pnmToDICOMFrame(pnm, descriptor: descriptor) + let expected = descriptor.bytesPerFrame + guard frame.count == expected else { + throw CLICodecError.outputSizeMismatch(expected: expected, got: frame.count) + } + return frame + } +} +#endif diff --git a/Sources/DICOMCore/DjxlCLICodec.swift b/Sources/DICOMCore/DjxlCLICodec.swift new file mode 100644 index 00000000..317f89c2 --- /dev/null +++ b/Sources/DICOMCore/DjxlCLICodec.swift @@ -0,0 +1,56 @@ +// DjxlCLICodec.swift +// DICOMCore +// +// Decodes JXLSwift-produced JPEG XL via the libjxl `djxl` CLI, for head-to-head +// comparison in the DICOMStudio JPEG XL bench. Decode-only — JXLSwift is the +// reference encoder. djxl is selected by output extension: `.pgm` for +// grayscale, `.ppm` for RGB, both binary PNM. macOS-only, mirrors the +// Kakadu/Grok CLI peers. + +#if os(macOS) +import Foundation + +public struct DjxlCLICodec: Sendable { + + public static let binaryName = "djxl" + + public static let binaryPath: String? = locateBinary(name: binaryName) + + public static var version: String { + binaryPath.map { cliVersion(at: $0, flag: "--version", matching: "djxl") } ?? "unavailable" + } + + public init() {} + + public func decodeFrame(_ frameData: Data, descriptor: PixelDataDescriptor) throws -> Data { + guard let bin = Self.binaryPath else { + throw CLICodecError.binaryNotFound(Self.binaryName) + } + guard descriptor.samplesPerPixel == 1 || descriptor.samplesPerPixel == 3 else { + throw CLICodecError.unsupportedConfiguration("samplesPerPixel=\(descriptor.samplesPerPixel)") + } + + let work = try TempWorkDir(prefix: "djxl") + defer { work.cleanup() } + + let inputURL = work.url.appendingPathComponent("in.jxl") + // Force the PNM flavor by extension so the magic number is deterministic. + let outName = descriptor.samplesPerPixel == 1 ? "out.pgm" : "out.ppm" + let outputURL = work.url.appendingPathComponent(outName) + try frameData.write(to: inputURL) + + try runProcess(executable: bin, arguments: [ + inputURL.path, + outputURL.path + ]) + + let pnm = try Data(contentsOf: outputURL) + let frame = try pnmToDICOMFrame(pnm, descriptor: descriptor) + let expected = descriptor.bytesPerFrame + guard frame.count == expected else { + throw CLICodecError.outputSizeMismatch(expected: expected, got: frame.count) + } + return frame + } +} +#endif diff --git a/Sources/DICOMCore/JLICodec.swift b/Sources/DICOMCore/JLICodec.swift new file mode 100644 index 00000000..d3356109 --- /dev/null +++ b/Sources/DICOMCore/JLICodec.swift @@ -0,0 +1,60 @@ +import Foundation +import JLISwift + +/// Bench adapter for the JLISwift native-Swift JPEG codec. +/// +/// Encodes **lossless** JPEG (ITU-T T.81 SOF3 predictive lossless) and decodes +/// it back, so the DICOMStudio codec bench can compare JLISwift against other +/// JPEG implementations (e.g. libjpeg-turbo `djpeg`) the way it compares +/// J2KSwift against Kakadu/Grok. Lossless-only by design — the bench's axis is +/// bit-exact round-trip + peer decode. +/// +/// Pixel bridging: JLISwift's `JLIImage.data` is channel-interleaved `[UInt8]`, +/// row-major, 16-bit samples little-endian — matching DICOM little-endian +/// storage, so only planar→interleaved reshuffling (handled upstream) is needed. +public struct JLICodec: Sendable { + public init() {} + + // MARK: - Encoding + + public func encodeFrame(_ frameData: Data, descriptor: PixelDataDescriptor, + frameIndex: Int, configuration: CompressionConfiguration) throws -> Data { + let spp = descriptor.samplesPerPixel + guard spp == 1 || spp == 3 else { + throw DICOMError.parsingFailed("JLISwift: unsupported samplesPerPixel \(spp)") + } + let pixelFormat: JLIPixelFormat = descriptor.bitsAllocated <= 8 ? .uint8 : .uint16 + let colorModel: JLIColorModel = spp == 1 ? .grayscale : .rgb + let interleaved = interleavedFrameBytes(from: frameData, descriptor: descriptor) + + do { + let image = try JLIImage(width: descriptor.columns, height: descriptor.rows, + pixelFormat: pixelFormat, colorModel: colorModel, + data: interleaved, isSigned: descriptor.isSigned) + // Lossless preset: SOF3 predictor 1, point-transform 0, 4:4:4, no + // perceptual/adaptive paths. For >8-bit, precision must be set to the + // stored depth or JLISwift derives 12-bit and drops the top bits. + var cfg = JLIEncoderConfiguration.diagnosticLossless + if pixelFormat == .uint16 { + cfg.losslessPrecision = min(16, max(2, descriptor.bitsStored)) + } + return Data(try JLIEncoder().encode(image, configuration: cfg)) + } catch { + throw DICOMError.parsingFailed("JLISwift encode failed: \(error)") + } + } + + // MARK: - Decoding + + public func decodeFrame(_ frameData: Data, descriptor: PixelDataDescriptor, frameIndex: Int) throws -> Data { + guard !frameData.isEmpty else { + throw DICOMError.parsingFailed("Empty JPEG data") + } + do { + let image = try JLIDecoder().decode(from: [UInt8](frameData)) + return dicomFrameBytes(fromInterleaved: image.data, descriptor: descriptor) + } catch { + throw DICOMError.parsingFailed("JLISwift decode failed: \(error)") + } + } +} diff --git a/Sources/DICOMCore/JPEGLSCodec.swift b/Sources/DICOMCore/JPEGLSCodec.swift index 197f4587..adca007b 100644 --- a/Sources/DICOMCore/JPEGLSCodec.swift +++ b/Sources/DICOMCore/JPEGLSCodec.swift @@ -1,13 +1,16 @@ import Foundation +import JPEGLS -/// Pure Swift JPEG-LS codec implementation +/// JPEG-LS codec backed by the JLSwift `JPEGLS` package. /// -/// Implements the JPEG-LS standard (ITU-T T.87 / ISO/IEC 14495-1) for -/// both lossless and near-lossless image compression. -/// Supports both decoding and encoding of JPEG-LS compressed pixel data. +/// Bridges DICOM pixel data (PS3.5 §A.4.5) to JLSwift's pure-Swift JPEG-LS +/// implementation (ITU-T T.87 / ISO/IEC 14495-1) for both lossless and +/// near-lossless encode/decode. The previous in-tree JPEG-LS implementation +/// (markers, bit I/O, context modelling) was retired in favour of JLSwift. /// -/// Reference: DICOM PS3.5 Section A.4.5 -/// Standard: ITU-T T.87 | ISO/IEC 14495-1 +/// Transfer syntaxes: +/// • 1.2.840.10008.1.2.4.80 JPEG-LS Lossless +/// • 1.2.840.10008.1.2.4.81 JPEG-LS Near-Lossless public struct JPEGLSCodec: ImageCodec, ImageEncoder, Sendable { /// Supported JPEG-LS transfer syntaxes for decoding public static let supportedTransferSyntaxes: [String] = [ @@ -30,14 +33,21 @@ public struct JPEGLSCodec: ImageCodec, ImageEncoder, Sendable { /// - frameData: JPEG-LS compressed data /// - descriptor: Pixel data descriptor /// - frameIndex: Frame index (unused for single frame decode) - /// - Returns: Uncompressed pixel data - /// - Throws: DICOMError if decoding fails + /// - Returns: Uncompressed pixel data laid out per `descriptor` + /// - Throws: `DICOMError` if decoding fails public func decodeFrame(_ frameData: Data, descriptor: PixelDataDescriptor, frameIndex: Int) throws -> Data { guard !frameData.isEmpty else { throw DICOMError.parsingFailed("Empty JPEG-LS data") } - let decoder = try JPEGLSDecoder(data: frameData) - return try decoder.decode() + + let image: MultiComponentImageData + do { + image = try JPEGLSDecoder().decode(frameData) + } catch { + throw DICOMError.parsingFailed("JPEG-LS decode failed: \(error)") + } + + return serialize(image, descriptor: descriptor) } // MARK: - Encoding @@ -55,1193 +65,140 @@ public struct JPEGLSCodec: ImageCodec, ImageEncoder, Sendable { /// Encodes a single frame to JPEG-LS format /// - Parameters: - /// - frameData: Uncompressed frame data + /// - frameData: Uncompressed frame data laid out per `descriptor` /// - descriptor: Pixel data descriptor /// - frameIndex: Zero-based frame index /// - configuration: Compression configuration /// - Returns: JPEG-LS compressed frame data - /// - Throws: DICOMError if encoding fails + /// - Throws: `DICOMError` if encoding fails public func encodeFrame(_ frameData: Data, descriptor: PixelDataDescriptor, frameIndex: Int, configuration: CompressionConfiguration) throws -> Data { - let near: Int - if configuration.preferLossless || configuration.quality.isLossless { - near = 0 - } else { - let maxVal = descriptor.bitsAllocated == 8 ? 255 : (1 << descriptor.bitsStored) - 1 - near = max(0, Int(Double(maxVal) * (1.0 - configuration.quality.value) * 0.1)) - } - let encoder = JPEGLSEncoder( - width: descriptor.columns, - height: descriptor.rows, - bitsPerSample: descriptor.bitsStored, - components: descriptor.samplesPerPixel, - near: near, - interleaveMode: descriptor.samplesPerPixel > 1 ? 2 : 0 - ) - return try encoder.encode(frameData) - } -} - -// MARK: - JPEG-LS Markers - -/// JPEG-LS marker codes per ITU-T T.87 -enum JPEGLSMarker { - static let soi: UInt16 = 0xFFD8 // Start of image - static let eoi: UInt16 = 0xFFD9 // End of image - static let sof55: UInt16 = 0xFFF7 // Start of frame (JPEG-LS) - static let sos: UInt16 = 0xFFDA // Start of scan - static let lst: UInt16 = 0xFFF8 // JPEG-LS preset parameters - static let dnl: UInt16 = 0xFFDC // Define number of lines - static let com: UInt16 = 0xFFFE // Comment - static let app0: UInt16 = 0xFFE0 // Application segment 0 - static let app15: UInt16 = 0xFFEF // Application segment 15 -} - -// MARK: - JPEG-LS Preset Parameters - -/// JPEG-LS preset coding parameters per ITU-T T.87 Section C.2 -struct JPEGLSPresetParameters { - var maxVal: Int // Maximum sample value (MAXVAL) - var t1: Int // Threshold 1 - var t2: Int // Threshold 2 - var t3: Int // Threshold 3 - var reset: Int // Counter reset value - - /// Computes default thresholds per ITU-T T.87 Annex C - static func defaultParameters(maxVal: Int, near: Int) -> JPEGLSPresetParameters { - let factor = max(2, (maxVal + 2 * near) / (2 * near + 1) + 1) - let clamp = max(2, (maxVal + 1) / 2) - let t1: Int - let t2: Int - let t3: Int - if maxVal >= 128 { - t1 = clampValue(factor * 3 + 2 + near, min: near + 1, max: clamp) - t2 = clampValue(factor * 7 + 3 + near, min: t1, max: clamp) - t3 = clampValue(factor * 21 + 4 + near, min: t2, max: clamp) - } else { - t1 = clampValue(2 + near, min: near + 1, max: clamp) - t2 = clampValue(3 + near, min: t1, max: clamp) - t3 = clampValue(4 + near, min: t2, max: clamp) - } - return JPEGLSPresetParameters(maxVal: maxVal, t1: t1, t2: t2, t3: t3, reset: 64) - } - - private static func clampValue(_ value: Int, min minV: Int, max maxV: Int) -> Int { - return max(minV, min(value, maxV)) - } -} - -// MARK: - JPEG-LS Context (Adaptive statistics) - -/// Adaptive context for JPEG-LS prediction error modeling -/// Reference: ITU-T T.87 Section A.3 - A.6 -struct JPEGLSContext { - var a: Int // Accumulated prediction error magnitude - var b: Int // Accumulated prediction error bias - var c: Int // Context correction value - var n: Int // Counter for number of occurrences - - init() { - a = 0; b = 0; c = 0; n = 0 - } - - init(a: Int, b: Int, c: Int, n: Int) { - self.a = a; self.b = b; self.c = c; self.n = n - } -} - -// MARK: - Bit Reader for Decoding - -/// Reads individual bits from a byte buffer per JPEG-LS bitstream conventions -/// Handles byte unstuffing: after reading 0xFF, the next byte has only 7 data bits -/// Reference: ITU-T T.87 Section B - Byte stuffing -final class JPEGLSBitReader { - private let data: Data - private var bytePos: Int - private var bitBuffer: Int - private var bitsInBuffer: Int - private var previousByteFF: Bool - - init(data: Data, offset: Int) { - self.data = data - self.bytePos = offset - self.bitBuffer = 0 - self.bitsInBuffer = 0 - self.previousByteFF = false - } - - var position: Int { bytePos } - - func readBit() throws -> Int { - if bitsInBuffer == 0 { - try fillBuffer() - } - bitsInBuffer -= 1 - let bit = (bitBuffer >> bitsInBuffer) & 1 - return bit - } - - func readBits(_ count: Int) throws -> Int { - var value = 0 - for _ in 0..> i) & 1) - } - } - - func flush() { - if bitsUsed > 0 { - let maxBits = previousByteFF ? 7 : 8 - currentByte <<= (maxBits - bitsUsed) - let byte = UInt8(currentByte & 0xFF) - buffer.append(byte) - previousByteFF = false - currentByte = 0 - bitsUsed = 0 - } - } - - var data: Data { Data(buffer) } -} - -// MARK: - JPEG-LS Decoder - -/// Decodes JPEG-LS compressed image data per ITU-T T.87 -final class JPEGLSDecoder { - private let data: Data - private var offset: Int = 0 - - // Frame parameters - private var width: Int = 0 - private var height: Int = 0 - private var bitsPerSample: Int = 0 - private var components: Int = 0 - private var near: Int = 0 - private var interleaveMode: Int = 0 // 0 = none, 1 = line, 2 = sample - private var componentIds: [UInt8] = [] - - // Derived parameters - private var maxVal: Int = 0 - private var range: Int = 0 - private var qbpp: Int = 0 - private var bpp: Int = 0 - private var limit: Int = 0 - private var scanDataOffset: Int = 0 - - private var preset: JPEGLSPresetParameters? - - init(data: Data) throws { - self.data = data - } - - func decode() throws -> Data { - try parseMarkers() - - guard width > 0 && height > 0 else { - throw DICOMError.parsingFailed("JPEG-LS: invalid image dimensions") - } - - maxVal = (1 << bitsPerSample) - 1 - let effectiveNear = near - if effectiveNear == 0 { - range = maxVal + 1 - } else { - range = (maxVal + 2 * effectiveNear) / (2 * effectiveNear + 1) + 1 - } - qbpp = ceilLog2(range) - bpp = max(2, ceilLog2(maxVal + 1)) - limit = 2 * (bpp + max(8, bpp)) - - if preset == nil { - preset = JPEGLSPresetParameters.defaultParameters(maxVal: maxVal, near: effectiveNear) - } - - // Decode scan data - if components == 1 || interleaveMode == 0 { - return try decodeScanNonInterleaved() - } else if interleaveMode == 2 { - return try decodeScanSampleInterleaved() - } else { - return try decodeScanLineInterleaved() - } - } - - // MARK: - Marker Parsing - - private func parseMarkers() throws { - // SOI - let soi = readUInt16BE() - guard soi == JPEGLSMarker.soi else { - throw DICOMError.parsingFailed("JPEG-LS: missing SOI marker") - } - - var foundSOS = false - while offset < data.count - 1 && !foundSOS { - let marker = readUInt16BE() - - switch marker { - case JPEGLSMarker.sof55: - try parseSOF55() - case JPEGLSMarker.sos: - try parseSOS() - scanDataOffset = offset // Record where scan data begins - foundSOS = true - case JPEGLSMarker.lst: - try parseLST() - case JPEGLSMarker.com: - try skipSegment() - case JPEGLSMarker.app0...JPEGLSMarker.app15: - try skipSegment() - case JPEGLSMarker.eoi: - break - default: - if marker & 0xFF00 == 0xFF00 { - try skipSegment() - } - } - } - - guard foundSOS else { - throw DICOMError.parsingFailed("JPEG-LS: missing SOS marker") - } - } - - private func parseSOF55() throws { - let length = Int(readUInt16BE()) - let endOffset = offset + length - 2 // length includes the 2 bytes of the length field - - bitsPerSample = Int(readByte()) - height = Int(readUInt16BE()) - width = Int(readUInt16BE()) - components = Int(readByte()) - - guard components >= 1 && components <= 4 else { - throw DICOMError.parsingFailed("JPEG-LS: invalid component count \(components)") - } - - componentIds = [] - for _ in 0..= 1 else { - throw DICOMError.parsingFailed("JPEG-LS: invalid SOS component count") - } - - for _ in 0.. 1 ? .sample : .none - if offset < endOffset { - offset = endOffset + do { + let image = try buildImage(from: frameData, descriptor: descriptor, bitsPerSample: bitsPerSample) + let config = try JPEGLSEncoder.Configuration(near: near, interleaveMode: interleave) + return try JPEGLSEncoder().encode(image, configuration: config) + } catch let error as DICOMError { + throw error + } catch { + throw DICOMError.parsingFailed("JPEG-LS encode failed: \(error)") } } - private func parseLST() throws { - let length = Int(readUInt16BE()) - let endOffset = offset + length - 2 - - let id = Int(readByte()) - if id == 1 && length >= 11 { - let mv = Int(readUInt16BE()) - let t1 = Int(readUInt16BE()) - let t2 = Int(readUInt16BE()) - let t3 = Int(readUInt16BE()) - let rst = Int(readUInt16BE()) - preset = JPEGLSPresetParameters(maxVal: mv, t1: t1, t2: t2, t3: t3, reset: rst) - } - - if offset < endOffset { - offset = endOffset - } - } - - private func skipSegment() throws { - let length = Int(readUInt16BE()) - offset += length - 2 // length includes the 2 bytes of the length field itself - if offset > data.count { - offset = data.count - } - } - - // MARK: - Scan Decoding - - private func decodeScanNonInterleaved() throws -> Data { - let bytesPerSample = (bitsPerSample + 7) / 8 - return try decodeSinglePass(bytesPerSample: bytesPerSample) - } - - private func decodeSinglePass(bytesPerSample: Int) throws -> Data { - let totalPixels = width * height * components - var output = Data(count: totalPixels * bytesPerSample) + // MARK: - NEAR parameter - var currentScanOffset = scanDataOffset - - for comp in 0.. 0 ? currentRow[x] : (y > 0 ? previousRow[1] : 0) - let rb = previousRow[x + 1] - let rc = x > 0 ? previousRow[x] : (y > 0 ? previousRow[1] : 0) - let rd = (x + 1 < compWidth) ? previousRow[x + 2] : rb - - let g1 = rd - rb - let g2 = rb - rc - let g3 = rc - ra - - if isRunMode(gradient1: g1, gradient2: g2, gradient3: g3) { - let runLen = try decodeRunLength(reader: reader, maxRun: compWidth - x) - for i in 0.. Int { + if configuration.preferLossless || configuration.quality.isLossless { + return 0 + } + let maxVal = descriptor.bitsAllocated == 8 ? 255 : (1 << descriptor.bitsStored) - 1 + return max(0, Int(Double(maxVal) * (1.0 - configuration.quality.value) * 0.1)) + } + + // MARK: - Pixel (de)serialization + + /// Bytes per stored sample, derived from Bits Allocated (8 → 1, otherwise 2). + private func bytesPerSample(_ descriptor: PixelDataDescriptor) -> Int { + descriptor.bitsAllocated <= 8 ? 1 : 2 + } + + /// Linear pixel index for component `c` at (`x`, `y`), honouring Samples per + /// Pixel and Planar Configuration (0 = sample-interleaved, 1 = planar). + private func pixelIndex(component c: Int, x: Int, y: Int, width: Int, height: Int, samples: Int, planar: Bool) -> Int { + if samples == 1 { + return y * width + x + } else if planar { + return c * width * height + y * width + x + } else { + return (y * width + x) * samples + c + } + } + + /// Serializes JLSwift's row-major `[[Int]]` components into DICOM pixel bytes + /// matching `descriptor` (Bits Allocated, Samples per Pixel, Planar + /// Configuration). 16-bit samples are written little-endian per DICOM. + private func serialize(_ image: MultiComponentImageData, descriptor: PixelDataDescriptor) -> Data { + let width = descriptor.columns + let height = descriptor.rows + let samples = descriptor.samplesPerPixel + let bps = bytesPerSample(descriptor) + let planar = descriptor.planarConfiguration == 1 + var output = Data(count: width * height * samples * bps) + + output.withUnsafeMutableBytes { raw in + guard let base = raw.baseAddress else { return } + let buffer = base.assumingMemoryBound(to: UInt8.self) + for c in 0..> 8) & 0xFF) } - } else { - let sample = try decodeRegularMode( - reader: reader, - ra: ra, rb: rb, rc: rc, - gradient1: g1, gradient2: g2, gradient3: g3, - contexts: &contexts - ) - currentRow[x + 1] = sample - writePixel(&output, value: sample, index: comp * compWidth * compHeight + y * compWidth + x, bytesPerSample: bytesPerSample) - x += 1 } } - previousRow = currentRow - currentRow = [Int](repeating: 0, count: compWidth + 1) } - - currentScanOffset = reader.position } - return output } - private func decodeScanSampleInterleaved() throws -> Data { - return try decodeSinglePass(bytesPerSample: (bitsPerSample + 7) / 8) - } - - private func decodeScanLineInterleaved() throws -> Data { - return try decodeSinglePass(bytesPerSample: (bitsPerSample + 7) / 8) - } - - // MARK: - Regular Mode Decoding - - private func decodeRegularMode( - reader: JPEGLSBitReader, - ra: Int, rb: Int, rc: Int, - gradient1: Int, gradient2: Int, gradient3: Int, - contexts: inout [JPEGLSContext] - ) throws -> Int { - let p = preset ?? JPEGLSPresetParameters.defaultParameters(maxVal: maxVal, near: near) - - // Quantize gradients to get context index - let (q, sign) = quantizeGradients(gradient1: gradient1, gradient2: gradient2, gradient3: gradient3, preset: p) - - // Predict - var px: Int - if rc >= max(ra, rb) { - px = min(ra, rb) - } else if rc <= min(ra, rb) { - px = max(ra, rb) - } else { - px = ra + rb - rc - } - - // Apply context correction - px = clampSample(px + (sign > 0 ? contexts[q].c : -contexts[q].c)) - - // Decode error value using Golomb-Rice coding - let k = computeK(contexts[q]) - var errVal = try decodeGolombRice(reader: reader, k: k) - - // Map error value - if sign < 0 { - errVal = -errVal - } - - // Compute reconstructed value - var rx: Int - if near == 0 { - rx = clampSample(px + errVal) - } else { - rx = clampSample(px + errVal * (2 * near + 1)) - } - - // Update context - updateContext(&contexts[q], errVal: sign > 0 ? errVal : -errVal) - - return rx - } - - // MARK: - Run Mode Decoding - - private func isRunMode(gradient1: Int, gradient2: Int, gradient3: Int) -> Bool { - return abs(gradient1) <= near && abs(gradient2) <= near && abs(gradient3) <= near - } - - private func decodeRunLength(reader: JPEGLSBitReader, maxRun: Int) throws -> Int { - var runLen = 0 - // J[0..31] table from T.87 Table A.5 - let jTable = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 9, 10, 11, 12, 13, 14, 15] - var rk = 0 - - while runLen < maxRun { - let bit = try reader.readBit() - if bit == 1 { - let increment = 1 << (rk < jTable.count ? jTable[rk] : 15) - runLen += increment - if rk < jTable.count - 1 { rk += 1 } + /// Builds a JLSwift `MultiComponentImageData` from DICOM pixel bytes. + private func buildImage(from frameData: Data, descriptor: PixelDataDescriptor, bitsPerSample: Int) throws -> MultiComponentImageData { + let width = descriptor.columns + let height = descriptor.rows + let samples = descriptor.samplesPerPixel + let bps = bytesPerSample(descriptor) + let planar = descriptor.planarConfiguration == 1 + let start = frameData.startIndex + + func sample(component c: Int, _ x: Int, _ y: Int) -> Int { + let byteIndex = pixelIndex(component: c, x: x, y: y, width: width, height: height, samples: samples, planar: planar) * bps + if bps == 1 { + guard byteIndex < frameData.count else { return 0 } + return Int(frameData[start + byteIndex]) } else { - // Read remaining run length bits - let jVal = rk < jTable.count ? jTable[rk] : 15 - if jVal > 0 { - let remaining = try reader.readBits(jVal) - runLen += remaining - } - break - } - } - return min(runLen, maxRun) - } - - private func decodeRunInterruption( - reader: JPEGLSBitReader, - ra: Int, rb: Int, - contexts: inout [JPEGLSContext], - runIndex: inout Int - ) throws -> Int { - let sign = ra > rb ? -1 : 1 - let temp = (ra == rb) ? 0 : 1 - let ctxIdx = 365 + temp // Run interruption contexts at indices 365, 366 - - let k = computeK(contexts[ctxIdx]) - var errVal = try decodeGolombRice(reader: reader, k: k) - - if sign < 0 { errVal = -errVal } - - var rx: Int - if near == 0 { - rx = clampSample(rb + errVal) - } else { - rx = clampSample(rb + errVal * (2 * near + 1)) - } - - updateContext(&contexts[ctxIdx], errVal: sign > 0 ? errVal : -errVal) - if runIndex < 31 { runIndex += 1 } - - return rx - } - - // MARK: - Golomb-Rice Coding - - private func decodeGolombRice(reader: JPEGLSBitReader, k: Int) throws -> Int { - // Limited-length Golomb code (ITU-T T.87 A.5.3). Counterpart of - // `JPEGLSEncoder.encodeGolombRice` — the two must agree exactly: - // • escape threshold = `limit - qbpp - 1` zero bits, then a '1' - // • normal: mapped = (zeros << k) | k-bit remainder - // • overflow: mapped read directly as a (qbpp + 1)-bit field - let escapeThreshold = max(1, limit - qbpp - 1) - - var zeros = 0 - while try reader.readBit() == 0 { - zeros += 1 - if zeros >= escapeThreshold { break } // the next bit is the '1' - } - // After the loop `zeros` is the run of leading zeros. When it reached - // the escape threshold the terminating '1' has not been consumed yet. - let mapped: Int - if zeros < escapeThreshold { - var remainder = 0 - if k > 0 { remainder = try reader.readBits(k) } - mapped = (zeros << k) | remainder - } else { - _ = try reader.readBit() // consume the terminating '1' - mapped = try reader.readBits(qbpp + 1) - } - - // Inverse mapping from non-negative to signed - if mapped % 2 == 0 { - return mapped / 2 - } else { - return -(mapped + 1) / 2 - } - } - - // MARK: - Context Operations - - private func initializeContexts() -> [JPEGLSContext] { - let numContexts = 367 // 365 regular + 2 run interruption - var contexts = [JPEGLSContext](repeating: JPEGLSContext(), count: numContexts) - let initA = max(2, (range + 32) / 64) - for i in 0.. (Int, Int) { - let q1 = quantizeGradient(gradient1, preset: preset) - let q2 = quantizeGradient(gradient2, preset: preset) - let q3 = quantizeGradient(gradient3, preset: preset) - - var sign = 1 - var qIndex: Int - - if q1 < 0 || (q1 == 0 && q2 < 0) || (q1 == 0 && q2 == 0 && q3 < 0) { - sign = -1 - qIndex = contextIndex(q1: -q1, q2: -q2, q3: -q3) - } else { - qIndex = contextIndex(q1: q1, q2: q2, q3: q3) - } - - return (min(qIndex, 364), sign) - } - - private func quantizeGradient(_ gradient: Int, preset: JPEGLSPresetParameters) -> Int { - let absG = abs(gradient) - if absG <= near { return 0 } - if absG <= preset.t1 { return gradient > 0 ? 1 : -1 } - if absG <= preset.t2 { return gradient > 0 ? 2 : -2 } - if absG <= preset.t3 { return gradient > 0 ? 3 : -3 } - return gradient > 0 ? 4 : -4 - } - - private func contextIndex(q1: Int, q2: Int, q3: Int) -> Int { - // Map 3D gradient quantization to a linear index - // q1 in [0..4], q2 in [-4..4], q3 in [-4..4] - // Total contexts: 5 * 9 * 9 = 405, but we clamp to 365 - return q1 * 81 + (q2 + 4) * 9 + (q3 + 4) - } - - private func computeK(_ ctx: JPEGLSContext) -> Int { - var k = 0 - var nTimesA = ctx.n - while nTimesA < ctx.a { - nTimesA <<= 1 - k += 1 - } - return min(k, bpp - 2) - } - - private func updateContext(_ ctx: inout JPEGLSContext, errVal: Int) { - let resetValue = preset?.reset ?? 64 - - ctx.a += abs(errVal) - ctx.b += errVal - - if ctx.n == resetValue { - ctx.a = max(1, ctx.a >> 1) - ctx.b >>= 1 - ctx.n >>= 1 - } - ctx.n += 1 - - // Bias correction - if ctx.b <= -ctx.n { - ctx.b = max(ctx.b + ctx.n, 1 - ctx.n) - if ctx.c > -128 { ctx.c -= 1 } - } else if ctx.b > 0 { - ctx.b = min(ctx.b - ctx.n, 0) - if ctx.c < 127 { ctx.c += 1 } - } - } - - // MARK: - Helpers - - private func clampSample(_ value: Int) -> Int { - return max(0, min(value, maxVal)) - } - - private func ceilLog2(_ value: Int) -> Int { - guard value > 0 else { return 0 } - var v = value - 1 - var bits = 0 - while v > 0 { - v >>= 1 - bits += 1 - } - return max(1, bits) - } - - private func readByte() -> UInt8 { - guard offset < data.count else { return 0 } - let b = data[offset] - offset += 1 - return b - } - - private func readUInt16BE() -> UInt16 { - guard offset + 1 < data.count else { return 0 } - let value = UInt16(data[offset]) << 8 | UInt16(data[offset + 1]) - offset += 2 - return value - } - - private func writePixel(_ output: inout Data, value: Int, index: Int, bytesPerSample: Int) { - if bytesPerSample == 1 { - let byteIndex = index - if byteIndex < output.count { - output[byteIndex] = UInt8(clamping: value) - } - } else { - let byteIndex = index * 2 - if byteIndex + 1 < output.count { - output[byteIndex] = UInt8(value & 0xFF) - output[byteIndex + 1] = UInt8((value >> 8) & 0xFF) + guard byteIndex + 1 < frameData.count else { return 0 } + return Int(frameData[start + byteIndex]) | (Int(frameData[start + byteIndex + 1]) << 8) } } - } -} - -// MARK: - JPEG-LS Encoder - -/// Encodes uncompressed image data to JPEG-LS format per ITU-T T.87 -final class JPEGLSEncoder { - private let width: Int - private let height: Int - private let bitsPerSample: Int - private let components: Int - private let near: Int - private let interleaveMode: Int // 0 = none, 2 = sample - - private var maxVal: Int - private var range: Int - private var qbpp: Int - private var bpp: Int - private var limit: Int - private var preset: JPEGLSPresetParameters - - init(width: Int, height: Int, bitsPerSample: Int, components: Int, near: Int, interleaveMode: Int) { - self.width = width - self.height = height - self.bitsPerSample = bitsPerSample - self.components = components - self.near = near - self.interleaveMode = interleaveMode - - maxVal = (1 << bitsPerSample) - 1 - if near == 0 { - range = maxVal + 1 - } else { - range = (maxVal + 2 * near) / (2 * near + 1) + 1 - } - qbpp = JPEGLSEncoder.ceilLog2(range) - bpp = max(2, JPEGLSEncoder.ceilLog2(maxVal + 1)) - limit = 2 * (bpp + max(8, bpp)) - preset = JPEGLSPresetParameters.defaultParameters(maxVal: maxVal, near: near) - } - - func encode(_ pixelData: Data) throws -> Data { - var output = Data() - let bytesPerSample = (bitsPerSample + 7) / 8 - - // Write markers - writeSOI(&output) - writeSOF55(&output) - writeSOS(&output) - - // Encode scan data - let writer = JPEGLSBitWriter() - - for comp in 0.. [[Int]] { + var rows = [[Int]]() + rows.reserveCapacity(height) for y in 0.. 0 ? currentRow[x] : (y > 0 ? previousRow[1] : 0) - let rb = previousRow[x + 1] - let rc = x > 0 ? previousRow[x] : (y > 0 ? previousRow[1] : 0) - let rd = (x + 1 < width) ? previousRow[x + 2] : rb - - let g1 = rd - rb - let g2 = rb - rc - let g3 = rc - ra - - if isRunMode(gradient1: g1, gradient2: g2, gradient3: g3) { - // Run mode: count consecutive equal samples - var runLen = 0 - while x + runLen < width { - let ri = comp * width * height + y * width + x + runLen - let s = readPixel(pixelData, index: ri, bytesPerSample: bytesPerSample) - if abs(s - ra) <= near { - let reconstructed = near == 0 ? ra : clampSample(s) - currentRow[x + runLen + 1] = reconstructed - runLen += 1 - } else { - break - } - } - encodeRunLength(writer: writer, runLen: runLen, maxRun: width - x) - x += runLen - - if x < width { - // Encode run interruption sample - let ri = comp * width * height + y * width + x - let s = readPixel(pixelData, index: ri, bytesPerSample: bytesPerSample) - let rbNew = previousRow[x + 1] - let reconstructed = encodeRunInterruption( - writer: writer, - sample: s, ra: ra, rb: rbNew, - contexts: &contexts, - runIndex: &runIndex - ) - currentRow[x + 1] = reconstructed - x += 1 - } - } else { - // Regular mode - let reconstructed = encodeRegularMode( - writer: writer, - sample: sample, - ra: ra, rb: rb, rc: rc, - gradient1: g1, gradient2: g2, gradient3: g3, - contexts: &contexts - ) - currentRow[x + 1] = reconstructed - x += 1 - } - } - previousRow = currentRow - currentRow = [Int](repeating: 0, count: width + 1) - } - } - - writer.flush() - output.append(writer.data) - - // Write EOI - writeEOI(&output) - - return output - } - - // MARK: - Regular Mode Encoding - - private func encodeRegularMode( - writer: JPEGLSBitWriter, - sample: Int, - ra: Int, rb: Int, rc: Int, - gradient1: Int, gradient2: Int, gradient3: Int, - contexts: inout [JPEGLSContext] - ) -> Int { - let (q, sign) = quantizeGradients(gradient1: gradient1, gradient2: gradient2, gradient3: gradient3) - - // Predict - var px: Int - if rc >= max(ra, rb) { - px = min(ra, rb) - } else if rc <= min(ra, rb) { - px = max(ra, rb) - } else { - px = ra + rb - rc - } - - px = clampSample(px + (sign > 0 ? contexts[q].c : -contexts[q].c)) - - // Compute prediction error - var errVal: Int - if near == 0 { - errVal = sample - px - } else { - errVal = sample - px - if errVal > 0 { - errVal = (errVal + near) / (2 * near + 1) - } else { - errVal = -((-errVal + near) / (2 * near + 1)) - } - } - - if sign < 0 { errVal = -errVal } - - // Reconstruct for context tracking - var rx: Int - if near == 0 { - rx = clampSample(px + (sign > 0 ? errVal : -errVal)) - } else { - rx = clampSample(px + (sign > 0 ? errVal : -errVal) * (2 * near + 1)) - } - - // Encode error value using Golomb-Rice coding - let k = computeK(contexts[q]) - encodeGolombRice(writer: writer, errVal: errVal, k: k) - - // Update context - updateContext(&contexts[q], errVal: errVal) - - return rx - } - - // MARK: - Run Mode Encoding - - private func isRunMode(gradient1: Int, gradient2: Int, gradient3: Int) -> Bool { - return abs(gradient1) <= near && abs(gradient2) <= near && abs(gradient3) <= near - } - - private func encodeRunLength(writer: JPEGLSBitWriter, runLen: Int, maxRun: Int) { - // ITU-T T.87 A.7.1.2 run-length coding. Emits a '1' bit per full run - // segment (length 1<= increment { - writer.writeBit(1) // Full run segment - remaining -= increment - if rk < jTable.count - 1 { rk += 1 } - // Whole run consumed by full segments and it reaches the end of - // the line — decoder terminates on `runLen == maxRun`. - if remaining == 0 && runLen == maxRun { - return + var row = [Int](repeating: 0, count: width) + for x in 0.. 0 { - writer.writeBits(remaining, count: jVal) - } - return - } - } - } - - private func encodeRunInterruption( - writer: JPEGLSBitWriter, - sample: Int, ra: Int, rb: Int, - contexts: inout [JPEGLSContext], - runIndex: inout Int - ) -> Int { - let sign = ra > rb ? -1 : 1 - let temp = (ra == rb) ? 0 : 1 - let ctxIdx = 365 + temp - - var errVal: Int - if near == 0 { - errVal = sample - rb - } else { - let diff = sample - rb - if diff > 0 { - errVal = (diff + near) / (2 * near + 1) - } else { - errVal = -((-diff + near) / (2 * near + 1)) + rows.append(row) } - } - - if sign < 0 { errVal = -errVal } - - var rx: Int - if near == 0 { - rx = clampSample(rb + (sign > 0 ? errVal : -errVal)) - } else { - rx = clampSample(rb + (sign > 0 ? errVal : -errVal) * (2 * near + 1)) - } - - let k = computeK(contexts[ctxIdx]) - encodeGolombRice(writer: writer, errVal: errVal, k: k) - - updateContext(&contexts[ctxIdx], errVal: errVal) - if runIndex < 31 { runIndex += 1 } - - return rx - } - - // MARK: - Golomb-Rice Encoding - - private func encodeGolombRice(writer: JPEGLSBitWriter, errVal: Int, k: Int) { - // Limited-length Golomb code (ITU-T T.87 A.5.3). Counterpart of - // `JPEGLSDecoder.decodeGolombRice` — see that function for the shared - // contract. `mapped` is bounded by 2·maxVal, so it always fits the - // (qbpp + 1)-bit escape field. - let mapped: Int - if errVal >= 0 { - mapped = 2 * errVal - } else { - mapped = 2 * abs(errVal) - 1 - } - - let escapeThreshold = max(1, limit - qbpp - 1) - let unary = mapped >> k - - if unary < escapeThreshold { - // Normal: `unary` zeros, terminating '1', k-bit remainder. - for _ in 0.. 0 { - writer.writeBits(mapped & ((1 << k) - 1), count: k) - } - } else { - // Overflow: `escapeThreshold` zeros, terminating '1', then the - // full mapped value in a (qbpp + 1)-bit field. - for _ in 0..> 8) & 0xFF)) - output.append(UInt8(length & 0xFF)) - output.append(UInt8(bitsPerSample)) - output.append(UInt8((height >> 8) & 0xFF)) - output.append(UInt8(height & 0xFF)) - output.append(UInt8((width >> 8) & 0xFF)) - output.append(UInt8(width & 0xFF)) - output.append(UInt8(components)) - for i in 0..> 8) & 0xFF)) - output.append(UInt8(length & 0xFF)) - output.append(UInt8(components)) - for i in 0.. [JPEGLSContext] { - let numContexts = 367 - var contexts = [JPEGLSContext](repeating: JPEGLSContext(), count: numContexts) - let initA = max(2, (range + 32) / 64) - for i in 0.. (Int, Int) { - let q1 = quantizeGradient(gradient1) - let q2 = quantizeGradient(gradient2) - let q3 = quantizeGradient(gradient3) - - var sign = 1 - var qIndex: Int - - if q1 < 0 || (q1 == 0 && q2 < 0) || (q1 == 0 && q2 == 0 && q3 < 0) { - sign = -1 - qIndex = contextIndex(q1: -q1, q2: -q2, q3: -q3) - } else { - qIndex = contextIndex(q1: q1, q2: q2, q3: q3) - } - - return (min(qIndex, 364), sign) - } - - private func quantizeGradient(_ gradient: Int) -> Int { - let absG = abs(gradient) - if absG <= near { return 0 } - if absG <= preset.t1 { return gradient > 0 ? 1 : -1 } - if absG <= preset.t2 { return gradient > 0 ? 2 : -2 } - if absG <= preset.t3 { return gradient > 0 ? 3 : -3 } - return gradient > 0 ? 4 : -4 - } - - private func contextIndex(q1: Int, q2: Int, q3: Int) -> Int { - return q1 * 81 + (q2 + 4) * 9 + (q3 + 4) - } - - private func computeK(_ ctx: JPEGLSContext) -> Int { - var k = 0 - var nTimesA = ctx.n - while nTimesA < ctx.a { - nTimesA <<= 1 - k += 1 - } - return min(k, bpp - 2) - } - - private func updateContext(_ ctx: inout JPEGLSContext, errVal: Int) { - ctx.a += abs(errVal) - ctx.b += errVal - - if ctx.n == preset.reset { - ctx.a = max(1, ctx.a >> 1) - ctx.b >>= 1 - ctx.n >>= 1 - } - ctx.n += 1 - - if ctx.b <= -ctx.n { - ctx.b = max(ctx.b + ctx.n, 1 - ctx.n) - if ctx.c > -128 { ctx.c -= 1 } - } else if ctx.b > 0 { - ctx.b = min(ctx.b - ctx.n, 0) - if ctx.c < 127 { ctx.c += 1 } - } - } - - // MARK: - Helpers - - private func clampSample(_ value: Int) -> Int { - return max(0, min(value, maxVal)) - } - - private static func ceilLog2(_ value: Int) -> Int { - guard value > 0 else { return 0 } - var v = value - 1 - var bits = 0 - while v > 0 { - v >>= 1 - bits += 1 - } - return max(1, bits) - } - - private func readPixel(_ data: Data, index: Int, bytesPerSample: Int) -> Int { - if bytesPerSample == 1 { - guard index < data.count else { return 0 } - return Int(data[index]) - } else { - let byteIndex = index * 2 - guard byteIndex + 1 < data.count else { return 0 } - return Int(data[byteIndex]) | (Int(data[byteIndex + 1]) << 8) + return rows + } + + switch samples { + case 1: + return try MultiComponentImageData.grayscale(pixels: plane(component: 0), bitsPerSample: bitsPerSample) + case 3: + return try MultiComponentImageData.rgb( + redPixels: plane(component: 0), + greenPixels: plane(component: 1), + bluePixels: plane(component: 2), + bitsPerSample: bitsPerSample + ) + default: + throw DICOMError.parsingFailed("JPEG-LS encode: unsupported samplesPerPixel \(samples)") } } } diff --git a/Sources/DICOMCore/JXLCodec.swift b/Sources/DICOMCore/JXLCodec.swift new file mode 100644 index 00000000..3ccab1a0 --- /dev/null +++ b/Sources/DICOMCore/JXLCodec.swift @@ -0,0 +1,61 @@ +import Foundation +import JXLSwift + +/// Bench adapter for the JXLSwift pure-Swift JPEG XL codec. +/// +/// Encodes **lossless** JPEG XL (Modular mode, distance 0) and decodes it back, +/// so the DICOMStudio codec bench can compare JXLSwift against other JPEG XL +/// implementations (e.g. libjxl `djxl`). Lossless-only by design: JXLSwift's +/// lossy VarDCT path is only partially implemented, and the bench's axis is +/// bit-exact round-trip + peer decode. +/// +/// Pixel bridging: JXLSwift's `ImageFrame.data` is channel-interleaved `[UInt8]`, +/// row-major, 16-bit samples little-endian — matching DICOM little-endian +/// storage. JXLSwift handles only unsigned samples, so signed frames are +/// rejected here rather than silently mis-encoded. +public struct JXLCodec: Sendable { + public init() {} + + // MARK: - Encoding + + public func encodeFrame(_ frameData: Data, descriptor: PixelDataDescriptor, + frameIndex: Int, configuration: CompressionConfiguration) throws -> Data { + let spp = descriptor.samplesPerPixel + guard spp == 1 || spp == 3 else { + throw DICOMError.parsingFailed("JXLSwift: unsupported samplesPerPixel \(spp)") + } + guard !descriptor.isSigned else { + throw DICOMError.parsingFailed("JXLSwift: signed pixel data not supported (apply a rescale offset first)") + } + guard descriptor.columns > 0, descriptor.rows > 0 else { + throw DICOMError.parsingFailed("JXLSwift: invalid frame dimensions") + } + let pixelType: PixelType = descriptor.bitsAllocated <= 8 ? .uint8 : .uint16 + let colorSpace: ColorSpace = spp == 1 ? .grayscale : .sRGB + + var frame = ImageFrame(width: descriptor.columns, height: descriptor.rows, + channels: spp, pixelType: pixelType, + colorSpace: colorSpace, alphaChannels: 0) + frame.data = interleavedFrameBytes(from: frameData, descriptor: descriptor) + + do { + return try JXLEncoder(options: .lossless).encode(frame).data + } catch { + throw DICOMError.parsingFailed("JXLSwift encode failed: \(error)") + } + } + + // MARK: - Decoding + + public func decodeFrame(_ frameData: Data, descriptor: PixelDataDescriptor, frameIndex: Int) throws -> Data { + guard !frameData.isEmpty else { + throw DICOMError.parsingFailed("Empty JPEG XL data") + } + do { + let frame = try JXLDecoder().decode(frameData) + return dicomFrameBytes(fromInterleaved: frame.data, descriptor: descriptor) + } catch { + throw DICOMError.parsingFailed("JXLSwift decode failed: \(error)") + } + } +} diff --git a/Sources/DICOMCore/PixelInterleaveSupport.swift b/Sources/DICOMCore/PixelInterleaveSupport.swift new file mode 100644 index 00000000..cf3359d1 --- /dev/null +++ b/Sources/DICOMCore/PixelInterleaveSupport.swift @@ -0,0 +1,58 @@ +import Foundation + +// Helpers for bridging DICOM frame buffers to/from the channel-interleaved, +// little-endian pixel layout that the JLISwift / JXLSwift codecs expect. +// +// A DICOM frame for a multi-component image may be stored either +// sample-interleaved (Planar Configuration 0: R,G,B,R,G,B,…) or planar +// (Planar Configuration 1: R…R,G…G,B…B). Both JLISwift's `JLIImage.data` and +// JXLSwift's `ImageFrame.data` are channel-interleaved `[UInt8]`, row-major, +// with 16-bit samples little-endian — which matches DICOM's little-endian +// sample storage, so only the planar↔interleaved reshuffle is ever needed. + +/// DICOM frame (per `descriptor`) → channel-interleaved, row-major bytes. +/// A single-component frame, or one already stored interleaved, passes through +/// unchanged. +func interleavedFrameBytes(from frame: Data, descriptor: PixelDataDescriptor) -> [UInt8] { + let spp = descriptor.samplesPerPixel + if spp == 1 || descriptor.planarConfiguration == 0 { + return [UInt8](frame) + } + let bps = descriptor.bitsAllocated <= 8 ? 1 : 2 + let pixels = descriptor.rows * descriptor.columns + let src = [UInt8](frame) + var out = [UInt8](repeating: 0, count: pixels * spp * bps) + for p in 0.. Data { + let spp = descriptor.samplesPerPixel + if spp == 1 || descriptor.planarConfiguration == 0 { + return Data(data) + } + let bps = descriptor.bitsAllocated <= 8 ? 1 : 2 + let pixels = descriptor.rows * descriptor.columns + var out = [UInt8](repeating: 0, count: pixels * spp * bps) + for p in 0..= 1) - #expect(params.t2 >= params.t1) - #expect(params.t3 >= params.t2) - #expect(params.reset == 64) - } - - @Test("Default parameters for 12-bit lossless") - func testDefaultParams12Bit() { - let params = JPEGLSPresetParameters.defaultParameters(maxVal: 4095, near: 0) - - #expect(params.maxVal == 4095) - #expect(params.t1 >= 1) - #expect(params.t2 >= params.t1) - #expect(params.t3 >= params.t2) - #expect(params.reset == 64) - } - - @Test("Default parameters for near-lossless") - func testDefaultParamsNearLossless() { - let params = JPEGLSPresetParameters.defaultParameters(maxVal: 255, near: 3) - - #expect(params.maxVal == 255) - #expect(params.t1 >= 4) // near + 1 - #expect(params.t2 >= params.t1) - #expect(params.t3 >= params.t2) - } -} +// NOTE: The "JPEG-LS Preset Parameters Tests" suite was removed when the +// in-tree JPEG-LS implementation (and its internal JPEGLSPresetParameters +// type) was retired in favour of the JLSwift `JPEGLS` package. Preset-parameter +// behaviour is now owned and tested by JLSwift; DICOMCore validates JPEG-LS only +// through the public JPEGLSCodec encode/decode round-trips above. diff --git a/Tests/DICOMCoreTests/MultiCodecBenchAdaptersTests.swift b/Tests/DICOMCoreTests/MultiCodecBenchAdaptersTests.swift new file mode 100644 index 00000000..a17a7523 --- /dev/null +++ b/Tests/DICOMCoreTests/MultiCodecBenchAdaptersTests.swift @@ -0,0 +1,148 @@ +import Testing +import Foundation +@testable import DICOMCore + +/// Round-trip + interop verification for the multi-format codec-bench adapters +/// (JLISwift JPEG, JXLSwift JPEG XL) and their CLI peers (libjpeg-turbo `djpeg`, +/// libjxl `djxl`). The bench's entire premise is bit-exact lossless round-trips, +/// so these guard against silent layout/endianness/precision regressions. +@Suite("Multi-codec bench adapters") +struct MultiCodecBenchAdaptersTests { + + // MARK: - Fixtures + + private func descriptor(_ w: Int, _ h: Int, bitsAllocated: Int, bitsStored: Int, + spp: Int) -> PixelDataDescriptor { + PixelDataDescriptor( + rows: h, columns: w, + bitsAllocated: bitsAllocated, bitsStored: bitsStored, highBit: bitsStored - 1, + isSigned: false, samplesPerPixel: spp, + photometricInterpretation: spp == 1 ? .monochrome2 : .rgb, + planarConfiguration: 0) + } + + /// A structured (non-constant) frame so prediction/entropy paths are exercised. + private func frame(_ w: Int, _ h: Int, bitsStored: Int, spp: Int, bytesPerSample: Int) -> Data { + let maxVal = (1 << bitsStored) - 1 + var data = Data(count: w * h * spp * bytesPerSample) + data.withUnsafeMutableBytes { raw in + let p = raw.bindMemory(to: UInt8.self) + var i = 0 + for y in 0..> 8) & 0xFF); i += 2 + } + } + } + } + } + return data + } + + // MARK: - JLISwift (JPEG, lossless SOF3) + + @Test("JLISwift lossless round-trip — 8-bit grayscale") + func jliGray8() throws { + let d = descriptor(40, 32, bitsAllocated: 8, bitsStored: 8, spp: 1) + let original = frame(40, 32, bitsStored: 8, spp: 1, bytesPerSample: 1) + let encoded = try JLICodec().encodeFrame(original, descriptor: d, frameIndex: 0, configuration: .lossless) + let decoded = try JLICodec().decodeFrame(encoded, descriptor: d, frameIndex: 0) + #expect(decoded == original) + } + + @Test("JLISwift lossless round-trip — 16-bit (12-bit stored) grayscale") + func jliGray16() throws { + let d = descriptor(40, 32, bitsAllocated: 16, bitsStored: 12, spp: 1) + let original = frame(40, 32, bitsStored: 12, spp: 1, bytesPerSample: 2) + let encoded = try JLICodec().encodeFrame(original, descriptor: d, frameIndex: 0, configuration: .lossless) + let decoded = try JLICodec().decodeFrame(encoded, descriptor: d, frameIndex: 0) + #expect(decoded == original) + } + + @Test("JLISwift lossless round-trip — 8-bit RGB") + func jliRGB8() throws { + let d = descriptor(24, 24, bitsAllocated: 8, bitsStored: 8, spp: 3) + let original = frame(24, 24, bitsStored: 8, spp: 3, bytesPerSample: 1) + let encoded = try JLICodec().encodeFrame(original, descriptor: d, frameIndex: 0, configuration: .lossless) + let decoded = try JLICodec().decodeFrame(encoded, descriptor: d, frameIndex: 0) + #expect(decoded == original) + } + + // MARK: - JXLSwift (JPEG XL, lossless Modular) + + @Test("JXLSwift lossless round-trip — 8-bit grayscale") + func jxlGray8() throws { + let d = descriptor(40, 32, bitsAllocated: 8, bitsStored: 8, spp: 1) + let original = frame(40, 32, bitsStored: 8, spp: 1, bytesPerSample: 1) + let encoded = try JXLCodec().encodeFrame(original, descriptor: d, frameIndex: 0, configuration: .lossless) + let decoded = try JXLCodec().decodeFrame(encoded, descriptor: d, frameIndex: 0) + #expect(decoded == original) + } + + @Test("JXLSwift lossless round-trip — 16-bit (12-bit stored) grayscale") + func jxlGray16() throws { + let d = descriptor(40, 32, bitsAllocated: 16, bitsStored: 12, spp: 1) + let original = frame(40, 32, bitsStored: 12, spp: 1, bytesPerSample: 2) + let encoded = try JXLCodec().encodeFrame(original, descriptor: d, frameIndex: 0, configuration: .lossless) + let decoded = try JXLCodec().decodeFrame(encoded, descriptor: d, frameIndex: 0) + #expect(decoded == original) + } + + @Test("JXLSwift lossless round-trip — 8-bit RGB") + func jxlRGB8() throws { + let d = descriptor(24, 24, bitsAllocated: 8, bitsStored: 8, spp: 3) + let original = frame(24, 24, bitsStored: 8, spp: 3, bytesPerSample: 1) + let encoded = try JXLCodec().encodeFrame(original, descriptor: d, frameIndex: 0, configuration: .lossless) + let decoded = try JXLCodec().decodeFrame(encoded, descriptor: d, frameIndex: 0) + #expect(decoded == original) + } + + // MARK: - CLI peer interop (PNM parse + endianness) + + #if os(macOS) + @Test("djpeg decodes JLISwift's lossless JPEG bit-exactly — 8-bit") + func djpegInterop8() throws { + guard DjpegCLICodec.binaryPath != nil else { return } // peer not installed + let d = descriptor(40, 32, bitsAllocated: 8, bitsStored: 8, spp: 1) + let original = frame(40, 32, bitsStored: 8, spp: 1, bytesPerSample: 1) + let encoded = try JLICodec().encodeFrame(original, descriptor: d, frameIndex: 0, configuration: .lossless) + let decoded = try DjpegCLICodec().decodeFrame(encoded, descriptor: d) + #expect(decoded == original) + } + + @Test("djpeg decodes JLISwift's lossless JPEG bit-exactly — 16-bit") + func djpegInterop16() throws { + guard DjpegCLICodec.binaryPath != nil else { return } + let d = descriptor(40, 32, bitsAllocated: 16, bitsStored: 12, spp: 1) + let original = frame(40, 32, bitsStored: 12, spp: 1, bytesPerSample: 2) + let encoded = try JLICodec().encodeFrame(original, descriptor: d, frameIndex: 0, configuration: .lossless) + let decoded = try DjpegCLICodec().decodeFrame(encoded, descriptor: d) + #expect(decoded == original) + } + + @Test("djxl decodes JXLSwift's lossless JXL bit-exactly — 8-bit") + func djxlInterop8() throws { + guard DjxlCLICodec.binaryPath != nil else { return } + let d = descriptor(40, 32, bitsAllocated: 8, bitsStored: 8, spp: 1) + let original = frame(40, 32, bitsStored: 8, spp: 1, bytesPerSample: 1) + let encoded = try JXLCodec().encodeFrame(original, descriptor: d, frameIndex: 0, configuration: .lossless) + let decoded = try DjxlCLICodec().decodeFrame(encoded, descriptor: d) + #expect(decoded == original) + } + + @Test("djxl decodes JXLSwift's lossless JXL bit-exactly — 16-bit") + func djxlInterop16() throws { + guard DjxlCLICodec.binaryPath != nil else { return } + let d = descriptor(40, 32, bitsAllocated: 16, bitsStored: 12, spp: 1) + let original = frame(40, 32, bitsStored: 12, spp: 1, bytesPerSample: 2) + let encoded = try JXLCodec().encodeFrame(original, descriptor: d, frameIndex: 0, configuration: .lossless) + let decoded = try DjxlCLICodec().decodeFrame(encoded, descriptor: d) + #expect(decoded == original) + } + #endif +} From 2f1d6f669a7abf0de05549816f24ae1cdb09142a Mon Sep 17 00:00:00 2001 From: raster Date: Tue, 2 Jun 2026 11:08:53 +0530 Subject: [PATCH 2/2] feat(dicomstudio): multi-format codec comparison in the J2K Test Bench - Generalize the bench with a CODEC FAMILY selector (JPEG 2000 / JPEG / JPEG-LS / JPEG XL); each family compares its reference Swift codec against same-family peers, mirroring J2KSwift vs Kakadu/Grok/OpenJPEG per format - Per-format reference-encode dispatch + timedEncode in the service; decode and full-resolution cases for JLISwift / JLSwift / JXLSwift + djpeg/djxl peers - Format-scoped installed/active codec lists and reference-aware analytics; format-filtered chips, generalized reference labeling, and J2K-only mode pickers in the view (existing J2K run history preserved, Codable-safe) - Headless comparison test driving the real J2KTestBenchService over SampleStudies frames (all four families bit-exact) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Models/J2KTestBenchModels.swift | 140 +++++++++++++--- .../Services/J2KTestBenchService.swift | 150 ++++++++++++++++-- .../ViewModels/J2KTestBenchViewModel.swift | 29 +++- .../DICOMStudio/Views/J2KTestBenchView.swift | 60 ++++--- .../MultiCodecComparisonTableTests.swift | 124 +++++++++++++++ 5 files changed, 443 insertions(+), 60 deletions(-) create mode 100644 Tests/DICOMStudioTests/MultiCodecComparisonTableTests.swift diff --git a/Sources/DICOMStudio/Models/J2KTestBenchModels.swift b/Sources/DICOMStudio/Models/J2KTestBenchModels.swift index 406c2be3..01a72c72 100644 --- a/Sources/DICOMStudio/Models/J2KTestBenchModels.swift +++ b/Sources/DICOMStudio/Models/J2KTestBenchModels.swift @@ -22,51 +22,120 @@ public struct J2KBenchError: Error, Sendable { // MARK: - Codec +/// A compression family the bench can exercise. Each format names one +/// reference Swift codec (which produces the codestream) plus the same-family +/// peer codecs that decode it — mirroring how J2KSwift is compared against +/// Kakadu/Grok/OpenJPEG, replicated per format. +public enum J2KBenchFormat: String, CaseIterable, Identifiable, Codable, Sendable { + case jpeg2000 = "JPEG 2000" + case jpeg = "JPEG" + case jpegLS = "JPEG-LS" + case jpegXL = "JPEG XL" + + public var id: String { rawValue } + + public var systemImage: String { + switch self { + case .jpeg2000: return "square.stack.3d.up" + case .jpeg: return "photo" + case .jpegLS: return "waveform" + case .jpegXL: return "sparkles" + } + } + + /// The Swift codec that produces this format's reference codestream. + public var referenceCodec: J2KBenchCodec { + switch self { + case .jpeg2000: return .j2kSwift + case .jpeg: return .jliSwift + case .jpegLS: return .jlSwift + case .jpegXL: return .jxlSwift + } + } + + /// All codecs in this format's bench, reference first then same-family peers. + public var codecs: [J2KBenchCodec] { + switch self { + case .jpeg2000: return [.j2kSwift, .openJPEG, .kakadu, .grok] + case .jpeg: return [.jliSwift, .djpeg] + case .jpegLS: return [.jlSwift] + case .jpegXL: return [.jxlSwift, .djxl] + } + } +} + /// A codec participating in a test-bench run. public enum J2KBenchCodec: String, CaseIterable, Identifiable, Codable, Sendable { + // JPEG 2000 family case j2kSwift = "J2KSwift" case openJPEG = "OpenJPEG" case kakadu = "Kakadu" case grok = "Grok" + // JPEG family + case jliSwift = "JLISwift" + case djpeg = "djpeg" + // JPEG-LS family + case jlSwift = "JLSwift" + // JPEG XL family + case jxlSwift = "JXLSwift" + case djxl = "djxl" public var id: String { rawValue } - /// Only J2KSwift produces the reference codestream; the others are - /// decode-only in the bench (no Swift encode API is wired for them). - public var encodes: Bool { self == .j2kSwift } + /// The compression family this codec belongs to. + public var format: J2KBenchFormat { + switch self { + case .j2kSwift, .openJPEG, .kakadu, .grok: return .jpeg2000 + case .jliSwift, .djpeg: return .jpeg + case .jlSwift: return .jpegLS + case .jxlSwift, .djxl: return .jpegXL + } + } + + /// True for the reference codec of each format — the one that produces the + /// codestream its peers decode. Peers are decode-only in the bench. + public var encodes: Bool { self == format.referenceCodec } /// SF Symbol shown beside the codec in the results grid. public var systemImage: String { switch self { - case .j2kSwift: return "swift" - case .openJPEG: return "shippingbox" - case .kakadu, .grok: return "terminal" + case .j2kSwift, .jliSwift, .jlSwift, .jxlSwift: return "swift" + case .openJPEG: return "shippingbox" + case .kakadu, .grok, .djpeg, .djxl: return "terminal" } } } // MARK: - Transfer syntax -/// A JPEG 2000 / HTJ2K transfer syntax the bench can exercise. +/// A transfer syntax / mode the bench can exercise, tagged with its codec +/// family. JPEG 2000 has several; the other formats currently expose a single +/// lossless mode (the bench's axis is bit-exact round-trip + peer decode). public struct J2KBenchSyntax: Identifiable, Hashable, Codable, Sendable { public let uid: String public let shortName: String + /// The codec family this syntax belongs to. + public let format: J2KBenchFormat + /// Lossless syntaxes must reconstruct bit-exact; lossy ones are scored + /// against a PSNR threshold. + public let isLossless: Bool public var id: String { uid } - public init(uid: String, shortName: String) { + public init(uid: String, shortName: String, + format: J2KBenchFormat = .jpeg2000, isLossless: Bool? = nil) { self.uid = uid self.shortName = shortName + self.format = format + // JPEG 2000 keeps the original UID-suffix heuristic when not specified. + self.isLossless = isLossless + ?? (!uid.hasSuffix(".91") && !uid.hasSuffix(".93") && !uid.hasSuffix(".203")) } - /// Lossless syntaxes must reconstruct bit-exact; lossy ones are scored - /// against a PSNR threshold. - public var isLossless: Bool { - !uid.hasSuffix(".91") && !uid.hasSuffix(".93") && !uid.hasSuffix(".203") - } - - /// The seven JPEG 2000 family transfer syntaxes DICOMKit can encode. + /// Every syntax across all formats. JPEG XL is bench-only (synthetic id, no + /// DICOM transfer syntax); the rest use their real DICOM UIDs. public static let all: [J2KBenchSyntax] = [ + // JPEG 2000 / HTJ2K J2KBenchSyntax(uid: "1.2.840.10008.1.2.4.90", shortName: "J2K Lossless"), J2KBenchSyntax(uid: "1.2.840.10008.1.2.4.91", shortName: "J2K Lossy"), J2KBenchSyntax(uid: "1.2.840.10008.1.2.4.92", shortName: "J2K Part 2 Lossless"), @@ -74,8 +143,22 @@ public struct J2KBenchSyntax: Identifiable, Hashable, Codable, Sendable { J2KBenchSyntax(uid: "1.2.840.10008.1.2.4.201", shortName: "HTJ2K Lossless"), J2KBenchSyntax(uid: "1.2.840.10008.1.2.4.202", shortName: "HTJ2K RPCL Lossless"), J2KBenchSyntax(uid: "1.2.840.10008.1.2.4.203", shortName: "HTJ2K Lossy"), + // JPEG (JLISwift) — lossless SOF3 (DICOM JPEG Lossless, First-Order Prediction) + J2KBenchSyntax(uid: "1.2.840.10008.1.2.4.70", shortName: "JPEG Lossless", + format: .jpeg, isLossless: true), + // JPEG-LS (JLSwift) + J2KBenchSyntax(uid: "1.2.840.10008.1.2.4.80", shortName: "JPEG-LS Lossless", + format: .jpegLS, isLossless: true), + // JPEG XL (JXLSwift) — bench-only; no DICOM transfer syntax assigned + J2KBenchSyntax(uid: "bench.jpegxl.lossless", shortName: "JPEG XL Lossless", + format: .jpegXL, isLossless: true), ] + /// Syntaxes belonging to a given format, in canonical order. + public static func all(for format: J2KBenchFormat) -> [J2KBenchSyntax] { + all.filter { $0.format == format } + } + public static func named(_ uid: String) -> J2KBenchSyntax? { all.first { $0.uid == uid } } @@ -253,14 +336,20 @@ public struct J2KTestRun: Identifiable, Codable, Sendable { /// Configuration for a test-bench run. public struct J2KTestPlan: Sendable { + /// The compression family being benchmarked. + public var format: J2KBenchFormat /// Transfer syntaxes to exercise (by UID). public var selectedSyntaxUIDs: Set public var includeOpenJPEG: Bool public var includeKakadu: Bool public var includeGrok: Bool - /// Which J2KSwift encode API produces the reference codestream. + /// JPEG peer (libjpeg-turbo djpeg) — decodes the JLISwift codestream. + public var includeDjpeg: Bool + /// JPEG XL peer (libjxl djxl) — decodes the JXLSwift codestream. + public var includeDjxl: Bool + /// Which J2KSwift encode API produces the reference codestream (JPEG 2000 only). public var encodeMode: J2KSwiftEncodeMode - /// Which J2KSwift decode API the J2KSwift row exercises. + /// Which J2KSwift decode API the J2KSwift row exercises (JPEG 2000 only). public var decodeMode: J2KSwiftDecodeMode /// Untimed warmups before timing (cross-host bench default: 2). public var warmups: Int @@ -269,20 +358,29 @@ public struct J2KTestPlan: Sendable { /// Minimum PSNR (dB) for a lossy cell to pass. public var lossyPSNRThresholdDb: Double - public init(selectedSyntaxUIDs: Set = ["1.2.840.10008.1.2.4.90", - "1.2.840.10008.1.2.4.201"], + public init(format: J2KBenchFormat = .jpeg2000, + selectedSyntaxUIDs: Set = ["1.2.840.10008.1.2.4.90", + "1.2.840.10008.1.2.4.201", + "1.2.840.10008.1.2.4.70", + "1.2.840.10008.1.2.4.80", + "bench.jpegxl.lossless"], includeOpenJPEG: Bool = true, includeKakadu: Bool = true, includeGrok: Bool = true, + includeDjpeg: Bool = true, + includeDjxl: Bool = true, encodeMode: J2KSwiftEncodeMode = .cpu, decodeMode: J2KSwiftDecodeMode = .cpu, warmups: Int = 2, timedRuns: Int = 7, lossyPSNRThresholdDb: Double = 40.0) { + self.format = format self.selectedSyntaxUIDs = selectedSyntaxUIDs self.includeOpenJPEG = includeOpenJPEG self.includeKakadu = includeKakadu self.includeGrok = includeGrok + self.includeDjpeg = includeDjpeg + self.includeDjxl = includeDjxl self.encodeMode = encodeMode self.decodeMode = decodeMode self.warmups = warmups @@ -290,8 +388,8 @@ public struct J2KTestPlan: Sendable { self.lossyPSNRThresholdDb = lossyPSNRThresholdDb } - /// Selected syntaxes in canonical order. + /// Selected syntaxes for the active format, in canonical order. public var syntaxes: [J2KBenchSyntax] { - J2KBenchSyntax.all.filter { selectedSyntaxUIDs.contains($0.uid) } + J2KBenchSyntax.all(for: format).filter { selectedSyntaxUIDs.contains($0.uid) } } } diff --git a/Sources/DICOMStudio/Services/J2KTestBenchService.swift b/Sources/DICOMStudio/Services/J2KTestBenchService.swift index e2482cd0..3615b1f3 100644 --- a/Sources/DICOMStudio/Services/J2KTestBenchService.swift +++ b/Sources/DICOMStudio/Services/J2KTestBenchService.swift @@ -17,8 +17,10 @@ public enum J2KTestBenchService { // MARK: - Encode - /// Encodes a frame with J2KSwift to produce the reference codestream that - /// every codec in the matrix then decodes. + /// Encodes a frame with the syntax's reference codec to produce the + /// reference codestream that every codec in that format's matrix then + /// decodes. JPEG 2000 uses J2KSwift's mode-aware bench encoder; the other + /// formats use their pure-Swift codec's synchronous `encodeFrame`. public static func encodeReference( frame: Data, descriptor: PixelDataDescriptor, @@ -33,19 +35,40 @@ public enum J2KTestBenchService { progressive: false, preferLossless: syntax.isLossless ) - let result = J2KSwiftCodec.benchEncode( - frame, - descriptor: descriptor, - transferSyntaxUID: syntax.uid, - configuration: configuration, - mode: mode, - warmups: max(0, warmups), - runs: max(1, runs) - ) - guard let data = result.data, !result.samples.isEmpty else { - return .failure(J2KBenchError(result.error ?? "encode produced no output")) + switch syntax.format { + case .jpeg2000: + let result = J2KSwiftCodec.benchEncode( + frame, + descriptor: descriptor, + transferSyntaxUID: syntax.uid, + configuration: configuration, + mode: mode, + warmups: max(0, warmups), + runs: max(1, runs) + ) + guard let data = result.data, !result.samples.isEmpty else { + return .failure(J2KBenchError(result.error ?? "encode produced no output")) + } + return .success((data, median(result.samples))) + + case .jpeg: + return timedEncode(warmups: warmups, runs: runs) { + try JLICodec().encodeFrame(frame, descriptor: descriptor, + frameIndex: 0, configuration: configuration) + }.map { (codestream: $0.data, encodeMs: $0.ms) } + + case .jpegLS: + return timedEncode(warmups: warmups, runs: runs) { + try JPEGLSCodec().encodeFrame(frame, descriptor: descriptor, + frameIndex: 0, configuration: configuration) + }.map { (codestream: $0.data, encodeMs: $0.ms) } + + case .jpegXL: + return timedEncode(warmups: warmups, runs: runs) { + try JXLCodec().encodeFrame(frame, descriptor: descriptor, + frameIndex: 0, configuration: configuration) + }.map { (codestream: $0.data, encodeMs: $0.ms) } } - return .success((data, median(result.samples))) } // MARK: - Decode + score @@ -119,6 +142,60 @@ public enum J2KTestBenchService { #else return (nil, nil, .skipped("Grok unavailable on this platform"), nil) #endif + + case .jliSwift: + switch timedDecode(warmups: warmups, runs: runs, { + try JLICodec().decodeFrame(codestream, descriptor: descriptor, frameIndex: 0) + }) { + case .failure(let message): return (nil, nil, .error(message.message), nil) + case .success(let timed): decoded = timed.data; decodeMs = timed.ms + } + + case .djpeg: + #if os(macOS) + guard DjpegCLICodec.binaryPath != nil else { + return (nil, nil, .skipped("djpeg CLI not installed"), nil) + } + switch timedDecode(warmups: warmups, runs: runs, { + try DjpegCLICodec().decodeFrame(codestream, descriptor: descriptor) + }) { + case .failure(let message): return (nil, nil, .error(message.message), nil) + case .success(let timed): decoded = timed.data; decodeMs = timed.ms + } + #else + return (nil, nil, .skipped("djpeg unavailable on this platform"), nil) + #endif + + case .jlSwift: + switch timedDecode(warmups: warmups, runs: runs, { + try JPEGLSCodec().decodeFrame(codestream, descriptor: descriptor, frameIndex: 0) + }) { + case .failure(let message): return (nil, nil, .error(message.message), nil) + case .success(let timed): decoded = timed.data; decodeMs = timed.ms + } + + case .jxlSwift: + switch timedDecode(warmups: warmups, runs: runs, { + try JXLCodec().decodeFrame(codestream, descriptor: descriptor, frameIndex: 0) + }) { + case .failure(let message): return (nil, nil, .error(message.message), nil) + case .success(let timed): decoded = timed.data; decodeMs = timed.ms + } + + case .djxl: + #if os(macOS) + guard DjxlCLICodec.binaryPath != nil else { + return (nil, nil, .skipped("djxl CLI not installed"), nil) + } + switch timedDecode(warmups: warmups, runs: runs, { + try DjxlCLICodec().decodeFrame(codestream, descriptor: descriptor) + }) { + case .failure(let message): return (nil, nil, .error(message.message), nil) + case .success(let timed): decoded = timed.data; decodeMs = timed.ms + } + #else + return (nil, nil, .skipped("djxl unavailable on this platform"), nil) + #endif } let scored = score(decoded: decoded, original: original, @@ -197,6 +274,33 @@ public enum J2KTestBenchService { } } + /// Encode counterpart of `timedDecode`: warmups + median-timed encode runs, + /// returning the last produced bitstream and the median wall-clock time. + /// Used for the non-J2KSwift reference encoders (JLISwift / JLSwift / JXLSwift), + /// whose `encodeFrame` is synchronous (J2KSwift has its own benchEncode). + private static func timedEncode( + warmups: Int, + runs: Int, + _ encode: () throws -> Data + ) -> Result<(data: Data, ms: Double), J2KBenchError> { + do { + var last = Data() + for _ in 0.. Int { guard a.count == b.count else { return max(a.count, b.count) } var diff = 0 @@ -275,6 +379,24 @@ public enum J2KTestBenchService { #else return nil #endif + case .jliSwift: + return try? JLICodec().decodeFrame(codestream, descriptor: descriptor, frameIndex: 0) + case .jlSwift: + return try? JPEGLSCodec().decodeFrame(codestream, descriptor: descriptor, frameIndex: 0) + case .jxlSwift: + return try? JXLCodec().decodeFrame(codestream, descriptor: descriptor, frameIndex: 0) + case .djpeg: + #if os(macOS) + return try? DjpegCLICodec().decodeFrame(codestream, descriptor: descriptor) + #else + return nil + #endif + case .djxl: + #if os(macOS) + return try? DjxlCLICodec().decodeFrame(codestream, descriptor: descriptor) + #else + return nil + #endif } } } diff --git a/Sources/DICOMStudio/ViewModels/J2KTestBenchViewModel.swift b/Sources/DICOMStudio/ViewModels/J2KTestBenchViewModel.swift index 8bf5dd72..83882bce 100644 --- a/Sources/DICOMStudio/ViewModels/J2KTestBenchViewModel.swift +++ b/Sources/DICOMStudio/ViewModels/J2KTestBenchViewModel.swift @@ -133,7 +133,9 @@ public final class J2KTestBenchViewModel { // MARK: - Codecs - /// Codecs available on this machine — J2KSwift always, the rest if found. + /// Codecs available on this machine. The pure-Swift reference codecs + /// (J2KSwift, JLISwift, JLSwift, JXLSwift) are always available; the + /// comparison peers depend on a bundled library or an installed CLI binary. public var installedCodecs: [J2KBenchCodec] { var codecs: [J2KBenchCodec] = [.j2kSwift] #if canImport(COpenJPEG) && os(macOS) @@ -143,17 +145,27 @@ public final class J2KTestBenchViewModel { if KakaduCLICodec.binaryPath != nil { codecs.append(.kakadu) } if GrokCLICodec.binaryPath != nil { codecs.append(.grok) } #endif + // JPEG / JPEG-LS / JPEG XL reference codecs are pure-Swift, in-process. + codecs.append(contentsOf: [.jliSwift, .jlSwift, .jxlSwift]) + #if os(macOS) + if DjpegCLICodec.binaryPath != nil { codecs.append(.djpeg) } + if DjxlCLICodec.binaryPath != nil { codecs.append(.djxl) } + #endif return codecs } - /// Installed codecs the current plan has enabled. + /// Codecs in the active format that are installed and enabled. Each format's + /// reference codec is always on; the same-family peers are toggleable. public var activeCodecs: [J2KBenchCodec] { - installedCodecs.filter { codec in + plan.format.codecs.filter { codec in + guard installedCodecs.contains(codec) else { return false } switch codec { - case .j2kSwift: return true + case .j2kSwift, .jliSwift, .jlSwift, .jxlSwift: return true // reference case .openJPEG: return plan.includeOpenJPEG case .kakadu: return plan.includeKakadu case .grok: return plan.includeGrok + case .djpeg: return plan.includeDjpeg + case .djxl: return plan.includeDjxl } } } @@ -290,7 +302,10 @@ public final class J2KTestBenchViewModel { for standing in speedStandings { if let median = standing.medianDecodeMs { medians[standing.codec] = median } } - guard let reference = medians[.j2kSwift], reference > 0 else { return [] } + // Anchor to the displayed run's reference codec (J2KSwift for JPEG 2000, + // JLISwift/JLSwift/JXLSwift for the other formats). + let referenceCodec = displayedCells.first?.codec.format.referenceCodec ?? .j2kSwift + guard let reference = medians[referenceCodec], reference > 0 else { return [] } return J2KBenchCodec.allCases.compactMap { codec in guard let median = medians[codec] else { return nil } return RelativeSpeed(codec: codec.rawValue, ratio: median / reference) @@ -300,7 +315,9 @@ public final class J2KTestBenchViewModel { /// Median compression ratio achieved per transfer syntax. public var compressionBySyntax: [CompressionStat] { var ratios: [String: [Double]] = [:] - for cell in displayedCells where cell.codec == .j2kSwift { + // Compression is a property of the reference codestream each format's + // encoder produces, so attribute it to the reference codec's cells. + for cell in displayedCells where cell.codec.encodes { if let ratio = cell.compressionRatio { ratios[cell.syntaxName, default: []].append(ratio) } diff --git a/Sources/DICOMStudio/Views/J2KTestBenchView.swift b/Sources/DICOMStudio/Views/J2KTestBenchView.swift index 0dbe85f8..ee7b39e2 100644 --- a/Sources/DICOMStudio/Views/J2KTestBenchView.swift +++ b/Sources/DICOMStudio/Views/J2KTestBenchView.swift @@ -250,10 +250,24 @@ public struct J2KTestBenchView: View { GroupBox { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 6) { - sectionCaption("Transfer syntaxes") + sectionCaption("Codec family") + Picker("Codec family", selection: $viewModel.plan.format) { + ForEach(J2KBenchFormat.allCases) { format in + Label(format.rawValue, systemImage: format.systemImage).tag(format) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .disabled(viewModel.isRunning) + Text("\(viewModel.plan.format.referenceCodec.rawValue) is the reference encoder; same-family codecs decode its output.") + .font(.caption2).foregroundStyle(.tertiary) + } + Divider() + VStack(alignment: .leading, spacing: 6) { + sectionCaption(viewModel.plan.format == .jpeg2000 ? "Transfer syntaxes" : "Modes") LazyVGrid(columns: [GridItem(.adaptive(minimum: 200), spacing: 8)], alignment: .leading, spacing: 8) { - ForEach(J2KBenchSyntax.all) { syntax in + ForEach(J2KBenchSyntax.all(for: viewModel.plan.format)) { syntax in syntaxChip(syntax) } } @@ -263,7 +277,7 @@ public struct J2KTestBenchView: View { sectionCaption("Codecs") LazyVGrid(columns: [GridItem(.adaptive(minimum: 170), spacing: 8)], alignment: .leading, spacing: 8) { - ForEach(J2KBenchCodec.allCases) { codec in + ForEach(viewModel.plan.format.codecs) { codec in codecChip(codec) } } @@ -314,7 +328,7 @@ public struct J2KTestBenchView: View { .foregroundStyle(!installed ? .secondary : (enabled ? Color.accentColor : .secondary)) Text(codec.rawValue).font(.caption) - if codec == .j2kSwift { + if codec.encodes { Text("reference").font(.caption2).foregroundStyle(.tertiary) } else if !installed { Text("not installed").font(.caption2).foregroundStyle(.tertiary) @@ -325,7 +339,7 @@ public struct J2KTestBenchView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .disabled(viewModel.isRunning || !installed || codec == .j2kSwift) + .disabled(viewModel.isRunning || !installed || codec.encodes) } private var methodologyControls: some View { @@ -333,21 +347,24 @@ public struct J2KTestBenchView: View { sectionCaption("Methodology") LazyVGrid(columns: [GridItem(.adaptive(minimum: 230), spacing: 12)], alignment: .leading, spacing: 8) { - labeledControl("Encode API") { - Picker("", selection: $viewModel.plan.encodeMode) { - ForEach(J2KSwiftEncodeMode.allCases) { mode in - Text(plainLabel(mode.label)).tag(mode) + // Encode/decode API modes (CPU/GPU/HT) are J2KSwift-specific. + if viewModel.plan.format == .jpeg2000 { + labeledControl("Encode API") { + Picker("", selection: $viewModel.plan.encodeMode) { + ForEach(J2KSwiftEncodeMode.allCases) { mode in + Text(plainLabel(mode.label)).tag(mode) + } } + .labelsHidden() } - .labelsHidden() - } - labeledControl("Decode API") { - Picker("", selection: $viewModel.plan.decodeMode) { - ForEach(J2KSwiftDecodeMode.allCases) { mode in - Text(plainLabel(mode.label)).tag(mode) + labeledControl("Decode API") { + Picker("", selection: $viewModel.plan.decodeMode) { + ForEach(J2KSwiftDecodeMode.allCases) { mode in + Text(plainLabel(mode.label)).tag(mode) + } } + .labelsHidden() } - .labelsHidden() } Stepper("Warmups: \(viewModel.plan.warmups)", value: $viewModel.plan.warmups, in: 0...5) @@ -568,7 +585,8 @@ public struct J2KTestBenchView: View { @ViewBuilder private func groupSubheader(_ group: J2KTestBenchViewModel.ResultGroup) -> some View { - let reference = group.cells.first { $0.codec == .j2kSwift } + // The reference codec varies by format (J2KSwift, JLISwift, JLSwift, JXLSwift). + let reference = group.cells.first { $0.codec.encodes } ?? group.cells.first HStack(spacing: 12) { if let reference { if let bytes = reference.encodedBytes { @@ -996,19 +1014,23 @@ public struct J2KTestBenchView: View { private func isCodecEnabled(_ codec: J2KBenchCodec) -> Bool { switch codec { - case .j2kSwift: return true + case .j2kSwift, .jliSwift, .jlSwift, .jxlSwift: return true // reference codecs case .openJPEG: return viewModel.plan.includeOpenJPEG case .kakadu: return viewModel.plan.includeKakadu case .grok: return viewModel.plan.includeGrok + case .djpeg: return viewModel.plan.includeDjpeg + case .djxl: return viewModel.plan.includeDjxl } } private func toggleCodec(_ codec: J2KBenchCodec) { switch codec { - case .j2kSwift: break + case .j2kSwift, .jliSwift, .jlSwift, .jxlSwift: break // reference codecs are always on case .openJPEG: viewModel.plan.includeOpenJPEG.toggle() case .kakadu: viewModel.plan.includeKakadu.toggle() case .grok: viewModel.plan.includeGrok.toggle() + case .djpeg: viewModel.plan.includeDjpeg.toggle() + case .djxl: viewModel.plan.includeDjxl.toggle() } } diff --git a/Tests/DICOMStudioTests/MultiCodecComparisonTableTests.swift b/Tests/DICOMStudioTests/MultiCodecComparisonTableTests.swift new file mode 100644 index 00000000..515d024a --- /dev/null +++ b/Tests/DICOMStudioTests/MultiCodecComparisonTableTests.swift @@ -0,0 +1,124 @@ +import Testing +import Foundation +@testable import DICOMStudio +import DICOMKit +import DICOMCore + +/// Headless driver for the multi-format codec bench: runs the *real* +/// `J2KTestBenchService` engine (the same code the DICOMStudio J2K Test Bench +/// GUI uses) over real `SampleStudies` frames and prints the per-format +/// comparison table — reference compression + each same-family codec's decode +/// time and bit-exact correctness. This is the headless equivalent of clicking +/// "Run Test Matrix" in the app. +@Suite("Multi-codec comparison table") +struct MultiCodecComparisonTableTests { + + private func loadFrame(_ path: String) -> (frame: Data, desc: PixelDataDescriptor)? { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return nil } + let file: DICOMFile + if let p = try? DICOMFile.read(from: data) { file = p } + else if let f = try? DICOMFile.read(from: data, force: true) { file = f } + else { return nil } + guard let pd = file.pixelData(), let frame = pd.frameData(at: 0) else { return nil } + return (frame, pd.descriptor) + } + + private func col(_ s: String, _ w: Int, right: Bool = false) -> String { + if s.count >= w { return String(s.prefix(w)) } + let pad = String(repeating: " ", count: w - s.count) + return right ? pad + s : s + pad + } + + @Test("Per-format codec comparison on SampleStudies") + func comparisonTable() throws { + let root = FileManager.default.currentDirectoryPath + "/SampleStudies" + // Search a handful of modality folders for suitable frames. + let dirs = ["dx", "cr", "mg", "mr", "px", "ct", "xa"].map { root + "/" + $0 } + let fm = FileManager.default + + var picked: [(name: String, frame: Data, desc: PixelDataDescriptor)] = [] + outer: for dir in dirs { + guard let en = fm.enumerator(atPath: dir) else { continue } + for case let rel as String in en where rel.hasSuffix(".dcm") { + let path = dir + "/" + rel + guard let loaded = loadFrame(path) else { continue } + let d = loaded.desc + // Require an uncompressed (raw == expected size), unsigned, + // 8/16-bit, 1- or 3-component frame of a sensible size so all + // four codecs (incl. JXLSwift, which is unsigned-only) apply and + // the run stays quick. + let pixels = d.rows * d.columns + guard loaded.frame.count == d.bytesPerFrame, + !d.isSigned, + d.samplesPerPixel == 1 || d.samplesPerPixel == 3, + d.bitsAllocated == 8 || d.bitsAllocated == 16, + pixels >= 256 * 256, pixels <= 1_500_000 else { continue } + let modality = (dir as NSString).lastPathComponent.uppercased() + picked.append(("\(modality)/\(URL(fileURLWithPath: path).lastPathComponent)", + loaded.frame, d)) + if picked.count >= 2 { break outer } + } + } + + try #require(!picked.isEmpty, + "no suitable uncompressed sample frame found under \(root)") + + let formats: [J2KBenchFormat] = [.jpeg2000, .jpeg, .jpegLS, .jpegXL] + let warmups = 1, runs = 3 + + for img in picked { + let d = img.desc + print(""" + + ══════════════════════════════════════════════════════════════════════════ + \(img.name) — \(d.columns)×\(d.rows), \(d.bitsAllocated)-bit, spp=\(d.samplesPerPixel), \(img.frame.count) raw bytes + ══════════════════════════════════════════════════════════════════════════ + """) + print(col("format", 11) + col("codec", 13) + col("enc bytes", 11, right: true) + + col("ratio", 9, right: true) + col("enc ms", 10, right: true) + + col("dec ms", 10, right: true) + " result") + print(String(repeating: "─", count: 76)) + + for fmt in formats { + guard let syntax = J2KBenchSyntax.all(for: fmt).first(where: { $0.isLossless }) else { continue } + let enc = J2KTestBenchService.encodeReference( + frame: img.frame, descriptor: d, syntax: syntax, + mode: .cpu, warmups: warmups, runs: runs) + + switch enc { + case .failure(let e): + print(col(fmt.rawValue, 11) + col(syntax.shortName, 13) + + " encode failed — \(e.message)") + case .success(let product): + let ratio = Double(d.bytesPerFrame) / Double(max(1, product.codestream.count)) + for codec in fmt.codecs { + let scored = J2KTestBenchService.decodeAndScore( + codestream: product.codestream, original: img.frame, + descriptor: d, syntax: syntax, codec: codec, + decodeMode: .cpu, warmups: warmups, runs: runs, + lossyThresholdDb: 40) + let result: String + switch scored.outcome { + case .pass: result = scored.psnrDb == nil ? "bit-exact ✓" + : String(format: "PSNR %.1f dB", scored.psnrDb!) + case .fail(let m): result = "FAIL — \(m)" + case .error(let m): result = "error — \(m)" + case .skipped(let m): result = "skipped — \(m)" + } + let encMs = codec.encodes ? String(format: "%.1f", product.encodeMs) : "—" + let decMs = scored.decodeMs.map { String(format: "%.1f", $0) } ?? "—" + let isRef = codec.encodes ? "▸ " : " " + print(col(fmt.rawValue, 11) + + col(isRef + codec.rawValue, 13) + + col(String(product.codestream.count), 11, right: true) + + col(String(format: "%.2f×", ratio), 9, right: true) + + col(encMs, 10, right: true) + + col(decMs, 10, right: true) + + " " + result) + } + } + } + } + print("\n(▸ = reference encoder for the family; peers decode its codestream. Compression ratio is the family encoder's; decode times are per codec.)\n") + } +}