diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d429fd80..6284bfdf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ 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 + 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 + 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` @@ -15,6 +29,56 @@ 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, + `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 + 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 + 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. 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 + 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 @@ -195,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-alloc-probe/tests/main.rs b/crates/j2k-alloc-probe/tests/main.rs index 6e2ffeac4..14e8caee3 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-cuda/src/direct_plan/classic.rs b/crates/j2k-cuda/src/direct_plan/classic.rs index f68c5fce3..a432c8af8 100644 --- a/crates/j2k-cuda/src/direct_plan/classic.rs +++ b/crates/j2k-cuda/src/direct_plan/classic.rs @@ -1,16 +1,18 @@ // 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 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; @@ -102,16 +104,27 @@ 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) || !(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 +154,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 +232,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 000000000..f4bc4a02e --- /dev/null +++ b/crates/j2k-cuda/src/direct_plan/classic/tests.rs @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use j2k_native::{ + decode_j2k_code_block_scalar, DecodeError, DecodingError, J2kCodeBlockDecodeJob, + 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 +} + +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()); + 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:?}" + ); +} + +/// 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)] { + 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 new file mode 100644 index 000000000..ce8f5eb80 --- /dev/null +++ b/crates/j2k-cuda/tests/packet_walk.rs @@ -0,0 +1,1098 @@ +// 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 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")] + +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, packed_packet_headers, + packet_ranges, with_byte_after_last_packets, with_packet_lengths_edited, with_packets_filled, + with_sop_marker_fields, with_surplus_packet_lengths, with_tile_cod_disabling_sop_eph, + with_tile_parts_interleaved, +}; + +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 { + 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 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") + } + + /// 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) + } + + /// Packets of a one-precinct tile in one layer. + fn packets_per_layer(self) -> usize { + self.packets() / usize::from(LAYERS) + } +} + +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(), result.group_errors()) { + ([group], [], []) => { + Ok(Surface::download_batch_tight(group.surfaces()).expect("download batch")) + } + ([], [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)), + }; + (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, 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 scales { + 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))), + ); +} + +#[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); +} + +#[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 + .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, + }, + )], + ); +} + +/// 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")); +} + +/// `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); +} diff --git a/crates/j2k-metal/src/classic.rs b/crates/j2k-metal/src/classic.rs index 324a5bd5e..4d122c982 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 409c290db..06bd67f84 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; } diff --git a/crates/j2k-metal/tests/device.rs b/crates/j2k-metal/tests/device.rs index 239413984..2fd17d087 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 000000000..26eb0c742 --- /dev/null +++ b/crates/j2k-metal/tests/device/packet_walk_parity.rs @@ -0,0 +1,1141 @@ +// 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, packed_packet_headers, packet_ranges, + with_byte_after_last_packets, with_packet_lengths_edited, with_packets_filled, + with_sop_marker_fields, with_surplus_packet_lengths, with_tile_cod_disabling_sop_eph, + with_tile_parts_interleaved, +}; + +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() + )), + } + } +} + +#[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")); +} + +#[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 +/// 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")); +} + +/// 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")); +} diff --git a/crates/j2k-native/src/j2c/build.rs b/crates/j2k-native/src/j2c/build.rs index 8a2e010d9..af0b002da 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 d58fcf846..4da703139 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. @@ -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,115 @@ 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, 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 && !has_precincts_without_code_blocks(tile) { + 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, &counts, 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<'_>, + counts: &SelectionCounts, + 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 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() +} + +/// 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, @@ -316,6 +440,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()); @@ -333,14 +470,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, @@ -741,6 +880,220 @@ 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, &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(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, + 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) + } + + /// 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 + /// 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. diff --git a/crates/j2k-native/src/j2c/decode.rs b/crates/j2k-native/src/j2c/decode.rs index d4c87655a..d153ee0a5 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( diff --git a/crates/j2k-native/src/j2c/decode/direct_plan.rs b/crates/j2k-native/src/j2c/decode/direct_plan.rs index 64367a503..7c63ebc9d 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())?; @@ -165,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], @@ -177,7 +180,9 @@ fn build_direct_grayscale_plan_inner<'a>( None, ht_payloads, classic_payloads, - ) + )?; + reject_grayscale_mct(&tiles, header)?; + Ok(plan) } #[expect( @@ -274,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 5c3507efb..48862e882 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/j2c/segment.rs b/crates/j2k-native/src/j2c/segment.rs index e0435b58d..b833d53c8 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 ()>, } @@ -254,6 +253,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 +279,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)); } @@ -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" @@ -396,27 +397,25 @@ 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 { // The last precinct's place among its sub-bands' records, by (component, // resolution, precinct): position-major progressions read all of 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); + }; + 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!( @@ -437,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")?; @@ -473,7 +471,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 { @@ -482,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"); } @@ -524,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(); @@ -668,13 +665,44 @@ 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. 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() + && (!strict || progression_iterator.next().is_some()) + { + return Err("packet length markers contain unused entries"); + } 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, @@ -956,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 { @@ -1026,6 +1070,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-native/src/j2c/tile/cursor.rs b/crates/j2k-native/src/j2c/tile/cursor.rs index 9b86f6186..a1c9013cb 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-native/src/j2c/tile/tile_part.rs b/crates/j2k-native/src/j2c/tile/tile_part.rs index 173c85b55..063c61b1e 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/src/tests.rs b/crates/j2k-native/src/tests.rs index 8544577e3..500becd47 100644 --- a/crates/j2k-native/src/tests.rs +++ b/crates/j2k-native/src/tests.rs @@ -2526,6 +2526,444 @@ 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"); + } + } +} + +/// 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" + ); + } +} + +/// 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] +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-native/tests/tile_header_markers.rs b/crates/j2k-native/tests/tile_header_markers.rs index 76c2189a2..5ae3e7369 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,181 @@ 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); + 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/fixtures/empty_coarse_levels/README.md b/crates/j2k-test-support/fixtures/empty_coarse_levels/README.md index 7d6f564e5..914d82d6a 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 000000000..cd69991a1 Binary files /dev/null and b/crates/j2k-test-support/fixtures/empty_coarse_levels/openjpeg_gray_3x3_tile2x2_53_eph.j2k differ diff --git a/crates/j2k-test-support/src/lib.rs b/crates/j2k-test-support/src/lib.rs index 6f4e65e04..32781d4d4 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,12 @@ 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, packed_packet_headers, packet_ranges, + with_byte_after_last_packets, with_packet_lengths_edited, with_packets_filled, + 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, 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 000000000..2df10403c --- /dev/null +++ b/crates/j2k-test-support/src/packet_edits.rs @@ -0,0 +1,460 @@ +// 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, 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 SOP: [u8; 2] = [0xff, 0x91]; +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]; +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 +} + +/// 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 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] +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 +} + +/// `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; + }) +} + +/// `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::*; + + /// 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]); + 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]); + } + + /// 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 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]; + 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/batch/admission.rs b/crates/j2k/src/batch/admission.rs index beec297f2..fa57a3be4 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 f3c3f2586..d12df4b6c 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/decode/route/tests.rs b/crates/j2k/src/decode/route/tests.rs index 1e917c0bc..4297369d6 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/src/owned_batch.rs b/crates/j2k/src/owned_batch.rs index 2e875afeb..5d39b0c04 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 a0a169945..c5dba0dbd 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 bc36a6d66..3d1a7a2cc 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 7b87c7967..cfa5a930d 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 b57861270..6c959c4fc 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 8ad462980..d83f25f04 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 21573e77c..a4c501e84 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 c2a39adfa..462fee884 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 7f37d6280..b4eba0af8 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 2b64f1e8e..d29173eeb 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", @@ -94,7 +89,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) @@ -139,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>( @@ -164,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>( @@ -187,15 +186,14 @@ 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. Working memory stays bounded by the -/// streamed decode instead of growing with the image. +/// [`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>( image: &'image PreparedImage, options: BatchDecodeOptions, @@ -203,7 +201,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 +212,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 +223,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 +244,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 +257,7 @@ impl<'out, 'info, T: Copy> SampleRowSink<'out, 'info, T> { Ok(Self { info, out, - convert, + convert_row, pixels, written_pixels: 0, malformed: false, @@ -287,32 +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]; - for (destination, &sample) in destination.iter_mut().zip(samples) { - *destination = (self.convert)(sample, 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); - } - } + 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; @@ -327,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> { - if components.dimensions() != info.dimensions - || components.planes().len() != info.color.channels() + 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 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( @@ -398,6 +493,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 +560,90 @@ pub(super) fn round_signed(sample: f32, precision: u8) -> i16 { i16::MAX }) } + +#[cfg(test)] +mod tests { + 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 + /// 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}" + ); + } + } + } + } + + #[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 f1ed9e88e..96578e317 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 8d0d02072..ebfea145f 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 84ce312c9..b6fd2acc2 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 a0aef92e1..dd4ee61c5 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 f3b38d1b5..2a6db508d 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/src/view.rs b/crates/j2k/src/view.rs index 4e8cd5095..858a8487b 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 9a49d7ed5..82240e9b2 100644 --- a/crates/j2k/tests/full_image_streaming.rs +++ b/crates/j2k/tests/full_image_streaming.rs @@ -8,15 +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, EncodeOptions}; -use j2k_test_support::wrap_jp2_rgba_codestream; +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) @@ -34,41 +39,23 @@ 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. -fn decode( - bytes: &[u8], - planes_budget: Option, - parallelism: CpuDecodeParallelism, - fmt: PixelFormat, - scale: Downscale, - padding: usize, -) -> Option> { - 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.ok()?; - for line in out.chunks(stride) { +/// 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!( - line[row..].iter().all(|&byte| byte == PADDING), - "stride padding untouched" + matches!(failure, Failure::Native(_)), + "{case}: {failure:?} has no native cause" ); + assert_eq!(failure, first, "{case}: error class"); } - Some(out) } #[test] @@ -155,11 +142,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()); } } } @@ -189,7 +176,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:?}"); } @@ -230,3 +217,1087 @@ fn routes_alternate_on_one_decoder() { assert!(out == expected, "budget {budget}"); } } + +/// 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() == Ok(&expected), + "{label} {fmt:?} {scale:?} {threads} threads, planes budget {budget:?}: {}", + if decoded.is_ok() { "other bytes" } else { "failed" } + ); + } + } + } + } + } +} + +/// 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_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)); + failure.check( + &decoded, + &format!("{scale:?} {threads} threads, planes budget {budget:?}"), + ); + } + } + } +} + +/// 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_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( + &edited, + budget, + CpuDecodeParallelism::Auto, + fmt, + Downscale::None, + 0, + ) + }); + failure.check( + &decoded, + &format!("{threads} threads, planes budget {budget:?}"), + ); + } + } +} + +/// 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"); + 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!( + "{}, {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() == Ok(&expected), "{label}: half resolution"); + } else { + half_failure.check(&half, &format!("{label}: half resolution")); + } + let full = pool.install(|| { + decode( + &edited, + budget, + CpuDecodeParallelism::Auto, + fmt, + Downscale::None, + 0, + ) + }); + full_failure.check(&full, &format!("{label}: full resolution")); + } + } + } +} + +/// 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_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) + .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, + ) + }); + failure.check( + &half, + &format!("{label}, {threads} threads, planes budget {budget:?}"), + ); + } + } + } +} + +/// 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")); + } +} + +/// 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 +/// (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)); + 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() == Ok(&expected), "{label}: half resolution"); + let full = decode(&edited, budget, parallelism, fmt, Downscale::None, 0); + failure.check(&full, &format!("{label}: full resolution")); + } + } + } + } +} + +/// 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 nothing a +/// decode uses. Every route leaves that packet unread: with its EPH marker +/// damaged, every route and pool size writes the undamaged image, at full +/// and half resolution. +#[test] +fn packets_of_precincts_without_code_blocks_are_left_unread_on_every_route() { + 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 fmt = PixelFormat::Gray8; + let pools = [1, 4].map(|threads| { + rayon::ThreadPoolBuilder::new() + .num_threads(threads) + .build() + .expect("pool") + }); + for scale in [Downscale::None, Downscale::Half] { + let expected = decode( + clean, + Some(usize::MAX), + CpuDecodeParallelism::Serial, + fmt, + scale, + 0, + ) + .expect("clean decode"); + for (pool, threads) in pools.iter().zip([1, 4]) { + for budget in [Some(usize::MAX), Some(0), None] { + let decoded = pool.install(|| { + decode(&damaged, budget, CpuDecodeParallelism::Auto, fmt, scale, 0) + }); + assert!( + decoded.as_ref() == Ok(&expected), + "{scale:?} {threads} threads, planes budget {budget:?}" + ); + } + } + } +} diff --git a/crates/j2k/tests/streamed_sixteen_bit_and_native.rs b/crates/j2k/tests/streamed_sixteen_bit_and_native.rs index 8b76ff161..67130432c 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 000000000..fdf939e14 --- /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) +}