From c95028de573b6edaee6799e3090c1b97e64e57c3 Mon Sep 17 00:00:00 2001 From: GF Date: Mon, 3 Aug 2026 21:59:30 -0400 Subject: [PATCH 1/3] feat(j2k): add exact deep region reduction --- README.md | 7 + crates/j2k-native/src/image.rs | 38 ++- crates/j2k-native/src/image/contract_tests.rs | 80 ++++++ .../j2k-native/src/j2c/codestream/header.rs | 46 ++-- crates/j2k-native/src/j2c/codestream/tests.rs | 8 +- .../src/j2c/encode/multitile/tests.rs | 2 +- crates/j2k-native/src/j2c/mod.rs | 27 +- crates/j2k-native/src/j2c/tile/tile_part.rs | 10 + .../src/j2c/tile/tile_part/tests.rs | 1 + .../src/j2c/tile/tile_part/tests/reduction.rs | 65 +++++ crates/j2k-native/src/jp2/container.rs | 10 +- crates/j2k-native/src/tests.rs | 1 + crates/j2k/src/backend.rs | 9 + crates/j2k/src/view.rs | 6 +- crates/j2k/src/view/deep_scale.rs | 93 +++++++ crates/j2k/tests/deep_scale.rs | 250 ++++++++++++++++++ ...able-api-1.0.implementation-public-api.txt | 1 + docs/stable-api-1.0.public-api.txt | 1 + engineering/public-api-review-0.8.0.yml | 10 +- engineering/reviewed-public-api-diff-0.8.0.md | 9 +- 20 files changed, 632 insertions(+), 42 deletions(-) create mode 100644 crates/j2k-native/src/j2c/tile/tile_part/tests/reduction.rs create mode 100644 crates/j2k/src/view/deep_scale.rs create mode 100644 crates/j2k/tests/deep_scale.rs diff --git a/README.md b/README.md index c5e05d75..588fa449 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,13 @@ Runnable repository examples: - `cargo run -p j2k-tilecodec --example decompress` ([crates/j2k-tilecodec/examples/decompress.rs](crates/j2k-tilecodec/examples/decompress.rs)) +JPEG 2000 callers that need reduced regions below the shared +`Downscale::Eighth` ceiling can use +`J2kDecoder::decode_region_scaled_pow2_into`. The level is an exact count of +power-of-two halvings; requests beyond any component's codestream resolution +ladder return an unsupported error instead of silently decoding at another +scale. + Runtime backend selection defaults to `Auto`: CPU remains the portable baseline, and Metal or CUDA paths are selected only for supported shapes with validation and benchmark evidence. Single-frame HTJ2K host-output encode stays CPU by diff --git a/crates/j2k-native/src/image.rs b/crates/j2k-native/src/image.rs index 7d71bce5..37ad77bb 100644 --- a/crates/j2k-native/src/image.rs +++ b/crates/j2k-native/src/image.rs @@ -229,9 +229,39 @@ impl<'a> Image<'a> { /// Returns an error when the input signature, container, or codestream is invalid. pub fn new(data: &'a [u8], settings: &DecodeSettings) -> Result { if data.starts_with(JP2_MAGIC) { - jp2::parse(data, *settings) + jp2::parse(data, *settings, None) } else if data.starts_with(CODESTREAM_MAGIC) { - j2c::parse(data, settings) + j2c::parse(data, settings, None) + } else { + err!(FormatError::InvalidSignature) + } + } + + /// Parse an image at an exact JPEG 2000 resolution reduction level. + /// + /// This low-level adapter exists for the `j2k` facade. A reduction of zero + /// preserves full resolution; each additional level halves both axes. + /// + /// # Errors + /// + /// Returns an error when a target-resolution hint is also configured, the + /// reduction is not representable, the codestream has too few resolution + /// levels, or the input is invalid. + #[doc(hidden)] + pub fn new_with_reduction( + data: &'a [u8], + settings: &DecodeSettings, + reduction_levels: u8, + ) -> Result { + if settings.target_resolution.is_some() { + return err!(DecodingError::UnsupportedFeature( + "exact reduction cannot be combined with a target-resolution hint", + )); + } + if data.starts_with(JP2_MAGIC) { + jp2::parse(data, *settings, Some(reduction_levels)) + } else if data.starts_with(CODESTREAM_MAGIC) { + j2c::parse(data, settings, Some(reduction_levels)) } else { err!(FormatError::InvalidSignature) } @@ -256,9 +286,9 @@ impl<'a> Image<'a> { return Self::new(data, settings); } if data.starts_with(JP2_MAGIC) { - jp2::parse_with_retained_baseline(data, *settings, retained_baseline_bytes) + jp2::parse_with_retained_baseline(data, *settings, retained_baseline_bytes, None) } else if data.starts_with(CODESTREAM_MAGIC) { - j2c::parse_with_retained_baseline(data, settings, retained_baseline_bytes) + j2c::parse_with_retained_baseline(data, settings, retained_baseline_bytes, None) } else { err!(FormatError::InvalidSignature) } diff --git a/crates/j2k-native/src/image/contract_tests.rs b/crates/j2k-native/src/image/contract_tests.rs index e2b2e76b..53d587ad 100644 --- a/crates/j2k-native/src/image/contract_tests.rs +++ b/crates/j2k-native/src/image/contract_tests.rs @@ -26,6 +26,35 @@ fn gray_fixture() -> (Vec, Vec) { (samples, encoded) } +fn insert_main_header_coc( + mut codestream: Vec, + component: u8, + decomposition_levels: u8, +) -> Vec { + let cod_offset = codestream + .windows(2) + .position(|marker| marker == [0xff, 0x52]) + .expect("COD marker"); + let cod_length = u16::from_be_bytes( + codestream[cod_offset + 2..cod_offset + 4] + .try_into() + .expect("COD length"), + ) as usize; + let cod_end = cod_offset + 2 + cod_length; + let coding_style = codestream[cod_offset + 4]; + let mut component_parameters = codestream[cod_offset + 9..cod_end].to_vec(); + component_parameters[0] = decomposition_levels; + + let coc_segment_length = u16::try_from(4 + component_parameters.len()).expect("COC length"); + let mut coc = vec![0xff, 0x53]; + coc.extend_from_slice(&coc_segment_length.to_be_bytes()); + coc.push(component); + coc.push(coding_style); + coc.extend_from_slice(&component_parameters); + codestream.splice(cod_end..cod_end, coc); + codestream +} + fn expected_crop(samples: &[u8], roi: (u32, u32, u32, u32)) -> Vec { let (x, y, width, height) = roi; let mut cropped = Vec::new(); @@ -172,3 +201,54 @@ fn retained_baseline_zero_and_nonzero_paths_preserve_parse_metadata() { retained.color_space().num_channels() ); } + +#[test] +fn exact_reduction_rejects_a_shorter_component_coc_ladder() { + let pixels = vec![17_u8; 64 * 64 * 3]; + let encoded = encode( + &pixels, + 64, + 64, + 3, + 8, + false, + &EncodeOptions { + num_decomposition_levels: 4, + reversible: true, + ..EncodeOptions::default() + }, + ) + .expect("RGB fixture encodes"); + let encoded = insert_main_header_coc(encoded, 1, 2); + + let Err(error) = Image::new_with_reduction(&encoded, &DecodeSettings::strict(), 3) else { + panic!("component COC must cap exact reduction"); + }; + + assert!(matches!( + error, + crate::DecodeError::Decoding(DecodingError::UnsupportedFeature( + "requested reduction exceeds the codestream resolution ladder" + )) + )); +} + +#[test] +fn exact_reduction_rejects_a_target_resolution_hint() { + let (_, encoded) = gray_fixture(); + let settings = DecodeSettings { + target_resolution: Some((1, 1)), + ..DecodeSettings::strict() + }; + + let Err(error) = Image::new_with_reduction(&encoded, &settings, 0) else { + panic!("two resolution policies must not be combined"); + }; + + assert!(matches!( + error, + crate::DecodeError::Decoding(DecodingError::UnsupportedFeature( + "exact reduction cannot be combined with a target-resolution hint" + )) + )); +} diff --git a/crates/j2k-native/src/j2c/codestream/header.rs b/crates/j2k-native/src/j2c/codestream/header.rs index 871d8ff9..232c3971 100644 --- a/crates/j2k-native/src/j2c/codestream/header.rs +++ b/crates/j2k-native/src/j2c/codestream/header.rs @@ -35,6 +35,7 @@ pub(crate) fn read_header<'a>( reader: &mut BitReader<'a>, settings: &DecodeSettings, retained_baseline_bytes: usize, + exact_reduction_levels: Option, ) -> Result> { if reader.read_marker()? != markers::SIZ { bail!(MarkerError::Expected("SIZ")); @@ -214,27 +215,38 @@ pub(crate) fn read_header<'a>( .map(super::model::ComponentInfo::num_resolution_levels) .min() .ok_or(ValidationError::InvalidComponentMetadata)?; - let skipped_resolution_levels = - if let Some((target_width, target_height)) = settings.target_resolution { - if target_width == 0 || target_height == 0 { - bail!(ValidationError::InvalidDimensions); - } - let width_log = - skipped_levels_to_reach_target(size_data.checked_image_width()?, target_width); - let height_log = - skipped_levels_to_reach_target(size_data.checked_image_height()?, target_height); - - width_log.min(height_log) - } else { - 0 + let max_skipped_resolution_levels = min_num_resolution_levels + .checked_sub(1) + .ok_or(ValidationError::InvalidComponentMetadata)?; + let skipped_resolution_levels = if let Some(requested) = exact_reduction_levels { + if requested > max_skipped_resolution_levels { + bail!(DecodingError::UnsupportedFeature( + "requested reduction exceeds the codestream resolution ladder", + )); + } + requested + } else if let Some((target_width, target_height)) = settings.target_resolution { + if target_width == 0 || target_height == 0 { + bail!(ValidationError::InvalidDimensions); } - .min(min_num_resolution_levels - 1); + let width_log = + skipped_levels_to_reach_target(size_data.checked_image_width()?, target_width); + let height_log = + skipped_levels_to_reach_target(size_data.checked_image_height()?, target_height); + + width_log.min(height_log).min(max_skipped_resolution_levels) + } else { + 0 + }; // If the user defined a maximum resolution level that is lower than the // maximum available one, the final image needs to be shrunk further. - let resolution_shrink_factor = 1u32 - .checked_shl(u32::from(skipped_resolution_levels)) - .ok_or(ValidationError::InvalidDimensions)?; + let Some(resolution_shrink_factor) = 1u32.checked_shl(u32::from(skipped_resolution_levels)) + else { + bail!(DecodingError::UnsupportedFeature( + "requested reduction exceeds supported image geometry", + )); + }; size_data.x_resolution_shrink_factor = size_data .x_resolution_shrink_factor .checked_mul(resolution_shrink_factor) diff --git a/crates/j2k-native/src/j2c/codestream/tests.rs b/crates/j2k-native/src/j2c/codestream/tests.rs index fab44e0a..2e143c02 100644 --- a/crates/j2k-native/src/j2c/codestream/tests.rs +++ b/crates/j2k-native/src/j2c/codestream/tests.rs @@ -162,8 +162,12 @@ fn incomplete_packet_length_varint_is_rejected() { #[test] fn public_marker_function_signatures_stay_stable() { - let _: for<'a> fn(&mut BitReader<'a>, &DecodeSettings, usize) -> Result> = - read_header; + let _: for<'a> fn( + &mut BitReader<'a>, + &DecodeSettings, + usize, + Option, + ) -> Result> = read_header; let _: fn(&mut BitReader<'_>) -> Result = cod_marker; let _: fn(&mut BitReader<'_>, u16) -> Result<(u16, CodingStyleComponent)> = coc_marker; let _: fn(&mut BitReader<'_>) -> Result = qcd_marker; diff --git a/crates/j2k-native/src/j2c/encode/multitile/tests.rs b/crates/j2k-native/src/j2c/encode/multitile/tests.rs index 8559d0a2..9506148b 100644 --- a/crates/j2k-native/src/j2c/encode/multitile/tests.rs +++ b/crates/j2k-native/src/j2c/encode/multitile/tests.rs @@ -130,7 +130,7 @@ fn direct_packet_owners_match_single_tile_marker_serialization() { let mut reader = BitReader::new(&codestream); assert_eq!(reader.read_marker().expect("SOC marker"), markers::SOC); - let header = read_header(&mut reader, &DecodeSettings::default(), 0) + let header = read_header(&mut reader, &DecodeSettings::default(), 0, None) .expect("serialized single-tile header"); assert_eq!(header.plm_packet_lengths, packetized.packet_lengths); assert_eq!(header.ppm_packets.len(), packetized.packet_headers.len()); diff --git a/crates/j2k-native/src/j2c/mod.rs b/crates/j2k-native/src/j2c/mod.rs index 6ec13d00..ebb681c4 100644 --- a/crates/j2k-native/src/j2c/mod.rs +++ b/crates/j2k-native/src/j2c/mod.rs @@ -67,19 +67,28 @@ pub(crate) struct ComponentData { crate::move_only::assert_move_only!(ComponentData); -pub(crate) fn parse<'a>(stream: &'a [u8], settings: &DecodeSettings) -> Result> { - parse_with_retained_baseline(stream, settings, 0) +pub(crate) fn parse<'a>( + stream: &'a [u8], + settings: &DecodeSettings, + exact_reduction_levels: Option, +) -> Result> { + parse_with_retained_baseline(stream, settings, 0, exact_reduction_levels) } pub(crate) fn parse_with_retained_baseline<'a>( stream: &'a [u8], settings: &DecodeSettings, retained_baseline_bytes: usize, + exact_reduction_levels: Option, ) -> Result> { let mut strict_settings = *settings; strict_settings.strict = true; - let parsed_codestream = - parse_raw_with_retained_baseline(stream, &strict_settings, retained_baseline_bytes)?; + let parsed_codestream = parse_raw_with_retained_baseline( + stream, + &strict_settings, + retained_baseline_bytes, + exact_reduction_levels, + )?; let header = &parsed_codestream.header; // Raw codestreams do not carry JP2 channel definitions. Keep the // conventional grayscale/RGB assumptions for 1- and 3-component images, @@ -133,13 +142,14 @@ pub(crate) fn parse_raw<'a>( stream: &'a [u8], settings: &DecodeSettings, ) -> Result> { - parse_raw_with_retained_baseline(stream, settings, 0) + parse_raw_with_retained_baseline(stream, settings, 0, None) } pub(crate) fn parse_raw_with_retained_baseline<'a>( stream: &'a [u8], settings: &DecodeSettings, retained_baseline_bytes: usize, + exact_reduction_levels: Option, ) -> Result> { let mut reader = BitReader::new(stream); @@ -148,7 +158,12 @@ pub(crate) fn parse_raw_with_retained_baseline<'a>( bail!(MarkerError::Expected("SOC")); } - let header = codestream::read_header(&mut reader, settings, retained_baseline_bytes)?; + let header = codestream::read_header( + &mut reader, + settings, + retained_baseline_bytes, + exact_reduction_levels, + )?; let code_stream_data = reader.tail().ok_or(FormatError::MissingCodestream)?; Ok(ParsedCodestream { diff --git a/crates/j2k-native/src/j2c/tile/tile_part.rs b/crates/j2k-native/src/j2c/tile/tile_part.rs index 28c9fab1..22b70c80 100644 --- a/crates/j2k-native/src/j2c/tile/tile_part.rs +++ b/crates/j2k-native/src/j2c/tile/tile_part.rs @@ -228,6 +228,16 @@ pub(super) fn parse_tile_part<'a>( } } + if tile + .component_infos + .iter() + .any(|component| component.num_resolution_levels() <= main_header.skipped_resolution_levels) + { + bail!(DecodingError::UnsupportedFeature( + "tile coding style has fewer levels than the requested reduction", + )); + } + let Some(remaining_bytes) = data_len.checked_sub(reader.offset() - start) else { return if main_header.strict { err!(TileError::Invalid) diff --git a/crates/j2k-native/src/j2c/tile/tile_part/tests.rs b/crates/j2k-native/src/j2c/tile/tile_part/tests.rs index 83d5885d..0fab4789 100644 --- a/crates/j2k-native/src/j2c/tile/tile_part/tests.rs +++ b/crates/j2k-native/src/j2c/tile/tile_part/tests.rs @@ -107,5 +107,6 @@ pub(super) fn tile_part_bytes( bytes } +mod reduction; mod sot; mod transaction; diff --git a/crates/j2k-native/src/j2c/tile/tile_part/tests/reduction.rs b/crates/j2k-native/src/j2c/tile/tile_part/tests/reduction.rs new file mode 100644 index 00000000..22f191c5 --- /dev/null +++ b/crates/j2k-native/src/j2c/tile/tile_part/tests/reduction.rs @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use super::super::parse_tile_part; +use super::{header, inherited_tile_state}; +use crate::reader::BitReader; +use crate::{DecodeError, DecodingError}; + +#[test] +fn tile_part_coc_cannot_shorten_the_requested_reduction_ladder() { + let mut header = header(); + header.skipped_resolution_levels = 1; + header.size_data.x_resolution_shrink_factor = 2; + header.size_data.y_resolution_shrink_factor = 2; + for component in &mut header.component_infos { + component.coding_style.parameters.num_decomposition_levels = 1; + component.coding_style.parameters.num_resolution_levels = 2; + component + .coding_style + .parameters + .precinct_exponents + .push((15, 15)); + } + header + .global_coding_style + .component_parameters + .parameters + .num_decomposition_levels = 1; + header + .global_coding_style + .component_parameters + .parameters + .num_resolution_levels = 2; + header + .global_coding_style + .component_parameters + .parameters + .precinct_exponents + .push((15, 15)); + + let (mut tiles, mut budget, _) = inherited_tile_state(&header); + let mut bytes = vec![ + 0xff, 0x90, 0x00, 0x0a, 0x00, 0x00, 0x00, 0x00, 0x00, 0x19, 0x00, 0x01, + ]; + // COC for component zero with no decomposition levels, followed by SOD. + bytes.extend_from_slice(&[ + 0xff, 0x53, 0x00, 0x09, 0x00, 0x00, 0x00, 0x04, 0x04, 0x00, 0x01, 0xff, 0x93, + ]); + let mut ppm_packet_idx = 0; + + let error = parse_tile_part( + &mut BitReader::new(&bytes), + &header, + &mut tiles, + &mut ppm_packet_idx, + &mut budget, + ) + .expect_err("tile COC must not undercut the requested reduction"); + + assert!(matches!( + error, + DecodeError::Decoding(DecodingError::UnsupportedFeature( + "tile coding style has fewer levels than the requested reduction" + )) + )); +} diff --git a/crates/j2k-native/src/jp2/container.rs b/crates/j2k-native/src/jp2/container.rs index 1e70f965..8ad3dbfc 100644 --- a/crates/j2k-native/src/jp2/container.rs +++ b/crates/j2k-native/src/jp2/container.rs @@ -114,14 +114,19 @@ pub fn extract_jp2_codestream_payload(data: &[u8]) -> Result<(Jp2FileKind, usize bail!(FormatError::MissingCodestream); } -pub(crate) fn parse(data: &[u8], settings: DecodeSettings) -> Result> { - parse_with_retained_baseline(data, settings, 0) +pub(crate) fn parse( + data: &[u8], + settings: DecodeSettings, + exact_reduction_levels: Option, +) -> Result> { + parse_with_retained_baseline(data, settings, 0, exact_reduction_levels) } pub(crate) fn parse_with_retained_baseline( data: &[u8], mut settings: DecodeSettings, retained_baseline_bytes: usize, + exact_reduction_levels: Option, ) -> Result> { let container = parse_jp2_container_with_strict_and_retained_baseline( data, @@ -144,6 +149,7 @@ pub(crate) fn parse_with_retained_baseline( container.codestream, &codestream_settings, retained_box_bytes, + exact_reduction_levels, )?; validate_codestream_file_kind(container.file_kind, &parsed_codestream.header)?; validate_image_header_matches_codestream(&image_boxes, &parsed_codestream.header)?; diff --git a/crates/j2k-native/src/tests.rs b/crates/j2k-native/src/tests.rs index b8230237..9ef28403 100644 --- a/crates/j2k-native/src/tests.rs +++ b/crates/j2k-native/src/tests.rs @@ -984,6 +984,7 @@ fn retained_container_metadata_rejects_header_parse_before_decode_growth() { &bytes, &DecodeSettings::default(), DEFAULT_MAX_DECODE_BYTES, + None, ) else { panic!("a full retained-container baseline must leave no room for the header"); }; diff --git a/crates/j2k/src/backend.rs b/crates/j2k/src/backend.rs index aa9c3406..433431a8 100644 --- a/crates/j2k/src/backend.rs +++ b/crates/j2k/src/backend.rs @@ -14,6 +14,15 @@ pub(crate) fn image( .map_err(J2kError::from_native_decode_error) } +pub(crate) fn image_with_reduction( + bytes: &[u8], + settings: DecodeSettings, + reduction_levels: u8, +) -> Result, J2kError> { + Image::new_with_reduction(bytes, &settings.to_native(None), reduction_levels) + .map_err(J2kError::from_native_decode_error) +} + pub(crate) fn inspect_info(bytes: &[u8]) -> Result { let image = image(bytes, DecodeSettings::strict(), None)?; Ok(inspect_info_from_image(&image)) diff --git a/crates/j2k/src/view.rs b/crates/j2k/src/view.rs index db1733c5..3e836cb4 100644 --- a/crates/j2k/src/view.rs +++ b/crates/j2k/src/view.rs @@ -1,7 +1,10 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 use crate::{ - backend::{image as backend_image, inspect_info, inspect_info_from_image, Image}, + backend::{ + image as backend_image, image_with_reduction as backend_image_with_reduction, inspect_info, + inspect_info_from_image, Image, + }, decode::{ decode_image_into_with_native_context, decode_image_region_into_with_native_context, decode_warnings_for_image, validate_buffer, validate_region, J2kDecodeOutcome, @@ -16,6 +19,7 @@ use j2k_core::{ PixelFormat, Rect, }; +mod deep_scale; mod rows; mod traits; diff --git a/crates/j2k/src/view/deep_scale.rs b/crates/j2k/src/view/deep_scale.rs new file mode 100644 index 00000000..8869e81c --- /dev/null +++ b/crates/j2k/src/view/deep_scale.rs @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use super::{ + backend_image_with_reduction, decode_image_region_into_with_native_context, + decode_warnings_for_image, validate_buffer, validate_region, J2kDecoder, +}; +use crate::{decode::J2kDecodeOutcome, scratch::J2kScratchPool, J2kError}; +use j2k_core::{PixelFormat, Rect, Unsupported}; + +const UNREPRESENTABLE_REDUCTION: &str = "requested reduction exceeds supported image geometry"; +const INEXACT_REDUCTION: &str = "native backend did not honor the requested reduction level"; + +fn unsupported(what: &'static str) -> J2kError { + J2kError::Unsupported(Unsupported { what }) +} + +fn reduction_denominator(levels: u8) -> Result { + 1_u32 + .checked_shl(u32::from(levels)) + .ok_or_else(|| unsupported(UNREPRESENTABLE_REDUCTION)) +} + +fn scaled_covering_pow2(rect: Rect, denominator: u32) -> Rect { + let x_end = rect.x.saturating_add(rect.w); + let y_end = rect.y.saturating_add(rect.h); + let x0 = rect.x / denominator; + let y0 = rect.y / denominator; + let x1 = x_end.div_ceil(denominator); + let y1 = y_end.div_ceil(denominator); + Rect { + x: x0, + y: y0, + w: x1.saturating_sub(x0), + h: y1.saturating_sub(y0), + } +} + +impl J2kDecoder<'_> { + /// Decode a source-coordinate region after discarding an exact number of + /// JPEG 2000 resolution levels. + /// + /// `levels` counts power-of-two halvings. Zero delegates to full-resolution + /// region decode, three is equivalent to [`j2k_core::Downscale::Eighth`], + /// and deeper values use the codestream's wavelet ladder. `roi` remains in + /// full-resolution source coordinates. + /// + /// # Errors + /// Returns [`J2kError`] when the reduction cannot be represented, exceeds + /// any component's available resolution ladder, is not honored exactly by + /// the native backend, or when region, buffer, format, or decode validation + /// fails. + pub fn decode_region_scaled_pow2_into( + &mut self, + pool: &mut J2kScratchPool, + out: &mut [u8], + stride: usize, + fmt: PixelFormat, + roi: Rect, + levels: u8, + ) -> Result { + if levels == 0 { + return self.decode_region_into(pool, out, stride, fmt, roi); + } + + let denominator = reduction_denominator(levels)?; + validate_region(roi, self.info.dimensions)?; + let scaled_roi = scaled_covering_pow2(roi, denominator); + validate_buffer((scaled_roi.w, scaled_roi.h), out.len(), stride, fmt)?; + + let expected_dims = ( + self.info.dimensions.0.div_ceil(denominator), + self.info.dimensions.1.div_ceil(denominator), + ); + let image = backend_image_with_reduction(self.bytes, self.settings, levels)?; + let image_dims = (image.width(), image.height()); + if image_dims != expected_dims { + return Err(unsupported(INEXACT_REDUCTION)); + } + validate_region(scaled_roi, image_dims)?; + + let warnings = decode_warnings_for_image(&image); + let mut native_context = self.scaled_decode_native_context(); + decode_image_region_into_with_native_context( + &image, + &mut native_context, + out, + stride, + fmt, + scaled_roi, + )?; + Ok(j2k_core::DecodeOutcome::new(scaled_roi, warnings)) + } +} diff --git a/crates/j2k/tests/deep_scale.rs b/crates/j2k/tests/deep_scale.rs new file mode 100644 index 00000000..4fa13bc9 --- /dev/null +++ b/crates/j2k/tests/deep_scale.rs @@ -0,0 +1,250 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use j2k::{DecodeSettings, J2kDecodeWarning, J2kDecoder, J2kError, J2kScratchPool}; +use j2k_core::{Downscale, PixelFormat, Rect}; +use j2k_native::{encode, EncodeOptions}; +use j2k_test_support::wrap_jp2_codestream; + +fn encode_rgb_fixture( + width: u32, + height: u32, + decomposition_levels: u8, + tile_size: Option<(u32, u32)>, +) -> Vec { + let pixels = (0..height) + .flat_map(|y| { + (0..width).flat_map(move |x| [(x % 251) as u8, (y % 241) as u8, ((x + y) % 233) as u8]) + }) + .collect::>(); + encode( + &pixels, + width, + height, + 3, + 8, + false, + &EncodeOptions { + reversible: true, + num_decomposition_levels: decomposition_levels, + tile_size, + ..EncodeOptions::default() + }, + ) + .expect("encode RGB fixture") +} + +fn scaled_covering_pow2(rect: Rect, levels: u8) -> Rect { + let denominator = 1_u32 + .checked_shl(u32::from(levels)) + .expect("test reduction is representable"); + let x_end = rect.x.saturating_add(rect.w); + let y_end = rect.y.saturating_add(rect.h); + let x0 = rect.x / denominator; + let y0 = rect.y / denominator; + let x1 = x_end.div_ceil(denominator); + let y1 = y_end.div_ceil(denominator); + Rect { + x: x0, + y: y0, + w: x1.saturating_sub(x0), + h: y1.saturating_sub(y0), + } +} + +fn crop_interleaved( + pixels: &[u8], + source_width: u32, + bytes_per_pixel: usize, + rect: Rect, +) -> Vec { + let source_stride = source_width as usize * bytes_per_pixel; + let row_bytes = rect.w as usize * bytes_per_pixel; + let mut cropped = Vec::with_capacity(row_bytes * rect.h as usize); + for row in rect.y as usize..(rect.y + rect.h) as usize { + let start = row * source_stride + rect.x as usize * bytes_per_pixel; + cropped.extend_from_slice(&pixels[start..start + row_bytes]); + } + cropped +} + +fn decode_reduced_region(bytes: &[u8], roi: Rect, levels: u8) -> Result<(Vec, Rect), J2kError> { + let format = PixelFormat::Rgb8; + let scaled = scaled_covering_pow2(roi, levels); + let stride = scaled.w as usize * format.bytes_per_pixel(); + let mut output = vec![0_u8; stride * scaled.h as usize]; + let mut decoder = J2kDecoder::new(bytes)?; + let outcome = decoder.decode_region_scaled_pow2_into( + &mut J2kScratchPool::new(), + &mut output, + stride, + format, + roi, + levels, + )?; + assert_eq!(outcome.decoded, scaled); + Ok((output, scaled)) +} + +#[test] +fn deep_scaled_region_matches_eighth_and_tiled_full_image_crops() { + let (width, height) = (640_u32, 512_u32); + let bytes = encode_rgb_fixture(width, height, 5, Some((256, 256))); + let full = Rect { + x: 0, + y: 0, + w: width, + h: height, + }; + let roi = Rect { + x: 96, + y: 64, + w: 320, + h: 288, + }; + + let (pow2_full, full_resolution_roi) = + decode_reduced_region(&bytes, roi, 0).expect("zero-level decode"); + let format = PixelFormat::Rgb8; + let full_stride = full_resolution_roi.w as usize * format.bytes_per_pixel(); + let mut ordinary_output = vec![0_u8; full_stride * full_resolution_roi.h as usize]; + J2kDecoder::new(&bytes) + .expect("ordinary decoder") + .decode_region_into( + &mut J2kScratchPool::new(), + &mut ordinary_output, + full_stride, + format, + roi, + ) + .expect("ordinary region decode"); + assert_eq!(pow2_full, ordinary_output); + + let (pow2_eighth, scaled_eighth) = + decode_reduced_region(&bytes, roi, 3).expect("1/8 power-of-two decode"); + let enum_stride = scaled_eighth.w as usize * format.bytes_per_pixel(); + let mut enum_output = vec![0_u8; enum_stride * scaled_eighth.h as usize]; + J2kDecoder::new(&bytes) + .expect("enum decoder") + .decode_region_scaled_into( + &mut J2kScratchPool::new(), + &mut enum_output, + enum_stride, + format, + roi, + Downscale::Eighth, + ) + .expect("1/8 enum decode"); + assert_eq!(pow2_eighth, enum_output); + + for levels in [4_u8, 5] { + let (whole, whole_rect) = + decode_reduced_region(&bytes, full, levels).expect("whole-image deep decode"); + let (region, region_rect) = + decode_reduced_region(&bytes, roi, levels).expect("region deep decode"); + assert_eq!( + region, + crop_interleaved(&whole, whole_rect.w, format.bytes_per_pixel(), region_rect,), + "1/{} region disagrees with the full-image crop", + 1_u32 << levels, + ); + } +} + +#[test] +fn deep_scaled_decode_honors_exact_level_for_a_skinny_image() { + let (width, height) = (17_u32, 257_u32); + let bytes = encode_rgb_fixture(width, height, 4, None); + let full = Rect { + x: 0, + y: 0, + w: width, + h: height, + }; + + let (output, decoded) = + decode_reduced_region(&bytes, full, 4).expect("exact 1/16 skinny decode"); + + assert_eq!( + decoded, + Rect { + x: 0, + y: 0, + w: 2, + h: 17 + } + ); + assert_eq!(output.len(), 34 * PixelFormat::Rgb8.bytes_per_pixel()); +} + +#[test] +fn deep_scaled_decode_rejects_ladder_and_shift_overflow() { + let (width, height) = (64_u32, 64_u32); + let bytes = encode_rgb_fixture(width, height, 5, None); + let full = Rect { + x: 0, + y: 0, + w: width, + h: height, + }; + let mut decoder = J2kDecoder::new(&bytes).expect("decoder"); + let mut output = vec![0_u8; width as usize * height as usize * 3]; + + for levels in [6_u8, 32] { + let error = decoder + .decode_region_scaled_pow2_into( + &mut J2kScratchPool::new(), + &mut output, + width as usize * 3, + PixelFormat::Rgb8, + full, + levels, + ) + .expect_err("invalid reduction must be rejected"); + assert!(matches!(error, J2kError::Unsupported(_))); + } +} + +#[test] +fn deep_scaled_decode_preserves_lenient_recovery_warning() { + let pixels = [3_u8, 9, 27, 81]; + let codestream = encode( + &pixels, + 2, + 2, + 1, + 8, + false, + &EncodeOptions { + reversible: true, + num_decomposition_levels: 1, + ..EncodeOptions::default() + }, + ) + .expect("encode grayscale fixture"); + let mut jp2 = wrap_jp2_codestream(&codestream, 2, 2, 1, 8, 17); + jp2.extend_from_slice(&[0, 0, 0, 16, b'x', b'm', b'l', b' ']); + let mut decoder = + J2kDecoder::new_with_settings(&jp2, DecodeSettings::lenient()).expect("lenient decoder"); + let mut output = [0_u8; 1]; + + let outcome = decoder + .decode_region_scaled_pow2_into( + &mut J2kScratchPool::new(), + &mut output, + 1, + PixelFormat::Gray8, + Rect { + x: 0, + y: 0, + w: 2, + h: 2, + }, + 1, + ) + .expect("lenient exact reduction"); + + assert_eq!( + outcome.warnings, + vec![J2kDecodeWarning::LenientMetadataRecovery] + ); +} diff --git a/docs/stable-api-1.0.implementation-public-api.txt b/docs/stable-api-1.0.implementation-public-api.txt index 5dfcdd52..9a96f9a3 100644 --- a/docs/stable-api-1.0.implementation-public-api.txt +++ b/docs/stable-api-1.0.implementation-public-api.txt @@ -2270,6 +2270,7 @@ pub fn j2k_native::Image<'a>::decode_native_with_retained_capacity(&self, usize) pub fn j2k_native::Image<'a>::decode_region_components_with_ht_decoder<'ctx>(&self, &'ctx mut j2k_native::DecoderContext<'a>, (u32, u32, u32, u32), &mut dyn j2k_native::HtCodeBlockDecoder) -> j2k_native::Result> pub fn j2k_native::Image<'a>::decode_reversible_53_coefficients(&self) -> j2k_native::Result pub fn j2k_native::Image<'a>::decode_reversible_53_coefficients_with_context(&self, &mut j2k_native::DecoderContext<'a>) -> j2k_native::Result +pub fn j2k_native::Image<'a>::new_with_reduction(&'a [u8], &j2k_native::DecodeSettings, u8) -> j2k_native::Result pub fn j2k_native::Image<'a>::new_with_retained_baseline(&'a [u8], &j2k_native::DecodeSettings, usize) -> j2k_native::Result pub fn j2k_native::Image<'a>::retained_allocation_bytes(&self) -> j2k_native::Result pub fn j2k_native::Image<'a>::supports_direct_device_plane_reuse(&self) -> bool diff --git a/docs/stable-api-1.0.public-api.txt b/docs/stable-api-1.0.public-api.txt index 5e5c5bd6..a6e28f28 100644 --- a/docs/stable-api-1.0.public-api.txt +++ b/docs/stable-api-1.0.public-api.txt @@ -235,6 +235,7 @@ pub fn j2k::J2kDecodedNativeComponents::color_space(&self) -> &j2k::J2kDecodedCo pub fn j2k::J2kDecodedNativeComponents::dimensions(&self) -> (u32, u32) pub fn j2k::J2kDecodedNativeComponents::has_alpha(&self) -> bool pub fn j2k::J2kDecodedNativeComponents::planes(&self) -> &[j2k::J2kNativeComponentPlane] +pub fn j2k::J2kDecoder<'_>::decode_region_scaled_pow2_into(&mut self, &mut j2k::J2kScratchPool, &mut [u8], usize, j2k_core::pixel::PixelFormat, j2k_core::types::Rect, u8) -> core::result::Result, j2k::J2kError> pub fn j2k::J2kDecoder<'_>::decode_rows_u16_bounded>(&mut self, &mut R, j2k::J2kRowDecodeOptions) -> core::result::Result, j2k_core::traits::DecodeRowsError::Error>> pub fn j2k::J2kDecoder<'_>::decode_rows_u8_bounded>(&mut self, &mut R, j2k::J2kRowDecodeOptions) -> core::result::Result, j2k_core::traits::DecodeRowsError::Error>> pub fn j2k::J2kDecoder<'a>::cpu_decode_parallelism(&self) -> j2k::CpuDecodeParallelism diff --git a/engineering/public-api-review-0.8.0.yml b/engineering/public-api-review-0.8.0.yml index ea097db3..82b0e4fd 100644 --- a/engineering/public-api-review-0.8.0.yml +++ b/engineering/public-api-review-0.8.0.yml @@ -30,10 +30,10 @@ break_ledger: reviews: j2k: removed_fingerprint: "fnv1a64:66f96e29b35dc974" - added_fingerprint: "fnv1a64:87ff4d4ed759e25b" + added_fingerprint: "fnv1a64:af14d7bd483fd32d" hidden_count: 104 hidden_fingerprint: "fnv1a64:4c828a7f3b5307a4" - rationale: "Reviewed the warning-variant rename and the additive J2kView and J2kDecoder settings constructors against v0.7.5. The source break is confined to the exact ledger item, while the new constructors make explicit lenient recovery available without mutable shared context policy." + rationale: "Reviewed the warning-variant rename and the additive settings constructors and exact power-of-two region reduction method against v0.7.5. The source break is confined to the exact ledger item; the constructors expose explicit lenient recovery, and the reduction method exposes deeper codestream resolution levels without changing the existing scale APIs." hidden_rationale: "Reviewed the complete hidden j2k inventory, including the new prepared-image recovery-use query, adapter-view borrows, retained classic and HT plans, and native pixel-format validation." j2k-core: removed_fingerprint: "none" @@ -121,10 +121,10 @@ reviews: j2k-native: removed_fingerprint: "none" added_fingerprint: "none" - hidden_count: 662 - hidden_fingerprint: "fnv1a64:c6883b9e9225ad87" + hidden_count: 663 + hidden_fingerprint: "fnv1a64:722e2b73b10d9585" rationale: "Reviewed the ordinary j2k-native inventory against v0.7.5; no public API item was added, removed, or changed." - hidden_rationale: "Reviewed the complete hidden native inventory, including the new image recovery-use query, retained plans, payload descriptors, staged entropy and IDWT execution, decoded-plane borrows, and allocation metrics." + hidden_rationale: "Reviewed the complete hidden native inventory, including the exact-reduction facade bridge, image recovery-use query, retained plans, payload descriptors, staged entropy and IDWT execution, decoded-plane borrows, and allocation metrics." j2k-types: removed_fingerprint: "none" added_fingerprint: "none" diff --git a/engineering/reviewed-public-api-diff-0.8.0.md b/engineering/reviewed-public-api-diff-0.8.0.md index 35854dce..7ffc4199 100644 --- a/engineering/reviewed-public-api-diff-0.8.0.md +++ b/engineering/reviewed-public-api-diff-0.8.0.md @@ -19,10 +19,10 @@ This report is generated by `cargo xtask semver --write-report`. Normal `cargo x | `j2k-codec-math` | `0.7.5` | `0.8.0` | `major` | 0 | 0 | `none` | `none` | 0 | `none` | | `j2k-cuda-runtime` | `0.7.5` | `0.8.0` | `major` | 0 | 0 | `none` | `none` | 1089 | `fnv1a64:d33ffdcb13c73dd5` | | `j2k-metal-support` | `0.7.5` | `0.8.0` | `major` | 0 | 0 | `none` | `none` | 11 | `fnv1a64:671e50d4d121b419` | -| `j2k-native` | `0.7.5` | `0.8.0` | `major` | 0 | 0 | `none` | `none` | 662 | `fnv1a64:c6883b9e9225ad87` | +| `j2k-native` | `0.7.5` | `0.8.0` | `major` | 0 | 0 | `none` | `none` | 663 | `fnv1a64:722e2b73b10d9585` | | `j2k-jpeg` | `0.7.5` | `0.8.0` | `major` | 0 | 0 | `none` | `none` | 550 | `fnv1a64:d2bd76732f32e066` | | `j2k-tilecodec` | `0.7.5` | `0.8.0` | `major` | 0 | 0 | `none` | `none` | 32 | `fnv1a64:eb677842559d9b0e` | -| `j2k` | `0.7.5` | `0.8.0` | `major` | 3 | 1 | `fnv1a64:66f96e29b35dc974` | `fnv1a64:87ff4d4ed759e25b` | 104 | `fnv1a64:4c828a7f3b5307a4` | +| `j2k` | `0.7.5` | `0.8.0` | `major` | 4 | 1 | `fnv1a64:66f96e29b35dc974` | `fnv1a64:af14d7bd483fd32d` | 104 | `fnv1a64:4c828a7f3b5307a4` | | `j2k-transcode` | `0.7.5` | `0.8.0` | `major` | 0 | 0 | `none` | `none` | 455 | `fnv1a64:fdd61f6fdda4a9ab` | | `j2k-transcode-cuda` | `0.7.5` | `0.8.0` | `major` | 0 | 0 | `none` | `none` | 17 | `fnv1a64:3e3e69d35c4a62a5` | | `j2k-jpeg-metal` | `0.7.5` | `0.8.0` | `major` | 0 | 0 | `none` | `none` | 83 | `fnv1a64:8c83730972447ab6` | @@ -108,7 +108,7 @@ None. ### `j2k-native` -Baseline items: 377. Candidate items: 377. Computed release type: `major`. Rustdoc-hidden candidate items: 662. Full hidden-inventory fingerprint: `fnv1a64:c6883b9e9225ad87`. +Baseline items: 377. Candidate items: 377. Computed release type: `major`. Rustdoc-hidden candidate items: 663. Full hidden-inventory fingerprint: `fnv1a64:722e2b73b10d9585`. #### Removed or changed baseline API items @@ -144,7 +144,7 @@ None. ### `j2k` -Baseline items: 761. Candidate items: 763. Computed release type: `major`. Rustdoc-hidden candidate items: 104. Full hidden-inventory fingerprint: `fnv1a64:4c828a7f3b5307a4`. +Baseline items: 761. Candidate items: 764. Computed release type: `major`. Rustdoc-hidden candidate items: 104. Full hidden-inventory fingerprint: `fnv1a64:4c828a7f3b5307a4`. #### Removed or changed baseline API items @@ -155,6 +155,7 @@ pub j2k::J2kDecodeWarning::LenientDecodeMode #### Added candidate API items ```text +pub fn j2k::J2kDecoder<'_>::decode_region_scaled_pow2_into(&mut self, &mut j2k::J2kScratchPool, &mut [u8], usize, j2k_core::pixel::PixelFormat, j2k_core::types::Rect, u8) -> core::result::Result, j2k::J2kError> pub fn j2k::J2kDecoder<'a>::new_with_settings(&'a [u8], j2k::DecodeSettings) -> core::result::Result pub fn j2k::J2kView<'a>::parse_with_settings(&'a [u8], j2k::DecodeSettings) -> core::result::Result pub j2k::J2kDecodeWarning::LenientMetadataRecovery From f894c5415f708a949b4230d82c963daf97a11631 Mon Sep 17 00:00:00 2001 From: GF Date: Mon, 3 Aug 2026 22:18:19 -0400 Subject: [PATCH 2/3] fix(metal): preserve cropped IDWT window origins --- crates/j2k-metal/src/compute/direct_roi.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/j2k-metal/src/compute/direct_roi.rs b/crates/j2k-metal/src/compute/direct_roi.rs index b52aa615..6ccbbc37 100644 --- a/crates/j2k-metal/src/compute/direct_roi.rs +++ b/crates/j2k-metal/src/compute/direct_roi.rs @@ -284,10 +284,10 @@ pub(super) fn idwt_input_windows_from_slices( hh: &DirectBandSlice, ) -> PreparedIdwtInputWindows { PreparedIdwtInputWindows { - ll: BandRequiredRegion::full(ll.window.width(), ll.window.height()), - hl: BandRequiredRegion::full(hl.window.width(), hl.window.height()), - lh: BandRequiredRegion::full(lh.window.width(), lh.window.height()), - hh: BandRequiredRegion::full(hh.window.width(), hh.window.height()), + ll: ll.window, + hl: hl.window, + lh: lh.window, + hh: hh.window, } } From a4482bfccf3f5bed3e6bb153b65b4083333e1c2c Mon Sep 17 00:00:00 2001 From: GF Date: Mon, 3 Aug 2026 22:31:23 -0400 Subject: [PATCH 3/3] fix(ci): fetch release tags for repo lint --- .github/workflows/ci.yml | 2 ++ .../workflow_structure_policy.rs | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f868bc9..691508a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + fetch-depth: 0 - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c with: toolchain: "1.96" diff --git a/xtask/tests/repo_lint_support/workflow_structure_policy.rs b/xtask/tests/repo_lint_support/workflow_structure_policy.rs index 0e028794..a4497db9 100644 --- a/xtask/tests/repo_lint_support/workflow_structure_policy.rs +++ b/xtask/tests/repo_lint_support/workflow_structure_policy.rs @@ -168,6 +168,37 @@ fn stable_api_job_fetches_release_tags() { ); } +#[test] +fn rust_quality_job_fetches_release_tags() { + let ci = workflow("ci.yml"); + let root = value_mapping(&ci.document, &ci.file_name); + let rust_quality = jobs(root, &ci.file_name) + .get(Value::String("rust-quality".to_owned())) + .and_then(Value::as_mapping) + .expect("rust-quality job"); + let steps = mapping_get(rust_quality, "steps") + .and_then(Value::as_sequence) + .expect("rust-quality steps"); + let checkout = steps + .iter() + .filter_map(Value::as_mapping) + .find(|step| { + mapping_get(step, "uses") + .and_then(Value::as_str) + .is_some_and(|reference| reference.starts_with("actions/checkout@")) + }) + .expect("rust-quality checkout step"); + let fetches_full_history = mapping_get(checkout, "with") + .and_then(Value::as_mapping) + .and_then(|with| mapping_get(with, "fetch-depth")) + .is_some_and(is_numeric_zero); + + assert!( + fetches_full_history, + "rust-quality checkout must fetch release tags used by repo-lint" + ); +} + #[test] fn manual_publish_dispatch_is_dry_run_only() { let publish = workflow("publish.yml");