From 0acdc739fae1ea3520889bbcb58627f63a686153 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 01:19:48 -0400 Subject: [PATCH 01/30] fix(j2k-native): ignore tile-part data after a tile's last packet on every route The packet walk of decodes on the component planes failed with PacketParseFailure("packet header outlives the progression iterator") when a tile-part held bytes after the tile's last packet, and with "packet length markers contain unused entries" when its PLT listed more packets than the tile has. Region decodes, and the full-image decodes that stream their rows through them, stop after the last packet they need and decoded the same codestreams. The full-image route follows the pool's size, so a half-resolution decode of a 512 x 512 RGB image with one byte after its last packet failed on one thread and decoded on four. The walk now stops at the end of the tile's progression and leaves the rest of the tile-part unread, and PLT lengths left after the last packet are accepted. PLT lengths left while the progression continues in a later tile-part remain an error. The output equals that of the codestream without the extra data, as OpenJPEG 2.5 decodes these inputs. On a corpus of 4160 malformed and valid codestreams, full-resolution decodes now give the same result on the planes and streamed routes (305 inputs differed); valid codestreams parse the same packets as before. --- crates/j2k-native/src/j2c/segment.rs | 25 ++-- crates/j2k/tests/full_image_streaming.rs | 158 ++++++++++++++++++++++- 2 files changed, 174 insertions(+), 9 deletions(-) diff --git a/crates/j2k-native/src/j2c/segment.rs b/crates/j2k-native/src/j2c/segment.rs index e0435b58..81fd2739 100644 --- a/crates/j2k-native/src/j2c/segment.rs +++ b/crates/j2k-native/src/j2c/segment.rs @@ -378,8 +378,9 @@ impl<'s> PacketRelevance<'s> { } } -/// Parse packets until the tile-part ends, or until `relevance` has seen -/// every packet the ROI plan needs, which returns `Ok(true)`. +/// Parse packets until the tile-part or the tile's progression ends, or +/// until `relevance` has seen every packet the ROI plan needs, which returns +/// `Ok(true)`. #[expect( clippy::too_many_lines, reason = "one ordered pass keeps packet skipping, header parsing, body reads, and length checks together" @@ -410,9 +411,13 @@ fn parse_inner<'a>( // precinct's layers in a row, so it is looked up once for them. let mut last_record: Option<((u16, u8, u64), usize)> = None; while !tile_part.header().at_end() { - let progression_data = progression_iterator - .next() - .ok_or("packet header outlives the progression iterator")?; + let Some(progression_data) = progression_iterator.next() else { + // Every packet of the tile has been read. Bytes the tile-part + // holds after them belong to no packet and are left unread, as + // a region decode leaves the packets after its last required one. + lwarn!("tile-part data continues after the tile's last packet"); + return Ok(false); + }; #[cfg(feature = "std")] let ordinal = { let ordinal = walk.next_packet; @@ -668,9 +673,13 @@ fn parse_inner<'a>( } } - tile_part - .validate_all_packet_lengths_consumed() - .ok_or("packet length markers contain unused entries")?; + // PLT entries left once the tile's last packet has been read describe no + // packet, like bytes after it. + if tile_part.validate_all_packet_lengths_consumed().is_none() + && progression_iterator.next().is_some() + { + return Err("packet length markers contain unused entries"); + } Ok(false) } diff --git a/crates/j2k/tests/full_image_streaming.rs b/crates/j2k/tests/full_image_streaming.rs index 9a49d7ed..a116aca2 100644 --- a/crates/j2k/tests/full_image_streaming.rs +++ b/crates/j2k/tests/full_image_streaming.rs @@ -10,7 +10,7 @@ use j2k::{CpuDecodeParallelism, J2kDecoder, J2kScratchPool}; use j2k_core::{Downscale, PixelFormat}; -use j2k_native::{encode, EncodeOptions}; +use j2k_native::{encode, EncodeOptions, EncodeProgressionOrder}; use j2k_test_support::wrap_jp2_rgba_codestream; const WIDTH: u32 = 203; @@ -230,3 +230,159 @@ fn routes_alternate_on_one_decoder() { assert!(out == expected, "budget {budget}"); } } + +/// `codestream` with `edit` applied to each tile-part, from its SOT marker +/// to its end, and each `Psot` set to the edited length. +fn edit_tile_parts(codestream: &[u8], mut edit: impl FnMut(&mut Vec)) -> Vec { + let marker_end = |at: usize| { + at + 2 + usize::from(u16::from_be_bytes([codestream[at + 2], codestream[at + 3]])) + }; + let mut at = 2; + while codestream[at..at + 2] != [0xff, 0x90] { + at = marker_end(at); + } + let mut edited = codestream[..at].to_vec(); + while codestream[at..at + 2] == [0xff, 0x90] { + let psot = u32::from_be_bytes(codestream[at + 6..at + 10].try_into().expect("Psot")); + let end = at + usize::try_from(psot).expect("Psot fits"); + let mut part = codestream[at..end].to_vec(); + edit(&mut part); + let psot = u32::try_from(part.len()).expect("edited Psot fits"); + part[6..10].copy_from_slice(&psot.to_be_bytes()); + edited.extend_from_slice(&part); + at = end; + } + assert_eq!( + codestream[at..], + [0xff, 0xd9], + "EOC follows the last tile-part" + ); + edited.extend_from_slice(&codestream[at..]); + edited +} + +/// `codestream` with a zero byte after the last packet of each tile-part. +fn with_byte_after_last_packets(codestream: &[u8]) -> Vec { + edit_tile_parts(codestream, |part| part.push(0)) +} + +/// `codestream` with one more packet length in each tile-part's PLT marker +/// than the tile-part has packets. +fn with_surplus_packet_lengths(codestream: &[u8]) -> Vec { + edit_tile_parts(codestream, |part| { + let mut at = 12; + while part[at..at + 2] != [0xff, 0x58] { + assert_ne!(part[at..at + 2], [0xff, 0x93], "tile-part has a PLT marker"); + at += 2 + usize::from(u16::from_be_bytes([part[at + 2], part[at + 3]])); + } + let length = u16::from_be_bytes([part[at + 2], part[at + 3]]); + part.insert(at + 2 + usize::from(length), 5); + part[at + 2..at + 4].copy_from_slice(&(length + 1).to_be_bytes()); + }) +} + +/// Data a tile-part holds after its tile's last packet is no packet's: bytes +/// after it, or PLT lengths past it, change no sample. Every route decodes +/// such a codestream as it decodes the codestream without them, on a pool of +/// one thread and of four (a half-resolution decode of the 512 x 512 RGB image +/// streams on 2 to 11 pool threads and keeps the planes on one). +#[test] +fn data_after_a_tiles_last_packet_decodes_as_without_it_on_every_route() { + const SIDE: u32 = 512; + let rgb_pixels: Vec = (0..SIDE * SIDE * 3) + .map(|index| { + let (pixel, component) = (index / 3, index % 3); + ((pixel % SIDE) * (component + 1) + (pixel / SIDE) * 3).to_le_bytes()[0] + }) + .collect(); + let rgb = encode( + &rgb_pixels, + SIDE, + SIDE, + 3, + 8, + false, + &EncodeOptions { + num_decomposition_levels: 3, + reversible: false, + ..EncodeOptions::default() + }, + ) + .expect("encode 512 x 512 fixture"); + let tiled = EncodeOptions { + num_decomposition_levels: 2, + num_layers: 2, + progression_order: EncodeProgressionOrder::Rpcl, + tile_size: Some((64, 64)), + write_plt: true, + ..EncodeOptions::default() + }; + let gray = codestream(1, &tiled); + let rgb_ht = codestream( + 3, + &EncodeOptions { + use_ht_block_coding: true, + ..tiled + }, + ); + let rgb_formats = &[PixelFormat::Rgb8, PixelFormat::Rgb16][..]; + let cases = [ + ( + "rgb, byte after", + &rgb, + with_byte_after_last_packets(&rgb), + rgb_formats, + ), + ( + "gray tiled PLT, byte after", + &gray, + with_byte_after_last_packets(&gray), + &[PixelFormat::Gray8][..], + ), + ( + "gray tiled PLT, surplus lengths", + &gray, + with_surplus_packet_lengths(&gray), + &[PixelFormat::Gray8][..], + ), + ( + "rgb tiled HT PLT, both", + &rgb_ht, + with_byte_after_last_packets(&with_surplus_packet_lengths(&rgb_ht)), + rgb_formats, + ), + ]; + let pools = [1, 4].map(|threads| { + rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .expect("pool") + }); + for (label, clean, edited, formats) in &cases { + for &fmt in *formats { + for scale in [Downscale::None, Downscale::Half] { + let expected = decode( + clean, + Some(usize::MAX), + CpuDecodeParallelism::Serial, + fmt, + scale, + 0, + ) + .expect("clean fixture decodes"); + for (pool, threads) in pools.iter().zip([1, 4]) { + for budget in [Some(usize::MAX), Some(0), None] { + let decoded = pool.install(|| { + decode(edited, budget, CpuDecodeParallelism::Auto, fmt, scale, 0) + }); + assert!( + decoded.as_ref() == Some(&expected), + "{label} {fmt:?} {scale:?} {threads} threads, planes budget {budget:?}: {}", + if decoded.is_some() { "other bytes" } else { "failed" } + ); + } + } + } + } + } +} From fb03d35e30be2aff454041557958c6d0c2993125 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 02:08:52 -0500 Subject: [PATCH 02/30] fix(j2k-native): keep a non-strict packet walk's progression on surplus PLT lengths The end-of-tile-part PLT check takes the next packet from the progression to tell lengths left after the tile's last packet from lengths of packets still to come. A walk that goes on after an error, as a non-strict one does, then lost that packet and parsed the next tile-part from the one after it. Only walks that stop at an error look ahead now; strict walks, the ones every public decode runs, are unchanged. Tests: a surplus PLT length in a tile-part followed by more of the tile's packets fails on the planes, streamed and default routes at full and half resolution on one and four threads; a tile's last packet with its non-empty bit cleared still fails on every route when PLT lengths are signalled; a non-strict walk records the same segments with and without a surplus length in its first tile-part (it lost the next tile-part's packets before). --- crates/j2k-native/src/j2c/segment.rs | 87 ++++++++++++++-- crates/j2k/tests/full_image_streaming.rs | 121 +++++++++++++++++++++-- 2 files changed, 192 insertions(+), 16 deletions(-) diff --git a/crates/j2k-native/src/j2c/segment.rs b/crates/j2k-native/src/j2c/segment.rs index 81fd2739..160ee06f 100644 --- a/crates/j2k-native/src/j2c/segment.rs +++ b/crates/j2k-native/src/j2c/segment.rs @@ -254,6 +254,10 @@ fn parse_tile_parts<'a>( mut relevance: Option<&mut PacketRelevance<'_>>, walk: &mut PacketWalk<'_>, ) -> Result<()> { + #[cfg(feature = "std")] + let strict = header.strict || walk.recording.is_some(); + #[cfg(not(feature = "std"))] + let strict = header.strict; for tile_part in &tile.tile_parts { if relevance .as_ref() @@ -276,16 +280,13 @@ fn parse_tile_parts<'a>( skips, relevance.as_deref_mut(), walk, + strict, ) }); if let Some(error) = storage.packet_workspace_error.take() { return Err(error); } if let Err(context) = parsed { - #[cfg(feature = "std")] - let strict = header.strict || walk.recording.is_some(); - #[cfg(not(feature = "std"))] - let strict = header.strict; if strict { bail!(DecodingError::PacketParseFailure(context)); } @@ -405,6 +406,7 @@ fn parse_inner<'a>( ) )] walk: &mut PacketWalk<'_>, + strict: bool, ) -> PacketResult { // The last precinct's place among its sub-bands' records, by (component, // resolution, precinct): position-major progressions read all of a @@ -674,9 +676,10 @@ fn parse_inner<'a>( } // PLT entries left once the tile's last packet has been read describe no - // packet, like bytes after it. + // packet, like bytes after it. Only a walk that stops at an error looks + // ahead: one that goes on needs the next packet for the next tile-part. if tile_part.validate_all_packet_lengths_consumed().is_none() - && progression_iterator.next().is_some() + && (!strict || progression_iterator.next().is_some()) { return Err("packet length markers contain unused entries"); } @@ -1035,6 +1038,78 @@ mod tests { } } + /// The segments a non-strict walk records for `codestream`'s tile. + fn non_strict_segments(codestream: &[u8]) -> Vec<(u8, u8, u32)> { + use crate::j2c::{build, tile}; + + let mut parsed = crate::j2c::parse_raw(codestream, &crate::DecodeSettings::default()) + .expect("parse codestream"); + parsed.header.strict = false; + let tiles = + tile::parse(&mut BitReader::new(parsed.data), &parsed.header, 0).expect("parse tiles"); + let tile = tiles.iter().next().expect("one tile"); + let mut storage = DecompositionStorage::default(); + build::build( + tile, + &mut storage, + tiles.structural_workspace_bytes(), + false, + build::BuildWorkspace::MetadataOnly, + None, + ) + .expect("plan code-blocks"); + parse(tile, &parsed.header, &mut storage).expect("non-strict walk"); + storage + .segments + .iter() + .map(|segment| (segment.idx, segment.coding_pases, segment.data_length)) + .collect() + } + + /// A walk that goes on past a tile-part's error, as a non-strict one + /// does, parses the next tile-parts from where the progression stands: a + /// PLT length left after the first tile-part's packet takes no packet + /// from it. + #[test] + fn non_strict_walk_keeps_the_progression_past_surplus_packet_lengths() { + let mut state = 0x2545_f491_u32; + let pixels: Vec = (0..16 * 16) + .map(|_| { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state.to_be_bytes()[0] + }) + .collect(); + let options = crate::EncodeOptions { + num_decomposition_levels: 1, + num_layers: 2, + tile_part_packet_limit: Some(1), + write_plt: true, + ..crate::EncodeOptions::default() + }; + let clean = crate::encode(&pixels, 16, 16, 1, 8, false, &options).expect("encode"); + let sot = clean + .windows(2) + .position(|window| window == [0xff, 0x90]) + .expect("SOT marker"); + let plt = sot + + clean[sot..] + .windows(2) + .position(|window| window == [0xff, 0x58]) + .expect("PLT marker"); + let mut edited = clean.clone(); + let plt_length = u16::from_be_bytes([edited[plt + 2], edited[plt + 3]]); + edited.insert(plt + 2 + usize::from(plt_length), 5); + edited[plt + 2..plt + 4].copy_from_slice(&(plt_length + 1).to_be_bytes()); + let psot = u32::from_be_bytes(edited[sot + 6..sot + 10].try_into().expect("Psot")); + edited[sot + 6..sot + 10].copy_from_slice(&(psot + 1).to_be_bytes()); + + let segments = non_strict_segments(&clean); + assert!(segments.len() > 1, "packets after the first tile-part"); + assert_eq!(non_strict_segments(&edited), segments); + } + #[test] fn packet_segment_growth_respects_remaining_workspace_budget() { let segment_bytes = size_of::>(); diff --git a/crates/j2k/tests/full_image_streaming.rs b/crates/j2k/tests/full_image_streaming.rs index a116aca2..a2632494 100644 --- a/crates/j2k/tests/full_image_streaming.rs +++ b/crates/j2k/tests/full_image_streaming.rs @@ -266,19 +266,22 @@ fn with_byte_after_last_packets(codestream: &[u8]) -> Vec { edit_tile_parts(codestream, |part| part.push(0)) } +/// Add one packet length to a tile-part's PLT marker. +fn add_packet_length(part: &mut Vec) { + let mut at = 12; + while part[at..at + 2] != [0xff, 0x58] { + assert_ne!(part[at..at + 2], [0xff, 0x93], "tile-part has a PLT marker"); + at += 2 + usize::from(u16::from_be_bytes([part[at + 2], part[at + 3]])); + } + let length = u16::from_be_bytes([part[at + 2], part[at + 3]]); + part.insert(at + 2 + usize::from(length), 5); + part[at + 2..at + 4].copy_from_slice(&(length + 1).to_be_bytes()); +} + /// `codestream` with one more packet length in each tile-part's PLT marker /// than the tile-part has packets. fn with_surplus_packet_lengths(codestream: &[u8]) -> Vec { - edit_tile_parts(codestream, |part| { - let mut at = 12; - while part[at..at + 2] != [0xff, 0x58] { - assert_ne!(part[at..at + 2], [0xff, 0x93], "tile-part has a PLT marker"); - at += 2 + usize::from(u16::from_be_bytes([part[at + 2], part[at + 3]])); - } - let length = u16::from_be_bytes([part[at + 2], part[at + 3]]); - part.insert(at + 2 + usize::from(length), 5); - part[at + 2..at + 4].copy_from_slice(&(length + 1).to_be_bytes()); - }) + edit_tile_parts(codestream, add_packet_length) } /// Data a tile-part holds after its tile's last packet is no packet's: bytes @@ -386,3 +389,101 @@ fn data_after_a_tiles_last_packet_decodes_as_without_it_on_every_route() { } } } + +/// A PLT length past the last packet of a tile-part while the tile's packets +/// go on in the next one disagrees with the packets: every route fails. +#[test] +fn surplus_packet_length_before_a_later_tile_part_fails_on_every_route() { + let split = codestream( + 1, + &EncodeOptions { + num_decomposition_levels: 2, + num_layers: 2, + tile_part_packet_limit: Some(1), + write_plt: true, + ..EncodeOptions::default() + }, + ); + let mut first = true; + let edited = edit_tile_parts(&split, |part| { + if std::mem::take(&mut first) { + add_packet_length(part); + } + }); + let pools = [1, 4].map(|threads| { + rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .expect("pool") + }); + for scale in [Downscale::None, Downscale::Half] { + let fmt = PixelFormat::Gray8; + assert!(decode(&split, None, CpuDecodeParallelism::Serial, fmt, scale, 0).is_some()); + for (pool, threads) in pools.iter().zip([1, 4]) { + for budget in [Some(usize::MAX), Some(0), None] { + let decoded = pool + .install(|| decode(&edited, budget, CpuDecodeParallelism::Auto, fmt, scale, 0)); + assert!( + decoded.is_none(), + "{scale:?} {threads} threads, planes budget {budget:?} decoded" + ); + } + } + } +} + +/// PLT lengths still bound a tile's last packet: with its non-empty bit +/// cleared, the packet's header ends after one byte, short of its signalled +/// length, and every route fails. Without PLT lengths nothing tells the +/// packet's unread bytes from data after the last packet. +#[test] +fn last_packet_shorter_than_its_plt_length_fails_on_every_route() { + let one_packet = codestream( + 1, + &EncodeOptions { + num_decomposition_levels: 0, + write_plt: true, + ..EncodeOptions::default() + }, + ); + let edited = edit_tile_parts(&one_packet, |part| { + let sod = part + .windows(2) + .position(|window| window == [0xff, 0x93]) + .expect("SOD marker"); + assert_ne!(part[sod + 2] & 0x80, 0, "the packet is not empty"); + part[sod + 2] &= 0x7f; + }); + let fmt = PixelFormat::Gray8; + let clean = decode( + &one_packet, + None, + CpuDecodeParallelism::Serial, + fmt, + Downscale::None, + 0, + ); + assert!(clean.is_some()); + for threads in [1, 4] { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .expect("pool"); + for budget in [Some(usize::MAX), Some(0), None] { + let decoded = pool.install(|| { + decode( + &edited, + budget, + CpuDecodeParallelism::Auto, + fmt, + Downscale::None, + 0, + ) + }); + assert!( + decoded.is_none(), + "{threads} threads, planes budget {budget:?} decoded" + ); + } + } +} From fda2be581f745f9e61afe80b248de99fcb55b68f Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 02:52:04 -0500 Subject: [PATCH 03/30] test(j2k-native): check direct plans skip data after a tile's last packet Direct plans, which the GPU decodes and the staged batch execute, read packets with the same walk as the decode routes. A test now builds the direct RGB plan of classic and HTJ2K codestreams with a byte after the tile's last packet and with a PLT length past it: each plan builds, the one with the extra byte is the clean codestream's plan, and both, run on the CPU, write the clean codestream's decoded image. Before the walk stopped at the end of the progression, building either plan failed. --- crates/j2k-native/src/tests.rs | 101 +++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/crates/j2k-native/src/tests.rs b/crates/j2k-native/src/tests.rs index 8544577e..e7b1893d 100644 --- a/crates/j2k-native/src/tests.rs +++ b/crates/j2k-native/src/tests.rs @@ -2526,6 +2526,107 @@ fn color_direct_region_does_not_parse_an_unneeded_trailing_packet() { .expect("the region plan stops before the unneeded trailing packet"); } +/// `codestream` with `edit` applied to its one tile-part, from its SOT marker +/// to its end, and `Psot` set to the edited length. +fn edit_only_tile_part(codestream: &[u8], edit: impl FnOnce(&mut Vec)) -> Vec { + let sot = codestream + .windows(2) + .position(|window| window == [0xff, 0x90]) + .expect("SOT marker"); + let psot = u32::from_be_bytes(codestream[sot + 6..sot + 10].try_into().expect("Psot")); + let end = sot + usize::try_from(psot).expect("Psot fits"); + assert_eq!(codestream[end..], [0xff, 0xd9], "one tile-part"); + let mut part = codestream[sot..end].to_vec(); + edit(&mut part); + let psot = u32::try_from(part.len()).expect("edited Psot fits"); + part[6..10].copy_from_slice(&psot.to_be_bytes()); + [&codestream[..sot], &part, &codestream[end..]].concat() +} + +/// Direct plans read packets with the walk the decodes use: a byte after the +/// tile's last packet, or a PLT length past it, is left out of the plan as the +/// decodes leave it, so the plan, run on the CPU, writes the decoded image of +/// the codestream without it. +#[test] +fn direct_color_plans_ignore_data_after_the_last_packet_as_decodes_do() { + let pixels = gradient_pixels(16, 16, 3); + let options = EncodeOptions { + num_decomposition_levels: 2, + num_layers: 2, + code_block_width_exp: 1, + code_block_height_exp: 1, + write_plt: true, + ..EncodeOptions::default() + }; + for (name, bytes) in [ + ( + "classic", + encode(&pixels, 16, 16, 3, 8, false, &options).expect("encode classic rgb8"), + ), + ( + "htj2k", + encode_htj2k(&pixels, 16, 16, 3, 8, false, &options).expect("encode ht rgb8"), + ), + ] { + let clean = Image::new(&bytes, &DecodeSettings::default()).expect("image"); + let expected = clean + .decode_with_context(&mut DecoderContext::default()) + .expect("decode") + .data; + let clean_plan = clean + .build_direct_color_plan_with_context(&mut DecoderContext::default()) + .expect("direct plan"); + let byte_after = edit_only_tile_part(&bytes, |part| part.push(0)); + let surplus_length = edit_only_tile_part(&bytes, |part| { + let plt = part + .windows(2) + .position(|window| window == [0xff, 0x58]) + .expect("PLT marker"); + let length = u16::from_be_bytes([part[plt + 2], part[plt + 3]]); + part.insert(plt + 2 + usize::from(length), 5); + part[plt + 2..plt + 4].copy_from_slice(&(length + 1).to_be_bytes()); + }); + for (variant, edited) in [ + ("byte after", byte_after), + ("surplus PLT length", surplus_length), + ] { + let image = Image::new(&edited, &DecodeSettings::default()).expect("image"); + let decoded = image + .decode_with_context(&mut DecoderContext::default()) + .unwrap_or_else(|error| panic!("{name} {variant}: decode: {error:?}")) + .data; + assert_eq!(decoded, expected, "{name} {variant}: decode"); + let plan = image + .build_direct_color_plan_with_context(&mut DecoderContext::default()) + .unwrap_or_else(|error| panic!("{name} {variant}: direct plan: {error:?}")); + if variant == "byte after" { + // Every payload keeps its offset: the plan is the clean one. + assert_eq!( + format!("{plan:?}"), + format!("{clean_plan:?}"), + "{name} {variant}" + ); + } + let region = J2kRect { + x0: 0, + y0: 0, + x1: 16, + y1: 16, + }; + let mut direct = vec![0_u8; 16 * 16 * 3]; + execute_direct_color_plan_rgb8_into( + &plan, + region, + &mut J2kDirectCpuScratch::new(), + &mut direct, + 16 * 3, + ) + .unwrap_or_else(|error| panic!("{name} {variant}: execute: {error:?}")); + assert_eq!(direct, expected, "{name} {variant}: direct plan on the CPU"); + } + } +} + #[test] fn color_direct_plan_honors_target_resolution() { for (name, bytes) in [ From 71b210e1ac99b567d944bb4774c935a9ac544f80 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 02:55:16 -0500 Subject: [PATCH 04/30] test(j2k-test-support): share packet edits for route and backend parity tests Edits of raw codestreams around their packets move to the shared test support crate, so the facade, native and GPU tests build the same inputs: `edit_tile_parts` (Psot kept consistent), a byte after each tile-part's last packet, an extra PLT length, the packet byte ranges a tile-part's PLT lengths give, and packets overwritten in place. The facade route tests use them; their inputs and assertions are unchanged. --- crates/j2k-test-support/src/lib.rs | 5 + crates/j2k-test-support/src/packet_edits.rs | 198 ++++++++++++++++++++ crates/j2k/tests/full_image_streaming.rs | 58 +----- 3 files changed, 207 insertions(+), 54 deletions(-) create mode 100644 crates/j2k-test-support/src/packet_edits.rs diff --git a/crates/j2k-test-support/src/lib.rs b/crates/j2k-test-support/src/lib.rs index 6f4e65e0..38c64aaa 100644 --- a/crates/j2k-test-support/src/lib.rs +++ b/crates/j2k-test-support/src/lib.rs @@ -18,6 +18,7 @@ mod jpeg_fixtures; mod lossless_matrix; mod manifest; mod metal; +mod packet_edits; mod pixels; mod scaled_matrix; @@ -79,6 +80,10 @@ pub use manifest::{ optional_manifest_column, }; pub use metal::{metal_device_unavailable_is_skip, metal_runtime_gate, metal_runtime_required}; +pub use packet_edits::{ + add_packet_length, edit_tile_parts, packet_ranges, with_byte_after_last_packets, + with_packets_filled, with_surplus_packet_lengths, +}; pub use pixels::{ crop_interleaved_bytes, crop_interleaved_u16, project_scaled_interleaved_u16, project_scaled_interleaved_u8, rgb16le_to_rgba16le, rgb16ne_to_opaque_rgba16ne, rgb8_to_rgba8, diff --git a/crates/j2k-test-support/src/packet_edits.rs b/crates/j2k-test-support/src/packet_edits.rs new file mode 100644 index 00000000..cc0bdb3e --- /dev/null +++ b/crates/j2k-test-support/src/packet_edits.rs @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Byte-level edits of JPEG 2000 codestreams around their packets, for tests +//! of what a decode reads: data after a tile's last packet, PLT lengths past +//! it, and damaged packets. Codestreams are raw (`SOC` first), each tile-part +//! a `SOT` marker segment with a nonzero `Psot`, and `EOC` last. + +use std::ops::Range; + +const SOT: [u8; 2] = [0xff, 0x90]; +const SOD: [u8; 2] = [0xff, 0x93]; +const PLT: [u8; 2] = [0xff, 0x58]; +const EOC: [u8; 2] = [0xff, 0xd9]; + +fn marker_segment_end(bytes: &[u8], at: usize) -> usize { + at + 2 + usize::from(u16::from_be_bytes([bytes[at + 2], bytes[at + 3]])) +} + +fn first_tile_part(codestream: &[u8]) -> usize { + let mut at = 2; + while codestream[at..at + 2] != SOT { + at = marker_segment_end(codestream, at); + } + at +} + +fn tile_part_end(codestream: &[u8], sot: usize) -> usize { + let psot = u32::from_be_bytes( + codestream[sot + 6..sot + 10] + .try_into() + .expect("Psot is four bytes"), + ); + assert_ne!(psot, 0, "every tile-part signals its length"); + sot + usize::try_from(psot).expect("Psot fits usize") +} + +/// The tile-parts of `codestream`, each from its `SOT` marker to its end. +fn tile_parts(codestream: &[u8]) -> Vec> { + let mut parts = Vec::new(); + let mut at = first_tile_part(codestream); + while codestream[at..at + 2] == SOT { + let end = tile_part_end(codestream, at); + parts.push(at..end); + at = end; + } + assert_eq!(codestream[at..], EOC, "EOC follows the last tile-part"); + parts +} + +/// `codestream` with `edit` applied to each tile-part, from its `SOT` marker +/// to its end, and each `Psot` set to the edited length. `edit` is called +/// once per tile-part, in codestream order. +/// +/// # Panics +/// +/// When `codestream` is not as the module describes, or an edited tile-part +/// is too long for its `Psot`. +#[must_use] +pub fn edit_tile_parts(codestream: &[u8], mut edit: impl FnMut(&mut Vec)) -> Vec { + let parts = tile_parts(codestream); + let mut edited = codestream[..parts[0].start].to_vec(); + for part in &parts { + let mut bytes = codestream[part.clone()].to_vec(); + edit(&mut bytes); + let psot = u32::try_from(bytes.len()).expect("edited Psot fits"); + bytes[6..10].copy_from_slice(&psot.to_be_bytes()); + edited.extend_from_slice(&bytes); + } + edited.extend_from_slice(&EOC); + edited +} + +/// `codestream` with a zero byte after the last packet of each tile-part, +/// inside it. +#[must_use] +pub fn with_byte_after_last_packets(codestream: &[u8]) -> Vec { + edit_tile_parts(codestream, |part| part.push(0)) +} + +/// Add one packet length (5) at the end of a tile-part's first PLT marker. +/// `part` runs from the tile-part's `SOT` marker; its `Psot` is left to the +/// caller (see [`edit_tile_parts`]). +/// +/// # Panics +/// +/// When the tile-part header has no PLT marker. +pub fn add_packet_length(part: &mut Vec) { + let mut at = 12; + while part[at..at + 2] != PLT { + assert_ne!(part[at..at + 2], SOD, "tile-part has a PLT marker"); + at = marker_segment_end(part, at); + } + let length = u16::from_be_bytes([part[at + 2], part[at + 3]]); + part.insert(at + 2 + usize::from(length), 5); + part[at + 2..at + 4].copy_from_slice(&(length + 1).to_be_bytes()); +} + +/// `codestream` with one more packet length in each tile-part's PLT marker +/// than the tile-part has packets. +#[must_use] +pub fn with_surplus_packet_lengths(codestream: &[u8]) -> Vec { + edit_tile_parts(codestream, add_packet_length) +} + +/// The byte range of every packet of `codestream`, header and body, in +/// codestream order (tile-part by tile-part, each in its progression order), +/// from the PLT lengths each tile-part signals for its in-band packets. +/// +/// # Panics +/// +/// When a tile-part has no PLT marker or its lengths do not end at its end. +#[must_use] +pub fn packet_ranges(codestream: &[u8]) -> Vec> { + let mut ranges = Vec::new(); + for part in tile_parts(codestream) { + let mut at = part.start + 12; + let mut lengths = Vec::new(); + while codestream[at..at + 2] != SOD { + let end = marker_segment_end(codestream, at); + if codestream[at..at + 2] == PLT { + let mut length = 0_usize; + for &byte in &codestream[at + 5..end] { + length = (length << 7) | usize::from(byte & 0x7f); + if byte & 0x80 == 0 { + lengths.push(length); + length = 0; + } + } + assert_eq!(length, 0, "PLT lengths terminate"); + } + at = end; + } + assert!(!lengths.is_empty(), "tile-part has PLT lengths"); + let mut start = at + 2; + for length in lengths { + ranges.push(start..start + length); + start += length; + } + assert_eq!(start, part.end, "PLT lengths end at the tile-part's end"); + } + ranges +} + +/// `codestream` with every byte of the packets at `ranges` set to `byte`, +/// lengths unchanged. +#[must_use] +pub fn with_packets_filled(codestream: &[u8], ranges: &[Range], byte: u8) -> Vec { + let mut edited = codestream.to_vec(); + for range in ranges { + edited[range.clone()].fill(byte); + } + edited +} + +#[cfg(test)] +mod tests { + use super::*; + + /// SOC, a SIZ-like dummy segment, one tile-part with PLT lengths 2 and 3 + /// for five packet bytes, EOC. + fn codestream() -> Vec { + let mut bytes = vec![0xff, 0x4f, 0xff, 0x51, 0, 3, 0]; + let header = [0xff, 0x58, 0, 5, 0, 2, 3, 0xff, 0x93]; + let psot = u32::try_from(12 + header.len() + 5).expect("small"); + bytes.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0]); + bytes.extend_from_slice(&psot.to_be_bytes()); + bytes.extend_from_slice(&[0, 1]); + bytes.extend_from_slice(&header); + bytes.extend_from_slice(&[1, 2, 3, 4, 5]); + bytes.extend_from_slice(&EOC); + bytes + } + + #[test] + fn packet_ranges_follow_plt_lengths() { + let bytes = codestream(); + let ranges = packet_ranges(&bytes); + assert_eq!(ranges.len(), 2); + assert_eq!(bytes[ranges[0].clone()], [1, 2]); + assert_eq!(bytes[ranges[1].clone()], [3, 4, 5]); + } + + #[test] + fn edits_keep_psot_consistent() { + let bytes = codestream(); + let after = with_byte_after_last_packets(&bytes); + assert_eq!(after.len(), bytes.len() + 1); + assert_eq!(tile_parts(&after)[0].len(), tile_parts(&bytes)[0].len() + 1); + let surplus = with_surplus_packet_lengths(&bytes); + assert_eq!(surplus.len(), bytes.len() + 1); + assert_eq!( + tile_parts(&surplus)[0].len(), + tile_parts(&bytes)[0].len() + 1 + ); + let filled = with_packets_filled(&bytes, &packet_ranges(&bytes)[1..], 0xff); + assert_eq!(filled[filled.len() - 5..filled.len() - 2], [0xff; 3]); + } +} diff --git a/crates/j2k/tests/full_image_streaming.rs b/crates/j2k/tests/full_image_streaming.rs index a2632494..a758674e 100644 --- a/crates/j2k/tests/full_image_streaming.rs +++ b/crates/j2k/tests/full_image_streaming.rs @@ -11,7 +11,10 @@ use j2k::{CpuDecodeParallelism, J2kDecoder, J2kScratchPool}; use j2k_core::{Downscale, PixelFormat}; use j2k_native::{encode, EncodeOptions, EncodeProgressionOrder}; -use j2k_test_support::wrap_jp2_rgba_codestream; +use j2k_test_support::{ + add_packet_length, edit_tile_parts, with_byte_after_last_packets, with_surplus_packet_lengths, + wrap_jp2_rgba_codestream, +}; const WIDTH: u32 = 203; const HEIGHT: u32 = 157; @@ -231,59 +234,6 @@ fn routes_alternate_on_one_decoder() { } } -/// `codestream` with `edit` applied to each tile-part, from its SOT marker -/// to its end, and each `Psot` set to the edited length. -fn edit_tile_parts(codestream: &[u8], mut edit: impl FnMut(&mut Vec)) -> Vec { - let marker_end = |at: usize| { - at + 2 + usize::from(u16::from_be_bytes([codestream[at + 2], codestream[at + 3]])) - }; - let mut at = 2; - while codestream[at..at + 2] != [0xff, 0x90] { - at = marker_end(at); - } - let mut edited = codestream[..at].to_vec(); - while codestream[at..at + 2] == [0xff, 0x90] { - let psot = u32::from_be_bytes(codestream[at + 6..at + 10].try_into().expect("Psot")); - let end = at + usize::try_from(psot).expect("Psot fits"); - let mut part = codestream[at..end].to_vec(); - edit(&mut part); - let psot = u32::try_from(part.len()).expect("edited Psot fits"); - part[6..10].copy_from_slice(&psot.to_be_bytes()); - edited.extend_from_slice(&part); - at = end; - } - assert_eq!( - codestream[at..], - [0xff, 0xd9], - "EOC follows the last tile-part" - ); - edited.extend_from_slice(&codestream[at..]); - edited -} - -/// `codestream` with a zero byte after the last packet of each tile-part. -fn with_byte_after_last_packets(codestream: &[u8]) -> Vec { - edit_tile_parts(codestream, |part| part.push(0)) -} - -/// Add one packet length to a tile-part's PLT marker. -fn add_packet_length(part: &mut Vec) { - let mut at = 12; - while part[at..at + 2] != [0xff, 0x58] { - assert_ne!(part[at..at + 2], [0xff, 0x93], "tile-part has a PLT marker"); - at += 2 + usize::from(u16::from_be_bytes([part[at + 2], part[at + 3]])); - } - let length = u16::from_be_bytes([part[at + 2], part[at + 3]]); - part.insert(at + 2 + usize::from(length), 5); - part[at + 2..at + 4].copy_from_slice(&(length + 1).to_be_bytes()); -} - -/// `codestream` with one more packet length in each tile-part's PLT marker -/// than the tile-part has packets. -fn with_surplus_packet_lengths(codestream: &[u8]) -> Vec { - edit_tile_parts(codestream, add_packet_length) -} - /// Data a tile-part holds after its tile's last packet is no packet's: bytes /// after it, or PLT lengths past it, change no sample. Every route decodes /// such a codestream as it decodes the codestream without them, on a pool of From efe8f817bd32405a6a7954485aa4b280070f955e Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 02:55:16 -0500 Subject: [PATCH 05/30] docs(changelog): note the tile data after the last packet --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d429fd8..69825fbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,14 @@ Staged workspace version: `0.13.2`. status, detail and batch source index. CUDA keeps its typed source and job attribution. Unsupported statuses, raw runtime failures and combined cleanup or release failures keep their existing classes. +- A tile-part that holds data after its tile's last packet, bytes or PLT + packet lengths past it, decodes on every route. Decodes on the component + planes failed with a packet parse error where decodes that stream their + rows succeeded, so the same codestream decoded or failed depending on the + route, which can follow the pool's size (a half-resolution decode of a + 512 x 512 RGB image failed on one thread and decoded on four). The output + is that of the codestream without the extra data. PLT lengths left over + while the tile's packets continue in a later tile-part remain an error. - Row-streamed decodes on serial bands finish directly from the inverse transform's own rows. The streamed inverse transform lifts every level with the banded transform's kernels, one arrival at a time on each level's From 05fdb00568b98a936a0369d190182e5dde1d115f Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 03:11:27 -0500 Subject: [PATCH 06/30] fix(j2k-native): read only the packets a reduced planes decode needs Decodes below full resolution on the component planes parsed every packet of the tile. The streamed route parses with a region decode's precinct selection: the packets of the resolutions it keeps, skipping the others by their PLT lengths where every packet has one, and none after the last it needs. A codestream damaged only in packets of the discarded resolutions therefore decoded or failed depending on the route, which follows the pool size: on a corpus of 4160 malformed and valid codestreams, 216 half-resolution decodes failed on the planes route and decoded streamed. For each of them that can be checked (155, single tile), the streamed output equals the half-resolution decode of the codestream cut after the last packet that decode needs. Planes decodes below full resolution now parse with the region route's selection for the whole reduced image, made on a scratch storage and keeping every precinct's record, so both routes read the same packets; the build is unchanged. The selection charges the decode budget for what it allocates beside the tile storage's retained capacity, not for the precinct records the build later makes in that capacity, so it rejects no tile that the build accepts. Damage in a packet the reduced output needs, or in one that must be parsed to locate a later needed packet, remains an error, and full-resolution decodes are unchanged. --- crates/j2k-native/src/j2c/build.rs | 4 +- crates/j2k-native/src/j2c/build/selection.rs | 130 +++++++++++++++++++ crates/j2k-native/src/j2c/decode.rs | 9 +- 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/crates/j2k-native/src/j2c/build.rs b/crates/j2k-native/src/j2c/build.rs index 8a2e010d..af0b002d 100644 --- a/crates/j2k-native/src/j2c/build.rs +++ b/crates/j2k-native/src/j2c/build.rs @@ -20,7 +20,9 @@ use allocation::{ DecompositionAllocationPlan, }; pub(in crate::j2c) use allocation::{global_code_block_count, IdwtWorkspaceTracker, RecordCounts}; -pub(crate) use selection::{select_region_precincts, PrecinctSelection, PrecinctUse}; +pub(crate) use selection::{ + select_region_precincts, select_whole_image_precincts, PrecinctSelection, PrecinctUse, +}; /// Build and allocate all necessary structures to process the code-blocks /// for a specific tile. Also parses the segments for each code-block. diff --git a/crates/j2k-native/src/j2c/build/selection.rs b/crates/j2k-native/src/j2c/build/selection.rs index d58fcf84..a9b507d5 100644 --- a/crates/j2k-native/src/j2c/build/selection.rs +++ b/crates/j2k-native/src/j2c/build/selection.rs @@ -115,6 +115,21 @@ impl PrecinctSelection { Ok(selection) } + /// This selection for storage that keeps every precinct's record: a + /// packet's precinct is found at its own index, and a parsed-only + /// precinct is parsed in its own record. + fn with_every_record(mut self) -> Result { + self.records = Vec::new(); + self.record_runs = Vec::new(); + self.parsed_in_scratch = false; + let mut bytes = 0; + checked_include_elements::(&mut bytes, self.uses.capacity())?; + checked_include_elements::(&mut bytes, self.runs.capacity())?; + checked_include_elements::(&mut bytes, self.component_runs.capacity())?; + self.owned_bytes = bytes; + Ok(self) + } + /// The precinct uses for the `run`-th resolution in build order. pub(crate) fn run(&self, run: usize) -> Option<&[PrecinctUse]> { let start = *self.runs.get(run)?; @@ -257,6 +272,59 @@ pub(crate) fn select_region_precincts( Ok(selection) } +/// The selection a decode that builds every precinct of the tile parses it +/// with: below full resolution, the one a region decode of the whole image +/// parses it with, so both read the same packets; otherwise `None`, every +/// packet being read. `live_bytes` are live beside `storage`, whose retained +/// capacity the build reuses. +pub(crate) fn select_whole_image_precincts( + tile: &Tile<'_>, + header: &Header<'_>, + storage: &DecompositionStorage<'_>, + live_bytes: usize, +) -> Result> { + if header.skipped_resolution_levels == 0 { + return Ok(None); + } + let retained_baseline_bytes = live_bytes + .checked_add(storage.retained_capacity_bytes()?) + .ok_or(ValidationError::ImageTooLarge)?; + whole_image_selection(tile, header, retained_baseline_bytes) +} + +/// The selection a region decode of the whole image parses a tile with, for a +/// decode that builds every precinct of the tile: the same packets are read, +/// skipped by their PLT lengths, or left after the last one the output needs, +/// so both decodes accept and reject the same codestreams. The region +/// decode's skeleton and ROI plan live only while the selection is made, in +/// their own storage beside the `retained_baseline_bytes` (the tile storage's +/// retained capacity included), which the later build reuses; every +/// precinct keeps its record. +/// +/// Unlike [`select_region_precincts`], this does not reserve room for the +/// build's precinct records and tag-tree roots up front: the build makes +/// them later, in the retained capacity, after its own preflight. Only what +/// is allocated here is charged. +fn whole_image_selection( + tile: &Tile<'_>, + header: &Header<'_>, + retained_baseline_bytes: usize, +) -> Result> { + let whole_image = OutputRegion { + x: 0, + y: 0, + width: header.size_data.image_width(), + height: header.size_data.image_height(), + }; + let counts = count(tile)?; + let mut budget = DecodeAllocationBudget::from_live_bytes(retained_baseline_bytes)?; + let mut skeleton = DecompositionStorage::default(); + build_skeleton(tile, &counts, &mut skeleton, &mut budget)?; + RoiPlan::build(tile, header, &skeleton, whole_image, &mut budget)? + .map(|plan| select(tile, &skeleton, &plan, &counts, &mut budget, 0)?.with_every_record()) + .transpose() +} + fn count(tile: &Tile<'_>) -> Result { let mut counts = SelectionCounts { components: 0, @@ -741,6 +809,68 @@ mod tests { } } + /// A whole-image selection made beside a tile storage that keeps its + /// capacity charges what it allocates, not the precinct records and tag + /// trees the later build reuses from that capacity: it fits a baseline at + /// which a region decode's selection, which reserves room for them, + /// rejects the tile. + #[test] + fn whole_image_selection_charges_only_its_own_allocations() { + const WIDTH: u32 = 120; + const HEIGHT: u32 = 64; + let options = EncodeOptions { + reversible: true, + num_decomposition_levels: 0, + code_block_width_exp: 0, + code_block_height_exp: 0, + precinct_exponents: vec![(2, 2)], + ..EncodeOptions::default() + }; + let bytes = encode( + &vec![128; (WIDTH * HEIGHT) as usize], + WIDTH, + HEIGHT, + 1, + 8, + false, + &options, + ) + .expect("encode fixture"); + let image = Image::new(&bytes, &DecodeSettings::default()).expect("parse fixture"); + let tiles = crate::j2c::tile::parse( + &mut crate::reader::BitReader::new(image.codestream), + &image.header, + 0, + ) + .expect("parse tile"); + let tile = &tiles[0]; + let counts = count(tile).unwrap(); + let owned = selection_owned_bytes(&counts).unwrap(); + let (mut accepted, mut rejected) = (0, DEFAULT_MAX_DECODE_BYTES); + while rejected - accepted > 1 { + let baseline = accepted + (rejected - accepted) / 2; + if preflight_unavoidable_minimum(&counts, baseline, owned).is_ok() { + accepted = baseline; + } else { + rejected = baseline; + } + } + let whole_image = OutputRegion::from_tuple((0, 0, WIDTH, HEIGHT)); + assert!(select_region_precincts( + tile, + &image.header, + &mut DecompositionStorage::default(), + whole_image, + rejected, + ) + .is_err()); + let selection = whole_image_selection(tile, &image.header, rejected) + .expect("the whole-image selection fits beside the retained storage") + .expect("a selection"); + // Every 4 x 4 precinct of the 120 x 64 tile, one layer. + assert_eq!(selection.required_packets(), 30 * 16); + } + #[test] fn dense_region_selection_keeps_the_existing_budget_boundary() { // A scaled-down 7680x4096 LRCP example with 4x4 precincts and blocks. diff --git a/crates/j2k-native/src/j2c/decode.rs b/crates/j2k-native/src/j2c/decode.rs index d4c87655..d153ee0a 100644 --- a/crates/j2k-native/src/j2c/decode.rs +++ b/crates/j2k-native/src/j2c/decode.rs @@ -881,7 +881,11 @@ fn decode_tile<'a>( output_region, retained_decode_baseline, )?, - None => None, + // A decode that builds every precinct reads the packets a region + // decode of the whole image reads. + None => { + build::select_whole_image_precincts(tile, header, storage, retained_decode_baseline)? + } }; // The selection stays live through packet parsing, so its bytes are part // of the baseline every later phase budgets against until it is dropped. @@ -912,7 +916,8 @@ fn decode_tile<'a>( build::CoefficientLayout::Component }, }, - selection.as_ref(), + // Only a region decode builds just the selected precincts. + tile_ctx.output_region.and(selection.as_ref()), )?; if tile_ctx.output_region.is_some() { prepare_region_storage( From 794a7a0766bdb3221bc3c652e07104315135f30a Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 03:11:27 -0500 Subject: [PATCH 07/30] fix(j2k-native): build reduced direct plans from the packets the decodes read Direct plans below full resolution, which the GPU decodes and the staged batch execute, parsed every packet of the tile, so a codestream damaged only in packets of the discarded resolutions failed to plan where the CPU decodes at that resolution succeed. They now parse with the same selection as the planes and streamed decodes, so every backend accepts and rejects the same codestreams; plans of valid codestreams are unchanged, as they only ever held the kept resolutions' code-blocks. --- crates/j2k-native/src/j2c/decode/direct_plan.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/crates/j2k-native/src/j2c/decode/direct_plan.rs b/crates/j2k-native/src/j2c/decode/direct_plan.rs index 64367a50..bbaa5f2b 100644 --- a/crates/j2k-native/src/j2c/decode/direct_plan.rs +++ b/crates/j2k-native/src/j2c/decode/direct_plan.rs @@ -97,7 +97,10 @@ fn build_and_parse_direct_tile_metadata<'a>( output_region, structural_workspace_bytes, )?, - None => None, + // A plan reads the packets the decodes read. + None => { + build::select_whole_image_precincts(tile, header, storage, structural_workspace_bytes)? + } }; let selection_bytes = selection .as_ref() @@ -110,7 +113,7 @@ fn build_and_parse_direct_tile_metadata<'a>( .ok_or(ValidationError::ImageTooLarge)?, decode_region.is_some(), build::BuildWorkspace::MetadataOnly, - selection.as_ref(), + decode_region.and(selection.as_ref()), )?; if decode_region.is_some() && storage.roi_plan.is_none() { build::release_unused_roi_workspace(storage, tile.component_infos.len())?; From fb905382906149246ff7ce0224fc492b93333612 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 03:11:13 -0500 Subject: [PATCH 08/30] test(j2k): check reduced decodes read the same packets on every route Half-resolution decodes of codestreams damaged in chosen packets, on the planes, streamed and default routes on pools of one and four threads: finest-resolution packets damaged in RPCL (with PLT, without, over tiles and over several tile-parts per tile), RLCP HTJ2K, and LRCP, CPRL and PCRL with PLT lengths to skip them decode as the undamaged codestream does; the same damage in packets that must be parsed to reach a later needed one (LRCP without PLT, with a progression order change, or with one tile-part lacking PLT lengths), in the coarsest packets, and every full-resolution decode of these codestreams fail. A PLT length that ends a skipped packet inside the next needed one or past the tile-part fails, and a tile-part cut anywhere in its last packet decodes at half resolution. In the native crate, cutting or damaging an RPCL tile's last packet gives each of the packet parse errors the planes route reported at half resolution before (PLT disagreement, short body, coding-pass count, inclusion tree, segment length, EPH), and the planes, region and direct plan decodes at half resolution all write the undamaged image; damage in a needed packet fails all three; and planes decodes below full resolution parse exactly the packet headers the region decode parses. The reduced and region decode docs state what such decodes validate. --- crates/j2k-native/src/tests.rs | 248 ++++++++++ crates/j2k-test-support/src/lib.rs | 2 +- crates/j2k-test-support/src/packet_edits.rs | 61 +++ crates/j2k/src/view.rs | 18 + crates/j2k/tests/full_image_streaming.rs | 504 ++++++++++++++++++++ 5 files changed, 832 insertions(+), 1 deletion(-) diff --git a/crates/j2k-native/src/tests.rs b/crates/j2k-native/src/tests.rs index e7b1893d..366c053f 100644 --- a/crates/j2k-native/src/tests.rs +++ b/crates/j2k-native/src/tests.rs @@ -2627,6 +2627,254 @@ fn direct_color_plans_ignore_data_after_the_last_packet_as_decodes_do() { } } +/// Where the last packet of a one-tile-part codestream with one PLT marker +/// starts. +fn last_plt_packet_start(codestream: &[u8]) -> usize { + let plt = codestream + .windows(2) + .position(|window| window == [0xff, 0x58]) + .expect("PLT marker"); + let end = plt + + 2 + + usize::from(u16::from_be_bytes([ + codestream[plt + 2], + codestream[plt + 3], + ])); + let mut lengths = Vec::new(); + let mut length = 0_usize; + for &byte in &codestream[plt + 5..end] { + length = (length << 7) | usize::from(byte & 0x7f); + if byte & 0x80 == 0 { + lengths.push(length); + length = 0; + } + } + codestream.len() - 2 - lengths.last().expect("PLT lengths") +} + +/// A half-resolution decode needs none of an RPCL tile's finest-resolution +/// packets, its last ones. Cut or damaged anywhere in the tile's last packet, +/// the codestream fails at full resolution, with each of the packet errors +/// listed below among the cases, while at half resolution the planes decode, +/// the region decode of the whole image and the direct plan run on the CPU +/// all write the undamaged codestream's image. +#[test] +fn reduced_decodes_skip_damage_to_packets_they_do_not_need() { + let pixels: Vec = { + let mut state = 0x2545_f491_u32; + (0..64 * 64 * 3) + .map(|_| { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state.to_be_bytes()[0] + }) + .collect() + }; + let half = DecodeSettings { + target_resolution: Some((32, 32)), + ..DecodeSettings::default() + }; + let mut full_errors = alloc::collections::BTreeSet::new(); + for write_eph in [false, true] { + let options = EncodeOptions { + num_decomposition_levels: 2, + num_layers: 2, + code_block_width_exp: 2, + code_block_height_exp: 2, + progression_order: EncodeProgressionOrder::Rpcl, + write_eph, + write_plt: true, + ..EncodeOptions::default() + }; + let clean = encode(&pixels, 64, 64, 3, 8, false, &options).expect("encode"); + let clean_half = Image::new(&clean, &half).expect("half image"); + let native = clean_half.decode_native().expect("decode").data; + let rgb = clean_half + .decode_with_context(&mut DecoderContext::default()) + .expect("decode") + .data; + let start = last_plt_packet_start(&clean); + let last = clean.len() - 2 - start; + let mut cases: Vec> = (0..last.min(160)) + .map(|kept| edit_only_tile_part(&clean, |part| part.truncate(part.len() - last + kept))) + .collect(); + for byte in [0x00, 0xff] { + let mut damaged = clean.clone(); + damaged[start] = byte; + cases.push(damaged); + } + for (case, edited) in cases.iter().enumerate() { + let label = format!("EPH {write_eph}, case {case}"); + match Image::new(edited, &DecodeSettings::default()) + .expect("image") + .decode_native() + { + Err(DecodeError::Decoding(DecodingError::PacketParseFailure(context))) => { + full_errors.insert(context); + } + other => panic!("{label}: full resolution gave {:?}", other.map(|_| ())), + } + let image = Image::new(edited, &half).expect("half image"); + let planes = image + .decode_native() + .unwrap_or_else(|error| panic!("{label}: planes: {error:?}")); + assert_eq!(planes.data, native, "{label}: planes"); + let region = image + .decode_native_region_with_context((0, 0, 32, 32), &mut DecoderContext::default()) + .unwrap_or_else(|error| panic!("{label}: region: {error:?}")); + assert_eq!(region.data, native, "{label}: region"); + let plan = image + .build_direct_color_plan_with_context(&mut DecoderContext::default()) + .unwrap_or_else(|error| panic!("{label}: direct plan: {error:?}")); + let mut direct = vec![0_u8; 32 * 32 * 3]; + execute_direct_color_plan_rgb8_into( + &plan, + J2kRect { + x0: 0, + y0: 0, + x1: 32, + y1: 32, + }, + &mut J2kDirectCpuScratch::new(), + &mut direct, + 32 * 3, + ) + .unwrap_or_else(|error| panic!("{label}: execute: {error:?}")); + assert_eq!(direct, rgb, "{label}: direct plan on the CPU"); + } + } + for context in [ + "packet length marker disagrees with consumed bytes", + "packet body is shorter than its segment lengths", + "classic coding-pass count is truncated or invalid", + "classic code-block inclusion or zero-bitplane tree is truncated", + "code-block segment length is truncated", + "EPH marker is truncated", + ] { + assert!( + full_errors.contains(context), + "no case fails with {context:?}: {full_errors:?}" + ); + } +} + +/// A planes decode below full resolution parses exactly the packet headers the +/// region decode of the whole reduced image parses, tile by tile, on tiles +/// with empty coarse resolutions, position- and component-major +/// progressions with and without PLT, and writes the same samples. +#[test] +fn reduced_planes_decode_parses_the_packets_the_region_decode_parses() { + let fixtures: [(&str, &[u8]); 6] = [ + ( + "gray 8x6, 8x5 tiles", + include_bytes!( + "../../j2k-test-support/fixtures/empty_coarse_levels/openjpeg_gray_8x6_tile8x5_53.j2k" + ), + ), + ( + "rgb 6x6, 5x5 tiles", + include_bytes!( + "../../j2k-test-support/fixtures/empty_coarse_levels/openjpeg_rgb_6x6_tile5x5_97.j2k" + ), + ), + ( + "PCRL", + include_bytes!("../../j2k-test-support/fixtures/openjpeg_progression/pcrl_97x61.j2k"), + ), + ( + "CPRL", + include_bytes!("../../j2k-test-support/fixtures/openjpeg_progression/cprl_97x61.j2k"), + ), + ( + "PCRL, tiles, PLT", + include_bytes!( + "../../j2k-test-support/fixtures/openjpeg_progression/pcrl_97x61_tiles_plt.j2k" + ), + ), + ( + "CPRL, tiles, PLT", + include_bytes!( + "../../j2k-test-support/fixtures/openjpeg_progression/cprl_97x61_tiles_plt.j2k" + ), + ), + ]; + for (name, bytes) in fixtures { + let full = Image::new(bytes, &DecodeSettings::default()).expect("image"); + let half = Image::new( + bytes, + &DecodeSettings { + target_resolution: Some((full.width().div_ceil(2), full.height().div_ceil(2))), + ..DecodeSettings::default() + }, + ) + .expect("half image"); + let mut full_context = DecoderContext::default(); + full.decode_native_with_context(&mut full_context) + .unwrap_or_else(|error| panic!("{name}: full: {error:?}")); + let mut planes_context = DecoderContext::default(); + let planes = half + .decode_native_with_context(&mut planes_context) + .unwrap_or_else(|error| panic!("{name}: planes: {error:?}")); + let mut region_context = DecoderContext::default(); + let region = half + .decode_native_region_with_context( + (0, 0, half.width(), half.height()), + &mut region_context, + ) + .unwrap_or_else(|error| panic!("{name}: region: {error:?}")); + assert_eq!(planes.data, region.data, "{name}: samples"); + let headers = |context: &DecoderContext<'_>| { + context.tile_decode_context.debug_counters.packet_headers + }; + assert_eq!( + headers(&planes_context), + headers(®ion_context), + "{name}: packet headers parsed" + ); + assert!( + headers(&planes_context) < headers(&full_context), + "{name}: the half-resolution decode parses fewer packets than the full one" + ); + } +} + +/// Damage to a packet a reduced decode needs fails it as it fails the full +/// decode, on the planes, the region decode and the direct plan. +#[test] +fn reduced_decodes_fail_on_damage_to_packets_they_need() { + let pixels = gradient_pixels(64, 64, 3); + let options = EncodeOptions { + num_decomposition_levels: 2, + progression_order: EncodeProgressionOrder::Rpcl, + write_plt: true, + ..EncodeOptions::default() + }; + let mut damaged = encode(&pixels, 64, 64, 3, 8, false, &options).expect("encode"); + // RPCL: the first packet is the coarsest resolution's, which every + // reduced decode reads. + let sod = damaged + .windows(2) + .position(|window| window == [0xff, 0x93]) + .expect("SOD marker"); + damaged[sod + 2] = 0x00; + let half = DecodeSettings { + target_resolution: Some((32, 32)), + ..DecodeSettings::default() + }; + let image = Image::new(&damaged, &half).expect("half image"); + assert!(image.decode_native().is_err(), "planes"); + assert!( + image + .decode_native_region_with_context((0, 0, 32, 32), &mut DecoderContext::default()) + .is_err(), + "region" + ); + image + .build_direct_color_plan_with_context(&mut DecoderContext::default()) + .expect_err("direct plan"); +} + #[test] fn color_direct_plan_honors_target_resolution() { for (name, bytes) in [ diff --git a/crates/j2k-test-support/src/lib.rs b/crates/j2k-test-support/src/lib.rs index 38c64aaa..db34f4df 100644 --- a/crates/j2k-test-support/src/lib.rs +++ b/crates/j2k-test-support/src/lib.rs @@ -82,7 +82,7 @@ pub use manifest::{ pub use metal::{metal_device_unavailable_is_skip, metal_runtime_gate, metal_runtime_required}; pub use packet_edits::{ add_packet_length, edit_tile_parts, packet_ranges, with_byte_after_last_packets, - with_packets_filled, with_surplus_packet_lengths, + with_packet_lengths_edited, with_packets_filled, with_surplus_packet_lengths, }; pub use pixels::{ crop_interleaved_bytes, crop_interleaved_u16, project_scaled_interleaved_u16, diff --git a/crates/j2k-test-support/src/packet_edits.rs b/crates/j2k-test-support/src/packet_edits.rs index cc0bdb3e..8f31335b 100644 --- a/crates/j2k-test-support/src/packet_edits.rs +++ b/crates/j2k-test-support/src/packet_edits.rs @@ -152,6 +152,59 @@ pub fn with_packets_filled(codestream: &[u8], ranges: &[Range], byte: u8) edited } +/// `codestream` with each tile-part's PLT lengths passed through `edit` and +/// written back as one PLT marker, packets unchanged. +/// +/// # Panics +/// +/// When a tile-part has no PLT marker or its new lengths do not fit one. +#[must_use] +pub fn with_packet_lengths_edited( + codestream: &[u8], + mut edit: impl FnMut(&mut Vec), +) -> Vec { + edit_tile_parts(codestream, |part| { + let mut header = part[..12].to_vec(); + let mut lengths = Vec::new(); + let mut at = 12; + while part[at..at + 2] != SOD { + let end = marker_segment_end(part, at); + if part[at..at + 2] == PLT { + let mut length = 0_usize; + for &byte in &part[at + 5..end] { + length = (length << 7) | usize::from(byte & 0x7f); + if byte & 0x80 == 0 { + lengths.push(length); + length = 0; + } + } + } else { + header.extend_from_slice(&part[at..end]); + } + at = end; + } + assert!(!lengths.is_empty(), "tile-part has PLT lengths"); + edit(&mut lengths); + let mut entries = Vec::new(); + for &length in &lengths { + let mut groups = vec![u8::try_from(length & 0x7f).expect("seven bits")]; + let mut rest = length >> 7; + while rest != 0 { + groups.push(u8::try_from(rest & 0x7f).expect("seven bits") | 0x80); + rest >>= 7; + } + entries.extend(groups.iter().rev()); + } + let marker_length = u16::try_from(entries.len() + 3).expect("lengths fit one PLT marker"); + header.extend_from_slice(&PLT); + header.extend_from_slice(&marker_length.to_be_bytes()); + header.push(0); + header.extend_from_slice(&entries); + header.extend_from_slice(&part[at..]); + *part = header; + }) +} + #[cfg(test)] mod tests { use super::*; @@ -194,5 +247,13 @@ mod tests { ); let filled = with_packets_filled(&bytes, &packet_ranges(&bytes)[1..], 0xff); assert_eq!(filled[filled.len() - 5..filled.len() - 2], [0xff; 3]); + assert_eq!(with_packet_lengths_edited(&bytes, |_| {}), bytes); + let moved = with_packet_lengths_edited(&bytes, |lengths| { + lengths[0] += 1; + lengths[1] -= 1; + }); + let ranges = packet_ranges(&moved); + assert_eq!(moved[ranges[0].clone()], [1, 2, 3]); + assert_eq!(moved[ranges[1].clone()], [4, 5]); } } diff --git a/crates/j2k/src/view.rs b/crates/j2k/src/view.rs index 4e8cd509..858a8487 100644 --- a/crates/j2k/src/view.rs +++ b/crates/j2k/src/view.rs @@ -442,6 +442,12 @@ impl<'a> J2kDecoder<'a> { /// must hold `roi.w * roi.h * fmt.bytes_per_pixel()` bytes with the /// provided row stride. /// + /// Reduced-resolution and region decoding validate the packets and + /// structural dependencies needed for the requested output and may leave + /// other packets unexamined; detected errors in required data remain + /// fatal, success does not establish whole-codestream validity, and + /// acceptance is independent of execution route or thread count. + /// /// # Errors /// Returns [`J2kError`] when the region is out of bounds, the output buffer /// is too small, the format is unsupported, or decode fails. @@ -496,6 +502,12 @@ impl<'a> J2kDecoder<'a> { /// a decode whose component planes would pass the native decode budget /// runs as a region decode of the whole reduced image. /// + /// Reduced-resolution and region decoding validate the packets and + /// structural dependencies needed for the requested output and may leave + /// other packets unexamined; detected errors in required data remain + /// fatal, success does not establish whole-codestream validity, and + /// acceptance is independent of execution route or thread count. + /// /// # Errors /// Returns [`J2kError`] when the format or scale request is unsupported, /// including a scale deeper than the codestream's resolution ladder, the @@ -539,6 +551,12 @@ impl<'a> J2kDecoder<'a> { /// `roi` is expressed in full-resolution source pixels. The decoded output /// covers `roi.scaled_covering(scale)` in reduced-resolution coordinates. /// + /// Reduced-resolution and region decoding validate the packets and + /// structural dependencies needed for the requested output and may leave + /// other packets unexamined; detected errors in required data remain + /// fatal, success does not establish whole-codestream validity, and + /// acceptance is independent of execution route or thread count. + /// /// # Errors /// Returns [`J2kError`] when the region is out of bounds, the scale or /// pixel format is unsupported (including a scale deeper than the diff --git a/crates/j2k/tests/full_image_streaming.rs b/crates/j2k/tests/full_image_streaming.rs index a758674e..d5c314bd 100644 --- a/crates/j2k/tests/full_image_streaming.rs +++ b/crates/j2k/tests/full_image_streaming.rs @@ -437,3 +437,507 @@ fn last_packet_shorter_than_its_plt_length_fails_on_every_route() { } } } + +/// The packets of a one-precinct-per-resolution tile in progression order, +/// as (resolution, layer) per packet, with `components` components. +fn packet_order( + order: EncodeProgressionOrder, + resolutions: usize, + layers: usize, + components: usize, +) -> Vec<(usize, usize)> { + let mut packets = Vec::new(); + match order { + EncodeProgressionOrder::Lrcp => { + for layer in 0..layers { + for resolution in 0..resolutions { + packets.extend((0..components).map(|_| (resolution, layer))); + } + } + } + EncodeProgressionOrder::Rlcp | EncodeProgressionOrder::Rpcl => { + for resolution in 0..resolutions { + for layer in 0..layers { + packets.extend((0..components).map(|_| (resolution, layer))); + } + } + } + EncodeProgressionOrder::Pcrl | EncodeProgressionOrder::Cprl => { + for _ in 0..components { + for resolution in 0..resolutions { + packets.extend((0..layers).map(|layer| (resolution, layer))); + } + } + } + } + packets +} + +/// One damage case: a codestream whose packets selected by `damaged` (from +/// (resolution, layer)) are overwritten with 0xff in every tile-part, coded +/// with `options` at three resolutions and two layers. +struct Damage { + label: &'static str, + components: u16, + options: EncodeOptions, + damaged: fn(usize, usize) -> bool, + /// An edit of the damaged codestream's markers, packets unchanged. + markers: fn(&[u8]) -> Vec, + /// Whether a half-resolution decode reads none of the damaged packets. + half_succeeds: bool, +} + +fn same(codestream: &[u8]) -> Vec { + codestream.to_vec() +} + +/// `codestream` with a progression order change in its main header that +/// repeats its LRCP progression over every packet of three components. +fn with_lrcp_poc(codestream: &[u8]) -> Vec { + let sot = codestream + .windows(2) + .position(|window| window == [0xff, 0x90]) + .expect("SOT marker"); + let poc = [0xff, 0x5f, 0, 9, 0, 0, 0, 2, 3, 3, 0]; + [&codestream[..sot], &poc[..], &codestream[sot..]].concat() +} + +/// `codestream` without the PLT marker of its second tile-part. +fn without_second_plt(codestream: &[u8]) -> Vec { + let mut part_index = 0; + edit_tile_parts(codestream, |part| { + part_index += 1; + if part_index != 2 { + return; + } + let plt = part + .windows(2) + .position(|window| window == [0xff, 0x58]) + .expect("PLT marker"); + let end = plt + 2 + usize::from(u16::from_be_bytes([part[plt + 2], part[plt + 3]])); + part.drain(plt..end); + }) +} + +fn damaged_codestream(damage: &Damage) -> (Vec, Vec) { + let with_plt = EncodeOptions { + write_plt: true, + ..damage.options.clone() + }; + let clean_plt = codestream(damage.components, &with_plt); + let clean = codestream(damage.components, &damage.options); + let packets = packet_order( + damage.options.progression_order, + 3, + 2, + usize::from(damage.components), + ); + let ranges = j2k_test_support::packet_ranges(&clean_plt); + assert_eq!( + ranges.len() % packets.len(), + 0, + "{}: whole tiles", + damage.label + ); + let selected: Vec<_> = ranges + .iter() + .enumerate() + .filter(|&(index, _)| { + let (resolution, layer) = packets[index % packets.len()]; + (damage.damaged)(resolution, layer) + }) + .map(|(_, range)| range.clone()) + .collect(); + if damage.options.write_plt { + let edited = j2k_test_support::with_packets_filled(&clean_plt, &selected, 0xff); + return ((damage.markers)(&clean_plt), (damage.markers)(&edited)); + } + // The same packets without PLT: the tile-part headers are shorter by the + // PLT marker, the packet bytes are the same. + assert_eq!( + damage.options.tile_size, None, + "{}: one tile-part", + damage.label + ); + let sod = |bytes: &[u8]| { + bytes + .windows(2) + .position(|window| window == [0xff, 0x93]) + .expect("SOD marker") + }; + let shift = sod(&clean_plt) - sod(&clean); + assert_eq!(clean_plt[sod(&clean_plt)..], clean[sod(&clean)..]); + let shifted: Vec<_> = selected + .iter() + .map(|range| range.start - shift..range.end - shift) + .collect(); + let edited = j2k_test_support::with_packets_filled(&clean, &shifted, 0xff); + ((damage.markers)(&clean), (damage.markers)(&edited)) +} + +/// Damage to the finest or the first layer's finest packets, in the +/// progressions and layouts a half-resolution decode skips them in. +fn finest_damage_cases() -> [Damage; 6] { + let base = EncodeOptions { + num_decomposition_levels: 2, + num_layers: 2, + ..EncodeOptions::default() + }; + [ + Damage { + label: "RPCL, PLT, finest packets", + components: 3, + options: EncodeOptions { + progression_order: EncodeProgressionOrder::Rpcl, + write_plt: true, + ..base.clone() + }, + damaged: finest, + markers: same, + half_succeeds: true, + }, + Damage { + label: "RPCL, finest packets", + components: 1, + options: EncodeOptions { + progression_order: EncodeProgressionOrder::Rpcl, + ..base.clone() + }, + damaged: finest, + markers: same, + half_succeeds: true, + }, + Damage { + label: "RLCP, HTJ2K, finest packets", + components: 3, + options: EncodeOptions { + progression_order: EncodeProgressionOrder::Rlcp, + use_ht_block_coding: true, + ..base.clone() + }, + damaged: finest, + markers: same, + half_succeeds: true, + }, + Damage { + label: "RPCL, PLT, tiles, finest packets", + components: 3, + options: EncodeOptions { + progression_order: EncodeProgressionOrder::Rpcl, + tile_size: Some((64, 64)), + write_plt: true, + ..base.clone() + }, + damaged: finest, + markers: same, + half_succeeds: true, + }, + Damage { + label: "LRCP, PLT, first layer's finest packets", + components: 3, + options: EncodeOptions { + progression_order: EncodeProgressionOrder::Lrcp, + write_plt: true, + ..base.clone() + }, + damaged: first_finest, + markers: same, + half_succeeds: true, + }, + Damage { + label: "CPRL, PLT, finest packets", + components: 3, + options: EncodeOptions { + progression_order: EncodeProgressionOrder::Cprl, + write_plt: true, + ..base.clone() + }, + damaged: finest, + markers: same, + half_succeeds: true, + }, + ] +} + +/// The other damage cases of +/// [`reduced_decodes_read_the_same_packets_on_every_route`]: packets a +/// half-resolution decode must parse or read, and more finest packets it +/// skips, with PCRL or several tile-parts per tile. +fn other_damage_cases() -> [Damage; 6] { + let base = EncodeOptions { + num_decomposition_levels: 2, + num_layers: 2, + ..EncodeOptions::default() + }; + [ + Damage { + label: "LRCP, first layer's finest packets, parsed to reach the second layer", + components: 1, + options: EncodeOptions { + progression_order: EncodeProgressionOrder::Lrcp, + ..base.clone() + }, + damaged: first_finest, + markers: same, + half_succeeds: false, + }, + Damage { + label: "RPCL, PLT, coarsest packets", + components: 3, + options: EncodeOptions { + progression_order: EncodeProgressionOrder::Rpcl, + write_plt: true, + ..base.clone() + }, + damaged: coarsest, + markers: same, + half_succeeds: false, + }, + Damage { + label: "PCRL, PLT, finest packets", + components: 3, + options: EncodeOptions { + progression_order: EncodeProgressionOrder::Pcrl, + write_plt: true, + ..base.clone() + }, + damaged: finest, + markers: same, + half_succeeds: true, + }, + Damage { + label: "RPCL, PLT, two packets per tile-part, finest packets", + components: 3, + options: EncodeOptions { + progression_order: EncodeProgressionOrder::Rpcl, + tile_part_packet_limit: Some(2), + write_plt: true, + ..base.clone() + }, + damaged: finest, + markers: same, + half_succeeds: true, + }, + Damage { + label: "LRCP, PLT, POC, first layer's finest packets, parsed to reach the second layer", + components: 3, + options: EncodeOptions { + progression_order: EncodeProgressionOrder::Lrcp, + write_plt: true, + ..base.clone() + }, + damaged: first_finest, + markers: with_lrcp_poc, + half_succeeds: false, + }, + Damage { + label: "LRCP, PLT in all but one tile-part, first layer's finest packets, parsed", + components: 3, + options: EncodeOptions { + progression_order: EncodeProgressionOrder::Lrcp, + tile_part_packet_limit: Some(3), + write_plt: true, + ..base.clone() + }, + damaged: first_finest, + markers: without_second_plt, + half_succeeds: false, + }, + ] +} + +fn finest(resolution: usize, _layer: usize) -> bool { + resolution == 2 +} + +fn first_finest(resolution: usize, layer: usize) -> bool { + resolution == 2 && layer == 0 +} + +fn coarsest(resolution: usize, _layer: usize) -> bool { + resolution == 0 +} + +/// A decode below full resolution reads the packets of the resolutions it +/// keeps, and of the finer ones only those it has to parse to find a later +/// packet it needs. Damage confined to the others leaves its output that of +/// the undamaged codestream on every route and pool size; damage in a +/// packet it needs or has to parse fails on every route, as the +/// full-resolution decode of every damaged codestream does. +#[test] +fn reduced_decodes_read_the_same_packets_on_every_route() { + let cases = finest_damage_cases() + .into_iter() + .chain(other_damage_cases()); + let pools = [1, 4].map(|threads| { + rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .expect("pool") + }); + for damage in cases { + let (clean, edited) = damaged_codestream(&damage); + let fmt = if damage.components == 1 { + PixelFormat::Gray8 + } else { + PixelFormat::Rgb8 + }; + let expected = decode( + &clean, + None, + CpuDecodeParallelism::Serial, + fmt, + Downscale::Half, + 0, + ) + .expect("clean half-resolution decode"); + for (pool, threads) in pools.iter().zip([1, 4]) { + for budget in [Some(usize::MAX), Some(0), None] { + let label = format!( + "{}, {threads} threads, planes budget {budget:?}", + damage.label + ); + let half = pool.install(|| { + decode( + &edited, + budget, + CpuDecodeParallelism::Auto, + fmt, + Downscale::Half, + 0, + ) + }); + if damage.half_succeeds { + assert!(half.as_ref() == Some(&expected), "{label}: half resolution"); + } else { + assert!(half.is_none(), "{label}: half resolution decoded"); + } + let full = pool.install(|| { + decode( + &edited, + budget, + CpuDecodeParallelism::Auto, + fmt, + Downscale::None, + 0, + ) + }); + assert!(full.is_none(), "{label}: full resolution decoded"); + } + } + } +} + +/// A PLT length that ends a skipped packet inside the next packet the reduced +/// decode needs, or past the tile-part, disagrees with the packets: every +/// route fails rather than read a needed packet from the wrong byte. +#[test] +fn misplaced_plt_length_before_a_needed_packet_fails_on_every_route() { + let clean = codestream( + 3, + &EncodeOptions { + num_decomposition_levels: 2, + num_layers: 2, + progression_order: EncodeProgressionOrder::Lrcp, + write_plt: true, + ..EncodeOptions::default() + }, + ); + // LRCP, three components: packets 6..9 are the first layer's finest, + // packet 9 the second layer's first, which a half-resolution decode reads. + let one_byte_early = j2k_test_support::with_packet_lengths_edited(&clean, |lengths| { + assert!(lengths[8] > 1, "a finest packet with a body"); + lengths[8] -= 1; + lengths[9] += 1; + }); + let past_the_tile_part = + j2k_test_support::with_packet_lengths_edited(&clean, |lengths| lengths[8] += 1 << 20); + let fmt = PixelFormat::Rgb8; + assert!(decode( + &clean, + None, + CpuDecodeParallelism::Serial, + fmt, + Downscale::Half, + 0 + ) + .is_some()); + for (label, edited) in [ + ("one byte early", one_byte_early), + ("past the tile-part", past_the_tile_part), + ] { + for threads in [1, 4] { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .expect("pool"); + for budget in [Some(usize::MAX), Some(0), None] { + let half = pool.install(|| { + decode( + &edited, + budget, + CpuDecodeParallelism::Auto, + fmt, + Downscale::Half, + 0, + ) + }); + assert!( + half.is_none(), + "{label}, {threads} threads, planes budget {budget:?} decoded" + ); + } + } + } +} + +/// A tile-part cut inside its tile's last packet, a finest-resolution one, +/// fails at full resolution and decodes at half resolution as the uncut +/// codestream does, on every route, wherever the cut falls in that packet +/// (with EPH markers after the packet headers too). +#[test] +fn truncated_last_packet_spares_reduced_decodes_on_every_route() { + for write_eph in [false, true] { + let options = EncodeOptions { + num_decomposition_levels: 2, + progression_order: EncodeProgressionOrder::Rpcl, + write_eph, + write_plt: true, + ..EncodeOptions::default() + }; + let with_plt = codestream(1, &options); + let last = j2k_test_support::packet_ranges(&with_plt) + .pop() + .expect("packets") + .len(); + let clean = codestream( + 1, + &EncodeOptions { + write_plt: false, + ..options + }, + ); + let fmt = PixelFormat::Gray8; + let expected = decode( + &clean, + None, + CpuDecodeParallelism::Serial, + fmt, + Downscale::Half, + 0, + ) + .expect("clean half-resolution decode"); + for cut in (1..last).step_by((last / 8).max(1)) { + let edited = edit_tile_parts(&clean, |part| part.truncate(part.len() - cut)); + for parallelism in [CpuDecodeParallelism::Serial, CpuDecodeParallelism::Auto] { + for budget in [Some(usize::MAX), Some(0), None] { + let label = format!("EPH {write_eph}, cut {cut} of {last}, {parallelism:?}, planes budget {budget:?}"); + let half = decode(&edited, budget, parallelism, fmt, Downscale::Half, 0); + assert!(half.as_ref() == Some(&expected), "{label}: half resolution"); + let full = decode(&edited, budget, parallelism, fmt, Downscale::None, 0); + assert!(full.is_none(), "{label}: full resolution decoded"); + } + } + } + } +} From cf5d58ee1d00749d479262ce09ca2416c625c988 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 05:34:09 -0500 Subject: [PATCH 09/30] test(j2k): compare the error classes of failing decodes across routes The forced-route tests kept only whether each decode failed, so a route that failed for another reason than the others went unnoticed. Each decode now gives the class of the native error behind its failure (or the facade's own error, which a failing case must not have), and every route, pool size and planes budget of one codestream and request must fail with one class. All cases do. --- crates/j2k/tests/full_image_streaming.rs | 107 ++++++++++++++++------- 1 file changed, 76 insertions(+), 31 deletions(-) diff --git a/crates/j2k/tests/full_image_streaming.rs b/crates/j2k/tests/full_image_streaming.rs index d5c314bd..bc9c0736 100644 --- a/crates/j2k/tests/full_image_streaming.rs +++ b/crates/j2k/tests/full_image_streaming.rs @@ -10,7 +10,7 @@ use j2k::{CpuDecodeParallelism, J2kDecoder, J2kScratchPool}; use j2k_core::{Downscale, PixelFormat}; -use j2k_native::{encode, EncodeOptions, EncodeProgressionOrder}; +use j2k_native::{encode, DecodeErrorClass, EncodeOptions, EncodeProgressionOrder}; use j2k_test_support::{ add_packet_length, edit_tile_parts, with_byte_after_last_packets, with_surplus_packet_lengths, wrap_jp2_rgba_codestream, @@ -37,9 +37,49 @@ fn codestream(components: u16, options: &EncodeOptions) -> Vec { encode(&pixels, WIDTH, HEIGHT, components, 8, false, options).expect("encode fixture") } -/// The image at `scale` in a buffer padded past each row, or `None` when the -/// decode fails, from a decoder whose planes budget is `planes_budget` when -/// one is given. +/// Why a decode failed: the class of the native decode error behind it, or +/// the facade's own error where there is none. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Failure { + Native(DecodeErrorClass), + Facade(String), +} + +impl Failure { + fn of(error: &j2k::J2kError) -> Self { + let mut next: Option<&(dyn std::error::Error + 'static)> = Some(error); + while let Some(current) = next { + if let Some(native) = current.downcast_ref::() { + return Self::Native(native.classify()); + } + next = current.source(); + } + Self::Facade(error.to_string()) + } +} + +/// Decodes of one codestream and request that must all fail, and for one +/// reason. +#[derive(Default)] +struct OneFailure(Option); + +impl OneFailure { + fn check(&mut self, decoded: &Result, Failure>, case: &str) { + let Err(failure) = decoded else { + panic!("{case} decoded"); + }; + let first = self.0.get_or_insert_with(|| failure.clone()); + assert!( + matches!(failure, Failure::Native(_)), + "{case}: {failure:?} has no native cause" + ); + assert_eq!(failure, first, "{case}: error class"); + } +} + +/// The image at `scale` in a buffer padded past each row, or the native error +/// class of the failure, from a decoder whose planes budget is +/// `planes_budget` when one is given. fn decode( bytes: &[u8], planes_budget: Option, @@ -47,7 +87,7 @@ fn decode( fmt: PixelFormat, scale: Downscale, padding: usize, -) -> Option> { +) -> Result, Failure> { let mut decoder = J2kDecoder::new(bytes).expect("decoder"); decoder.set_cpu_decode_parallelism(parallelism); if let Some(bytes) = planes_budget { @@ -64,14 +104,14 @@ fn decode( Downscale::None => decoder.decode_into(&mut out, stride, fmt), _ => decoder.decode_scaled_into(pool, &mut out, stride, fmt, scale), }; - result.ok()?; + result.map_err(|error| Failure::of(&error))?; for line in out.chunks(stride) { assert!( line[row..].iter().all(|&byte| byte == PADDING), "stride padding untouched" ); } - Some(out) + Ok(out) } #[test] @@ -158,11 +198,11 @@ fn streamed_full_image_matches_planes() { assert!( planes == streamed && planes == default, "{label} {fmt:?} {scale:?} {parallelism:?}: planes {} streamed {} default {}", - planes.is_some(), - streamed.is_some(), - default.is_some() + planes.is_ok(), + streamed.is_ok(), + default.is_ok() ); - decoded += usize::from(planes.is_some()); + decoded += usize::from(planes.is_ok()); } } } @@ -192,7 +232,7 @@ fn tiles_with_empty_coarse_levels_stream_as_the_planes_decode() { let planes = decode(bytes, Some(usize::MAX), parallelism, fmt, scale, 0); let lowered = decode(bytes, Some(0), parallelism, fmt, scale, 0); let default = decode(bytes, None, parallelism, fmt, scale, 0); - assert!(planes.is_some(), "{label} {parallelism:?} {scale:?}"); + assert!(planes.is_ok(), "{label} {parallelism:?} {scale:?}"); assert_eq!(planes, lowered, "{label} {parallelism:?} {scale:?}"); assert_eq!(planes, default, "{label} {parallelism:?} {scale:?}"); } @@ -329,9 +369,9 @@ fn data_after_a_tiles_last_packet_decodes_as_without_it_on_every_route() { decode(edited, budget, CpuDecodeParallelism::Auto, fmt, scale, 0) }); assert!( - decoded.as_ref() == Some(&expected), + decoded.as_ref() == Ok(&expected), "{label} {fmt:?} {scale:?} {threads} threads, planes budget {budget:?}: {}", - if decoded.is_some() { "other bytes" } else { "failed" } + if decoded.is_ok() { "other bytes" } else { "failed" } ); } } @@ -368,14 +408,15 @@ fn surplus_packet_length_before_a_later_tile_part_fails_on_every_route() { }); for scale in [Downscale::None, Downscale::Half] { let fmt = PixelFormat::Gray8; - assert!(decode(&split, None, CpuDecodeParallelism::Serial, fmt, scale, 0).is_some()); + assert!(decode(&split, None, CpuDecodeParallelism::Serial, fmt, scale, 0).is_ok()); + let mut failure = OneFailure::default(); for (pool, threads) in pools.iter().zip([1, 4]) { for budget in [Some(usize::MAX), Some(0), None] { let decoded = pool .install(|| decode(&edited, budget, CpuDecodeParallelism::Auto, fmt, scale, 0)); - assert!( - decoded.is_none(), - "{scale:?} {threads} threads, planes budget {budget:?} decoded" + failure.check( + &decoded, + &format!("{scale:?} {threads} threads, planes budget {budget:?}"), ); } } @@ -413,7 +454,8 @@ fn last_packet_shorter_than_its_plt_length_fails_on_every_route() { Downscale::None, 0, ); - assert!(clean.is_some()); + assert!(clean.is_ok()); + let mut failure = OneFailure::default(); for threads in [1, 4] { let pool = rayon::ThreadPoolBuilder::new() .num_threads(threads) @@ -430,9 +472,9 @@ fn last_packet_shorter_than_its_plt_length_fails_on_every_route() { 0, ) }); - assert!( - decoded.is_none(), - "{threads} threads, planes budget {budget:?} decoded" + failure.check( + &decoded, + &format!("{threads} threads, planes budget {budget:?}"), ); } } @@ -791,6 +833,7 @@ fn reduced_decodes_read_the_same_packets_on_every_route() { 0, ) .expect("clean half-resolution decode"); + let (mut half_failure, mut full_failure) = (OneFailure::default(), OneFailure::default()); for (pool, threads) in pools.iter().zip([1, 4]) { for budget in [Some(usize::MAX), Some(0), None] { let label = format!( @@ -808,9 +851,9 @@ fn reduced_decodes_read_the_same_packets_on_every_route() { ) }); if damage.half_succeeds { - assert!(half.as_ref() == Some(&expected), "{label}: half resolution"); + assert!(half.as_ref() == Ok(&expected), "{label}: half resolution"); } else { - assert!(half.is_none(), "{label}: half resolution decoded"); + half_failure.check(&half, &format!("{label}: half resolution")); } let full = pool.install(|| { decode( @@ -822,7 +865,7 @@ fn reduced_decodes_read_the_same_packets_on_every_route() { 0, ) }); - assert!(full.is_none(), "{label}: full resolution decoded"); + full_failure.check(&full, &format!("{label}: full resolution")); } } } @@ -861,11 +904,12 @@ fn misplaced_plt_length_before_a_needed_packet_fails_on_every_route() { Downscale::Half, 0 ) - .is_some()); + .is_ok()); for (label, edited) in [ ("one byte early", one_byte_early), ("past the tile-part", past_the_tile_part), ] { + let mut failure = OneFailure::default(); for threads in [1, 4] { let pool = rayon::ThreadPoolBuilder::new() .num_threads(threads) @@ -882,9 +926,9 @@ fn misplaced_plt_length_before_a_needed_packet_fails_on_every_route() { 0, ) }); - assert!( - half.is_none(), - "{label}, {threads} threads, planes budget {budget:?} decoded" + failure.check( + &half, + &format!("{label}, {threads} threads, planes budget {budget:?}"), ); } } @@ -929,13 +973,14 @@ fn truncated_last_packet_spares_reduced_decodes_on_every_route() { .expect("clean half-resolution decode"); for cut in (1..last).step_by((last / 8).max(1)) { let edited = edit_tile_parts(&clean, |part| part.truncate(part.len() - cut)); + let mut failure = OneFailure::default(); for parallelism in [CpuDecodeParallelism::Serial, CpuDecodeParallelism::Auto] { for budget in [Some(usize::MAX), Some(0), None] { let label = format!("EPH {write_eph}, cut {cut} of {last}, {parallelism:?}, planes budget {budget:?}"); let half = decode(&edited, budget, parallelism, fmt, Downscale::Half, 0); - assert!(half.as_ref() == Some(&expected), "{label}: half resolution"); + assert!(half.as_ref() == Ok(&expected), "{label}: half resolution"); let full = decode(&edited, budget, parallelism, fmt, Downscale::None, 0); - assert!(full.is_none(), "{label}: full resolution decoded"); + failure.check(&full, &format!("{label}: full resolution")); } } } From d73d1f3610a20a955e957eb0bd9c4fd4ac6e0aee Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 05:34:25 -0500 Subject: [PATCH 10/30] fix(j2k-native): reject PLT lengths shorter than any packet a walk would skip by A walk that skips unneeded packets by their PLT lengths, as region decodes and decodes below full resolution do, took any length as the packet's. Every packet with an in-band header holds at least the first byte of its header, and two more for its EPH marker where the coding style requires one, so a shorter length made the walk read the next packet from the bytes of the one it skipped. In a flat three-component CPRL image whose finer packets are empty, a zero length for the second component's finest packet and a length of one for the third component's coarsest made a half-resolution decode read the empty finest packet as the third component's coarsest, find every packet it needs and stop: it reported success with RGB (88, 88, 158) where the image is (200, 50, 120), on the planes and the streamed route alike. With EPH markers and three resolutions, lengths of one and two for the second component's finer packets did the same at quarter resolution. Full decodes reject these lengths, as the packets they parse disagree with them. Skipping now fails with a packet error on a length below one byte, or three where EPH markers are required. Lengths a walk never skips by (after its last needed packet, or with packed packet headers, whose PLT lengths cover the body alone) are not examined. Facade tests check every route, pool size and the direct plan at every resolution of both images. --- crates/j2k-native/src/j2c/segment.rs | 9 +- crates/j2k-native/src/j2c/tile/cursor.rs | 26 +++- crates/j2k/tests/full_image_streaming.rs | 145 +++++++++++++++++++++++ 3 files changed, 173 insertions(+), 7 deletions(-) diff --git a/crates/j2k-native/src/j2c/segment.rs b/crates/j2k-native/src/j2c/segment.rs index 160ee06f..2405e98c 100644 --- a/crates/j2k-native/src/j2c/segment.rs +++ b/crates/j2k-native/src/j2c/segment.rs @@ -480,7 +480,14 @@ fn parse_inner<'a>( }; #[cfg(not(feature = "std"))] let layers = progression_iterator.remaining_precinct_layers(); - let (skipped, complete) = tile_part.skip_packets(1 + layers); + // A packet header takes a byte at least, and its EPH marker two + // more where the coding style requires one. + let min_length = if component_info.coding_style.flags.uses_eph_marker() { + 3 + } else { + 1 + }; + let (skipped, complete) = tile_part.skip_packets(1 + layers, min_length)?; // The packets a one-by-one walk would have taken from the // progression after this one: each skipped, plus a failing one. let further = if complete { diff --git a/crates/j2k-native/src/j2c/tile/cursor.rs b/crates/j2k-native/src/j2c/tile/cursor.rs index 9b86f618..a1c9013c 100644 --- a/crates/j2k-native/src/j2c/tile/cursor.rs +++ b/crates/j2k-native/src/j2c/tile/cursor.rs @@ -158,16 +158,27 @@ impl<'data> TilePartCursor<'_, 'data> { /// skipped and whether the next skip would have succeeded: `false` when /// the length is not signalled, the headers live in PPM/PPT, or the length /// runs past the tile-part, which consumes nothing of that packet. - pub(crate) fn skip_packets(&mut self, count: usize) -> (usize, bool) { + /// + /// # Errors + /// + /// A length below `min_length`, the fewest bytes a packet with an in-band + /// header takes (its header's first byte, and an EPH marker where the + /// coding style requires one): skipping by it would read the next packet + /// from the bytes of the one it skips. + pub(crate) fn skip_packets( + &mut self, + count: usize, + min_length: u32, + ) -> Result<(usize, bool), &'static str> { let Self::Merged { data, packet_lengths, } = self else { - return (0, false); + return Ok((0, false)); }; if !packet_lengths.present { - return (0, false); + return Ok((0, false)); } let mut skipped = 0; while skipped < count { @@ -175,15 +186,18 @@ impl<'data> TilePartCursor<'_, 'data> { break; } let Some(&length) = packet_lengths.lengths.get(packet_lengths.next) else { - return (skipped, false); + return Ok((skipped, false)); }; + if length < min_length { + return Err("PLT packet length is shorter than any packet"); + } if data.skip_bytes(length as usize).is_none() { - return (skipped, false); + return Ok((skipped, false)); } packet_lengths.next += 1; skipped += 1; } - (skipped, true) + Ok((skipped, true)) } pub(crate) fn validate_all_packet_lengths_consumed(&self) -> Option<()> { diff --git a/crates/j2k/tests/full_image_streaming.rs b/crates/j2k/tests/full_image_streaming.rs index bc9c0736..9db2f319 100644 --- a/crates/j2k/tests/full_image_streaming.rs +++ b/crates/j2k/tests/full_image_streaming.rs @@ -935,6 +935,151 @@ fn misplaced_plt_length_before_a_needed_packet_fails_on_every_route() { } } +/// A direct plan of `bytes`, RGB, at `scale`: `Ok` when it builds. +fn direct_plan(bytes: &[u8], scale: Downscale) -> Result<(), Failure> { + let image = j2k_native::Image::new(bytes, &j2k_native::DecodeSettings::default()) + .expect("parse header"); + let (width, height) = (image.width(), image.height()); + let divisor = scale.denominator(); + let settings = j2k_native::DecodeSettings { + target_resolution: Some((width.div_ceil(divisor), height.div_ceil(divisor))), + ..j2k_native::DecodeSettings::default() + }; + let image = j2k_native::Image::new(bytes, &settings).expect("parse header"); + image + .build_direct_color_plan_with_context(&mut j2k_native::DecoderContext::default()) + .map(drop) + .map_err(|error| Failure::Native(error.classify())) +} + +/// Every packet holds at least the first byte of its header, so a zero PLT +/// length is no packet's. Skipping an unneeded packet by such a length would +/// read the next needed packet from the skipped one's bytes: here, in a flat +/// image whose finest packets are empty, the second component's finest packet +/// as the third component's coarsest, whose data a half-resolution decode +/// needs and would never read. Every route, pool size and direct plan +/// rejects it, at both resolutions. +#[test] +fn zero_plt_length_before_a_needed_packet_fails_on_every_route() { + let flat: Vec = (0..WIDTH * HEIGHT).flat_map(|_| [200, 50, 120]).collect(); + let options = EncodeOptions { + num_decomposition_levels: 1, + progression_order: EncodeProgressionOrder::Cprl, + write_plt: true, + ..EncodeOptions::default() + }; + let clean = encode(&flat, WIDTH, HEIGHT, 3, 8, false, &options).expect("encode fixture"); + // CPRL, one layer, one precinct per resolution: each component's + // coarsest packet, then its finest, which the flat image leaves empty. + let lengths: Vec = j2k_test_support::packet_ranges(&clean) + .iter() + .map(std::ops::Range::len) + .collect(); + assert_eq!(lengths.len(), 6, "six packets"); + assert_eq!( + [lengths[1], lengths[3], lengths[5]], + [1, 1, 1], + "empty finest packets" + ); + assert!( + lengths[4] > 1, + "the third component's coarsest packet has data" + ); + let zero = j2k_test_support::with_packet_lengths_edited(&clean, |lengths| { + let total: usize = lengths.iter().sum(); + lengths[3] = 0; + lengths[4] = 1; + lengths[5] = total - lengths[..5].iter().sum::(); + }); + let fmt = PixelFormat::Rgb8; + let pools = [1, 4].map(|threads| { + rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .expect("pool") + }); + for scale in [Downscale::None, Downscale::Half] { + assert!(decode(&clean, None, CpuDecodeParallelism::Serial, fmt, scale, 0).is_ok()); + assert!(direct_plan(&clean, scale).is_ok()); + let mut failure = OneFailure::default(); + for (pool, threads) in pools.iter().zip([1, 4]) { + for budget in [Some(usize::MAX), Some(0), None] { + let decoded = pool + .install(|| decode(&zero, budget, CpuDecodeParallelism::Auto, fmt, scale, 0)); + failure.check( + &decoded, + &format!("{scale:?} {threads} threads, planes budget {budget:?}"), + ); + } + } + let planned = direct_plan(&zero, scale).map(|()| Vec::new()); + failure.check(&planned, &format!("{scale:?} direct plan")); + } +} + +/// With EPH markers every packet holds at least three bytes, its header's +/// first byte and the marker, so PLT lengths of one and two are no packet's. +/// Skipping by them would misplace the next needed packet as a zero length +/// does: in a flat CPRL image with three resolutions, lengths of one and two +/// for the second component's finer packets make a quarter-resolution decode +/// read the second one as the third component's coarsest, needed, packet. +/// Every route, pool size and direct plan rejects it, at every resolution. +#[test] +fn plt_lengths_shorter_than_an_eph_packet_fail_on_every_route() { + let flat: Vec = (0..WIDTH * HEIGHT).flat_map(|_| [200, 50, 120]).collect(); + let options = EncodeOptions { + num_decomposition_levels: 2, + progression_order: EncodeProgressionOrder::Cprl, + write_plt: true, + write_eph: true, + ..EncodeOptions::default() + }; + let clean = encode(&flat, WIDTH, HEIGHT, 3, 8, false, &options).expect("encode fixture"); + // CPRL, one layer, one precinct per resolution: each component's three + // packets, coarsest first; the finer ones are empty, a byte and EPH. + let lengths: Vec = j2k_test_support::packet_ranges(&clean) + .iter() + .map(std::ops::Range::len) + .collect(); + assert_eq!(lengths.len(), 9, "nine packets"); + for finer in [1, 2, 4, 5, 7, 8] { + assert_eq!(lengths[finer], 3, "empty packet {finer}"); + } + assert!( + lengths[6] > 3, + "the third component's coarsest packet has data" + ); + let short = j2k_test_support::with_packet_lengths_edited(&clean, |lengths| { + let total: usize = lengths.iter().sum(); + lengths[4] = 1; + lengths[5] = 2; + lengths[6] = 3; + lengths[8] = total - lengths[..8].iter().sum::(); + }); + let fmt = PixelFormat::Rgb8; + for scale in [Downscale::None, Downscale::Half, Downscale::Quarter] { + assert!(decode(&clean, None, CpuDecodeParallelism::Serial, fmt, scale, 0).is_ok()); + assert!(direct_plan(&clean, scale).is_ok()); + let mut failure = OneFailure::default(); + for threads in [1, 4] { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .expect("pool"); + for budget in [Some(usize::MAX), Some(0), None] { + let decoded = pool + .install(|| decode(&short, budget, CpuDecodeParallelism::Auto, fmt, scale, 0)); + failure.check( + &decoded, + &format!("{scale:?} {threads} threads, planes budget {budget:?}"), + ); + } + } + let planned = direct_plan(&short, scale).map(|()| Vec::new()); + failure.check(&planned, &format!("{scale:?} direct plan")); + } +} + /// A tile-part cut inside its tile's last packet, a finest-resolution one, /// fails at full resolution and decodes at half resolution as the uncut /// codestream does, on every route, wherever the cut falls in that packet From fe95a920a7331fe08b9edadc7a8f5205dafbe328 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 05:34:40 -0500 Subject: [PATCH 11/30] fix(j2k-native): check a tile's precinct records fit before its whole-image selection The whole-image selection that planes decodes and direct plans below full resolution parse with charged only what it allocates, without the region selection's up-front check of the build's structure, so as not to count the retained capacity the build reuses twice. A tile the build must reject was then walked first: a 105-byte header declaring one 16,383 x 16,383 gray tile with one-sample LL precincts and two-sample ones above (2^28 sub-band precincts) made a half-resolution direct plan hold 134 MB and walk 2^27 precincts before the build failed it. The selection now first checks that the decode's live bytes, the selection and the structure the build stores for every precinct (one record and two tag-tree roots each, and the skeleton) fit the cap, without the retained capacity, which the build reuses for them, and without the region build's ROI windows. The region selection's check is unchanged. The tile above now fails with a resource limit before any precinct is walked: the direct plan holds under 4 MiB, the planes decode nothing beyond the coefficient plane it allocates before building the tile. --- crates/j2k-alloc-probe/tests/main.rs | 76 +++++++++++- crates/j2k-native/src/j2c/build/selection.rs | 117 ++++++++++++++++--- 2 files changed, 177 insertions(+), 16 deletions(-) diff --git a/crates/j2k-alloc-probe/tests/main.rs b/crates/j2k-alloc-probe/tests/main.rs index 6e2ffeac..14e8caee 100644 --- a/crates/j2k-alloc-probe/tests/main.rs +++ b/crates/j2k-alloc-probe/tests/main.rs @@ -9,9 +9,10 @@ use j2k_native::{ decode_ht_code_block_scalar_with_workspace, encode, encode_ht_code_block_scalar_with_passes_and_workspace, encode_precomputed_htj2k_97_with_accelerator_and_max_host_bytes, - CpuOnlyJ2kEncodeStageAccelerator, DecodeSettings, DecoderContext, EncodeError, EncodeOptions, - HtCodeBlockDecodeJob, HtCodeBlockDecodeWorkspace, HtCodeBlockEncodeWorkspace, Image, - J2kForwardDwt97Output, PrecomputedHtj2k97Component, PrecomputedHtj2k97Image, + CpuOnlyJ2kEncodeStageAccelerator, DecodeErrorClass, DecodeSettings, DecoderContext, + EncodeError, EncodeOptions, HtCodeBlockDecodeJob, HtCodeBlockDecodeWorkspace, + HtCodeBlockEncodeWorkspace, Image, J2kForwardDwt97Output, PrecomputedHtj2k97Component, + PrecomputedHtj2k97Image, }; use proptest::{ collection::vec, @@ -72,6 +73,10 @@ fn main() { "precomputed_encode_obeys_ledger_and_allocator_budgets", precomputed_encode_obeys_ledger_and_allocator_budgets, ), + ( + "reduced_decodes_reject_a_precinct_grid_past_the_cap_before_walking_it", + reduced_decodes_reject_a_precinct_grid_past_the_cap_before_walking_it, + ), ]; let mut failures = 0_u32; @@ -367,6 +372,71 @@ fn warmed_decoder_context_has_bounded_transients() { assert_eq!(output, pixels); } +/// A raw codestream of one 16,383 x 16,383 gray tile with one decomposition +/// level, one-sample precincts in the LL sub-band and two-sample ones above: +/// 2^28 sub-band precincts, whose records no decode can hold within the cap, +/// declared in 105 bytes. Its one tile-part holds one empty packet. +fn precinct_grid_past_the_cap() -> Vec { + let mut bytes = vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0]; + for value in [16_383_u32, 16_383, 0, 0, 16_383, 16_383, 0, 0] { + bytes.extend_from_slice(&value.to_be_bytes()); + } + bytes.extend_from_slice(&[0, 1, 7, 1, 1]); + // COD: precincts given; LRCP, one layer; one level, 4 x 4 blocks, 5/3; + // precincts 1 x 1 at resolution 0 and 2 x 2 at resolution 1. + bytes.extend_from_slice(&[0xff, 0x52, 0, 14, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0x00, 0x11]); + // QCD: reversible, two guard bits. + bytes.extend_from_slice(&[0xff, 0x5c, 0, 7, 0x40, 0x48, 0x48, 0x48, 0x48]); + bytes.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0, 0, 0, 0, 15, 0, 1]); + bytes.extend_from_slice(&[0xff, 0x93, 0, 0xff, 0xd9]); + bytes +} + +/// A half-resolution direct plan and planes decode of a tile whose precinct +/// records cannot fit the cap fail as the build would, before choosing the +/// packets they read, which would walk and record each of the 2^27 precincts +/// first: the plan holds no more than 4 MiB at once, the planes decode no +/// more beside the 8,192 x 8,192 coefficient plane it allocates before it +/// builds the tile. +fn reduced_decodes_reject_a_precinct_grid_past_the_cap_before_walking_it() { + const PEAK: usize = 4 << 20; + const PLANE: usize = 8_192 * 8_192 * size_of::(); + let codestream = precinct_grid_past_the_cap(); + let half = DecodeSettings { + target_resolution: Some((8_192, 8_192)), + ..DecodeSettings::default() + }; + let image = Image::new(&codestream, &half).expect("parse header"); + let resource_limit = |error: &j2k_native::DecodeError| { + matches!(error.classify(), DecodeErrorClass::ResourceLimit { .. }) + }; + + let base = reset_peak(); + let plan = image.build_direct_grayscale_plan_with_context(&mut DecoderContext::default()); + let plan_peak = peak_bytes() - base; + assert!( + plan.as_ref().is_err_and(resource_limit), + "direct plan: {:?}", + plan.err() + ); + assert!(plan_peak <= PEAK, "direct plan held {plan_peak} bytes"); + + let mut output = vec![0_u8; 8_192 * 8_192]; + let mut context = DecoderContext::default(); + let base = reset_peak(); + let decoded = image.decode_into(&mut output, &mut context); + let decode_peak = peak_bytes() - base; + assert!( + decoded.as_ref().is_err_and(resource_limit), + "planes decode: {:?}", + decoded.err() + ); + assert!( + decode_peak <= PLANE + PEAK, + "planes decode held {decode_peak} bytes" + ); +} + fn profile_row_formatting_is_single_allocation() { let fields = [("route", "scalar"), ("result", "success")]; drop( diff --git a/crates/j2k-native/src/j2c/build/selection.rs b/crates/j2k-native/src/j2c/build/selection.rs index a9b507d5..cdeba8ce 100644 --- a/crates/j2k-native/src/j2c/build/selection.rs +++ b/crates/j2k-native/src/j2c/build/selection.rs @@ -286,10 +286,15 @@ pub(crate) fn select_whole_image_precincts( if header.skipped_resolution_levels == 0 { return Ok(None); } + // The build stores a record and two tag-tree roots for every precinct + // beside the selection, in whatever capacity it reuses: a tile for which + // that alone passes the cap fails here, before its precincts are walked. + let counts = count(tile)?; + preflight_structure(&counts, live_bytes, selection_owned_bytes(&counts)?, false)?; let retained_baseline_bytes = live_bytes .checked_add(storage.retained_capacity_bytes()?) .ok_or(ValidationError::ImageTooLarge)?; - whole_image_selection(tile, header, retained_baseline_bytes) + whole_image_selection(tile, header, &counts, retained_baseline_bytes) } /// The selection a region decode of the whole image parses a tile with, for a @@ -308,6 +313,7 @@ pub(crate) fn select_whole_image_precincts( fn whole_image_selection( tile: &Tile<'_>, header: &Header<'_>, + counts: &SelectionCounts, retained_baseline_bytes: usize, ) -> Result> { let whole_image = OutputRegion { @@ -316,12 +322,11 @@ fn whole_image_selection( width: header.size_data.image_width(), height: header.size_data.image_height(), }; - let counts = count(tile)?; let mut budget = DecodeAllocationBudget::from_live_bytes(retained_baseline_bytes)?; let mut skeleton = DecompositionStorage::default(); - build_skeleton(tile, &counts, &mut skeleton, &mut budget)?; + build_skeleton(tile, counts, &mut skeleton, &mut budget)?; RoiPlan::build(tile, header, &skeleton, whole_image, &mut budget)? - .map(|plan| select(tile, &skeleton, &plan, &counts, &mut budget, 0)?.with_every_record()) + .map(|plan| select(tile, &skeleton, &plan, counts, &mut budget, 0)?.with_every_record()) .transpose() } @@ -384,6 +389,19 @@ fn preflight_unavoidable_minimum( counts: &SelectionCounts, retained_baseline_bytes: usize, owned_bytes: usize, +) -> Result<()> { + preflight_structure(counts, retained_baseline_bytes, owned_bytes, true) +} + +/// Reject a tile whose selection (`owned_bytes`) and build structure do not +/// fit the cap beside `retained_baseline_bytes`: the skeleton, every sub-band +/// precinct with two root tag-tree nodes, and, with `roi_windows`, the ROI +/// windows a region build adds. +fn preflight_structure( + counts: &SelectionCounts, + retained_baseline_bytes: usize, + owned_bytes: usize, + roi_windows: bool, ) -> Result<()> { if retained_baseline_bytes > DEFAULT_MAX_DECODE_BYTES { return Err(ValidationError::ImageTooLarge.into()); @@ -401,14 +419,16 @@ fn preflight_unavoidable_minimum( checked_include_elements::(&mut structure, counts.components)?; checked_include_elements::(&mut structure, counts.decompositions)?; checked_include_elements::(&mut structure, counts.sub_bands)?; - checked_include_elements::>( - &mut structure, - counts - .sub_bands - .checked_add(counts.decompositions) - .and_then(|count| count.checked_add(counts.components)) - .ok_or(ValidationError::ImageTooLarge)?, - )?; + if roi_windows { + checked_include_elements::>( + &mut structure, + counts + .sub_bands + .checked_add(counts.decompositions) + .and_then(|count| count.checked_add(counts.components)) + .ok_or(ValidationError::ImageTooLarge)?, + )?; + } checked_include_elements::(&mut structure, counts.sub_band_precincts)?; checked_include_elements::( &mut structure, @@ -864,13 +884,84 @@ mod tests { rejected, ) .is_err()); - let selection = whole_image_selection(tile, &image.header, rejected) + let selection = whole_image_selection(tile, &image.header, &counts, rejected) .expect("the whole-image selection fits beside the retained storage") .expect("a selection"); // Every 4 x 4 precinct of the 120 x 64 tile, one layer. assert_eq!(selection.required_packets(), 30 * 16); } + /// A one-tile gray codestream header covering `[x0, x0 + width) x + /// [y0, y0 + height)` with `precincts.len() - 1` decomposition levels and + /// `precincts` as its COD precinct bytes, followed by an empty tile-part. + fn tile_geometry_codestream( + (x0, y0): (u32, u32), + (width, height): (u32, u32), + precincts: &[u8], + ) -> Vec { + let mut bytes = vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0]; + for value in [x0 + width, y0 + height, x0, y0, width, height, x0, y0] { + bytes.extend_from_slice(&value.to_be_bytes()); + } + bytes.extend_from_slice(&[0, 1, 7, 1, 1]); + let levels = u8::try_from(precincts.len() - 1).unwrap(); + // COD: precincts given; LRCP, one layer; 4 x 4 blocks, 5/3. + let mut cod = vec![1, 0, 0, 1, 0, levels, 0, 0, 0, 1]; + cod.extend_from_slice(precincts); + bytes.extend_from_slice(&[0xff, 0x52]); + bytes.extend_from_slice(&u16::try_from(cod.len() + 2).unwrap().to_be_bytes()); + bytes.extend_from_slice(&cod); + // QCD: reversible, two guard bits. + let sub_bands = 3 * usize::from(levels) + 1; + bytes.extend_from_slice(&[0xff, 0x5c]); + bytes.extend_from_slice(&u16::try_from(sub_bands + 3).unwrap().to_be_bytes()); + bytes.push(0x40); + bytes.extend(core::iter::repeat_n(0x48, sub_bands)); + bytes.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0, 0, 0, 0, 15, 0, 1]); + bytes.extend_from_slice(&[0xff, 0x93, 0, 0xff, 0xd9]); + bytes + } + + fn with_tile_and_header( + codestream: &[u8], + settings: &DecodeSettings, + check: impl FnOnce(&Tile<'_>, &Header<'_>) -> R, + ) -> R { + let image = Image::new(codestream, settings).expect("parse header"); + let tiles = crate::j2c::tile::parse( + &mut crate::reader::BitReader::new(image.codestream), + &image.header, + 0, + ) + .expect("parse tile"); + check(&tiles[0], &image.header) + } + + /// A tile whose build could not hold one record and two tag-tree roots per + /// precinct within the cap is rejected before its whole-image selection + /// is made: 16,383 x 16,383 samples, one decomposition level, one-sample + /// precincts in the LL sub-band and two-sample ones above, 2^28 sub-band + /// precincts, at half resolution, with nothing else live. + #[test] + fn whole_image_selection_rejects_a_tile_its_build_cannot_hold_first() { + let codestream = tile_geometry_codestream((0, 0), (16_383, 16_383), &[0x00, 0x11]); + let half = DecodeSettings { + target_resolution: Some((8_192, 8_192)), + ..DecodeSettings::default() + }; + with_tile_and_header(&codestream, &half, |tile, header| { + assert_eq!(header.skipped_resolution_levels, 1); + assert_eq!(count(tile).unwrap().sub_band_precincts, 4 << 26); + assert!(select_whole_image_precincts( + tile, + header, + &DecompositionStorage::default(), + 0, + ) + .is_err()); + }); + } + #[test] fn dense_region_selection_keeps_the_existing_budget_boundary() { // A scaled-down 7680x4096 LRCP example with 4x4 precincts and blocks. From 6aa5068575fc0a236886065803f2b4889323ae07 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 05:34:40 -0500 Subject: [PATCH 12/30] docs(changelog): note the packets reduced decodes read and the PLT lengths too short to skip by --- CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69825fbd..8742feaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,24 @@ Staged workspace version: `0.13.2`. status, detail and batch source index. CUDA keeps its typed source and job attribution. Unsupported statuses, raw runtime failures and combined cleanup or release failures keep their existing classes. +- A PLT length shorter than any packet, zero or, where EPH markers are + required, below three bytes, for a packet that a region or + reduced-resolution decode skips by its length is a packet error. Skipping + by such a length made the decode read the next packet it needs from the + skipped packet's bytes: in a flat three-component image whose last needed + packet was found that way, reduced decodes wrote other colours and + reported success. Full decodes already rejected these lengths. +- Decodes below full resolution read the packets the reduced output needs on + every route: the packets of the resolutions they keep, the others only + where they must be parsed to find a later needed one and their PLT + lengths cannot skip them, and none after the last needed one, as region + decodes already did. Decodes on the component planes and the direct plans + that GPU decodes and the staged batch run read every packet, so a + codestream damaged only in packets of the discarded resolutions decoded or + failed depending on the route and the pool's size; it now decodes + everywhere, as the undamaged codestream does at that resolution. Damage in + a packet the output needs, or in one parsed to find a later needed one, + remains an error, and full-resolution decodes are unchanged. - A tile-part that holds data after its tile's last packet, bytes or PLT packet lengths past it, decodes on every route. Decodes on the component planes failed with a packet parse error where decodes that stream their From f73007e55be2a9ce5d0fe6dbbbf125fbfcd4ace5 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 03:31:57 -0500 Subject: [PATCH 13/30] fix(j2k-native): read packets of precincts without code-blocks as region decodes do A precinct can hold no code-block, as at a one-sample tile corner whose detail sub-bands are empty. Region decodes, and full-image decodes that stream their rows, leave its packets unread when they come after the last packet the output needs, or skip them by their PLT lengths; full-image decodes on the component planes and direct plans read every packet. A 3 x 3 image in 2 x 2 tiles with EPH markers whose last packet, that of the corner tile's empty precinct, has a damaged EPH marker therefore failed on the planes route and decoded streamed, at full resolution; on Metal it failed on explicit requests and tile batches and decoded on the batch decoder, which plans the image as a region. Planes decodes and direct plans of a tile with such a precinct now parse with the whole-image region selection, as below full resolution; other full-resolution decodes keep parsing every packet without a selection. Only the last precinct of a resolution above 0 can lack code-blocks, as precincts there are at least two samples wide and high and start at even coordinates, so the check reads one precinct per resolution however many the tile declares; a unit test compares it with a check of every precinct over 3600 tile geometries. The facade and Metal tests check every route, pool size and Metal entry point at full and half resolution. --- crates/j2k-native/src/j2c/build/selection.rs | 144 +++++++++++++++++- .../fixtures/empty_coarse_levels/README.md | 10 ++ .../openjpeg_gray_3x3_tile2x2_53_eph.j2k | Bin 0 -> 224 bytes crates/j2k/tests/full_image_streaming.rs | 49 ++++++ 4 files changed, 197 insertions(+), 6 deletions(-) create mode 100644 crates/j2k-test-support/fixtures/empty_coarse_levels/openjpeg_gray_3x3_tile2x2_53_eph.j2k diff --git a/crates/j2k-native/src/j2c/build/selection.rs b/crates/j2k-native/src/j2c/build/selection.rs index cdeba8ce..4da70313 100644 --- a/crates/j2k-native/src/j2c/build/selection.rs +++ b/crates/j2k-native/src/j2c/build/selection.rs @@ -24,7 +24,7 @@ use crate::j2c::progression::{progression_iterator, ProgressionData}; use crate::j2c::rect::IntRect; use crate::j2c::roi::RoiPlan; use crate::j2c::tag_tree::TagNode; -use crate::j2c::tile::Tile; +use crate::j2c::tile::{ResolutionTile, Tile}; use crate::DEFAULT_MAX_DECODE_BYTES; /// How region decode uses one precinct of one component and resolution. @@ -273,17 +273,17 @@ pub(crate) fn select_region_precincts( } /// The selection a decode that builds every precinct of the tile parses it -/// with: below full resolution, the one a region decode of the whole image -/// parses it with, so both read the same packets; otherwise `None`, every -/// packet being read. `live_bytes` are live beside `storage`, whose retained -/// capacity the build reuses. +/// with: below full resolution, or where a precinct holds no code-block, the +/// one a region decode of the whole image parses it with, so both read the +/// same packets; otherwise `None`, every packet being read. `live_bytes` are +/// live beside `storage`, whose retained capacity the build reuses. pub(crate) fn select_whole_image_precincts( tile: &Tile<'_>, header: &Header<'_>, storage: &DecompositionStorage<'_>, live_bytes: usize, ) -> Result> { - if header.skipped_resolution_levels == 0 { + if header.skipped_resolution_levels == 0 && !has_precincts_without_code_blocks(tile) { return Ok(None); } // The build stores a record and two tag-tree roots for every precinct @@ -330,6 +330,57 @@ fn whole_image_selection( .transpose() } +/// Whether a precinct of the tile holds no code-block in any of its +/// sub-bands, as at a one-sample tile corner whose detail sub-bands are empty. +/// A region decode of the whole image leaves such a precinct's packets +/// unread where it can, so a full-resolution decode that builds every +/// precinct parses with its selection only for these tiles. +/// +/// Every precinct of resolution 0 overlaps the LL sub-band. Above it, +/// precincts are at least two samples wide and high and start at even +/// coordinates, so one that misses all three detail sub-bands overlaps its +/// resolution in a single sample at even coordinates, which only the last +/// precinct, at the resolution's bottom-right corner, can: one precinct per +/// resolution is checked, whatever the tile declares. A precinct grid the +/// build rejects counts as holding code-blocks, so the build reports it. +fn has_precincts_without_code_blocks(tile: &Tile<'_>) -> bool { + tile.component_tiles().any(|component_tile| { + component_tile.resolution_tiles().any(|resolution_tile| { + resolution_tile.resolution > 0 + && resolution_tile + .num_precincts() + .checked_sub(1) + .and_then(|last| usize::try_from(last).ok()) + .is_some_and(|last| { + precinct_holds_code_blocks(&resolution_tile, last) == Some(false) + }) + }) + }) +} + +/// Whether precinct `index` of `resolution_tile` holds a code-block of one of +/// its sub-bands, or `None` where its grid is invalid. +fn precinct_holds_code_blocks(resolution_tile: &ResolutionTile<'_>, index: usize) -> Option { + let precinct_rect = resolution_tile.precinct_grid()?.rect_at(index)?; + let sub_band_types: &[SubBandType] = if resolution_tile.resolution == 0 { + &[SubBandType::LowLow] + } else { + &[ + SubBandType::HighLow, + SubBandType::LowHigh, + SubBandType::HighHigh, + ] + }; + for &sub_band_type in sub_band_types { + let rect = resolution_tile.sub_band_rect(sub_band_type); + let grid = code_block_grid(resolution_tile, rect, precinct_rect).ok()?; + if grid.columns > 0 && grid.rows > 0 && rect.intersects(grid.area.intersect(rect)) { + return Some(true); + } + } + Some(false) +} + fn count(tile: &Tile<'_>) -> Result { let mut counts = SelectionCounts { components: 0, @@ -922,6 +973,12 @@ mod tests { bytes } + fn with_tile(codestream: &[u8], check: impl FnOnce(&Tile<'_>) -> R) -> R { + with_tile_and_header(codestream, &DecodeSettings::default(), |tile, _| { + check(tile) + }) + } + fn with_tile_and_header( codestream: &[u8], settings: &DecodeSettings, @@ -937,6 +994,81 @@ mod tests { check(&tiles[0], &image.header) } + /// Every precinct of every resolution of `tile`, checked one by one. + fn every_precinct_holds_code_blocks(tile: &Tile<'_>) -> bool { + tile.component_tiles().all(|component_tile| { + component_tile.resolution_tiles().all(|resolution_tile| { + let count = usize::try_from(resolution_tile.num_precincts()).unwrap(); + (0..count).all(|index| { + precinct_holds_code_blocks(&resolution_tile, index).expect("valid precinct") + }) + }) + }) + } + + /// Checking the last precinct of each resolution above 0 finds a precinct + /// without code-blocks exactly where checking every precinct does, over + /// tile origins of either parity, small sizes, one to three decomposition + /// levels and precincts from the smallest to the largest. + #[test] + fn last_precincts_find_every_precinct_without_code_blocks() { + let (mut with_empty, mut without_empty) = (0, 0); + for origin in [(0, 0), (1, 0), (2, 1), (3, 3)] { + for width in 1..=10 { + for height in 1..=10 { + for levels in 1..=3 { + for (coarsest, finer) in [(0x00, 0x11), (0x11, 0x22), (0x00, 0xff)] { + let mut precincts = vec![finer; levels + 1]; + precincts[0] = coarsest; + let codestream = + tile_geometry_codestream(origin, (width, height), &precincts); + let (fast, every) = with_tile(&codestream, |tile| { + ( + has_precincts_without_code_blocks(tile), + !every_precinct_holds_code_blocks(tile), + ) + }); + assert_eq!( + fast, every, + "origin {origin:?}, {width} x {height}, precincts {precincts:x?}" + ); + if every { + with_empty += 1; + } else { + without_empty += 1; + } + } + } + } + } + } + assert!( + with_empty > 0 && without_empty > 0, + "{with_empty} / {without_empty}" + ); + } + + /// The check reads one precinct per resolution: a tile that declares + /// 2^30 precincts in each of its two resolutions, one-sample precincts in + /// the LL sub-band and an empty last one above it, is answered at once. + #[test] + fn precincts_without_code_blocks_are_found_without_walking_every_precinct() { + let codestream = tile_geometry_codestream((0, 0), (65_535, 65_535), &[0x00, 0x11]); + with_tile(&codestream, |tile| { + let precinct_counts: Vec<_> = tile + .component_tiles() + .flat_map(|component_tile| { + component_tile + .resolution_tiles() + .map(|resolution_tile| resolution_tile.num_precincts()) + .collect::>() + }) + .collect(); + assert_eq!(precinct_counts, [1 << 30, 1 << 30]); + assert!(has_precincts_without_code_blocks(tile)); + }); + } + /// A tile whose build could not hold one record and two tag-tree roots per /// precinct within the cap is rejected before its whole-image selection /// is made: 16,383 x 16,383 samples, one decomposition level, one-sample diff --git a/crates/j2k-test-support/fixtures/empty_coarse_levels/README.md b/crates/j2k-test-support/fixtures/empty_coarse_levels/README.md index 7d6f564e..914d82d6 100644 --- a/crates/j2k-test-support/fixtures/empty_coarse_levels/README.md +++ b/crates/j2k-test-support/fixtures/empty_coarse_levels/README.md @@ -21,10 +21,20 @@ opj_compress -i gray8x6.pgm -o openjpeg_gray_8x6_tile8x5_53.j2k -n 3 -t 8,5 opj_compress -i rgb6x6.ppm -o openjpeg_rgb_6x6_tile5x5_97.j2k -n 3 -t 5,5 -I ``` +A third image, 3 x 3 gray in 2 x 2 tiles with one decomposition level and +EPH markers, has a corner tile of one sample whose detail sub-bands are +empty: its full-resolution precinct holds no code-block, and its packet is +the tile's last. From a 3 x 3 PGM of the same samples: + +```sh +opj_compress -i gray3x3.pgm -o openjpeg_gray_3x3_tile2x2_53_eph.j2k -n 2 -t 2,2 -EPH +``` + | File | Contents | SHA-256 | | --- | --- | --- | | `openjpeg_gray_8x6_tile8x5_53.j2k` | Gray, 8 x 5 tiles, reversible 5/3; decodes to the samples exactly. | `09b737d7300ea4ddfc81a29740083105594e77c9029a2635c77bd05c98fb4d85` | | `openjpeg_rgb_6x6_tile5x5_97.j2k` | RGB, 5 x 5 tiles, irreversible 9/7 with the colour transform. | `ea6d998d5d77cc63f86dbda5f9924177848eac1ea25c17cb5ea162ba687561e7` | +| `openjpeg_gray_3x3_tile2x2_53_eph.j2k` | Gray, 2 x 2 tiles, reversible 5/3, EPH markers; decodes to the samples exactly. | `7427e785a23347fda3c2da701a91fdd07fd37feea2b5401a1d1bbc3fedab1a66` | The samples are generated for j2k and contain no externally sourced image content. OpenJPEG is an independent fixture encoder, not a production codec diff --git a/crates/j2k-test-support/fixtures/empty_coarse_levels/openjpeg_gray_3x3_tile2x2_53_eph.j2k b/crates/j2k-test-support/fixtures/empty_coarse_levels/openjpeg_gray_3x3_tile2x2_53_eph.j2k new file mode 100644 index 0000000000000000000000000000000000000000..cd69991a18c7d9314d93bee0837dd7663c30205d GIT binary patch literal 224 zcmezG|38pHlK}*np%}~o5=>AG;WM%`GX4)@;9+54U}RuqVPRnWAH%@z;NamA@IQq? zm4VT@C^fMpHANw*Qo+9Hj3C9LPzsAi3jLSpHAqEC=caYXxfr QxnBY<$@aeC|D^vn0l2 Date: Sun, 1 Nov 2026 03:31:57 -0500 Subject: [PATCH 14/30] docs(changelog): note the packets of precincts without code-blocks --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8742feaf..ee6facaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,7 +32,9 @@ Staged workspace version: `0.13.2`. failed depending on the route and the pool's size; it now decodes everywhere, as the undamaged codestream does at that resolution. Damage in a packet the output needs, or in one parsed to find a later needed one, - remains an error, and full-resolution decodes are unchanged. + remains an error. Full-resolution decodes are unchanged, except that the + packets of precincts holding no code-block, as at a one-sample tile + corner, are read on every route as region decodes read them. - A tile-part that holds data after its tile's last packet, bytes or PLT packet lengths past it, decodes on every route. Decodes on the component planes failed with a packet parse error where decodes that stream their From ba146f0492312f4a7adc45c1d7333230a93bb518 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 05:34:40 -0500 Subject: [PATCH 15/30] test(j2k): check packed packet headers and interleaved tile-parts on every route With packet headers in PPM or PPT, PLT lengths cover packet bodies only, so reduced decodes parse every header up to their last needed packet. In a flat image whose later headers are one empty byte each, damage to a finest packet's header before a needed packet fails every route and pool size at half resolution, and damage to the last header decodes there as the undamaged codestream; both fail at full resolution. Tile-parts of several tiles taking turns, one RPCL packet each, with twelve 64 x 64 tiles, which stream as one region, and eight 512 x 512 ones, which stream tile by tile at once at half resolution on four threads (a route test checks that choice): the interleaved codestream decodes as the tile-major one; damage to every tile's finest packet decodes at half resolution as the undamaged codestream, and damage to every coarsest packet fails; both fail at full resolution. The test support gains the interleaving and a lookup of the packed headers. --- crates/j2k-test-support/src/lib.rs | 5 +- crates/j2k-test-support/src/packet_edits.rs | 122 +++++++++++++- crates/j2k/src/decode/route/tests.rs | 37 ++++ crates/j2k/tests/full_image_streaming.rs | 177 ++++++++++++++++++++ 4 files changed, 337 insertions(+), 4 deletions(-) diff --git a/crates/j2k-test-support/src/lib.rs b/crates/j2k-test-support/src/lib.rs index db34f4df..9ecebc36 100644 --- a/crates/j2k-test-support/src/lib.rs +++ b/crates/j2k-test-support/src/lib.rs @@ -81,8 +81,9 @@ pub use manifest::{ }; pub use metal::{metal_device_unavailable_is_skip, metal_runtime_gate, metal_runtime_required}; pub use packet_edits::{ - add_packet_length, edit_tile_parts, packet_ranges, with_byte_after_last_packets, - with_packet_lengths_edited, with_packets_filled, with_surplus_packet_lengths, + add_packet_length, edit_tile_parts, packed_packet_headers, packet_ranges, + with_byte_after_last_packets, with_packet_lengths_edited, with_packets_filled, + with_surplus_packet_lengths, with_tile_parts_interleaved, }; pub use pixels::{ crop_interleaved_bytes, crop_interleaved_u16, project_scaled_interleaved_u16, diff --git a/crates/j2k-test-support/src/packet_edits.rs b/crates/j2k-test-support/src/packet_edits.rs index 8f31335b..b5275a86 100644 --- a/crates/j2k-test-support/src/packet_edits.rs +++ b/crates/j2k-test-support/src/packet_edits.rs @@ -2,14 +2,17 @@ //! Byte-level edits of JPEG 2000 codestreams around their packets, for tests //! of what a decode reads: data after a tile's last packet, PLT lengths past -//! it, and damaged packets. Codestreams are raw (`SOC` first), each tile-part -//! a `SOT` marker segment with a nonzero `Psot`, and `EOC` last. +//! it, damaged packets and packed packet headers, and tile-parts of several +//! tiles interleaved. Codestreams are raw (`SOC` first), each tile-part a +//! `SOT` marker segment with a nonzero `Psot`, and `EOC` last. use std::ops::Range; const SOT: [u8; 2] = [0xff, 0x90]; const SOD: [u8; 2] = [0xff, 0x93]; const PLT: [u8; 2] = [0xff, 0x58]; +const PPM: [u8; 2] = [0xff, 0x60]; +const PPT: [u8; 2] = [0xff, 0x61]; const EOC: [u8; 2] = [0xff, 0xd9]; fn marker_segment_end(bytes: &[u8], at: usize) -> usize { @@ -205,6 +208,70 @@ pub fn with_packet_lengths_edited( }) } +/// `codestream` with its tile-parts reordered so that tiles take turns: every +/// tile's first tile-part in tile order, then every second one, and so on. +/// Each tile's tile-parts keep their order, as the standard requires. +/// +/// # Panics +/// +/// When `codestream` is not as the module describes. +#[must_use] +pub fn with_tile_parts_interleaved(codestream: &[u8]) -> Vec { + let parts = tile_parts(codestream); + let mut tiles: Vec<(u16, Vec>)> = Vec::new(); + for part in parts.iter().cloned() { + let tile = u16::from_be_bytes([codestream[part.start + 4], codestream[part.start + 5]]); + match tiles.iter_mut().find(|(index, _)| *index == tile) { + Some((_, tile_parts)) => tile_parts.push(part), + None => tiles.push((tile, vec![part])), + } + } + let mut interleaved = codestream[..parts[0].start].to_vec(); + let rounds = tiles + .iter() + .map(|(_, parts)| parts.len()) + .max() + .unwrap_or(0); + for round in 0..rounds { + for (_, tile_parts) in &tiles { + if let Some(part) = tile_parts.get(round) { + interleaved.extend_from_slice(&codestream[part.clone()]); + } + } + } + interleaved.extend_from_slice(&EOC); + interleaved +} + +/// The bytes of the packed packet headers of a codestream of one tile-part +/// with one PPM segment in its main header or one PPT segment in its +/// tile-part header: every packet's header, in progression order. +/// +/// # Panics +/// +/// When `codestream` has no such single segment. +#[must_use] +pub fn packed_packet_headers(codestream: &[u8]) -> Range { + let first_part = first_tile_part(codestream); + let mut at = 2; + while at < first_part { + if codestream[at..at + 2] == PPM { + // Zppm, then the first tile-part's Nppm. + let end = marker_segment_end(codestream, at); + return at + 9..end; + } + at = marker_segment_end(codestream, at); + } + let mut at = first_part + 12; + while codestream[at..at + 2] != SOD { + if codestream[at..at + 2] == PPT { + return at + 5..marker_segment_end(codestream, at); + } + at = marker_segment_end(codestream, at); + } + panic!("no packed packet headers"); +} + #[cfg(test)] mod tests { use super::*; @@ -256,4 +323,55 @@ mod tests { assert_eq!(moved[ranges[0].clone()], [1, 2, 3]); assert_eq!(moved[ranges[1].clone()], [4, 5]); } + + /// A tile-part of `tile` whose header holds `header` and whose data is + /// `data`. + fn tile_part(tile: u16, header: &[u8], data: &[u8]) -> Vec { + let psot = u32::try_from(12 + header.len() + 2 + data.len()).expect("small"); + let mut part = vec![0xff, 0x90, 0, 10]; + part.extend_from_slice(&tile.to_be_bytes()); + part.extend_from_slice(&psot.to_be_bytes()); + part.extend_from_slice(&[0, 0]); + part.extend_from_slice(header); + part.extend_from_slice(&SOD); + part.extend_from_slice(data); + part + } + + #[test] + fn interleaving_lets_tiles_take_turns() { + let main = [0xff, 0x4f, 0xff, 0x51, 0, 3, 0]; + let parts = [ + tile_part(0, &[], &[1]), + tile_part(0, &[], &[2]), + tile_part(1, &[], &[3]), + ]; + let mut bytes = main.to_vec(); + for part in &parts { + bytes.extend_from_slice(part); + } + bytes.extend_from_slice(&EOC); + let mut expected = main.to_vec(); + for part in [&parts[0], &parts[2], &parts[1]] { + expected.extend_from_slice(part); + } + expected.extend_from_slice(&EOC); + assert_eq!(with_tile_parts_interleaved(&bytes), expected); + } + + #[test] + fn packed_headers_are_found_in_ppt_and_ppm() { + let ppt = [0xff, 0x61, 0, 6, 0, 7, 8, 9]; + let mut bytes = vec![0xff, 0x4f, 0xff, 0x51, 0, 3, 0]; + bytes.extend_from_slice(&tile_part(0, &ppt, &[1])); + bytes.extend_from_slice(&EOC); + assert_eq!(bytes[packed_packet_headers(&bytes)], [7, 8, 9]); + // Zppm, then Nppm = 3 for the one tile-part. + let ppm = [0xff, 0x60, 0, 10, 0, 0, 0, 0, 3, 7, 8, 9]; + let mut bytes = vec![0xff, 0x4f, 0xff, 0x51, 0, 3, 0]; + bytes.extend_from_slice(&ppm); + bytes.extend_from_slice(&tile_part(0, &[], &[1])); + bytes.extend_from_slice(&EOC); + assert_eq!(bytes[packed_packet_headers(&bytes)], [7, 8, 9]); + } } diff --git a/crates/j2k/src/decode/route/tests.rs b/crates/j2k/src/decode/route/tests.rs index 1e917c0b..4297369d 100644 --- a/crates/j2k/src/decode/route/tests.rs +++ b/crates/j2k/src/decode/route/tests.rs @@ -313,6 +313,43 @@ fn tiled_images_stream_tiles_at_once_on_the_pool() { assert_eq!(auto, FullImageRoute::StreamedRegion); } +/// Eight 512 x 512 tiles whose tile-parts take turns in the codestream, one +/// RPCL packet each, stream tile by tile at once at half resolution on four +/// threads: the layout the forced-route tests check that route with. +#[test] +fn interleaved_tile_parts_stream_tiles_at_once() { + let options = EncodeOptions { + num_decomposition_levels: 2, + progression_order: j2k_native::EncodeProgressionOrder::Rpcl, + tile_size: Some((512, 512)), + tile_part_packet_limit: Some(1), + write_plt: true, + ..EncodeOptions::default() + }; + let bytes = j2k_test_support::with_tile_parts_interleaved( + &encode(&vec![77; 2048 * 1024], 2048, 1024, 1, 8, false, &options).expect("encode fixture"), + ); + let image = image_at_scale(&bytes, DecodeSettings::default(), Downscale::Half).expect("image"); + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(4) + .build() + .expect("pool"); + let route = pool.install(|| { + full_image_route( + &image, + RouteRequest { + components: 1, + scale: Downscale::Half, + ..request(PixelFormat::Gray8, 0, CpuDecodeParallelism::Auto) + }, + ) + }); + assert!( + matches!(route, FullImageRoute::StreamedTiles { ref tiles, .. } if tiles.len() == 8), + "{route:?}" + ); +} + /// Tiles at once take the calling thread and every pool thread, and need a /// tile for each that keeps them busy, of a size worth its own decode. #[test] diff --git a/crates/j2k/tests/full_image_streaming.rs b/crates/j2k/tests/full_image_streaming.rs index 8e278de3..564e3d6e 100644 --- a/crates/j2k/tests/full_image_streaming.rs +++ b/crates/j2k/tests/full_image_streaming.rs @@ -1080,6 +1080,183 @@ fn plt_lengths_shorter_than_an_eph_packet_fail_on_every_route() { } } +/// Every forced route and pool size gives `expected` for `bytes` at `scale`: +/// the bytes it holds, or one failure for all. +fn assert_same_on_every_route( + bytes: &[u8], + fmt: PixelFormat, + scale: Downscale, + expected: Option<&[u8]>, + case: &str, +) { + let mut failure = OneFailure::default(); + for threads in [1, 4] { + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .expect("pool"); + for budget in [Some(usize::MAX), Some(0), None] { + let decoded = + pool.install(|| decode(bytes, budget, CpuDecodeParallelism::Auto, fmt, scale, 0)); + let label = format!("{case}, {scale:?}, {threads} threads, planes budget {budget:?}"); + match expected { + Some(expected) => assert!(decoded.as_deref() == Ok(expected), "{label}"), + None => failure.check(&decoded, &label), + } + } + } +} + +/// With packet headers packed in PPM or PPT, PLT lengths hold only packet +/// bodies, so a reduced decode parses every header up to the last packet it +/// needs: in a flat image, whose packets after the first hold one-byte empty +/// headers, damage to the header of a finest packet that precedes a needed +/// one fails every route at half resolution; damage to the last packet's +/// header, after every packet a half-resolution decode needs, decodes as the +/// undamaged codestream there; both fail at full resolution. +#[test] +fn damaged_packed_headers_fail_reduced_decodes_that_must_parse_them() { + let flat = vec![90; (WIDTH * HEIGHT) as usize]; + for (label, write_ppm, write_ppt) in [("PPM", true, false), ("PPT", false, true)] { + let options = EncodeOptions { + num_decomposition_levels: 2, + num_layers: 2, + // Every coding pass in the first layer. + quality_layer_byte_targets: vec![1, u64::MAX], + progression_order: EncodeProgressionOrder::Lrcp, + write_plt: true, + write_ppm, + write_ppt, + ..EncodeOptions::default() + }; + let clean = encode(&flat, WIDTH, HEIGHT, 1, 8, false, &options).expect("encode fixture"); + // LRCP, one component, one precinct per resolution: the first layer's + // coarsest header holds the image, then five empty ones, the first + // layer's two finer packets' and the second layer's three. + let headers = j2k_test_support::packed_packet_headers(&clean); + assert_eq!( + clean[headers.end - 5..headers.end], + [0; 5], + "{label}: empty headers" + ); + let damaged_at = |from_end: usize| { + let mut damaged = clean.clone(); + damaged[headers.end - from_end] = 0xff; + damaged + }; + let fmt = PixelFormat::Gray8; + let expected = decode( + &clean, + None, + CpuDecodeParallelism::Serial, + fmt, + Downscale::Half, + 0, + ) + .expect("clean half-resolution decode"); + // The first layer's finest header, before the second layer's packets. + let navigation = damaged_at(4); + assert_same_on_every_route(&navigation, fmt, Downscale::Half, None, label); + assert_same_on_every_route(&navigation, fmt, Downscale::None, None, label); + // The second layer's finest header, the last. + let last = damaged_at(1); + assert_same_on_every_route(&last, fmt, Downscale::Half, Some(&expected), label); + assert_same_on_every_route(&last, fmt, Downscale::None, None, label); + } +} + +/// Tile-parts of several tiles may take turns in the codestream. Completion +/// is per tile, across its tile-parts: with one RPCL packet per tile-part, +/// damage to every tile's finest packet, in the last round of tile-parts, +/// decodes at half resolution as the undamaged interleaved codestream does, +/// and damage to every tile's coarsest packet, in the first round, fails; +/// both fail at full resolution. Twelve 64 x 64 tiles stream as one region; +/// eight 512 x 512 ones, at half resolution on four threads, tile by tile at +/// once. +#[test] +fn interleaved_tile_parts_spare_reduced_decodes_per_tile() { + let options = |tile: u32| EncodeOptions { + num_decomposition_levels: 2, + progression_order: EncodeProgressionOrder::Rpcl, + tile_size: Some((tile, tile)), + tile_part_packet_limit: Some(1), + write_plt: true, + ..EncodeOptions::default() + }; + let small_tiles = codestream(1, &options(64)); + let large_tiles = { + let (width, height) = (2048, 1024); + let mut state = 0x9e37_79b9_u32; + let pixels: Vec = (0..width * height) + .map(|_| { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state.to_be_bytes()[0] + }) + .collect(); + encode(&pixels, width, height, 1, 8, false, &options(512)).expect("encode fixture") + }; + for (label, clean, tiles) in [ + ("64 x 64 tiles", small_tiles, 12), + ("512 x 512 tiles", large_tiles, 8), + ] { + let ranges = j2k_test_support::packet_ranges(&clean); + assert_eq!(ranges.len(), tiles * 3, "{label}: three packets a tile"); + let resolution = |level: usize| -> Vec> { + ranges.iter().skip(level).step_by(3).cloned().collect() + }; + let fmt = PixelFormat::Gray8; + let interleaved = j2k_test_support::with_tile_parts_interleaved(&clean); + assert_ne!(interleaved, clean, "{label}: tile-parts change places"); + let expected = decode( + &interleaved, + None, + CpuDecodeParallelism::Serial, + fmt, + Downscale::Half, + 0, + ) + .expect("clean half-resolution decode"); + assert_eq!( + decode( + &clean, + None, + CpuDecodeParallelism::Serial, + fmt, + Downscale::Half, + 0 + ), + Ok(expected.clone()), + "{label}: interleaving keeps the image" + ); + let case = |damage: &str| format!("{label}, {damage}"); + assert_same_on_every_route( + &interleaved, + fmt, + Downscale::Half, + Some(&expected), + &case("clean"), + ); + let finest = j2k_test_support::with_tile_parts_interleaved( + &j2k_test_support::with_packets_filled(&clean, &resolution(2), 0xff), + ); + assert_same_on_every_route( + &finest, + fmt, + Downscale::Half, + Some(&expected), + &case("finest"), + ); + assert_same_on_every_route(&finest, fmt, Downscale::None, None, &case("finest")); + let coarsest = j2k_test_support::with_tile_parts_interleaved( + &j2k_test_support::with_packets_filled(&clean, &resolution(0), 0xff), + ); + assert_same_on_every_route(&coarsest, fmt, Downscale::Half, None, &case("coarsest")); + assert_same_on_every_route(&coarsest, fmt, Downscale::None, None, &case("coarsest")); + } +} + /// A tile-part cut inside its tile's last packet, a finest-resolution one, /// fails at full resolution and decodes at half resolution as the uncut /// codestream does, on every route, wherever the cut falls in that packet From a0787112b000a4321617fceaac49de7105992659 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 06:59:41 -0500 Subject: [PATCH 16/30] fix(j2k-native): reject a multiple component transform in grayscale plans A multiple component transform needs three components. Decodes of a one-component codestream whose COD asks for one fail with a colour error in strict mode, but the grayscale direct plan and the referenced grayscale plans (classic and HTJ2K), which GPU decodes and decode batches execute, never looked at the flag and decoded such a codestream: a 39 x 7 one-bit gray image in five tiles with the flag set failed every decode of `J2kDecoder` and decoded in the prepared batch on the CPU and on CUDA. The plans now fail with the same error where a tile asks for the transform and the header is strict, once every tile they cover is planned, as decodes fail after parsing every tile's packets: a failure a decode meets first, such as a precinct grid past the decode cap, keeps its class. A native test checks the single-tile direct plan and the referenced plan over four tiles against the decode's error class, and that precedence on a 16,383 x 16,383 tile at half resolution. --- .../j2k-native/src/j2c/decode/direct_plan.rs | 17 +++- .../direct_plan/referenced_grayscale.rs | 12 +-- crates/j2k-native/src/tests.rs | 89 +++++++++++++++++++ 3 files changed, 111 insertions(+), 7 deletions(-) diff --git a/crates/j2k-native/src/j2c/decode/direct_plan.rs b/crates/j2k-native/src/j2c/decode/direct_plan.rs index bbaa5f2b..7c63ebc9 100644 --- a/crates/j2k-native/src/j2c/decode/direct_plan.rs +++ b/crates/j2k-native/src/j2c/decode/direct_plan.rs @@ -168,7 +168,7 @@ fn build_direct_grayscale_plan_inner<'a>( let mut next_band_id = 0; let output_region = ctx.tile_decode_context.output_region; - build_direct_grayscale_tile_plan( + let plan = build_direct_grayscale_tile_plan( data, payload_range_owner, &tiles[0], @@ -180,7 +180,9 @@ fn build_direct_grayscale_plan_inner<'a>( None, ht_payloads, classic_payloads, - ) + )?; + reject_grayscale_mct(&tiles, header)?; + Ok(plan) } #[expect( @@ -277,6 +279,17 @@ fn validate_grayscale_tile(tile: &Tile<'_>) -> Result<()> { validate_unit_sampled_component(&tile.component_infos[0]) } +/// A multiple component transform needs three components: a decode of a +/// one-component image one of whose tiles asks for one fails, as +/// `mct::apply_inverse` does, after every tile's packets are parsed, and so +/// does its plan, once every tile it covers is planned. +fn reject_grayscale_mct(tiles: &[Tile<'_>], header: &Header<'_>) -> Result<()> { + if header.strict && tiles.iter().any(|tile| tile.mct) { + bail!(ColorError::Mct); + } + Ok(()) +} + fn validate_color_tile( tile: &Tile<'_>, component_count_error: DirectPlanUnsupportedReason, diff --git a/crates/j2k-native/src/j2c/decode/direct_plan/referenced_grayscale.rs b/crates/j2k-native/src/j2c/decode/direct_plan/referenced_grayscale.rs index 5c3507ef..48862e88 100644 --- a/crates/j2k-native/src/j2c/decode/direct_plan/referenced_grayscale.rs +++ b/crates/j2k-native/src/j2c/decode/direct_plan/referenced_grayscale.rs @@ -5,11 +5,11 @@ use super::{ append_classic_payload_records, append_retained_records, bail, build_direct_grayscale_tile_plan, grayscale_plan_rects, output_region_rect, - payload_record_span, referenced_output_region, strip_classic_payload_owners, tile, - tile_intersects_output, validate_grayscale_tile, validate_ht_payload_records, BitReader, - ClassicPayloadCollector, DecodeAllocationBudget, DecoderContext, DecodingError, Header, - J2kReferencedClassicPlan, J2kReferencedHtj2kPlan, J2kReferencedTileGeometry, - J2kReferencedTilePlan, Result, ValidationError, Vec, + payload_record_span, referenced_output_region, reject_grayscale_mct, + strip_classic_payload_owners, tile, tile_intersects_output, validate_grayscale_tile, + validate_ht_payload_records, BitReader, ClassicPayloadCollector, DecodeAllocationBudget, + DecoderContext, DecodingError, Header, J2kReferencedClassicPlan, J2kReferencedHtj2kPlan, + J2kReferencedTileGeometry, J2kReferencedTilePlan, Result, ValidationError, Vec, }; pub(crate) fn build_referenced_htj2k_grayscale_plan<'a>( @@ -84,6 +84,7 @@ pub(crate) fn build_referenced_htj2k_grayscale_plan<'a>( J2kReferencedTileGeometry::Grayscale(geometry), )); } + reject_grayscale_mct(&parsed_tiles, header)?; Ok(J2kReferencedHtj2kPlan::grayscale( tile_plans, @@ -170,6 +171,7 @@ pub(crate) fn build_referenced_classic_grayscale_plan<'a>( J2kReferencedTileGeometry::Grayscale(geometry), )); } + reject_grayscale_mct(&parsed_tiles, header)?; Ok(J2kReferencedClassicPlan::grayscale( tile_plans, diff --git a/crates/j2k-native/src/tests.rs b/crates/j2k-native/src/tests.rs index 366c053f..500becd4 100644 --- a/crates/j2k-native/src/tests.rs +++ b/crates/j2k-native/src/tests.rs @@ -2839,6 +2839,95 @@ fn reduced_planes_decode_parses_the_packets_the_region_decode_parses() { } } +/// A multiple component transform needs three components, so a decode of a +/// one-component codestream whose COD asks for one fails; the grayscale +/// plans GPU decodes and batches execute, single-tile and referenced over +/// several tiles, fail with it once their tiles are planned, so a failure a +/// decode meets first, such as a precinct grid past the cap, still comes +/// first. +#[test] +fn grayscale_plans_reject_a_multiple_component_transform_as_decodes_do() { + // One 16,383 x 16,383 tile, one level, one-sample LL precincts: 2^28 + // sub-band precinct records, past the cap at half resolution. + let mut grid = vec![0xff, 0x4f, 0xff, 0x51, 0, 41, 0, 0]; + for value in [16_383_u32, 16_383, 0, 0, 16_383, 16_383, 0, 0] { + grid.extend_from_slice(&value.to_be_bytes()); + } + grid.extend_from_slice(&[0, 1, 7, 1, 1]); + grid.extend_from_slice(&[0xff, 0x52, 0, 14, 1, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0x00, 0x11]); + grid.extend_from_slice(&[0xff, 0x5c, 0, 7, 0x40, 0x48, 0x48, 0x48, 0x48]); + grid.extend_from_slice(&[0xff, 0x90, 0, 10, 0, 0, 0, 0, 0, 15, 0, 1, 0xff, 0x93, 0]); + grid.extend_from_slice(&[0xff, 0xd9]); + let half = DecodeSettings { + target_resolution: Some((8_192, 8_192)), + ..DecodeSettings::default() + }; + let image = Image::new(&grid, &half).expect("grid image"); + let mut output = vec![0_u8; 8_192 * 8_192]; + let decoded = image + .decode_into(&mut output, &mut DecoderContext::default()) + .expect_err("grid decode"); + let Err(planned) = + image.build_direct_grayscale_plan_with_context(&mut DecoderContext::default()) + else { + panic!("grid: the direct plan built"); + }; + assert!(matches!( + decoded.classify(), + DecodeErrorClass::ResourceLimit { .. } + )); + assert_eq!(planned.classify(), decoded.classify(), "grid: direct plan"); + + let pixels = gradient_pixels(64, 64, 1); + for tile_size in [None, Some((32, 32))] { + let options = EncodeOptions { + num_decomposition_levels: 2, + tile_size, + ..EncodeOptions::default() + }; + let mut bytes = encode(&pixels, 64, 64, 1, 8, false, &options).expect("encode"); + let cod = bytes + .windows(2) + .position(|window| window == [0xff, 0x52]) + .expect("COD marker"); + // Lcod, Scod, progression order, layers, then the MCT byte. + assert_eq!(bytes[cod + 8], 0, "no transform"); + bytes[cod + 8] = 1; + let image = Image::new(&bytes, &DecodeSettings::default()).expect("image"); + let Err(decoded) = image.decode_native() else { + panic!("tiles {tile_size:?}: decoded"); + }; + let label = format!("tiles {tile_size:?}"); + if tile_size.is_none() { + let Err(planned) = + image.build_direct_grayscale_plan_with_context(&mut DecoderContext::default()) + else { + panic!("{label}: the direct plan built"); + }; + assert_eq!( + planned.classify(), + decoded.classify(), + "{label}: direct plan" + ); + } + let Err(referenced) = image.build_referenced_classic_plan_region_with_context( + &mut DecoderContext::default(), + (0, 0, 64, 64), + ) else { + panic!("{label}: the referenced plan built"); + }; + assert_eq!( + referenced.classify(), + decoded.classify(), + "{label}: referenced plan" + ); + assert!( + matches!(referenced, DecodeError::Color(ColorError::Mct)), + "{label}: {referenced:?}" + ); + } +} + /// Damage to a packet a reduced decode needs fails it as it fails the full /// decode, on the planes, the region decode and the direct plan. #[test] From 26cf5dd00ad39578f32e59db442ce508a09699e5 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 06:59:41 -0500 Subject: [PATCH 17/30] docs(changelog): note the multiple component transform in grayscale plans --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee6facaa..9a6d0b47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ Staged workspace version: `0.13.2`. status, detail and batch source index. CUDA keeps its typed source and job attribution. Unsupported statuses, raw runtime failures and combined cleanup or release failures keep their existing classes. +- Grayscale direct and referenced plans, which GPU decodes and decode + batches execute, reject a one-component codestream whose COD asks for a + multiple component transform, as decodes of it already did. A 39 x 7 + one-bit gray image in five tiles with that flag failed every decode and + decoded in decode batches on the CPU and on CUDA. - A PLT length shorter than any packet, zero or, where EPH markers are required, below three bytes, for a packet that a region or reduced-resolution decode skips by its length is a packet error. Skipping From 64fca8a48997ccef50fdb085f49f0717f5b421a3 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 03:11:26 -0500 Subject: [PATCH 18/30] test(j2k-metal): check Metal decodes read packets as the CPU decodes do Metal full and reduced-resolution decodes build their direct plans, or fall back to decoding the component planes, with the packet walk the CPU decodes use. Device tests now hold every Metal entry point that consumes it (explicit requests with and without a session, the tile batch and the batch decoder) to the CPU decode on six fixtures: gray and RGB, classic and HTJ2K, one tile and four, at full and half resolution. - Bytes or PLT lengths after a tile's last packet decode to the CPU output of the codestream without them. - A last packet shorter than its PLT length, a PLT length left while the tile continues in a later tile-part, and a damaged first packet fail on Metal with the native error class the CPU decode reports. - At half resolution, damage confined to finest-level packets after the last needed one (resolution-major, with or without PLT lengths) or skipped by PLT length (layer-major) decodes to the clean image; damage in a needed packet, or in one parsed to reach a needed packet, and any of it at full resolution, fails on both with one error class. --- crates/j2k-metal/tests/device.rs | 2 + .../tests/device/packet_walk_parity.rs | 658 ++++++++++++++++++ 2 files changed, 660 insertions(+) create mode 100644 crates/j2k-metal/tests/device/packet_walk_parity.rs diff --git a/crates/j2k-metal/tests/device.rs b/crates/j2k-metal/tests/device.rs index 23941398..2fd17d08 100644 --- a/crates/j2k-metal/tests/device.rs +++ b/crates/j2k-metal/tests/device.rs @@ -507,6 +507,8 @@ mod irreversible_output_rounding; mod legacy_batch; #[path = "device/multitile_color.rs"] mod multitile_color; +#[path = "device/packet_walk_parity.rs"] +mod packet_walk_parity; #[path = "device/resident_batch.rs"] mod resident_batch; #[path = "device/reversible_early_stop.rs"] diff --git a/crates/j2k-metal/tests/device/packet_walk_parity.rs b/crates/j2k-metal/tests/device/packet_walk_parity.rs new file mode 100644 index 00000000..d0241d89 --- /dev/null +++ b/crates/j2k-metal/tests/device/packet_walk_parity.rs @@ -0,0 +1,658 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! The Metal decodes read packets with the walk the CPU decodes use: data a +//! decode does not need is left unread on both, and packet data both must +//! read and reject fails on both with the same error class. + +use std::ops::Range; + +use super::*; +use j2k_native::{DecodeErrorClass, EncodeProgressionOrder}; +use j2k_test_support::{ + add_packet_length, edit_tile_parts, packet_ranges, with_byte_after_last_packets, + with_packets_filled, with_surplus_packet_lengths, +}; + +const WIDTH: u32 = 96; +const HEIGHT: u32 = 80; +const LEVELS: u8 = 2; +const LAYERS: u8 = 2; + +#[derive(Clone, Copy, Debug)] +struct Fixture { + components: u16, + ht: bool, + tiled: bool, +} + +const FIXTURES: [Fixture; 6] = [ + Fixture { + components: 1, + ht: false, + tiled: false, + }, + Fixture { + components: 1, + ht: true, + tiled: false, + }, + Fixture { + components: 3, + ht: false, + tiled: false, + }, + Fixture { + components: 3, + ht: true, + tiled: false, + }, + Fixture { + components: 1, + ht: false, + tiled: true, + }, + Fixture { + components: 3, + ht: true, + tiled: true, + }, +]; + +impl Fixture { + fn formats(self) -> &'static [PixelFormat] { + if self.components == 1 { + &[PixelFormat::Gray8, PixelFormat::Gray16] + } else { + &[PixelFormat::Rgb8, PixelFormat::Rgb16] + } + } + + /// Two layers over two decomposition levels, one precinct per + /// resolution, with PLT lengths; tiled fixtures are four + /// resolution-major 64 x 64 tiles, the others one layer-major tile. + fn options(self) -> EncodeOptions { + EncodeOptions { + num_decomposition_levels: LEVELS, + num_layers: LAYERS, + use_ht_block_coding: self.ht, + write_plt: true, + tile_size: self.tiled.then_some((64, 64)), + progression_order: if self.tiled { + EncodeProgressionOrder::Rpcl + } else { + EncodeProgressionOrder::Lrcp + }, + ..EncodeOptions::default() + } + } + + fn codestream(self, options: &EncodeOptions) -> Vec { + self.codestream_of_noise(options, 0x2545_f491) + } + + /// Another valid codestream of the same shape, other samples. + fn companion(self, options: &EncodeOptions) -> Vec { + self.codestream_of_noise(options, 0x9e37_79b9) + } + + fn codestream_of_noise(self, options: &EncodeOptions, seed: u32) -> Vec { + let mut state = seed; + let pixels: Vec = (0..WIDTH * HEIGHT * u32::from(self.components)) + .map(|_| { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state.to_be_bytes()[0] + }) + .collect(); + encode(&pixels, WIDTH, HEIGHT, self.components, 8, false, options).expect("encode fixture") + } + + /// Indices, among one tile's packets, of the packets of `resolution` + /// (of `layer` only, when given) in `progression`. + fn packets_of( + self, + progression: EncodeProgressionOrder, + resolution: usize, + layer: Option, + ) -> Vec { + let (resolutions, components, layers) = ( + usize::from(LEVELS) + 1, + usize::from(self.components), + usize::from(LAYERS), + ); + let mut indices = Vec::new(); + for component in 0..components { + for l in (0..layers).filter(|&l| layer.is_none_or(|layer| layer == l)) { + indices.push(match progression { + EncodeProgressionOrder::Rpcl => { + (resolution * components + component) * layers + l + } + EncodeProgressionOrder::Lrcp => { + (l * resolutions + resolution) * components + component + } + _ => unreachable!("fixtures are RPCL or LRCP"), + }); + } + } + indices.sort_unstable(); + indices + } +} + +/// Where each tile-part's packet data starts, after its SOD marker. +fn tile_part_bodies(codestream: &[u8]) -> Vec { + let segment_end = |at: usize| { + at + 2 + usize::from(u16::from_be_bytes([codestream[at + 2], codestream[at + 3]])) + }; + let mut bodies = Vec::new(); + let mut at = 2; + while codestream[at..at + 2] != [0xff, 0x90] { + at = segment_end(at); + } + while codestream[at..at + 2] == [0xff, 0x90] { + let psot = u32::from_be_bytes(codestream[at + 6..at + 10].try_into().expect("Psot")); + let end = at + usize::try_from(psot).expect("Psot fits"); + let mut marker = at + 12; + while codestream[marker..marker + 2] != [0xff, 0x93] { + marker = segment_end(marker); + } + bodies.push(marker + 2); + at = end; + } + bodies +} + +/// `codestream` (with PLT lengths) and `twin` (the same encode without them) +/// with the packets at `indices` of every tile filled with 0xFF. +fn damaged_packets( + codestream: &[u8], + twin: &[u8], + packets_per_tile: usize, + indices: &[usize], +) -> (Vec, Vec) { + let ranges = packet_ranges(codestream); + assert_eq!(ranges.len() % packets_per_tile, 0, "whole tiles of packets"); + let (bodies, twin_bodies) = (tile_part_bodies(codestream), tile_part_bodies(twin)); + assert_eq!( + bodies.len(), + ranges.len() / packets_per_tile, + "one tile-part per tile" + ); + let mut damaged = Vec::new(); + let mut twin_damaged = Vec::new(); + for (tile, packets) in ranges.chunks(packets_per_tile).enumerate() { + for &index in indices { + let range: Range = packets[index].clone(); + let shift = |at: usize| at - bodies[tile] + twin_bodies[tile]; + twin_damaged.push(shift(range.start)..shift(range.end)); + damaged.push(range); + } + } + let twin_damaged_bytes = with_packets_filled(twin, &twin_damaged, 0xff); + for (range, twin_range) in damaged.iter().zip(&twin_damaged) { + assert_eq!( + codestream[range.clone()], + twin[twin_range.clone()], + "the twin holds the same packet bytes" + ); + } + ( + with_packets_filled(codestream, &damaged, 0xff), + twin_damaged_bytes, + ) +} + +/// The CPU decode of `bytes` at `scale`, tightly packed. +fn cpu_decode(bytes: &[u8], fmt: PixelFormat, scale: Downscale) -> Result, j2k::J2kError> { + let mut decoder = j2k::J2kDecoder::new(bytes)?; + let (width, height) = decoder.info().dimensions; + let divisor = scale.denominator(); + let stride = width.div_ceil(divisor) as usize * fmt.bytes_per_pixel(); + let mut out = vec![0_u8; stride * height.div_ceil(divisor) as usize]; + match scale { + Downscale::None => decoder.decode_into(&mut out, stride, fmt)?, + _ => decoder.decode_scaled_into( + &mut j2k::J2kScratchPool::new(), + &mut out, + stride, + fmt, + scale, + )?, + }; + Ok(out) +} + +fn request(fmt: PixelFormat, scale: Downscale) -> MetalDecodeRequest { + match scale { + Downscale::None => MetalDecodeRequest::full(fmt, BackendRequest::Metal), + _ => MetalDecodeRequest::scaled(fmt, scale, BackendRequest::Metal), + } +} + +fn surface_bytes(surface: &Surface) -> Vec { + assert_eq!(surface.backend_kind(), BackendKind::Metal); + surface.as_bytes().expect("surface readback").into_owned() +} + +/// A failed decode: its message and the class of the native decode error +/// behind it, if there is one. +#[derive(Debug)] +struct Failure { + message: String, + class: Option, +} + +impl Failure { + fn of(error: &(dyn std::error::Error + 'static)) -> Self { + let mut class = None; + let mut next = Some(error); + while let Some(current) = next { + if let Some(native) = current.downcast_ref::() { + class = Some(native.classify()); + break; + } + next = current.source(); + } + Self { + message: error.to_string(), + class, + } + } +} + +/// `bytes` decoded at `scale` by each Metal entry point that consumes the +/// shared packet walk: an explicit request with and without a session, a +/// tile batch that also holds `other` (a distinct input that decodes, so the +/// batch takes its distinct-input route and fails only for `bytes`), and, +/// for 8-bit output, the batch decoder. +fn metal_outcomes( + bytes: &[u8], + other: &[u8], + fmt: PixelFormat, + scale: Downscale, + session: &MetalBackendSession, +) -> Vec<(&'static str, Result, Failure>)> { + let failure = |error: Error| Failure::of(&error); + let mut outcomes = Vec::new(); + outcomes.push(( + "request", + J2kDecoder::new(bytes) + .and_then(|mut decoder| decoder.decode_request_to_device(request(fmt, scale))) + .map(|surface| surface_bytes(&surface)) + .map_err(failure), + )); + outcomes.push(( + "session request", + J2kDecoder::new(bytes) + .and_then(|mut decoder| { + decoder.decode_request_to_device_with_session(request(fmt, scale), session) + }) + .map(|surface| surface_bytes(&surface)) + .map_err(failure), + )); + let mut batch = MetalTileBatch::with_capacity(2); + for input in [bytes, other] { + batch + .push_tile_request(input, request(fmt, scale)) + .expect("queue tile batch request"); + } + outcomes.push(( + "tile batch", + batch + .decode_all() + .map(|surfaces| surface_bytes(&surfaces[0])) + .map_err(failure), + )); + if matches!(fmt, PixelFormat::Gray8 | PixelFormat::Rgb8) { + let mut options = BatchDecodeOptions::default(); + options.layout = BatchLayout::Nhwc; + let decode_request = match scale { + Downscale::None => DecodeRequest::Full, + _ => DecodeRequest::Reduced { scale }, + }; + let mut decoder = + MetalBatchDecoder::system_default_with_options(options).expect("Metal batch decoder"); + let outcome = + match decoder.decode_batch(vec![EncodedImage::new(Arc::from(bytes), decode_request)]) { + Err(error) => Err(failure(error)), + Ok(batch) => match (batch.errors(), batch.group_errors(), batch.groups()) { + ([error], [], []) => Err(Failure::of(error)), + ([], [error], []) => Err(Failure::of(error)), + ([], [], [group]) => Ok(surface_bytes(&group.surfaces()[0])), + _ => Err(Failure { + message: "an unexpected batch result shape".to_string(), + class: None, + }), + }, + }; + outcomes.push(("batch decoder", outcome)); + } + outcomes +} + +/// What every route must give for one codestream and request. +enum Expected<'a> { + /// These bytes. + Decodes(&'a [u8]), + /// A failure of one native error class. + Fails, +} + +/// Checks that the CPU decode and every Metal entry point give `expected` +/// for `bytes` at `scale`, and on failure fail with one native error class. +fn check( + case: &str, + (bytes, other): (&[u8], &[u8]), + fmt: PixelFormat, + scale: Downscale, + expected: &Expected<'_>, + session: &MetalBackendSession, + failures: &mut Vec, +) { + let case = format!("{case} {fmt:?} {scale:?}"); + let cpu = cpu_decode(bytes, fmt, scale).map_err(|error| Failure::of(&error)); + let class = match (expected, &cpu) { + (Expected::Decodes(expected), Ok(decoded)) if decoded == expected => None, + (Expected::Decodes(_), Ok(_)) => { + failures.push(format!("{case} CPU: other bytes")); + None + } + (Expected::Decodes(_), Err(failure)) => { + failures.push(format!("{case} CPU: {}", failure.message)); + None + } + (Expected::Fails, Ok(_)) => { + failures.push(format!("{case} CPU: decoded")); + None + } + (Expected::Fails, Err(failure)) => { + if failure.class.is_none() { + failures.push(format!("{case} CPU: no native cause: {}", failure.message)); + } + failure.class + } + }; + for (route, outcome) in metal_outcomes(bytes, other, fmt, scale, session) { + match (expected, outcome) { + (Expected::Decodes(expected), Ok(decoded)) if decoded == *expected => {} + (Expected::Decodes(_), Ok(_)) => failures.push(format!("{case} {route}: other bytes")), + (Expected::Decodes(_), Err(failure)) => { + failures.push(format!("{case} {route}: {}", failure.message)); + } + (Expected::Fails, Ok(_)) => failures.push(format!("{case} {route}: decoded")), + (Expected::Fails, Err(failure)) if class.is_some() && failure.class == class => {} + (Expected::Fails, Err(failure)) => failures.push(format!( + "{case} {route}: {} ({:?}), where the CPU reports {:?}", + failure.message, + failure.class, + cpu.as_ref().err() + )), + } + } +} + +/// Data after the tile's last packet in each tile-part, bytes or PLT +/// lengths past it, is no packet's: every Metal entry point decodes such a +/// codestream, at full and half resolution, to the CPU decode of the +/// codestream without it. +#[test] +fn data_after_a_tiles_last_packet_decodes_on_metal_as_on_the_cpu() { + if !should_run_metal_runtime() { + return; + } + + let session = MetalBackendSession::system_default().expect("Metal session"); + let mut failures = Vec::new(); + for fixture in FIXTURES { + let clean = fixture.codestream(&fixture.options()); + let companion = fixture.companion(&fixture.options()); + let edits = [ + ("clean", clean.clone()), + ("byte after", with_byte_after_last_packets(&clean)), + ("surplus lengths", with_surplus_packet_lengths(&clean)), + ( + "both", + with_byte_after_last_packets(&with_surplus_packet_lengths(&clean)), + ), + ]; + for &fmt in fixture.formats() { + for scale in [Downscale::None, Downscale::Half] { + let expected = cpu_decode(&clean, fmt, scale).expect("clean CPU decode"); + for (edit, edited) in &edits { + check( + &format!("{fixture:?} {edit}"), + (edited, &companion), + fmt, + scale, + &Expected::Decodes(&expected), + &session, + &mut failures, + ); + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// Packet data the walk must reject fails every Metal entry point with the +/// error class the CPU decode reports: a tile's last packet shorter than its +/// PLT length (at full resolution, where that packet is read), a PLT length +/// past a tile-part's packets while the tile's packets go on in the next +/// tile-part, and a damaged first packet, which every resolution needs. +#[test] +fn rejected_packet_data_fails_on_metal_as_on_the_cpu() { + if !should_run_metal_runtime() { + return; + } + + let session = MetalBackendSession::system_default().expect("Metal session"); + let mut failures = Vec::new(); + for fixture in FIXTURES { + let clean = fixture.codestream(&fixture.options()); + // One layer: HT code-blocks put every pass in the first, which leaves + // the last packet of a second layer empty. + let mut short_last = fixture.codestream(&EncodeOptions { + num_layers: 1, + ..fixture.options() + }); + let last = packet_ranges(&short_last).last().expect("packets").start; + assert_ne!( + short_last[last] & 0x80, + 0, + "{fixture:?}: last packet is empty" + ); + // Cleared, the packet's non-empty bit ends its header after one + // byte, short of its PLT length. + short_last[last] &= 0x7f; + let mut cases = vec![ + ("short last packet", short_last, &[Downscale::None][..]), + ( + "damaged first packet", + with_packets_filled(&clean, &packet_ranges(&clean)[..1], 0xff), + &[Downscale::None, Downscale::Half][..], + ), + ]; + if !fixture.tiled { + let split = fixture.codestream(&EncodeOptions { + tile_part_packet_limit: Some(1), + ..fixture.options() + }); + let mut first = true; + let surplus_first = edit_tile_parts(&split, |part| { + if std::mem::take(&mut first) { + add_packet_length(part); + } + }); + cases.push(( + "surplus length before a later tile-part", + surplus_first, + &[Downscale::None, Downscale::Half][..], + )); + } + for (edit, edited, scales) in &cases { + for &fmt in fixture.formats() { + for &scale in *scales { + check( + &format!("{fixture:?} {edit}"), + (edited, &clean), + fmt, + scale, + &Expected::Fails, + &session, + &mut failures, + ); + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// A half-resolution decode reads only the packets it needs, on every +/// backend. Damage confined to finest-level packets after the last needed +/// one (resolution-major, with PLT lengths or without), or to finest-level +/// packets a PLT length skips (layer-major), decodes to the clean +/// codestream's half-resolution image on Metal as on the CPU. Damage in a +/// coarsest-level packet, or in a packet that must be parsed to reach a +/// later needed one, and any of it at full resolution, fails on both with +/// one error class. +#[test] +fn half_resolution_skips_damaged_finest_packets_on_metal_as_on_the_cpu() { + if !should_run_metal_runtime() { + return; + } + + let session = MetalBackendSession::system_default().expect("Metal session"); + let finest = usize::from(LEVELS); + let mut failures = Vec::new(); + for fixture in FIXTURES { + let packets_per_tile = + (usize::from(LEVELS) + 1) * usize::from(fixture.components) * usize::from(LAYERS); + let progressions: &[EncodeProgressionOrder] = if fixture.tiled { + &[EncodeProgressionOrder::Rpcl] + } else { + &[EncodeProgressionOrder::Rpcl, EncodeProgressionOrder::Lrcp] + }; + for &progression in progressions { + let options = EncodeOptions { + progression_order: progression, + ..fixture.options() + }; + let clean = fixture.codestream(&options); + let twin = fixture.codestream(&EncodeOptions { + write_plt: false, + ..options + }); + // (damaged packets, half resolution decodes with PLT, without) + let cases = match progression { + EncodeProgressionOrder::Rpcl => [ + ( + "finest", + fixture.packets_of(progression, finest, None), + true, + true, + ), + ( + "coarsest", + fixture.packets_of(progression, 0, None), + false, + false, + ), + ], + _ => [ + ( + "layer-0 finest", + fixture.packets_of(progression, finest, Some(0)), + true, + false, + ), + ( + "layer-0 coarsest", + fixture.packets_of(progression, 0, Some(0)), + false, + false, + ), + ], + }; + for (damage, indices, plt_decodes, twin_decodes) in &cases { + let (damaged, twin_damaged) = + damaged_packets(&clean, &twin, packets_per_tile, indices); + for &fmt in fixture.formats() { + for (plt, original, edited, decodes) in [ + ("PLT", &clean, &damaged, *plt_decodes), + ("no PLT", &twin, &twin_damaged, *twin_decodes), + ] { + let case = format!("{fixture:?} {progression:?} {plt} {damage} damaged"); + let expected = cpu_decode(original, fmt, Downscale::Half) + .expect("clean half-resolution CPU decode"); + for (scale, expected) in [ + (Downscale::None, Expected::Fails), + ( + Downscale::Half, + if decodes { + Expected::Decodes(&expected) + } else { + Expected::Fails + }, + ), + ] { + check( + &case, + (edited, original), + fmt, + scale, + &expected, + &session, + &mut failures, + ); + } + } + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// A tile of one sample at an image corner has empty detail sub-bands, so its +/// full-resolution precinct holds no code-block and its packet, the +/// codestream's last, nothing a decode uses. With that packet's EPH marker +/// damaged, every Metal entry point decodes, at full and half resolution, to +/// the CPU decode of the undamaged codestream. +#[test] +fn packets_of_precincts_without_code_blocks_are_left_unread_on_metal_as_on_the_cpu() { + if !should_run_metal_runtime() { + return; + } + + let clean: &[u8] = include_bytes!( + "../../../j2k-test-support/fixtures/empty_coarse_levels/openjpeg_gray_3x3_tile2x2_53_eph.j2k" + ); + let mut damaged = clean.to_vec(); + let eph = damaged.len() - 4; + assert_eq!( + damaged[eph..], + [0xff, 0x92, 0xff, 0xd9], + "the last packet ends in EPH" + ); + damaged[eph + 1] = 0; + let session = MetalBackendSession::system_default().expect("Metal session"); + let mut failures = Vec::new(); + for fmt in [PixelFormat::Gray8, PixelFormat::Gray16] { + for scale in [Downscale::None, Downscale::Half] { + let expected = cpu_decode(clean, fmt, scale).expect("clean CPU decode"); + check( + "corner tile EPH damaged", + (&damaged, clean), + fmt, + scale, + &Expected::Decodes(&expected), + &session, + &mut failures, + ); + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} From 6c6e36ca1ac51e09d1d58faf566726dbd2f60fff Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 06:01:56 -0500 Subject: [PATCH 19/30] test(j2k-metal): check the newer packet-walk cases on every Metal entry point Extends the Metal packet-walk parity tests with the facade's newer cases, each checked on the CPU decode and on every Metal entry point (request, session request, tile batch with a distinct companion, and the batch decoder for 8-bit output), 8- and 16-bit, classic and HT where the fixture allows: - a zero PLT length for an empty finest packet before a needed one, in a flat CPRL RGB image, fails at full and half resolution with the CPU's error class, and the codestream with its true lengths decodes as on the CPU; - with EPH markers, PLT lengths of one and two for empty finer packets before a needed one fail at full, half and quarter resolution likewise; - with PPM or PPT packet headers, damage to a finest header before a needed packet fails at half resolution, damage to the last header decodes there as the undamaged codestream, and both fail at full resolution; - tile-parts taking turns, one RPCL packet each, in the tiled fixtures and in a 2048 x 1024 gray image of eight 512 x 512 tiles, decode as the tile-major codestream; damage to every tile's finest packets decodes at half resolution as the undamaged codestream, damage to the coarsest fails, and both fail at full resolution. Without the minimum PLT lengths in the packet walk, the CPU decode and every Metal entry point decode the zero-length codestream at half resolution and the short-length one at quarter resolution. Without the series' native changes, the CPU decode fails the reduced cases that every Metal entry point already decoded to the undamaged image. --- .../tests/device/packet_walk_parity.rs | 384 +++++++++++++++++- 1 file changed, 382 insertions(+), 2 deletions(-) diff --git a/crates/j2k-metal/tests/device/packet_walk_parity.rs b/crates/j2k-metal/tests/device/packet_walk_parity.rs index d0241d89..b1a640f5 100644 --- a/crates/j2k-metal/tests/device/packet_walk_parity.rs +++ b/crates/j2k-metal/tests/device/packet_walk_parity.rs @@ -9,8 +9,9 @@ use std::ops::Range; use super::*; use j2k_native::{DecodeErrorClass, EncodeProgressionOrder}; use j2k_test_support::{ - add_packet_length, edit_tile_parts, packet_ranges, with_byte_after_last_packets, - with_packets_filled, with_surplus_packet_lengths, + add_packet_length, edit_tile_parts, packed_packet_headers, packet_ranges, + with_byte_after_last_packets, with_packet_lengths_edited, with_packets_filled, + with_surplus_packet_lengths, with_tile_parts_interleaved, }; const WIDTH: u32 = 96; @@ -656,3 +657,382 @@ fn packets_of_precincts_without_code_blocks_are_left_unread_on_metal_as_on_the_c } assert!(failures.is_empty(), "{}", failures.join("\n")); } + +/// A flat image of `WIDTH` x `HEIGHT` samples, each pixel `pixel`. +fn flat_codestream(pixel: &[u8], ht: bool, options: &EncodeOptions) -> Vec { + let pixels = pixel.repeat((WIDTH * HEIGHT) as usize); + let components = u16::try_from(pixel.len()).expect("component count"); + let options = EncodeOptions { + use_ht_block_coding: ht, + ..options.clone() + }; + encode(&pixels, WIDTH, HEIGHT, components, 8, false, &options).expect("encode fixture") +} + +/// Every packet holds at least the first byte of its header, so a zero PLT +/// length is no packet's. Skipping an unneeded packet by such a length would +/// read the next needed packet from the skipped one's bytes: here, in a flat +/// image whose finest packets are empty, the second component's finest packet +/// as the third component's coarsest, whose data a half-resolution decode +/// needs. Every Metal entry point fails on it, at full and half resolution, +/// with the error class the CPU decode reports, and decodes the codestream +/// with its true lengths as the CPU does. +#[test] +fn zero_plt_length_before_a_needed_packet_fails_on_metal_as_on_the_cpu() { + if !should_run_metal_runtime() { + return; + } + + let options = EncodeOptions { + num_decomposition_levels: 1, + progression_order: EncodeProgressionOrder::Cprl, + write_plt: true, + ..EncodeOptions::default() + }; + let session = MetalBackendSession::system_default().expect("Metal session"); + let mut failures = Vec::new(); + for ht in [false, true] { + let clean = flat_codestream(&[200, 50, 120], ht, &options); + let companion = flat_codestream(&[30, 160, 90], ht, &options); + // CPRL, one layer, one precinct per resolution: each component's + // coarsest packet, then its finest, which the flat image leaves empty. + let lengths: Vec = packet_ranges(&clean).iter().map(Range::len).collect(); + assert_eq!(lengths.len(), 6, "HT {ht}: six packets"); + assert_eq!( + [lengths[1], lengths[3], lengths[5]], + [1, 1, 1], + "HT {ht}: empty finest packets" + ); + assert!( + lengths[4] > 1, + "HT {ht}: the third component's coarsest packet has data" + ); + let zero = with_packet_lengths_edited(&clean, |lengths| { + let total: usize = lengths.iter().sum(); + lengths[3] = 0; + lengths[4] = 1; + lengths[5] = total - lengths[..5].iter().sum::(); + }); + for fmt in [PixelFormat::Rgb8, PixelFormat::Rgb16] { + for scale in [Downscale::None, Downscale::Half] { + let expected = cpu_decode(&clean, fmt, scale).expect("clean CPU decode"); + let case = format!("flat CPRL HT {ht}"); + for (edit, edited, other, expected) in [ + ("clean", &clean, &companion, Expected::Decodes(&expected)), + ("zero length", &zero, &clean, Expected::Fails), + ] { + check( + &format!("{case} {edit}"), + (edited, other), + fmt, + scale, + &expected, + &session, + &mut failures, + ); + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// With EPH markers every packet holds at least three bytes, its header's +/// first byte and the marker, so PLT lengths of one and two are no packet's. +/// Skipping by them would misplace the next needed packet as a zero length +/// does: in a flat CPRL image with three resolutions, lengths of one and two +/// for the second component's finer packets make a quarter-resolution decode +/// read the second one as the third component's coarsest, needed, packet. +/// Every Metal entry point fails on it, at every resolution, with the error +/// class the CPU decode reports, and decodes the codestream with its true +/// lengths as the CPU does. +#[test] +fn plt_lengths_shorter_than_an_eph_packet_fail_on_metal_as_on_the_cpu() { + if !should_run_metal_runtime() { + return; + } + + let options = EncodeOptions { + num_decomposition_levels: 2, + progression_order: EncodeProgressionOrder::Cprl, + write_plt: true, + write_eph: true, + ..EncodeOptions::default() + }; + let session = MetalBackendSession::system_default().expect("Metal session"); + let mut failures = Vec::new(); + for ht in [false, true] { + let clean = flat_codestream(&[200, 50, 120], ht, &options); + let companion = flat_codestream(&[30, 160, 90], ht, &options); + // CPRL, one layer, one precinct per resolution: each component's + // three packets, coarsest first; the finer ones are empty, a byte and + // EPH. + let lengths: Vec = packet_ranges(&clean).iter().map(Range::len).collect(); + assert_eq!(lengths.len(), 9, "HT {ht}: nine packets"); + for finer in [1, 2, 4, 5, 7, 8] { + assert_eq!(lengths[finer], 3, "HT {ht}: empty packet {finer}"); + } + assert!( + lengths[6] > 3, + "HT {ht}: the third component's coarsest packet has data" + ); + let short = with_packet_lengths_edited(&clean, |lengths| { + let total: usize = lengths.iter().sum(); + lengths[4] = 1; + lengths[5] = 2; + lengths[6] = 3; + lengths[8] = total - lengths[..8].iter().sum::(); + }); + for fmt in [PixelFormat::Rgb8, PixelFormat::Rgb16] { + for scale in [Downscale::None, Downscale::Half, Downscale::Quarter] { + let expected = cpu_decode(&clean, fmt, scale).expect("clean CPU decode"); + let case = format!("flat CPRL EPH HT {ht}"); + for (edit, edited, other, expected) in [ + ("clean", &clean, &companion, Expected::Decodes(&expected)), + ("short lengths", &short, &clean, Expected::Fails), + ] { + check( + &format!("{case} {edit}"), + (edited, other), + fmt, + scale, + &expected, + &session, + &mut failures, + ); + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// With packet headers packed in PPM or PPT, PLT lengths hold only packet +/// bodies, so a reduced decode parses every header up to the last packet it +/// needs. In a flat image, whose packets after the first hold one-byte empty +/// headers, damage to the header of a finest packet that precedes a needed +/// one fails every Metal entry point at half resolution, and damage to the +/// last packet's header, after every packet a half-resolution decode needs, +/// decodes there to the CPU decode of the undamaged codestream; both fail at +/// full resolution, with the error class the CPU decode reports. +#[test] +fn damaged_packed_headers_fail_reduced_decodes_on_metal_as_on_the_cpu() { + if !should_run_metal_runtime() { + return; + } + + let session = MetalBackendSession::system_default().expect("Metal session"); + let mut failures = Vec::new(); + for (label, write_ppm, write_ppt) in [("PPM", true, false), ("PPT", false, true)] { + for ht in [false, true] { + let options = EncodeOptions { + num_decomposition_levels: LEVELS, + num_layers: LAYERS, + // Every coding pass in the first layer, where HT code-blocks + // put them without targets. + quality_layer_byte_targets: if ht { Vec::new() } else { vec![1, u64::MAX] }, + progression_order: EncodeProgressionOrder::Lrcp, + write_plt: true, + write_ppm, + write_ppt, + ..EncodeOptions::default() + }; + let clean = flat_codestream(&[90], ht, &options); + let companion = flat_codestream(&[170], ht, &options); + // LRCP, one component, one precinct per resolution: the first + // layer's coarsest header holds the image, then five empty ones, + // the first layer's two finer packets' and the second layer's + // three. + let headers = packed_packet_headers(&clean); + assert_eq!( + clean[headers.end - 5..headers.end], + [0; 5], + "{label} HT {ht}: empty headers" + ); + let damaged_at = |from_end: usize| { + let mut damaged = clean.clone(); + damaged[headers.end - from_end] = 0xff; + damaged + }; + // The first layer's finest header, before the second layer's + // packets, and the second layer's finest header, the last. + let (navigation, last) = (damaged_at(4), damaged_at(1)); + for fmt in [PixelFormat::Gray8, PixelFormat::Gray16] { + let half = cpu_decode(&clean, fmt, Downscale::Half) + .expect("clean half-resolution CPU decode"); + let full = cpu_decode(&clean, fmt, Downscale::None).expect("clean CPU decode"); + for (edit, edited, other, scale, expected) in [ + ( + "clean", + &clean, + &companion, + Downscale::Half, + Expected::Decodes(&half), + ), + ( + "clean", + &clean, + &companion, + Downscale::None, + Expected::Decodes(&full), + ), + ( + "finest header before a needed packet damaged", + &navigation, + &clean, + Downscale::Half, + Expected::Fails, + ), + ( + "finest header before a needed packet damaged", + &navigation, + &clean, + Downscale::None, + Expected::Fails, + ), + ( + "last header damaged", + &last, + &clean, + Downscale::Half, + Expected::Decodes(&half), + ), + ( + "last header damaged", + &last, + &clean, + Downscale::None, + Expected::Fails, + ), + ] { + check( + &format!("flat {label} HT {ht} {edit}"), + (edited, other), + fmt, + scale, + &expected, + &session, + &mut failures, + ); + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +/// Tile-parts of several tiles may take turns in the codestream, and a +/// tile's packets are complete across its tile-parts. With one RPCL packet +/// per tile-part, every Metal entry point decodes the interleaved codestream +/// to the CPU decode of the tile-major one; damage to every tile's finest +/// packets, in the last rounds of tile-parts, decodes at half resolution as +/// the undamaged codestream does, and damage to every tile's coarsest +/// packets, in the first rounds, fails; both fail at full resolution, with +/// the error class the CPU decode reports. The images are the tiled +/// fixtures, four tiles of at most 64 x 64, and a gray one of eight +/// 512 x 512 tiles. +#[test] +fn interleaved_tile_parts_spare_reduced_decodes_per_tile_on_metal_as_on_the_cpu() { + if !should_run_metal_runtime() { + return; + } + + let session = MetalBackendSession::system_default().expect("Metal session"); + let finest = usize::from(LEVELS); + // (image, codestream, formats, tiles, packets per tile, the packets of + // each tile's finest and coarsest resolution) + let mut images = Vec::new(); + for fixture in FIXTURES.into_iter().filter(|fixture| fixture.tiled) { + let options = EncodeOptions { + tile_part_packet_limit: Some(1), + ..fixture.options() + }; + images.push(( + format!("{fixture:?}"), + fixture.codestream(&options), + fixture.formats(), + 4, + (usize::from(LEVELS) + 1) * usize::from(fixture.components) * usize::from(LAYERS), + fixture.packets_of(EncodeProgressionOrder::Rpcl, finest, None), + fixture.packets_of(EncodeProgressionOrder::Rpcl, 0, None), + )); + } + let (width, height) = (2048, 1024); + let mut state = 0x9e37_79b9_u32; + let pixels: Vec = (0..width * height) + .map(|_| { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state.to_be_bytes()[0] + }) + .collect(); + let options = EncodeOptions { + num_decomposition_levels: LEVELS, + progression_order: EncodeProgressionOrder::Rpcl, + tile_size: Some((512, 512)), + tile_part_packet_limit: Some(1), + write_plt: true, + ..EncodeOptions::default() + }; + images.push(( + "2048 x 1024 gray, 512 x 512 tiles".to_string(), + encode(&pixels, width, height, 1, 8, false, &options).expect("encode fixture"), + &[PixelFormat::Gray8, PixelFormat::Gray16][..], + 8, + usize::from(LEVELS) + 1, + vec![finest], + vec![0], + )); + let mut failures = Vec::new(); + for (image, clean, formats, tiles, packets_per_tile, finest_packets, coarsest_packets) in + &images + { + let ranges = packet_ranges(clean); + assert_eq!( + ranges.len(), + tiles * packets_per_tile, + "{image}: {tiles} tiles" + ); + let damaged = |indices: &[usize]| { + let packets: Vec> = ranges + .chunks(*packets_per_tile) + .flat_map(|tile| indices.iter().map(|&index| tile[index].clone())) + .collect(); + with_tile_parts_interleaved(&with_packets_filled(clean, &packets, 0xff)) + }; + let interleaved = with_tile_parts_interleaved(clean); + assert_ne!(&interleaved, clean, "{image}: tile-parts change places"); + let (finest_damaged, coarsest_damaged) = + (damaged(finest_packets), damaged(coarsest_packets)); + for &fmt in *formats { + for scale in [Downscale::None, Downscale::Half] { + let expected = cpu_decode(clean, fmt, scale).expect("clean CPU decode"); + assert!( + cpu_decode(&interleaved, fmt, scale).is_ok_and(|decoded| decoded == expected), + "{image} {fmt:?} {scale:?}: interleaving keeps the image" + ); + let finest_expected = match scale { + Downscale::None => Expected::Fails, + _ => Expected::Decodes(&expected), + }; + for (edit, edited, expected) in [ + ("clean", &interleaved, Expected::Decodes(&expected)), + ("finest damaged", &finest_damaged, finest_expected), + ("coarsest damaged", &coarsest_damaged, Expected::Fails), + ] { + check( + &format!("{image} interleaved {edit}"), + (edited, clean), + fmt, + scale, + &expected, + &session, + &mut failures, + ); + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} From 094ce54645ae8b3f44f47359deca86fd2a4c7d26 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 06:46:11 -0500 Subject: [PATCH 20/30] fix(j2k-metal): pad an exhausted raw code-block segment with one bits A code-block's raw (bypass) coding passes may read past the coded bytes of their terminated segment when the segment holds fewer bits than the passes consume, as a truncated or lossily coded codestream can leave it. T.800 D.4.1 extends such a cleanly exhausted segment with 0xFF bytes, so every raw bit past its end reads as one, in strict mode as in lenient mode; the CPU decoder does this, but the Metal classic kernel's raw bit reader failed the read in strict mode, so a decode the CPU completed by padding failed on Metal with a code-block decode error. The kernel's raw bit reader now returns a one bit at exhaustion regardless of strict mode, as the CPU decoder does. Strict mode still rejects a missing or set stuff bit after a 0xFF byte, which the stuffing check makes separately, so malformed byte stuffing stays an error on both. A regression test shortens a raw segment of a bypass code-block so its passes exhaust it and asserts the scalar decode and the Metal kernel pad to the same samples; it fails before this change with a kernel decode error. --- crates/j2k-metal/src/classic.rs | 79 +++++++++++++++++++ crates/j2k-metal/src/classic/mq_decoder.metal | 30 +++---- 2 files changed, 90 insertions(+), 19 deletions(-) diff --git a/crates/j2k-metal/src/classic.rs b/crates/j2k-metal/src/classic.rs index 324a5bd5..4d122c98 100644 --- a/crates/j2k-metal/src/classic.rs +++ b/crates/j2k-metal/src/classic.rs @@ -1148,6 +1148,85 @@ mod tests { assert_eq!(actual, expected); } + /// A raw (bypass) segment whose coded bytes run out before its coding + /// passes end: T.800 D.4.1 extends a cleanly exhausted terminated segment + /// with 0xFF bytes, so every raw bit past the end reads as one. The + /// scalar decoder and the Metal kernel must pad alike, in strict mode too. + #[cfg(target_os = "macos")] + #[test] + fn metal_classic_kernel_matches_scalar_for_exhausted_bypass_segment() { + if !should_run_metal_runtime() { + return; + } + + let bytes = fixture_j2k_gray16_bypass(); + let image = Image::new(&bytes, &DecodeSettings::default()).expect("image"); + let mut context = DecoderContext::default(); + let mut capture = CaptureFirstClassicJob::default(); + image + .decode_components_with_ht_decoder(&mut context, &mut capture) + .expect("capture classic job"); + + let mut job = capture.first.expect("captured classic job"); + let original_coefficients = { + let mut coefficients = vec![0.0f32; job.output_len()]; + decode_j2k_code_block_scalar(job.as_job(), &mut coefficients) + .expect("decode original bypass job"); + integral_coefficients(&coefficients) + }; + let encoded = j2k_native::encode_j2k_code_block_scalar_with_style( + &original_coefficients, + job.width, + job.height, + job.sub_band_type, + job.total_bitplanes, + j2k_native::J2kCodeBlockStyle { + selective_arithmetic_coding_bypass: true, + ..job.style + }, + ) + .expect("encode bypass classic job"); + job.style.selective_arithmetic_coding_bypass = true; + job.data = encoded.data; + job.segments = encoded.segments; + job.number_of_coding_passes = encoded.number_of_coding_passes; + job.missing_bit_planes = encoded.missing_bit_planes; + job.strict = true; + + // Drop the trailing bytes of one raw segment so its coding passes read + // past its coded data, which both decoders pad with one bits. The + // following segments keep their own data (their offsets slide down), so + // the coding-pass chain and the total payload length stay consistent. + // Keep at least one byte, not ending in 0xFF, so a cleanly exhausted + // read is exercised rather than a missing stuff bit, which strict mode + // rejects on both decoders. + let raw = job + .segments + .iter() + .rposition(|segment| !segment.use_arithmetic && segment.data_length > 1) + .expect("a raw segment with coded bytes"); + let start = job.segments[raw].data_offset as usize; + let end = start + job.segments[raw].data_length as usize; + let keep_end = (start + 1..end) + .find(|&candidate| job.data[candidate - 1] != 0xff) + .expect("a truncation not ending in 0xFF"); + let removed = u32::try_from(end - keep_end).expect("removed byte count fits u32"); + job.data.drain(keep_end..end); + job.segments[raw].data_length -= removed; + for segment in &mut job.segments[raw + 1..] { + segment.data_offset -= removed; + } + assert!(removed > 0, "the raw segment is shortened"); + assert!(super::supports_metal_classic_kernel(&job.as_job())); + + let mut expected = vec![0.0f32; job.output_len()]; + decode_j2k_code_block_scalar(job.as_job(), &mut expected).expect("scalar decode pads"); + let mut actual = vec![0.0f32; job.output_len()]; + compute::decode_classic_cleanup_code_block_with_midpoint(job.as_job(), &mut actual, false) + .expect("metal decode pads"); + assert_eq!(actual, expected); + } + #[cfg(target_os = "macos")] #[test] fn metal_classic_kernel_accepts_zero_length_prefix_segment() { diff --git a/crates/j2k-metal/src/classic/mq_decoder.metal b/crates/j2k-metal/src/classic/mq_decoder.metal index 409c290d..06bd67f8 100644 --- a/crates/j2k-metal/src/classic/mq_decoder.metal +++ b/crates/j2k-metal/src/classic/mq_decoder.metal @@ -21,34 +21,26 @@ inline void arithmetic_read_byte(thread J2kArithmeticDecoder &decoder) { } } -inline bool raw_read_bit(thread J2kBypassDecoder &decoder, thread uint &bit) { +inline uint raw_read_bit(thread J2kBypassDecoder &decoder) { const uint byte_pos = decoder.bit_pos / 8u; + decoder.bit_pos += 1u; if (byte_pos >= decoder.data_len) { - if (decoder.strict != 0u) { - return false; - } - bit = 1u; - decoder.bit_pos += 1u; - return true; + // T.800 D.4.1 extends a cleanly exhausted terminated segment with + // 0xFF bytes, so raw bits past its end read as one in every mode. + return 1u; } - - const uint bit_pos = decoder.bit_pos % 8u; - bit = (uint(decoder.data[byte_pos]) >> (7u - bit_pos)) & 1u; - decoder.bit_pos += 1u; - return true; + const uint bit_pos = (decoder.bit_pos - 1u) % 8u; + return (uint(decoder.data[byte_pos]) >> (7u - bit_pos)) & 1u; } +// Fails only in strict mode, when the stuffed bit after a 0xFF byte is one +// or missing (the segment ends right after the 0xFF byte). inline bool bypass_read_bit(thread J2kBypassDecoder &decoder, thread uint &bit) { const uint byte_pos = decoder.bit_pos / 8u; const uint bit_pos = decoder.bit_pos % 8u; - if (!raw_read_bit(decoder, bit)) { - return false; - } + bit = raw_read_bit(decoder); if (bit_pos == 7u && byte_pos < decoder.data_len && decoder.data[byte_pos] == uchar(0xFFu)) { - uint stuffed_bit = 0u; - if (!raw_read_bit(decoder, stuffed_bit)) { - return decoder.strict == 0u; - } + const uint stuffed_bit = raw_read_bit(decoder); if (stuffed_bit != 0u && decoder.strict != 0u) { return false; } From a868538753e29cb0bbde2a987cf954dc0bca0902 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 03:31:25 -0500 Subject: [PATCH 21/30] test(j2k-cuda): check CUDA decodes read the packets the CPU decodes read Codestreams with data after a tile's last packet, PLT lengths past it, or damaged packets decode on CUDA as they decode on the CPU, through the single-image decode, the batch of images and the prepared batch, at full and half resolution, gray and RGB, classic and HTJ2K: the samples of the clean codestream where the CPU decode leaves the extra data or the damaged packets unread, the CPU decode's native error where it reads them. The half-resolution cases cover damage confined to finest-level packets with and without PLT lengths, in RPCL and LRCP order, and damage in a packet the reduced decode needs. A tiled image whose last packet, that of a precinct without code-blocks, has a damaged EPH marker decodes as the clean image on the prepared batch; the single-image CUDA decodes, whose direct plans take one tile, reject it as unsupported before reading any packet, as they reject the clean image. --- crates/j2k-cuda/tests/packet_walk.rs | 611 +++++++++++++++++++++++++++ 1 file changed, 611 insertions(+) create mode 100644 crates/j2k-cuda/tests/packet_walk.rs diff --git a/crates/j2k-cuda/tests/packet_walk.rs b/crates/j2k-cuda/tests/packet_walk.rs new file mode 100644 index 00000000..cac32c05 --- /dev/null +++ b/crates/j2k-cuda/tests/packet_walk.rs @@ -0,0 +1,611 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! CUDA decodes read a codestream's packets as the CPU decodes do. Data after +//! a tile's last packet, PLT lengths past it, and damage in packets that a +//! reduced-resolution decode does not read leave the samples of the clean +//! codestream; damage in a packet the decode must read fails the CUDA decode +//! with the CPU decode's native error. Each case runs on the single-image +//! decode, the batch of images and the prepared batch. + +#![cfg(feature = "cuda-runtime")] + +use std::ops::Range; +use std::sync::Arc; + +use j2k::{ + prepare_batch, BatchDecodeOptions, BatchLayout, CpuBatchDecoder, CpuBatchSamples, + DecodeRequest, EncodedImage, +}; +use j2k_core::{BackendRequest, Downscale, ImageDecode, ImageDecodeDevice, PixelFormat}; +use j2k_cuda::{ + CudaBatchDecoder, CudaSession, J2kDecoder, J2kScratchPool, Surface, SurfaceResidency, +}; +use j2k_native::{ + encode, encode_htj2k, DecodeError, DecodeErrorClass, EncodeOptions, EncodeProgressionOrder, +}; +use j2k_test_support::{ + add_packet_length, cuda_runtime_and_strict_oxide_gate, edit_tile_parts, packet_ranges, + with_byte_after_last_packets, with_packets_filled, with_surplus_packet_lengths, +}; + +const WIDTH: u32 = 70; +const HEIGHT: u32 = 52; +const LEVELS: u8 = 2; +const LAYERS: u8 = 2; + +/// The sample layout and block coder of a fixture. +#[derive(Clone, Copy, Debug)] +struct Kind { + components: u16, + ht: bool, +} + +const KINDS: [Kind; 4] = [ + Kind { + components: 1, + ht: false, + }, + Kind { + components: 1, + ht: true, + }, + Kind { + components: 3, + ht: false, + }, + Kind { + components: 3, + ht: true, + }, +]; + +impl Kind { + fn format(self) -> PixelFormat { + if self.components == 1 { + PixelFormat::Gray8 + } else { + PixelFormat::Rgb8 + } + } + + fn channels(self) -> usize { + usize::from(self.components) + } + + /// A one-tile reversible codestream of this kind with `LEVELS` + /// decomposition levels and `LAYERS` quality layers, in `progression`. + fn codestream(self, progression: EncodeProgressionOrder, write_plt: bool) -> Vec { + self.encode(&EncodeOptions { + progression_order: progression, + write_plt, + ..fixture_options() + }) + } + + fn encode(self, options: &EncodeOptions) -> Vec { + let mut pixels = Vec::new(); + for y in 0..HEIGHT { + for x in 0..WIDTH { + for c in 0..u32::from(self.components) { + let noise = (x.wrapping_mul(2_654_435_761) ^ (y * 40_503) ^ (c * 977)) >> 13; + pixels.push((x * 5 + y * 3 + c * 61 + noise % 37).to_le_bytes()[0]); + } + } + } + let encoder = if self.ht { encode_htj2k } else { encode }; + encoder(&pixels, WIDTH, HEIGHT, self.components, 8, false, options).expect("encode fixture") + } + + /// Packets per resolution of a one-precinct tile. + fn packets_per_resolution(self) -> usize { + self.channels() * usize::from(LAYERS) + } + + /// Packets of a one-precinct tile: resolutions 0 (the coarsest) to + /// `LEVELS` (the finest). + fn packets(self) -> usize { + self.packets_per_resolution() * (usize::from(LEVELS) + 1) + } +} + +fn fixture_options() -> EncodeOptions { + EncodeOptions { + reversible: true, + num_decomposition_levels: LEVELS, + num_layers: LAYERS, + ..EncodeOptions::default() + } +} + +/// The tiles of a case's codestream. Direct plans take one tile, so the +/// single-image CUDA decodes reject a tiled codestream before reading any +/// packet, as unsupported; the prepared batch decodes it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tiles { + One, + Several, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Expect { + Decodes, + Fails, +} + +/// An edited codestream and what its full- and half-resolution decodes do. +struct Case { + label: &'static str, + clean: Vec, + edited: Vec, + full: Expect, + half: Expect, +} + +/// A decode's samples, or the native decoder error it failed with. +type Outcome = Result, String>; + +/// Marks a native error that rejects the request rather than the codestream. +const UNSUPPORTED: &str = "unsupported: "; + +fn failure(error: &(dyn std::error::Error + 'static)) -> String { + let mut current = Some(error); + while let Some(cause) = current { + if let Some(native) = cause.downcast_ref::() { + let unsupported = matches!(native.classify(), DecodeErrorClass::Unsupported { .. }); + return format!("{}{native:?}", if unsupported { UNSUPPORTED } else { "" }); + } + current = cause.source(); + } + format!("no native decoder error: {error}") +} + +/// The stride and length of a decode of `bytes` at `scale`. +fn layout(bytes: &[u8], kind: Kind, scale: Downscale) -> (usize, usize) { + let (width, height) = J2kDecoder::inspect(bytes).expect("inspect").dimensions; + let width = width.div_ceil(scale.denominator()) as usize; + let height = height.div_ceil(scale.denominator()) as usize; + let stride = width * kind.channels(); + (stride, stride * height) +} + +fn cpu_decode(bytes: &[u8], kind: Kind, scale: Downscale) -> Outcome { + let (stride, len) = layout(bytes, kind, scale); + let mut out = vec![0_u8; len]; + let mut decoder = J2kDecoder::new(bytes).map_err(|error| failure(&error))?; + let mut pool = J2kScratchPool::new(); + if scale == Downscale::None { + decoder.decode_into_with_scratch(&mut pool, &mut out, stride, kind.format()) + } else { + decoder.decode_scaled_into(&mut pool, &mut out, stride, kind.format(), scale) + } + .map_err(|error| failure(&error))?; + Ok(out) +} + +/// The samples of a surface a CUDA decode produced on the device. +fn device_samples(bytes: &[u8], surface: &Surface, kind: Kind, scale: Downscale) -> Vec { + assert_eq!( + surface.residency(), + SurfaceResidency::CudaResidentDecode, + "decoded on the device" + ); + let (stride, len) = layout(bytes, kind, scale); + let mut out = vec![0_u8; len]; + surface + .download_into(&mut out, stride) + .expect("download surface"); + out +} + +fn cuda_decode(bytes: &[u8], kind: Kind, scale: Downscale) -> Outcome { + let mut decoder = J2kDecoder::new(bytes).map_err(|error| failure(&error))?; + let surface = if scale == Downscale::None { + decoder.decode_to_device(kind.format(), BackendRequest::Cuda) + } else { + decoder.decode_scaled_to_device(kind.format(), scale, BackendRequest::Cuda) + } + .map_err(|error| failure(&error))?; + Ok(device_samples(bytes, &surface, kind, scale)) +} + +fn cuda_batch_decode(bytes: &[u8], kind: Kind) -> Outcome { + let mut session = CudaSession::default(); + let surfaces = + J2kDecoder::decode_batch_to_device_with_session(&[bytes], kind.format(), &mut session) + .map_err(|error| failure(&error))?; + let [surface] = surfaces.as_slice() else { + panic!("one surface per input"); + }; + Ok(device_samples(bytes, surface, kind, Downscale::None)) +} + +fn batch_options() -> BatchDecodeOptions { + let mut options = BatchDecodeOptions::default(); + options.layout = BatchLayout::Nhwc; + options +} + +/// The CPU and CUDA decodes of a prepared batch of `bytes` alone. +fn prepared_batch_decodes( + bytes: &[u8], + scale: Downscale, + cuda: &mut CudaBatchDecoder, +) -> (Outcome, Outcome) { + let request = if scale == Downscale::None { + DecodeRequest::Full + } else { + DecodeRequest::Reduced { scale } + }; + let prepared = prepare_batch( + vec![EncodedImage::new(Arc::from(bytes), request)], + batch_options(), + ) + .expect("prepare batch"); + let cpu = CpuBatchDecoder::new(batch_options()) + .decode_prepared(&prepared) + .expect("CPU batch"); + let cpu = match (cpu.groups(), cpu.errors()) { + ([group], []) => match group.samples() { + CpuBatchSamples::U8(samples) => Ok(samples.clone()), + other => panic!("8-bit samples, not {other:?}"), + }, + ([], [error]) => Err(failure(&error.source)), + _ => panic!("one image, one group or error"), + }; + let cuda = match cuda.decode_prepared(&prepared) { + Ok(result) => match (result.groups(), result.errors()) { + ([group], []) => { + Ok(Surface::download_batch_tight(group.surfaces()).expect("download batch")) + } + ([], [error]) => Err(failure(&error.source)), + _ => panic!("one image, one group or error"), + }, + Err(error) => Err(failure(&error)), + }; + (cpu, cuda) +} + +/// How `outcome` differs from the CPU decode's `cpu`. +fn difference(outcome: &Outcome, cpu: &Outcome) -> String { + match (outcome, cpu) { + (Ok(samples), Ok(expected)) => { + let differing = samples + .iter() + .zip(expected) + .filter(|(sample, expected)| sample != expected) + .count(); + format!( + "{differing} of {} samples differ ({} expected)", + samples.len(), + expected.len() + ) + } + (Ok(_), Err(error)) => format!("decodes where the CPU fails with {error}"), + (Err(error), Ok(_)) => format!("fails with {error} where the CPU decodes"), + (Err(error), Err(expected)) => format!("fails with {error}, the CPU with {expected}"), + } +} + +/// Every way a decode of `case` departs from its expectation or from the CPU +/// decode of the same request. +fn check(case: &Case, kind: Kind, tiles: Tiles, prepared: &mut CudaBatchDecoder) -> Vec { + let mut mismatches = Vec::new(); + for (scale, expect) in [(Downscale::None, case.full), (Downscale::Half, case.half)] { + let label = format!("{} {kind:?} {scale:?}", case.label); + let cpu = cpu_decode(&case.edited, kind, scale); + match expect { + Expect::Decodes => { + let clean = cpu_decode(&case.clean, kind, scale); + if clean.is_err() || cpu != clean { + mismatches.push(format!( + "{label}: CPU {} of the clean codestream", + difference(&cpu, &clean) + )); + } + } + Expect::Fails => { + if cpu.is_ok() { + mismatches.push(format!("{label}: CPU decodes")); + } + } + } + let mut single_image = vec![("CUDA", cuda_decode(&case.edited, kind, scale))]; + if scale == Downscale::None { + single_image.push(("CUDA batch", cuda_batch_decode(&case.edited, kind))); + } + let mut routes = Vec::new(); + match tiles { + Tiles::One => routes.extend(single_image), + Tiles::Several => { + for (route, outcome) in single_image { + if !matches!(&outcome, Err(error) if error.starts_with(UNSUPPORTED)) { + mismatches.push(format!( + "{label}: {route} does not reject the tiled codestream: {}", + difference(&outcome, &cpu) + )); + } + } + } + } + let (cpu_prepared, cuda_prepared) = prepared_batch_decodes(&case.edited, scale, prepared); + routes.push(("CPU prepared batch", cpu_prepared)); + routes.push(("CUDA prepared batch", cuda_prepared)); + for (route, outcome) in routes { + if outcome != cpu { + mismatches.push(format!("{label}: {route} {}", difference(&outcome, &cpu))); + } + } + } + mismatches +} + +fn assert_parity(tiles: Tiles, cases: impl IntoIterator) { + let mut prepared = CudaBatchDecoder::with_options(batch_options()); + let mut mismatches = Vec::new(); + for (kind, case) in cases { + mismatches.extend(check(&case, kind, tiles, &mut prepared)); + } + assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); +} + +fn check_all(cases: impl Fn(Kind) -> Vec) { + assert_parity( + Tiles::One, + KINDS + .into_iter() + .flat_map(|kind| cases(kind).into_iter().map(move |case| (kind, case))), + ); +} + +/// The position of the first `SOD` marker of a one-tile-part codestream. +fn sod(codestream: &[u8]) -> usize { + codestream + .windows(2) + .position(|window| window == [0xff, 0x93]) + .expect("SOD marker") +} + +/// The packet ranges of `plain`, the codestream `with_plt` encodes without its +/// PLT marker: the packets are the same bytes, after a shorter header. +fn packet_ranges_without_plt(with_plt: &[u8], plain: &[u8]) -> Vec> { + let shift = sod(with_plt) - sod(plain); + packet_ranges(with_plt) + .into_iter() + .map(|range| { + let moved = range.start - shift..range.end - shift; + assert_eq!(plain[moved.clone()], with_plt[range], "same packet bytes"); + moved + }) + .collect() +} + +/// RPCL ranges of the packets of `resolution`, every component and layer. +fn rpcl_resolution(kind: Kind, ranges: &[Range], resolution: usize) -> Vec> { + let per_resolution = kind.packets_per_resolution(); + assert_eq!(ranges.len(), kind.packets(), "one precinct"); + ranges[resolution * per_resolution..][..per_resolution].to_vec() +} + +/// LRCP ranges of the packets of `resolution` in the first layer. +fn lrcp_first_layer_resolution( + kind: Kind, + ranges: &[Range], + resolution: usize, +) -> Vec> { + assert_eq!(ranges.len(), kind.packets(), "one precinct"); + ranges[resolution * kind.channels()..][..kind.channels()].to_vec() +} + +#[test] +fn cuda_decodes_leave_data_after_a_tiles_last_packet_unread_as_cpu_decodes_do() { + if !cuda_runtime_and_strict_oxide_gate(module_path!()) { + return; + } + check_all(|kind| { + let lrcp_plt = kind.codestream(EncodeProgressionOrder::Lrcp, true); + let rpcl = kind.codestream(EncodeProgressionOrder::Rpcl, false); + let rpcl_plt = kind.codestream(EncodeProgressionOrder::Rpcl, true); + vec![ + Case { + label: "clean RPCL with PLT", + clean: rpcl_plt.clone(), + edited: rpcl_plt.clone(), + full: Expect::Decodes, + half: Expect::Decodes, + }, + Case { + label: "byte after the last packet, RPCL", + edited: with_byte_after_last_packets(&rpcl), + clean: rpcl, + full: Expect::Decodes, + half: Expect::Decodes, + }, + Case { + label: "PLT length past the last packet, LRCP", + edited: with_surplus_packet_lengths(&lrcp_plt), + clean: lrcp_plt, + full: Expect::Decodes, + half: Expect::Decodes, + }, + Case { + label: "byte and PLT length past the last packet, RPCL", + edited: with_byte_after_last_packets(&with_surplus_packet_lengths(&rpcl_plt)), + clean: rpcl_plt, + full: Expect::Decodes, + half: Expect::Decodes, + }, + ] + }); +} + +#[test] +fn cuda_decodes_fail_with_the_cpu_decodes_error_on_packets_they_must_read() { + if !cuda_runtime_and_strict_oxide_gate(module_path!()) { + return; + } + check_all(|kind| { + // A PLT length past a tile-part's last packet while the tile's + // packets go on in the next tile-part. + let split = kind.encode(&EncodeOptions { + tile_part_packet_limit: Some(1), + write_plt: true, + ..fixture_options() + }); + let mut first = true; + let surplus_before_next = edit_tile_parts(&split, |part| { + if std::mem::take(&mut first) { + add_packet_length(part); + } + }); + // The tile's last packet, a finest-level one, with its non-empty bit + // cleared: its header ends short of its PLT length. A reduced decode + // does not read it. + let one_layer = kind.encode(&EncodeOptions { + progression_order: EncodeProgressionOrder::Rpcl, + num_layers: 1, + write_plt: true, + ..fixture_options() + }); + let last = packet_ranges(&one_layer).pop().expect("packets").start; + let mut short_last = one_layer.clone(); + assert_ne!(short_last[last] & 0x80, 0, "the last packet is not empty"); + short_last[last] &= 0x7f; + let rpcl_plt = kind.codestream(EncodeProgressionOrder::Rpcl, true); + let first_packet = packet_ranges(&rpcl_plt)[..1].to_vec(); + vec![ + Case { + label: "PLT length past a tile-part's last packet before the next", + clean: split, + edited: surplus_before_next, + full: Expect::Fails, + half: Expect::Fails, + }, + Case { + label: "last packet shorter than its PLT length", + clean: one_layer, + edited: short_last, + full: Expect::Fails, + half: Expect::Decodes, + }, + Case { + label: "first packet filled", + edited: with_packets_filled(&rpcl_plt, &first_packet, 0xff), + clean: rpcl_plt, + full: Expect::Fails, + half: Expect::Fails, + }, + ] + }); +} + +#[test] +fn cuda_reduced_decodes_read_only_the_packets_cpu_decodes_read() { + if !cuda_runtime_and_strict_oxide_gate(module_path!()) { + return; + } + check_all(|kind| { + let rpcl_plt = kind.codestream(EncodeProgressionOrder::Rpcl, true); + let rpcl = kind.codestream(EncodeProgressionOrder::Rpcl, false); + let lrcp_plt = kind.codestream(EncodeProgressionOrder::Lrcp, true); + let lrcp = kind.codestream(EncodeProgressionOrder::Lrcp, false); + let rpcl_plt_ranges = packet_ranges(&rpcl_plt); + let rpcl_ranges = packet_ranges_without_plt(&rpcl_plt, &rpcl); + let lrcp_plt_ranges = packet_ranges(&lrcp_plt); + let lrcp_ranges = packet_ranges_without_plt(&lrcp_plt, &lrcp); + let finest = usize::from(LEVELS); + let filled = |codestream: &[u8], ranges: Vec>| { + with_packets_filled(codestream, &ranges, 0xff) + }; + vec![ + Case { + label: "finest-level packets filled, RPCL with PLT", + edited: filled(&rpcl_plt, rpcl_resolution(kind, &rpcl_plt_ranges, finest)), + clean: rpcl_plt.clone(), + full: Expect::Fails, + half: Expect::Decodes, + }, + Case { + label: "finest-level packets filled, RPCL", + edited: filled(&rpcl, rpcl_resolution(kind, &rpcl_ranges, finest)), + clean: rpcl.clone(), + full: Expect::Fails, + half: Expect::Decodes, + }, + Case { + label: "first-layer finest-level packets filled, LRCP with PLT", + edited: filled( + &lrcp_plt, + lrcp_first_layer_resolution(kind, &lrcp_plt_ranges, finest), + ), + clean: lrcp_plt, + full: Expect::Fails, + half: Expect::Decodes, + }, + Case { + label: "first-layer finest-level packets filled, LRCP", + edited: filled( + &lrcp, + lrcp_first_layer_resolution(kind, &lrcp_ranges, finest), + ), + clean: lrcp, + full: Expect::Fails, + half: Expect::Fails, + }, + Case { + label: "coarsest-level packets filled, RPCL with PLT", + edited: filled(&rpcl_plt, rpcl_resolution(kind, &rpcl_plt_ranges, 0)), + clean: rpcl_plt, + full: Expect::Fails, + half: Expect::Fails, + }, + Case { + label: "coarsest-level packets filled, RPCL", + edited: filled(&rpcl, rpcl_resolution(kind, &rpcl_ranges, 0)), + clean: rpcl, + full: Expect::Fails, + half: Expect::Fails, + }, + ] + }); +} + +/// A tile of one sample at an image corner has empty detail sub-bands, so its +/// full-resolution precinct holds no code-block, and its packet, the tile's +/// last, nothing a decode uses. With that packet's EPH marker damaged, the +/// image decodes as the clean one on the prepared batch, of tiled images, as +/// on the CPU. +#[test] +fn cuda_decodes_leave_packets_of_precincts_without_code_blocks_unread_as_cpu_decodes_do() { + if !cuda_runtime_and_strict_oxide_gate(module_path!()) { + return; + } + let clean = include_bytes!( + "../../j2k-test-support/fixtures/empty_coarse_levels/openjpeg_gray_3x3_tile2x2_53_eph.j2k" + ) + .to_vec(); + let mut edited = clean.clone(); + let eph = edited.len() - 4; + assert_eq!( + edited[eph..], + [0xff, 0x92, 0xff, 0xd9], + "the last packet ends in EPH" + ); + edited[eph + 1] = 0; + let kind = Kind { + components: 1, + ht: false, + }; + assert_parity( + Tiles::Several, + [( + kind, + Case { + label: "EPH of an empty precinct's packet damaged", + clean, + edited, + full: Expect::Decodes, + half: Expect::Decodes, + }, + )], + ); +} From f58d66edb0495c563d6d3eafcf05913fe58813ac Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 06:11:48 -0500 Subject: [PATCH 22/30] test(j2k-cuda): check CUDA decodes on short PLT lengths, packed headers and interleaved tile-parts Extend the CUDA packet-walk parity tests with the cases the CPU facade tests cover, on the single-image decode, the batch of images and the prepared batch: - a zero PLT length for a packet a half-resolution decode skips, before a packet it needs (flat RGB in CPRL, flat gray in LRCP with two layers), and PLT lengths of one and two where EPH markers make every packet at least three bytes, which fail at full, half and quarter resolution; - damaged PPM and PPT packet headers: before a needed packet the half-resolution decode fails, in the last header it decodes as the undamaged codestream, and both fail at full resolution; - tile-parts of several tiles interleaved, six 32 x 32 tiles of every kind and eight 512 x 512 tiles of a 2048 x 1024 gray image. Direct plans take one tile, so the single-image CUDA decodes reject these as unsupported; the prepared batch decodes them on the device as the CPU does. Every CUDA outcome must equal the CPU decode's: the same samples, or the same native decode error. A CUDA prepared batch whose group fails on the device now reports that error as its outcome. With the packet walk from before the length check, the zero-length and EPH cases decode at half and quarter resolution on the CPU and every CUDA route, and the tests fail. --- crates/j2k-cuda/tests/packet_walk.rs | 398 ++++++++++++++++++++++++++- 1 file changed, 385 insertions(+), 13 deletions(-) diff --git a/crates/j2k-cuda/tests/packet_walk.rs b/crates/j2k-cuda/tests/packet_walk.rs index cac32c05..15aa6c42 100644 --- a/crates/j2k-cuda/tests/packet_walk.rs +++ b/crates/j2k-cuda/tests/packet_walk.rs @@ -3,9 +3,11 @@ //! CUDA decodes read a codestream's packets as the CPU decodes do. Data after //! a tile's last packet, PLT lengths past it, and damage in packets that a //! reduced-resolution decode does not read leave the samples of the clean -//! codestream; damage in a packet the decode must read fails the CUDA decode -//! with the CPU decode's native error. Each case runs on the single-image -//! decode, the batch of images and the prepared batch. +//! codestream; damage in a packet the decode must read or parse, in packed +//! packet headers it must parse, and PLT lengths it cannot skip by fail the +//! CUDA decode with the CPU decode's native error. Each case runs on the +//! single-image decode, the batch of images and the prepared batch, and with +//! tile-parts of several tiles interleaved, on the prepared batch. #![cfg(feature = "cuda-runtime")] @@ -24,8 +26,9 @@ use j2k_native::{ encode, encode_htj2k, DecodeError, DecodeErrorClass, EncodeOptions, EncodeProgressionOrder, }; use j2k_test_support::{ - add_packet_length, cuda_runtime_and_strict_oxide_gate, edit_tile_parts, packet_ranges, - with_byte_after_last_packets, with_packets_filled, with_surplus_packet_lengths, + add_packet_length, cuda_runtime_and_strict_oxide_gate, edit_tile_parts, packed_packet_headers, + packet_ranges, with_byte_after_last_packets, with_packet_lengths_edited, with_packets_filled, + with_surplus_packet_lengths, with_tile_parts_interleaved, }; const WIDTH: u32 = 70; @@ -83,17 +86,40 @@ impl Kind { } fn encode(self, options: &EncodeOptions) -> Vec { + self.encode_sized(WIDTH, HEIGHT, options) + } + + /// A `width` x `height` codestream of this kind. + fn encode_sized(self, width: u32, height: u32, options: &EncodeOptions) -> Vec { let mut pixels = Vec::new(); - for y in 0..HEIGHT { - for x in 0..WIDTH { + for y in 0..height { + for x in 0..width { for c in 0..u32::from(self.components) { let noise = (x.wrapping_mul(2_654_435_761) ^ (y * 40_503) ^ (c * 977)) >> 13; pixels.push((x * 5 + y * 3 + c * 61 + noise % 37).to_le_bytes()[0]); } } } + self.encode_pixels(&pixels, width, height, options) + } + + /// A codestream of this kind whose every pixel is (200, 50, 120), or 200 + /// in gray: its detail sub-bands are empty. + fn encode_flat(self, options: &EncodeOptions) -> Vec { + let pixel = &[200, 50, 120][..self.channels()]; + let pixels = pixel.repeat((WIDTH * HEIGHT) as usize); + self.encode_pixels(&pixels, WIDTH, HEIGHT, options) + } + + fn encode_pixels( + self, + pixels: &[u8], + width: u32, + height: u32, + options: &EncodeOptions, + ) -> Vec { let encoder = if self.ht { encode_htj2k } else { encode }; - encoder(&pixels, WIDTH, HEIGHT, self.components, 8, false, options).expect("encode fixture") + encoder(pixels, width, height, self.components, 8, false, options).expect("encode fixture") } /// Packets per resolution of a one-precinct tile. @@ -106,6 +132,11 @@ impl Kind { fn packets(self) -> usize { self.packets_per_resolution() * (usize::from(LEVELS) + 1) } + + /// Packets of a one-precinct tile in one layer. + fn packets_per_layer(self) -> usize { + self.packets() / usize::from(LAYERS) + } } fn fixture_options() -> EncodeOptions { @@ -253,11 +284,13 @@ fn prepared_batch_decodes( _ => panic!("one image, one group or error"), }; let cuda = match cuda.decode_prepared(&prepared) { - Ok(result) => match (result.groups(), result.errors()) { - ([group], []) => { + Ok(result) => match (result.groups(), result.errors(), result.group_errors()) { + ([group], [], []) => { Ok(Surface::download_batch_tight(group.surfaces()).expect("download batch")) } - ([], [error]) => Err(failure(&error.source)), + ([], [error], []) => Err(failure(&error.source)), + // The group failed on the device. + ([], [], [error]) => Err(failure(error.source())), _ => panic!("one image, one group or error"), }, Err(error) => Err(failure(&error)), @@ -287,10 +320,22 @@ fn difference(outcome: &Outcome, cpu: &Outcome) -> String { } /// Every way a decode of `case` departs from its expectation or from the CPU -/// decode of the same request. +/// decode of the same request, at full and half resolution. fn check(case: &Case, kind: Kind, tiles: Tiles, prepared: &mut CudaBatchDecoder) -> Vec { + let scales = [(Downscale::None, case.full), (Downscale::Half, case.half)]; + check_at(case, kind, tiles, prepared, &scales) +} + +/// [`check`] at each scale of `scales`, with what the decode there does. +fn check_at( + case: &Case, + kind: Kind, + tiles: Tiles, + prepared: &mut CudaBatchDecoder, + scales: &[(Downscale, Expect)], +) -> Vec { let mut mismatches = Vec::new(); - for (scale, expect) in [(Downscale::None, case.full), (Downscale::Half, case.half)] { + for &(scale, expect) in scales { let label = format!("{} {kind:?} {scale:?}", case.label); let cpu = cpu_decode(&case.edited, kind, scale); match expect { @@ -609,3 +654,330 @@ fn cuda_decodes_leave_packets_of_precincts_without_code_blocks_unread_as_cpu_dec )], ); } + +/// Every packet holds at least the first byte of its header, so a zero PLT +/// length is no packet's. In a flat image, whose finest packets are empty, a +/// zero length for a finest packet that a half-resolution decode skips, and a +/// length of one for the needed packet after it, would have that decode read +/// the needed packet from the skipped one's byte: in RGB, CPRL, the second +/// component's finest packet and the third component's coarsest; in gray, +/// LRCP with two layers, the first layer's finest packet and the second +/// layer's coarsest. The CUDA decodes fail as the CPU decodes do, at both +/// resolutions. +#[test] +fn cuda_decodes_reject_a_zero_plt_length_before_a_needed_packet_as_cpu_decodes_do() { + if !cuda_runtime_and_strict_oxide_gate(module_path!()) { + return; + } + check_all(|kind| { + let (progression, layers, skipped) = if kind.components == 1 { + (EncodeProgressionOrder::Lrcp, 2, 1) + } else { + (EncodeProgressionOrder::Cprl, 1, 3) + }; + let clean = kind.encode_flat(&EncodeOptions { + num_decomposition_levels: 1, + num_layers: layers, + progression_order: progression, + write_plt: true, + ..fixture_options() + }); + // One precinct per resolution: the packets alternate between the + // coarsest and the finest resolution, whose packets are empty. + let lengths: Vec = packet_ranges(&clean).iter().map(Range::len).collect(); + assert_eq!( + lengths.len(), + kind.channels() * 2 * usize::from(layers), + "two resolutions" + ); + assert!( + lengths.iter().skip(1).step_by(2).all(|&length| length == 1), + "empty finest packets: {lengths:?}" + ); + let zero = with_packet_lengths_edited(&clean, |lengths| { + let total: usize = lengths.iter().sum(); + lengths[skipped] = 0; + lengths[skipped + 1] = 1; + let last = lengths.len() - 1; + lengths[last] = total - lengths[..last].iter().sum::(); + }); + vec![Case { + label: "zero PLT length before a needed packet", + clean, + edited: zero, + full: Expect::Fails, + half: Expect::Fails, + }] + }); +} + +/// With packet headers packed in PPM or PPT, PLT lengths hold only packet +/// bodies, so a reduced decode parses every header up to the last packet it +/// needs. In a flat image, whose packets after each component's first hold +/// one-byte empty headers, a header damaged to include a code-block reads +/// that block's zero bit-plane count from the zero bytes after it and runs +/// out of packed headers: damage to the header of the first layer's last +/// finest packet, before the second layer's packets, fails the +/// half-resolution decodes; damage to the last packet's header, after every +/// packet they need, leaves them the undamaged image. Both fail at full +/// resolution. +#[test] +fn cuda_decodes_parse_damaged_packed_packet_headers_as_cpu_decodes_do() { + if !cuda_runtime_and_strict_oxide_gate(module_path!()) { + return; + } + check_all(|kind| { + let mut cases = Vec::new(); + for (labels, write_ppm, write_ppt) in [ + ( + [ + "PPM: first layer's finest header damaged", + "PPM: last header damaged", + ], + true, + false, + ), + ( + [ + "PPT: first layer's finest header damaged", + "PPT: last header damaged", + ], + false, + true, + ), + ] { + let clean = kind.encode_flat(&EncodeOptions { + // Every coding pass in the first layer: an HTJ2K block's + // cleanup pass is there without targets. + quality_layer_byte_targets: if kind.ht { + Vec::new() + } else { + vec![1, u64::MAX] + }, + progression_order: EncodeProgressionOrder::Lrcp, + write_plt: true, + write_ppm, + write_ppt, + ..fixture_options() + }); + // LRCP, one precinct per resolution: the first layer's coarsest + // headers hold the image, every later one is empty. + let headers = packed_packet_headers(&clean); + let empty = kind.packets() - kind.channels(); + assert!( + clean[headers.end - empty..headers.end] + .iter() + .all(|&byte| byte == 0), + "{}: empty headers", + labels[0] + ); + let damaged_at = |from_end: usize| { + let mut damaged = clean.clone(); + // Not empty, its first code-block included. + damaged[headers.end - from_end] = 0xc0; + damaged + }; + let [navigation, last] = labels; + cases.push(Case { + label: navigation, + edited: damaged_at(kind.packets_per_layer() + 1), + clean: clean.clone(), + full: Expect::Fails, + half: Expect::Fails, + }); + cases.push(Case { + label: last, + edited: damaged_at(1), + clean, + full: Expect::Fails, + half: Expect::Decodes, + }); + } + cases + }); +} + +/// The cases of one interleaved tile layout: `width` x `height` pixels of +/// `kind` in `tiles` tiles of `tile` x `tile`, one RPCL packet per tile-part. +fn interleaved_cases( + kind: Kind, + (width, height, tile): (u32, u32, u32), + tiles: usize, + labels: [&'static str; 3], +) -> [(Kind, Case); 3] { + let clean = kind.encode_sized( + width, + height, + &EncodeOptions { + progression_order: EncodeProgressionOrder::Rpcl, + tile_size: Some((tile, tile)), + tile_part_packet_limit: Some(1), + write_plt: true, + ..fixture_options() + }, + ); + let ranges = packet_ranges(&clean); + assert_eq!( + ranges.len(), + tiles * kind.packets(), + "{}: one-precinct tiles", + labels[0] + ); + let resolution = |level: usize| -> Vec> { + ranges + .iter() + .enumerate() + .filter(|(index, _)| index % kind.packets() / kind.packets_per_resolution() == level) + .map(|(_, range)| range.clone()) + .collect() + }; + let interleaved = with_tile_parts_interleaved(&clean); + assert_ne!( + interleaved, clean, + "{}: tile-parts change places", + labels[0] + ); + let filled = + |level| with_tile_parts_interleaved(&with_packets_filled(&clean, &resolution(level), 0xff)); + let finest = filled(usize::from(LEVELS)); + let coarsest = filled(0); + let [moved, finest_filled, coarsest_filled] = labels; + [ + Case { + label: moved, + clean: clean.clone(), + edited: interleaved.clone(), + full: Expect::Decodes, + half: Expect::Decodes, + }, + Case { + label: finest_filled, + clean: interleaved.clone(), + edited: finest, + full: Expect::Fails, + half: Expect::Decodes, + }, + Case { + label: coarsest_filled, + clean: interleaved, + edited: coarsest, + full: Expect::Fails, + half: Expect::Fails, + }, + ] + .map(|case| (kind, case)) +} + +/// Tile-parts of several tiles may take turns in the codestream, and a +/// reduced decode completes each tile across its tile-parts. With one RPCL +/// packet per tile-part, interleaving keeps the image; damage to every tile's +/// finest packets, in the last rounds of tile-parts, leaves the +/// half-resolution decode the undamaged image, and damage to every tile's +/// coarsest packets, in the first rounds, fails it; both fail at full +/// resolution. Six 32 x 32 tiles of every kind, and eight 512 x 512 tiles of +/// a 2048 x 1024 gray image. Direct plans take one tile, so the single-image +/// CUDA decodes reject these codestreams as unsupported, and the prepared +/// batch decodes them on the device as the CPU does. +#[test] +fn cuda_prepared_batches_read_interleaved_tile_parts_as_cpu_decodes_do() { + if !cuda_runtime_and_strict_oxide_gate(module_path!()) { + return; + } + let small = KINDS.into_iter().flat_map(|kind| { + interleaved_cases( + kind, + (WIDTH, HEIGHT, 32), + 6, + [ + "32 x 32 tiles interleaved", + "32 x 32 tiles interleaved, finest-level packets filled", + "32 x 32 tiles interleaved, coarsest-level packets filled", + ], + ) + }); + let large = KINDS + .into_iter() + .filter(|kind| kind.components == 1) + .flat_map(|kind| { + interleaved_cases( + kind, + (2048, 1024, 512), + 8, + [ + "512 x 512 tiles interleaved", + "512 x 512 tiles interleaved, finest-level packets filled", + "512 x 512 tiles interleaved, coarsest-level packets filled", + ], + ) + }); + assert_parity(Tiles::Several, small.chain(large)); +} + +/// With EPH markers every packet holds at least three bytes, its header's +/// first byte and the marker, so PLT lengths of one and two are no packet's. +/// Skipping by them would misplace the next needed packet as a zero length +/// does. In a flat image with three resolutions, lengths of one, two and +/// three for two finer packets that a quarter-resolution decode skips and +/// the needed packet after them would have that decode read the needed one +/// from the second skipped packet's bytes: in RGB, CPRL, the second +/// component's finer packets and the third component's coarsest; in gray, +/// LRCP with two layers, the first layer's finer packets and the second +/// layer's coarsest. The CUDA decodes fail as the CPU decodes do, at full, +/// half and quarter resolution. +#[test] +fn cuda_decodes_reject_plt_lengths_shorter_than_an_eph_packet_as_cpu_decodes_do() { + if !cuda_runtime_and_strict_oxide_gate(module_path!()) { + return; + } + let mut prepared = CudaBatchDecoder::with_options(batch_options()); + let mut mismatches = Vec::new(); + for kind in KINDS { + let (progression, layers, skipped) = if kind.components == 1 { + (EncodeProgressionOrder::Lrcp, 2, 1) + } else { + (EncodeProgressionOrder::Cprl, 1, 4) + }; + let clean = kind.encode_flat(&EncodeOptions { + num_layers: layers, + progression_order: progression, + write_plt: true, + write_eph: true, + ..fixture_options() + }); + // One precinct per resolution: the packets come in threes, coarsest + // first; the finer ones are empty, a byte and EPH. + let lengths: Vec = packet_ranges(&clean).iter().map(Range::len).collect(); + assert_eq!( + lengths.len(), + kind.channels() * 3 * usize::from(layers), + "three resolutions" + ); + assert!( + lengths + .iter() + .enumerate() + .all(|(index, &length)| index % 3 == 0 || length == 3), + "empty finer packets: {lengths:?}" + ); + let short = with_packet_lengths_edited(&clean, |lengths| { + let total: usize = lengths.iter().sum(); + lengths[skipped..skipped + 3].copy_from_slice(&[1, 2, 3]); + let last = lengths.len() - 1; + lengths[last] = total - lengths[..last].iter().sum::(); + }); + let case = Case { + label: "PLT lengths shorter than an EPH packet", + clean, + edited: short, + full: Expect::Fails, + half: Expect::Fails, + }; + let scales = [ + (Downscale::None, Expect::Fails), + (Downscale::Half, Expect::Fails), + (Downscale::Quarter, Expect::Fails), + ]; + mismatches.extend(check_at(&case, kind, Tiles::One, &mut prepared, &scales)); + } + assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); +} From 5a1bd463fb973aafaf2c7daff634cb5dca145867 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 07:09:56 -0500 Subject: [PATCH 23/30] fix(j2k-cuda): fail classic code-blocks past their bit-planes with a native error A strict decode rejects a code-block whose packet headers claim more missing bit-planes than its sub-band codes, or more coding passes than its remaining bit-planes hold. The CPU decode reports these as InvalidBitplaneCount and TooManyCodingPasses, decode errors of the codestream. The CUDA classic Tier-1 plan rejected the same blocks as an unsupported CUDA request, so the single-image decode, the batch of images and the prepared batch failed with another error class than the CPU decode of the same codestream. The plan now checks these two conditions for strict blocks as the CPU decode does and fails with a native decode error of the class the CPU decode reports. Where a codestream has more than one fault, the first error each reports can still differ. Lenient blocks, and the plan's own geometry and segment checks, keep the unsupported-request rejection. A packet-walk test lowers the QCD guard bits of lossless classic codestreams by one, which leaves every coded block one bit-plane's passes too many: before the change every CUDA route failed with the unsupported request where the CPU decode failed with TooManyCodingPasses. Unit tests cover the strict and lenient checks. --- crates/j2k-cuda/src/direct_plan/classic.rs | 44 ++++++- .../j2k-cuda/src/direct_plan/classic/tests.rs | 120 ++++++++++++++++++ crates/j2k-cuda/tests/packet_walk.rs | 48 +++++++ 3 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 crates/j2k-cuda/src/direct_plan/classic/tests.rs diff --git a/crates/j2k-cuda/src/direct_plan/classic.rs b/crates/j2k-cuda/src/direct_plan/classic.rs index f68c5fce..707b175b 100644 --- a/crates/j2k-cuda/src/direct_plan/classic.rs +++ b/crates/j2k-cuda/src/direct_plan/classic.rs @@ -1,14 +1,15 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 use j2k_native::{ - J2kCodeBlockSegment, J2kCodeBlockStyle, J2kOwnedCodeBlockBatchJob, J2kOwnedSubBandPlan, - J2kSubBandType, + DecodeError, DecodingError, J2kCodeBlockSegment, J2kCodeBlockStyle, J2kOwnedCodeBlockBatchJob, + J2kOwnedSubBandPlan, J2kSubBandType, }; use super::{ required_regions::RequiredBandRegions, shared::CudaPlanOwners, CudaClassicCodeBlock, CudaClassicSegment, CudaClassicSubband, Error, PLAN_PAYLOAD_TOO_LARGE, }; +use crate::error::NativeBackendError; const CLASSIC_PLAN_INVALID: &str = "strict CUDA classic Tier-1 plan is invalid"; const STYLE_RESET_CONTEXT_PROBABILITIES: u32 = 1 << 0; @@ -104,14 +105,19 @@ fn validate_classic_job(job: &J2kOwnedCodeBlockBatchJob, payload_len: usize) -> let Some(coded_bitplanes) = job.total_bitplanes.checked_add(job.roi_shift) else { return invalid_classic_plan(); }; + if job.strict { + check_strict_code_block(job, coded_bitplanes)?; + } if !(1..=64).contains(&job.width) || !(1..=64).contains(&job.height) || !(1..=31).contains(&job.total_bitplanes) || coded_bitplanes > 31 - || job.missing_bit_planes >= coded_bitplanes { return invalid_classic_plan(); } + if job.missing_bit_planes >= coded_bitplanes { + return invalid_classic_plan(); + } let decoded_bitplanes = coded_bitplanes - job.missing_bit_planes; let max_passes = 1 + 3 * (decoded_bitplanes - 1); if job.number_of_coding_passes > max_passes { @@ -141,6 +147,35 @@ fn validate_classic_job(job: &J2kOwnedCodeBlockBatchJob, payload_len: usize) -> Ok(()) } +/// Reject, as the CPU decode does, a code-block whose packet headers claim +/// more missing bit-planes than its sub-band codes, or more coding passes +/// than its remaining bit-planes hold: errors in the codestream, which a +/// strict decode reports with their native class on every backend. +fn check_strict_code_block( + job: &J2kOwnedCodeBlockBatchJob, + coded_bitplanes: u8, +) -> Result<(), Error> { + let Some(bitplanes) = coded_bitplanes.checked_sub(job.missing_bit_planes) else { + return rejected_code_block(DecodingError::InvalidBitplaneCount); + }; + let max_passes = if bitplanes == 0 { + 0 + } else { + 1 + 3 * (u32::from(bitplanes) - 1) + }; + if u32::from(job.number_of_coding_passes) > max_passes { + return rejected_code_block(DecodingError::TooManyCodingPasses); + } + Ok(()) +} + +fn rejected_code_block(error: DecodingError) -> Result<(), Error> { + Err(Error::NativeDecode { + context: "CUDA decode rejected an invalid code-block", + source: NativeBackendError::decode(DecodeError::Decoding(error)), + }) +} + fn invalid_classic_plan() -> Result { Err(Error::capability_rejected( j2k_core::CapabilityRejection::geometry_mismatch(CLASSIC_PLAN_INVALID), @@ -190,3 +225,6 @@ fn checked_u64(value: usize) -> Result { )) }) } + +#[cfg(test)] +mod tests; diff --git a/crates/j2k-cuda/src/direct_plan/classic/tests.rs b/crates/j2k-cuda/src/direct_plan/classic/tests.rs new file mode 100644 index 00000000..7f7700a8 --- /dev/null +++ b/crates/j2k-cuda/src/direct_plan/classic/tests.rs @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use j2k_native::{ + DecodeError, DecodingError, J2kCodeBlockSegment, J2kCodeBlockStyle, J2kOwnedCodeBlockBatchJob, + J2kSubBandType, +}; + +use super::validate_classic_job; +use crate::Error; + +/// An 8 x 8 block of a sub-band with four bit-planes, at most ten passes. +fn job( + missing_bit_planes: u8, + number_of_coding_passes: u8, + strict: bool, +) -> J2kOwnedCodeBlockBatchJob { + J2kOwnedCodeBlockBatchJob { + output_x: 0, + output_y: 0, + data: vec![0; 4], + segments: vec![J2kCodeBlockSegment { + data_offset: 0, + data_length: 4, + start_coding_pass: 0, + end_coding_pass: number_of_coding_passes, + use_arithmetic: true, + }], + width: 8, + height: 8, + output_stride: 8, + missing_bit_planes, + number_of_coding_passes, + total_bitplanes: 4, + roi_shift: 0, + sub_band_type: J2kSubBandType::LowLow, + style: J2kCodeBlockStyle { + selective_arithmetic_coding_bypass: false, + reset_context_probabilities: false, + termination_on_each_pass: false, + vertically_causal_context: false, + segmentation_symbols: false, + }, + strict, + dequantization_step: 1.0, + } +} + +/// The native decode error behind `error`, if any. +fn native(error: &Error) -> Option<&DecodeError> { + let mut next: Option<&(dyn std::error::Error + 'static)> = Some(error); + while let Some(current) = next { + if let Some(native) = current.downcast_ref::() { + return Some(native); + } + next = current.source(); + } + None +} + +#[test] +fn strict_code_blocks_with_codestream_errors_fail_with_the_native_error() { + assert!(validate_classic_job(&job(0, 10, true), 4).is_ok()); + for (missing_bit_planes, passes, expected) in [ + (0, 11, DecodingError::TooManyCodingPasses), + (3, 2, DecodingError::TooManyCodingPasses), + (4, 1, DecodingError::TooManyCodingPasses), + (5, 1, DecodingError::InvalidBitplaneCount), + ] { + let error = validate_classic_job(&job(missing_bit_planes, passes, true), 4) + .expect_err("a codestream error"); + assert_eq!( + native(&error), + Some(&DecodeError::Decoding(expected)), + "{missing_bit_planes} missing bit-planes, {passes} passes" + ); + } +} + +/// A sub-band with no bit-planes (guard bits 0, exponent 1) lies outside +/// what the device decodes, yet strict decodes still report the +/// codestream error the CPU reports. +#[test] +fn strict_code_blocks_of_a_sub_band_without_bit_planes_fail_with_the_native_error() { + for (missing_bit_planes, expected) in [ + (0, DecodingError::TooManyCodingPasses), + (1, DecodingError::InvalidBitplaneCount), + ] { + let job = J2kOwnedCodeBlockBatchJob { + total_bitplanes: 0, + ..job(missing_bit_planes, 1, true) + }; + let error = validate_classic_job(&job, 4).expect_err("a codestream error"); + assert_eq!( + native(&error), + Some(&DecodeError::Decoding(expected)), + "{missing_bit_planes} missing bit-planes" + ); + } + let lenient = J2kOwnedCodeBlockBatchJob { + total_bitplanes: 0, + ..job(0, 1, false) + }; + let error = validate_classic_job(&lenient, 4).expect_err("unsupported on the device"); + assert!( + matches!(error, Error::UnsupportedCudaRequest { .. }), + "{error:?}" + ); +} + +#[test] +fn lenient_code_blocks_past_their_bit_planes_stay_unsupported() { + for (missing_bit_planes, passes) in [(0, 11), (4, 1), (5, 1)] { + let error = validate_classic_job(&job(missing_bit_planes, passes, false), 4) + .expect_err("unsupported on the device"); + assert!( + matches!(error, Error::UnsupportedCudaRequest { .. }), + "{error:?}" + ); + } +} diff --git a/crates/j2k-cuda/tests/packet_walk.rs b/crates/j2k-cuda/tests/packet_walk.rs index 15aa6c42..5fd91192 100644 --- a/crates/j2k-cuda/tests/packet_walk.rs +++ b/crates/j2k-cuda/tests/packet_walk.rs @@ -981,3 +981,51 @@ fn cuda_decodes_reject_plt_lengths_shorter_than_an_eph_packet_as_cpu_decodes_do( } assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); } + +/// `codestream` with one guard bit fewer in each QCD and QCC marker of its +/// main header: every sub-band then codes one bit-plane fewer. +fn with_guard_bits_lowered(codestream: &[u8]) -> Vec { + let mut edited = codestream.to_vec(); + let mut lowered = 0; + let mut at = 2; + while codestream[at..at + 2] != [0xff, 0x90] { + // Sqcd follows Lqcd; Sqcc follows Lqcc and a one-byte Cqcc. + let style = match codestream[at + 1] { + 0x5c => Some(at + 4), + 0x5d => Some(at + 5), + _ => None, + }; + if let Some(style) = style { + assert_ne!(codestream[style] >> 5, 0, "a guard bit to remove"); + edited[style] -= 1 << 5; + lowered += 1; + } + at += 2 + usize::from(u16::from_be_bytes([codestream[at + 2], codestream[at + 3]])); + } + assert_ne!(lowered, 0, "a QCD marker"); + edited +} + +/// A strict decode rejects a code-block whose packet headers claim more +/// coding passes than its bit-planes allow. With the guard bits lowered by +/// one, every coded block of a lossless codestream has one bit-plane's +/// passes too many, a codestream error: the CUDA decodes of classic +/// code-blocks fail with the CPU decode's native error, at both resolutions. +#[test] +fn cuda_classic_decodes_reject_more_coding_passes_than_bit_planes_as_cpu_decodes_do() { + if !cuda_runtime_and_strict_oxide_gate(module_path!()) { + return; + } + let cases = KINDS.into_iter().filter(|kind| !kind.ht).map(|kind| { + let clean = kind.codestream(EncodeProgressionOrder::Lrcp, false); + let case = Case { + label: "QCD guard bits lowered", + edited: with_guard_bits_lowered(&clean), + clean, + full: Expect::Fails, + half: Expect::Fails, + }; + (kind, case) + }); + assert_parity(Tiles::One, cases); +} From 2a78667e856a6e8f397af5bb6a113a82d7fc88a9 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 07:09:56 -0500 Subject: [PATCH 24/30] docs(changelog): note the native errors of CUDA classic code-blocks --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a6d0b47..56874c5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,13 @@ Staged workspace version: `0.13.2`. status, detail and batch source index. CUDA keeps its typed source and job attribution. Unsupported statuses, raw runtime failures and combined cleanup or release failures keep their existing classes. +- A strict CUDA decode of a classic code-block whose packet headers claim + more missing bit-planes than its sub-band codes, or more coding passes + than its bit-planes hold, fails with a native decode error, + `InvalidBitplaneCount` or `TooManyCodingPasses`, of the class the CPU + decode reports for the same codestream, on the single-image decode, the + batch of images and the prepared batch. It was rejected as an unsupported + CUDA request, a different error class. - Grayscale direct and referenced plans, which GPU decodes and decode batches execute, reject a one-component codestream whose COD asks for a multiple component transform, as decodes of it already did. A 39 x 7 From 14007515eb4ce9a122fe963fcff180e7dfb1ca7e Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 11:28:24 -0500 Subject: [PATCH 25/30] perf(j2k): convert a streamed batch image's rows a row at a time The CPU batch decoder hands the rows of a streamed image to a sink that converts every sample to the batch's u8 output through a function pointer, in f64. The decode calls that sink on the thread that reads its rows, between the batches it reconstructs, and meanwhile the pool's band workers decode only as far ahead as their band rings hold. The conversion was therefore serial time on the decode's path. Since the finest band rings of classic tiles read per component hold two bands in place of three, the band workers run out of room sooner during it: a 5661 x 8287 RGB image decoded at half resolution as a one-image batch on 18 threads took 79 ms in place of 71 ms, while the same decode into a caller's RGB8 buffer, which packs its rows on the pool, got no slower. The band plan is the same on both routes (three readers, the same rings and parts); only the hand-over differs. The sink now converts a row at a time with the same rounding and saturation in f32. f32::round is exact and, above zero, equals the f64 floor of the sample plus one half, and values past the precision's maximum saturate, so the bytes are the same. The compiler can vectorize the contiguous rows of planar output, and interleaved rows convert in a tight loop. A unit test compares the row conversion with the per-sample one around every rounding and saturation boundary, at NaN and the infinities, for precisions 1 to 16, planar and interleaved. Measured on an 18-core ARM64 machine, 9 interleaved rounds against two builds of the base, default threads, output identical, peak heap unchanged: - the 5661 x 8287 RGB image as a one-image batch at half resolution: 79.2 -> 64.9 ms (71.2 ms before the shallower rings), at full resolution 196 -> 127 ms; four and eight copies with automatic workers 592 -> 447 ms and 1065 -> 876 ms; - the same image decoded alone at half resolution, into a buffer or as a region: unchanged (1.008, 0.997); - eight distinct 2048 x 2048 RGB (interleaved) or 2896 x 2896 gray images on 1, 2, 4, 8 and automatic workers: 1.5-3.5% faster in every case. With the conversion fast, the shallower rings no longer cost the batch time: at half resolution the batch takes 64.5 ms with them and without them, and keeps their 3 MiB lower peak. --- crates/j2k/src/owned_batch/cpu_materialize.rs | 149 ++++++++++++++++-- 1 file changed, 135 insertions(+), 14 deletions(-) diff --git a/crates/j2k/src/owned_batch/cpu_materialize.rs b/crates/j2k/src/owned_batch/cpu_materialize.rs index 2b64f1e8..427f51fd 100644 --- a/crates/j2k/src/owned_batch/cpu_materialize.rs +++ b/crates/j2k/src/owned_batch/cpu_materialize.rs @@ -94,7 +94,15 @@ pub(super) fn decode_image_u8<'image>( match image.cpu_execution() { CpuExecution::ExceedsDecodeBound => Err(CpuExecution::decode_bound_error()), CpuExecution::StreamedRows | CpuExecution::StreamedRowsOrPlan { .. } => { - decode_streamed_samples(image, options, info, parallelism, context, out, convert_u8) + decode_streamed_samples( + image, + options, + info, + parallelism, + context, + out, + convert_u8_row, + ) } CpuExecution::OffsetPlan { .. } | CpuExecution::ComponentPlanes => { decode_planned_u8(image, options, info, parallelism, context, worker, out) @@ -194,8 +202,9 @@ pub(super) fn decode_image_i16<'image>( /// Decode `image` through the bounded region decode, which hands over its /// rows as they are finished, writing each sample exactly as -/// [`write_samples`] writes whole planes. Working memory stays bounded by the -/// streamed decode instead of growing with the image. +/// [`write_samples`] writes whole planes, a row at a time through +/// `convert_row` (see [`convert_u8_row`]). Working memory stays bounded by +/// the streamed decode instead of growing with the image. fn decode_streamed_samples<'image, T: Copy>( image: &'image PreparedImage, options: BatchDecodeOptions, @@ -203,7 +212,7 @@ fn decode_streamed_samples<'image, T: Copy>( parallelism: CpuDecodeParallelism, context: &mut j2k_native::DecoderContext<'image>, out: &mut [T], - convert: fn(f32, u8) -> T, + convert_row: ConvertRow, ) -> Result<(), J2kError> { context.set_cpu_decode_parallelism(parallelism.to_native()); let plan = image.plan(); @@ -214,7 +223,7 @@ fn decode_streamed_samples<'image, T: Copy>( "prepared batch output metadata changed during decode", )); } - let mut sink = SampleRowSink::new(info, out, convert)?; + let mut sink = SampleRowSink::new(info, out, convert_row)?; decoded .decode_region_rows_for_integer_output_with_context( (output_rect.x, output_rect.y, output_rect.w, output_rect.h), @@ -225,11 +234,18 @@ fn decode_streamed_samples<'image, T: Copy>( sink.finish() } +/// Converts a row of decoded samples of the given precision into +/// `destination`, one sample every `stride` elements from its start. +type ConvertRow = fn(&mut [T], usize, &[f32], u8); + /// Writes streamed region rectangles into one image's dense batch samples. +/// The decode hands each batch of rows over on its reading thread, between +/// the batches it reconstructs, while the pool's band workers decode only as +/// far ahead as their rings hold, so the rows convert a row at a time. struct SampleRowSink<'out, 'info, T> { info: &'info BatchGroupInfo, out: &'out mut [T], - convert: fn(f32, u8) -> T, + convert_row: ConvertRow, pixels: usize, written_pixels: usize, malformed: bool, @@ -239,7 +255,7 @@ impl<'out, 'info, T: Copy> SampleRowSink<'out, 'info, T> { fn new( info: &'info BatchGroupInfo, out: &'out mut [T], - convert: fn(f32, u8) -> T, + convert_row: ConvertRow, ) -> Result { let pixels = (info.dimensions.0 as usize) .checked_mul(info.dimensions.1 as usize) @@ -252,7 +268,7 @@ impl<'out, 'info, T: Copy> SampleRowSink<'out, 'info, T> { Ok(Self { info, out, - convert, + convert_row, pixels, written_pixels: 0, malformed: false, @@ -304,14 +320,10 @@ impl<'out, 'info, T: Copy> SampleRowSink<'out, 'info, T> { let pixel = (y + row) * width + x; if planar { let destination = &mut self.out[channel * self.pixels + pixel..][..rect_width]; - for (destination, &sample) in destination.iter_mut().zip(samples) { - *destination = (self.convert)(sample, precision); - } + (self.convert_row)(destination, 1, samples, precision); } else { let destination = &mut self.out[pixel * channels..][..rect_width * channels]; - for (pixel, &sample) in destination.chunks_exact_mut(channels).zip(samples) { - pixel[channel] = (self.convert)(sample, precision); - } + (self.convert_row)(&mut destination[channel..], channels, samples, precision); } } } @@ -398,6 +410,43 @@ pub(super) fn convert_u8(sample: f32, precision: u8) -> u8 { u8::try_from(round_unsigned(sample, precision)).unwrap_or(u8::MAX) } +/// [`convert_u8`] for a row of `samples`, written `stride` apart from the +/// start of `destination`. It rounds and saturates in `f32`, which gives the +/// same bytes: `f32::round` is exact and, above zero, equals the `f64` +/// floor of the sample plus one half, and any value past the precision's +/// maximum saturates. Per sample, `convert_u8` costs a call and `f64` math; +/// over a row, the compiler can vectorize the contiguous rows of planar +/// output, and interleaved rows convert in a tight loop. +fn convert_u8_row(destination: &mut [u8], stride: usize, samples: &[f32], precision: u8) { + #[expect( + clippy::cast_precision_loss, + reason = "a u8 output's maximum is far below f32's exact integer range" + )] + let ceiling = ((1_u32 << u32::from(precision)) - 1) as f32; + #[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the sample is positive and rounded below the ceiling before the cast, which saturates" + )] + let round = |sample: f32| -> u8 { + let rounded = if sample > 0.0 { + sample.min(ceiling).round() as u32 + } else { + 0 + }; + u8::try_from(rounded).unwrap_or(u8::MAX) + }; + if stride == 1 { + for (destination, &sample) in destination.iter_mut().zip(samples) { + *destination = round(sample); + } + } else { + for (destination, &sample) in destination.iter_mut().step_by(stride).zip(samples) { + *destination = round(sample); + } + } +} + pub(super) fn convert_u16(sample: f32, precision: u8) -> u16 { u16::try_from(round_unsigned(sample, precision)).unwrap_or(u16::MAX) } @@ -428,3 +477,75 @@ pub(super) fn round_signed(sample: f32, precision: u8) -> i16 { i16::MAX }) } + +#[cfg(test)] +mod tests { + use super::{convert_u8, convert_u8_row}; + use alloc::vec::Vec; + + /// Samples around every rounding and saturation boundary of 8-bit and + /// narrower outputs, and the values no decode should produce. + fn edge_samples() -> Vec { + let mut samples = vec![ + 0.0, + -0.0, + f32::from_bits(1), + f32::MIN_POSITIVE, + -f32::MIN_POSITIVE, + -1.0e-30, + f32::NAN, + -f32::NAN, + f32::INFINITY, + f32::NEG_INFINITY, + f32::MAX, + -f32::MAX, + 1.0e9, + 65_535.5, + 16_777_216.0, + 16_777_218.0, + ]; + for whole in -2_i16..=300 { + let whole = f32::from(whole); + for offset in [-0.5_f32, -0.25, -1.0e-3, 0.0, 1.0e-3, 0.25, 0.5] { + let sample = whole + offset; + samples.push(sample); + for ulps in 1..=2 { + samples.push(f32::from_bits(sample.to_bits().wrapping_add(ulps))); + samples.push(f32::from_bits(sample.to_bits().wrapping_sub(ulps))); + } + } + } + samples + } + + #[test] + fn rows_convert_as_each_sample_does() { + let samples = edge_samples(); + for precision in 1..=16 { + let expected: Vec = samples + .iter() + .map(|&sample| convert_u8(sample, precision)) + .collect(); + let mut planar = vec![0x5a; samples.len()]; + convert_u8_row(&mut planar, 1, &samples, precision); + assert_eq!(planar, expected, "planar, precision {precision}"); + // Interleaved: every third byte, from the second, and none other. + let mut interleaved = vec![0x5a; samples.len() * 3 - 1]; + convert_u8_row(&mut interleaved[1..], 3, &samples, precision); + for (index, &byte) in interleaved.iter().enumerate() { + if index % 3 == 1 { + assert_eq!( + byte, + expected[index / 3], + "sample {index}, precision {precision}" + ); + } else { + assert_eq!( + byte, 0x5a, + "byte {index} between samples, precision {precision}" + ); + } + } + } + } +} From 50d488627b526cbf0edadec15ace1c10793786e9 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 11:28:24 -0500 Subject: [PATCH 26/30] docs(changelog): note the batch's row-at-a-time conversion --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56874c5a..02b9ccec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ Staged workspace version: `0.13.2`. status, detail and batch source index. CUDA keeps its typed source and job attribution. Unsupported statuses, raw runtime failures and combined cleanup or release failures keep their existing classes. +- The CPU batch decoder converts the rows a streamed image hands over a + row at a time instead of a sample at a time, with the same rounding and + saturation, so the same bytes. The decode hands its rows over on the + thread that reads them, between the batches it reconstructs, while the + pool's band workers decode only as far ahead as their band rings hold, + so the conversion held the whole decode back. On an 18-core ARM64 + machine a 5661 x 8287 RGB image as a one-image batch decodes in 127 ms + instead of 196 ms, at half resolution in 65 ms instead of 79 ms, and + four and eight copies with automatic workers in 447 and 876 ms instead + of 592 and 1065 ms. - A strict CUDA decode of a classic code-block whose packet headers claim more missing bit-planes than its sub-band codes, or more coding passes than its bit-planes hold, fails with a native decode error, From 96cc57583e67391c36779ddabd942d5df16cde52 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 18:47:52 -0500 Subject: [PATCH 27/30] fix(j2k): honor tile COD packet-marker overrides --- CHANGELOG.md | 5 +++ crates/j2k-cuda/tests/packet_walk.rs | 21 +++++++++- .../tests/device/packet_walk_parity.rs | 32 ++++++++++++++- crates/j2k-native/src/j2c/tile/tile_part.rs | 2 +- .../j2k-native/tests/tile_header_markers.rs | 18 +++++++++ crates/j2k-test-support/src/lib.rs | 2 +- crates/j2k-test-support/src/packet_edits.rs | 39 +++++++++++++++++++ 7 files changed, 115 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 02b9ccec..edc90eca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and stale roadmap entries have been removed from the public documentation set. Staged workspace version: `0.13.2`. +- A tile-part COD replaces all of the main-header COD style flags instead + of retaining its SOP and EPH flags. Valid codestreams that disable packet + markers for a tile now decode on the CPU, Metal and CUDA, at full and + reduced resolution, instead of requiring markers that the packets do not + contain. - `AdapterErrorKind::Decode`, `BackendErrorKind::Decode` and `BackendError::decode` classify GPU Tier-1 device decode failures. Metal FAIL statuses now use the existing `Error::Decode` and `J2kError::Backend` diff --git a/crates/j2k-cuda/tests/packet_walk.rs b/crates/j2k-cuda/tests/packet_walk.rs index 5fd91192..0f491281 100644 --- a/crates/j2k-cuda/tests/packet_walk.rs +++ b/crates/j2k-cuda/tests/packet_walk.rs @@ -28,7 +28,7 @@ use j2k_native::{ use j2k_test_support::{ add_packet_length, cuda_runtime_and_strict_oxide_gate, edit_tile_parts, packed_packet_headers, packet_ranges, with_byte_after_last_packets, with_packet_lengths_edited, with_packets_filled, - with_surplus_packet_lengths, with_tile_parts_interleaved, + with_surplus_packet_lengths, with_tile_cod_disabling_sop_eph, with_tile_parts_interleaved, }; const WIDTH: u32 = 70; @@ -402,6 +402,25 @@ fn check_all(cases: impl Fn(Kind) -> Vec) { ); } +#[test] +fn cuda_decodes_honor_a_tile_cod_that_disables_sop_and_eph() { + if !cuda_runtime_and_strict_oxide_gate(module_path!()) { + return; + } + let cases = KINDS.into_iter().map(|kind| { + let clean = kind.codestream(EncodeProgressionOrder::Lrcp, true); + let case = Case { + label: "tile COD disables main-header SOP and EPH", + edited: with_tile_cod_disabling_sop_eph(&clean), + clean, + full: Expect::Decodes, + half: Expect::Decodes, + }; + (kind, case) + }); + assert_parity(Tiles::One, cases); +} + /// The position of the first `SOD` marker of a one-tile-part codestream. fn sod(codestream: &[u8]) -> usize { codestream diff --git a/crates/j2k-metal/tests/device/packet_walk_parity.rs b/crates/j2k-metal/tests/device/packet_walk_parity.rs index b1a640f5..1acf8722 100644 --- a/crates/j2k-metal/tests/device/packet_walk_parity.rs +++ b/crates/j2k-metal/tests/device/packet_walk_parity.rs @@ -11,7 +11,7 @@ use j2k_native::{DecodeErrorClass, EncodeProgressionOrder}; use j2k_test_support::{ add_packet_length, edit_tile_parts, packed_packet_headers, packet_ranges, with_byte_after_last_packets, with_packet_lengths_edited, with_packets_filled, - with_surplus_packet_lengths, with_tile_parts_interleaved, + with_surplus_packet_lengths, with_tile_cod_disabling_sop_eph, with_tile_parts_interleaved, }; const WIDTH: u32 = 96; @@ -393,6 +393,36 @@ fn check( } } +#[test] +fn tile_cod_can_disable_sop_and_eph_on_metal_as_on_the_cpu() { + if !should_run_metal_runtime() { + return; + } + + let session = MetalBackendSession::system_default().expect("Metal session"); + let mut failures = Vec::new(); + for fixture in FIXTURES.into_iter().filter(|fixture| !fixture.tiled) { + let clean = fixture.codestream(&fixture.options()); + let edited = with_tile_cod_disabling_sop_eph(&clean); + let companion = fixture.companion(&fixture.options()); + for &fmt in fixture.formats() { + for scale in [Downscale::None, Downscale::Half] { + let expected = cpu_decode(&clean, fmt, scale).expect("clean CPU decode"); + check( + &format!("{fixture:?} tile COD disables main-header SOP and EPH"), + (&edited, &companion), + fmt, + scale, + &Expected::Decodes(&expected), + &session, + &mut failures, + ); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + /// Data after the tile's last packet in each tile-part, bytes or PLT /// lengths past it, is no packet's: every Metal entry point decodes such a /// codestream, at full and half resolution, to the CPU decode of the diff --git a/crates/j2k-native/src/j2c/tile/tile_part.rs b/crates/j2k-native/src/j2c/tile/tile_part.rs index 173c85b5..063c61b1 100644 --- a/crates/j2k-native/src/j2c/tile/tile_part.rs +++ b/crates/j2k-native/src/j2c/tile/tile_part.rs @@ -355,7 +355,7 @@ fn apply_tile_cod( }; allocations .replace_coding_parameters(&mut component.coding_style.parameters, replacement)?; - component.coding_style.flags.raw |= flags.raw; + component.coding_style.flags = flags; } Ok(()) } diff --git a/crates/j2k-native/tests/tile_header_markers.rs b/crates/j2k-native/tests/tile_header_markers.rs index 76c2189a..406bd611 100644 --- a/crates/j2k-native/tests/tile_header_markers.rs +++ b/crates/j2k-native/tests/tile_header_markers.rs @@ -749,6 +749,24 @@ fn assert_decode_differs(patched: &[u8], original: &[u8], case: &str) { const SINGLE_TILE: (u32, u32) = (96, 64); +#[test] +fn tile_part_cod_can_disable_sop_and_eph() { + let original = encode_fixture(SINGLE_TILE, 3, REVERSIBLE, None); + let cod = main_header_segment(&original, COD); + assert_eq!(cod[4] & 0x06, 0, "fixture COD has no packet markers"); + + let mut patched = original.clone(); + let main_cod = main_header_segment_offset(&patched, COD); + patched[main_cod + 4] |= 0x06; + insert_tile_part_segments(&mut patched, 0, &cod); + + assert_decodes_match( + &patched, + &original, + "tile-part COD disables main-header SOP and EPH", + ); +} + #[test] fn tile_part_cod_overrides_the_main_header_wavelet() { for components in [1, 3] { diff --git a/crates/j2k-test-support/src/lib.rs b/crates/j2k-test-support/src/lib.rs index 9ecebc36..212ea52c 100644 --- a/crates/j2k-test-support/src/lib.rs +++ b/crates/j2k-test-support/src/lib.rs @@ -83,7 +83,7 @@ pub use metal::{metal_device_unavailable_is_skip, metal_runtime_gate, metal_runt pub use packet_edits::{ add_packet_length, edit_tile_parts, packed_packet_headers, packet_ranges, with_byte_after_last_packets, with_packet_lengths_edited, with_packets_filled, - with_surplus_packet_lengths, with_tile_parts_interleaved, + with_surplus_packet_lengths, with_tile_cod_disabling_sop_eph, with_tile_parts_interleaved, }; pub use pixels::{ crop_interleaved_bytes, crop_interleaved_u16, project_scaled_interleaved_u16, diff --git a/crates/j2k-test-support/src/packet_edits.rs b/crates/j2k-test-support/src/packet_edits.rs index b5275a86..14f413b5 100644 --- a/crates/j2k-test-support/src/packet_edits.rs +++ b/crates/j2k-test-support/src/packet_edits.rs @@ -10,6 +10,7 @@ use std::ops::Range; const SOT: [u8; 2] = [0xff, 0x90]; const SOD: [u8; 2] = [0xff, 0x93]; +const COD: [u8; 2] = [0xff, 0x52]; const PLT: [u8; 2] = [0xff, 0x58]; const PPM: [u8; 2] = [0xff, 0x60]; const PPT: [u8; 2] = [0xff, 0x61]; @@ -73,6 +74,44 @@ pub fn edit_tile_parts(codestream: &[u8], mut edit: impl FnMut(&mut Vec)) -> edited } +/// A one-tile-part `codestream` whose main-header COD signals SOP and EPH, +/// while a tile-part COD disables both. Packet bytes stay unchanged, so they +/// contain neither marker. +/// +/// # Panics +/// +/// When `codestream` is not as the module describes, has more than one +/// tile-part, has no main-header COD, or already signals SOP or EPH. +#[must_use] +pub fn with_tile_cod_disabling_sop_eph(codestream: &[u8]) -> Vec { + let sot = first_tile_part(codestream); + assert_eq!( + tile_part_end(codestream, sot), + codestream.len() - EOC.len(), + "fixture has one tile-part" + ); + let mut cod_at = 2; + while codestream[cod_at..cod_at + 2] != COD { + assert!(cod_at < sot, "main header has a COD marker"); + cod_at = marker_segment_end(codestream, cod_at); + } + let cod_end = marker_segment_end(codestream, cod_at); + let cod = codestream[cod_at..cod_end].to_vec(); + assert_eq!(cod[4] & 0x06, 0, "fixture COD has no packet markers"); + + let mut edited = codestream.to_vec(); + edited[cod_at + 4] |= 0x06; + edited.splice(sot + 12..sot + 12, cod.iter().copied()); + let psot = u32::from_be_bytes( + edited[sot + 6..sot + 10] + .try_into() + .expect("Psot is four bytes"), + ); + let grown = psot + u32::try_from(cod.len()).expect("COD length fits Psot"); + edited[sot + 6..sot + 10].copy_from_slice(&grown.to_be_bytes()); + edited +} + /// `codestream` with a zero byte after the last packet of each tile-part, /// inside it. #[must_use] From 50820dc7c9a747fee7b42305a2d7b0f61751acae Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 18:47:52 -0500 Subject: [PATCH 28/30] fix(j2k-cuda): report excessive ROI bitplanes as decode errors --- CHANGELOG.md | 5 ++ crates/j2k-cuda/src/direct_plan/classic.rs | 7 ++ .../j2k-cuda/src/direct_plan/classic/tests.rs | 88 ++++++++++++++++++- 3 files changed, 98 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edc90eca..a8fd1161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and stale roadmap entries have been removed from the public documentation set. Staged workspace version: `0.13.2`. +- A strict CUDA decode of a classic code-block whose ROI maxshift raises its + coded bit-plane count beyond the native decoder's limit fails with the + native `TooManyBitplanes` error, as a CPU decode does, instead of reporting + an unsupported CUDA request. Counts within the native limit but beyond the + device's limit remain unsupported. - A tile-part COD replaces all of the main-header COD style flags instead of retaining its SOP and EPH flags. Valid codestreams that disable packet markers for a tile now decode on the CPU, Metal and CUDA, at full and diff --git a/crates/j2k-cuda/src/direct_plan/classic.rs b/crates/j2k-cuda/src/direct_plan/classic.rs index 707b175b..a432c8af 100644 --- a/crates/j2k-cuda/src/direct_plan/classic.rs +++ b/crates/j2k-cuda/src/direct_plan/classic.rs @@ -12,6 +12,7 @@ use super::{ use crate::error::NativeBackendError; const CLASSIC_PLAN_INVALID: &str = "strict CUDA classic Tier-1 plan is invalid"; +const NATIVE_CLASSIC_BITPLANE_LIMIT: u8 = 63; const STYLE_RESET_CONTEXT_PROBABILITIES: u32 = 1 << 0; const STYLE_TERMINATION_ON_EACH_PASS: u32 = 1 << 1; const STYLE_VERTICALLY_CAUSAL_CONTEXT: u32 = 1 << 2; @@ -103,9 +104,15 @@ fn append_classic_job_metadata( fn validate_classic_job(job: &J2kOwnedCodeBlockBatchJob, payload_len: usize) -> Result<(), Error> { let Some(coded_bitplanes) = job.total_bitplanes.checked_add(job.roi_shift) else { + if job.strict { + return rejected_code_block(DecodingError::TooManyBitplanes); + } return invalid_classic_plan(); }; if job.strict { + if coded_bitplanes > NATIVE_CLASSIC_BITPLANE_LIMIT { + return rejected_code_block(DecodingError::TooManyBitplanes); + } check_strict_code_block(job, coded_bitplanes)?; } if !(1..=64).contains(&job.width) diff --git a/crates/j2k-cuda/src/direct_plan/classic/tests.rs b/crates/j2k-cuda/src/direct_plan/classic/tests.rs index 7f7700a8..f4bc4a02 100644 --- a/crates/j2k-cuda/src/direct_plan/classic/tests.rs +++ b/crates/j2k-cuda/src/direct_plan/classic/tests.rs @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 use j2k_native::{ - DecodeError, DecodingError, J2kCodeBlockSegment, J2kCodeBlockStyle, J2kOwnedCodeBlockBatchJob, - J2kSubBandType, + decode_j2k_code_block_scalar, DecodeError, DecodingError, J2kCodeBlockDecodeJob, + J2kCodeBlockSegment, J2kCodeBlockStyle, J2kOwnedCodeBlockBatchJob, J2kSubBandType, }; use super::validate_classic_job; @@ -57,6 +57,29 @@ fn native(error: &Error) -> Option<&DecodeError> { None } +fn native_scalar_error(job: &J2kOwnedCodeBlockBatchJob) -> Option { + let mut output = vec![0.0; job.output_stride * job.height as usize]; + decode_j2k_code_block_scalar( + J2kCodeBlockDecodeJob { + data: &job.data, + segments: &job.segments, + width: job.width, + height: job.height, + output_stride: job.output_stride, + missing_bit_planes: job.missing_bit_planes, + number_of_coding_passes: job.number_of_coding_passes, + total_bitplanes: job.total_bitplanes, + roi_shift: job.roi_shift, + sub_band_type: job.sub_band_type, + style: job.style, + strict: job.strict, + dequantization_step: job.dequantization_step, + }, + &mut output, + ) + .err() +} + #[test] fn strict_code_blocks_with_codestream_errors_fail_with_the_native_error() { assert!(validate_classic_job(&job(0, 10, true), 4).is_ok()); @@ -107,6 +130,67 @@ fn strict_code_blocks_of_a_sub_band_without_bit_planes_fail_with_the_native_erro ); } +/// Native classic decode accepts at most 63 coded bit-planes, while the +/// device accepts 31. Strict planning keeps those two rejections distinct. +#[test] +fn strict_roi_shift_past_the_native_bitplane_limit_fails_with_the_native_error() { + let native_limit = J2kOwnedCodeBlockBatchJob { + total_bitplanes: 31, + roi_shift: 32, + ..job(0, 1, true) + }; + assert!(!matches!( + native_scalar_error(&native_limit), + Some(DecodeError::Decoding(DecodingError::TooManyBitplanes)) + )); + let error = validate_classic_job(&native_limit, 4).expect_err("past the device limit"); + assert!( + matches!(error, Error::UnsupportedCudaRequest { .. }), + "{error:?}" + ); + + for (roi_shift, missing_bit_planes) in [(33, 70), (u8::MAX, 0)] { + let job = J2kOwnedCodeBlockBatchJob { + total_bitplanes: 31, + roi_shift, + ..job(missing_bit_planes, 1, true) + }; + assert_eq!( + native_scalar_error(&job), + Some(DecodeError::Decoding(DecodingError::TooManyBitplanes)), + "native scalar, 31 bit-planes plus ROI shift {roi_shift}" + ); + let error = validate_classic_job(&job, 4).expect_err("too many coded bit-planes"); + assert_eq!( + native(&error), + Some(&DecodeError::Decoding(DecodingError::TooManyBitplanes)), + "31 bit-planes plus ROI shift {roi_shift}" + ); + } + + let device_limit = J2kOwnedCodeBlockBatchJob { + total_bitplanes: 31, + roi_shift: 1, + ..job(0, 1, true) + }; + let error = validate_classic_job(&device_limit, 4).expect_err("past the device limit"); + assert!( + matches!(error, Error::UnsupportedCudaRequest { .. }), + "{error:?}" + ); + + let lenient = J2kOwnedCodeBlockBatchJob { + strict: false, + roi_shift: u8::MAX, + ..device_limit + }; + let error = validate_classic_job(&lenient, 4).expect_err("unsupported on the device"); + assert!( + matches!(error, Error::UnsupportedCudaRequest { .. }), + "{error:?}" + ); +} + #[test] fn lenient_code_blocks_past_their_bit_planes_stay_unsupported() { for (missing_bit_planes, passes) in [(0, 11), (4, 1), (5, 1)] { From 1739b079634db1a8ff97e56f1ec32ccf71490307 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 18:47:52 -0500 Subject: [PATCH 29/30] fix(j2k-native): validate SOP lengths and packet ordinals --- CHANGELOG.md | 4 + crates/j2k-cuda/tests/packet_walk.rs | 50 +++++- .../tests/device/packet_walk_parity.rs | 75 +++++++- crates/j2k-native/src/j2c/segment.rs | 77 ++++++--- .../j2k-native/tests/tile_header_markers.rs | 161 +++++++++++++++++- crates/j2k-test-support/src/lib.rs | 3 +- crates/j2k-test-support/src/packet_edits.rs | 44 +++++ 7 files changed, 384 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8fd1161..3b5a83ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and stale roadmap entries have been removed from the public documentation set. Staged workspace version: `0.13.2`. +- SOP marker segments a decode parses require `Lsop` 4 and an `Nsop` equal to + the tile-wide packet ordinal modulo 65536. The ordinal continues across + tile-parts and counts packets skipped without parsing; an invalid SOP in a + discarded packet remains unread by a selective decode. - A strict CUDA decode of a classic code-block whose ROI maxshift raises its coded bit-plane count beyond the native decoder's limit fails with the native `TooManyBitplanes` error, as a CPU decode does, instead of reporting diff --git a/crates/j2k-cuda/tests/packet_walk.rs b/crates/j2k-cuda/tests/packet_walk.rs index 0f491281..ce8f5eb8 100644 --- a/crates/j2k-cuda/tests/packet_walk.rs +++ b/crates/j2k-cuda/tests/packet_walk.rs @@ -28,7 +28,8 @@ use j2k_native::{ use j2k_test_support::{ add_packet_length, cuda_runtime_and_strict_oxide_gate, edit_tile_parts, packed_packet_headers, packet_ranges, with_byte_after_last_packets, with_packet_lengths_edited, with_packets_filled, - with_surplus_packet_lengths, with_tile_cod_disabling_sop_eph, with_tile_parts_interleaved, + with_sop_marker_fields, with_surplus_packet_lengths, with_tile_cod_disabling_sop_eph, + with_tile_parts_interleaved, }; const WIDTH: u32 = 70; @@ -421,6 +422,53 @@ fn cuda_decodes_honor_a_tile_cod_that_disables_sop_and_eph() { assert_parity(Tiles::One, cases); } +#[test] +fn cuda_decodes_validate_sop_fields_only_in_packets_the_cpu_parses() { + if !cuda_runtime_and_strict_oxide_gate(module_path!()) { + return; + } + check_all(|kind| { + let clean = kind.encode(&EncodeOptions { + progression_order: EncodeProgressionOrder::Lrcp, + write_plt: true, + write_sop: true, + tile_part_packet_limit: Some(1), + ..fixture_options() + }); + let discarded_finest = usize::from(LEVELS) * kind.channels(); + vec![ + Case { + label: "invalid Lsop in the first packet", + edited: with_sop_marker_fields(&clean, 0, 3, 0), + clean: clean.clone(), + full: Expect::Fails, + half: Expect::Fails, + }, + Case { + label: "invalid first Nsop", + edited: with_sop_marker_fields(&clean, 0, 4, 1), + clean: clean.clone(), + full: Expect::Fails, + half: Expect::Fails, + }, + Case { + label: "Nsop restarts in the second tile-part", + edited: with_sop_marker_fields(&clean, 1, 4, 0), + clean: clean.clone(), + full: Expect::Fails, + half: Expect::Fails, + }, + Case { + label: "invalid Nsop in a discarded finest packet", + edited: with_sop_marker_fields(&clean, discarded_finest, 4, u16::MAX), + clean, + full: Expect::Fails, + half: Expect::Decodes, + }, + ] + }); +} + /// The position of the first `SOD` marker of a one-tile-part codestream. fn sod(codestream: &[u8]) -> usize { codestream diff --git a/crates/j2k-metal/tests/device/packet_walk_parity.rs b/crates/j2k-metal/tests/device/packet_walk_parity.rs index 1acf8722..26eb0c74 100644 --- a/crates/j2k-metal/tests/device/packet_walk_parity.rs +++ b/crates/j2k-metal/tests/device/packet_walk_parity.rs @@ -11,7 +11,8 @@ use j2k_native::{DecodeErrorClass, EncodeProgressionOrder}; use j2k_test_support::{ add_packet_length, edit_tile_parts, packed_packet_headers, packet_ranges, with_byte_after_last_packets, with_packet_lengths_edited, with_packets_filled, - with_surplus_packet_lengths, with_tile_cod_disabling_sop_eph, with_tile_parts_interleaved, + with_sop_marker_fields, with_surplus_packet_lengths, with_tile_cod_disabling_sop_eph, + with_tile_parts_interleaved, }; const WIDTH: u32 = 96; @@ -423,6 +424,78 @@ fn tile_cod_can_disable_sop_and_eph_on_metal_as_on_the_cpu() { assert!(failures.is_empty(), "{}", failures.join("\n")); } +#[test] +fn sop_fields_are_validated_on_metal_only_in_packets_the_cpu_parses() { + if !should_run_metal_runtime() { + return; + } + + let session = MetalBackendSession::system_default().expect("Metal session"); + let mut failures = Vec::new(); + for fixture in FIXTURES.into_iter().filter(|fixture| !fixture.tiled) { + let options = EncodeOptions { + write_sop: true, + tile_part_packet_limit: Some(1), + ..fixture.options() + }; + let clean = fixture.codestream(&options); + let companion = fixture.companion(&options); + let discarded_finest = usize::from(LEVELS) * usize::from(fixture.components); + let cases = [ + ( + "invalid Lsop in the first packet", + with_sop_marker_fields(&clean, 0, 3, 0), + false, + ), + ( + "invalid first Nsop", + with_sop_marker_fields(&clean, 0, 4, 1), + false, + ), + ( + "Nsop restarts in the second tile-part", + with_sop_marker_fields(&clean, 1, 4, 0), + false, + ), + ( + "invalid Nsop in a discarded finest packet", + with_sop_marker_fields(&clean, discarded_finest, 4, u16::MAX), + true, + ), + ]; + for &fmt in fixture.formats() { + for (label, edited, half_decodes) in &cases { + for scale in [Downscale::None, Downscale::Half] { + if *half_decodes && scale == Downscale::Half { + let expected = + cpu_decode(&clean, fmt, scale).expect("clean CPU half decode"); + check( + &format!("{fixture:?} {label}"), + (edited, &companion), + fmt, + scale, + &Expected::Decodes(&expected), + &session, + &mut failures, + ); + } else { + check( + &format!("{fixture:?} {label}"), + (edited, &companion), + fmt, + scale, + &Expected::Fails, + &session, + &mut failures, + ); + } + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + /// Data after the tile's last packet in each tile-part, bytes or PLT /// lengths past it, is no packet's: every Metal entry point decodes such a /// codestream, at full and half resolution, to the CPU decode of the diff --git a/crates/j2k-native/src/j2c/segment.rs b/crates/j2k-native/src/j2c/segment.rs index 2405e98c..b833d53c 100644 --- a/crates/j2k-native/src/j2c/segment.rs +++ b/crates/j2k-native/src/j2c/segment.rs @@ -34,7 +34,6 @@ struct PacketWalk<'d> { recording: Option<&'d mut Vec>, #[cfg(feature = "std")] indexed: Option<&'d directory::PacketDirectory>, - #[cfg(feature = "std")] next_packet: usize, marker: core::marker::PhantomData<&'d ()>, } @@ -398,13 +397,6 @@ fn parse_inner<'a>( storage: &mut DecompositionStorage<'a>, skips: &mut PacketSkips, mut relevance: Option<&mut PacketRelevance<'_>>, - #[cfg_attr( - not(feature = "std"), - expect( - unused_variables, - reason = "without std there is no packet directory for the walk to record" - ) - )] walk: &mut PacketWalk<'_>, strict: bool, ) -> PacketResult { @@ -420,10 +412,10 @@ fn parse_inner<'a>( lwarn!("tile-part data continues after the tile's last packet"); return Ok(false); }; + let ordinal = walk.next_packet; + walk.next_packet = ordinal.checked_add(1).ok_or("packet ordinal overflows")?; #[cfg(feature = "std")] - let ordinal = { - let ordinal = walk.next_packet; - walk.next_packet = ordinal.checked_add(1).ok_or("packet ordinal overflows")?; + { if let Some(directory) = walk.indexed { if relevance.as_ref().is_some_and(|relevance| { matches!( @@ -444,8 +436,7 @@ fn parse_inner<'a>( continue; } } - ordinal - }; + } let resolution = progression_data.resolution; let precinct_index = usize::try_from(progression_data.precinct) .map_err(|_| "packet precinct index does not fit the platform")?; @@ -496,13 +487,10 @@ fn parse_inner<'a>( skipped }; progression_iterator.skip_precinct_layers(further); - #[cfg(feature = "std")] - { - walk.next_packet = walk - .next_packet - .checked_add(further) - .ok_or("packet ordinal overflows")?; - } + walk.next_packet = walk + .next_packet + .checked_add(further) + .ok_or("packet ordinal overflows")?; if !complete { return Err("PLT packet length runs past the tile-part"); } @@ -538,12 +526,7 @@ fn parse_inner<'a>( if component_info.coding_style.flags.may_use_sop_markers() && body_reader.peek_marker() == Some(SOP) { - body_reader - .read_marker() - .map_err(|_| "SOP marker is truncated")?; - body_reader - .skip_bytes(4) - .ok_or("SOP marker segment is truncated")?; + parse_sop_marker(body_reader, ordinal)?; } let header_reader = tile_part.header(); @@ -694,6 +677,32 @@ fn parse_inner<'a>( Ok(false) } +fn parse_sop_marker(body_reader: &mut BitReader<'_>, packet_ordinal: usize) -> PacketResult { + if body_reader + .read_marker() + .map_err(|_| "SOP marker is truncated")? + != SOP + { + return Err("packet does not start with an SOP marker"); + } + let length = body_reader + .read_u16() + .ok_or("SOP marker segment is truncated")?; + if length != 4 { + return Err("SOP marker segment length is not 4"); + } + let sequence = body_reader + .read_u16() + .ok_or("SOP marker segment is truncated")?; + let modulus = usize::from(u16::MAX) + 1; + let expected = u16::try_from(packet_ordinal % modulus) + .map_err(|_| "SOP packet sequence does not fit u16")?; + if sequence != expected { + return Err("SOP marker sequence number disagrees with the packet ordinal"); + } + Ok(()) +} + fn try_push_segment_with_budget<'a>( segments: &mut Vec>, structural_workspace_bytes: usize, @@ -975,6 +984,22 @@ mod tests { use super::*; use alloc::vec::Vec; + #[test] + fn sop_sequence_number_wraps_after_u16_max() { + for (ordinal, sequence) in [ + (0, 0_u16), + (usize::from(u16::MAX), u16::MAX), + (usize::from(u16::MAX) + 1, 0), + (usize::from(u16::MAX) + 2, 1), + ] { + let sequence = sequence.to_be_bytes(); + let bytes = [0xff, SOP, 0, 4, sequence[0], sequence[1]]; + let mut reader = BitReader::new(&bytes); + assert_eq!(parse_sop_marker(&mut reader, ordinal), Ok(())); + assert!(reader.at_end()); + } + } + /// The original chain of separate peeks, as the reference for /// `decode_num_coding_passes`. fn coding_passes_by_peeks(reader: &mut BitReader<'_>) -> Option { diff --git a/crates/j2k-native/tests/tile_header_markers.rs b/crates/j2k-native/tests/tile_header_markers.rs index 406bd611..5ae3e736 100644 --- a/crates/j2k-native/tests/tile_header_markers.rs +++ b/crates/j2k-native/tests/tile_header_markers.rs @@ -4,7 +4,8 @@ use std::path::Path; use j2k_native::{ encode, encode_with_roi_regions, ComponentPlane, ComponentRowSink, CpuDecodeParallelism, - DecodeSettings, DecoderContext, EncodeError, EncodeOptions, EncodeRoiRegion, Image, RawBitmap, + DecodeSettings, DecoderContext, EncodeError, EncodeOptions, EncodeProgressionOrder, + EncodeRoiRegion, Image, RawBitmap, }; fn marker_offset(codestream: &[u8], marker: u8) -> usize { @@ -368,6 +369,7 @@ const COC: u8 = 0x53; const QCD: u8 = 0x5C; const QCC: u8 = 0x5D; const SOT: u8 = 0x90; +const SOP: u8 = 0x91; #[derive(Clone, Copy, Debug)] struct Style { @@ -749,6 +751,163 @@ fn assert_decode_differs(patched: &[u8], original: &[u8], case: &str) { const SINGLE_TILE: (u32, u32) = (96, 64); +fn sop_offsets(codestream: &[u8]) -> Vec { + codestream + .windows(2) + .enumerate() + .filter_map(|(at, marker)| (marker == [0xff, SOP]).then_some(at)) + .collect() +} + +fn with_sop_fields(codestream: &[u8], packet: usize, length: u16, sequence: u16) -> Vec { + let at = sop_offsets(codestream)[packet]; + let mut edited = codestream.to_vec(); + edited[at + 2..at + 4].copy_from_slice(&length.to_be_bytes()); + edited[at + 4..at + 6].copy_from_slice(&sequence.to_be_bytes()); + edited +} + +fn without_first_sop(codestream: &[u8]) -> Vec { + let sot = marker_offset(codestream, SOT); + let psot = u32::from_be_bytes( + codestream[sot + 6..sot + 10] + .try_into() + .expect("Psot is four bytes"), + ); + let sop = sop_offsets(codestream)[0]; + let mut edited = codestream.to_vec(); + edited.drain(sop..sop + 6); + edited[sot + 6..sot + 10].copy_from_slice(&(psot - 6).to_be_bytes()); + edited +} + +fn assert_sop_decode_fails(codestream: &[u8], case: &str) { + let image = Image::new(codestream, &DecodeSettings::default()).expect("parse codestream"); + let Err(error) = image.decode_native() else { + panic!("{case}: invalid SOP marker decoded"); + }; + assert!( + format!("{error}: {error:?}").contains("SOP marker"), + "{case}: {error:?}" + ); +} + +#[test] +fn sop_markers_validate_length_and_tile_wide_sequence_when_parsed() { + let (width, height) = SINGLE_TILE; + let pixels = textured_pixels(width, height, 1); + let options = EncodeOptions { + reversible: true, + num_decomposition_levels: 2, + num_layers: 2, + write_plt: true, + write_sop: true, + tile_part_packet_limit: Some(1), + ..EncodeOptions::default() + }; + let original = encode(&pixels, width, height, 1, 8, false, &options).expect("encode fixture"); + let offsets = sop_offsets(&original); + assert_eq!(offsets.len(), 6, "two layers over three resolutions"); + for (packet, &at) in offsets.iter().enumerate() { + assert_eq!(&original[at + 2..at + 4], &[0, 4], "Lsop"); + assert_eq!( + u16::from_be_bytes([original[at + 4], original[at + 5]]), + u16::try_from(packet).expect("small packet ordinal"), + "Nsop" + ); + } + + assert_sop_decode_fails(&with_sop_fields(&original, 0, 3, 0), "bad Lsop"); + assert_sop_decode_fails( + &with_sop_fields(&original, 0, 4, 1), + "first Nsop is not zero", + ); + assert_sop_decode_fails( + &with_sop_fields(&original, 1, 4, 0), + "Nsop restarts in the second tile-part", + ); + + // SOP markers are optional packet by packet. Packet 1 keeps Nsop 1 when + // packet 0 has no SOP, so the sequence counts packets, not SOP markers. + let without_plt = encode( + &pixels, + width, + height, + 1, + 8, + false, + &EncodeOptions { + write_plt: false, + ..options.clone() + }, + ) + .expect("encode fixture without PLT"); + let missing_first_sop = without_first_sop(&without_plt); + let expected = Image::new(&without_plt, &DecodeSettings::default()) + .expect("parse clean codestream") + .decode_native() + .expect("decode clean codestream"); + let actual = Image::new(&missing_first_sop, &DecodeSettings::default()) + .expect("parse codestream without its first SOP") + .decode_native() + .expect("Nsop still counts the packet without an SOP"); + assert_eq!(actual.data, expected.data); + + // In LRCP order packet 2 is the discarded finest-resolution packet of + // layer 0. A half-resolution decode skips it by PLT length, then parses + // packet 3: the skipped marker is not validated, but still counts. + let damaged_discarded = with_sop_fields(&original, 2, 4, 99); + assert_sop_decode_fails(&damaged_discarded, "bad Nsop in a full decode"); + let settings = DecodeSettings { + target_resolution: Some((width / 2, height / 2)), + ..DecodeSettings::default() + }; + let expected = Image::new(&original, &settings) + .expect("parse clean codestream") + .decode_native() + .expect("decode clean half resolution"); + let actual = Image::new(&damaged_discarded, &settings) + .expect("parse edited codestream") + .decode_native() + .expect("skip the discarded packet without validating its SOP"); + assert_eq!(actual.data, expected.data); + + // CPRL puts both layers of a component-resolution precinct together. A + // half decode skips C0R2L0 and C0R2L1 in one PLT operation, then parses + // C1R0L0 as packet 6: both skipped packets must advance its expected Nsop. + let cprl_pixels = textured_pixels(width, height, 3); + let cprl = encode( + &cprl_pixels, + width, + height, + 3, + 8, + false, + &EncodeOptions { + progression_order: EncodeProgressionOrder::Cprl, + tile_part_packet_limit: None, + ..options + }, + ) + .expect("encode CPRL fixture"); + let cprl_sops = sop_offsets(&cprl); + assert_eq!( + u16::from_be_bytes([cprl[cprl_sops[6] + 4], cprl[cprl_sops[6] + 5]]), + 6, + "C1R0L0 Nsop counts both discarded C0R2 packets" + ); + let cprl_damaged_discarded = with_sop_fields(&cprl, 5, 4, u16::MAX); + let expected = Image::new(&cprl, &settings) + .expect("parse clean CPRL codestream") + .decode_native() + .expect("decode clean CPRL half resolution"); + let actual = Image::new(&cprl_damaged_discarded, &settings) + .expect("parse edited CPRL codestream") + .decode_native() + .expect("skip both discarded CPRL packets and count both"); + assert_eq!(actual.data, expected.data); +} + #[test] fn tile_part_cod_can_disable_sop_and_eph() { let original = encode_fixture(SINGLE_TILE, 3, REVERSIBLE, None); diff --git a/crates/j2k-test-support/src/lib.rs b/crates/j2k-test-support/src/lib.rs index 212ea52c..32781d4d 100644 --- a/crates/j2k-test-support/src/lib.rs +++ b/crates/j2k-test-support/src/lib.rs @@ -83,7 +83,8 @@ pub use metal::{metal_device_unavailable_is_skip, metal_runtime_gate, metal_runt pub use packet_edits::{ add_packet_length, edit_tile_parts, packed_packet_headers, packet_ranges, with_byte_after_last_packets, with_packet_lengths_edited, with_packets_filled, - with_surplus_packet_lengths, with_tile_cod_disabling_sop_eph, with_tile_parts_interleaved, + with_sop_marker_fields, with_surplus_packet_lengths, with_tile_cod_disabling_sop_eph, + with_tile_parts_interleaved, }; pub use pixels::{ crop_interleaved_bytes, crop_interleaved_u16, project_scaled_interleaved_u16, diff --git a/crates/j2k-test-support/src/packet_edits.rs b/crates/j2k-test-support/src/packet_edits.rs index 14f413b5..2df10403 100644 --- a/crates/j2k-test-support/src/packet_edits.rs +++ b/crates/j2k-test-support/src/packet_edits.rs @@ -9,6 +9,7 @@ use std::ops::Range; const SOT: [u8; 2] = [0xff, 0x90]; +const SOP: [u8; 2] = [0xff, 0x91]; const SOD: [u8; 2] = [0xff, 0x93]; const COD: [u8; 2] = [0xff, 0x52]; const PLT: [u8; 2] = [0xff, 0x58]; @@ -112,6 +113,34 @@ pub fn with_tile_cod_disabling_sop_eph(codestream: &[u8]) -> Vec { edited } +/// `codestream` with the SOP marker segment of packet `packet` changed to +/// `length` and `sequence` without moving packet bytes. +/// +/// # Panics +/// +/// When the packet does not exist or does not start with a complete SOP +/// marker segment. +#[must_use] +pub fn with_sop_marker_fields( + codestream: &[u8], + packet: usize, + length: u16, + sequence: u16, +) -> Vec { + let range = packet_ranges(codestream) + .into_iter() + .nth(packet) + .unwrap_or_else(|| panic!("fixture has no packet {packet}")); + let at = range.start; + assert!(range.end >= at + 6, "packet holds a complete SOP marker"); + assert_eq!(codestream[at..at + 2], SOP, "packet starts with SOP"); + + let mut edited = codestream.to_vec(); + edited[at + 2..at + 4].copy_from_slice(&length.to_be_bytes()); + edited[at + 4..at + 6].copy_from_slice(&sequence.to_be_bytes()); + edited +} + /// `codestream` with a zero byte after the last packet of each tile-part, /// inside it. #[must_use] @@ -377,6 +406,21 @@ mod tests { part } + #[test] + fn sop_field_edits_leave_the_packet_in_place() { + let main = [0xff, 0x4f, 0xff, 0x51, 0, 3, 0]; + let plt = [0xff, 0x58, 0, 4, 0, 7]; + let packet = [0xff, 0x91, 0, 4, 0, 0, 0]; + let mut bytes = main.to_vec(); + bytes.extend_from_slice(&tile_part(0, &plt, &packet)); + bytes.extend_from_slice(&EOC); + + let edited = with_sop_marker_fields(&bytes, 0, 3, 42); + let range = packet_ranges(&edited).pop().expect("one packet"); + assert_eq!(&edited[range], &[0xff, 0x91, 0, 3, 0, 42, 0]); + assert_eq!(edited.len(), bytes.len()); + } + #[test] fn interleaving_lets_tiles_take_turns() { let main = [0xff, 0x4f, 0xff, 0x51, 0, 3, 0]; From 9e8b6690f8af59858254d0e79952dd309275f4a8 Mon Sep 17 00:00:00 2001 From: GF Date: Sun, 1 Nov 2026 19:08:14 -0500 Subject: [PATCH 30/30] refactor(j2k): share batch output and streaming test drivers Borrow split prepared groups and share checked native-output sizing, plane validation and stores. Remove redundant owned-batch admission claims while preserving the staged-window budget and direct-ROI admission. Reuse the streaming parity driver with typed errors and padding checks. --- CHANGELOG.md | 5 +- crates/j2k/src/batch/admission.rs | 17 -- crates/j2k/src/batch/admission/tests.rs | 14 - crates/j2k/src/owned_batch.rs | 4 +- crates/j2k/src/owned_batch/contracts.rs | 25 ++ crates/j2k/src/owned_batch/cpu.rs | 53 +--- crates/j2k/src/owned_batch/cpu_execute.rs | 29 +- crates/j2k/src/owned_batch/cpu_fast.rs | 8 +- .../j2k/src/owned_batch/cpu_fast/classic.rs | 6 +- crates/j2k/src/owned_batch/cpu_fast/ht.rs | 12 +- crates/j2k/src/owned_batch/cpu_fast/plan.rs | 4 +- .../j2k/src/owned_batch/cpu_fast/plan/ht.rs | 4 +- crates/j2k/src/owned_batch/cpu_materialize.rs | 252 ++++++++++++------ crates/j2k/src/owned_batch/cpu_prepared.rs | 88 ++---- .../j2k/src/owned_batch/cpu_staged_execute.rs | 25 +- crates/j2k/src/owned_batch/prepare.rs | 19 +- .../prepare/cpu_preflight_tests.rs | 3 + crates/j2k/src/owned_batch/prepared.rs | 18 +- crates/j2k/tests/full_image_streaming.rs | 68 +---- .../tests/streamed_sixteen_bit_and_native.rs | 65 +---- .../j2k/tests/support/full_image_streaming.rs | 75 ++++++ 21 files changed, 385 insertions(+), 409 deletions(-) create mode 100644 crates/j2k/tests/support/full_image_streaming.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b5a83ea..6284bfdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -259,9 +259,8 @@ Staged workspace version: `0.13.2`. image's output exceeds the allocation cap. On either preparation path, a group whose combined output exceeds that cap is decoded as several result groups, each with an output buffer within the cap. - Staged windows are admitted by planned execution memory, and an admission - claim that cannot fit beside the shared plan fails instead of waiting - indefinitely. Successful output is unchanged. + Staged windows stay within the budget computed from their planned + execution memory. Successful output is unchanged. - 16-bit full-image decodes (`J2kDecoder::decode_into`, `decode_scaled_into` and the decoders behind `decode_tiles_into` at `Rgb16`, `Rgba16` and `Gray16`) and `J2kDecoder::decode_native_components` whose component diff --git a/crates/j2k/src/batch/admission.rs b/crates/j2k/src/batch/admission.rs index beec297f..fa57a3be 100644 --- a/crates/j2k/src/batch/admission.rs +++ b/crates/j2k/src/batch/admission.rs @@ -158,23 +158,6 @@ impl BatchAllocationClaim { } } -/// Keep `held` at least `bytes`: one owner's claim at the largest planned -/// execution it has run, because its retained scratch keeps that peak while -/// later executions reuse it. A smaller claim is released before waiting for -/// the larger one, so an owner never holds two. -pub(crate) fn hold_at_least( - budget: &Arc, - held: &mut Option, - bytes: usize, -) -> Result<(), BatchInfrastructureError> { - if held.as_ref().is_some_and(|claim| claim.bytes >= bytes) { - return Ok(()); - } - *held = None; - *held = Some(budget.claim(bytes)?); - Ok(()) -} - impl Drop for BatchAllocationClaim { fn drop(&mut self) { let mut state = self.budget.lock_state(); diff --git a/crates/j2k/src/batch/admission/tests.rs b/crates/j2k/src/batch/admission/tests.rs index f3c3f258..d12df4b6 100644 --- a/crates/j2k/src/batch/admission/tests.rs +++ b/crates/j2k/src/batch/admission/tests.rs @@ -143,17 +143,3 @@ fn poisoned_admission_state_is_a_typed_infrastructure_error() { Err(BatchInfrastructureError::SchedulerPoisoned) )); } - -#[test] -fn held_claim_grows_to_the_largest_execution_and_never_doubles() { - let budget = BatchAllocationBudget::with_baseline(0).expect("budget"); - let mut held = None; - hold_at_least(&budget, &mut held, 100).expect("first claim"); - assert_eq!(budget.lock_state().live, 100); - hold_at_least(&budget, &mut held, 40).expect("smaller execution reuses the claim"); - assert_eq!(budget.lock_state().live, 100); - hold_at_least(&budget, &mut held, 300).expect("larger execution grows the claim"); - assert_eq!(budget.lock_state().live, 300); - drop(held); - assert_eq!(budget.lock_state().live, 0); -} diff --git a/crates/j2k/src/owned_batch.rs b/crates/j2k/src/owned_batch.rs index 2e875afe..5d39b0c0 100644 --- a/crates/j2k/src/owned_batch.rs +++ b/crates/j2k/src/owned_batch.rs @@ -37,7 +37,9 @@ pub use self::errors::{ mod prepared; pub use self::prepared::{BatchDecoder, PreparedBatch, PreparedBatchGroup, PreparedImage}; -use self::prepared::{BatchExecutionShape, CpuExecution, PreparedCodecPlan, PreparedImageInner}; +use self::prepared::{ + BatchExecutionShape, CpuExecution, PreparedBatchGroupRef, PreparedCodecPlan, PreparedImageInner, +}; mod prepared_plan; pub use self::prepared_plan::{ diff --git a/crates/j2k/src/owned_batch/contracts.rs b/crates/j2k/src/owned_batch/contracts.rs index a0a16994..c5dba0db 100644 --- a/crates/j2k/src/owned_batch/contracts.rs +++ b/crates/j2k/src/owned_batch/contracts.rs @@ -190,6 +190,22 @@ impl BatchGroupInfo { .checked_mul(self.color.channels()) } + /// Bytes in one native sample represented by this group. + pub(super) const fn sample_bytes(&self) -> Option { + match self.sample_type { + SampleType::U8 => Some(1), + SampleType::U16 | SampleType::I16 => Some(2), + _ => None, + } + } + + /// Bytes in the dense native output of `image_count` images. + pub(super) fn output_bytes(&self, image_count: usize) -> Option { + self.samples_per_image()? + .checked_mul(image_count)? + .checked_mul(self.sample_bytes()?) + } + /// Exact native integer pixel format represented by this group. /// /// `None` reports a newer color/sample contract or metadata that does not @@ -347,4 +363,13 @@ mod tests { assert_eq!(info.native_pixel_format(), None); } } + + #[test] + fn native_output_bytes_are_checked_once_from_group_metadata() { + let mut info = group_info(PixelLayout::Rgb, SampleType::U16, 12, false); + assert_eq!(info.output_bytes(3), Some(8 * 8 * 3 * 2 * 3)); + + info.dimensions = (u32::MAX, u32::MAX); + assert_eq!(info.output_bytes(usize::MAX), None); + } } diff --git a/crates/j2k/src/owned_batch/cpu.rs b/crates/j2k/src/owned_batch/cpu.rs index bc36a6d6..3d1a7a2c 100644 --- a/crates/j2k/src/owned_batch/cpu.rs +++ b/crates/j2k/src/owned_batch/cpu.rs @@ -7,10 +7,9 @@ use super::{ prepare_batch_with_workers, BatchDecodeOptions, BatchDecoder, BatchGroupInfo, BatchInfrastructureError, BatchWorker, CpuBatchWorkspaceStats, CpuGroupFastWorkspace, CpuStagedWorkspace, CpuStreamPool, EncodedImage, IndexedBatchError, J2kDecodeWarning, Mutex, - NativeSampleType, NonZeroUsize, PrepareFor, PreparedBatch, PreparedBatchGroup, PreparedImage, - Rect, Vec, + NativeSampleType, NonZeroUsize, PrepareFor, PreparedBatch, PreparedBatchGroup, + PreparedBatchGroupRef, PreparedImage, Rect, Vec, }; -use crate::batch::admission::BatchAllocationBudget; use crate::batch::allocation::try_vec_with_capacity; use crate::batch::scheduler::decode_retained_tiles; use crate::{DecodeOutcome, PixelFormat, TileBatchError, TileBatchOptions, TileDecodeJob}; @@ -416,27 +415,8 @@ impl CpuBatchDecoder { try_vec_with_capacity(prepared.groups().len(), "J2K owned batch result groups")?; let mut errors = try_vec_with_capacity(input_count, "J2K owned batch indexed errors")?; errors.extend_from_slice(prepared.errors()); - // Every execution in flight holds a claim sized from its prepared - // plan, under the same aggregate cap as the tile-batch facade. - let admission = BatchAllocationBudget::with_baseline(0)?; for group in prepared.groups() { let images_per_group = output_images_per_group(group); - if images_per_group >= group.images.len() { - if let Some(decoded) = decode_cpu_group( - workers, - &mut self.fast_workspace, - &mut self.staged_workspace, - stream_pool, - group, - prepared.options, - self.options.workers, - &admission, - &mut errors, - )? { - push_group(&mut groups, decoded)?; - } - continue; - } // One dense buffer for the whole group would exceed the // per-allocation cap: decode consecutive images as several // groups with the same info, each buffer within the cap. @@ -445,30 +425,19 @@ impl CpuBatchDecoder { .chunks(images_per_group) .zip(group.source_indices.chunks(images_per_group)) { - let mut part_images = - try_vec_with_capacity(images.len(), "J2K owned batch output group images")?; - part_images.extend(images.iter().cloned()); - let mut part_indices = try_vec_with_capacity( - source_indices.len(), - "J2K owned batch output group source indices", - )?; - part_indices.extend_from_slice(source_indices); - let part = PreparedBatchGroup { - info: group.info.clone(), - options: group.options, - execution_shape: group.execution_shape, - images: part_images, - source_indices: part_indices, + let part = PreparedBatchGroupRef { + info: &group.info, + images, + source_indices, }; if let Some(decoded) = decode_cpu_group( workers, &mut self.fast_workspace, &mut self.staged_workspace, stream_pool, - &part, + part, prepared.options, self.options.workers, - &admission, &mut errors, )? { push_group(&mut groups, decoded)?; @@ -484,15 +453,9 @@ impl CpuBatchDecoder { /// (at least one). An image whose own output exceeds the cap still fails in /// its own group, as before. fn output_images_per_group(group: &PreparedBatchGroup) -> usize { - let sample_bytes = match group.info.sample_type { - NativeSampleType::U8 => 1, - NativeSampleType::U16 | NativeSampleType::I16 => 2, - _ => return group.images.len(), - }; group .info - .samples_per_image() - .and_then(|samples| samples.checked_mul(sample_bytes)) + .output_bytes(1) .map_or(group.images.len(), |image_bytes| { images_within_cap(image_bytes, j2k_core::DEFAULT_MAX_HOST_ALLOCATION_BYTES) }) diff --git a/crates/j2k/src/owned_batch/cpu_execute.rs b/crates/j2k/src/owned_batch/cpu_execute.rs index 7b87c796..cfa5a930 100644 --- a/crates/j2k/src/owned_batch/cpu_execute.rs +++ b/crates/j2k/src/owned_batch/cpu_execute.rs @@ -13,26 +13,24 @@ use super::{ run_retained_chunks, Arc, BatchCodecRoute, BatchColor, BatchDecodeOptions, BatchErrorStage, BatchGroupInfo, BatchInfrastructureError, BatchItemError, BatchWorker, CpuBatchGroup, CpuBatchSamples, CpuDecodeParallelism, CpuGroupFastWorkspace, CpuStagedWorkspace, - IndexedBatchError, J2kError, NativeSampleType, NonZeroUsize, PreparedBatchGroup, PreparedImage, - TileBatchOptions, Vec, + IndexedBatchError, J2kError, NativeSampleType, NonZeroUsize, PreparedBatchGroupRef, + PreparedImage, TileBatchOptions, Vec, }; -use crate::batch::admission::{hold_at_least, BatchAllocationBudget}; use crate::batch::allocation::try_vec_with_capacity; #[expect( clippy::too_many_lines, clippy::too_many_arguments, - reason = "the typed output allocation and one shared decode/compaction boundary remain together so every native sample type follows the same group-level ownership rules; retained workspaces and admission stay explicit" + reason = "the typed output allocation and one shared decode/compaction boundary remain together so every native sample type follows the same group-level ownership rules and retained workspaces stay explicit" )] pub(super) fn decode_cpu_group( workers: &mut [BatchWorker], fast_workspace: &mut CpuGroupFastWorkspace, staged_workspace: &mut CpuStagedWorkspace, stream_pool: &mut CpuStreamPool, - group: &PreparedBatchGroup, + group: PreparedBatchGroupRef<'_>, options: BatchDecodeOptions, workers_option: Option, - admission: &Arc, errors: &mut Vec, ) -> Result, BatchInfrastructureError> { let tile_options = TileBatchOptions::new(workers_option); @@ -79,7 +77,7 @@ pub(super) fn decode_cpu_group( cap: j2k_core::DEFAULT_MAX_HOST_ALLOCATION_BYTES, }, )?; - ensure_batch_output_within_cap(sample_count, group.info.sample_type)?; + ensure_batch_output_within_cap(group.info, group.images.len())?; let (samples, successful_slots, copied_samples) = match group.info.sample_type { NativeSampleType::U8 => { @@ -95,7 +93,6 @@ pub(super) fn decode_cpu_group( staged_workspace, stream_pool, image_parallelism, - admission, // The only image of a Gray8 group is the whole in-flight set. if group.images.len() == 1 && group.info.color == BatchColor::Gray { decode_lone_gray_u8 @@ -124,7 +121,6 @@ pub(super) fn decode_cpu_group( staged_workspace, stream_pool, image_parallelism, - admission, decode_image_u16, convert_u16, errors, @@ -148,7 +144,6 @@ pub(super) fn decode_cpu_group( staged_workspace, stream_pool, image_parallelism, - admission, decode_image_i16, round_signed, errors, @@ -297,7 +292,7 @@ type CpuDecodeFn = for<'image> fn( )] fn run_typed_group( workers: &mut [BatchWorker], - group: &PreparedBatchGroup, + group: PreparedBatchGroupRef<'_>, options: BatchDecodeOptions, tile_options: TileBatchOptions, samples_per_image: usize, @@ -306,7 +301,6 @@ fn run_typed_group( staged_workspace: &mut CpuStagedWorkspace, stream_pool: Option<&rayon::ThreadPool>, image_parallelism: Option, - admission: &Arc, decode: CpuDecodeFn, convert: fn(f32, u8) -> T, errors: &mut Vec, @@ -335,7 +329,6 @@ fn run_typed_group( &mut results, flattened, staged_workspace, - admission, convert, )?; } else { @@ -357,14 +350,8 @@ fn run_typed_group( let parallelism = worker.prepare_owned_decode(); let workspace = worker.take_native_workspace(); let mut context = j2k_native::DecoderContext::from_workspace(workspace); - let mut claim = None; for (job, slot) in jobs.iter_mut().zip(results) { context.set_shared_pool(shared_pool && job.image.bytes().len() <= shared_bytes); - hold_at_least( - admission, - &mut claim, - job.image.cpu_execution().claim_bytes(), - )?; *slot = Some(match stream_pool { // This thread waits while a pool thread decodes the // image and idle ones take its bands. @@ -372,7 +359,7 @@ fn run_typed_group( decode( job.image, options, - &group.info, + group.info, CpuDecodeParallelism::Auto, &mut context, worker, @@ -382,7 +369,7 @@ fn run_typed_group( None => decode( job.image, options, - &group.info, + group.info, parallelism, &mut context, worker, diff --git a/crates/j2k/src/owned_batch/cpu_fast.rs b/crates/j2k/src/owned_batch/cpu_fast.rs index b5786127..6c959c4f 100644 --- a/crates/j2k/src/owned_batch/cpu_fast.rs +++ b/crates/j2k/src/owned_batch/cpu_fast.rs @@ -11,7 +11,7 @@ use j2k_native::{ }; use super::cpu_staged::{staged_window_fits, CPU_ENTROPY_IMAGES_PER_WINDOW}; -use super::{BatchCodecRoute, PreparedBatchGroup, PreparedImage}; +use super::{BatchCodecRoute, PreparedBatchGroupRef, PreparedImage}; use crate::batch::allocation::{try_vec_with_capacity, J2K_BATCH_METADATA_ALLOWANCE_BYTES}; mod classic; @@ -149,7 +149,7 @@ pub(super) struct CpuGroupFastWorkspace { impl CpuGroupFastWorkspace { pub(super) fn prepare_group( &mut self, - group: &PreparedBatchGroup, + group: PreparedBatchGroupRef<'_>, ) -> Result { self.clear_active_plan(); let flattened = match group.info.route { @@ -292,11 +292,11 @@ impl CpuGroupFastWorkspace { fn assign_image_spans( &mut self, - group: &PreparedBatchGroup, + images: &[PreparedImage], payload_count: impl Fn(&PreparedImage) -> Result, ) -> Result<(), BatchInfrastructureError> { let mut start = 0usize; - for image in &group.images { + for image in images { let len = payload_count(image)?; self.image_spans.push(CpuImagePayloadSpan { start }); start = start diff --git a/crates/j2k/src/owned_batch/cpu_fast/classic.rs b/crates/j2k/src/owned_batch/cpu_fast/classic.rs index 8ad46298..d83f25f0 100644 --- a/crates/j2k/src/owned_batch/cpu_fast/classic.rs +++ b/crates/j2k/src/owned_batch/cpu_fast/classic.rs @@ -5,7 +5,7 @@ use j2k_core::BatchInfrastructureError; use j2k_native::{J2kClassicCodeBlockPayload, J2kCodestreamRange}; -use super::super::{BatchCodecRoute, PreparedBatchGroup, PreparedClassicPlan}; +use super::super::{BatchCodecRoute, PreparedBatchGroupRef, PreparedClassicPlan}; use super::plan::{ append_input_range, classic_group_requirements, empty_range, reserve_reused, visit_classic_jobs, }; @@ -14,7 +14,7 @@ use super::{CpuFlattenedPayloadJob, CpuGroupFastWorkspace, CpuPayloadBucket}; impl CpuGroupFastWorkspace { pub(super) fn prepare_classic( &mut self, - group: &PreparedBatchGroup, + group: PreparedBatchGroupRef<'_>, ) -> Result<(), BatchInfrastructureError> { let (payload_count, payload_bytes) = classic_group_requirements(group)?; self.prepare_storage::<(J2kClassicCodeBlockPayload, J2kCodestreamRange)>( @@ -41,7 +41,7 @@ impl CpuGroupFastWorkspace { }, ); self.classic_ranges.resize(payload_count, empty_range()); - self.assign_image_spans(group, |image| { + self.assign_image_spans(group.images, |image| { Ok(image .classic_plan() .map_or(0, PreparedClassicPlan::payload_count)) diff --git a/crates/j2k/src/owned_batch/cpu_fast/ht.rs b/crates/j2k/src/owned_batch/cpu_fast/ht.rs index 21573e77..a4c501e8 100644 --- a/crates/j2k/src/owned_batch/cpu_fast/ht.rs +++ b/crates/j2k/src/owned_batch/cpu_fast/ht.rs @@ -5,7 +5,7 @@ use j2k_core::BatchInfrastructureError; use j2k_native::HtCodeBlockPayloadRanges; -use super::super::{BatchCodecRoute, PreparedBatchGroup}; +use super::super::{BatchCodecRoute, PreparedBatchGroupRef}; use super::plan::{ append_input_range, empty_range, ht_bucket, ht_bucket_index, ht_group_requirements, reserve_reused, visit_ht_jobs, @@ -15,7 +15,7 @@ use super::{CpuFlattenedPayloadJob, CpuGroupFastWorkspace, CpuPayloadBucket}; impl CpuGroupFastWorkspace { pub(super) fn prepare_htj2k( &mut self, - group: &PreparedBatchGroup, + group: PreparedBatchGroupRef<'_>, ) -> Result<(), BatchInfrastructureError> { let (payload_count, payload_bytes) = ht_group_requirements(group)?; self.initialize_ht_storage(group, payload_count, payload_bytes)?; @@ -26,7 +26,7 @@ impl CpuGroupFastWorkspace { fn initialize_ht_storage( &mut self, - group: &PreparedBatchGroup, + group: PreparedBatchGroupRef<'_>, payload_count: usize, payload_bytes: usize, ) -> Result<(), BatchInfrastructureError> { @@ -47,7 +47,7 @@ impl CpuGroupFastWorkspace { refinement: None, }, ); - self.assign_image_spans(group, |image| { + self.assign_image_spans(group.images, |image| { let plan = image .htj2k_plan() .ok_or(BatchInfrastructureError::MissingResult { index: 0 })?; @@ -57,7 +57,7 @@ impl CpuGroupFastWorkspace { fn collect_ht_jobs( &mut self, - group: &PreparedBatchGroup, + group: PreparedBatchGroupRef<'_>, payload_count: usize, ) -> Result<(), BatchInfrastructureError> { for bucket in [ @@ -110,7 +110,7 @@ impl CpuGroupFastWorkspace { fn materialize_ht_payloads( &mut self, - group: &PreparedBatchGroup, + group: PreparedBatchGroupRef<'_>, ) -> Result<(), BatchInfrastructureError> { for job in &self.jobs { if group.source_indices.get(job.image_slot).copied() != Some(job.source_index) { diff --git a/crates/j2k/src/owned_batch/cpu_fast/plan.rs b/crates/j2k/src/owned_batch/cpu_fast/plan.rs index c2a39adf..462fee88 100644 --- a/crates/j2k/src/owned_batch/cpu_fast/plan.rs +++ b/crates/j2k/src/owned_batch/cpu_fast/plan.rs @@ -11,7 +11,7 @@ use j2k_native::{ J2kReferencedClassicPlan, }; -use super::super::{PreparedBatchGroup, PreparedImage}; +use super::super::{PreparedBatchGroupRef, PreparedImage}; use super::{CpuFlattenedPayloadJob, CpuImagePayloadSpan}; use crate::batch::allocation::J2K_BATCH_METADATA_ALLOWANCE_BYTES; @@ -19,7 +19,7 @@ mod ht; pub(super) use ht::{ht_bucket, ht_bucket_index, ht_group_requirements, visit_ht_jobs}; pub(super) fn classic_group_requirements( - group: &PreparedBatchGroup, + group: PreparedBatchGroupRef<'_>, ) -> Result<(usize, usize), BatchInfrastructureError> { let mut payload_count = 0usize; let mut payload_bytes = 0usize; diff --git a/crates/j2k/src/owned_batch/cpu_fast/plan/ht.rs b/crates/j2k/src/owned_batch/cpu_fast/plan/ht.rs index 7f37d628..b4eba0af 100644 --- a/crates/j2k/src/owned_batch/cpu_fast/plan/ht.rs +++ b/crates/j2k/src/owned_batch/cpu_fast/plan/ht.rs @@ -9,14 +9,14 @@ use j2k_native::{ }; use super::checked_add; -use crate::owned_batch::{cpu_fast::CpuPayloadBucket, PreparedBatchGroup}; +use crate::owned_batch::{cpu_fast::CpuPayloadBucket, PreparedBatchGroupRef}; #[cfg(test)] #[path = "ht_tests.rs"] mod tests; pub(in crate::owned_batch::cpu_fast) fn ht_group_requirements( - group: &PreparedBatchGroup, + group: PreparedBatchGroupRef<'_>, ) -> Result<(usize, usize), BatchInfrastructureError> { let mut payload_count = 0usize; let mut payload_bytes = 0usize; diff --git a/crates/j2k/src/owned_batch/cpu_materialize.rs b/crates/j2k/src/owned_batch/cpu_materialize.rs index 427f51fd..d29173ee 100644 --- a/crates/j2k/src/owned_batch/cpu_materialize.rs +++ b/crates/j2k/src/owned_batch/cpu_materialize.rs @@ -5,30 +5,25 @@ use super::{ backend, decode_prepared_plan_samples, size_of, BatchDecodeOptions, BatchGroupInfo, BatchInfrastructureError, BatchLayout, BatchWorker, CpuDecodeParallelism, CpuExecution, - J2kError, NativeSampleType, PreparedImage, Rect, Vec, + J2kError, PreparedImage, Rect, Vec, }; pub(super) fn ensure_batch_output_within_cap( - samples: usize, - sample_type: NativeSampleType, + info: &BatchGroupInfo, + image_count: usize, ) -> Result<(), BatchInfrastructureError> { - let width = match sample_type { - NativeSampleType::U8 => size_of::(), - NativeSampleType::U16 => size_of::(), - NativeSampleType::I16 => size_of::(), - _ => { - return Err(BatchInfrastructureError::UnsupportedContract { - what: "owned CPU batch sample width", - }); - } - }; - let bytes = samples - .checked_mul(width) - .ok_or(BatchInfrastructureError::AllocationTooLarge { - what: "J2K owned batch output", - requested: usize::MAX, - cap: j2k_core::DEFAULT_MAX_HOST_ALLOCATION_BYTES, - })?; + if info.sample_bytes().is_none() { + return Err(BatchInfrastructureError::UnsupportedContract { + what: "owned CPU batch sample width", + }); + } + let bytes = + info.output_bytes(image_count) + .ok_or(BatchInfrastructureError::AllocationTooLarge { + what: "J2K owned batch output", + requested: usize::MAX, + cap: j2k_core::DEFAULT_MAX_HOST_ALLOCATION_BYTES, + })?; if bytes > j2k_core::DEFAULT_MAX_HOST_ALLOCATION_BYTES { return Err(BatchInfrastructureError::AllocationTooLarge { what: "J2K owned batch output", @@ -147,11 +142,9 @@ fn decode_planned_u8<'image>( return Ok(()); } let components = image_components(image, options, parallelism, context)?; - validate_decoded_components(&components, info)?; - write_samples(&components, info, out, |sample, precision| { + write_decoded_samples(&components, info, out, |sample, precision| { u8::try_from(round_unsigned(sample, precision)).unwrap_or(u8::MAX) - }); - Ok(()) + }) } pub(super) fn decode_image_u16<'image>( @@ -172,11 +165,9 @@ pub(super) fn decode_image_u16<'image>( return Ok(()); } let components = image_components(image, options, parallelism, context)?; - validate_decoded_components(&components, info)?; - write_samples(&components, info, out, |sample, precision| { + write_decoded_samples(&components, info, out, |sample, precision| { u16::try_from(round_unsigned(sample, precision)).unwrap_or(u16::MAX) - }); - Ok(()) + }) } pub(super) fn decode_image_i16<'image>( @@ -195,14 +186,12 @@ pub(super) fn decode_image_i16<'image>( return Ok(()); } let components = image_components(image, options, parallelism, context)?; - validate_decoded_components(&components, info)?; - write_samples(&components, info, out, round_signed); - Ok(()) + write_decoded_samples(&components, info, out, round_signed) } /// Decode `image` through the bounded region decode, which hands over its /// rows as they are finished, writing each sample exactly as -/// [`write_samples`] writes whole planes, a row at a time through +/// [`write_batch_samples`] writes whole planes, a row at a time through /// `convert_row` (see [`convert_u8_row`]). Working memory stays bounded by /// the streamed decode instead of growing with the image. fn decode_streamed_samples<'image, T: Copy>( @@ -303,28 +292,44 @@ impl<'out, 'info, T: Copy> SampleRowSink<'out, 'info, T> { if planes.len() != channels || x + rect_width > width || y + rect_height > self.info.dimensions.1 as usize - || planes.iter().any(|plane| { - plane.dimensions() != rect_dimensions || plane.samples().len() < rect_pixels - }) { self.malformed = true; return; } - let precision = self.info.precision; - let planar = self.info.layout == BatchLayout::Nchw || channels == 1; + if planes.iter().enumerate().any(|(component, plane)| { + validate_sample_plane( + component, + plane.dimensions(), + plane.bit_depth(), + plane.samples().len(), + rect_dimensions, + self.info.precision, + rect_pixels, + ) + .is_err() + }) { + self.malformed = true; + return; + } for (channel, plane) in planes.iter().enumerate() { for (row, samples) in plane.samples()[..rect_pixels] .chunks_exact(rect_width) .enumerate() { let pixel = (y + row) * width + x; - if planar { - let destination = &mut self.out[channel * self.pixels + pixel..][..rect_width]; - (self.convert_row)(destination, 1, samples, precision); - } else { - let destination = &mut self.out[pixel * channels..][..rect_width * channels]; - (self.convert_row)(&mut destination[channel..], channels, samples, precision); - } + let Some((destination, stride)) = channel_destination( + self.out, + self.info.layout, + self.pixels, + channels, + channel, + pixel, + rect_width, + ) else { + self.malformed = true; + return; + }; + (self.convert_row)(destination, stride, samples, self.info.precision); } } self.written_pixels += rect_pixels; @@ -339,55 +344,133 @@ impl j2k_native::ComponentRowSink for SampleRowSink<'_, '_, T> { } } -fn validate_decoded_components( +fn write_decoded_samples( components: &j2k_native::DecodedComponents<'_>, info: &BatchGroupInfo, + out: &mut [T], + convert: impl Fn(f32, u8) -> T, +) -> Result<(), J2kError> { + write_batch_samples( + components.dimensions(), + components.planes().len(), + |component| { + components + .planes() + .get(component) + .map(|plane| (plane.dimensions(), plane.bit_depth(), plane.samples())) + }, + info, + out, + convert, + ) +} + +/// Validate and write every decoded plane through the batch's native layout. +pub(super) fn write_batch_samples<'a, T: Copy>( + dimensions: (u32, u32), + component_count: usize, + mut plane: impl FnMut(usize) -> Option<((u32, u32), u8, &'a [f32])>, + info: &BatchGroupInfo, + out: &mut [T], + convert: impl Fn(f32, u8) -> T, ) -> Result<(), J2kError> { - if components.dimensions() != info.dimensions - || components.planes().len() != info.color.channels() + if dimensions != info.dimensions + || component_count != info.color.channels() + || info.samples_per_image() != Some(out.len()) { return Err(J2kError::internal_backend( "prepared batch output metadata changed during decode", )); } - let expected = (info.dimensions.0 as usize) + let pixels = (info.dimensions.0 as usize) .checked_mul(info.dimensions.1 as usize) .ok_or_else(|| J2kError::internal_backend("prepared batch sample count overflow"))?; - for (component, plane) in components.planes().iter().enumerate() { - if plane.dimensions() != info.dimensions || plane.samples().len() < expected { - return Err(J2kError::BackendComponentPlaneTooShort { - component, - samples: plane.samples().len(), - expected, - }); + let channels = info.color.channels(); + for channel in 0..channels { + let (plane_dimensions, bit_depth, samples) = plane(channel).ok_or_else(|| { + J2kError::internal_backend("prepared codec component plane is missing") + })?; + validate_sample_plane( + channel, + plane_dimensions, + bit_depth, + samples.len(), + info.dimensions, + info.precision, + pixels, + )?; + let (destination, stride) = + channel_destination(out, info.layout, pixels, channels, channel, 0, pixels) + .ok_or_else(|| { + J2kError::internal_backend("prepared batch output slot is too short") + })?; + if stride == 1 { + for (destination, &sample) in destination.iter_mut().zip(&samples[..pixels]) { + *destination = convert(sample, bit_depth); + } + } else { + for (destination, &sample) in destination + .iter_mut() + .step_by(stride) + .zip(&samples[..pixels]) + { + *destination = convert(sample, bit_depth); + } } } Ok(()) } -fn write_samples( - components: &j2k_native::DecodedComponents<'_>, - info: &BatchGroupInfo, +/// Validate one decoded sample plane against its prepared batch contract. +pub(super) fn validate_sample_plane( + component: usize, + dimensions: (u32, u32), + bit_depth: u8, + samples: usize, + expected_dimensions: (u32, u32), + expected_bit_depth: u8, + expected_samples: usize, +) -> Result<(), J2kError> { + if dimensions != expected_dimensions + || bit_depth != expected_bit_depth + || samples < expected_samples + { + return Err(J2kError::BackendComponentPlaneTooShort { + component, + samples, + expected: expected_samples, + }); + } + Ok(()) +} + +/// The destination span and sample stride for one channel of consecutive pixels. +pub(super) fn channel_destination( out: &mut [T], - convert: impl Fn(f32, u8) -> T, -) { - let pixels = info.dimensions.0 as usize * info.dimensions.1 as usize; - let channels = info.color.channels(); - let precision = info.precision; - for (channel, plane) in components.planes().iter().enumerate() { - let samples = &plane.samples()[..pixels]; - if info.layout == BatchLayout::Nchw || channels == 1 { - // One contiguous plane per channel. - let destination = &mut out[channel * pixels..][..pixels]; - for (destination, &sample) in destination.iter_mut().zip(samples) { - *destination = convert(sample, precision); - } - } else { - for (pixel, &sample) in out.chunks_exact_mut(channels).zip(samples) { - pixel[channel] = convert(sample, precision); - } - } + layout: BatchLayout, + pixels: usize, + channels: usize, + channel: usize, + first_pixel: usize, + count: usize, +) -> Option<(&mut [T], usize)> { + let end_pixel = first_pixel.checked_add(count)?; + if channel >= channels || end_pixel > pixels { + return None; } + let planar = layout == BatchLayout::Nchw || channels == 1; + let stride = if planar { 1 } else { channels }; + let start = if planar { + channel.checked_mul(pixels)?.checked_add(first_pixel)? + } else { + first_pixel.checked_mul(channels)?.checked_add(channel)? + }; + let len = if count == 0 { + 0 + } else { + (count - 1).checked_mul(stride)?.checked_add(1)? + }; + Some((out.get_mut(start..start.checked_add(len)?)?, stride)) } #[expect( @@ -480,7 +563,8 @@ pub(super) fn round_signed(sample: f32, precision: u8) -> i16 { #[cfg(test)] mod tests { - use super::{convert_u8, convert_u8_row}; + use super::{convert_u8, convert_u8_row, validate_sample_plane}; + use crate::J2kError; use alloc::vec::Vec; /// Samples around every rounding and saturation boundary of 8-bit and @@ -548,4 +632,18 @@ mod tests { } } } + + #[test] + fn sample_plane_validation_rejects_precision_drift() { + let error = validate_sample_plane(1, (8, 8), 10, 64, (8, 8), 12, 64) + .expect_err("prepared precision must match decoded plane"); + assert!(matches!( + error, + J2kError::BackendComponentPlaneTooShort { + component: 1, + samples: 64, + expected: 64, + } + )); + } } diff --git a/crates/j2k/src/owned_batch/cpu_prepared.rs b/crates/j2k/src/owned_batch/cpu_prepared.rs index f1ed9e88..96578e31 100644 --- a/crates/j2k/src/owned_batch/cpu_prepared.rs +++ b/crates/j2k/src/owned_batch/cpu_prepared.rs @@ -2,10 +2,9 @@ //! Parse-free prepared-plan execution and dense CPU final store. -use j2k_core::Rect; - use super::cpu_fast::{CpuFlattenedPayloadJob, CpuGroupFastWorkspace}; -use super::{BatchCodecRoute, BatchGroupInfo, BatchLayout, PreparedCodecPlan, PreparedImage}; +use super::cpu_materialize::write_batch_samples; +use super::{BatchCodecRoute, BatchGroupInfo, PreparedCodecPlan, PreparedImage}; use crate::{batch::worker::BatchWorker, J2kError}; pub(super) fn decode_prepared_plan_samples( @@ -25,8 +24,12 @@ pub(super) fn decode_prepared_plan_samples( PreparedCodecPlan::MetadataOnly => return Ok(false), }; let output_rect = image.plan().output_rect(); - validate_components(&components, output_rect, info, out.len())?; - write_samples(&components, output_rect, info, out, convert)?; + if (output_rect.w, output_rect.h) != info.dimensions { + return Err(J2kError::internal_backend( + "prepared codec plan output metadata changed during decode", + )); + } + write_direct_samples(&components, info, out, convert)?; Ok(true) } @@ -202,75 +205,30 @@ pub(super) fn finish_staged_plan_samples( } }; let output_rect = image.plan().output_rect(); - validate_components(&components, output_rect, info, out.len())?; - write_samples(&components, output_rect, info, out, convert) -} - -fn validate_components( - components: &j2k_native::J2kDirectDecodedComponents<'_>, - output_rect: Rect, - info: &BatchGroupInfo, - output_len: usize, -) -> Result<(), J2kError> { - if components.component_count() != info.color.channels() - || (output_rect.w, output_rect.h) != info.dimensions - || components.dimensions() != info.dimensions - || info.samples_per_image() != Some(output_len) - { + if (output_rect.w, output_rect.h) != info.dimensions { return Err(J2kError::internal_backend( "prepared codec plan output metadata changed during decode", )); } - let plane_len = (components.dimensions().0 as usize) - .checked_mul(components.dimensions().1 as usize) - .ok_or_else(|| J2kError::internal_backend("prepared HTJ2K plane size overflow"))?; - for component in 0..components.component_count() { - let plane = components.plane(component).ok_or_else(|| { - J2kError::internal_backend("prepared codec component plane is missing") - })?; - if plane.dimensions() != components.dimensions() - || plane.bit_depth() != info.precision - || plane.samples().len() < plane_len - { - return Err(J2kError::BackendComponentPlaneTooShort { - component, - samples: plane.samples().len(), - expected: plane_len, - }); - } - } - Ok(()) + write_direct_samples(&components, info, out, convert) } -fn write_samples( +fn write_direct_samples( components: &j2k_native::J2kDirectDecodedComponents<'_>, - output_rect: Rect, info: &BatchGroupInfo, out: &mut [T], convert: impl Fn(f32, u8) -> T, ) -> Result<(), J2kError> { - let output_width = output_rect.w as usize; - let output_height = output_rect.h as usize; - let source_width = components.dimensions().0 as usize; - let channels = components.component_count(); - let output_pixels = output_width * output_height; - let planar = info.layout == BatchLayout::Nchw; - for y in 0..output_height { - for x in 0..output_width { - let output_pixel = y * output_width + x; - let source_pixel = y * source_width + x; - for channel in 0..channels { - let plane = components.plane(channel).ok_or_else(|| { - J2kError::internal_backend("prepared codec component plane is missing") - })?; - let destination = if planar { - channel * output_pixels + output_pixel - } else { - output_pixel * channels + channel - }; - out[destination] = convert(plane.samples()[source_pixel], plane.bit_depth()); - } - } - } - Ok(()) + write_batch_samples( + components.dimensions(), + components.component_count(), + |component| { + components + .plane(component) + .map(|plane| (plane.dimensions(), plane.bit_depth(), plane.samples())) + }, + info, + out, + convert, + ) } diff --git a/crates/j2k/src/owned_batch/cpu_staged_execute.rs b/crates/j2k/src/owned_batch/cpu_staged_execute.rs index 8d0d0207..ebfea145 100644 --- a/crates/j2k/src/owned_batch/cpu_staged_execute.rs +++ b/crates/j2k/src/owned_batch/cpu_staged_execute.rs @@ -7,15 +7,13 @@ use super::{ execute_staged_entropy_job, finish_staged_plan_samples, finish_staged_tile, prepare_staged_entropy_worker, prepare_staged_image, prepare_staged_tile, run_retained_chunks, staged_tile_count, BatchCodecRoute, BatchInfrastructureError, BatchWorker, CpuEntropyOutcome, - CpuGroupFastWorkspace, CpuStagedWorkspace, J2kError, Ordering, PreparedBatchGroup, Range, + CpuGroupFastWorkspace, CpuStagedWorkspace, J2kError, Ordering, PreparedBatchGroupRef, Range, TileBatchOptions, CPU_ENTROPY_IMAGES_PER_WINDOW, }; -use crate::batch::admission::BatchAllocationBudget; use crate::batch::allocation::batch_execution_cap_bytes; -use alloc::sync::Arc; struct StagedWindowContext<'a> { - group: &'a PreparedBatchGroup, + group: PreparedBatchGroupRef<'a>, route: BatchCodecRoute, tile_options: TileBatchOptions, image_range: Range, @@ -27,26 +25,24 @@ struct StagedWindowContext<'a> { )] pub(super) fn run_staged_typed_group( workers: &mut [BatchWorker], - group: &PreparedBatchGroup, + group: PreparedBatchGroupRef<'_>, tile_options: TileBatchOptions, image_jobs: &mut [CpuTypedDecodeJob<'_, '_, T>], image_results: &mut [Option>], flattened: &mut CpuGroupFastWorkspace, staged: &mut CpuStagedWorkspace, - admission: &Arc, convert: fn(f32, u8) -> T, ) -> Result<(), BatchInfrastructureError> { let route = flattened .route() .ok_or(BatchInfrastructureError::MissingResult { index: 0 })?; // Every image of a window keeps its own whole-image scratch until the - // window is materialized, so the window claims the sum of its images' - // planned executions. Windows stay as wide as the admission cap allows: - // narrower windows cut throughput even with one worker. + // window is materialized. Keep the summed planned executions within the + // same cap the generic scheduler uses; windows run one after another. let window_budget = batch_execution_cap_bytes(); let mut window_start = 0; while window_start < image_jobs.len() { - let (window_end, window_bytes) = staged_window_len( + let (window_end, _) = staged_window_len( image_jobs[window_start..].iter().map(|job| { let execution = job.image.cpu_execution(); execution @@ -56,7 +52,6 @@ pub(super) fn run_staged_typed_group( window_budget, ); let window_end = window_start + window_end; - let _window_claim = admission.claim(window_bytes)?; let window = StagedWindowContext { group, route, @@ -268,7 +263,7 @@ fn materialize_staged_window( .unwrap_or_else(std::sync::PoisonError::into_inner); *result = Some(finish_staged_plan_samples( job.image, - &window.group.info, + window.group.info, &mut scratch, job.output, convert, @@ -285,7 +280,7 @@ fn materialize_staged_window( )] fn prepare_staged_tile_window( workers: &mut [BatchWorker], - _group: &PreparedBatchGroup, + _group: PreparedBatchGroupRef<'_>, staged: &mut CpuStagedWorkspace, route: BatchCodecRoute, tile_options: TileBatchOptions, @@ -349,7 +344,7 @@ fn prepare_staged_tile_window( )] fn execute_staged_tile_entropy( workers: &mut [BatchWorker], - group: &PreparedBatchGroup, + group: PreparedBatchGroupRef<'_>, staged: &mut CpuStagedWorkspace, flattened: &CpuGroupFastWorkspace, route: BatchCodecRoute, @@ -446,7 +441,7 @@ fn execute_staged_tile_entropy( )] fn finish_staged_tile_window( workers: &mut [BatchWorker], - group: &PreparedBatchGroup, + group: PreparedBatchGroupRef<'_>, staged: &mut CpuStagedWorkspace, route: BatchCodecRoute, tile_options: TileBatchOptions, diff --git a/crates/j2k/src/owned_batch/prepare.rs b/crates/j2k/src/owned_batch/prepare.rs index 84ce312c..b6fd2acc 100644 --- a/crates/j2k/src/owned_batch/prepare.rs +++ b/crates/j2k/src/owned_batch/prepare.rs @@ -5,9 +5,9 @@ use super::{ generic_batch_worker_limit, run_retained_chunks, size_of, Arc, BatchDecodeOptions, BatchErrorStage, BatchGroupInfo, BatchInfrastructureError, BatchItemError, BatchWorker, - CpuExecution, EncodedImage, IndexedBatchError, J2kError, NativeSampleType, NonZeroUsize, - PrepareImageResult, PrepareJob, PreparedBatch, PreparedBatchGroup, PreparedImage, - TileBatchOptions, Vec, J2K_BATCH_METADATA_ALLOWANCE_BYTES, + CpuExecution, EncodedImage, IndexedBatchError, J2kError, NonZeroUsize, PrepareImageResult, + PrepareJob, PreparedBatch, PreparedBatchGroup, PreparedImage, TileBatchOptions, Vec, + J2K_BATCH_METADATA_ALLOWANCE_BYTES, }; #[cfg(test)] @@ -229,16 +229,9 @@ fn cpu_preflight_error(image: &PreparedImage, info: &BatchGroupInfo) -> Option 1, - NativeSampleType::U16 | NativeSampleType::I16 => 2, - // Decode reports an unsupported sample width itself. - _ => return None, - }; - let output_bytes = info - .samples_per_image() - .and_then(|samples| samples.checked_mul(sample_bytes)) - .unwrap_or(usize::MAX); + // Decode reports an unsupported sample width itself. + info.sample_bytes()?; + let output_bytes = info.output_bytes(1).unwrap_or(usize::MAX); if output_bytes <= j2k_core::DEFAULT_MAX_HOST_ALLOCATION_BYTES { return None; } diff --git a/crates/j2k/src/owned_batch/prepare/cpu_preflight_tests.rs b/crates/j2k/src/owned_batch/prepare/cpu_preflight_tests.rs index a0aef92e..dd4ee61c 100644 --- a/crates/j2k/src/owned_batch/prepare/cpu_preflight_tests.rs +++ b/crates/j2k/src/owned_batch/prepare/cpu_preflight_tests.rs @@ -111,6 +111,9 @@ fn planned_bytes(prepared: &PreparedBatch) -> Vec<(usize, CpuExecution)> { .map(|(&index, image)| (index, image.cpu_execution())) .collect::>(); planned.sort_by_key(|(index, _)| *index); + assert!(planned.iter().all(|(_, execution)| { + execution.claim_bytes() <= crate::batch::allocation::GENERIC_WORKER_CLAIM_BYTES + })); planned } diff --git a/crates/j2k/src/owned_batch/prepared.rs b/crates/j2k/src/owned_batch/prepared.rs index f3b38d1b..2a6db508 100644 --- a/crates/j2k/src/owned_batch/prepared.rs +++ b/crates/j2k/src/owned_batch/prepared.rs @@ -59,16 +59,16 @@ pub(super) enum CpuExecution { } impl CpuExecution { - /// Admission claim for one execution: the planned peak of an offset - /// plan, or one worst-case native decode. + /// Conservative bytes for staged-window sizing: the planned peak of an + /// offset plan, or one worst-case native decode. pub(super) const fn claim_bytes(self) -> usize { match self { Self::OffsetPlan { bytes } => bytes, - // The streamed route has no planned peak yet; it is claimed as one + // The streamed route has no planned peak yet; size it as one // worst-case native decode (measured: 95 MiB for a 5661 x 8287 // RGB8 scan at default threads). // An optional plan fits the bound, so its peak is within this - // claim too. + // amount too. Self::ComponentPlanes | Self::StreamedRows | Self::StreamedRowsOrPlan { .. } => { crate::batch::allocation::GENERIC_WORKER_CLAIM_BYTES } @@ -77,7 +77,7 @@ impl CpuExecution { } /// The planned peak of the offset plan this image may decode, if any: - /// what the staged route, which decodes plans only, claims for it. + /// what the staged route, which decodes plans only, budgets for it. pub(super) const fn plan_bytes(self) -> Option { match self { Self::OffsetPlan { bytes } | Self::StreamedRowsOrPlan { bytes } => Some(bytes), @@ -208,6 +208,14 @@ pub struct PreparedBatchGroup { pub(super) source_indices: Vec, } +/// Borrowed images and source indices of one homogeneous prepared group. +#[derive(Clone, Copy)] +pub(super) struct PreparedBatchGroupRef<'a> { + pub(super) info: &'a BatchGroupInfo, + pub(super) images: &'a [PreparedImage], + pub(super) source_indices: &'a [usize], +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct BatchExecutionShape { pub(super) source_dimensions: (u32, u32), diff --git a/crates/j2k/tests/full_image_streaming.rs b/crates/j2k/tests/full_image_streaming.rs index 564e3d6e..82240e9b 100644 --- a/crates/j2k/tests/full_image_streaming.rs +++ b/crates/j2k/tests/full_image_streaming.rs @@ -8,18 +8,20 @@ //! the bytes the planes route writes, at every scale, stride and //! parallelism, and fail where it fails. -use j2k::{CpuDecodeParallelism, J2kDecoder, J2kScratchPool}; +#[path = "support/full_image_streaming.rs"] +mod streaming; + +use j2k::{CpuDecodeParallelism, J2kDecoder}; use j2k_core::{Downscale, PixelFormat}; -use j2k_native::{encode, DecodeErrorClass, EncodeOptions, EncodeProgressionOrder}; +use j2k_native::{encode, EncodeOptions, EncodeProgressionOrder}; use j2k_test_support::{ add_packet_length, edit_tile_parts, with_byte_after_last_packets, with_surplus_packet_lengths, wrap_jp2_rgba_codestream, }; +use streaming::{decode, Failure}; const WIDTH: u32 = 203; const HEIGHT: u32 = 157; -const PADDING: u8 = 0xab; - fn noisy_pixels(components: u32) -> Vec { let mut state = 0x2545_f491_u32; (0..WIDTH * HEIGHT * components) @@ -37,27 +39,6 @@ fn codestream(components: u16, options: &EncodeOptions) -> Vec { encode(&pixels, WIDTH, HEIGHT, components, 8, false, options).expect("encode fixture") } -/// Why a decode failed: the class of the native decode error behind it, or -/// the facade's own error where there is none. -#[derive(Debug, Clone, PartialEq, Eq)] -enum Failure { - Native(DecodeErrorClass), - Facade(String), -} - -impl Failure { - fn of(error: &j2k::J2kError) -> Self { - let mut next: Option<&(dyn std::error::Error + 'static)> = Some(error); - while let Some(current) = next { - if let Some(native) = current.downcast_ref::() { - return Self::Native(native.classify()); - } - next = current.source(); - } - Self::Facade(error.to_string()) - } -} - /// Decodes of one codestream and request that must all fail, and for one /// reason. #[derive(Default)] @@ -77,43 +58,6 @@ impl OneFailure { } } -/// The image at `scale` in a buffer padded past each row, or the native error -/// class of the failure, from a decoder whose planes budget is -/// `planes_budget` when one is given. -fn decode( - bytes: &[u8], - planes_budget: Option, - parallelism: CpuDecodeParallelism, - fmt: PixelFormat, - scale: Downscale, - padding: usize, -) -> Result, Failure> { - let mut decoder = J2kDecoder::new(bytes).expect("decoder"); - decoder.set_cpu_decode_parallelism(parallelism); - if let Some(bytes) = planes_budget { - decoder.set_full_decode_planes_budget(bytes); - } - let (width, height) = decoder.info().dimensions; - let divisor = scale.denominator(); - let (width, height) = (width.div_ceil(divisor), height.div_ceil(divisor)); - let row = width as usize * fmt.bytes_per_pixel(); - let stride = row + padding; - let mut out = vec![PADDING; stride * height as usize]; - let pool = &mut J2kScratchPool::new(); - let result = match scale { - Downscale::None => decoder.decode_into(&mut out, stride, fmt), - _ => decoder.decode_scaled_into(pool, &mut out, stride, fmt, scale), - }; - result.map_err(|error| Failure::of(&error))?; - for line in out.chunks(stride) { - assert!( - line[row..].iter().all(|&byte| byte == PADDING), - "stride padding untouched" - ); - } - Ok(out) -} - #[test] fn streamed_full_image_matches_planes() { let base = EncodeOptions { diff --git a/crates/j2k/tests/streamed_sixteen_bit_and_native.rs b/crates/j2k/tests/streamed_sixteen_bit_and_native.rs index 8b76ff16..67130432 100644 --- a/crates/j2k/tests/streamed_sixteen_bit_and_native.rs +++ b/crates/j2k/tests/streamed_sixteen_bit_and_native.rs @@ -9,15 +9,17 @@ //! signedness, at every scale, stride and parallelism, and fail where it //! fails. -use j2k::{CpuDecodeParallelism, J2kDecodedNativeComponents, J2kDecoder, J2kScratchPool}; +#[path = "support/full_image_streaming.rs"] +mod streaming; + +use j2k::{CpuDecodeParallelism, J2kDecodedNativeComponents, J2kScratchPool}; use j2k_core::{Downscale, PixelFormat, Rect}; use j2k_native::{encode, EncodeOptions}; use j2k_test_support::wrap_jp2_rgba_codestream; +use streaming::{decode, decoder, PADDING}; const WIDTH: u32 = 203; const HEIGHT: u32 = 157; -const PADDING: u8 = 0xab; - const RGB_FORMATS: &[PixelFormat] = &[ PixelFormat::Rgb16, PixelFormat::Rgba16, @@ -149,51 +151,6 @@ fn fixtures() -> Vec<(&'static str, Vec, &'static [PixelFormat])> { ] } -fn decoder( - bytes: &[u8], - planes_budget: Option, - parallelism: CpuDecodeParallelism, -) -> J2kDecoder<'_> { - let mut decoder = J2kDecoder::new(bytes).expect("decoder"); - decoder.set_cpu_decode_parallelism(parallelism); - if let Some(bytes) = planes_budget { - decoder.set_full_decode_planes_budget(bytes); - } - decoder -} - -/// The image at `scale` in a buffer padded past each row, or `None` when the -/// decode fails, from a decoder whose planes budget is `planes_budget` when -/// one is given. -fn decode( - bytes: &[u8], - planes_budget: Option, - parallelism: CpuDecodeParallelism, - fmt: PixelFormat, - scale: Downscale, - padding: usize, -) -> Option> { - let mut decoder = decoder(bytes, planes_budget, parallelism); - let divisor = scale.denominator(); - let (width, height) = (WIDTH.div_ceil(divisor), HEIGHT.div_ceil(divisor)); - let row = width as usize * fmt.bytes_per_pixel(); - let stride = row + padding; - let mut out = vec![PADDING; stride * height as usize]; - let pool = &mut J2kScratchPool::new(); - let result = match scale { - Downscale::None => decoder.decode_into(&mut out, stride, fmt), - _ => decoder.decode_scaled_into(pool, &mut out, stride, fmt, scale), - }; - result.ok()?; - for line in out.chunks(stride) { - assert!( - line[row..].iter().all(|&byte| byte == PADDING), - "stride padding untouched" - ); - } - Some(out) -} - #[test] fn streamed_full_image_outputs_match_planes() { let mut decoded = 0; @@ -209,10 +166,10 @@ fn streamed_full_image_outputs_match_planes() { assert!( planes == streamed, "{label} {fmt:?} {scale:?} {parallelism:?}: planes {} streamed {}", - planes.is_some(), - streamed.is_some() + planes.is_ok(), + streamed.is_ok() ); - decoded += usize::from(planes.is_some()); + decoded += usize::from(planes.is_ok()); } } } @@ -310,7 +267,7 @@ fn sixteen_bit_regions_match_the_full_image() { for (label, bytes, formats) in &fixtures() { for &fmt in formats.iter().filter(|fmt| fmt.bytes_per_pixel() % 2 == 0) { for parallelism in [CpuDecodeParallelism::Serial, CpuDecodeParallelism::Auto] { - let Some(full) = decode(bytes, None, parallelism, fmt, Downscale::None, 0) else { + let Ok(full) = decode(bytes, None, parallelism, fmt, Downscale::None, 0) else { continue; }; let pixel = fmt.bytes_per_pixel(); @@ -406,7 +363,7 @@ fn deep_samples_decode_to_their_values_clamped_to_sixteen_bits() { for parallelism in [CpuDecodeParallelism::Serial, CpuDecodeParallelism::Auto] { for budget in [None, Some(0)] { let decoded = decode(&bytes, budget, parallelism, fmt, Downscale::None, 0) - .unwrap_or_else(|| panic!("{label} {budget:?}: decode failed")); + .unwrap_or_else(|error| panic!("{label} {budget:?}: {error:?}")); assert!( decoded == expected, "{label} {parallelism:?} planes budget {budget:?}" @@ -436,7 +393,7 @@ fn deep_samples_decode_to_their_values_clamped_to_sixteen_bits() { for budget in [None, Some(0)] { let decoded = decode(&bytes, budget, parallelism, fmt, Downscale::None, 0); assert!( - decoded.as_ref() == Some(&expected), + decoded.as_deref() == Ok(expected.as_slice()), "{label} {fmt:?} {parallelism:?} planes budget {budget:?}" ); } diff --git a/crates/j2k/tests/support/full_image_streaming.rs b/crates/j2k/tests/support/full_image_streaming.rs new file mode 100644 index 00000000..fdf939e1 --- /dev/null +++ b/crates/j2k/tests/support/full_image_streaming.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Shared driver for full-image route parity integration tests. + +use j2k::{CpuDecodeParallelism, J2kDecoder, J2kScratchPool}; +use j2k_core::{Downscale, PixelFormat}; +use j2k_native::DecodeErrorClass; + +pub(super) const PADDING: u8 = 0xab; + +/// Why a decode failed: the class of the native decode error behind it, or +/// the facade's own error where there is none. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum Failure { + Native(DecodeErrorClass), + Facade(String), +} + +impl Failure { + fn of(error: &j2k::J2kError) -> Self { + let mut next: Option<&(dyn std::error::Error + 'static)> = Some(error); + while let Some(current) = next { + if let Some(native) = current.downcast_ref::() { + return Self::Native(native.classify()); + } + next = current.source(); + } + Self::Facade(error.to_string()) + } +} + +pub(super) fn decoder( + bytes: &[u8], + planes_budget: Option, + parallelism: CpuDecodeParallelism, +) -> J2kDecoder<'_> { + let mut decoder = J2kDecoder::new(bytes).expect("decoder"); + decoder.set_cpu_decode_parallelism(parallelism); + if let Some(bytes) = planes_budget { + decoder.set_full_decode_planes_budget(bytes); + } + decoder +} + +/// The image at `scale` in a buffer padded past each row, or the native +/// error class of the failure, from a decoder whose planes budget is +/// `planes_budget` when one is given. +pub(super) fn decode( + bytes: &[u8], + planes_budget: Option, + parallelism: CpuDecodeParallelism, + fmt: PixelFormat, + scale: Downscale, + padding: usize, +) -> Result, Failure> { + let mut decoder = decoder(bytes, planes_budget, parallelism); + let (width, height) = decoder.info().dimensions; + let divisor = scale.denominator(); + let (width, height) = (width.div_ceil(divisor), height.div_ceil(divisor)); + let row = width as usize * fmt.bytes_per_pixel(); + let stride = row + padding; + let mut out = vec![PADDING; stride * height as usize]; + let result = match scale { + Downscale::None => decoder.decode_into(&mut out, stride, fmt), + _ => decoder.decode_scaled_into(&mut J2kScratchPool::new(), &mut out, stride, fmt, scale), + }; + result.map_err(|error| Failure::of(&error))?; + for line in out.chunks(stride) { + assert!( + line[row..].iter().all(|&byte| byte == PADDING), + "stride padding untouched" + ); + } + Ok(out) +}