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];