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
37 changes: 22 additions & 15 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

#### DICOM Independence & Final Integration (Milestone 20)
- Non-DICOM usage examples in USAGE_EXAMPLES.md (general-purpose compression, web assets, archival storage)
- DICOM-aware/DICOM-independent architecture documented in README.md

### Fixed

#### DICOM Independence & Final Integration (Milestone 20)
- Fixed incorrect threshold values in `JPEGLSPresetParametersTests` for 8-bit and 12-bit images; test expectations now match the ITU-T.87 Table C.2 formula (8-bit: T1=3, T2=7, T3=21; 12-bit: T1=18, T2=67, T3=276)
- Fixed incorrect expected values in `JPEGLSNearLosslessTests` for reconstructed-value boundary tests; test inputs now avoid the ITU-T.87 modular-wrap threshold, correctly exercising the clamp-to-MAXVAL and clamp-to-zero paths

## [0.8.0] - In Progress
## [0.8.0] - 2026-05-30

### Added

Expand Down Expand Up @@ -53,16 +41,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Man page documentation (man/jpegls.1) in groff format
- Man page installation instructions in README

#### DICOM Test Harness & Tooling
- `jpegls bench-dicom` command: JPEG-LS round-trip benchmark over a DICOM corpus, reporting per-modality encode/decode throughput and lossless verification
- First-mismatch diagnostics on `bench-dicom` lossless failures (reports row/col and decoded vs original sample value, written to stderr)

#### DICOM Independence & Final Integration (Milestone 20)
- Non-DICOM usage examples in USAGE_EXAMPLES.md (general-purpose compression, web assets, archival storage)
- DICOM-aware/DICOM-independent architecture documented in README.md
Comment on lines +48 to +50

### Changed
- Overall project test coverage measured at 95.80% on Linux x86_64 (exceeds 95% threshold)
- Coverage varies by platform due to conditional compilation of platform-specific optimisations
- README.md updated with accurate coverage measurements and platform notes
- README.md updated with links to new documentation guides
- MILESTONES.md updated to reflect progress on Milestone 8 and 9
- Reorganised repository layout: documentation moved into `docs/`, man page into `man/`
- Renamed executable target `jpegls` → `jpeglscli` to avoid a case-insensitive-filesystem collision with the `JPEGLS` library target (the built product is still named `jpegls`)

### Fixed
- Parser now handles CharLS-encoded files with extension markers
- All 12 CharLS reference files can be parsed without errors
- Build & test compilation on case-insensitive (Apple) filesystems
- `SIMDMask.any()` build error on ARM64 (replaced with an equality check)
- Metal GPU path now works under `swift build`/`swift test`: when no precompiled `default.metallib` is present (SwiftPM copies the `.metal` source rather than compiling it), `MetalAccelerator` compiles the bundled shader source at runtime instead of failing with "no default library was found"
- `ARM64Accelerator.computeGolombRiceParameter` now clamps the result to the documented `[0, 31]` range for pathological `a/n` ratios (previously could return an unclamped value; now matches `X86_64Accelerator`)
- `MetalAccelerator.computeGradientsBatch` CPU fallback now uses wrapping subtraction to match the GPU shader's two's-complement semantics, keeping the two paths bit-identical and preventing a trap on extreme `Int32` inputs (e.g. `0 - Int32.min`)

#### DICOM Independence & Final Integration (Milestone 20)
- Fixed incorrect threshold values in `JPEGLSPresetParametersTests` for 8-bit and 12-bit images; test expectations now match the ITU-T.87 Table C.2 formula (8-bit: T1=3, T2=7, T3=21; 12-bit: T1=18, T2=67, T3=276)
- Fixed incorrect expected values in `JPEGLSNearLosslessTests` for reconstructed-value boundary tests; test inputs now avoid the ITU-T.87 modular-wrap threshold, correctly exercising the clamp-to-MAXVAL and clamp-to-zero paths

## [0.7.0] - Completed

Expand Down Expand Up @@ -314,5 +321,5 @@ See [RELEASE_NOTES_TEMPLATE.md](docs/RELEASE_NOTES_TEMPLATE.md) for the release
- **0.8.0** - Validation & conformance (CharLS, benchmarks, edge cases)
- **1.0.0** - Planned stable release

