Skip to content

Commit 735871c

Browse files
rasterclaude
authored andcommitted
feat(dicomstudio): J2K Compare encode/decode mode pickers + Kakadu/Grok adapters
Adds explicit encode + decode API selection to the J2K Compare panel so the panel can directly measure J2KSwift's CPU `decode` vs `decodeGPU` vs `decodeWithGPUHT` paths (and CPU `encode` vs `encodeGPU`) on a chosen transfer syntax, alongside OpenJPEG and (when installed) Kakadu / Grok CLI decoders. Timing methodology mirrors J2KSwift's published `CROSS_HOST_*_inproc.md` reports: a single reused encoder/decoder per fixture, 2 untimed warmups + 7 timed runs, median of 7, `DispatchTime` nanosecond clock. DICOMCore adds: * `J2KSwiftDecodeMode` / `J2KSwiftEncodeMode` enums (`.cpu`, `.decodeGPU`, `.decodeWithGPUHT` / `.cpu`, `.gpu`) — public so DICOMStudio can build a Picker over them. * `J2KSwiftCodec.decode(_:descriptor:mode:)` and `J2KSwiftCodec.encode(_:descriptor:transferSyntaxUID:configuration:mode:)` — explicit-mode entry points that bypass the runtime router used by `decodeFrame` / `encodeFrame`. * `J2KSwiftCodec.benchEncode` / `benchDecode` — hold one `J2KEncoder`/`J2KDecoder` across all warmups + timed iterations, preventing the `HTBlockEncoderConformant.useNEONHotPath` `dispatch_once` race that crashed J2KSwift's parallel codeblock workers when a fresh encoder was constructed per iteration on HTJ2K targets. * `KakaduCLICodec` / `GrokCLICodec` — decode-only adapters that shell out to `kdu_expand` / `grk_decompress`. macOS-only; locate the binary on `$PATH` + `/usr/local/bin` + `/opt/homebrew/bin`. Both write the encoded J2K to a temp file, run the binary to `.rawl`, read it back. Requires sandbox-disabled builds to actually launch the subprocess (the default Release entitlements keep sandbox on; the adapters silently fail at runtime in sandboxed builds, leaving empty rows). * `CLICodecSupport` — shared `locateBinary`, `runProcess`, `TempWorkDir`, `CLICodecError`. DICOMStudio J2K Compare panel: * Encode-mode picker (CPU / GPU) and decode-mode picker (CPU / decodeGPU / decodeWithGPUHT) above the comparison table; the J2KSwift row's encode and decode columns reflect the picked APIs. * New "Encode" column showing the J2KSwift encode time; non-J2KSwift rows show "—" (they only decode whatever J2KSwift produced). * Picker selections drive `benchEncode` / `benchDecode` (when warm-up is on); cold-shot path uses a single timed iteration. * Route badge per row shows the API actually exercised (e.g. `CPU \`decode\``, `\`decodeGPU\``). DICOMStudio image viewer: * New File row at the top of the metadata overlay showing the full path of the currently-reviewed image. Middle-truncated, selectable for copy, full path in tooltip. Tests (`Tests/DICOMCoreTests/`): * `KakaduJ2KSwiftSampleStudiesTests` — cross-codec bit-exactness validation: encodes a corpus of CT/MR/XA/PX/DX/MG fixtures with J2KSwift, decodes back with both J2KSwift (per-mode) and Kakadu, asserts byte-equality across all paths, prints a per-fixture timing report (2+7 median methodology). Skips cleanly when `KakaduCLICodec.binaryPath` is nil or `SampleStudies/` is absent (developer-only fixtures, not in repo). * `DICOMStudioPanelSubstituteTests` — multi-mode driver that exercises the same `benchEncode` / `benchDecode` APIs the panel uses, without launching the SwiftUI app. Same skip behaviour as above. Notes: * Does not bump `Package.swift` — J2KSwift v8.0+ ships a `package(path: "../CompressionFamily")` in its manifest that blocks SwiftPM URL consumption from a third-party repo. Stays on `from: "5.21.0"` which resolves to v5.22.0; everything in this PR compiles against that. * Does not change `DICOMStudio.entitlements` — sandbox stays on in Release. The Kakadu/Grok CLI rows in the J2K Compare panel require a sandbox-disabled dev build to actually run. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent f870e09 commit 735871c

9 files changed

