From f01194ccf6438e5b3a6d0812dbeeefbc4edcdf84 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:33:53 +0000 Subject: [PATCH 1/6] Wire HTJ2K block coders into encoder and decoder pipelines Agent-Logs-Url: https://github.com/Raster-Lab/J2KSwift/sessions/92efab50-87fd-48f6-82f6-4a38a03184b9 Co-authored-by: SureshKViswanathan <257696045+SureshKViswanathan@users.noreply.github.com> --- Sources/J2KCodec/J2KDecoderPipeline.swift | 118 ++++++---- Sources/J2KCodec/J2KEncoderPipeline.swift | 258 ++++++++++++++++------ Sources/J2KCodec/J2KHTBlockCoder.swift | 236 ++++++++++++++++++-- 3 files changed, 493 insertions(+), 119 deletions(-) diff --git a/Sources/J2KCodec/J2KDecoderPipeline.swift b/Sources/J2KCodec/J2KDecoderPipeline.swift index 70562f49..37aeb36c 100644 --- a/Sources/J2KCodec/J2KDecoderPipeline.swift +++ b/Sources/J2KCodec/J2KDecoderPipeline.swift @@ -1104,17 +1104,22 @@ struct DecoderPipeline: Sendable { } /// Applies entropy decoding to code blocks. + /// + /// When `metadata.configuration.useHTJ2K` is true, uses HTJ2K FBCOT block + /// decoding (ISO/IEC 15444-15). Otherwise uses legacy EBCOT bit-plane + /// decoding (ISO/IEC 15444-1). private func applyEntropyDecoding( _ blocks: [CodeBlockInfo], metadata: CodestreamMetadata ) throws -> [SubbandInfo] { - let decoder = CodeBlockDecoder() let isIrreversible: Bool if case .irreversible97 = metadata.configuration.waveletFilter { isIrreversible = true } else { isIrreversible = false } + let useHT = metadata.configuration.useHTJ2K + // Track subband dimensions and use 2D placement for code blocks var subbandDims: [String: (width: Int, height: Int)] = [:] // Store decoded code blocks with their positions for proper 2D placement @@ -1131,7 +1136,8 @@ struct DecoderPipeline: Sendable { if blockCount >= 4 { // === Parallel code block decoding === - // Each code block is independent (own MQ state + context models). + // Each code block is independent (own MQ state + context models for EBCOT, + // own MEL/VLC/MagSgn state for HTJ2K). // Use DispatchQueue.concurrentPerform for cross-platform parallelism // (macOS Intel/ARM, Linux). let componentBitDepths = metadata.components.map { $0.bitDepth } @@ -1148,25 +1154,44 @@ struct DecoderPipeline: Sendable { DispatchQueue.concurrentPerform(iterations: blockCount) { i in let block = blocks[i] - let blockDecoder = CodeBlockDecoder() - let codeBlock = J2KCodeBlock( - index: 0, - x: block.x, - y: block.y, - width: block.width, - height: block.height, - subband: block.subband, - data: block.data, - passeCount: block.passCount, - zeroBitPlanes: block.zeroBitPlanes - ) let bitDepth = block.bandKb > 0 ? block.bandKb : componentBitDepths[block.componentIndex] - if let coeffs = try? blockDecoder.decode( - codeBlock: codeBlock, - bitDepth: bitDepth, - irreversible: isIrreversible - ) { - resultsPtr[i] = coeffs + + if useHT { + // HTJ2K path: use FBCOT block decoding + let htDecoder = HTBlockDecoder( + width: block.width, + height: block.height, + subband: block.subband + ) + if let coeffs = try? htDecoder.decodeFromCodestream( + data: block.data, + passCount: block.passCount, + bitDepth: bitDepth, + zeroBitPlanes: block.zeroBitPlanes + ) { + resultsPtr[i] = coeffs + } + } else { + // Legacy path: use EBCOT bit-plane decoding + let blockDecoder = CodeBlockDecoder() + let codeBlock = J2KCodeBlock( + index: 0, + x: block.x, + y: block.y, + width: block.width, + height: block.height, + subband: block.subband, + data: block.data, + passeCount: block.passCount, + zeroBitPlanes: block.zeroBitPlanes + ) + if let coeffs = try? blockDecoder.decode( + codeBlock: codeBlock, + bitDepth: bitDepth, + irreversible: isIrreversible + ) { + resultsPtr[i] = coeffs + } } } @@ -1199,24 +1224,43 @@ struct DecoderPipeline: Sendable { } else { // Sequential path for small block counts for block in blocks { - let codeBlock = J2KCodeBlock( - index: 0, - x: block.x, - y: block.y, - width: block.width, - height: block.height, - subband: block.subband, - data: block.data, - passeCount: block.passCount, - zeroBitPlanes: block.zeroBitPlanes - ) - let compInfo = metadata.components[block.componentIndex] - let coeffs = try decoder.decode( - codeBlock: codeBlock, - bitDepth: block.bandKb > 0 ? block.bandKb : compInfo.bitDepth, - irreversible: isIrreversible - ) + let bitDepth = block.bandKb > 0 ? block.bandKb : compInfo.bitDepth + let coeffs: [Int32] + + if useHT { + // HTJ2K path: use FBCOT block decoding + let htDecoder = HTBlockDecoder( + width: block.width, + height: block.height, + subband: block.subband + ) + coeffs = try htDecoder.decodeFromCodestream( + data: block.data, + passCount: block.passCount, + bitDepth: bitDepth, + zeroBitPlanes: block.zeroBitPlanes + ) + } else { + // Legacy path: use EBCOT bit-plane decoding + let decoder = CodeBlockDecoder() + let codeBlock = J2KCodeBlock( + index: 0, + x: block.x, + y: block.y, + width: block.width, + height: block.height, + subband: block.subband, + data: block.data, + passeCount: block.passCount, + zeroBitPlanes: block.zeroBitPlanes + ) + coeffs = try decoder.decode( + codeBlock: codeBlock, + bitDepth: bitDepth, + irreversible: isIrreversible + ) + } let key = "\(block.componentIndex)_\(block.level)_\(block.subband.rawValue)" diff --git a/Sources/J2KCodec/J2KEncoderPipeline.swift b/Sources/J2KCodec/J2KEncoderPipeline.swift index fdd6af94..e5ca0466 100644 --- a/Sources/J2KCodec/J2KEncoderPipeline.swift +++ b/Sources/J2KCodec/J2KEncoderPipeline.swift @@ -1033,7 +1033,11 @@ struct EncoderPipeline: Sendable { let bitPlanePopulation: [Int] } - /// Applies EBCOT entropy coding to all subbands, producing code blocks. + /// Applies entropy coding to all subbands, producing code blocks. + /// + /// When `config.useHTJ2K` is true, uses HTJ2K FBCOT (Fast Block Coder with + /// Optimised Truncation) per ISO/IEC 15444-15. Otherwise uses legacy EBCOT + /// bit-plane coding per ISO/IEC 15444-1. private func applyEntropyCoding( _ componentSubbands: [[SubbandInfo]], image: J2KImage @@ -1186,43 +1190,55 @@ struct EncoderPipeline: Sendable { } /// Encodes code-blocks sequentially. + /// + /// Dispatches to HTJ2K FBCOT block coding when `config.useHTJ2K` is true, + /// otherwise uses legacy EBCOT bit-plane coding. private func encodeCodeBlocksSequential( _ pendingBlocks: [PendingCodeBlock] ) throws -> [J2KCodeBlock] { - let encoder = CodeBlockEncoder() var results: [J2KCodeBlock] = [] results.reserveCapacity(pendingBlocks.count) - for pending in pendingBlocks { - var codeBlock = try encoder.encode( - coefficients: pending.coefficients, - width: pending.width, - height: pending.height, - subband: pending.subband, - bitDepth: pending.bitDepth - ) + if config.useHTJ2K { + // HTJ2K path: use FBCOT block coding + for pending in pendingBlocks { + let codeBlock = try encodeCodeBlockHTJ2K(pending) + results.append(codeBlock) + } + } else { + // Legacy path: use EBCOT bit-plane coding + let encoder = CodeBlockEncoder() + for pending in pendingBlocks { + var codeBlock = try encoder.encode( + coefficients: pending.coefficients, + width: pending.width, + height: pending.height, + subband: pending.subband, + bitDepth: pending.bitDepth + ) - codeBlock = J2KCodeBlock( - index: pending.index, - x: pending.x, - y: pending.y, - width: pending.width, - height: pending.height, - subband: codeBlock.subband, - componentIndex: pending.componentIndex, - resolutionLevel: pending.resolutionLevel, - data: codeBlock.data, - passeCount: codeBlock.passeCount, - zeroBitPlanes: codeBlock.zeroBitPlanes, - passSegmentLengths: codeBlock.passSegmentLengths, - cumulativePassBytes: codeBlock.cumulativePassBytes, - coefficientSquaredSum: pending.coefficientSquaredSum, - bitPlanePopulation: pending.bitPlanePopulation, - cumulativePassDistortion: codeBlock.cumulativePassDistortion, - perPassSnapshotData: codeBlock.perPassSnapshotData - ) + codeBlock = J2KCodeBlock( + index: pending.index, + x: pending.x, + y: pending.y, + width: pending.width, + height: pending.height, + subband: codeBlock.subband, + componentIndex: pending.componentIndex, + resolutionLevel: pending.resolutionLevel, + data: codeBlock.data, + passeCount: codeBlock.passeCount, + zeroBitPlanes: codeBlock.zeroBitPlanes, + passSegmentLengths: codeBlock.passSegmentLengths, + cumulativePassBytes: codeBlock.cumulativePassBytes, + coefficientSquaredSum: pending.coefficientSquaredSum, + bitPlanePopulation: pending.bitPlanePopulation, + cumulativePassDistortion: codeBlock.cumulativePassDistortion, + perPassSnapshotData: codeBlock.perPassSnapshotData + ) - results.append(codeBlock) + results.append(codeBlock) + } } return results @@ -1231,7 +1247,11 @@ struct EncoderPipeline: Sendable { /// Encodes code-blocks in parallel using structured concurrency. /// /// Each code-block is an independent unit of entropy coding with its own - /// MQ encoder state and context models, making them safe to process in parallel. + /// MQ encoder state and context models (EBCOT) or MEL/VLC/MagSgn state (HTJ2K), + /// making them safe to process in parallel. + /// + /// Dispatches to HTJ2K FBCOT block coding when `config.useHTJ2K` is true, + /// otherwise uses legacy EBCOT bit-plane coding. private func encodeCodeBlocksParallel( _ pendingBlocks: [PendingCodeBlock] ) throws -> [J2KCodeBlock] { @@ -1248,45 +1268,60 @@ struct EncoderPipeline: Sendable { return Array(pendingBlocks[start.. J2KCodeBlock { + let htEncoder = HTBlockEncoder( + width: pending.width, + height: pending.height, + subband: pending.subband + ) + + // Convert Int32 coefficients to Int for HTBlockEncoder interface + let coeffsInt = pending.coefficients.map { Int($0) } + + // Determine the most significant bit-plane from the magnitudes + let maxMag = coeffsInt.lazy.map { abs($0) }.max() ?? 0 + let topBitPlane: Int + if maxMag > 0 { + topBitPlane = Int(log2(Double(maxMag))) + } else { + topBitPlane = 0 + } + + // Encode cleanup pass (the primary HT coding pass) + let cleanupBlock = try htEncoder.encodeCleanup( + coefficients: coeffsInt, + bitPlane: topBitPlane + ) + + // Build significance state from cleanup pass results + var significanceState = [Bool](repeating: false, count: pending.width * pending.height) + for i in 0..> topBitPlane) & 1 != 0 { + significanceState[i] = true + } + + // Encode refinement passes (SigProp + MagRef) for lower bit-planes + var allPassData = cleanupBlock.codedData + var totalPasses = 1 // cleanup pass + var passSegmentLengths = [cleanupBlock.codedData.count] + var cumulativePassBytes = [cleanupBlock.codedData.count] + + for bp in stride(from: topBitPlane - 1, through: 0, by: -1) { + let sigPropData = try htEncoder.encodeSigProp( + coefficients: coeffsInt, + significanceState: significanceState, + bitPlane: bp + ) + allPassData.append(sigPropData) + totalPasses += 1 + passSegmentLengths.append(sigPropData.count) + cumulativePassBytes.append(allPassData.count) + + let magRefData = try htEncoder.encodeMagRef( + coefficients: coeffsInt, + significanceState: significanceState, + bitPlane: bp + ) + allPassData.append(magRefData) + totalPasses += 1 + passSegmentLengths.append(magRefData.count) + cumulativePassBytes.append(allPassData.count) + + // Update significance state for next bit-plane + for i in 0..> bp) & 1 != 0 { + significanceState[i] = true + } + } + + let zeroBitPlanes = maxMag > 0 ? max(0, pending.bitDepth - topBitPlane - 1) : pending.bitDepth + + return J2KCodeBlock( + index: pending.index, + x: pending.x, + y: pending.y, + width: pending.width, + height: pending.height, + subband: pending.subband, + componentIndex: pending.componentIndex, + resolutionLevel: pending.resolutionLevel, + data: allPassData, + passeCount: totalPasses, + zeroBitPlanes: zeroBitPlanes, + passSegmentLengths: passSegmentLengths, + cumulativePassBytes: cumulativePassBytes, + coefficientSquaredSum: pending.coefficientSquaredSum, + bitPlanePopulation: pending.bitPlanePopulation + ) + } + // MARK: - SIMD Helpers /// Computes the maximum absolute value in an array using SIMD operations. diff --git a/Sources/J2KCodec/J2KHTBlockCoder.swift b/Sources/J2KCodec/J2KHTBlockCoder.swift index d8aab4a7..0fba6a7a 100644 --- a/Sources/J2KCodec/J2KHTBlockCoder.swift +++ b/Sources/J2KCodec/J2KHTBlockCoder.swift @@ -473,6 +473,69 @@ struct HTBlockEncoder: Sendable { ) } + let count = coefficients.count + + // Pre-compute significance bits, absolute magnitudes, and sign bits + // using SIMD4 vectorisation for the bulk of the array. + var sigBits = [Int](repeating: 0, count: count) + var absMags = [Int](repeating: 0, count: count) + var signBits = [Int](repeating: 0, count: count) + + coefficients.withUnsafeBufferPointer { srcPtr in + sigBits.withUnsafeMutableBufferPointer { sigPtr in + absMags.withUnsafeMutableBufferPointer { magPtr in + signBits.withUnsafeMutableBufferPointer { sgnPtr in + let src = srcPtr.baseAddress! + let sig = sigPtr.baseAddress! + let mag = magPtr.baseAddress! + let sgn = sgnPtr.baseAddress! + + let simdCount = count / 4 + + for i in 0..( + src[offset], src[offset + 1], + src[offset + 2], src[offset + 3] + ) + + // Absolute value + let negative = v .< SIMD4.zero + let absV = v.replacing( + with: SIMD4.zero &- v, where: negative + ) + + // Significance: (abs >> bitPlane) & 1 + let shifted = absV &>> SIMD4(repeating: bitPlane) + let masked = shifted & SIMD4(repeating: 1) + + // Sign: 1 if negative, 0 otherwise + let signV = SIMD4.zero.replacing( + with: SIMD4(repeating: 1), where: negative + ) + + sig[offset] = masked[0]; sig[offset+1] = masked[1] + sig[offset+2] = masked[2]; sig[offset+3] = masked[3] + mag[offset] = absV[0]; mag[offset+1] = absV[1] + mag[offset+2] = absV[2]; mag[offset+3] = absV[3] + sgn[offset] = signV[0]; sgn[offset+1] = signV[1] + sgn[offset+2] = signV[2]; sgn[offset+3] = signV[3] + } + + // Scalar remainder + let remStart = simdCount * 4 + for i in remStart..> bitPlane) & 1 + } + } + } + } + } + var mel = HTMELCoder() var vlc = HTVLCCoder() var magsgn = HTMagSgnCoder() @@ -490,15 +553,11 @@ struct HTBlockEncoder: Sendable { let x0 = col let idx0 = y * width + x0 - let coeff0 = coefficients[idx0] - let sig0 = (abs(coeff0) >> bitPlane) & 1 + let sig0 = sigBits[idx0] var sig1 = 0 - var coeff1 = 0 if pairWidth > 1 { - let idx1 = y * width + x0 + 1 - coeff1 = coefficients[idx1] - sig1 = (abs(coeff1) >> bitPlane) & 1 + sig1 = sigBits[idx0 + 1] } // Encode significance via MEL and VLC @@ -508,14 +567,11 @@ struct HTBlockEncoder: Sendable { // Encode magnitude/sign for significant samples if sig0 != 0 { - let mag = abs(coeff0) - let sign = coeff0 < 0 ? 1 : 0 - magsgn.encode(magnitude: mag, sign: sign, bitPlane: bitPlane) + magsgn.encode(magnitude: absMags[idx0], sign: signBits[idx0], bitPlane: bitPlane) } if sig1 != 0 { - let mag = abs(coeff1) - let sign = coeff1 < 0 ? 1 : 0 - magsgn.encode(magnitude: mag, sign: sign, bitPlane: bitPlane) + let idx1 = idx0 + 1 + magsgn.encode(magnitude: absMags[idx1], sign: signBits[idx1], bitPlane: bitPlane) } } } @@ -526,9 +582,18 @@ struct HTBlockEncoder: Sendable { let vlcData = vlc.flush() let magsgnData = magsgn.flush() - // Combine into a single coded data buffer: - // [MEL data | MagSgn data | VLC data (reversed)] + // Combine into a single coded data buffer with a 6-byte length header + // so the decoder can split the streams without external metadata: + // [melLen:2 | vlcLen:2 | magsgnLen:2 | MEL data | MagSgn data | VLC data (reversed)] var codedData = Data() + codedData.reserveCapacity(6 + melData.count + magsgnData.count + vlcData.count) + // Stream length header (big-endian UInt16 each) + codedData.append(UInt8((melData.count >> 8) & 0xFF)) + codedData.append(UInt8(melData.count & 0xFF)) + codedData.append(UInt8((vlcData.count >> 8) & 0xFF)) + codedData.append(UInt8(vlcData.count & 0xFF)) + codedData.append(UInt8((magsgnData.count >> 8) & 0xFF)) + codedData.append(UInt8(magsgnData.count & 0xFF)) codedData.append(melData) codedData.append(magsgnData) codedData.append(Data(vlcData.reversed())) @@ -731,17 +796,30 @@ struct HTBlockDecoder: Sendable { var coefficients = [Int](repeating: 0, count: width * height) let data = block.codedData - // Split the coded data into the three streams - let melEnd = block.melLength + // The coded data has a 6-byte length header: + // [melLen:2 | vlcLen:2 | magsgnLen:2 | MEL data | MagSgn data | VLC data (reversed)] + guard data.count >= 6 else { + // Empty or trivial block — all zeros + return coefficients + } + + // Parse the stream length header + let headerMelLen = Int(data[data.startIndex]) << 8 | Int(data[data.startIndex + 1]) + let headerVlcLen = Int(data[data.startIndex + 2]) << 8 | Int(data[data.startIndex + 3]) + _ = Int(data[data.startIndex + 4]) << 8 | Int(data[data.startIndex + 5]) + + let headerSize = 6 + let payloadStart = data.startIndex + headerSize + let melEnd = payloadStart + headerMelLen let magsgnEnd = melEnd + block.magsgnLength - guard melEnd <= data.count && magsgnEnd <= data.count else { + guard melEnd <= data.endIndex && magsgnEnd <= data.endIndex else { throw J2KError.decodingError("Invalid stream lengths in HT encoded block") } - let melData = data.prefix(melEnd) + let melData = data[payloadStart.. [Int32] { + guard passCount > 0 else { + return [Int32](repeating: 0, count: width * height) + } + + // Determine the top bit-plane from zeroBitPlanes and bitDepth + let topBitPlane = max(0, bitDepth - zeroBitPlanes - 1) + + // Split data into per-pass segments + var segments: [Data] = [] + if !passSegmentLengths.isEmpty { + var offset = data.startIndex + for len in passSegmentLengths { + let end = min(offset + len, data.endIndex) + segments.append(data[offset..= 6 else { + // Trivial block with no significant coefficients + return [Int32](repeating: 0, count: width * height) + } + + // Parse stream length header from cleanup data + let s = cleanupData.startIndex + let melLen = Int(cleanupData[s]) << 8 | Int(cleanupData[s + 1]) + let vlcLen = Int(cleanupData[s + 2]) << 8 | Int(cleanupData[s + 3]) + let magsgnLen = Int(cleanupData[s + 4]) << 8 | Int(cleanupData[s + 5]) + + let headerSize = 6 + let payloadStart = s + headerSize + + let block = HTEncodedBlock( + codedData: cleanupData, + passType: .htCleanup, + melLength: melLen, + vlcLength: vlcLen, + magsgnLength: magsgnLen, + bitPlane: topBitPlane, + width: width, + height: height + ) + // Use the existing cleanup decoder via the HTEncodedBlock path + _ = payloadStart // payloadStart used by decodeCleanup internally + var coefficients = try decodeCleanup(from: block) + + // Apply refinement passes (SigProp + MagRef pairs) + if segments.count > 1 { + var significanceState = coefficients.map { $0 != 0 } + var passIdx = 1 + var bp = topBitPlane - 1 + + while passIdx < segments.count && bp >= 0 { + // SigProp pass + let sigPropData = segments[passIdx] + let sigPropResult = try decodeSigProp( + coefficients: coefficients, + sigPropData: sigPropData, + significanceState: significanceState, + bitPlane: bp + ) + coefficients = sigPropResult.coefficients + significanceState = sigPropResult.significanceState + passIdx += 1 + + // MagRef pass + if passIdx < segments.count { + coefficients = try decodeMagRef( + coefficients: coefficients, + magRefData: segments[passIdx], + significanceState: significanceState, + bitPlane: bp + ) + passIdx += 1 + } + + bp -= 1 + } + } + + // Convert Int to Int32 + return coefficients.map { Int32($0) } + } + /// Applies the HT significance propagation pass to refine coefficients. /// /// - Parameters: From 12bff9269857c3d8533ffe06db658b01cf7b8579 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:35:28 +0000 Subject: [PATCH 2/6] Add Metal GPU availability fallback to DWT pipeline paths Agent-Logs-Url: https://github.com/Raster-Lab/J2KSwift/sessions/92efab50-87fd-48f6-82f6-4a38a03184b9 Co-authored-by: SureshKViswanathan <257696045+SureshKViswanathan@users.noreply.github.com> --- Sources/J2KCodec/J2KDecoderPipeline.swift | 17 +++++++++++++---- Sources/J2KCodec/J2KEncoderPipeline.swift | 16 +++++++++++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/Sources/J2KCodec/J2KDecoderPipeline.swift b/Sources/J2KCodec/J2KDecoderPipeline.swift index 37aeb36c..943a11fc 100644 --- a/Sources/J2KCodec/J2KDecoderPipeline.swift +++ b/Sources/J2KCodec/J2KDecoderPipeline.swift @@ -211,8 +211,12 @@ struct DecoderPipeline: Sendable { /// Decodes a JPEG 2000 codestream using GPU-accelerated inverse DWT. /// - /// Uses Metal GPU for the CDF 9/7 inverse wavelet transform stage. - /// Falls back to CPU for lossless (5/3) and custom wavelet filters. + /// Uses Metal GPU for the inverse wavelet transform and colour transform + /// stages when available. Falls back to CPU implementations when Metal is + /// unavailable (e.g. Linux, CI servers without GPU). + /// + /// HTJ2K block decoding (when signaled via COD marker bit 6) always runs + /// on CPU using the FBCOT algorithm. /// /// - Parameters: /// - data: The JPEG 2000 codestream data. @@ -1730,8 +1734,8 @@ struct DecoderPipeline: Sendable { /// GPU-accelerated inverse wavelet transform using Metal. /// - /// Uses Metal GPU for CDF 9/7 irreversible inverse DWT. - /// Falls back to CPU for Le Gall 5/3 reversible and custom filters. + /// Uses Metal GPU for CDF 9/7 irreversible and Le Gall 5/3 reversible inverse DWT. + /// Falls back to CPU when Metal is unavailable or for custom filters. private func applyInverseWaveletTransformGPU( _ subbands: [SubbandInfo], metadata: CodestreamMetadata @@ -1746,6 +1750,11 @@ struct DecoderPipeline: Sendable { return try applyInverseWaveletTransform(subbands, metadata: metadata) } + // Fall back to CPU when Metal GPU is not available (e.g. Linux, CI servers) + guard J2KMetalDWT.isAvailable else { + return try applyInverseWaveletTransform(subbands, metadata: metadata) + } + // Select filter based on configuration let metalFilter: J2KMetalDWTFilter if case .irreversible97 = metadata.configuration.waveletFilter { diff --git a/Sources/J2KCodec/J2KEncoderPipeline.swift b/Sources/J2KCodec/J2KEncoderPipeline.swift index e5ca0466..7ac84b34 100644 --- a/Sources/J2KCodec/J2KEncoderPipeline.swift +++ b/Sources/J2KCodec/J2KEncoderPipeline.swift @@ -186,8 +186,12 @@ struct EncoderPipeline: Sendable { /// Encodes an image through the GPU-accelerated JPEG 2000 pipeline. /// - /// Uses Metal GPU acceleration for the CDF 9/7 wavelet transform stage. - /// Falls back to CPU for lossless (5/3) and custom wavelet filters. + /// Uses Metal GPU acceleration for the CDF 9/7 wavelet transform and colour + /// transform stages when available. Falls back to CPU implementations when + /// Metal is unavailable (e.g. Linux, CI servers without GPU). + /// + /// HTJ2K block coding (when `config.useHTJ2K` is true) always runs on CPU + /// using the FBCOT algorithm with SIMD-accelerated significance extraction. /// /// - Parameters: /// - image: The image to encode. @@ -256,7 +260,7 @@ struct EncoderPipeline: Sendable { /// GPU-accelerated wavelet transform using Metal. /// /// Uses Metal GPU for both CDF 9/7 irreversible and Le Gall 5/3 reversible wavelet transforms. - /// Falls back to CPU for custom/arbitrary wavelet kernels. + /// Falls back to CPU when Metal is unavailable or for custom/arbitrary wavelet kernels. private func applyWaveletTransformGPU( _ components: [[Int32]], doubleComponents: [[Double]]? = nil, width: Int, height: Int @@ -279,6 +283,12 @@ struct EncoderPipeline: Sendable { width: width, height: height) } + // Fall back to CPU when Metal GPU is not available (e.g. Linux, CI servers) + guard J2KMetalDWT.isAvailable else { + return try applyWaveletTransform(components, doubleComponents: doubleComponents, + width: width, height: height) + } + // Select filter based on configuration let metalFilter: J2KMetalDWTFilter = config.useReversibleFilter ? .reversible53 : .irreversible97 From a214e7c17ee26162d2f1f6a7e943ae621d17fe21 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:37:38 +0000 Subject: [PATCH 3/6] Address code review: remove force unwraps, fix unused vars, use bit ops for topBitPlane Agent-Logs-Url: https://github.com/Raster-Lab/J2KSwift/sessions/92efab50-87fd-48f6-82f6-4a38a03184b9 Co-authored-by: SureshKViswanathan <257696045+SureshKViswanathan@users.noreply.github.com> --- Sources/J2KCodec/J2KEncoderPipeline.swift | 2 +- Sources/J2KCodec/J2KHTBlockCoder.swift | 17 ++++++----------- Sources/J2KCodec/J2KHTCodec.swift | 2 +- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/Sources/J2KCodec/J2KEncoderPipeline.swift b/Sources/J2KCodec/J2KEncoderPipeline.swift index 7ac84b34..399579ee 100644 --- a/Sources/J2KCodec/J2KEncoderPipeline.swift +++ b/Sources/J2KCodec/J2KEncoderPipeline.swift @@ -1373,7 +1373,7 @@ struct EncoderPipeline: Sendable { let maxMag = coeffsInt.lazy.map { abs($0) }.max() ?? 0 let topBitPlane: Int if maxMag > 0 { - topBitPlane = Int(log2(Double(maxMag))) + topBitPlane = Int.bitWidth - maxMag.leadingZeroBitCount - 1 } else { topBitPlane = 0 } diff --git a/Sources/J2KCodec/J2KHTBlockCoder.swift b/Sources/J2KCodec/J2KHTBlockCoder.swift index 0fba6a7a..75cb9628 100644 --- a/Sources/J2KCodec/J2KHTBlockCoder.swift +++ b/Sources/J2KCodec/J2KHTBlockCoder.swift @@ -485,10 +485,10 @@ struct HTBlockEncoder: Sendable { sigBits.withUnsafeMutableBufferPointer { sigPtr in absMags.withUnsafeMutableBufferPointer { magPtr in signBits.withUnsafeMutableBufferPointer { sgnPtr in - let src = srcPtr.baseAddress! - let sig = sigPtr.baseAddress! - let mag = magPtr.baseAddress! - let sgn = sgnPtr.baseAddress! + guard let src = srcPtr.baseAddress, + let sig = sigPtr.baseAddress, + let mag = magPtr.baseAddress, + let sgn = sgnPtr.baseAddress else { return } let simdCount = count / 4 @@ -806,12 +806,12 @@ struct HTBlockDecoder: Sendable { // Parse the stream length header let headerMelLen = Int(data[data.startIndex]) << 8 | Int(data[data.startIndex + 1]) let headerVlcLen = Int(data[data.startIndex + 2]) << 8 | Int(data[data.startIndex + 3]) - _ = Int(data[data.startIndex + 4]) << 8 | Int(data[data.startIndex + 5]) + let headerMagsgnLen = Int(data[data.startIndex + 4]) << 8 | Int(data[data.startIndex + 5]) let headerSize = 6 let payloadStart = data.startIndex + headerSize let melEnd = payloadStart + headerMelLen - let magsgnEnd = melEnd + block.magsgnLength + let magsgnEnd = melEnd + headerMagsgnLen guard melEnd <= data.endIndex && magsgnEnd <= data.endIndex else { throw J2KError.decodingError("Invalid stream lengths in HT encoded block") @@ -941,9 +941,6 @@ struct HTBlockDecoder: Sendable { let vlcLen = Int(cleanupData[s + 2]) << 8 | Int(cleanupData[s + 3]) let magsgnLen = Int(cleanupData[s + 4]) << 8 | Int(cleanupData[s + 5]) - let headerSize = 6 - let payloadStart = s + headerSize - let block = HTEncodedBlock( codedData: cleanupData, passType: .htCleanup, @@ -954,8 +951,6 @@ struct HTBlockDecoder: Sendable { width: width, height: height ) - // Use the existing cleanup decoder via the HTEncodedBlock path - _ = payloadStart // payloadStart used by decodeCleanup internally var coefficients = try decodeCleanup(from: block) // Apply refinement passes (SigProp + MagRef pairs) diff --git a/Sources/J2KCodec/J2KHTCodec.swift b/Sources/J2KCodec/J2KHTCodec.swift index e6704a3a..d6d0719a 100644 --- a/Sources/J2KCodec/J2KHTCodec.swift +++ b/Sources/J2KCodec/J2KHTCodec.swift @@ -293,7 +293,7 @@ struct HTJ2KEncoder: Sendable { let maxMag = coefficients.map { abs($0) }.max() ?? 0 let topBitPlane: Int if maxMag > 0 { - topBitPlane = Int(log2(Double(maxMag))) + topBitPlane = Int.bitWidth - maxMag.leadingZeroBitCount - 1 } else { topBitPlane = 0 } From 51a34d78e10a09db30d4ae99215ce3e24a8b960a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:39:35 +0000 Subject: [PATCH 4/6] Fix VLC bounds check, cache abs magnitudes, address review round 2 Agent-Logs-Url: https://github.com/Raster-Lab/J2KSwift/sessions/92efab50-87fd-48f6-82f6-4a38a03184b9 Co-authored-by: SureshKViswanathan <257696045+SureshKViswanathan@users.noreply.github.com> --- Sources/J2KCodec/J2KEncoderPipeline.swift | 15 +++++++++------ Sources/J2KCodec/J2KHTBlockCoder.swift | 3 ++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/Sources/J2KCodec/J2KEncoderPipeline.swift b/Sources/J2KCodec/J2KEncoderPipeline.swift index 399579ee..de286f34 100644 --- a/Sources/J2KCodec/J2KEncoderPipeline.swift +++ b/Sources/J2KCodec/J2KEncoderPipeline.swift @@ -1369,8 +1369,11 @@ struct EncoderPipeline: Sendable { // Convert Int32 coefficients to Int for HTBlockEncoder interface let coeffsInt = pending.coefficients.map { Int($0) } - // Determine the most significant bit-plane from the magnitudes - let maxMag = coeffsInt.lazy.map { abs($0) }.max() ?? 0 + // Pre-compute absolute magnitudes (reused for maxMag, significance, and refinement) + let absMags = coeffsInt.map { abs($0) } + + // Determine the most significant bit-plane from the magnitudes (single-pass) + let maxMag = absMags.reduce(0) { Swift.max($0, $1) } let topBitPlane: Int if maxMag > 0 { topBitPlane = Int.bitWidth - maxMag.leadingZeroBitCount - 1 @@ -1384,9 +1387,9 @@ struct EncoderPipeline: Sendable { bitPlane: topBitPlane ) - // Build significance state from cleanup pass results + // Build significance state from cleanup pass results using cached abs magnitudes var significanceState = [Bool](repeating: false, count: pending.width * pending.height) - for i in 0..> topBitPlane) & 1 != 0 { + for i in 0..> topBitPlane) & 1 != 0 { significanceState[i] = true } @@ -1417,8 +1420,8 @@ struct EncoderPipeline: Sendable { passSegmentLengths.append(magRefData.count) cumulativePassBytes.append(allPassData.count) - // Update significance state for next bit-plane - for i in 0..> bp) & 1 != 0 { + // Update significance state for next bit-plane using cached abs magnitudes + for i in 0..> bp) & 1 != 0 { significanceState[i] = true } } diff --git a/Sources/J2KCodec/J2KHTBlockCoder.swift b/Sources/J2KCodec/J2KHTBlockCoder.swift index 75cb9628..fc294829 100644 --- a/Sources/J2KCodec/J2KHTBlockCoder.swift +++ b/Sources/J2KCodec/J2KHTBlockCoder.swift @@ -813,7 +813,8 @@ struct HTBlockDecoder: Sendable { let melEnd = payloadStart + headerMelLen let magsgnEnd = melEnd + headerMagsgnLen - guard melEnd <= data.endIndex && magsgnEnd <= data.endIndex else { + guard melEnd <= data.endIndex && magsgnEnd <= data.endIndex + && (magsgnEnd + headerVlcLen) <= data.endIndex else { throw J2KError.decodingError("Invalid stream lengths in HT encoded block") } From 7438a349c695a677d6199c5112c902fb01395963 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:48:49 +0000 Subject: [PATCH 5/6] Enhance benchmark CLI with GPU, HTJ2K, and compare-all modes with no-GPU fallback Agent-Logs-Url: https://github.com/Raster-Lab/J2KSwift/sessions/837df556-ae9d-48ce-a7a4-11fff5dfdceb Co-authored-by: SureshKViswanathan <257696045+SureshKViswanathan@users.noreply.github.com> --- Sources/J2KCLI/Benchmark.swift | 597 ++++++++++++++++++++++++++------- Sources/J2KCLI/main.swift | 3 + 2 files changed, 486 insertions(+), 114 deletions(-) diff --git a/Sources/J2KCLI/Benchmark.swift b/Sources/J2KCLI/Benchmark.swift index 245ccf97..e18ffb26 100644 --- a/Sources/J2KCLI/Benchmark.swift +++ b/Sources/J2KCLI/Benchmark.swift @@ -2,14 +2,47 @@ // Benchmark.swift // J2KSwift // -/// Benchmark command implementation +/// Benchmark command implementation for CPU, GPU, and HTJ2K encoding/decoding +/// performance measurement with automatic GPU availability detection and fallback. import Foundation import J2KCore import J2KCodec extension J2KCLI { - /// Benchmark command: measure encoding/decoding performance + + // MARK: - GPU Availability + + /// Detects whether Metal GPU acceleration is available on the current platform. + /// + /// On macOS/iOS with Metal, this checks if the Metal framework is importable. + /// On Linux and other platforms without Metal, this returns false. + /// When GPU is unavailable, the GPU pipeline transparently falls back to CPU, + /// but the benchmark reports this so users understand the results. + private static var isGPUAvailable: Bool { + #if canImport(Metal) + return true + #else + return false + #endif + } + + /// Returns a description of the GPU backend for display. + private static var gpuBackendDescription: String { + #if canImport(Metal) + return "Metal" + #else + return "none" + #endif + } + + // MARK: - Benchmark Command + + /// Benchmark command: measure encoding/decoding performance across backends. + /// + /// Supports CPU, GPU, and HTJ2K modes. When `--gpu` is requested but Metal + /// is unavailable, the benchmark runs with CPU fallback and clearly reports + /// that GPU was not available. static func benchmarkCommand(_ args: [String]) async throws { let options = parseArguments(args) @@ -29,29 +62,44 @@ extension J2KCLI { let encodeOnly = options["encode-only"] != nil let decodeOnly = options["decode-only"] != nil let compareOJ = options["compare-openjpeg"] != nil + let useGPU = options["gpu"] != nil + let useHTJ2K = options["htj2k"] != nil + let compareAll = options["compare-all"] != nil let outputFmt = options["format"] ?? "text" + // Detect GPU availability + let gpuAvailable = isGPUAvailable + // Load input image if outputFmt == "text" { print("Loading test image: \(inputPath)") } let image = try loadImage(from: inputPath) - if outputFmt == "text" { print(" Image: \(image.width)×\(image.height), \(image.componentCount) component(s)\n") } + if outputFmt == "text" { + print(" Image: \(image.width)×\(image.height), \(image.componentCount) component(s)") + print(" Pixels: \(image.width * image.height)") + print("") + } - // Configure encoder - var config: J2KEncodingConfiguration - if let preset = options["preset"] { - switch preset { - case "fast": config = J2KEncodingPreset.fast.configuration() - case "balanced": config = J2KEncodingPreset.balanced.configuration() - case "quality": config = J2KEncodingPreset.quality.configuration() - default: - print("Error: Unknown preset '\(preset)'") - exit(1) - } - if outputFmt == "text" { print("Using preset: \(preset)\n") } - } else { - config = J2KEncodingConfiguration() + // Detect and report platform capabilities + if outputFmt == "text" { + print("Platform:") + #if canImport(Metal) + print(" GPU: Metal (available)") + #else + print(" GPU: not available (Metal not supported on this platform)") + #endif + #if arch(arm64) + print(" SIMD: NEON") + #elseif arch(x86_64) + print(" SIMD: SSE/AVX") + #else + print(" SIMD: generic") + #endif + print("") } + // Configure encoder + let config = makeEncodingConfiguration(from: options, outputFmt: outputFmt) + var results: [String: Any] = [ "image": [ "path": inputPath, @@ -62,91 +110,99 @@ extension J2KCLI { ], "runs": runs, "warmup": warmupRuns, + "gpu_available": gpuAvailable, + "gpu_backend": gpuBackendDescription, ] - // Benchmark encoding - var encodedData: Data? - if !decodeOnly { - let encoder = J2KEncoder(encodingConfiguration: config) - - // Warm-up - if warmupRuns > 0 && outputFmt == "text" { print("Warming up (\(warmupRuns) run(s))…") } - for _ in 0.. 1 && outputFmt == "text" { + printComparisonTable(results: results, modes: modes, gpuAvailable: gpuAvailable) } if compareOJ { @@ -161,7 +217,7 @@ extension J2KCLI { print(str) } case "csv": - printCSV(results) + printCSV(results, modes: modes) default: if outputFmt == "text" { print("Benchmark complete!") } } @@ -171,7 +227,7 @@ extension J2KCLI { let ext = URL(fileURLWithPath: outPath).pathExtension.lowercased() let saveData: Data if ext == "csv" { - let csv = buildCSVString(results) + let csv = buildCSVString(results, modes: modes) saveData = csv.data(using: .utf8) ?? Data() } else { saveData = (try? JSONSerialization.data(withJSONObject: results, options: .prettyPrinted)) ?? Data() @@ -181,6 +237,270 @@ extension J2KCLI { } } + // MARK: - Benchmark Modes + + private enum BenchmarkMode: String { + case cpu = "cpu" + case gpu = "gpu" + case htj2k = "htj2k" + + var label: String { rawValue } + + var title: String { + switch self { + case .cpu: return "CPU Benchmark (Part 1 EBCOT)" + case .gpu: return "GPU Benchmark (Metal Pipeline)" + case .htj2k: return "HTJ2K Benchmark (Part 15 FBCOT)" + } + } + } + + // MARK: - Encoding Configuration + + private static func makeEncodingConfiguration( + from options: [String: String], + outputFmt: String + ) -> J2KEncodingConfiguration { + if let preset = options["preset"] { + switch preset { + case "fast": + if outputFmt == "text" { print("Using preset: fast\n") } + return J2KEncodingPreset.fast.configuration() + case "balanced": + if outputFmt == "text" { print("Using preset: balanced\n") } + return J2KEncodingPreset.balanced.configuration() + case "quality": + if outputFmt == "text" { print("Using preset: quality\n") } + return J2KEncodingPreset.quality.configuration() + default: + print("Error: Unknown preset '\(preset)'") + exit(1) + } + } + return J2KEncodingConfiguration() + } + + // MARK: - Encode Benchmark + + private struct EncodeBenchmarkResult { + let data: Data + let stats: [String: Any] + } + + private static func benchmarkEncode( + image: J2KImage, + config: J2KEncodingConfiguration, + useGPU: Bool, + runs: Int, + warmupRuns: Int, + outputFmt: String + ) async throws -> EncodeBenchmarkResult { + let encoder = J2KEncoder(encodingConfiguration: config) + + // Warm-up + if warmupRuns > 0 && outputFmt == "text" { print("Warming up encoder (\(warmupRuns) run(s))…") } + for _ in 0.. [String: Any] { + let decoder = J2KDecoder() + + // Warm-up + if warmupRuns > 0 && outputFmt == "text" { print("Warming up decoder (\(warmupRuns) run(s))…") } + for _ in 0.. 1 { + print("") + let baseline = modes[0] + let baseEncAvg = (results["\(baseline.label)_encode"] as? [String: Any])?["average_ms"] as? Double + let baseDecAvg = (results["\(baseline.label)_decode"] as? [String: Any])?["average_ms"] as? Double + + var speedLine = String(format: "%-12s", "Speedup") + for mode in modes { + if mode == baseline { + speedLine += String(format: " %14s", "1.00x (base)") + } else { + let encAvg = (results["\(mode.label)_encode"] as? [String: Any])?["average_ms"] as? Double + let decAvg = (results["\(mode.label)_decode"] as? [String: Any])?["average_ms"] as? Double + // Use encode speedup if available, else decode + if let be = baseEncAvg, let ce = encAvg, ce > 0 { + let speedup = be / ce + speedLine += String(format: " %11.2fx enc", speedup) + } else if let bd = baseDecAvg, let cd = decAvg, cd > 0 { + let speedup = bd / cd + speedLine += String(format: " %11.2fx dec", speedup) + } else { + speedLine += String(format: " %14s", "—") + } + } + } + print(speedLine) + } + + print("") + if modes.contains(.gpu) && !gpuAvailable { + print("* GPU column ran with CPU fallback (Metal GPU not available)") + } + print("") + } + // MARK: - Statistics private struct Stats { @@ -192,16 +512,41 @@ extension J2KCLI { } private static func computeStats(_ times: [Double]) -> Stats { + guard !times.isEmpty else { + return Stats(avg: 0, median: 0, min: 0, max: 0, stddev: 0) + } let sorted = times.sorted() let n = Double(times.count) let avg = times.reduce(0, +) / n - let median = times.count.isMultiple(of: 2) - ? (sorted[times.count / 2 - 1] + sorted[times.count / 2]) / 2 - : sorted[times.count / 2] + let median: Double + if times.count == 1 { + median = sorted[0] + } else if times.count.isMultiple(of: 2) { + median = (sorted[times.count / 2 - 1] + sorted[times.count / 2]) / 2 + } else { + median = sorted[times.count / 2] + } let variance = times.count > 1 ? times.map { ($0 - avg) * ($0 - avg) }.reduce(0, +) / Double(times.count - 1) : 0.0 - return Stats(avg: avg, median: median, min: sorted.first!, max: sorted.last!, stddev: variance.squareRoot()) + return Stats( + avg: avg, + median: median, + min: sorted[0], + max: sorted[sorted.count - 1], + stddev: variance.squareRoot() + ) + } + + private static func printStats(label: String, stats: Stats, pixels: Int) { + print("\n \(label) Statistics:") + print(" Average: \(String(format: "%7.3f", stats.avg * 1000)) ms") + print(" Median: \(String(format: "%7.3f", stats.median * 1000)) ms") + print(" Min: \(String(format: "%7.3f", stats.min * 1000)) ms") + print(" Max: \(String(format: "%7.3f", stats.max * 1000)) ms") + print(" Std Dev: \(String(format: "%7.3f", stats.stddev * 1000)) ms") + let mpps = stats.avg > 0 ? Double(pixels) / 1_000_000 / stats.avg : 0 + print(" Throughput: \(String(format: "%.2f", mpps)) MP/s\n") } private static func buildStatsDict( @@ -214,7 +559,7 @@ extension J2KCLI { "min_ms": stats.min * 1000, "max_ms": stats.max * 1000, "stddev_ms": stats.stddev * 1000, - "throughput_mpps": Double(pixels) / 1_000_000 / stats.avg, + "throughput_mpps": stats.avg > 0 ? Double(pixels) / 1_000_000 / stats.avg : 0, ] if let cs = compressedSize { d["compressed_size"] = cs } return d @@ -222,23 +567,28 @@ extension J2KCLI { // MARK: - CSV output - private static func printCSV(_ results: [String: Any]) { - print(buildCSVString(results)) + private static func printCSV(_ results: [String: Any], modes: [BenchmarkMode]) { + print(buildCSVString(results, modes: modes)) } - private static func buildCSVString(_ results: [String: Any]) -> String { - var lines = ["metric,value_ms,throughput_mpps"] - for key in ["encode", "decode"] { - guard let d = results[key] as? [String: Any] else { continue } - let avg = d["average_ms"] as? Double ?? 0 - let mpps = d["throughput_mpps"] as? Double ?? 0 - lines.append("\(key)_avg,\(String(format: "%.3f", avg)),\(String(format: "%.2f", mpps))") - let min = d["min_ms"] as? Double ?? 0 - let max = d["max_ms"] as? Double ?? 0 - let std = d["stddev_ms"] as? Double ?? 0 - lines.append("\(key)_min,\(String(format: "%.3f", min)),") - lines.append("\(key)_max,\(String(format: "%.3f", max)),") - lines.append("\(key)_stddev,\(String(format: "%.3f", std)),") + private static func buildCSVString(_ results: [String: Any], modes: [BenchmarkMode]) -> String { + var lines = ["mode,direction,average_ms,median_ms,min_ms,max_ms,stddev_ms,throughput_mpps,compressed_size,gpu_available"] + let gpuAvail = results["gpu_available"] as? Bool ?? false + + for mode in modes { + for direction in ["encode", "decode"] { + let key = "\(mode.label)_\(direction)" + guard let d = results[key] as? [String: Any] else { continue } + let avg = d["average_ms"] as? Double ?? 0 + let med = d["median_ms"] as? Double ?? 0 + let mn = d["min_ms"] as? Double ?? 0 + let mx = d["max_ms"] as? Double ?? 0 + let std = d["stddev_ms"] as? Double ?? 0 + let mpps = d["throughput_mpps"] as? Double ?? 0 + let cs = d["compressed_size"] as? Int + let csStr = cs.map { String($0) } ?? "" + lines.append("\(mode.label),\(direction),\(String(format: "%.3f", avg)),\(String(format: "%.3f", med)),\(String(format: "%.3f", mn)),\(String(format: "%.3f", mx)),\(String(format: "%.3f", std)),\(String(format: "%.2f", mpps)),\(csStr),\(gpuAvail)") + } } return lines.joined(separator: "\n") } @@ -252,17 +602,36 @@ extension J2KCLI { USAGE: j2k benchmark -i [options] + MODES: + (default) CPU-only Part 1 EBCOT benchmark + --gpu GPU-accelerated pipeline (falls back to + CPU when Metal is unavailable) + --htj2k HTJ2K Part 15 FBCOT benchmark + --compare-all Run CPU, GPU, and HTJ2K side-by-side + with a comparison summary table + OPTIONS: -i, --input PATH Input image file -r, --runs N Measurement runs (default: 3) - --warmup N Warm-up runs (default: 1) + --warmup N Warm-up runs before measurement (default: 1) -o, --output PATH Output report file --format text|json|csv Output format (default: text) --encode-only Only benchmark encoding --decode-only Only benchmark decoding --preset fast|balanced|quality Encoding preset --compare-openjpeg Note if OpenJPEG comparison is available + + GPU NOTES: + When --gpu or --compare-all is used and Metal is not available + (e.g. on Linux or CI servers), the GPU pipeline automatically + falls back to CPU. The benchmark clearly reports this so results + are not misleading. + + EXAMPLES: + j2k benchmark -i test.pgm -r 10 + j2k benchmark -i test.pgm --gpu + j2k benchmark -i test.pgm --htj2k --encode-only + j2k benchmark -i test.pgm --compare-all --format csv -o results.csv """) } } - diff --git a/Sources/J2KCLI/main.swift b/Sources/J2KCLI/main.swift index fdb8b80c..686f51a1 100644 --- a/Sources/J2KCLI/main.swift +++ b/Sources/J2KCLI/main.swift @@ -224,6 +224,9 @@ struct J2KCLI { --encode-only Only benchmark encoding --decode-only Only benchmark decoding --preset NAME Use preset: fast, balanced, quality + --gpu Benchmark GPU-accelerated pipeline + --htj2k Benchmark HTJ2K (Part 15) mode + --compare-all Compare CPU vs GPU vs HTJ2K --compare-openjpeg Compare with OpenJPEG (if available) GLOBAL FLAGS: From d13d7723e1d59bd43b52ef3b0fef0f2714ce40b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 00:52:55 +0000 Subject: [PATCH 6/6] Address code review: extract HTStreamHeader helper, use .max(), runtime GPU detection Agent-Logs-Url: https://github.com/Raster-Lab/J2KSwift/sessions/837df556-ae9d-48ce-a7a4-11fff5dfdceb Co-authored-by: SureshKViswanathan <257696045+SureshKViswanathan@users.noreply.github.com> --- Sources/J2KCLI/Benchmark.swift | 19 ++++--- Sources/J2KCodec/J2KEncoderPipeline.swift | 4 +- Sources/J2KCodec/J2KHTBlockCoder.swift | 64 +++++++++++++++-------- 3 files changed, 57 insertions(+), 30 deletions(-) diff --git a/Sources/J2KCLI/Benchmark.swift b/Sources/J2KCLI/Benchmark.swift index e18ffb26..9daf3742 100644 --- a/Sources/J2KCLI/Benchmark.swift +++ b/Sources/J2KCLI/Benchmark.swift @@ -15,13 +15,20 @@ extension J2KCLI { /// Detects whether Metal GPU acceleration is available on the current platform. /// - /// On macOS/iOS with Metal, this checks if the Metal framework is importable. - /// On Linux and other platforms without Metal, this returns false. - /// When GPU is unavailable, the GPU pipeline transparently falls back to CPU, - /// but the benchmark reports this so users understand the results. + /// Uses a runtime check via `MTLCreateSystemDefaultDevice()` when Metal is + /// available at compile time, which handles headless systems where Metal is + /// importable but no GPU device exists. On platforms without Metal (Linux), + /// this returns false at compile time. + /// + /// When GPU is unavailable, the GPU pipeline (`encodeGPU`/`decodeGPU`) + /// transparently falls back to CPU. The benchmark reports the fallback + /// so users understand the results. private static var isGPUAvailable: Bool { #if canImport(Metal) - return true + if let _ = MTLCreateSystemDefaultDevice() { + return true + } + return false #else return false #endif @@ -30,7 +37,7 @@ extension J2KCLI { /// Returns a description of the GPU backend for display. private static var gpuBackendDescription: String { #if canImport(Metal) - return "Metal" + return isGPUAvailable ? "Metal" : "Metal (no device)" #else return "none" #endif diff --git a/Sources/J2KCodec/J2KEncoderPipeline.swift b/Sources/J2KCodec/J2KEncoderPipeline.swift index de286f34..1c2b8389 100644 --- a/Sources/J2KCodec/J2KEncoderPipeline.swift +++ b/Sources/J2KCodec/J2KEncoderPipeline.swift @@ -1372,8 +1372,8 @@ struct EncoderPipeline: Sendable { // Pre-compute absolute magnitudes (reused for maxMag, significance, and refinement) let absMags = coeffsInt.map { abs($0) } - // Determine the most significant bit-plane from the magnitudes (single-pass) - let maxMag = absMags.reduce(0) { Swift.max($0, $1) } + // Determine the most significant bit-plane from the magnitudes + let maxMag = absMags.max() ?? 0 let topBitPlane: Int if maxMag > 0 { topBitPlane = Int.bitWidth - maxMag.leadingZeroBitCount - 1 diff --git a/Sources/J2KCodec/J2KHTBlockCoder.swift b/Sources/J2KCodec/J2KHTBlockCoder.swift index fc294829..11bb437a 100644 --- a/Sources/J2KCodec/J2KHTBlockCoder.swift +++ b/Sources/J2KCodec/J2KHTBlockCoder.swift @@ -798,29 +798,23 @@ struct HTBlockDecoder: Sendable { // The coded data has a 6-byte length header: // [melLen:2 | vlcLen:2 | magsgnLen:2 | MEL data | MagSgn data | VLC data (reversed)] - guard data.count >= 6 else { + guard let header = HTStreamHeader.read(from: data) else { // Empty or trivial block — all zeros return coefficients } - // Parse the stream length header - let headerMelLen = Int(data[data.startIndex]) << 8 | Int(data[data.startIndex + 1]) - let headerVlcLen = Int(data[data.startIndex + 2]) << 8 | Int(data[data.startIndex + 3]) - let headerMagsgnLen = Int(data[data.startIndex + 4]) << 8 | Int(data[data.startIndex + 5]) - - let headerSize = 6 - let payloadStart = data.startIndex + headerSize - let melEnd = payloadStart + headerMelLen - let magsgnEnd = melEnd + headerMagsgnLen + let payloadStart = data.startIndex + HTStreamHeader.size + let melEnd = payloadStart + header.melLen + let magsgnEnd = melEnd + header.magsgnLen guard melEnd <= data.endIndex && magsgnEnd <= data.endIndex - && (magsgnEnd + headerVlcLen) <= data.endIndex else { + && (magsgnEnd + header.vlcLen) <= data.endIndex else { throw J2KError.decodingError("Invalid stream lengths in HT encoded block") } let melData = data[payloadStart..= 6 else { + + // Parse stream length header from cleanup data + guard let header = HTStreamHeader.read(from: cleanupData) else { // Trivial block with no significant coefficients return [Int32](repeating: 0, count: width * height) } - // Parse stream length header from cleanup data - let s = cleanupData.startIndex - let melLen = Int(cleanupData[s]) << 8 | Int(cleanupData[s + 1]) - let vlcLen = Int(cleanupData[s + 2]) << 8 | Int(cleanupData[s + 3]) - let magsgnLen = Int(cleanupData[s + 4]) << 8 | Int(cleanupData[s + 5]) - let block = HTEncodedBlock( codedData: cleanupData, passType: .htCleanup, - melLength: melLen, - vlcLength: vlcLen, - magsgnLength: magsgnLen, + melLength: header.melLen, + vlcLength: header.vlcLen, + magsgnLength: header.magsgnLen, bitPlane: topBitPlane, width: width, height: height @@ -1105,6 +1095,36 @@ struct HTBlockDecoder: Sendable { // MARK: - Encoded Block +/// Parsed 6-byte stream length header from HT cleanup pass data. +/// +/// Format: `[melLen:2 | vlcLen:2 | magsgnLen:2]` (big-endian UInt16 values). +private struct HTStreamHeader { + /// Size of the header in bytes. + static let size = 6 + + /// Length of the MEL stream in bytes. + let melLen: Int + + /// Length of the VLC stream in bytes. + let vlcLen: Int + + /// Length of the MagSgn stream in bytes. + let magsgnLen: Int + + /// Reads the 6-byte header from the start of `data`. + /// + /// - Parameter data: Data containing the header (must have at least 6 bytes). + /// - Returns: The parsed header, or `nil` if data is too short. + static func read(from data: Data) -> HTStreamHeader? { + guard data.count >= size else { return nil } + let s = data.startIndex + let mel = Int(data[s]) << 8 | Int(data[s + 1]) + let vlc = Int(data[s + 2]) << 8 | Int(data[s + 3]) + let mag = Int(data[s + 4]) << 8 | Int(data[s + 5]) + return HTStreamHeader(melLen: mel, vlcLen: vlc, magsgnLen: mag) + } +} + /// Represents an HTJ2K-encoded code-block. /// /// Contains the coded data and metadata from an HT block encoding operation,