<!-- Note: Comparison links will be added after the first release is tagged -->
[Unreleased]: https://github.com/Raster-Lab/JLSwift/commits/main
[Unreleased]: https://github.com/Raster-Lab/JLSwift/compare/v0.8.0...HEAD
[0.8.0]: https://github.com/Raster-Lab/JLSwift/releases/tag/v0.8.0
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Add JLSwift as a dependency in your `Package.swift`:

```swift
dependencies: [
.package(url: "https://github.com/Raster-Lab/JLSwift.git", from: "0.1.0")
.package(url: "https://github.com/Raster-Lab/JLSwift.git", from: "0.8.0")
]
```

Expand Down
7 changes: 5 additions & 2 deletions Sources/JPEGLS/Platform/ARM64/ARM64Accelerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,11 @@ public struct ARM64Accelerator: PlatformAccelerator {
while k < 31 && (n << k) < a {
k += 1
}

return k

// Clamp to the documented [0, 31] range. When the CLZ-based estimate
// already starts above 31 (pathological a/n), the loop above is skipped,
// so the clamp here is what enforces the upper bound (matches X86_64Accelerator).
return min(k, 31)
}

// MARK: - NEON-Optimized MED Predictor
Expand Down
62 changes: 55 additions & 7 deletions Sources/JPEGLS/Platform/Metal/MetalAccelerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,8 @@ public final class MetalAccelerator: @unchecked Sendable {

// Create compute pipeline states for all shaders
do {
let library = try device.makeDefaultLibrary(bundle: .module)
let library = try Self.loadShaderLibrary(device: device)

self.gradientPipelineState = try Self.makePipelineState(
library: library, device: device, functionName: "compute_gradients")
self.predictionPipelineState = try Self.makePipelineState(
Expand Down Expand Up @@ -129,7 +129,46 @@ public final class MetalAccelerator: @unchecked Sendable {
}

// MARK: - Private Helpers


/// Load the Metal shader library for `JPEGLSShaders.metal`.
///
/// Tries the precompiled `default.metallib` first — this is produced when the
/// package is built by Xcode's build system. Under Swift Package Manager on the
/// command line (`swift build`/`swift test`), `.process` only *copies* the
/// `.metal` file into the resource bundle without compiling it, so no
/// `default.metallib` exists and `makeDefaultLibrary(bundle:)` fails. In that
/// case we locate the bundled `.metal` source and compile it at runtime, so the
/// GPU path works in both environments.
private static func loadShaderLibrary(device: MTLDevice) throws -> MTLLibrary {
// Fast path: precompiled default library (Xcode-built bundles).
if let library = try? device.makeDefaultLibrary(bundle: .module) {
return library
}

// Fallback: compile the bundled shader source at runtime (SPM CLI builds).
guard let sourceURL = Bundle.module.url(
forResource: "JPEGLSShaders", withExtension: "metal"
) else {
throw MetalAcceleratorError.libraryCreationFailed(
"JPEGLSShaders.metal not found in module bundle and no default.metallib present")
}

let source: String
do {
source = try String(contentsOf: sourceURL, encoding: .utf8)
} catch {
throw MetalAcceleratorError.libraryCreationFailed(
"Failed to read JPEGLSShaders.metal: \(error)")
}

do {
return try device.makeLibrary(source: source, options: nil)
} catch {
throw MetalAcceleratorError.libraryCreationFailed(
"Runtime compilation of JPEGLSShaders.metal failed: \(error)")
}
}

/// Create a compute pipeline state for the named shader function.
private static func makePipelineState(
library: MTLLibrary,
Expand Down Expand Up @@ -506,9 +545,13 @@ public final class MetalAccelerator: @unchecked Sendable {
var d3 = [Int32](repeating: 0, count: count)

for i in 0..<count {
d1[i] = b[i] - c[i] // horizontal gradient
d2[i] = a[i] - c[i] // vertical gradient
d3[i] = c[i] - a[i] // diagonal gradient
// Use wrapping subtraction to match the GPU shader, which computes
// these differences in 32-bit `int` with two's-complement wraparound.
// This keeps the CPU and GPU paths bit-identical and prevents the
// public API from trapping on extreme Int32 inputs (e.g. 0 - Int32.min).
d1[i] = b[i] &- c[i] // horizontal gradient
d2[i] = a[i] &- c[i] // vertical gradient
d3[i] = c[i] &- a[i] // diagonal gradient
}

return (d1, d2, d3)
Expand Down Expand Up @@ -798,7 +841,10 @@ public enum MetalAcceleratorError: Error, CustomStringConvertible {

/// Failed to find shader function.
case shaderFunctionNotFound(String)


/// Failed to load or compile the Metal shader library.
case libraryCreationFailed(String)

/// Failed to create compute pipeline state.
case pipelineCreationFailed(Error)

Expand All @@ -819,6 +865,8 @@ public enum MetalAcceleratorError: Error, CustomStringConvertible {
return "Failed to create Metal command queue"
case .shaderFunctionNotFound(let name):
return "Shader function '\(name)' not found in Metal library"
case .libraryCreationFailed(let reason):
return "Failed to load Metal shader library: \(reason)"
case .pipelineCreationFailed(let error):
return "Failed to create compute pipeline state: \(error)"
case .bufferCreationFailed:
Expand Down
24 changes: 23 additions & 1 deletion Sources/jpeglscli/BenchDICOMCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,12 @@ extension JPEGLSCLITool {
s.encodeSeconds += encS
s.decodeSeconds += decS
s.pixels += pixelCount
if near == 0 && !lossless { s.failures += 1 }
if near == 0 && !lossless {
s.failures += 1
let where_ = firstMismatch(decoded.components.first?.pixels, pixels)
FileHandle.standardError.write(Data(
"FAIL lossless: \(url.path) \(img.columns)x\(img.rows) \(bps)-bit\(where_)\n".utf8))
}

frames.append(FrameResult(
modality: mod, width: img.columns, height: img.rows,
Expand All @@ -171,6 +176,8 @@ extension JPEGLSCLITool {
))
} catch {
s.failures += 1
FileHandle.standardError.write(Data(
"FAIL error: \(url.path) \(error)\n".utf8))
}
}
stats[mod] = s
Expand All @@ -191,6 +198,21 @@ extension JPEGLSCLITool {
return true
}

/// Describe the first differing sample between decoded and original
/// pixels, for actionable failure reports. Empty string if identical.
private func firstMismatch(_ a: [[Int]]?, _ b: [[Int]]) -> String {
guard let a = a else { return " (decoded image had no component)" }
if a.count != b.count { return " (decoded \(a.count) rows != original \(b.count))" }
for r in 0..<a.count where a[r] != b[r] {
let cols = min(a[r].count, b[r].count)
for c in 0..<cols where a[r][c] != b[r][c] {
return " first mismatch at (row \(r), col \(c)): decoded \(a[r][c]) != original \(b[r][c])"
}
return " row \(r) length differs (decoded \(a[r].count) vs original \(b[r].count))"
}
return ""
}

private func printTable(stats: [String: ModalityStats]) {
print("")
print("JPEG-LS DICOM round-trip (near=\(near))")
Expand Down
15 changes: 9 additions & 6 deletions Tests/JPEGLSTests/MetalAcceleratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,15 +135,18 @@ struct MetalAcceleratorTests {
let c: [Int32] = [32768, 32768, 0, 0]

let (d1, d2, d3) = try accelerator.computeGradientsBatch(a: a, b: b, c: c)

// Verify gradients computed correctly

// Verify gradients computed correctly. Use wrapping subtraction to match
// the accelerator's two's-complement wraparound semantics — with Int32.min
// boundary inputs, plain subtraction (e.g. 0 - Int32.min) would itself
// overflow and trap.
for i in 0..<a.count {
#expect(d1[i] == b[i] - c[i])
#expect(d2[i] == a[i] - c[i])
#expect(d3[i] == c[i] - a[i])
#expect(d1[i] == b[i] &- c[i])
#expect(d2[i] == a[i] &- c[i])
#expect(d3[i] == c[i] &- a[i])
}
}

@Test("Compute gradients with empty arrays")
func testGradientsWithEmptyArrays() throws {
guard MetalAccelerator.isSupported else { return }
Expand Down
Loading