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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 21 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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]))
],
Expand Down
110 changes: 110 additions & 0 deletions Sources/DICOMCore/CLICodecSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,114 @@ final class TempWorkDir {
try? FileManager.default.removeItem(at: url)
}
}

/// Runs `<binary> <flag>` 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..<nSamples {
let sample: Int
if srcBytesPerSample == 1 {
sample = Int(bytes[s]); s += 1
} else {
sample = (Int(bytes[s]) << 8) | Int(bytes[s + 1]); s += 2 // big-endian
}
let dstSampleIndex: Int
if planar {
dstSampleIndex = (idx % channels) * width * height + (idx / channels)
} else {
dstSampleIndex = idx
}
let dstByte = dstSampleIndex * dstBytesPerSample
if dstBytesPerSample == 1 {
dst[dstByte] = UInt8(truncatingIfNeeded: sample)
} else {
dst[dstByte] = UInt8(sample & 0xFF)
dst[dstByte + 1] = UInt8((sample >> 8) & 0xFF)
}
}
}
return out
}
#endif
57 changes: 57 additions & 0 deletions Sources/DICOMCore/DjpegCLICodec.swift
Original file line number Diff line number Diff line change
@@ -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
56 changes: 56 additions & 0 deletions Sources/DICOMCore/DjxlCLICodec.swift
Original file line number Diff line number Diff line change
@@ -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
60 changes: 60 additions & 0 deletions Sources/DICOMCore/JLICodec.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
}
Loading
Loading