Lines changed: 1346 additions & 85 deletions
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// CLICodecSupport.swift
2+
// DICOMCore
3+
//
4+
// Shared helpers for codec adapters that shell out to locally-installed
5+
// CLI binaries (Kakadu, Grok). macOS-only.
6+
7+
#if os(macOS)
8+
import Foundation
9+
10+
enum CLICodecError: Error, LocalizedError {
11+
case binaryNotFound(String)
12+
case launchFailed(String)
13+
case nonZeroExit(Int32, String)
14+
case outputSizeMismatch(expected: Int, got: Int)
15+
case unsupportedConfiguration(String)
16+
17+
var errorDescription: String? {
18+
switch self {
19+
case .binaryNotFound(let name):
20+
return "\(name): binary not found on PATH or standard install locations"
21+
case .launchFailed(let msg):
22+
return "Failed to launch codec binary: \(msg)"
23+
case .nonZeroExit(let code, let stderr):
24+
return "Codec exited with status \(code)\(stderr.isEmpty ? "" : ": \(stderr.prefix(200))")"
25+
case .outputSizeMismatch(let expected, let got):
26+
return "Codec output size mismatch: expected \(expected) bytes, got \(got)"
27+
case .unsupportedConfiguration(let reason):
28+
return "Codec configuration not supported: \(reason)"
29+
}
30+
}
31+
}
32+
33+
/// Searches a name on $PATH and a small list of common install locations.
34+
func locateBinary(name: String) -> String? {
35+
let candidates: [String] = [
36+
"/usr/local/bin/\(name)",
37+
"/opt/homebrew/bin/\(name)",
38+
"/usr/bin/\(name)"
39+
]
40+
for c in candidates where FileManager.default.isExecutableFile(atPath: c) {
41+
return c
42+
}
43+
// PATH fallback.
44+
if let path = ProcessInfo.processInfo.environment["PATH"] {
45+
for dir in path.split(separator: ":") {
46+
let full = "\(dir)/\(name)"
47+
if FileManager.default.isExecutableFile(atPath: full) { return full }
48+
}
49+
}
50+
return nil
51+
}
52+
53+
/// Launches `executable` with the given arguments and waits for exit.
54+
/// Throws on launch failure or non-zero exit; stdout/stderr are captured and surfaced on failure.
55+
func runProcess(executable: String, arguments: [String]) throws {
56+
let proc = Process()
57+
proc.executableURL = URL(fileURLWithPath: executable)
58+
proc.arguments = arguments
59+
let stderrPipe = Pipe()
60+
let stdoutPipe = Pipe()
61+
proc.standardError = stderrPipe
62+
proc.standardOutput = stdoutPipe
63+
do {
64+
try proc.run()
65+
} catch {
66+
throw CLICodecError.launchFailed(error.localizedDescription)
67+
}
68+
proc.waitUntilExit()
69+
if proc.terminationStatus != 0 {
70+
let err = stderrPipe.fileHandleForReading.readDataToEndOfFile()
71+
let out = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
72+
let msg = String(data: err.isEmpty ? out : err, encoding: .utf8) ?? "<no output>"
73+
throw CLICodecError.nonZeroExit(proc.terminationStatus, msg)
74+
}
75+
}
76+
77+
/// A throwaway temp directory under NSTemporaryDirectory().
78+
final class TempWorkDir {
79+
let url: URL
80+
81+
init(prefix: String) throws {
82+
let base = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
83+
let dir = base.appendingPathComponent("\(prefix)-\(UUID().uuidString)", isDirectory: true)
84+
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
85+
self.url = dir
86+
}
87+
88+
func cleanup() {
89+
try? FileManager.default.removeItem(at: url)
90+
}
91+
}
92+
#endif
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// GrokCLICodec.swift
2+
// DICOMCore
3+
//
4+
// Decode-only JPEG 2000 codec that shells out to a locally-installed
5+
// Grok `grk_decompress` binary. Used exclusively in the J2KSwift comparison
6+
// panel — not registered in CodecRegistry.
7+
//
8+
// Install: brew install grok-image-compression
9+
// Probed paths: $PATH, /opt/homebrew/bin/grk_decompress, /usr/local/bin/grk_decompress.
10+
11+
#if os(macOS)
12+
import Foundation
13+
14+
public struct GrokCLICodec: Sendable {
15+
16+
public static let binaryName = "grk_decompress"
17+
18+
public static let binaryPath: String? = locateBinary(name: binaryName)
19+
20+
public static var version: String { binaryPath.map { runVersion(at: $0) } ?? "unavailable" }
21+
22+
public init() {}
23+
24+
public func decodeFrame(_ frameData: Data, descriptor: PixelDataDescriptor) throws -> Data {
25+
guard let bin = Self.binaryPath else {
26+
throw CLICodecError.binaryNotFound(Self.binaryName)
27+
}
28+
guard descriptor.samplesPerPixel == 1 else {
29+
throw CLICodecError.unsupportedConfiguration("samplesPerPixel=\(descriptor.samplesPerPixel) not yet supported")
30+
}
31+
32+
let work = try TempWorkDir(prefix: "grok")
33+
defer { work.cleanup() }
34+
35+
let inputURL = work.url.appendingPathComponent("in.j2k")
36+
let outputURL = work.url.appendingPathComponent("out.rawl")
37+
try frameData.write(to: inputURL)
38+
39+
try runProcess(executable: bin, arguments: [
40+
"-i", inputURL.path,
41+
"-o", outputURL.path
42+
])
43+
44+
let raw = try Data(contentsOf: outputURL)
45+
let expected = descriptor.rows * descriptor.columns * descriptor.samplesPerPixel * (descriptor.bitsAllocated <= 8 ? 1 : 2)
46+
guard raw.count == expected else {
47+
throw CLICodecError.outputSizeMismatch(expected: expected, got: raw.count)
48+
}
49+
return raw
50+
}
51+
52+
private static func runVersion(at path: String) -> String {
53+
let proc = Process()
54+
proc.executableURL = URL(fileURLWithPath: path)
55+
proc.arguments = ["-V"]
56+
let pipe = Pipe()
57+
proc.standardOutput = pipe
58+
proc.standardError = pipe
59+
do { try proc.run() } catch { return "unknown" }
60+
proc.waitUntilExit()
61+
let data = pipe.fileHandleForReading.readDataToEndOfFile()
62+
if let line = String(data: data, encoding: .utf8)?
63+
.split(whereSeparator: \.isNewline)
64+
.first(where: { $0.localizedCaseInsensitiveContains("grok") || $0.localizedCaseInsensitiveContains("version") }) {
65+
return String(line).trimmingCharacters(in: .whitespaces)
66+
}
67+
return "installed"
68+
}
69+
}
70+
#endif

0 commit comments

Comments
 (0)