Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dc8a319
Add J2KSwift v3.0.1 integration plan
Apr 20, 2026
183dadb
Bump Package.swift to swift-tools-version 6.2; update J2KSwift plan t…
Apr 20, 2026
0918cec
Add J2KSwift 3.2 HTJ2K phase 2 integration
Apr 20, 2026
adf04e1
Fix dcm4chee validation stack and update J2KSwift phase docs
Apr 20, 2026
9528c4e
Complete Phase 2 HTJ2K CLI and DICOMweb support
Apr 20, 2026
0588300
Add J2KSwift v3.2 Phase 3: JP3D, JPIP, Codec Inspector, and Volume su…
Apr 21, 2026
d557453
plan: remove Phase 10 JPEG XS exploration from J2KSwift v3.2 integrat…
Apr 21, 2026
7bafeea
feat(jpip): progressive decoding for huge CT/MR studies via JPIP
Apr 21, 2026
90f32dc
Phase 8/9: Implement JP3D MPR view (axial/sagittal/coronal)
Apr 21, 2026
8b2f2d1
fix(tests): add @available to each @Test method in JP3DMPRViewModelTests
Apr 21, 2026
56e8439
feat(phase9-10): dicom-3d JP3D subcommands + Phase 10 documentation
Apr 21, 2026
e307779
feat(studio): J2K testing panel, codec comparison view, viewer commands
Apr 21, 2026
f94790b
fix: remove pkgConfig from COpenJPEG to fix dyld crash at launch
Apr 21, 2026
a1ed130
feat(studio): show raw/encoded/decoded sizes and image geometry in ro…
Apr 21, 2026
bef92d1
fix(studio): add debug-build warning to J2K comparison and round-trip…
Apr 21, 2026
05c398e
fix(xcode): link libopenjp2.a statically in DICOMStudioApp target
Apr 21, 2026
1ef7bb8
fix(xcode): exclude x86_64 from Release builds (Homebrew libopenjp2 i…
Apr 21, 2026
0af016d
perf(core): use decodeGPU in J2KSwiftCodec for Metal-accelerated decode
Apr 21, 2026
6e30b11
chore(deps): bump J2KSwift from 3.2.0 to 4.0.1
Apr 23, 2026
c278f3d
feat(studio): JP3D comparison, DICOM volume viewer, OpenJPEG referenc…
Apr 23, 2026
c13c51b
Merge branch 'main' into feature/j2kswift-v3-integration
Apr 23, 2026
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ dcm4chee-db-data/

# Local datasets
LocalDatasets/

# Xcode build output (SwiftPM uses .build/; Xcode schemes emit build/)
build/

# Claude Code per-project memory (not source)
memory/
3 changes: 3 additions & 0 deletions DICOMStudio.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
EXCLUDED_ARCHS = x86_64;
MACOSX_DEPLOYMENT_TARGET = 15.6;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
Expand All @@ -280,6 +281,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Raster Lab. All rights reserved.";
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rasterlab.DICOMStudio;
OTHER_LDFLAGS = "/opt/homebrew/lib/libopenjp2.a";
PRODUCT_MODULE_NAME = DICOMStudioApp;
PRODUCT_NAME = DICOMStudio;
SWIFT_EMIT_LOC_STRINGS = YES;
Expand All @@ -305,6 +307,7 @@
INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Raster Lab. All rights reserved.";
MARKETING_VERSION = 1.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.rasterlab.DICOMStudio;
OTHER_LDFLAGS = "/opt/homebrew/lib/libopenjp2.a";
PRODUCT_MODULE_NAME = DICOMStudioApp;
PRODUCT_NAME = DICOMStudio;
SWIFT_EMIT_LOC_STRINGS = YES;
Expand Down

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

Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
Expand Down
29 changes: 25 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,22 @@ let package = Package(
// .package(url: "https://github.com/awslabs/aws-sdk-swift.git", from: "1.6.0"),
// J2KSwift — pure-Swift JPEG 2000 / HTJ2K / JP3D / JPIP codec
// See J2KSWIFT_INTEGRATION_PLAN.md for the phased integration.
.package(url: "https://github.com/Raster-Lab/J2KSwift.git", from: "3.2.0")
.package(url: "https://github.com/Raster-Lab/J2KSwift.git", from: "4.0.1")
],
targets: [
// OpenJPEG 2.x system library (https://www.openjpeg.org)
// Requires: brew install openjpeg
//
// pkgConfig is intentionally omitted: `pkg-config --libs libopenjp2` emits
// `-L/opt/homebrew/opt/openjpeg/lib -lopenjp2`, which causes a dynamic link to
// libopenjp2.7.dylib. With App Sandbox enabled that path is inaccessible and
// dyld aborts at launch. The actual link is handled statically via the
// .unsafeFlags(["/opt/homebrew/lib/libopenjp2.a"]) in DICOMCore's linkerSettings.
// Headers are resolved directly from the modulemap (no pkg-config needed).
.systemLibrary(
name: "COpenJPEG",
providers: [.brew(["openjpeg"])]
),
.target(
name: "DICOMCore",
dependencies: [
Expand All @@ -223,9 +236,17 @@ let package = Package(
.product(name: "J2K3D", package: "J2KSwift"),
// Phase 5: hardware acceleration backends
.product(name: "J2KAccelerate", package: "J2KSwift"),
.product(name: "J2KMetal", package: "J2KSwift")
],
exclude: ["CharacterSetHandler+README.md"]
.product(name: "J2KMetal", package: "J2KSwift"),
// OpenJPEG — decode comparison codec (macOS only, requires brew install openjpeg)
.target(name: "COpenJPEG", condition: .when(platforms: [.macOS]))
],
exclude: ["CharacterSetHandler+README.md"],
linkerSettings: [
// Link the static OpenJPEG library directly so the app has no runtime
// dependency on /opt/homebrew/lib (avoids dyld rpath failures).
// Apple Silicon path; for Intel Macs change to /usr/local/lib/libopenjp2.a
.unsafeFlags(["/opt/homebrew/lib/libopenjp2.a"], .when(platforms: [.macOS]))
]
),
.target(
name: "DICOMDictionary",
Expand Down
14 changes: 14 additions & 0 deletions Sources/COpenJPEG/module.modulemap
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// System library wrapper for OpenJPEG 2.x (https://www.openjpeg.org)
// Install via Homebrew: brew install openjpeg
//
// Apple Silicon path: /opt/homebrew (used below)
// Intel Mac path: /usr/local (update the header path if needed)
//
// Note: "link" is intentionally omitted here; linking is handled via
// linkerSettings in Package.swift so the static library (.a) is used
// instead of the dynamic library (.dylib), avoiding a runtime rpath dependency.

module COpenJPEG [system] {
header "/opt/homebrew/include/openjpeg-2.5/openjpeg.h"
export *
}
2 changes: 1 addition & 1 deletion Sources/DICOMCore/J2KSwiftCodec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ private extension J2KSwiftCodec {
let image: J2KImage
do {
image = try Self.awaitJ2KResult {
try await J2KDecoder().decode(frameData)
try await J2KDecoder().decodeGPU(frameData)
}
} catch {
throw DICOMError.parsingFailed("J2KSwift decode failed: \(error)")
Expand Down
193 changes: 193 additions & 0 deletions Sources/DICOMCore/OpenJPEGCodec.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// OpenJPEGCodec.swift
// DICOMCore
//
// Decode-only JPEG 2000 codec backed by OpenJPEG 2.x (https://www.openjpeg.org).
// Used exclusively in the J2KSwift comparison panel — not registered in CodecRegistry.
//
// Requires: brew install openjpeg

#if canImport(COpenJPEG) && os(macOS)
import COpenJPEG
import Foundation

// MARK: - OpenJPEGCodec

/// Decode-only JPEG 2000 codec wrapping the OpenJPEG 2.x C library.
///
/// Supports J2K codestreams (bare codestream, not JP2 container).
/// Output format mirrors J2KSwiftCodec: little-endian, interleaved samples.
public struct OpenJPEGCodec: Sendable {

public static let version: String = String(cString: opj_version())

public init() {}

// MARK: - Decode

/// Decodes a single JPEG 2000 frame and returns raw pixel bytes.
///
/// - Parameters:
/// - frameData: Compressed J2K codestream bytes.
/// - descriptor: DICOM pixel descriptor (rows, columns, bitsAllocated, samplesPerPixel, isSigned).
/// - Returns: Uncompressed pixel bytes (little-endian, interleaved).
public func decodeFrame(_ frameData: Data, descriptor: PixelDataDescriptor) throws -> Data {
// Detect format from magic bytes: J2K starts with 0xFF 0x4F, JP2 with box header
let format: OPJ_CODEC_FORMAT = frameData.count >= 2
&& frameData[frameData.startIndex] == 0xFF
&& frameData[frameData.startIndex + 1] == 0x4F
? OPJ_CODEC_J2K : OPJ_CODEC_JP2

guard let codec = opj_create_decompress(format) else {
throw OpenJPEGError.codecCreationFailed
}
defer { opj_destroy_codec(codec) }

// Suppress all stdout/stderr from OpenJPEG
let noop: @convention(c) (UnsafePointer<CChar>?, UnsafeMutableRawPointer?) -> Void = { _, _ in }
opj_set_info_handler(codec, noop, nil)
opj_set_warning_handler(codec, noop, nil)
opj_set_error_handler(codec, noop, nil)

var params = opj_dparameters_t()
opj_set_default_decoder_parameters(&params)
guard opj_setup_decoder(codec, &params) == OPJ_TRUE else {
throw OpenJPEGError.setupFailed
}

// Build in-memory stream — retain ctx; free callback releases it on stream destroy
let ctx = OPJMemoryContext(frameData)
guard let stream = opj_stream_create(OPJ_SIZE_T(frameData.count), OPJ_TRUE) else {
throw OpenJPEGError.streamCreationFailed
}
defer { opj_stream_destroy(stream) }

opj_stream_set_user_data(stream, Unmanaged.passRetained(ctx).toOpaque(), opjFreeContext)
opj_stream_set_user_data_length(stream, OPJ_UINT64(frameData.count))
opj_stream_set_read_function(stream, opjRead)
opj_stream_set_skip_function(stream, opjSkip)
opj_stream_set_seek_function(stream, opjSeek)

var image: UnsafeMutablePointer<opj_image_t>? = nil
guard opj_read_header(stream, codec, &image) == OPJ_TRUE, let imgPtr = image else {
throw OpenJPEGError.readHeaderFailed
}
defer { opj_image_destroy(imgPtr) }

guard opj_decode(codec, stream, imgPtr) == OPJ_TRUE else {
throw OpenJPEGError.decodeFailed
}
_ = opj_end_decompress(codec, stream)

return try extractPixels(from: imgPtr, descriptor: descriptor)
}
}

// MARK: - Pixel Extraction

private func extractPixels(
from imgPtr: UnsafeMutablePointer<opj_image_t>,
descriptor: PixelDataDescriptor
) throws -> Data {
let img = imgPtr.pointee
let w = Int(img.x1 - img.x0)
let h = Int(img.y1 - img.y0)
let numComps = Int(img.numcomps)
guard w > 0, h > 0, numComps > 0 else { throw OpenJPEGError.invalidDimensions }

let pixelCount = w * h
let bytesPerSample = descriptor.bitsAllocated <= 8 ? 1 : 2
var out = Data(capacity: pixelCount * numComps * bytesPerSample)

for i in 0..<pixelCount {
for c in 0..<numComps {
let comp = img.comps.advanced(by: c).pointee
let raw = comp.data[i] // OPJ_INT32
if bytesPerSample == 1 {
out.append(UInt8(clamping: Int(raw)))
} else if comp.sgnd != 0 {
let v = UInt16(bitPattern: Int16(clamping: Int(raw)))
out.append(UInt8(v & 0xFF))
out.append(UInt8(v >> 8))
} else {
let v = UInt16(clamping: UInt32(bitPattern: raw))
out.append(UInt8(v & 0xFF))
out.append(UInt8(v >> 8))
}
}
}
return out
}

// MARK: - In-Memory Stream Context

private final class OPJMemoryContext {
let data: Data
var offset: Int = 0
var remaining: Int { data.count - offset }
init(_ data: Data) { self.data = data }
}

// MARK: - C Callbacks (global functions — no captures, usable as @convention(c) pointers)

private func opjRead(
buf: UnsafeMutableRawPointer?,
nb: OPJ_SIZE_T,
userData: UnsafeMutableRawPointer?
) -> OPJ_SIZE_T {
guard let buf, let userData else { return OPJ_SIZE_T.max }
let ctx = Unmanaged<OPJMemoryContext>.fromOpaque(userData).takeUnretainedValue()
let toRead = min(Int(nb), ctx.remaining)
guard toRead > 0 else { return OPJ_SIZE_T.max }
ctx.data.withUnsafeBytes { src in
buf.copyMemory(from: src.baseAddress!.advanced(by: ctx.offset), byteCount: toRead)
}
ctx.offset += toRead
return OPJ_SIZE_T(toRead)
}

private func opjSkip(
nb: OPJ_OFF_T,
userData: UnsafeMutableRawPointer?
) -> OPJ_OFF_T {
guard let userData else { return -1 }
let ctx = Unmanaged<OPJMemoryContext>.fromOpaque(userData).takeUnretainedValue()
let toSkip = min(Int(nb), ctx.remaining)
ctx.offset += toSkip
return OPJ_OFF_T(toSkip)
}

private func opjSeek(
pos: OPJ_OFF_T,
userData: UnsafeMutableRawPointer?
) -> OPJ_BOOL {
guard let userData, pos >= 0 else { return OPJ_FALSE }
let ctx = Unmanaged<OPJMemoryContext>.fromOpaque(userData).takeUnretainedValue()
guard Int(pos) <= ctx.data.count else { return OPJ_FALSE }
ctx.offset = Int(pos)
return OPJ_TRUE
}

private func opjFreeContext(userData: UnsafeMutableRawPointer?) {
guard let userData else { return }
Unmanaged<OPJMemoryContext>.fromOpaque(userData).release()
}

// MARK: - Errors

public enum OpenJPEGError: Error, LocalizedError {
case codecCreationFailed, setupFailed, streamCreationFailed
case readHeaderFailed, decodeFailed, invalidDimensions

public var errorDescription: String? {
switch self {
case .codecCreationFailed: return "OpenJPEG: failed to create decoder"
case .setupFailed: return "OpenJPEG: failed to set up decoder parameters"
case .streamCreationFailed: return "OpenJPEG: failed to create memory stream"
case .readHeaderFailed: return "OpenJPEG: failed to read J2K header"
case .decodeFailed: return "OpenJPEG: decoding failed"
case .invalidDimensions: return "OpenJPEG: decoded image has invalid dimensions"
}
}
}

#endif // canImport(COpenJPEG) && os(macOS)
Loading
Loading