From a8e87489fee2efd377a4d61af8e12e5c5fbfa63b Mon Sep 17 00:00:00 2001 From: GF Date: Fri, 23 Oct 2026 01:44:43 -0400 Subject: [PATCH] fix(metal): seed never-included code-blocks with zero in the zero-bitplane tree The Metal packetizer gave a code-block with no coding passes its own zero-bitplane count (all bitplanes) in the zero-bitplane tag tree, while the CPU packetizer seeds it with zero. Decoders never read that leaf, but it lowers or raises the parent minimum, so headers with an empty block beside coded ones differed: all-stage Metal lossy encodes were valid but not byte-identical to the CPU (mosaic qfactor 86: 2 bytes shorter). The Metal tree now seeds such blocks as the CPU does. (cherry picked from commit 575f6d2e2f3b3743d71ce91fbf60c660d55c2873) (cherry picked from commit 6341a18dd2c33d30ece345ddb1b8a2b0c946706b) --- CHANGELOG.md | 4 ++ crates/j2k-metal/src/encode/tests/kernels.rs | 54 +++++++++++++++++++ .../src/encode_bitstream_packetize.metal | 14 +++-- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e831ea8e..8a794be1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ and stale roadmap entries have been removed from the public documentation set. it out. This affected the CPU and Metal batches alike. On the 19 x 13 OpenJPH fixture in 11 x 7 tiles, 4,598 of the 17,290 half-scale regions failed to prepare; all now match single-image decode. +- All-stage Metal lossy encodes seed code-blocks without coding passes with zero + in the zero-bitplane tag tree, as the CPU encoder does, so their packet headers + match the CPU's byte for byte. Earlier codestreams were valid but could differ + from the CPU's by a few header bytes. - `j2k-tilecodec` LZW decode no longer reports valid TIFF strips and tiles as truncated. weezl reads input ahead of decoding, so the call that consumes the last byte can still hold codes up to the end code, often in diff --git a/crates/j2k-metal/src/encode/tests/kernels.rs b/crates/j2k-metal/src/encode/tests/kernels.rs index dd46ba10..ac230ea9 100644 --- a/crates/j2k-metal/src/encode/tests/kernels.rs +++ b/crates/j2k-metal/src/encode/tests/kernels.rs @@ -546,6 +546,60 @@ fn metal_htj2k_quality_layers_use_native_candidates_and_match_scalar_output() { assert!(accelerator.ht_code_block_dispatches() > 0); } +#[cfg(target_os = "macos")] +#[test] +fn metal_all_stage_lossy_encodes_match_cpu_bytes_with_empty_code_blocks() { + if !should_run_metal_runtime() { + return; + } + + // The left half is textured and the right half flat, so detail bands hold + // empty code-blocks beside coded ones in the same tag trees. + let (width, height) = (256_u32, 256_u32); + let pixels: Vec = (0..width * height * 3) + .map(|idx| { + let x = (idx / 3) % width; + let texture = if x < width / 2 { + (idx * 37 + idx / 7) & 0x07 + } else { + 0 + }; + u8::try_from((x / 4 + texture) & 0xFF).expect("masked pixel") + }) + .collect(); + let samples = + J2kLossySamples::new(&pixels, width, height, 3, 8, false).expect("valid RGB samples"); + for (mode, qfactor) in [ + (J2kBlockCodingMode::HighThroughput, Some(60)), + (J2kBlockCodingMode::HighThroughput, None), + (J2kBlockCodingMode::Classic, None), + ] { + let options = J2kLossyEncodeOptions::default() + .with_block_coding_mode(mode) + .with_max_decomposition_levels(Some(2)) + .with_qfactor(qfactor) + .with_validation(J2kEncodeValidation::External); + let cpu = j2k::encode_j2k_lossy( + samples, + &options + .clone() + .with_backend(EncodeBackendPreference::CpuOnly), + ) + .expect("CPU lossy encode"); + let mut accelerator = MetalEncodeStageAccelerator::default(); + let metal = encode_j2k_lossy_with_accelerator( + samples, + &options.with_backend(EncodeBackendPreference::RequireDevice), + BackendKind::Metal, + &mut accelerator, + ) + .expect("Metal all-stage lossy encode"); + + assert_eq!(metal.backend, BackendKind::Metal, "{mode:?} {qfactor:?}"); + assert_eq!(metal.codestream, cpu.codestream, "{mode:?} {qfactor:?}"); + } +} + #[cfg(target_os = "macos")] #[test] fn metal_htj2k_lossy_rgb_facade_reports_forward_ict_dispatch() { diff --git a/crates/j2k-metal/src/encode_bitstream_packetize.metal b/crates/j2k-metal/src/encode_bitstream_packetize.metal index 2a8f6cf1..7b760eb3 100644 --- a/crates/j2k-metal/src/encode_bitstream_packetize.metal +++ b/crates/j2k-metal/src/encode_bitstream_packetize.metal @@ -830,9 +830,12 @@ inline bool j2k_packet_prepare_tree( } for (uint idx = 0u; idx < block_count; ++idx) { const J2kPacketBlock block = blocks[block_offset + idx]; + // A block that is never included contributes zero to the zero-bitplane + // tree, as the CPU packetizer seeds it, so headers match byte for byte. + const bool included = block.num_coding_passes > 0u; value[idx] = zero_bitplanes - ? block.num_zero_bitplanes - : (block.num_coding_passes > 0u ? inclusion_layer : 0x7FFFFFFFu); + ? (included ? block.num_zero_bitplanes : 0u) + : (included ? inclusion_layer : 0x7FFFFFFFu); } for (uint level = 1u; level < levels; ++level) { const uint prev_w = level_widths[level - 1u]; @@ -910,9 +913,12 @@ inline bool j2k_packet_prepare_tree_resident_classic( tier1_job_count, block_offset + idx ); + // A block that is never included contributes zero to the zero-bitplane + // tree, as the CPU packetizer seeds it, so headers match byte for byte. + const bool included = block.num_coding_passes > 0u; value[idx] = zero_bitplanes - ? block.num_zero_bitplanes - : (block.num_coding_passes > 0u ? inclusion_layer : 0x7FFFFFFFu); + ? (included ? block.num_zero_bitplanes : 0u) + : (included ? inclusion_layer : 0x7FFFFFFFu); } for (uint level = 1u; level < levels; ++level) { const uint prev_w = level_widths[level - 1u];