diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c38774..7922f64a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,17 @@ and stale roadmap entries have been removed from the public documentation set. orchestration roots by pipeline ownership, with repository policies enforcing dependency direction, source boundaries, unsafe inventory, clone ceilings, benchmark registration, and generated routing evidence. +- Adds bounded HTJ2K lossy candidate generation with two consecutive HT sets, + exact cleanup/SigProp/MagRef byte boundaries, per-pass distortion scoring, + and dependency-aware slope allocation. CUDA and Metal can produce the exact + candidate sets on device; CUDA entropy writing remains serial within each + code block. Each selected HT set is emitted atomically in the layer of its + final selected pass so external decoders retain an unambiguous cleanup + boundary. +- Makes bounded classic JPEG 2000 and HTJ2K row decode through 24-bit component + precision parse the tile graph once per row operation and reuse it across + stripes while retaining the parsed metadata in the aggregate allocation + baseline. Higher-precision exact-integer rows keep their compatibility path. - Promotes measured staged CUDA JPEG encoding and adaptive CUDA JPEG checkpoint launch geometry. The staged encoder improves the representative 512 x 512 batch-8 cells by about 95%, while the checkpoint policy uses one-thread diff --git a/README.md b/README.md index 7177b255..f7978ee8 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **Docs & guides:** [Pure-Rust JPEG 2000 codec documentation](https://frames-sg.github.io/j2k/rust-jpeg2000-codec/) -**Release status:** `0.9.0` is published and security-supported. See the +**Release status:** `0.10.0` is published and security-supported. See the [release notes](CHANGELOG.md), [release policy](docs/release.md), and [security policy](SECURITY.md). @@ -293,6 +293,11 @@ surfaces are available for supported paths; unsupported explicit CUDA requests fail clearly. J2K-owned CUDA kernels are used for CUDA codec stages. NVIDIA performance claims require recorded self-hosted benchmark output. +Lossy HTJ2K encoding can opt into the OpenHTJ2K-compatible visual Qfactor +profile with `J2kLossyEncodeOptions::with_qfactor(Some(quality))`, where +`quality` is `1..=100`. This profile is intentionally separate from byte, +bits-per-pixel, PSNR, quality-layer-target, and ROI controls. + ## Public API and support policy Stable APIs are `j2k`, `j2k-core` traits and value types, `j2k-jpeg`, @@ -302,6 +307,10 @@ transcode crates, and backend encode-stage adapter SPI. Codec contracts include `ImageDecode`, `decode_region_scaled_into`, `decode_rows`, `TileBatchDecode`, `DeviceSurface`, `ScratchPool`, and the concrete `J2kContext` and `j2k_jpeg::DecoderContext` types. +Bounded JPEG 2000/HTJ2K row decode through 24-bit component precision retains +one parsed tile graph for the operation and reuses it across stripes; stripe +output scratch remains bounded by `J2kRowDecodeOptions`. Higher-precision exact +integer output keeps the existing full-decode/crop compatibility path. `BackendRequest::Auto` may return CPU output. `BackendRequest::Metal` and `BackendRequest::Cuda` are strict and fail for unsupported shapes. diff --git a/SECURITY.md b/SECURITY.md index f94d0406..ad02a379 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,7 +4,8 @@ | Version | Status | | --- | --- | -| `0.9.0` | Latest published and security-supported release | +| `0.10.0` | Latest published and security-supported release | +| `0.9.0` | Previous published release line; security-supported | | `0.8.1` | Previous published release line; security-supported | | `0.8.0` | Previous published release line; security-supported | | `0.7.5` | Previous published release line; security-supported, except for the documented `j2k-ml` CUDA and Metal packaging defect | diff --git a/crates/j2k-alloc-probe/tests/main.rs b/crates/j2k-alloc-probe/tests/main.rs index ce74f7ab..659813e7 100644 --- a/crates/j2k-alloc-probe/tests/main.rs +++ b/crates/j2k-alloc-probe/tests/main.rs @@ -7,10 +7,11 @@ use std::sync::{mpsc, OnceLock}; use j2k_alloc_probe::{assert_allocations, measure, Budget}; 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, Image, J2kForwardDwt97Output, - PrecomputedHtj2k97Component, PrecomputedHtj2k97Image, + HtCodeBlockDecodeJob, HtCodeBlockDecodeWorkspace, HtCodeBlockEncodeWorkspace, Image, + J2kForwardDwt97Output, PrecomputedHtj2k97Component, PrecomputedHtj2k97Image, }; use proptest::{ collection::vec, @@ -51,6 +52,10 @@ fn main() { "warmed_scalar_decode_workspace_reuses_without_allocating", warmed_scalar_decode_workspace_reuses_without_allocating, ), + ( + "warmed_ht_encode_workspace_avoids_fixed_reservoir_allocations", + warmed_ht_encode_workspace_avoids_fixed_reservoir_allocations, + ), ( "warmed_decoder_context_has_bounded_transients", warmed_decoder_context_has_bounded_transients, @@ -257,6 +262,34 @@ fn warmed_scalar_decode_workspace_reuses_without_allocating() { ); } +fn warmed_ht_encode_workspace_avoids_fixed_reservoir_allocations() { + let coefficients = (0_i32..64 * 64) + .map(|index| if index % 13 == 0 { 0 } else { index & 255 }) + .collect::>(); + let mut workspace = HtCodeBlockEncodeWorkspace::try_new().expect("HT encode workspace"); + + let (encoded, reused) = measure(|| { + encode_ht_code_block_scalar_with_passes_and_workspace( + &coefficients, + 64, + 64, + 9, + 1, + &mut workspace, + ) + }); + let encoded = encoded.expect("reused-workspace HT encode"); + let (_, fresh) = measure(|| { + j2k_native::encode_ht_code_block_scalar_with_passes(&coefficients, 64, 64, 9, 1) + }); + + assert!(!encoded.data.is_empty()); + assert!( + reused.allocation_calls() + 3 <= fresh.allocation_calls(), + "the reusable workspace must remove the MEL, VLC, and magnitude/sign reservoir allocations: reused={reused:?}, fresh={fresh:?}" + ); +} + fn warmed_decoder_context_has_bounded_transients() { let pool = probe_pool(); let pixels = (0_u8..64).collect::>(); diff --git a/crates/j2k-compare/build.rs b/crates/j2k-compare/build.rs index d6f3ffa7..8ade1f41 100644 --- a/crates/j2k-compare/build.rs +++ b/crates/j2k-compare/build.rs @@ -5,52 +5,195 @@ use std::{ fn main() { println!("cargo:rustc-check-cfg=cfg(have_grok)"); + println!("cargo:rustc-check-cfg=cfg(have_openhtj2k)"); + println!("cargo:rustc-check-cfg=cfg(have_openjph)"); println!("cargo:rerun-if-env-changed=J2K_GROK_ROOT"); println!("cargo:rerun-if-env-changed=J2K_GROK_SOURCE"); println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH"); println!("cargo:rerun-if-changed=src/grok_shim.c"); + println!("cargo:rerun-if-env-changed=J2K_OPENHTJ2K_SOURCE_DIR"); + println!("cargo:rerun-if-env-changed=J2K_OPENHTJ2K_LIB_DIR"); + println!("cargo:rerun-if-changed=src/openhtj2k_shim.cpp"); + println!("cargo:rerun-if-env-changed=J2K_OPENJPH_SOURCE_DIR"); + println!("cargo:rerun-if-env-changed=J2K_OPENJPH_LIB_DIR"); + println!("cargo:rerun-if-changed=src/openjph_shim.cpp"); if let Some(config) = grok_config() { - let staged_lib_dir = stage_grok_runtime(&config.lib_dir) - .unwrap_or_else(|err| panic!("failed to stage Grok runtime libraries: {err}")); - let grok_version = grok_version(&config); - println!("cargo:rustc-cfg=have_grok"); - println!("cargo:rustc-env=J2K_GROK_VERSION={grok_version}"); - println!( - "cargo:rustc-env=J2K_GROK_LIB_DIR={}", - config.lib_dir.display() - ); - println!( - "cargo:rustc-link-search=native={}", - staged_lib_dir.display() - ); - println!( - "cargo:rustc-link-search=native={}", - config.lib_dir.display() - ); - println!("cargo:rustc-link-lib=dylib=grokj2k"); - #[cfg(target_os = "macos")] - println!( - "cargo:rustc-link-arg=-Wl,-rpath,{}", - config.lib_dir.display() - ); - #[cfg(target_os = "macos")] - println!( - "cargo:rustc-link-arg=-Wl,-rpath,{}", - staged_lib_dir.display() - ); - - cc::Build::new() - .file("src/grok_shim.c") - .include(config.source_include) - .include(config.build_include) - .warnings(true) - .extra_warnings(true) - .warnings_into_errors(true) - .flag_if_supported("-Wconversion") - .flag_if_supported("-Wsign-conversion") - .compile("j2k_grok_shim"); + configure_grok(config); } + if let Some(config) = openhtj2k_config() { + configure_openhtj2k(&config); + } + if let Some(config) = openjph_config() { + configure_openjph(&config); + } +} + +fn configure_grok(config: GrokConfig) { + let staged_lib_dir = stage_grok_runtime(&config.lib_dir) + .unwrap_or_else(|err| panic!("failed to stage Grok runtime libraries: {err}")); + let grok_version = grok_version(&config); + println!("cargo:rustc-cfg=have_grok"); + println!("cargo:rustc-env=J2K_GROK_VERSION={grok_version}"); + println!( + "cargo:rustc-env=J2K_GROK_LIB_DIR={}", + config.lib_dir.display() + ); + println!( + "cargo:rustc-link-search=native={}", + staged_lib_dir.display() + ); + println!( + "cargo:rustc-link-search=native={}", + config.lib_dir.display() + ); + println!("cargo:rustc-link-lib=dylib=grokj2k"); + #[cfg(target_os = "macos")] + println!( + "cargo:rustc-link-arg=-Wl,-rpath,{}", + config.lib_dir.display() + ); + #[cfg(target_os = "macos")] + println!( + "cargo:rustc-link-arg=-Wl,-rpath,{}", + staged_lib_dir.display() + ); + + cc::Build::new() + .file("src/grok_shim.c") + .include(config.source_include) + .include(config.build_include) + .warnings(true) + .extra_warnings(true) + .warnings_into_errors(true) + .flag_if_supported("-Wconversion") + .flag_if_supported("-Wsign-conversion") + .compile("j2k_grok_shim"); +} + +fn configure_openhtj2k(config: &StaticReferenceConfig) { + println!("cargo:rustc-cfg=have_openhtj2k"); + println!( + "cargo:rustc-env=J2K_OPENHTJ2K_LIB_DIR={}", + config.lib_dir.display() + ); + println!("cargo:rustc-env=J2K_OPENHTJ2K_VERSION={}", config.version); + println!( + "cargo:rustc-link-search=native={}", + config.lib_dir.display() + ); + println!("cargo:rustc-link-lib=static=openhtj2k"); + + cc::Build::new() + .cpp(true) + .file("src/openhtj2k_shim.cpp") + .include(config.source_dir.join("source/core/interface")) + .include(config.source_dir.join("source/core/common")) + .warnings(true) + .extra_warnings(true) + .warnings_into_errors(true) + .flag_if_supported("-std=c++17") + .flag_if_supported("-Wconversion") + .flag_if_supported("-Wsign-conversion") + .compile("j2k_openhtj2k_shim"); +} + +fn configure_openjph(config: &StaticReferenceConfig) { + println!("cargo:rustc-cfg=have_openjph"); + println!( + "cargo:rustc-env=J2K_OPENJPH_LIB_DIR={}", + config.lib_dir.display() + ); + println!("cargo:rustc-env=J2K_OPENJPH_VERSION={}", config.version); + println!( + "cargo:rustc-link-search=native={}", + config.lib_dir.display() + ); + println!("cargo:rustc-link-lib=static=openjph"); + + cc::Build::new() + .cpp(true) + .file("src/openjph_shim.cpp") + .include(config.source_dir.join("src/core")) + .warnings(true) + .extra_warnings(true) + .warnings_into_errors(true) + .flag_if_supported("-std=c++14") + .flag_if_supported("-Wconversion") + .flag_if_supported("-Wsign-conversion") + .compile("j2k_openjph_shim"); +} + +struct StaticReferenceConfig { + source_dir: PathBuf, + lib_dir: PathBuf, + version: String, +} + +fn openjph_config() -> Option { + static_reference_config( + "J2K_OPENJPH_SOURCE_DIR", + "J2K_OPENJPH_LIB_DIR", + "build-reference/src/core", + "src/core/openjph/ojph_codestream.h", + &["libopenjph.a", "openjph.lib"], + None, + ) +} + +fn openhtj2k_config() -> Option { + static_reference_config( + "J2K_OPENHTJ2K_SOURCE_DIR", + "J2K_OPENHTJ2K_LIB_DIR", + "build-reference", + "source/core/interface/decoder.hpp", + &["libopenhtj2k.a", "openhtj2k.lib"], + Some("v"), + ) +} + +fn static_reference_config( + source_env: &str, + lib_env: &str, + default_lib_dir: &str, + required_header: &str, + library_names: &[&str], + version_prefix_to_strip: Option<&str>, +) -> Option { + let source_dir = PathBuf::from(std::env::var_os(source_env)?); + let lib_dir = + std::env::var_os(lib_env).map_or_else(|| source_dir.join(default_lib_dir), PathBuf::from); + if !source_dir.join(required_header).is_file() + || !library_names + .iter() + .any(|name| lib_dir.join(name).is_file()) + { + return None; + } + Some(StaticReferenceConfig { + version: exact_git_tag(&source_dir, version_prefix_to_strip), + source_dir, + lib_dir, + }) +} + +fn exact_git_tag(source_dir: &Path, prefix_to_strip: Option<&str>) -> String { + Command::new("git") + .args(["-C"]) + .arg(source_dir) + .args(["describe", "--tags", "--exact-match", "HEAD"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|value| { + let value = value.trim(); + prefix_to_strip + .map_or(value, |prefix| value.trim_start_matches(prefix)) + .to_string() + }) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) } fn stage_grok_runtime(lib_dir: &Path) -> Result { diff --git a/crates/j2k-compare/src/bin/jp2k_batch_compare.rs b/crates/j2k-compare/src/bin/jp2k_batch_compare.rs index 3c586356..a5263302 100644 --- a/crates/j2k-compare/src/bin/jp2k_batch_compare.rs +++ b/crates/j2k-compare/src/bin/jp2k_batch_compare.rs @@ -5,7 +5,8 @@ use std::path::{Path, PathBuf}; use j2k::{decode_tiles_into, J2kDecoder, PixelFormat, TileBatchOptions, TileDecodeJob}; use j2k_compare::{ - grok, measure_repeated, openjpeg, parse_positive_usize, sample_stats, usize_to_f64, + grok, measure_repeated, openhtj2k, openjpeg, openjph, parse_positive_usize, sample_stats, + usize_to_f64, }; use j2k_core::tile_batch_worker_count; @@ -31,10 +32,50 @@ struct Measurement { decoded_bytes_per_repeat: usize, } +struct RunOptions { + tile_dir: PathBuf, + batch_sizes: Vec, + repeats: usize, + workers: Option, +} + #[derive(Clone, Copy)] enum ExternalDecoder { OpenJpeg, Grok, + OpenHtj2k, + OpenJph, +} + +impl ExternalDecoder { + const OPTIONAL: [Self; 3] = [Self::Grok, Self::OpenHtj2k, Self::OpenJph]; + + const fn label(self) -> &'static str { + match self { + Self::OpenJpeg => "openjpeg", + Self::Grok => "grok", + Self::OpenHtj2k => "openhtj2k", + Self::OpenJph => "openjph", + } + } + + const fn display_name(self) -> &'static str { + match self { + Self::OpenJpeg => "OpenJPEG", + Self::Grok => "Grok", + Self::OpenHtj2k => "OpenHTJ2K", + Self::OpenJph => "OpenJPH", + } + } + + fn is_available(self) -> bool { + match self { + Self::OpenJpeg => openjpeg::is_available(), + Self::Grok => grok::is_available(), + Self::OpenHtj2k => openhtj2k::is_available(), + Self::OpenJph => openjph::is_available(), + } + } } fn main() { @@ -45,6 +86,38 @@ fn main() { } fn run() -> Result<(), String> { + let options = parse_run_options()?; + let max_batch_size = options + .batch_sizes + .iter() + .copied() + .max() + .ok_or_else(|| "no batch sizes requested".to_string())?; + let (tiles, skipped) = load_tiles(&options.tile_dir, max_batch_size)?; + if tiles.len() < max_batch_size { + return Err(format!( + "only loaded {} supported tiles from {}; need {max_batch_size}; skipped {skipped}", + tiles.len(), + options.tile_dir.display() + )); + } + let format = tiles[0].format; + if !tiles.iter().all(|tile| tile.format == format) { + return Err("selected tiles do not share one output pixel format".to_string()); + } + let (openhtj2k_max_abs_diff, openjph_max_abs_diff) = reference_parity(&tiles, format)?; + emit_configuration( + &options, + &tiles, + skipped, + format, + &openhtj2k_max_abs_diff, + &openjph_max_abs_diff, + ); + emit_all_measurements(&options, &tiles) +} + +fn parse_run_options() -> Result { let args = std::env::args().skip(1).collect::>(); if args.is_empty() { return Err("usage: jp2k_batch_compare [batch-size ...]".to_string()); @@ -65,11 +138,6 @@ fn run() -> Result<(), String> { } else { DEFAULT_BATCH_SIZES.to_vec() }; - let max_batch_size = batch_sizes - .iter() - .copied() - .max() - .ok_or_else(|| "no batch sizes requested".to_string())?; let repeats = std::env::var("J2K_BATCH_COMPARE_REPEATS") .ok() .map(|value| parse_positive_usize(&value, "J2K_BATCH_COMPARE_REPEATS")) @@ -81,50 +149,101 @@ fn run() -> Result<(), String> { .transpose()? .map(|value| NonZeroUsize::new(value).expect("positive value was validated")); - let (tiles, skipped) = load_tiles(&tile_dir, max_batch_size)?; - if tiles.len() < max_batch_size { - return Err(format!( - "only loaded {} supported tiles from {}; need {max_batch_size}; skipped {skipped}", - tiles.len(), - tile_dir.display() - )); - } - let format = tiles[0].format; - if !tiles.iter().all(|tile| tile.format == format) { - return Err("selected tiles do not share one output pixel format".to_string()); - } + Ok(RunOptions { + tile_dir, + batch_sizes, + repeats, + workers, + }) +} +fn reference_parity(tiles: &[TileInput], format: PixelFormat) -> Result<(String, String), String> { + let allowed_reference_difference = std::env::var("J2K_BATCH_COMPARE_MAX_ABS_DIFF") + .ok() + .map(|value| { + value.parse::().map_err(|error| { + format!("invalid J2K_BATCH_COMPARE_MAX_ABS_DIFF {value:?}: {error}") + }) + }) + .transpose()? + .unwrap_or(1); + let openhtj2k_max_abs_diff = reference_parity_result( + tiles, + format, + ExternalDecoder::OpenHtj2k, + allowed_reference_difference, + )?; + let openjph_max_abs_diff = reference_parity_result( + tiles, + format, + ExternalDecoder::OpenJph, + allowed_reference_difference, + )?; + + Ok((openhtj2k_max_abs_diff, openjph_max_abs_diff)) +} + +fn emit_configuration( + options: &RunOptions, + tiles: &[TileInput], + skipped: usize, + format: PixelFormat, + openhtj2k_max_abs_diff: &str, + openjph_max_abs_diff: &str, +) { println!( - "tile_dir\t{}\nloaded_tiles\t{}\nskipped_unsupported\t{}\nformat\t{:?}\nworkers\t{}\nopenjpeg_available\t{}\ngrok_available\t{}", - tile_dir.display(), + "tile_dir\t{}\nloaded_tiles\t{}\nskipped_unsupported\t{}\nformat\t{:?}\nworkers\t{}\nopenjpeg_available\t{}\ngrok_available\t{}\nopenhtj2k_available\t{}\nopenhtj2k_version\t{}\nopenhtj2k_library\t{}\nopenhtj2k_max_abs_diff\t{}\nopenjph_available\t{}\nopenjph_version\t{}\nopenjph_library\t{}\nopenjph_max_abs_diff\t{}", + options.tile_dir.display(), tiles.len(), skipped, format, - workers.map_or_else(|| "auto".to_string(), |value| value.get().to_string()), + options + .workers + .map_or_else(|| "auto".to_string(), |value| value.get().to_string()), openjpeg::is_available(), - grok::is_available() + grok::is_available(), + openhtj2k::is_available(), + openhtj2k::version(), + openhtj2k::library_path(), + openhtj2k_max_abs_diff, + openjph::is_available(), + openjph::version(), + openjph::library_path(), + openjph_max_abs_diff, ); println!( "decoder\tbatch_size\trepeats\tmedian_ms\tmean_ms\ttiles_per_second_median\tdecoded_bytes_per_repeat\tsamples_ms" ); +} - for batch_size in batch_sizes { - emit_measurement(measure_j2k(&tiles[..batch_size], repeats, workers)?); +fn emit_all_measurements(options: &RunOptions, tiles: &[TileInput]) -> Result<(), String> { + for &batch_size in &options.batch_sizes { + emit_measurement(measure_j2k( + &tiles[..batch_size], + options.repeats, + options.workers, + )?); emit_measurement(measure_external( &tiles[..batch_size], - repeats, - workers, + options.repeats, + options.workers, ExternalDecoder::OpenJpeg, )?); - if grok::is_available() { - emit_measurement(measure_external( - &tiles[..batch_size], - repeats, - workers, - ExternalDecoder::Grok, - )?); - } else { - println!("grok\t{batch_size}\t{repeats}\tNA\tNA\tNA\tNA\tunavailable"); + for decoder in ExternalDecoder::OPTIONAL { + if decoder.is_available() { + emit_measurement(measure_external( + &tiles[..batch_size], + options.repeats, + options.workers, + decoder, + )?); + } else { + println!( + "{}\t{batch_size}\t{}\tNA\tNA\tNA\tNA\tunavailable", + decoder.label(), + options.repeats + ); + } } } @@ -226,6 +345,17 @@ fn decode_j2k_once( format: PixelFormat, workers: Option, ) -> Result { + Ok(decode_j2k_outputs(tiles, format, workers)? + .iter() + .map(Vec::len) + .sum()) +} + +fn decode_j2k_outputs( + tiles: &[TileInput], + format: PixelFormat, + workers: Option, +) -> Result>, String> { let mut outputs = tiles .iter() .map(|tile| vec![0_u8; output_len(tile, format)]) @@ -241,7 +371,58 @@ fn decode_j2k_once( .collect::>(); decode_tiles_into(&mut jobs, format, TileBatchOptions { workers }) .map_err(|err| format!("j2k batch decode failed: {err}"))?; - Ok(outputs.iter().map(Vec::len).sum()) + Ok(outputs) +} + +fn reference_parity_result( + tiles: &[TileInput], + format: PixelFormat, + decoder: ExternalDecoder, + allowed_difference: u8, +) -> Result { + if !decoder.is_available() { + return Ok("unavailable".to_string()); + } + let observed = validate_reference_parity(tiles, format, decoder)?; + if observed > allowed_difference { + return Err(format!( + "{} maximum absolute byte difference {observed} exceeds the configured {allowed_difference}", + decoder.display_name() + )); + } + Ok(observed.to_string()) +} + +fn validate_reference_parity( + tiles: &[TileInput], + format: PixelFormat, + decoder: ExternalDecoder, +) -> Result { + let native = decode_j2k_outputs(tiles, format, NonZeroUsize::new(1))?; + tiles + .iter() + .zip(native) + .try_fold(0_u8, |maximum, (tile, expected)| { + let actual = decode_external_tile(tile, decoder)?; + if actual.len() != expected.len() { + return Err(format!( + "{}: {} decoded {} bytes, native decoded {}", + tile.path.display(), + decoder.label(), + actual.len(), + expected.len() + )); + } + Ok(maximum.max(maximum_absolute_byte_difference(&actual, &expected))) + }) +} + +fn maximum_absolute_byte_difference(left: &[u8], right: &[u8]) -> u8 { + left.iter() + .zip(right) + .map(|(&left, &right)| left.abs_diff(right)) + .max() + .unwrap_or(0) } fn measure_external( @@ -250,10 +431,7 @@ fn measure_external( workers: Option, decoder: ExternalDecoder, ) -> Result { - let decoder_name = match decoder { - ExternalDecoder::OpenJpeg => "openjpeg", - ExternalDecoder::Grok => "grok", - }; + let decoder_name = decoder.label(); let (samples, decoded_bytes_per_repeat) = measure_repeated(repeats, 1000.0, || { decode_external_once(tiles, workers, decoder) })?; @@ -306,6 +484,12 @@ fn decode_external_tile(tile: &TileInput, decoder: ExternalDecoder) -> Result openjpeg::decode_rgb(&tile.bytes), (ExternalDecoder::Grok, PixelFormat::Gray8) => grok::decode_gray(&tile.bytes), (ExternalDecoder::Grok, PixelFormat::Rgb8) => grok::decode_rgb(&tile.bytes), + (ExternalDecoder::OpenHtj2k, PixelFormat::Gray8) => { + openhtj2k::decode_gray(&tile.bytes, 0, 1) + } + (ExternalDecoder::OpenHtj2k, PixelFormat::Rgb8) => openhtj2k::decode_rgb(&tile.bytes, 0, 1), + (ExternalDecoder::OpenJph, PixelFormat::Gray8) => openjph::decode_gray(&tile.bytes, 0), + (ExternalDecoder::OpenJph, PixelFormat::Rgb8) => openjph::decode_rgb(&tile.bytes, 0), (_, other) => Err(format!( "{other:?} is not implemented for external comparator" )), @@ -343,8 +527,21 @@ fn output_len(tile: &TileInput, format: PixelFormat) -> usize { #[cfg(test)] mod tests { + use super::{maximum_absolute_byte_difference, ExternalDecoder}; use j2k_compare::{parse_positive_usize, sample_stats}; + #[test] + fn openhtj2k_has_a_distinct_in_process_benchmark_label() { + assert_eq!(ExternalDecoder::OpenHtj2k.label(), "openhtj2k",); + assert_eq!(ExternalDecoder::OpenJph.label(), "openjph"); + } + + #[test] + fn maximum_absolute_byte_difference_covers_both_directions() { + assert_eq!(maximum_absolute_byte_difference(&[0, 200], &[3, 190]), 10); + assert_eq!(maximum_absolute_byte_difference(&[9], &[2]), 7); + } + #[test] fn shared_parse_positive_usize_rejects_zero() { assert_eq!(parse_positive_usize("3", "threads"), Ok(3)); diff --git a/crates/j2k-compare/src/encode_compare.rs b/crates/j2k-compare/src/encode_compare.rs index 0210b60e..647cf874 100644 --- a/crates/j2k-compare/src/encode_compare.rs +++ b/crates/j2k-compare/src/encode_compare.rs @@ -36,8 +36,9 @@ use self::types::{ mod cli; use self::cli::{ batch_size_config_from_env, encode_one, encode_work_dir, include_generated_images, - include_kakadu_encoder, print_usage, validate_tool_gates, + include_kakadu_encoder, openjph_matrix_requested, print_usage, validate_tool_gates, }; +mod htj2k_matrix; mod images; use self::images::{all_image_cases, mixed_external_batches, read_pnm, select_cases}; mod tools; @@ -72,6 +73,9 @@ fn run() -> Result<(), String> { if args.get(1).is_some_and(|arg| arg == "--encode-one") { return encode_one(&args[2..]); } + if openjph_matrix_requested(&args) { + return htj2k_matrix::run(); + } validate_tool_gates()?; let repeats = std::env::var("J2K_ENCODE_COMPARE_REPEATS") diff --git a/crates/j2k-compare/src/encode_compare/cli.rs b/crates/j2k-compare/src/encode_compare/cli.rs index 670f6f85..d024d5f9 100644 --- a/crates/j2k-compare/src/encode_compare/cli.rs +++ b/crates/j2k-compare/src/encode_compare/cli.rs @@ -10,9 +10,14 @@ use super::{ pub(super) fn print_usage(program: &str) { eprintln!("usage: {program} [case-name-filter ...]"); eprintln!(" {program} --encode-one --input FILE.pnm --output FILE.jp2"); + eprintln!(" {program} --openjph-matrix"); eprintln!("Runs CLI-style lossless classic JPEG 2000 encoder benchmarks."); } +pub(super) fn openjph_matrix_requested(args: &[String]) -> bool { + args.len() == 2 && args[1] == "--openjph-matrix" +} + pub(super) fn encode_one(args: &[String]) -> Result<(), String> { let mut input = None; let mut output = None; @@ -146,3 +151,18 @@ pub(super) fn encode_work_dir() -> Result { fs::create_dir_all(&dir).map_err(|error| format!("create {}: {error}", dir.display()))?; Ok(dir) } + +#[cfg(test)] +mod tests { + use super::openjph_matrix_requested; + + #[test] + fn openjph_matrix_flag_selects_the_focused_htj2k_mode() { + let args = [ + "jp2k_encode_compare".to_string(), + "--openjph-matrix".to_string(), + ]; + assert!(openjph_matrix_requested(&args)); + assert!(!openjph_matrix_requested(&args[..1])); + } +} diff --git a/crates/j2k-compare/src/encode_compare/htj2k_matrix.rs b/crates/j2k-compare/src/encode_compare/htj2k_matrix.rs new file mode 100644 index 00000000..ff75f69f --- /dev/null +++ b/crates/j2k-compare/src/encode_compare/htj2k_matrix.rs @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Opt-in HTJ2K encoder interoperability mode within the encode comparator. + +use std::{ + fs, + path::{Path, PathBuf}, + time::Instant, +}; + +use j2k::{wrap_j2k_codestream, J2kFileWrapOptions}; + +use super::sample_stats; + +mod codec; +use codec::{ + decode_with_j2k, decode_with_openjph, discover_openjph_tools, encode_with_j2k, + encode_with_openjph, validate_ht_profile, +}; +mod metrics; +use metrics::{max_sample_delta, psnr}; +mod source; +use source::generated_source; +mod types; +use types::{ + matrix_cells, EncodedSample, MatrixCell, MatrixContext, Producer, Profile, SourceImage, +}; + +const WIDTH: u32 = 128; +const HEIGHT: u32 = 96; +const QFACTOR: u8 = 90; +const DEFAULT_REPEATS: usize = 3; + +pub(super) fn run() -> Result<(), String> { + let (compress, expand) = discover_openjph_tools()?; + let context = MatrixContext { + compress, + expand, + work_dir: matrix_work_dir()?, + repeats: matrix_repeats()?, + }; + + println!("openjph_compress_bin\t{}", context.compress.display()); + println!("openjph_expand_bin\t{}", context.expand.display()); + println!("encode_repeats\t{}", context.repeats); + println!( + "producer\tformat\tprofile\tcontainer\tcomponents\tbit_depth\tencode_median_us\tencoded_bytes\tpsnr_db\tmax_cross_decoder_delta\tlossless_source_exact" + ); + + for cell in matrix_cells() { + run_cell(&context, cell)?; + } + println!("matrix_complete\ttrue"); + Ok(()) +} + +fn run_cell(context: &MatrixContext, cell: MatrixCell) -> Result<(), String> { + let source = generated_source(cell.format); + let source_path = context.work_dir.join(format!( + "{}.{}", + cell.format.label(), + cell.format.pnm_extension() + )); + fs::write(&source_path, &source.pnm_bytes) + .map_err(|error| format!("write {}: {error}", source_path.display()))?; + for producer in [Producer::J2k, Producer::OpenJph] { + run_producer(context, cell, &source, &source_path, producer)?; + } + Ok(()) +} + +fn run_producer( + context: &MatrixContext, + cell: MatrixCell, + source: &SourceImage, + source_path: &Path, + producer: Producer, +) -> Result<(), String> { + let encoded = measure_encode(context, producer, cell.profile, source, source_path)?; + validate_ht_profile(&encoded.codestream, cell.profile)?; + let jph = wrap_j2k_codestream(&encoded.codestream, J2kFileWrapOptions::jph()) + .map_err(|error| format!("wrap JPH: {error}"))?; + for (container, bytes) in [ + ("j2c", encoded.codestream.as_slice()), + ("jph", jph.as_slice()), + ] { + run_container_row(&ContainerRow { + context, + cell, + source, + producer, + encoded: &encoded, + container, + bytes, + })?; + } + Ok(()) +} + +struct ContainerRow<'a> { + context: &'a MatrixContext, + cell: MatrixCell, + source: &'a SourceImage, + producer: Producer, + encoded: &'a EncodedSample, + container: &'a str, + bytes: &'a [u8], +} + +fn run_container_row(row: &ContainerRow<'_>) -> Result<(), String> { + let input_path = row.context.work_dir.join(format!( + "{}_{}_{}.{}", + row.producer.label(), + row.source.format.label(), + row.cell.profile.label(), + row.container + )); + fs::write(&input_path, row.bytes) + .map_err(|error| format!("write {}: {error}", input_path.display()))?; + let j2k_decoded = decode_with_j2k(row.bytes, row.source)?; + let openjph_decoded = decode_with_openjph( + &row.context.expand, + &input_path, + &row.context.work_dir, + row.producer, + row.cell, + row.container, + )?; + let cross_delta = max_sample_delta( + &j2k_decoded, + &openjph_decoded, + row.source.format.bit_depth(), + )?; + let source_exact = + j2k_decoded == row.source.pixels_le && openjph_decoded == row.source.pixels_le; + validate_parity( + row.cell, + row.source, + row.producer, + row.container, + cross_delta, + source_exact, + )?; + let psnr_db = psnr( + &row.source.pixels_le, + &j2k_decoded, + row.source.format.bit_depth(), + )?; + println!( + "{}\t{}\t{}\t{}\t{}\t{}\t{:.3}\t{}\t{}\t{}\t{}", + row.producer.label(), + row.source.format.label(), + row.cell.profile.label(), + row.container, + row.source.format.components(), + row.source.format.bit_depth(), + row.encoded.median_us, + row.bytes.len(), + if psnr_db.is_infinite() { + "inf".to_string() + } else { + format!("{psnr_db:.3}") + }, + cross_delta, + source_exact + ); + Ok(()) +} + +fn validate_parity( + cell: MatrixCell, + source: &SourceImage, + producer: Producer, + container: &str, + cross_delta: u32, + source_exact: bool, +) -> Result<(), String> { + if cell.profile == Profile::Lossless && !source_exact { + return Err(format!( + "{} {} {container} did not round-trip losslessly through both decoders", + producer.label(), + source.format.label() + )); + } + if cell.profile == Profile::Qfactor90 && cross_delta > 1 { + return Err(format!( + "{} {} {container} cross-decoder delta {cross_delta} exceeds one sample value", + producer.label(), + source.format.label() + )); + } + Ok(()) +} + +fn measure_encode( + context: &MatrixContext, + producer: Producer, + profile: Profile, + source: &SourceImage, + source_path: &Path, +) -> Result { + let mut samples_us = Vec::with_capacity(context.repeats); + let mut codestream = Vec::new(); + for index in 0..context.repeats { + let start = Instant::now(); + codestream = match producer { + Producer::J2k => encode_with_j2k(source, profile)?, + Producer::OpenJph => encode_with_openjph( + &context.compress, + source_path, + &context.work_dir, + source, + profile, + index, + )?, + }; + samples_us.push(start.elapsed().as_secs_f64() * 1_000_000.0); + } + Ok(EncodedSample { + codestream, + median_us: sample_stats(&samples_us)?.median, + }) +} + +fn matrix_repeats() -> Result { + let repeats = std::env::var("J2K_OPENJPH_MATRIX_REPEATS") + .ok() + .map(|value| { + value + .parse::() + .map_err(|error| format!("invalid J2K_OPENJPH_MATRIX_REPEATS: {error}")) + }) + .transpose()? + .unwrap_or(DEFAULT_REPEATS); + if repeats == 0 { + return Err("J2K_OPENJPH_MATRIX_REPEATS must be positive".to_string()); + } + Ok(repeats) +} + +fn matrix_work_dir() -> Result { + let path = PathBuf::from("target") + .join("j2k-openjph-encode-matrix") + .join(std::process::id().to_string()); + fs::create_dir_all(&path).map_err(|error| format!("create {}: {error}", path.display()))?; + Ok(path) +} diff --git a/crates/j2k-compare/src/encode_compare/htj2k_matrix/codec.rs b/crates/j2k-compare/src/encode_compare/htj2k_matrix/codec.rs new file mode 100644 index 00000000..767b47b1 --- /dev/null +++ b/crates/j2k-compare/src/encode_compare/htj2k_matrix/codec.rs @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::{ + fs, + path::{Path, PathBuf}, + process::Command, +}; + +use j2k::{ + encode_j2k_lossless, encode_j2k_lossy, EncodeBackendPreference, J2kBlockCodingMode, J2kDecoder, + J2kEncodeValidation, J2kLosslessEncodeOptions, J2kLosslessSamples, J2kLossyEncodeOptions, + J2kLossySamples, +}; +use j2k_core::PixelFormat; + +use super::super::tools::path_lookup; +use super::{ + source::read_pnm_as_le, + types::{MatrixCell, Producer, Profile, SampleFormat, SourceImage}, + HEIGHT, QFACTOR, WIDTH, +}; + +pub(super) fn discover_openjph_tools() -> Result<(PathBuf, PathBuf), String> { + Ok(( + required_tool( + "J2K_OPENJPH_COMPRESS_BIN", + "ojph_compress", + "target/reference/openjph-0.31.0/build-reference/src/apps/ojph_compress/ojph_compress", + )?, + required_tool( + "J2K_OPENJPH_EXPAND_BIN", + "ojph_expand", + "target/reference/openjph-0.31.0/build-reference/src/apps/ojph_expand/ojph_expand", + )?, + )) +} + +fn required_tool(env_name: &str, program: &str, local_fallback: &str) -> Result { + if let Some(path) = std::env::var_os(env_name).map(PathBuf::from) { + if path.is_file() { + return Ok(path); + } + return Err(format!( + "{env_name} does not identify a file: {}", + path.display() + )); + } + let local_fallback = PathBuf::from(local_fallback); + if local_fallback.is_file() { + return Ok(local_fallback); + } + path_lookup(program) + .filter(|path| path.is_file()) + .ok_or_else(|| { + format!( + "{program} is unavailable; set {env_name} or run scripts/prepare-openjph-reference.sh" + ) + }) +} + +pub(super) fn encode_with_j2k(source: &SourceImage, profile: Profile) -> Result, String> { + match profile { + Profile::Lossless => { + let samples = J2kLosslessSamples::new( + &source.pixels_le, + WIDTH, + HEIGHT, + source.format.components(), + source.format.bit_depth(), + false, + ) + .map_err(|error| error.to_string())?; + let options = J2kLosslessEncodeOptions::default() + .with_backend(EncodeBackendPreference::CpuOnly) + .with_block_coding_mode(J2kBlockCodingMode::HighThroughput) + .with_max_decomposition_levels(Some(1)) + .with_validation(J2kEncodeValidation::External); + encode_j2k_lossless(samples, &options) + .map(|encoded| encoded.codestream) + .map_err(|error| error.to_string()) + } + Profile::Qfactor90 => { + let samples = J2kLossySamples::new( + &source.pixels_le, + WIDTH, + HEIGHT, + source.format.components(), + source.format.bit_depth(), + false, + ) + .map_err(|error| error.to_string())?; + let options = J2kLossyEncodeOptions::default() + .with_backend(EncodeBackendPreference::CpuOnly) + .with_block_coding_mode(J2kBlockCodingMode::HighThroughput) + .with_max_decomposition_levels(Some(1)) + .with_qfactor(Some(QFACTOR)) + .with_validation(J2kEncodeValidation::External); + encode_j2k_lossy(samples, &options) + .map(|encoded| encoded.codestream) + .map_err(|error| error.to_string()) + } + } +} + +pub(super) fn encode_with_openjph( + compress: &Path, + source_path: &Path, + work_dir: &Path, + source: &SourceImage, + profile: Profile, + index: usize, +) -> Result, String> { + let output = work_dir.join(format!( + "openjph_{}_{}_{}.j2c", + source.format.label(), + profile.label(), + index + )); + let mut command = Command::new(compress); + command + .arg("-i") + .arg(source_path) + .arg("-o") + .arg(&output) + .arg("-num_decomps") + .arg("1") + .arg("-block_size") + .arg("{64,64}") + .arg("-prog_order") + .arg("LRCP") + .arg("-colour_trans") + .arg(if source.format == SampleFormat::Rgb8 { + "true" + } else { + "false" + }); + match profile { + Profile::Lossless => { + command.arg("-reversible").arg("true"); + } + Profile::Qfactor90 => { + command + .arg("-reversible") + .arg("false") + .arg("-qfactor") + .arg(QFACTOR.to_string()); + } + } + let result = command + .output() + .map_err(|error| format!("start ojph_compress: {error}"))?; + if !result.status.success() { + return Err(format!( + "ojph_compress exited with {}: {}", + result.status, + String::from_utf8_lossy(&result.stderr).trim() + )); + } + fs::read(&output).map_err(|error| format!("read {}: {error}", output.display())) +} + +pub(super) fn validate_ht_profile(codestream: &[u8], profile: Profile) -> Result<(), String> { + let header = j2k_native::inspect_j2k_codestream_header(codestream) + .map_err(|error| format!("inspect encoded HTJ2K profile: {error}"))?; + if !header.high_throughput { + return Err("encoder matrix output did not use HT block coding".to_string()); + } + let expect_reversible = profile == Profile::Lossless; + if header.reversible != expect_reversible { + return Err(format!( + "encoder matrix reversible={} but expected {expect_reversible}", + header.reversible + )); + } + if header.resolution_levels != 2 { + return Err(format!( + "encoder matrix resolution levels {} != 2", + header.resolution_levels + )); + } + Ok(()) +} + +pub(super) fn decode_with_j2k(bytes: &[u8], source: &SourceImage) -> Result, String> { + let format = match source.format { + SampleFormat::Gray8 => PixelFormat::Gray8, + SampleFormat::Rgb8 => PixelFormat::Rgb8, + SampleFormat::Gray16 => PixelFormat::Gray16, + }; + let stride = usize::try_from(WIDTH).map_err(|_| "width exceeds usize".to_string())? + * format.bytes_per_pixel(); + let height = usize::try_from(HEIGHT).map_err(|_| "height exceeds usize".to_string())?; + let mut output = vec![0_u8; stride * height]; + J2kDecoder::new(bytes) + .map_err(|error| error.to_string())? + .decode_into(&mut output, stride, format) + .map_err(|error| error.to_string())?; + Ok(output) +} + +pub(super) fn decode_with_openjph( + expand: &Path, + input: &Path, + work_dir: &Path, + producer: Producer, + cell: MatrixCell, + container: &str, +) -> Result, String> { + let output = work_dir.join(format!( + "decoded_{}_{}_{}_{}.{}", + producer.label(), + cell.format.label(), + cell.profile.label(), + container, + cell.format.pnm_extension() + )); + let result = Command::new(expand) + .arg("-i") + .arg(input) + .arg("-o") + .arg(&output) + .output() + .map_err(|error| format!("start ojph_expand: {error}"))?; + if !result.status.success() { + return Err(format!( + "ojph_expand exited with {}: {}", + result.status, + String::from_utf8_lossy(&result.stderr).trim() + )); + } + read_pnm_as_le(&output, cell.format) +} diff --git a/crates/j2k-compare/src/encode_compare/htj2k_matrix/metrics.rs b/crates/j2k-compare/src/encode_compare/htj2k_matrix/metrics.rs new file mode 100644 index 00000000..3e176747 --- /dev/null +++ b/crates/j2k-compare/src/encode_compare/htj2k_matrix/metrics.rs @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +pub(super) fn max_sample_delta(left: &[u8], right: &[u8], bit_depth: u8) -> Result { + if left.len() != right.len() { + return Err("decoded output lengths differ".to_string()); + } + if bit_depth <= 8 { + return Ok(left + .iter() + .zip(right) + .map(|(&a, &b)| u32::from(a.abs_diff(b))) + .max() + .unwrap_or_default()); + } + if !left.len().is_multiple_of(2) { + return Err("16-bit decoded output has an odd byte length".to_string()); + } + Ok(left + .chunks_exact(2) + .zip(right.chunks_exact(2)) + .map(|(a, b)| { + u32::from(u16::from_le_bytes([a[0], a[1]]).abs_diff(u16::from_le_bytes([b[0], b[1]]))) + }) + .max() + .unwrap_or_default()) +} + +pub(super) fn psnr(reference: &[u8], actual: &[u8], bit_depth: u8) -> Result { + if reference.len() != actual.len() { + return Err("PSNR input lengths differ".to_string()); + } + let (sum_squared_error, sample_count) = if bit_depth <= 8 { + let error = reference + .iter() + .zip(actual) + .map(|(&a, &b)| { + let delta = f64::from(a) - f64::from(b); + delta * delta + }) + .sum::(); + (error, reference.len()) + } else { + if !reference.len().is_multiple_of(2) { + return Err("16-bit PSNR input has an odd byte length".to_string()); + } + let error = reference + .chunks_exact(2) + .zip(actual.chunks_exact(2)) + .map(|(a, b)| { + let delta = f64::from(u16::from_le_bytes([a[0], a[1]])) + - f64::from(u16::from_le_bytes([b[0], b[1]])); + delta * delta + }) + .sum::(); + (error, reference.len() / 2) + }; + if sample_count == 0 { + return Err("PSNR inputs are empty".to_string()); + } + if sum_squared_error == 0.0 { + return Ok(f64::INFINITY); + } + let peak = f64::from((1_u32 << bit_depth) - 1); + let sample_count = + u32::try_from(sample_count).map_err(|_| "PSNR sample count exceeds u32".to_string())?; + let mse = sum_squared_error / f64::from(sample_count); + Ok(10.0 * (peak * peak / mse).log10()) +} + +#[cfg(test)] +mod tests { + use super::{max_sample_delta, psnr}; + + #[test] + fn parity_metrics_are_sample_based_for_eight_and_sixteen_bit_data() { + assert_eq!(max_sample_delta(&[0, 2, 255], &[1, 2, 253], 8), Ok(2)); + let left = [0_u16, 1024, 65_535] + .into_iter() + .flat_map(u16::to_le_bytes) + .collect::>(); + let right = [1_u16, 1022, 65_535] + .into_iter() + .flat_map(u16::to_le_bytes) + .collect::>(); + assert_eq!(max_sample_delta(&left, &right, 16), Ok(2)); + assert!(psnr(&left, &left, 16).is_ok_and(f64::is_infinite)); + } +} diff --git a/crates/j2k-compare/src/encode_compare/htj2k_matrix/source.rs b/crates/j2k-compare/src/encode_compare/htj2k_matrix/source.rs new file mode 100644 index 00000000..b0b262be --- /dev/null +++ b/crates/j2k-compare/src/encode_compare/htj2k_matrix/source.rs @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::{fs, path::Path}; + +use super::{ + types::{SampleFormat, SourceImage}, + HEIGHT, WIDTH, +}; + +pub(super) fn generated_source(format: SampleFormat) -> SourceImage { + let mut pixels_le = Vec::new(); + for y in 0..HEIGHT { + for x in 0..WIDTH { + match format { + SampleFormat::Gray8 => { + pixels_le.push((x * 37 + y * 73 + x * y).to_le_bytes()[0]); + } + SampleFormat::Rgb8 => { + pixels_le.push((x * 5 + y * 3).to_le_bytes()[0]); + pixels_le.push((x * 11 + y * 17 + 41).to_le_bytes()[0]); + pixels_le.push((x * 23 + y * 7 + x * y).to_le_bytes()[0]); + } + SampleFormat::Gray16 => { + let value = (x * 400 + y * 150).to_le_bytes(); + pixels_le.extend_from_slice(&value[..2]); + } + } + } + } + let magic = if format == SampleFormat::Rgb8 { + "P6" + } else { + "P5" + }; + let max_value = if format == SampleFormat::Gray16 { + 65_535 + } else { + 255 + }; + let mut pnm_bytes = format!("{magic}\n{WIDTH} {HEIGHT}\n{max_value}\n").into_bytes(); + if format == SampleFormat::Gray16 { + for sample in pixels_le.chunks_exact(2) { + pnm_bytes.extend_from_slice(&u16::from_le_bytes([sample[0], sample[1]]).to_be_bytes()); + } + } else { + pnm_bytes.extend_from_slice(&pixels_le); + } + SourceImage { + format, + pixels_le, + pnm_bytes, + } +} + +pub(super) fn read_pnm_as_le(path: &Path, expected: SampleFormat) -> Result, String> { + let bytes = fs::read(path).map_err(|error| format!("read {}: {error}", path.display()))?; + let mut cursor = 0; + let magic = pnm_token(&bytes, &mut cursor)?; + let expected_magic = if expected == SampleFormat::Rgb8 { + b"P6".as_slice() + } else { + b"P5".as_slice() + }; + if magic != expected_magic { + return Err(format!("{} has unexpected PNM magic", path.display())); + } + let width = parse_pnm_number(pnm_token(&bytes, &mut cursor)?, "width")?; + let height = parse_pnm_number(pnm_token(&bytes, &mut cursor)?, "height")?; + let max_value = parse_pnm_number(pnm_token(&bytes, &mut cursor)?, "max value")?; + let expected_max = if expected == SampleFormat::Gray16 { + 65_535 + } else { + 255 + }; + if width != WIDTH || height != HEIGHT || max_value != expected_max { + return Err(format!( + "{} has PNM profile {width}x{height} max={max_value}", + path.display() + )); + } + consume_pnm_separator(&bytes, &mut cursor)?; + let payload = &bytes[cursor..]; + let width = usize::try_from(WIDTH).map_err(|_| "PNM width exceeds usize".to_string())?; + let height = usize::try_from(HEIGHT).map_err(|_| "PNM height exceeds usize".to_string())?; + let samples = width * height * usize::from(expected.components()); + let expected_len = samples + * usize::from(if expected == SampleFormat::Gray16 { + 2_u8 + } else { + 1 + }); + if payload.len() != expected_len { + return Err(format!( + "{} PNM payload length {} != {expected_len}", + path.display(), + payload.len() + )); + } + if expected == SampleFormat::Gray16 { + Ok(payload + .chunks_exact(2) + .flat_map(|sample| u16::from_be_bytes([sample[0], sample[1]]).to_le_bytes()) + .collect()) + } else { + Ok(payload.to_vec()) + } +} + +fn pnm_token<'a>(bytes: &'a [u8], cursor: &mut usize) -> Result<&'a [u8], String> { + loop { + while bytes.get(*cursor).is_some_and(u8::is_ascii_whitespace) { + *cursor += 1; + } + if bytes.get(*cursor) != Some(&b'#') { + break; + } + while bytes.get(*cursor).is_some_and(|byte| *byte != b'\n') { + *cursor += 1; + } + } + let start = *cursor; + while bytes + .get(*cursor) + .is_some_and(|byte| !byte.is_ascii_whitespace() && *byte != b'#') + { + *cursor += 1; + } + if start == *cursor { + return Err("truncated PNM header".to_string()); + } + Ok(&bytes[start..*cursor]) +} + +fn parse_pnm_number(token: &[u8], label: &str) -> Result { + std::str::from_utf8(token) + .map_err(|error| format!("PNM {label} is not UTF-8: {error}"))? + .parse() + .map_err(|error| format!("invalid PNM {label}: {error}")) +} + +fn consume_pnm_separator(bytes: &[u8], cursor: &mut usize) -> Result<(), String> { + let separator = *bytes + .get(*cursor) + .ok_or_else(|| "PNM header has no pixel separator".to_string())?; + if !separator.is_ascii_whitespace() { + return Err("PNM header is not followed by whitespace".to_string()); + } + *cursor += 1; + if separator == b'\r' && bytes.get(*cursor) == Some(&b'\n') { + *cursor += 1; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{generated_source, read_pnm_as_le, SampleFormat}; + + #[test] + fn generated_pnm_round_trips_matrix_sample_endianness() { + for format in [ + SampleFormat::Gray8, + SampleFormat::Rgb8, + SampleFormat::Gray16, + ] { + let source = generated_source(format); + let path = std::env::temp_dir().join(format!( + "j2k-openjph-matrix-pnm-{}-{}", + std::process::id(), + format.label() + )); + std::fs::write(&path, &source.pnm_bytes).expect("write generated PNM"); + let parsed = read_pnm_as_le(&path, format).expect("parse generated PNM"); + assert_eq!(parsed, source.pixels_le); + } + } +} diff --git a/crates/j2k-compare/src/encode_compare/htj2k_matrix/types.rs b/crates/j2k-compare/src/encode_compare/htj2k_matrix/types.rs new file mode 100644 index 00000000..a499841a --- /dev/null +++ b/crates/j2k-compare/src/encode_compare/htj2k_matrix/types.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::path::PathBuf; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum SampleFormat { + Gray8, + Rgb8, + Gray16, +} + +impl SampleFormat { + pub(super) const fn label(self) -> &'static str { + match self { + Self::Gray8 => "gray8", + Self::Rgb8 => "rgb8", + Self::Gray16 => "gray16", + } + } + + pub(super) const fn components(self) -> u16 { + if matches!(self, Self::Rgb8) { + 3 + } else { + 1 + } + } + + pub(super) const fn bit_depth(self) -> u8 { + if matches!(self, Self::Gray16) { + 16 + } else { + 8 + } + } + + pub(super) const fn pnm_extension(self) -> &'static str { + if matches!(self, Self::Rgb8) { + "ppm" + } else { + "pgm" + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum Profile { + Lossless, + Qfactor90, +} + +impl Profile { + pub(super) const fn label(self) -> &'static str { + match self { + Self::Lossless => "reversible-53", + Self::Qfactor90 => "irreversible-qfactor90", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct MatrixCell { + pub(super) format: SampleFormat, + pub(super) profile: Profile, +} + +pub(super) const fn matrix_cells() -> [MatrixCell; 6] { + [ + MatrixCell { + format: SampleFormat::Gray8, + profile: Profile::Lossless, + }, + MatrixCell { + format: SampleFormat::Rgb8, + profile: Profile::Lossless, + }, + MatrixCell { + format: SampleFormat::Gray16, + profile: Profile::Lossless, + }, + MatrixCell { + format: SampleFormat::Gray8, + profile: Profile::Qfactor90, + }, + MatrixCell { + format: SampleFormat::Rgb8, + profile: Profile::Qfactor90, + }, + MatrixCell { + format: SampleFormat::Gray16, + profile: Profile::Qfactor90, + }, + ] +} + +#[derive(Clone, Copy)] +pub(super) enum Producer { + J2k, + OpenJph, +} + +impl Producer { + pub(super) const fn label(self) -> &'static str { + match self { + Self::J2k => "j2k", + Self::OpenJph => "openjph", + } + } +} + +pub(super) struct SourceImage { + pub(super) format: SampleFormat, + pub(super) pixels_le: Vec, + pub(super) pnm_bytes: Vec, +} + +pub(super) struct EncodedSample { + pub(super) codestream: Vec, + pub(super) median_us: f64, +} + +pub(super) struct MatrixContext { + pub(super) compress: PathBuf, + pub(super) expand: PathBuf, + pub(super) work_dir: PathBuf, + pub(super) repeats: usize, +} + +#[cfg(test)] +mod tests { + use super::{matrix_cells, Profile, SampleFormat}; + + #[test] + fn balanced_matrix_covers_requested_formats_and_profiles() { + let cells = matrix_cells(); + assert_eq!(cells.len(), 6); + for format in [ + SampleFormat::Gray8, + SampleFormat::Rgb8, + SampleFormat::Gray16, + ] { + assert!(cells + .iter() + .any(|cell| cell.format == format && cell.profile == Profile::Lossless)); + assert!(cells + .iter() + .any(|cell| cell.format == format && cell.profile == Profile::Qfactor90)); + } + } +} diff --git a/crates/j2k-compare/src/grok.rs b/crates/j2k-compare/src/grok.rs index e36f68a2..0d373e7f 100644 --- a/crates/j2k-compare/src/grok.rs +++ b/crates/j2k-compare/src/grok.rs @@ -4,8 +4,6 @@ use std::{ffi::c_void, ptr, sync::Once}; use crate::ExternalDecodeRequest; -#[cfg(any(have_grok, test))] -use crate::MAX_EXTERNAL_OUTPUT_BYTES; pub fn is_available() -> bool { cfg!(have_grok) @@ -102,7 +100,7 @@ fn decode(bytes: &[u8], request: ExternalDecodeRequest) -> Result, Strin if ok == 0 || output.0.is_null() { return Err("grok: decode failed".to_string()); } - let expected = checked_output_len(out_width, out_height, channels)?; + let expected = crate::checked_external_output_len("grok", out_width, out_height, channels)?; if out_len != expected { return Err(format!( "grok: unexpected output length {out_len} != {expected}" @@ -140,36 +138,6 @@ fn checked_region_bounds(roi: j2k_core::Rect) -> Result<[u32; 4], String> { Ok([roi.x, roi.y, x1, y1]) } -#[cfg(any(have_grok, test))] -fn checked_output_len(width: u32, height: u32, channels: u32) -> Result { - if !matches!(channels, 1 | 3) { - return Err(format!( - "grok: unsupported channel count {channels}, expected 1 or 3" - )); - } - if width == 0 || height == 0 { - return Err("grok: image has zero-sized output".to_string()); - } - let width = - usize::try_from(width).map_err(|_| "grok: width exceeds platform usize".to_string())?; - let height = - usize::try_from(height).map_err(|_| "grok: height exceeds platform usize".to_string())?; - let channels = usize::try_from(channels) - .map_err(|_| "grok: channel count exceeds platform usize".to_string())?; - let pixels = width - .checked_mul(height) - .ok_or_else(|| "grok: output pixel count overflow".to_string())?; - let len = pixels - .checked_mul(channels) - .ok_or_else(|| "grok: output byte count overflow".to_string())?; - if len > MAX_EXTERNAL_OUTPUT_BYTES { - return Err(format!( - "grok: output exceeds {MAX_EXTERNAL_OUTPUT_BYTES} byte cap" - )); - } - Ok(len) -} - #[cfg(have_grok)] #[expect( unsafe_code, @@ -199,8 +167,7 @@ unsafe extern "C" { mod tests { use j2k_core::Rect; - use super::{checked_output_len, checked_reduce, checked_region_bounds}; - use crate::MAX_EXTERNAL_OUTPUT_BYTES; + use super::{checked_reduce, checked_region_bounds}; #[test] fn region_bounds_reject_coordinate_overflow() { @@ -223,17 +190,6 @@ mod tests { ); } - #[test] - fn output_len_is_bounded_before_slice_construction() { - assert!(checked_output_len(0, 1, 1).is_err()); - assert!(checked_output_len(1, 1, 2).is_err()); - assert!(checked_output_len(u32::MAX, u32::MAX, 3).is_err()); - let over_cap = - u32::try_from(MAX_EXTERNAL_OUTPUT_BYTES + 1).expect("the shared output cap fits u32"); - assert!(checked_output_len(over_cap, 1, 1).is_err()); - assert_eq!(checked_output_len(2, 3, 3), Ok(18)); - } - #[test] fn reduce_rejects_values_that_the_c_abi_cannot_represent() { assert_eq!(checked_reduce(None), Ok(0)); diff --git a/crates/j2k-compare/src/lib.rs b/crates/j2k-compare/src/lib.rs index dccfd6aa..18dbd66b 100644 --- a/crates/j2k-compare/src/lib.rs +++ b/crates/j2k-compare/src/lib.rs @@ -8,6 +8,39 @@ use j2k_core::Rect; pub(crate) const MAX_EXTERNAL_OUTPUT_BYTES: usize = 512 * 1024 * 1024; +#[cfg(any(have_grok, have_openhtj2k, have_openjph, test))] +fn checked_external_output_len( + codec: &str, + width: u32, + height: u32, + channels: u32, +) -> Result { + if !matches!(channels, 1 | 3) { + return Err(format!( + "{codec}: unsupported channel count {channels}, expected 1 or 3" + )); + } + if width == 0 || height == 0 { + return Err(format!("{codec}: image has zero-sized output")); + } + let width = + usize::try_from(width).map_err(|_| format!("{codec}: width exceeds platform usize"))?; + let height = + usize::try_from(height).map_err(|_| format!("{codec}: height exceeds platform usize"))?; + let channels = usize::try_from(channels) + .map_err(|_| format!("{codec}: channel count exceeds platform usize"))?; + let len = width + .checked_mul(height) + .and_then(|pixels| pixels.checked_mul(channels)) + .ok_or_else(|| format!("{codec}: output byte count overflow"))?; + if len > MAX_EXTERNAL_OUTPUT_BYTES { + return Err(format!( + "{codec}: output exceeds {MAX_EXTERNAL_OUTPUT_BYTES} byte cap" + )); + } + Ok(len) +} + /// Color shape requested from an external comparator decode. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ExternalDecodeColor { @@ -173,7 +206,9 @@ pub mod common; pub mod encode_compare; pub mod fixture_compare; pub mod grok; +pub mod openhtj2k; pub mod openjpeg; +pub mod openjph; /// Summary statistics for benchmark samples. #[derive(Clone, Copy, Debug, PartialEq)] @@ -231,3 +266,19 @@ pub fn sample_stats(samples: &[f64]) -> Result { pub fn usize_to_f64(value: usize) -> f64 { f64::from(u32::try_from(value).unwrap_or(u32::MAX)) } + +#[cfg(test)] +mod external_output_len_tests { + use super::{checked_external_output_len, MAX_EXTERNAL_OUTPUT_BYTES}; + + #[test] + fn output_len_is_bounded_before_slice_construction() { + assert!(checked_external_output_len("codec", 0, 1, 1).is_err()); + assert!(checked_external_output_len("codec", 1, 1, 2).is_err()); + assert!(checked_external_output_len("codec", u32::MAX, u32::MAX, 3).is_err()); + let over_cap = + u32::try_from(MAX_EXTERNAL_OUTPUT_BYTES + 1).expect("the shared output cap fits u32"); + assert!(checked_external_output_len("codec", over_cap, 1, 1).is_err()); + assert_eq!(checked_external_output_len("codec", 2, 3, 3), Ok(18)); + } +} diff --git a/crates/j2k-compare/src/openhtj2k.rs b/crates/j2k-compare/src/openhtj2k.rs new file mode 100644 index 00000000..f0dc26ef --- /dev/null +++ b/crates/j2k-compare/src/openhtj2k.rs @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#[cfg(have_openhtj2k)] +use std::{ffi::c_void, ptr}; + +/// Whether the optional pinned `OpenHTJ2K` library was linked into this build. +#[must_use] +pub const fn is_available() -> bool { + cfg!(have_openhtj2k) +} + +/// Version of the linked `OpenHTJ2K` library. +#[must_use] +pub fn version() -> &'static str { + option_env!("J2K_OPENHTJ2K_VERSION").unwrap_or("unavailable") +} + +/// Directory containing the linked `OpenHTJ2K` library. +#[must_use] +pub fn library_path() -> &'static str { + option_env!("J2K_OPENHTJ2K_LIB_DIR").unwrap_or("unavailable") +} + +/// Decode a raw HTJ2K/JPH input to packed 8-bit grayscale in-process. +pub fn decode_gray(bytes: &[u8], reduce: u8, threads: u32) -> Result, String> { + decode(bytes, reduce, threads, 1) +} + +/// Decode a raw HTJ2K/JPH input to packed interleaved RGB8 in-process. +pub fn decode_rgb(bytes: &[u8], reduce: u8, threads: u32) -> Result, String> { + decode(bytes, reduce, threads, 3) +} + +#[cfg_attr( + have_openhtj2k, + expect( + unsafe_code, + reason = "OpenHTJ2K decode uses the optional checked C++ shim and frees its output exactly once" + ) +)] +fn decode(bytes: &[u8], reduce: u8, threads: u32, channels: u32) -> Result, String> { + #[cfg(have_openhtj2k)] + { + let threads = threads.max(1); + let mut out = ptr::null_mut(); + let mut out_len = 0_usize; + let mut out_width = 0_u32; + let mut out_height = 0_u32; + // SAFETY: the input slice remains live for the call and the output + // pointers refer to initialized writable locals governed by the shim ABI. + let ok = unsafe { + j2k_openhtj2k_decode_u8( + bytes.as_ptr(), + bytes.len(), + reduce, + threads, + channels, + &raw mut out, + &raw mut out_len, + &raw mut out_width, + &raw mut out_height, + ) + }; + let output = OpenHtj2kOutput(out); + if ok == 0 || output.0.is_null() { + return Err("openhtj2k: decode failed".to_string()); + } + let expected = + crate::checked_external_output_len("openhtj2k", out_width, out_height, channels)?; + if out_len != expected { + return Err(format!( + "openhtj2k: unexpected output length {out_len} != {expected}" + )); + } + // SAFETY: the non-null shim allocation has been independently bounded + // and its length matches the checked dimensions and channel count. + Ok(unsafe { std::slice::from_raw_parts(output.0, expected) }.to_vec()) + } + + #[cfg(not(have_openhtj2k))] + { + let _ = (bytes, reduce, threads, channels); + Err("openhtj2k: local library not available".to_string()) + } +} + +#[cfg(have_openhtj2k)] +struct OpenHtj2kOutput(*mut u8); + +#[cfg(have_openhtj2k)] +impl Drop for OpenHtj2kOutput { + #[expect( + unsafe_code, + reason = "the guard frees the OpenHTJ2K shim allocation exactly once" + )] + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the pointer is the allocation returned by the paired shim. + unsafe { j2k_openhtj2k_free(self.0.cast()) }; + } + } +} + +#[cfg(have_openhtj2k)] +#[expect( + unsafe_code, + reason = "these declarations are the optional OpenHTJ2K C++ shim's complete ABI" +)] +unsafe extern "C" { + fn j2k_openhtj2k_decode_u8( + bytes: *const u8, + len: usize, + reduce: u8, + threads: u32, + channels: u32, + out_data: *mut *mut u8, + out_len: *mut usize, + out_width: *mut u32, + out_height: *mut u32, + ) -> i32; + fn j2k_openhtj2k_free(ptr: *mut c_void); +} + +#[cfg(test)] +mod tests { + #[test] + fn decodes_pinned_gray8_fixture_in_process() { + let fixture = j2k_test_support::openjph_batch_fixtures() + .iter() + .find(|fixture| fixture.name == "openjph-gray-u8-53-raw") + .expect("pinned gray8 OpenJPH fixture"); + + if !super::is_available() { + return; + } + let decoded = super::decode_gray(fixture.encoded, 0, 1) + .expect("decode through pinned OpenHTJ2K library"); + assert_eq!(decoded, fixture.oracle); + assert_eq!(super::version(), "0.19.0"); + } + + #[test] + fn decodes_native_qfactor_rgb_within_one_lsb() { + if !super::is_available() { + return; + } + let pixels = (0_usize..64 * 64 * 3) + .map(|index| u8::try_from(index.wrapping_mul(37) & 0xff).expect("masked byte")) + .collect::>(); + let options = j2k_native::EncodeOptions { + reversible: false, + use_ht_block_coding: true, + num_decomposition_levels: 5, + validate_high_throughput_codestream: false, + ..j2k_native::EncodeOptions::default() + }; + let codestream = + j2k_native::encode_htj2k_with_qfactor(&pixels, 64, 64, 3, 8, false, 90, &options) + .expect("native Qfactor encode"); + let native = j2k_native::Image::new(&codestream, &j2k_native::DecodeSettings::default()) + .expect("parse native Qfactor codestream") + .decode_native() + .expect("decode native Qfactor codestream"); + let reference = + super::decode_rgb(&codestream, 0, 1).expect("OpenHTJ2K decode of native codestream"); + + assert_eq!(native.data.len(), reference.len()); + let max_delta = native + .data + .iter() + .zip(reference) + .map(|(&left, right)| left.abs_diff(right)) + .max() + .unwrap_or_default(); + assert!( + max_delta <= 1, + "native/OpenHTJ2K max byte delta {max_delta}" + ); + } +} diff --git a/crates/j2k-compare/src/openhtj2k_shim.cpp b/crates/j2k-compare/src/openhtj2k_shim.cpp new file mode 100644 index 00000000..8e744059 --- /dev/null +++ b/crates/j2k-compare/src/openhtj2k_shim.cpp @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#include "decoder.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr std::size_t OUTPUT_CAP_BYTES = std::size_t{512} * 1024 * 1024; + +bool checked_output_len(std::uint32_t width, std::uint32_t height, + std::uint32_t channels, std::size_t &out) { + if ((channels != 1 && channels != 3) || width == 0 || height == 0) { + return false; + } + if (static_cast(width) > + std::numeric_limits::max() / channels) { + return false; + } + const std::size_t row = static_cast(width) * channels; + if (row > std::numeric_limits::max() / height) { + return false; + } + out = row * height; + return out <= OUTPUT_CAP_BYTES; +} + +std::uint8_t sample_u8(std::int32_t sample, std::uint8_t depth, + bool is_signed) { + if (is_signed && depth != 0 && depth <= 31) { + sample += std::int32_t{1} << (depth - 1); + } + if (depth > 8) { + sample >>= depth - 8; + } + return static_cast(std::clamp(sample, 0, 255)); +} +} // namespace + +extern "C" int j2k_openhtj2k_decode_u8( + const std::uint8_t *bytes, std::size_t len, std::uint8_t reduce, + std::uint32_t threads, std::uint32_t channels, std::uint8_t **out_data, + std::size_t *out_len, std::uint32_t *out_width, + std::uint32_t *out_height) { + if (bytes == nullptr || len == 0 || out_data == nullptr || out_len == nullptr || + out_width == nullptr || out_height == nullptr || + (channels != 1 && channels != 3)) { + return 0; + } + *out_data = nullptr; + *out_len = 0; + *out_width = 0; + *out_height = 0; + + try { + open_htj2k::openhtj2k_decoder decoder(bytes, len, reduce, + std::max(threads, 1u)); + decoder.parse(); + const std::uint16_t components = decoder.get_num_component(); + if ((channels == 1 && components == 0) || + (channels == 3 && components < 3)) { + return 0; + } + + std::vector widths; + std::vector heights; + std::vector depths; + std::vector signedness; + std::vector output; + bool initialized = false; + std::uint32_t width = 0; + std::uint32_t height = 0; + + decoder.invoke_line_based_stream( + [&](std::uint32_t y, std::int32_t *const *rows, std::uint16_t nc) { + if (rows == nullptr) { + throw std::runtime_error("OpenHTJ2K returned null component rows"); + } + if (!initialized) { + if (nc < channels || widths.size() < channels || + heights.size() < channels || depths.size() < channels || + signedness.size() < channels) { + throw std::runtime_error("OpenHTJ2K component metadata mismatch"); + } + width = widths[0]; + height = heights[0]; + for (std::uint32_t c = 1; c < channels; ++c) { + if (widths[c] != width || heights[c] != height) { + throw std::runtime_error("subsampled OpenHTJ2K output unsupported"); + } + } + std::size_t required = 0; + if (!checked_output_len(width, height, channels, required)) { + throw std::runtime_error("OpenHTJ2K output size invalid"); + } + output.resize(required); + initialized = true; + } + if (y >= height) { + throw std::runtime_error("OpenHTJ2K row exceeds output height"); + } + const std::size_t row_offset = + static_cast(y) * width * channels; + for (std::uint32_t c = 0; c < channels; ++c) { + if (rows[c] == nullptr) { + throw std::runtime_error("OpenHTJ2K returned a null component row"); + } + } + for (std::uint32_t x = 0; x < width; ++x) { + for (std::uint32_t c = 0; c < channels; ++c) { + output[row_offset + static_cast(x) * channels + c] = + sample_u8(rows[c][x], depths[c], signedness[c]); + } + } + }, + widths, heights, depths, signedness); + + if (!initialized || output.empty()) { + return 0; + } + auto *allocation = static_cast(std::malloc(output.size())); + if (allocation == nullptr) { + return 0; + } + std::memcpy(allocation, output.data(), output.size()); + *out_data = allocation; + *out_len = output.size(); + *out_width = width; + *out_height = height; + return 1; + } catch (...) { + return 0; + } +} + +extern "C" void j2k_openhtj2k_free(void *ptr) { std::free(ptr); } diff --git a/crates/j2k-compare/src/openjph.rs b/crates/j2k-compare/src/openjph.rs new file mode 100644 index 00000000..5e0b3541 --- /dev/null +++ b/crates/j2k-compare/src/openjph.rs @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#[cfg(have_openjph)] +use std::{ffi::c_void, ptr}; + +/// Whether the optional pinned `OpenJPH` library was linked into this build. +#[must_use] +pub const fn is_available() -> bool { + cfg!(have_openjph) +} + +/// Version of the linked `OpenJPH` library. +#[must_use] +pub fn version() -> &'static str { + option_env!("J2K_OPENJPH_VERSION").unwrap_or("unavailable") +} + +/// Directory containing the linked `OpenJPH` library. +#[must_use] +pub fn library_path() -> &'static str { + option_env!("J2K_OPENJPH_LIB_DIR").unwrap_or("unavailable") +} + +/// Decode an HTJ2K codestream to packed 8-bit grayscale in-process. +pub fn decode_gray(bytes: &[u8], reduce: u8) -> Result, String> { + decode(bytes, reduce, 1) +} + +/// Decode an HTJ2K codestream to packed interleaved RGB8 in-process. +pub fn decode_rgb(bytes: &[u8], reduce: u8) -> Result, String> { + decode(bytes, reduce, 3) +} + +#[cfg_attr( + have_openjph, + expect( + unsafe_code, + reason = "OpenJPH decode uses the optional checked C++ shim and frees its output exactly once" + ) +)] +fn decode(bytes: &[u8], reduce: u8, channels: u32) -> Result, String> { + #[cfg(have_openjph)] + { + let mut out = ptr::null_mut(); + let mut out_len = 0_usize; + let mut out_width = 0_u32; + let mut out_height = 0_u32; + // SAFETY: the input slice remains live for the call and the output + // pointers refer to initialized writable locals governed by the shim ABI. + let ok = unsafe { + j2k_openjph_decode_u8( + bytes.as_ptr(), + bytes.len(), + reduce, + channels, + &raw mut out, + &raw mut out_len, + &raw mut out_width, + &raw mut out_height, + ) + }; + let output = OpenJphOutput(out); + if ok == 0 || output.0.is_null() { + return Err("openjph: decode failed".to_string()); + } + let expected = + crate::checked_external_output_len("openjph", out_width, out_height, channels)?; + if out_len != expected { + return Err(format!( + "openjph: unexpected output length {out_len} != {expected}" + )); + } + // SAFETY: the non-null shim allocation has been independently bounded + // and its length matches the checked dimensions and channel count. + Ok(unsafe { std::slice::from_raw_parts(output.0, expected) }.to_vec()) + } + + #[cfg(not(have_openjph))] + { + let _ = (bytes, reduce, channels); + Err("openjph: local library not available".to_string()) + } +} + +#[cfg(have_openjph)] +struct OpenJphOutput(*mut u8); + +#[cfg(have_openjph)] +impl Drop for OpenJphOutput { + #[expect( + unsafe_code, + reason = "the guard frees the OpenJPH shim allocation exactly once" + )] + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the pointer is the allocation returned by the paired shim. + unsafe { j2k_openjph_free(self.0.cast()) }; + } + } +} + +#[cfg(have_openjph)] +#[expect( + unsafe_code, + reason = "these declarations are the optional OpenJPH C++ shim's complete ABI" +)] +unsafe extern "C" { + fn j2k_openjph_decode_u8( + bytes: *const u8, + len: usize, + reduce: u8, + channels: u32, + out_data: *mut *mut u8, + out_len: *mut usize, + out_width: *mut u32, + out_height: *mut u32, + ) -> i32; + fn j2k_openjph_free(ptr: *mut c_void); +} + +#[cfg(test)] +mod tests { + #[test] + fn decodes_pinned_gray8_fixture_in_process() { + let fixture = j2k_test_support::openjph_batch_fixtures() + .iter() + .find(|fixture| fixture.name == "openjph-gray-u8-53-raw") + .expect("pinned gray8 OpenJPH fixture"); + + if !super::is_available() { + return; + } + let decoded = + super::decode_gray(fixture.encoded, 0).expect("decode through pinned OpenJPH library"); + assert_eq!(decoded, fixture.oracle); + assert_eq!(super::version(), "0.31.0"); + } +} diff --git a/crates/j2k-compare/src/openjph_shim.cpp b/crates/j2k-compare/src/openjph_shim.cpp new file mode 100644 index 00000000..3df89779 --- /dev/null +++ b/crates/j2k-compare/src/openjph_shim.cpp @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr std::size_t OUTPUT_CAP_BYTES = std::size_t{512} * 1024 * 1024; + +bool checked_output_len(std::uint32_t width, std::uint32_t height, + std::uint32_t channels, std::size_t &out) { + if ((channels != 1 && channels != 3) || width == 0 || height == 0) { + return false; + } + if (static_cast(width) > + std::numeric_limits::max() / channels) { + return false; + } + const std::size_t row = static_cast(width) * channels; + if (row > std::numeric_limits::max() / height) { + return false; + } + out = row * height; + return out <= OUTPUT_CAP_BYTES; +} + +std::uint8_t sample_u8(std::int32_t sample, std::uint32_t depth, + bool is_signed) { + if (is_signed && depth != 0 && depth <= 31) { + sample += std::int32_t{1} << (depth - 1); + } + if (depth > 8) { + sample >>= depth - 8; + } + return static_cast(std::min(std::max(sample, 0), 255)); +} +} // namespace + +extern "C" int j2k_openjph_decode_u8( + const std::uint8_t *bytes, std::size_t len, std::uint8_t reduce, + std::uint32_t channels, std::uint8_t **out_data, std::size_t *out_len, + std::uint32_t *out_width, std::uint32_t *out_height) { + if (bytes == nullptr || len == 0 || out_data == nullptr || out_len == nullptr || + out_width == nullptr || out_height == nullptr || + (channels != 1 && channels != 3)) { + return 0; + } + *out_data = nullptr; + *out_len = 0; + *out_width = 0; + *out_height = 0; + + try { + ojph::mem_infile input; + input.open(bytes, len); + ojph::codestream codestream; + codestream.set_planar(false); + codestream.read_headers(&input); + codestream.restrict_input_resolution(reduce, reduce); + const ojph::param_siz siz = codestream.access_siz(); + if (siz.get_num_components() < channels) { + return 0; + } + const std::uint32_t width = siz.get_recon_width(0); + const std::uint32_t height = siz.get_recon_height(0); + for (std::uint32_t component = 1; component < channels; ++component) { + if (siz.get_recon_width(component) != width || + siz.get_recon_height(component) != height) { + return 0; + } + } + std::size_t required = 0; + if (!checked_output_len(width, height, channels, required)) { + return 0; + } + std::vector output(required); + codestream.create(); + for (std::uint32_t y = 0; y < height; ++y) { + const std::size_t row_offset = + static_cast(y) * width * channels; + for (std::uint32_t component = 0; component < channels; ++component) { + ojph::ui32 pulled_component = 0; + const ojph::line_buf *line = codestream.pull(pulled_component); + if (line == nullptr || pulled_component != component || line->i32 == nullptr || + line->size < width) { + return 0; + } + const std::uint32_t depth = siz.get_bit_depth(component); + const bool signedness = siz.is_signed(component); + for (std::uint32_t x = 0; x < width; ++x) { + output[row_offset + static_cast(x) * channels + component] = + sample_u8(line->i32[x], depth, signedness); + } + } + } + codestream.close(); + input.close(); + + auto *allocation = static_cast(std::malloc(output.size())); + if (allocation == nullptr) { + return 0; + } + std::memcpy(allocation, output.data(), output.size()); + *out_data = allocation; + *out_len = output.size(); + *out_width = width; + *out_height = height; + return 1; + } catch (...) { + return 0; + } +} + +extern "C" void j2k_openjph_free(void *ptr) { std::free(ptr); } diff --git a/crates/j2k-compare/tests/cli_usage.rs b/crates/j2k-compare/tests/cli_usage.rs index cb3b5abb..54bf476e 100644 --- a/crates/j2k-compare/tests/cli_usage.rs +++ b/crates/j2k-compare/tests/cli_usage.rs @@ -15,6 +15,7 @@ fn encode_compare_help_prints_the_cli_contract() { &format!( "usage: {program} [case-name-filter ...]\n\ {spaces}{program} --encode-one --input FILE.pnm --output FILE.jp2\n\ + {spaces}{program} --openjph-matrix\n\ Runs CLI-style lossless classic JPEG 2000 encoder benchmarks.\n", spaces = " " ), diff --git a/crates/j2k-cuda-j2k-engine/build.rs b/crates/j2k-cuda-j2k-engine/build.rs index a6e88e48..60c964a3 100644 --- a/crates/j2k-cuda-j2k-engine/build.rs +++ b/crates/j2k-cuda-j2k-engine/build.rs @@ -12,6 +12,7 @@ const J2K_DECODE_STORE_EXTRA_SOURCES: &[&str] = &[ "simt/src/sample.rs", "simt/src/transform.rs", ]; +const HTJ2K_ENCODE_EXTRA_SOURCES: &[&str] = &["simt/src/analysis.rs"]; const J2K_ENCODE_EXTRA_SOURCES: &[&str] = &[ "simt/src/abi.rs", "simt/src/constants.rs", @@ -32,7 +33,7 @@ const PROJECTS: &[CudaOxideProject] = &[ output_name: "cuda_oxide_htj2k_encode.ptx", artifact_name: "j2k_cuda_oxide_htj2k_encode.ptx", display_name: "cuda-oxide HTJ2K encode", - extra_sources: &[], + extra_sources: HTJ2K_ENCODE_EXTRA_SOURCES, built_cfg: "j2k_cuda_oxide_htj2k_encode_built", }, CudaOxideProject { diff --git a/crates/j2k-cuda-j2k-engine/src/cuda_oxide_htj2k_encode/simt/src/analysis.rs b/crates/j2k-cuda-j2k-engine/src/cuda_oxide_htj2k_encode/simt/src/analysis.rs new file mode 100644 index 00000000..c6670fc0 --- /dev/null +++ b/crates/j2k-cuda-j2k-engine/src/cuda_oxide_htj2k_encode/simt/src/analysis.rs @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use super::{ + load_i32, max_u32, unsigned_magnitude, J2kHtEncodeJob, J2kHtEncodeMultiInputJob, + J2kHtEncodeParams, +}; + +pub(super) fn max_magnitude_serial( + coefficients: *const i32, + width: u32, + height: u32, + coefficient_stride: u32, +) -> u32 { + let mut max_magnitude = 0; + let mut y = 0; + while y < height { + let mut x = 0; + while x < width { + let magnitude = unsigned_magnitude(load_i32(coefficients, y * coefficient_stride + x)); + max_magnitude = max_u32(max_magnitude, magnitude); + x += 1; + } + y += 1; + } + max_magnitude +} + +fn coefficient_analysis_serial( + coefficients: *const i32, + width: u32, + height: u32, + coefficient_stride: u32, +) -> (u32, u32) { + let mut max_magnitude = 0; + let mut significant_count = 0; + let mut y = 0; + while y < height { + let mut x = 0; + while x < width { + let magnitude = unsigned_magnitude(load_i32(coefficients, y * coefficient_stride + x)); + max_magnitude = max_u32(max_magnitude, magnitude); + significant_count += u32::from(magnitude >= 4); + x += 1; + } + y += 1; + } + (max_magnitude, significant_count) +} + +pub(super) fn serial_analysis_for_passes( + coefficients: *const i32, + width: u32, + height: u32, + coefficient_stride: u32, + target_coding_passes: u32, +) -> (u32, u32) { + if target_coding_passes == 3 { + coefficient_analysis_serial(coefficients, width, height, coefficient_stride) + } else { + ( + max_magnitude_serial(coefficients, width, height, coefficient_stride), + 0, + ) + } +} + +#[inline(always)] +pub(super) fn params_from_job(job: J2kHtEncodeJob) -> J2kHtEncodeParams { + J2kHtEncodeParams { + width: job.width, + height: job.height, + coefficient_stride: job.coefficient_stride, + total_bitplanes: job.total_bitplanes, + output_capacity: job.output_capacity, + target_coding_passes: job.target_coding_passes, + } +} + +#[inline(always)] +pub(super) fn params_from_multi_job(job: J2kHtEncodeMultiInputJob) -> J2kHtEncodeParams { + J2kHtEncodeParams { + width: job.width, + height: job.height, + coefficient_stride: job.coefficient_stride, + total_bitplanes: job.total_bitplanes, + output_capacity: job.output_capacity, + target_coding_passes: job.target_coding_passes, + } +} diff --git a/crates/j2k-cuda-j2k-engine/src/cuda_oxide_htj2k_encode/simt/src/main.rs b/crates/j2k-cuda-j2k-engine/src/cuda_oxide_htj2k_encode/simt/src/main.rs index 69a7cdfa..0ec42588 100644 --- a/crates/j2k-cuda-j2k-engine/src/cuda_oxide_htj2k_encode/simt/src/main.rs +++ b/crates/j2k-cuda-j2k-engine/src/cuda_oxide_htj2k_encode/simt/src/main.rs @@ -10,12 +10,17 @@ clippy::too_many_lines, reason = "HT device kernels keep bitstream state local to preserve control flow and register layout" )] - use cuda_device::{kernel, thread}; use cuda_host::cuda_module; include!("../../../cuda_oxide_simt_prelude.rs"); +mod analysis; + +use analysis::{ + max_magnitude_serial, params_from_job, params_from_multi_job, serial_analysis_for_passes, +}; + const ENCODE_STATUS_OK: u32 = 0; const ENCODE_STATUS_FAIL: u32 = 1; const ENCODE_STATUS_UNSUPPORTED: u32 = 2; @@ -129,22 +134,38 @@ struct SigPropWriter { #[inline(always)] fn min_u32(a: u32, b: u32) -> u32 { - if a < b { a } else { b } + if a < b { + a + } else { + b + } } #[inline(always)] fn max_u32(a: u32, b: u32) -> u32 { - if a > b { a } else { b } + if a > b { + a + } else { + b + } } #[inline(always)] fn max_i32(a: i32, b: i32) -> i32 { - if a > b { a } else { b } + if a > b { + a + } else { + b + } } #[inline(always)] fn max_u8(a: u8, b: u8) -> u8 { - if a > b { a } else { b } + if a > b { + a + } else { + b + } } #[inline(always)] @@ -234,7 +255,7 @@ fn set_status_with_segments( passes: u32, zbp: u32, cleanup_len: u32, - refinement_len: u32, + sigprop_len: u32, reserved: u32, ) { unsafe { @@ -244,7 +265,7 @@ fn set_status_with_segments( (*status).num_coding_passes = passes; (*status).num_zero_bitplanes = zbp; (*status).reserved0 = cleanup_len; - (*status).reserved1 = refinement_len; + (*status).reserved1 = sigprop_len; (*status).reserved2 = reserved; } } @@ -347,6 +368,7 @@ fn sigprop_cleanup_sig16( height: u32, x_base: u32, y_base: u32, + cleanup_threshold: u32, ) -> u32 { let mut mask = 0; let mut col = 0; @@ -359,7 +381,7 @@ fn sigprop_cleanup_sig16( if y < height { let magnitude = unsigned_magnitude(load_i32(coefficients, y * coefficient_stride + x)); - if magnitude >= 5 && (magnitude & 1) != 0 { + if magnitude >= cleanup_threshold { mask |= 1 << (col * 4 + row); } } @@ -378,6 +400,8 @@ fn sigprop_target_sig16( height: u32, x_base: u32, y_base: u32, + cleanup_threshold: u32, + refinement_mask: u32, ) -> u32 { let mut mask = 0; let mut col = 0; @@ -390,7 +414,7 @@ fn sigprop_target_sig16( if y < height { let magnitude = unsigned_magnitude(load_i32(coefficients, y * coefficient_stride + x)); - if magnitude == 3 { + if magnitude < cleanup_threshold && (magnitude & refinement_mask) != 0 { mask |= 1 << (col * 4 + row); } } @@ -428,6 +452,8 @@ fn write_sigprop_segment( coefficient_stride: u32, width: u32, height: u32, + cleanup_threshold: u32, + refinement_mask: u32, out: *mut u8, capacity: u32, bytes_written: &mut u32, @@ -471,28 +497,43 @@ fn write_sigprop_segment( let idx = x >> 2; let ps = (prev_row_sig[idx as usize] as u32) | ((prev_row_sig[(idx + 1) as usize] as u32) << 16); - let ns = - sigprop_cleanup_sig16(coefficients, coefficient_stride, width, height, x, y + 4) - | (sigprop_cleanup_sig16( - coefficients, - coefficient_stride, - width, - height, - x + 4, - y + 4, - ) << 16); + let ns = sigprop_cleanup_sig16( + coefficients, + coefficient_stride, + width, + height, + x, + y + 4, + cleanup_threshold, + ) | (sigprop_cleanup_sig16( + coefficients, + coefficient_stride, + width, + height, + x + 4, + y + 4, + cleanup_threshold, + ) << 16); let mut u = (ps & 0x8888_8888) >> 3; u |= (ns & 0x1111_1111) << 3; - let cs = sigprop_cleanup_sig16(coefficients, coefficient_stride, width, height, x, y) - | (sigprop_cleanup_sig16( - coefficients, - coefficient_stride, - width, - height, - x + 4, - y, - ) << 16); + let cs = sigprop_cleanup_sig16( + coefficients, + coefficient_stride, + width, + height, + x, + y, + cleanup_threshold, + ) | (sigprop_cleanup_sig16( + coefficients, + coefficient_stride, + width, + height, + x + 4, + y, + cleanup_threshold, + ) << 16); let mut mbr = cs; mbr |= (cs & 0x7777_7777) << 1; mbr |= (cs & 0xeeee_eeee) >> 1; @@ -505,9 +546,16 @@ fn write_sigprop_segment( mbr &= !cs; let mut new_sig = 0; - let target_sig = - sigprop_target_sig16(coefficients, coefficient_stride, width, height, x, y) - & col_pattern; + let target_sig = sigprop_target_sig16( + coefficients, + coefficient_stride, + width, + height, + x, + y, + cleanup_threshold, + refinement_mask, + ) & col_pattern; if mbr != 0 { let mut candidates = mbr; let mut processed = 0; @@ -550,10 +598,6 @@ fn write_sigprop_segment( } } - if (target_sig & !new_sig) != 0 { - return 0; - } - let combined_sig = new_sig | cs; prev_row_sig[idx as usize] = (combined_sig & 0xffff) as u16; prev_row_sig[(idx + 1) as usize] = ((combined_sig >> 16) & 0xffff) as u16; @@ -581,6 +625,8 @@ fn write_magref_segment( coefficient_stride: u32, width: u32, height: u32, + cleanup_threshold: u32, + refinement_mask: u32, out: *mut u8, magref_len: u32, expected_bits: u32, @@ -615,8 +661,9 @@ fn write_magref_segment( coefficients, yy * coefficient_stride + x, )); - if magnitude >= 5 && (magnitude & 1) != 0 { - current |= (((magnitude >> 1) & 1) << used_bits) as u8; + if magnitude >= cleanup_threshold { + current |= (u32::from((magnitude & refinement_mask) != 0) + << used_bits) as u8; used_bits += 1; bit_idx += 1; let stuffed = @@ -1408,6 +1455,7 @@ fn encode_ht_code_block_impl_with_max_and_assembly( uvlc_table: *const u8, status: *mut J2kHtEncodeStatus, max_magnitude: u32, + significant_count: u32, cleanup_only: bool, assemble_final: bool, fixed_64: bool, @@ -1449,7 +1497,6 @@ fn encode_ht_code_block_impl_with_max_and_assembly( return; } - let mut significant_count = 0; if !cleanup_only && params.target_coding_passes > 1 && params.total_bitplanes < params.target_coding_passes @@ -1457,40 +1504,6 @@ fn encode_ht_code_block_impl_with_max_and_assembly( set_status(status, ENCODE_STATUS_UNSUPPORTED, 5, 0, 0, 0); return; } - if !cleanup_only && params.target_coding_passes == 2 { - let mut y = 0; - while y < params.height { - let mut x = 0; - while x < params.width { - let magnitude = - unsigned_magnitude(load_i32(coefficients, y * params.coefficient_stride + x)); - if magnitude != 0 && (magnitude < 3 || (magnitude & 1) == 0) { - set_status(status, ENCODE_STATUS_UNSUPPORTED, 6, 0, 0, 0); - return; - } - x += 1; - } - y += 1; - } - } else if !cleanup_only && params.target_coding_passes == 3 { - let mut y = 0; - while y < params.height { - let mut x = 0; - while x < params.width { - let magnitude = - unsigned_magnitude(load_i32(coefficients, y * params.coefficient_stride + x)); - if magnitude != 0 && magnitude != 3 { - significant_count += 1; - if magnitude < 5 || (magnitude & 1) == 0 { - set_status(status, ENCODE_STATUS_UNSUPPORTED, 6, 0, 0, 0); - return; - } - } - x += 1; - } - y += 1; - } - } let width = if fixed_64 { 64 } else { params.width }; let height = if fixed_64 { 64 } else { params.height }; @@ -1506,7 +1519,13 @@ fn encode_ht_code_block_impl_with_max_and_assembly( }; let missing_msbs = params.total_bitplanes - pass_span; let p = 30 - missing_msbs; - + let cleanup_bitplane = pass_span - 1; + let cleanup_threshold = 1 << cleanup_bitplane; + let refinement_mask = if cleanup_bitplane == 0 { + 0 + } else { + 1 << (cleanup_bitplane - 1) + }; let mut mel = MelEncoder { pos: 0, remaining_bits: 8, @@ -1649,6 +1668,7 @@ fn encode_ht_code_block_impl_with_max_and_assembly( let mut magref_len = 0; let mut refinement_len = 0; if !cleanup_only && params.target_coding_passes == 2 { + sigprop_len = 1; refinement_len = 1; } else if !cleanup_only && params.target_coding_passes == 3 { let sample_count = width * height; @@ -1658,6 +1678,8 @@ fn encode_ht_code_block_impl_with_max_and_assembly( coefficient_stride, width, height, + cleanup_threshold, + refinement_mask, core::ptr::null_mut(), u32::MAX, &mut actual_sigprop_len, @@ -1715,6 +1737,8 @@ fn encode_ht_code_block_impl_with_max_and_assembly( coefficient_stride, width, height, + cleanup_threshold, + refinement_mask, unsafe { out.add(cleanup_len as usize) }, sigprop_len, &mut actual_sigprop_len, @@ -1731,6 +1755,8 @@ fn encode_ht_code_block_impl_with_max_and_assembly( coefficient_stride, width, height, + cleanup_threshold, + refinement_mask, unsafe { out.add((cleanup_len + sigprop_len) as usize) }, magref_len, significant_count, @@ -1749,7 +1775,7 @@ fn encode_ht_code_block_impl_with_max_and_assembly( pass_span, missing_msbs, cleanup_len, - refinement_len, + sigprop_len, if assemble_final { 0 } else { @@ -1758,52 +1784,6 @@ fn encode_ht_code_block_impl_with_max_and_assembly( ); } -fn max_magnitude_serial( - coefficients: *const i32, - width: u32, - height: u32, - coefficient_stride: u32, -) -> u32 { - let mut max_magnitude = 0; - let mut y = 0; - while y < height { - let mut x = 0; - while x < width { - max_magnitude = max_u32( - max_magnitude, - unsigned_magnitude(load_i32(coefficients, y * coefficient_stride + x)), - ); - x += 1; - } - y += 1; - } - max_magnitude -} - -#[inline(always)] -fn params_from_job(job: J2kHtEncodeJob) -> J2kHtEncodeParams { - J2kHtEncodeParams { - width: job.width, - height: job.height, - coefficient_stride: job.coefficient_stride, - total_bitplanes: job.total_bitplanes, - output_capacity: job.output_capacity, - target_coding_passes: job.target_coding_passes, - } -} - -#[inline(always)] -fn params_from_multi_job(job: J2kHtEncodeMultiInputJob) -> J2kHtEncodeParams { - J2kHtEncodeParams { - width: job.width, - height: job.height, - coefficient_stride: job.coefficient_stride, - total_bitplanes: job.total_bitplanes, - output_capacity: job.output_capacity, - target_coding_passes: job.target_coding_passes, - } -} - #[cuda_module] mod kernels { use super::*; @@ -1826,11 +1806,12 @@ mod kernels { let job = load_job(jobs, job_idx); let params = params_from_job(job); let coeffs = simt_const_ptr_at(coefficients, job.coefficient_offset as usize); - let max_magnitude = max_magnitude_serial( + let (max_magnitude, significant_count) = serial_analysis_for_passes( coeffs, params.width, params.height, params.coefficient_stride, + params.target_coding_passes, ); encode_ht_code_block_impl_with_max_and_assembly( coeffs, @@ -1841,6 +1822,7 @@ mod kernels { uvlc_table, simt_mut_ptr_at(statuses, job_idx as usize), max_magnitude, + significant_count, false, params.target_coding_passes != 1, false, @@ -1865,11 +1847,12 @@ mod kernels { let params = params_from_multi_job(job); let coefficients = job.coefficient_ptr as usize as *const i32; let coeffs = simt_const_ptr_at(coefficients, job.coefficient_offset as usize); - let max_magnitude = max_magnitude_serial( + let (max_magnitude, significant_count) = serial_analysis_for_passes( coeffs, params.width, params.height, params.coefficient_stride, + params.target_coding_passes, ); encode_ht_code_block_impl_with_max_and_assembly( coeffs, @@ -1880,6 +1863,7 @@ mod kernels { uvlc_table, simt_mut_ptr_at(statuses, job_idx as usize), max_magnitude, + significant_count, false, params.target_coding_passes != 1, false, @@ -1924,6 +1908,7 @@ mod kernels { uvlc_table, simt_mut_ptr_at(statuses, job_idx as usize), max_magnitude, + 0, true, false, fixed_64, @@ -1958,6 +1943,7 @@ mod kernels { uvlc_table, simt_mut_ptr_at(statuses, job_idx as usize), max_magnitude, + 0, true, false, true, diff --git a/crates/j2k-cuda-j2k-engine/src/htj2k_encode.rs b/crates/j2k-cuda-j2k-engine/src/htj2k_encode.rs index 695448f2..2cdf378b 100644 --- a/crates/j2k-cuda-j2k-engine/src/htj2k_encode.rs +++ b/crates/j2k-cuda-j2k-engine/src/htj2k_encode.rs @@ -16,9 +16,10 @@ pub(crate) use self::planning::{ htj2k_encode_compact_jobs, htj2k_encode_compact_jobs_multi_input, HTJ2K_ENCODE_OUTPUT_CAPACITY, }; pub(crate) use self::types::{ - htj2k_encoded_cleanup_length, htj2k_encoded_num_coding_passes, - htj2k_encoded_num_zero_bitplanes, htj2k_encoded_refinement_length, CudaHtj2kEncodeCompactJob, - CudaHtj2kEncodeKernelJob, CudaHtj2kEncodeMultiInputKernelJob, + htj2k_encoded_cleanup_length, htj2k_encoded_magref_length, htj2k_encoded_num_coding_passes, + htj2k_encoded_num_zero_bitplanes, htj2k_encoded_refinement_length, + htj2k_encoded_sigprop_length, CudaHtj2kEncodeCompactJob, CudaHtj2kEncodeKernelJob, + CudaHtj2kEncodeMultiInputKernelJob, }; pub use self::types::{ CudaHtj2kEncodeCodeBlockJob, CudaHtj2kEncodeCodeBlockRegionJob, CudaHtj2kEncodeResidentTarget, diff --git a/crates/j2k-cuda-j2k-engine/src/htj2k_encode/compact_output.rs b/crates/j2k-cuda-j2k-engine/src/htj2k_encode/compact_output.rs index ac07a4f3..21d87026 100644 --- a/crates/j2k-cuda-j2k-engine/src/htj2k_encode/compact_output.rs +++ b/crates/j2k-cuda-j2k-engine/src/htj2k_encode/compact_output.rs @@ -5,10 +5,10 @@ use crate::{ error::CudaError, execution::CudaExecutionStats, htj2k_encode::{ - htj2k_encoded_cleanup_length, htj2k_encoded_num_coding_passes, + htj2k_encoded_cleanup_length, htj2k_encoded_magref_length, htj2k_encoded_num_coding_passes, htj2k_encoded_num_zero_bitplanes, htj2k_encoded_refinement_length, - CudaHtj2kEncodeStageTimings, CudaHtj2kEncodeStatus, CudaHtj2kEncodedCodeBlock, - CudaHtj2kEncodedCodeBlocks, + htj2k_encoded_sigprop_length, CudaHtj2kEncodeStageTimings, CudaHtj2kEncodeStatus, + CudaHtj2kEncodedCodeBlock, CudaHtj2kEncodedCodeBlocks, }, }; diff --git a/crates/j2k-cuda-j2k-engine/src/htj2k_encode/types.rs b/crates/j2k-cuda-j2k-engine/src/htj2k_encode/types.rs index edc4f66e..0f6a707d 100644 --- a/crates/j2k-cuda-j2k-engine/src/htj2k_encode/types.rs +++ b/crates/j2k-cuda-j2k-engine/src/htj2k_encode/types.rs @@ -170,7 +170,7 @@ pub struct CudaHtj2kEncodeStatus { pub missing_bit_planes: u32, /// Reserved for ABI stability. pub reserved0: u32, - /// Reserved for ABI stability. + /// Exact `SigProp` prefix length for multi-pass output. pub reserved1: u32, /// Reserved for ABI stability. pub reserved2: u32, @@ -248,6 +248,18 @@ impl CudaHtj2kEncodedCodeBlock { htj2k_encoded_num_zero_bitplanes(self.status), ) } + + /// Consume this block and preserve exact cleanup/SigProp/MagRef boundaries. + pub fn into_exact_parts(self) -> (Vec, u32, u32, u32, u8, u8) { + ( + self.data, + htj2k_encoded_cleanup_length(self.status), + htj2k_encoded_sigprop_length(self.status), + htj2k_encoded_magref_length(self.status), + htj2k_encoded_num_coding_passes(self.status), + htj2k_encoded_num_zero_bitplanes(self.status), + ) + } } pub(crate) fn htj2k_encoded_cleanup_length(status: CudaHtj2kEncodeStatus) -> u32 { @@ -259,6 +271,14 @@ pub(crate) fn htj2k_encoded_cleanup_length(status: CudaHtj2kEncodeStatus) -> u32 } pub(crate) fn htj2k_encoded_refinement_length(status: CudaHtj2kEncodeStatus) -> u32 { + if status.number_of_coding_passes <= 1 { + 0 + } else { + status.data_len.saturating_sub(status.reserved0) + } +} + +pub(crate) fn htj2k_encoded_sigprop_length(status: CudaHtj2kEncodeStatus) -> u32 { if status.number_of_coding_passes <= 1 { 0 } else { @@ -266,6 +286,10 @@ pub(crate) fn htj2k_encoded_refinement_length(status: CudaHtj2kEncodeStatus) -> } } +pub(crate) fn htj2k_encoded_magref_length(status: CudaHtj2kEncodeStatus) -> u32 { + htj2k_encoded_refinement_length(status).saturating_sub(htj2k_encoded_sigprop_length(status)) +} + pub(crate) fn htj2k_encoded_num_coding_passes(status: CudaHtj2kEncodeStatus) -> u8 { u8::try_from(status.number_of_coding_passes).unwrap_or(u8::MAX) } diff --git a/crates/j2k-cuda-j2k-engine/src/kernels.rs b/crates/j2k-cuda-j2k-engine/src/kernels.rs index efe565fb..9119d19d 100644 --- a/crates/j2k-cuda-j2k-engine/src/kernels.rs +++ b/crates/j2k-cuda-j2k-engine/src/kernels.rs @@ -14,6 +14,7 @@ pub(crate) use j2k_cuda_runtime::CudaLaunchGeometry; const HTJ2K_DECODE_PACKED_BLOCK_MIN_JOBS: usize = 2_048; const HTJ2K_DECODE_CODEBLOCK_THREADS: u32 = 32; +const HTJ2K_ENCODE_CODEBLOCK_THREADS: u32 = 128; const SAMPLE_THREADS: u32 = 256; const J2K_THREADS_X: u32 = 16; const J2K_THREADS_Y: u32 = 16; @@ -337,7 +338,7 @@ pub(crate) fn htj2k_encode_codeblock_launch_geometry( job_count: usize, ) -> Option { let jobs = u32::try_from(job_count).ok()?; - CudaLaunchGeometry::new((jobs, 1, 1), (128, 1, 1)) + CudaLaunchGeometry::new((jobs, 1, 1), (HTJ2K_ENCODE_CODEBLOCK_THREADS, 1, 1)) } pub(crate) fn htj2k_packetize_launch_geometry(packet_count: usize) -> Option { @@ -408,12 +409,35 @@ fn j2k_decode_store_ptx() -> &'static [u8] { mod tests { use super::*; + #[test] + fn rejected_parallel_ht_encode_candidate_is_absent() { + let sources = [ + include_str!("kernels.rs"), + include_str!("htj2k_encode/launch.rs"), + include_str!("cuda_oxide_htj2k_encode/simt/src/main.rs"), + ]; + for rejected in [ + ["J2", "K_CUDA_HT_ENCODE_", "COOPERATIVE"].concat(), + ["j2k_htj2k_encode_codeblocks_", "cooperative"].concat(), + ["j2k_htj2k_encode_codeblocks_multi_input_", "cooperative"].concat(), + ] { + assert!( + sources.iter().all(|source| !source.contains(&rejected)), + "rejected CUDA HT encode candidate marker remains: {rejected}" + ); + } + } + #[test] fn htj2k_launch_geometry_matches_codeblock_work() { let samples = htj2k_codeblock_sample_launch_geometry(3).expect("sample geometry"); assert_eq!(samples.grid(), (3, 1, 1)); assert_eq!(samples.block(), (SAMPLE_THREADS, 1, 1)); + let encode = htj2k_encode_codeblock_launch_geometry(3).expect("encode geometry"); + assert_eq!(encode.grid(), (3, 1, 1)); + assert_eq!(encode.block(), (HTJ2K_ENCODE_CODEBLOCK_THREADS, 1, 1)); + let small = htj2k_codeblock_launch_geometry(1_200).expect("small geometry"); assert_eq!(small.grid(), (1_200, 1, 1)); assert_eq!(small.block(), (1, 1, 1)); diff --git a/crates/j2k-cuda-j2k-engine/src/macros.rs b/crates/j2k-cuda-j2k-engine/src/macros.rs index 8f6a07fa..caf6a2d5 100644 --- a/crates/j2k-cuda-j2k-engine/src/macros.rs +++ b/crates/j2k-cuda-j2k-engine/src/macros.rs @@ -18,6 +18,16 @@ macro_rules! impl_cuda_htj2k_encoded_status_accessors { htj2k_encoded_refinement_length(self.status) } + /// HTJ2K `SigProp` prefix length in bytes. + pub fn sigprop_length(&self) -> u32 { + htj2k_encoded_sigprop_length(self.status) + } + + /// HTJ2K `MagRef` suffix length in bytes. + pub fn magref_length(&self) -> u32 { + htj2k_encoded_magref_length(self.status) + } + /// Number of coding passes in the encoded payload. pub fn num_coding_passes(&self) -> u8 { htj2k_encoded_num_coding_passes(self.status) diff --git a/crates/j2k-cuda-j2k-engine/src/tests/htj2k_encode.rs b/crates/j2k-cuda-j2k-engine/src/tests/htj2k_encode.rs index 95db4aa5..99a24909 100644 --- a/crates/j2k-cuda-j2k-engine/src/tests/htj2k_encode.rs +++ b/crates/j2k-cuda-j2k-engine/src/tests/htj2k_encode.rs @@ -11,7 +11,7 @@ fn htj2k_encoded_codeblock_reports_segment_lengths_from_status() { number_of_coding_passes: 3, missing_bit_planes: 4, reserved0: 7, - reserved1: 3, + reserved1: 2, reserved2: 0, }, execution: super::CudaExecutionStats::default(), @@ -20,6 +20,8 @@ fn htj2k_encoded_codeblock_reports_segment_lengths_from_status() { assert_eq!(encoded.cleanup_length(), 7); assert_eq!(encoded.refinement_length(), 3); + assert_eq!(encoded.sigprop_length(), 2); + assert_eq!(encoded.magref_length(), 1); } fn htj2k_multi_input_compact_job( @@ -554,7 +556,7 @@ fn htj2k_encode_rejects_unsupported_refinement_pass_count_when_required() { } #[test] -fn htj2k_encode_rejects_lossy_zero_sigprop_request_when_required() { +fn htj2k_encode_accepts_general_sigprop_coefficients_when_required() { if !cuda_runtime_gate() { return; } @@ -569,7 +571,7 @@ fn htj2k_encode_rejects_lossy_zero_sigprop_request_when_required() { target_coding_passes: 2, }]; - let error = crate::J2kCudaEngine::new(&context) + let encoded = crate::J2kCudaEngine::new(&context) .encode_htj2k_codeblocks( &coefficients, &jobs, @@ -579,24 +581,14 @@ fn htj2k_encode_rejects_lossy_zero_sigprop_request_when_required() { uvlc_table: &[0u8; super::HTJ2K_UVLC_ENCODE_TABLE_BYTES], }, ) - .expect_err("target-2 zero SigProp cannot silently drop low coefficient bits"); + .expect("general target-2 SigProp coefficients encode"); - match error { - CudaError::KernelStatus { - kernel, - code, - detail, - } => { - assert_eq!(kernel, "j2k_htj2k_encode_codeblocks"); - assert_eq!(code, super::HTJ2K_STATUS_UNSUPPORTED); - assert_eq!(detail, 6); - } - other => panic!("unexpected CUDA encode error: {other:?}"), - } + assert_eq!(encoded.code_blocks()[0].num_coding_passes(), 2); + assert!(encoded.code_blocks()[0].sigprop_length() > 0); } #[test] -fn htj2k_encode_rejects_unreachable_target_three_sigprop_coefficients_when_required() { +fn htj2k_encode_accepts_isolated_target_three_coefficients_when_required() { if !cuda_runtime_gate() { return; } @@ -611,7 +603,7 @@ fn htj2k_encode_rejects_unreachable_target_three_sigprop_coefficients_when_requi target_coding_passes: 3, }]; - let error = crate::J2kCudaEngine::new(&context) + let encoded = crate::J2kCudaEngine::new(&context) .encode_htj2k_codeblocks( &coefficients, &jobs, @@ -621,20 +613,10 @@ fn htj2k_encode_rejects_unreachable_target_three_sigprop_coefficients_when_requi uvlc_table: &[0u8; super::HTJ2K_UVLC_ENCODE_TABLE_BYTES], }, ) - .expect_err("isolated target-3 SigProp coefficient is explicitly unsupported"); + .expect("isolated target-3 coefficient encode"); - match error { - CudaError::KernelStatus { - kernel, - code, - detail, - } => { - assert_eq!(kernel, "j2k_htj2k_encode_codeblocks"); - assert_eq!(code, super::HTJ2K_STATUS_UNSUPPORTED); - assert_eq!(detail, 6); - } - other => panic!("unexpected CUDA encode error: {other:?}"), - } + assert_eq!(encoded.code_blocks()[0].num_coding_passes(), 3); + assert!(encoded.code_blocks()[0].sigprop_length() > 0); } #[test] diff --git a/crates/j2k-cuda/benches/htj2k_encode.rs b/crates/j2k-cuda/benches/htj2k_encode.rs index f4b6f128..d74c0a26 100644 --- a/crates/j2k-cuda/benches/htj2k_encode.rs +++ b/crates/j2k-cuda/benches/htj2k_encode.rs @@ -7,7 +7,9 @@ use std::{ time::Duration, }; -use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; +use criterion::{ + criterion_group, criterion_main, measurement::WallTime, BenchmarkGroup, BenchmarkId, Criterion, +}; use j2k::{ encode_j2k_lossless, EncodeBackendPreference, J2kBlockCodingMode, J2kEncodeValidation, J2kLosslessEncodeOptions, J2kLosslessSamples, @@ -20,7 +22,8 @@ use j2k_cuda_j2k_engine::{ }; use j2k_cuda_runtime::CudaContext; use j2k_native::{ - encode_ht_code_block_scalar, ht_uvlc_encode_table, ht_vlc_encode_table0, ht_vlc_encode_table1, + encode_ht_code_block_scalar, encode_ht_code_block_scalar_with_passes, ht_uvlc_encode_table, + ht_vlc_encode_table0, ht_vlc_encode_table1, }; use j2k_test_support::{ canonicalize_manifest_row_path, fnv1a64_hex, manifest_column, manifest_field, @@ -210,87 +213,134 @@ fn bench_codeblock_microkernels( jobs: &[CudaHtj2kEncodeCodeBlockJob], cuda_available: bool, ) { + let refinement_jobs = jobs + .iter() + .map(|job| CudaHtj2kEncodeCodeBlockJob { + target_coding_passes: 3, + ..*job + }) + .collect::>(); let mut group = c.benchmark_group("j2k_cuda_htj2k_codeblock_microkernel"); + for (cpu_id, host_id, resident_id, workload_jobs) in [ + ( + "cpu_scalar_cleanup", + "cuda_host_staged_cleanup", + "cuda_resident_cleanup", + jobs, + ), + ( + "cpu_scalar_refinement", + "cuda_host_staged_refinement", + "cuda_resident_refinement", + refinement_jobs.as_slice(), + ), + ] { + bench_cpu_codeblock_workload(&mut group, cpu_id, coefficients, workload_jobs); + if cuda_available { + bench_cuda_codeblock_workload( + &mut group, + host_id, + resident_id, + coefficients, + workload_jobs, + ); + } + } + group.finish(); +} + +fn bench_cpu_codeblock_workload( + group: &mut BenchmarkGroup<'_, WallTime>, + id: &str, + coefficients: &[i32], + jobs: &[CudaHtj2kEncodeCodeBlockJob], +) { group.bench_with_input( - BenchmarkId::new("cpu_scalar_cleanup", CODE_BLOCK_BATCH), + BenchmarkId::new(id, CODE_BLOCK_BATCH), &(coefficients, jobs), |b, (coefficients, jobs)| { b.iter(|| { let encoded_bytes = jobs .iter() .map(|job| { - let coefficients = contiguous_block(coefficients, *job); - encode_ht_code_block_scalar( - std::hint::black_box(coefficients), + let block = contiguous_block(coefficients, *job); + let encoded = encode_ht_code_block_scalar_with_passes( + std::hint::black_box(block), job.width, job.height, job.total_bitplanes, + job.target_coding_passes, ) - .expect("native scalar HT code-block encode") - .data - .len() + .expect("native scalar HT code-block encode"); + assert_eq!(encoded.num_coding_passes, job.target_coding_passes); + encoded.data.len() }) .sum::(); std::hint::black_box(encoded_bytes) }); }, ); +} - if cuda_available { - group.bench_with_input( - BenchmarkId::new("cuda_host_staged_cleanup", CODE_BLOCK_BATCH), - &(coefficients, jobs), - |b, (coefficients, jobs)| { - let runtime = CudaContext::system_default().expect("CUDA context"); - let engine = J2kCudaEngine::new(&runtime); - let uvlc_table = uvlc_encode_table_bytes(); - let resources = engine - .upload_htj2k_encode_resources(cuda_encode_tables(&uvlc_table)) - .expect("CUDA HTJ2K encode resources"); - b.iter(|| { - let encoded = engine - .encode_htj2k_codeblocks_with_resources( - std::hint::black_box(coefficients), - std::hint::black_box(jobs), - &resources, - ) - .expect("CUDA host-staged HTJ2K code-block encode"); - std::hint::black_box(assert_cuda_batch(&encoded)) - }); - }, - ); +fn bench_cuda_codeblock_workload( + group: &mut BenchmarkGroup<'_, WallTime>, + host_id: &str, + resident_id: &str, + coefficients: &[i32], + jobs: &[CudaHtj2kEncodeCodeBlockJob], +) { + group.bench_with_input( + BenchmarkId::new(host_id, CODE_BLOCK_BATCH), + &(coefficients, jobs), + |b, (coefficients, jobs)| { + let runtime = CudaContext::system_default().expect("CUDA context"); + let engine = J2kCudaEngine::new(&runtime); + let uvlc_table = uvlc_encode_table_bytes(); + let resources = engine + .upload_htj2k_encode_resources(cuda_encode_tables(&uvlc_table)) + .expect("CUDA HTJ2K encode resources"); + b.iter(|| { + let encoded = engine + .encode_htj2k_codeblocks_with_resources( + std::hint::black_box(coefficients), + std::hint::black_box(jobs), + &resources, + ) + .expect("CUDA host-staged HTJ2K code-block encode"); + std::hint::black_box(assert_cuda_batch(&encoded, jobs[0].target_coding_passes)) + }); + }, + ); - group.bench_with_input( - BenchmarkId::new("cuda_resident_cleanup", CODE_BLOCK_BATCH), - &(coefficients, jobs), - |b, (coefficients, jobs)| { - let runtime = CudaContext::system_default().expect("CUDA context"); - let engine = J2kCudaEngine::new(&runtime); - let coefficient_bytes = coefficients_as_bytes(coefficients); - let resident_coefficients = runtime - .upload(&coefficient_bytes) - .expect("resident quantized coefficients"); - let uvlc_table = uvlc_encode_table_bytes(); - let resources = engine - .upload_htj2k_encode_resources(cuda_encode_tables(&uvlc_table)) - .expect("CUDA HTJ2K encode resources"); - let pool = runtime.buffer_pool(); - b.iter(|| { - let encoded = engine - .encode_htj2k_codeblocks_resident_with_resources_and_pool( - &resident_coefficients, - coefficients.len(), - std::hint::black_box(jobs), - &resources, - &pool, - ) - .expect("CUDA resident HTJ2K code-block encode"); - std::hint::black_box(assert_cuda_batch(&encoded)) - }); - }, - ); - } - group.finish(); + group.bench_with_input( + BenchmarkId::new(resident_id, CODE_BLOCK_BATCH), + &(coefficients, jobs), + |b, (coefficients, jobs)| { + let runtime = CudaContext::system_default().expect("CUDA context"); + let engine = J2kCudaEngine::new(&runtime); + let coefficient_bytes = coefficients_as_bytes(coefficients); + let resident_coefficients = runtime + .upload(&coefficient_bytes) + .expect("resident quantized coefficients"); + let uvlc_table = uvlc_encode_table_bytes(); + let resources = engine + .upload_htj2k_encode_resources(cuda_encode_tables(&uvlc_table)) + .expect("CUDA HTJ2K encode resources"); + let pool = runtime.buffer_pool(); + b.iter(|| { + let encoded = engine + .encode_htj2k_codeblocks_resident_with_resources_and_pool( + &resident_coefficients, + coefficients.len(), + std::hint::black_box(jobs), + &resources, + &pool, + ) + .expect("CUDA resident HTJ2K code-block encode"); + std::hint::black_box(assert_cuda_batch(&encoded, jobs[0].target_coding_passes)) + }); + }, + ); } fn bench_device_input_regions( @@ -351,7 +401,7 @@ fn bench_device_input_regions( &pool, ) .expect("CUDA resident strided HTJ2K encode"); - std::hint::black_box(assert_cuda_batch(&encoded)) + std::hint::black_box(assert_cuda_batch(&encoded, 1)) }); }, ); @@ -732,12 +782,12 @@ fn uvlc_encode_table_bytes() -> Vec { .collect() } -fn assert_cuda_batch(encoded: &CudaHtj2kEncodedCodeBlocks) -> usize { +fn assert_cuda_batch(encoded: &CudaHtj2kEncodedCodeBlocks, expected_passes: u8) -> usize { assert_eq!(encoded.execution().kernel_dispatches(), 1); assert!(encoded .code_blocks() .iter() - .all(|block| block.status().is_ok())); + .all(|block| block.status().is_ok() && block.num_coding_passes() == expected_passes)); encoded .code_blocks() .iter() @@ -756,7 +806,10 @@ fn gather_region(coefficients: &[i32], job: CudaHtj2kEncodeCodeBlockRegionJob) - let stride = usize::try_from(job.coefficient_stride).expect("job stride fits usize"); let width = usize::try_from(job.width).expect("job width fits usize"); let height = usize::try_from(job.height).expect("job height fits usize"); - let mut block = Vec::with_capacity(width * height); + let mut block = Vec::new(); + block + .try_reserve_exact(width * height) + .expect("bench region block allocation"); for y in 0..height { let row_start = start + y * stride; block.extend_from_slice(&coefficients[row_start..row_start + width]); @@ -765,7 +818,10 @@ fn gather_region(coefficients: &[i32], job: CudaHtj2kEncodeCodeBlockRegionJob) - } fn generate_gray_tile(width: u32, height: u32) -> Vec { - let mut pixels = Vec::with_capacity(area_len(width, height)); + let mut pixels = Vec::new(); + pixels + .try_reserve_exact(area_len(width, height)) + .expect("bench gray tile allocation"); for y in 0..height { for x in 0..width { let value = (x * 17 + y * 31 + x.wrapping_mul(y) / 11) & 0xff; @@ -776,7 +832,10 @@ fn generate_gray_tile(width: u32, height: u32) -> Vec { } fn generate_codeblock_coefficients(width: u32, height: u32, batch: usize) -> Vec { - let mut coefficients = Vec::with_capacity(area_len(width, height) * batch); + let mut coefficients = Vec::new(); + coefficients + .try_reserve_exact(area_len(width, height) * batch) + .expect("bench contiguous coefficient allocation"); for block in 0..batch { let block = u32::try_from(block).expect("bench block index fits u32"); for y in 0..height { @@ -789,7 +848,10 @@ fn generate_codeblock_coefficients(width: u32, height: u32, batch: usize) -> Vec } fn generate_region_coefficients(width: u32, height: u32) -> Vec { - let mut coefficients = Vec::with_capacity(area_len(width, height)); + let mut coefficients = Vec::new(); + coefficients + .try_reserve_exact(area_len(width, height)) + .expect("bench region coefficient allocation"); for y in 0..height { for x in 0..width { coefficients.push(patterned_coefficient( @@ -812,7 +874,9 @@ fn patterned_coefficient(x: u32, y: u32, block: u32) -> i32 { fn contiguous_jobs(width: u32, height: u32, batch: usize) -> Vec { let block_len = area_len(width, height); - let mut jobs = Vec::with_capacity(batch); + let mut jobs = Vec::new(); + jobs.try_reserve_exact(batch) + .expect("bench contiguous job allocation"); for block in 0..batch { let offset = block .checked_mul(block_len) @@ -836,7 +900,9 @@ fn strided_region_jobs( let stride = block_dim .checked_mul(blocks_x) .expect("bench stride fits u32"); - let mut jobs = Vec::with_capacity(area_len(blocks_x, blocks_y)); + let mut jobs = Vec::new(); + jobs.try_reserve_exact(area_len(blocks_x, blocks_y)) + .expect("bench strided job allocation"); for by in 0..blocks_y { for bx in 0..blocks_x { let row_offset = by @@ -860,7 +926,10 @@ fn strided_region_jobs( } fn coefficients_as_bytes(coefficients: &[i32]) -> Vec { - let mut bytes = Vec::with_capacity(std::mem::size_of_val(coefficients)); + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(std::mem::size_of_val(coefficients)) + .expect("bench coefficient byte allocation"); for coefficient in coefficients { bytes.extend_from_slice(&coefficient.to_ne_bytes()); } diff --git a/crates/j2k-cuda/src/encode/htj2k.rs b/crates/j2k-cuda/src/encode/htj2k.rs index 28b606d4..7c6d5baa 100644 --- a/crates/j2k-cuda/src/encode/htj2k.rs +++ b/crates/j2k-cuda/src/encode/htj2k.rs @@ -18,7 +18,7 @@ mod validation; pub(crate) use self::code_blocks::cuda_htj2k_encode_tables; pub(super) use self::code_blocks::{ - cuda_encode_ht_code_block, cuda_encode_ht_code_blocks, cuda_encode_ht_subband, - encoded_ht_code_blocks_from_cuda, + cuda_encode_ht_code_block, cuda_encode_ht_code_block_sets, cuda_encode_ht_code_blocks, + cuda_encode_ht_subband, encoded_ht_code_block_sets_from_cuda, encoded_ht_code_blocks_from_cuda, }; pub(super) use self::resident::{cuda_encode_htj2k_device_tile_body, cuda_encode_htj2k_tile_body}; diff --git a/crates/j2k-cuda/src/encode/htj2k/code_blocks.rs b/crates/j2k-cuda/src/encode/htj2k/code_blocks.rs index 6addd97b..229e4240 100644 --- a/crates/j2k-cuda/src/encode/htj2k/code_blocks.rs +++ b/crates/j2k-cuda/src/encode/htj2k/code_blocks.rs @@ -1,8 +1,8 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 use j2k::{ - EncodedHtJ2kCodeBlock, J2kEncodeStageError, J2kHtCodeBlockEncodeJob, J2kHtSubbandEncodeJob, - J2kResidentHtj2kTileEncodeJob, + EncodedHtJ2kCodeBlock, EncodedHtJ2kCodeBlockSet, J2kEncodeStageError, J2kHtCodeBlockEncodeJob, + J2kHtCodeBlockSetEncodeJob, J2kHtSubbandEncodeJob, J2kResidentHtj2kTileEncodeJob, }; use j2k_cuda_j2k_engine::{ CudaHtj2kEncodeCodeBlockJob, CudaHtj2kEncodeCodeBlockRegionJob, CudaHtj2kEncodeResources, @@ -101,6 +101,85 @@ pub(in crate::encode) fn cuda_encode_ht_code_blocks( .map_err(|error| runtime_error("encode CUDA HTJ2K code-block batch", error)) } +#[cfg(feature = "cuda-runtime")] +pub(in crate::encode) fn cuda_encode_ht_code_block_sets( + context: &CudaContext, + resources: &CudaHtj2kEncodeResources, + jobs: &[J2kHtCodeBlockSetEncodeJob<'_>], +) -> CudaStageResult> { + let total_coefficients = jobs.iter().try_fold(0usize, |acc, job| { + let coefficient_len = (job.width as usize) + .checked_mul(job.height as usize) + .ok_or_else(|| arithmetic_overflow("CUDA HT candidate coefficient count"))?; + if coefficient_len != job.coefficients.len() { + return Err(J2kEncodeStageError::invalid_request( + "CUDA HT candidate job has invalid coefficient length", + )); + } + acc.checked_add(coefficient_len) + .ok_or_else(|| arithmetic_overflow("CUDA HT candidate coefficient count")) + })?; + if jobs.iter().any(|job| { + !matches!( + (job.cleanup_bitplane, job.target_coding_passes), + (0, 1) | (1 | 2, 3) + ) + }) { + return Ok(None); + } + let mut host_budget = HostPhaseBudget::new("j2k CUDA HT candidate staging"); + let mut coefficients = host_budget + .try_vec_with_capacity(total_coefficients) + .map_err(htj2k_allocation_error)?; + let mut cuda_jobs = host_budget + .try_vec_with_capacity(jobs.len()) + .map_err(htj2k_allocation_error)?; + for job in jobs { + let coefficient_offset = u32::try_from(coefficients.len()) + .map_err(|_| arithmetic_overflow("CUDA HT candidate coefficient offset"))?; + let shift = job + .target_coding_passes + .saturating_sub(1) + .saturating_sub(job.cleanup_bitplane); + let kernel_total_bitplanes = job + .total_bitplanes + .checked_add(shift) + .filter(|total| *total <= 31); + let Some(kernel_total_bitplanes) = kernel_total_bitplanes else { + return Ok(None); + }; + for &coefficient in job.coefficients { + let Some(coefficient) = coefficient.checked_shl(u32::from(shift)) else { + return Ok(None); + }; + host_budget + .try_vec_push(&mut coefficients, coefficient) + .map_err(htj2k_allocation_error)?; + } + host_budget + .try_vec_push( + &mut cuda_jobs, + CudaHtj2kEncodeCodeBlockJob { + coefficient_offset, + width: job.width, + height: job.height, + total_bitplanes: kernel_total_bitplanes, + target_coding_passes: job.target_coding_passes, + }, + ) + .map_err(htj2k_allocation_error)?; + } + j2k_cuda_j2k_engine::J2kCudaEngine::new(context) + .encode_htj2k_codeblocks_with_resources_and_live_host_bytes( + &coefficients, + &cuda_jobs, + resources, + host_budget.live_bytes(), + ) + .map(Some) + .map_err(|error| runtime_error("encode CUDA HT candidate sets", error)) +} + #[cfg(feature = "cuda-runtime")] pub(super) fn cuda_ht_region_jobs( width: u32, @@ -382,6 +461,28 @@ fn encoded_ht_code_block_from_cuda( } } +#[cfg(feature = "cuda-runtime")] +fn encoded_ht_code_block_set_from_cuda( + encoded: j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock, +) -> EncodedHtJ2kCodeBlockSet { + let ( + data, + cleanup_length, + sigprop_length, + magref_length, + num_coding_passes, + num_zero_bitplanes, + ) = encoded.into_exact_parts(); + EncodedHtJ2kCodeBlockSet { + data, + cleanup_length, + sigprop_length, + magref_length, + num_coding_passes, + num_zero_bitplanes, + } +} + #[cfg(feature = "cuda-runtime")] pub(in crate::encode) fn encoded_ht_code_blocks_from_cuda( encoded: j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlocks, @@ -400,6 +501,24 @@ pub(in crate::encode) fn encoded_ht_code_blocks_from_cuda( Ok(outputs) } +#[cfg(feature = "cuda-runtime")] +pub(in crate::encode) fn encoded_ht_code_block_sets_from_cuda( + encoded: j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlocks, +) -> CudaStageResult> { + let mut host_budget = HostPhaseBudget::new("j2k CUDA encoded HT candidate conversion"); + host_budget + .account_bytes(encoded.host_capacity_bytes()) + .map_err(htj2k_allocation_error)?; + let code_blocks = encoded.into_code_blocks(); + let mut outputs = host_budget + .try_vec_with_capacity(code_blocks.len()) + .map_err(htj2k_allocation_error)?; + for code_block in code_blocks { + outputs.push(encoded_ht_code_block_set_from_cuda(code_block)); + } + Ok(outputs) +} + #[cfg(feature = "cuda-runtime")] pub(crate) fn cuda_htj2k_encode_tables() -> CudaHtj2kEncodeTables<'static> { CudaHtj2kEncodeTables { diff --git a/crates/j2k-cuda/src/encode/stage.rs b/crates/j2k-cuda/src/encode/stage.rs index 55ec1cbe..45a261d9 100644 --- a/crates/j2k-cuda/src/encode/stage.rs +++ b/crates/j2k-cuda/src/encode/stage.rs @@ -1,11 +1,12 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 use j2k::{ - EncodedHtJ2kCodeBlock, EncodedJ2kCodeBlock, J2kDeinterleaveToF32Job, J2kEncodeDispatchReport, - J2kEncodeStageAccelerator, J2kEncodeStageError, J2kForwardDwt53Job, J2kForwardDwt53Output, - J2kForwardDwt97Job, J2kForwardDwt97Output, J2kForwardIctJob, J2kForwardRctJob, - J2kHtCodeBlockEncodeJob, J2kHtSubbandEncodeJob, J2kHtj2kTileEncodeJob, - J2kPacketizationEncodeJob, J2kQuantizeSubbandJob, J2kTier1CodeBlockEncodeJob, + EncodedHtJ2kCodeBlock, EncodedHtJ2kCodeBlockSet, EncodedJ2kCodeBlock, J2kDeinterleaveToF32Job, + J2kEncodeDispatchReport, J2kEncodeStageAccelerator, J2kEncodeStageError, J2kForwardDwt53Job, + J2kForwardDwt53Output, J2kForwardDwt97Job, J2kForwardDwt97Output, J2kForwardIctJob, + J2kForwardRctJob, J2kHtCodeBlockEncodeJob, J2kHtCodeBlockSetEncodeJob, J2kHtSubbandEncodeJob, + J2kHtj2kTileEncodeJob, J2kPacketizationEncodeJob, J2kQuantizeSubbandJob, + J2kTier1CodeBlockEncodeJob, }; #[cfg(feature = "cuda-runtime")] use j2k_cuda_j2k_engine::{CudaHtj2kEncodeResources, CudaJ2kQuantizeJob}; @@ -22,8 +23,9 @@ use crate::profile; use super::cuda_component_count_u8; #[cfg(feature = "cuda-runtime")] use super::htj2k::{ - cuda_encode_ht_code_block, cuda_encode_ht_code_blocks, cuda_encode_ht_subband, - cuda_encode_htj2k_tile_body, cuda_htj2k_encode_tables, encoded_ht_code_blocks_from_cuda, + cuda_encode_ht_code_block, cuda_encode_ht_code_block_sets, cuda_encode_ht_code_blocks, + cuda_encode_ht_subband, cuda_encode_htj2k_tile_body, cuda_htj2k_encode_tables, + encoded_ht_code_block_sets_from_cuda, encoded_ht_code_blocks_from_cuda, }; #[cfg(feature = "cuda-runtime")] use super::packetization::{ @@ -43,6 +45,8 @@ use super::stage_error::{adapter_error, arithmetic_overflow, CudaStageResult}; mod dwt_output; #[cfg(feature = "cuda-runtime")] pub(super) use self::dwt_output::{cuda_dwt53_output_to_j2k, cuda_dwt97_output_to_j2k}; +#[cfg(test)] +mod diagnostics; macro_rules! emit_cuda_encode_route { ($(($key:expr, $value:expr)),+ $(,)?) => {{ @@ -297,126 +301,6 @@ impl CudaEncodeStageAccelerator { backend: encoded.backend, } } - - /// Number of forward RCT attempts observed by crate-local diagnostics. - #[cfg(test)] - pub(crate) fn forward_rct_attempts(&self) -> usize { - self.forward_rct_attempts - } - - /// Number of forward ICT attempts observed by crate-local diagnostics. - #[cfg(all(test, feature = "cuda-runtime"))] - pub(crate) fn forward_ict_attempts(&self) -> usize { - self.forward_ict_attempts - } - - /// Number of forward 5/3 DWT attempts observed by crate-local diagnostics. - #[cfg(test)] - pub(crate) fn forward_dwt53_attempts(&self) -> usize { - self.forward_dwt53_attempts - } - - /// Number of forward 9/7 DWT attempts observed by crate-local diagnostics. - #[cfg(all(test, feature = "cuda-runtime"))] - pub(crate) fn forward_dwt97_attempts(&self) -> usize { - self.forward_dwt97_attempts - } - - /// Number of resident HTJ2K tile-body attempts observed by crate-local diagnostics. - #[cfg(all(test, feature = "cuda-runtime"))] - pub(crate) fn htj2k_tile_attempts(&self) -> usize { - self.htj2k_tile_attempts - } - - /// Number of sub-band quantization attempts observed by crate-local diagnostics. - #[cfg(test)] - pub(crate) fn quantize_subband_attempts(&self) -> usize { - self.quantize_subband_attempts - } - - /// Number of classic Tier-1 code-block attempts observed by crate-local diagnostics. - #[cfg(test)] - pub(crate) fn tier1_code_block_attempts(&self) -> usize { - self.tier1_code_block_attempts - } - - /// Number of HT code-block attempts observed by crate-local diagnostics. - #[cfg(test)] - pub(crate) fn ht_code_block_attempts(&self) -> usize { - self.ht_code_block_attempts - } - - /// Number of HT sub-band attempts observed by crate-local diagnostics. - #[cfg(test)] - pub(crate) fn ht_subband_attempts(&self) -> usize { - self.ht_subband_attempts - } - - /// Number of packetization attempts observed by crate-local diagnostics. - #[cfg(test)] - pub(crate) fn packetization_attempts(&self) -> usize { - self.packetization_attempts - } - - /// Number of deinterleave CUDA dispatches observed by crate-local diagnostics. - #[cfg(all(test, feature = "cuda-runtime"))] - pub(crate) fn deinterleave_dispatches(&self) -> usize { - self.deinterleave_dispatches - } - - /// Number of forward RCT CUDA dispatches observed by crate-local diagnostics. - #[cfg(all(test, feature = "cuda-runtime"))] - pub(crate) fn forward_rct_dispatches(&self) -> usize { - self.forward_rct_dispatches - } - - /// Number of forward ICT CUDA dispatches observed by crate-local diagnostics. - #[cfg(all(test, feature = "cuda-runtime"))] - pub(crate) fn forward_ict_dispatches(&self) -> usize { - self.forward_ict_dispatches - } - - /// Number of forward 5/3 DWT CUDA dispatches observed by crate-local diagnostics. - #[cfg(all(test, feature = "cuda-runtime"))] - pub(crate) fn forward_dwt53_dispatches(&self) -> usize { - self.forward_dwt53_dispatches - } - - /// Number of forward 9/7 DWT CUDA dispatches observed by crate-local diagnostics. - #[cfg(all(test, feature = "cuda-runtime"))] - pub(crate) fn forward_dwt97_dispatches(&self) -> usize { - self.forward_dwt97_dispatches - } - - /// Number of resident HTJ2K tile-body CUDA dispatches observed by crate-local diagnostics. - #[cfg(all(test, feature = "cuda-runtime"))] - pub(crate) fn htj2k_tile_dispatches(&self) -> usize { - self.htj2k_tile_dispatches - } - - /// Number of sub-band quantization CUDA dispatches observed by crate-local diagnostics. - #[cfg(all(test, feature = "cuda-runtime"))] - pub(crate) fn quantize_subband_dispatches(&self) -> usize { - self.quantize_subband_dispatches - } - - /// Number of HT code-block CUDA dispatches observed by crate-local diagnostics. - #[cfg(all(test, feature = "cuda-runtime"))] - pub(crate) fn ht_code_block_dispatches(&self) -> usize { - self.ht_code_block_dispatches - } - - /// Number of HT sub-band CUDA dispatches observed by crate-local diagnostics. - #[cfg(all(test, feature = "cuda-runtime"))] - pub(crate) fn ht_subband_dispatches(&self) -> usize { - self.ht_subband_dispatches - } - - /// Number of packetization CUDA dispatches observed by crate-local diagnostics. - #[cfg(test)] - pub(crate) fn packetization_dispatches(&self) -> usize { - self.packetization_dispatches - } } #[cfg(feature = "cuda-runtime")] @@ -903,6 +787,39 @@ impl J2kEncodeStageAccelerator for CudaEncodeStageAccelerator { Ok(None) } + fn encode_ht_code_block_sets( + &mut self, + jobs: &[J2kHtCodeBlockSetEncodeJob<'_>], + ) -> CudaStageResult>> { + self.ht_code_block_attempts = self.ht_code_block_attempts.saturating_add(jobs.len()); + #[cfg(feature = "cuda-runtime")] + if let Some(context) = self.cuda_context()? { + let resources = self.cuda_encode_resources(&context)?; + let Some(encoded) = cuda_encode_ht_code_block_sets(&context, resources.as_ref(), jobs)? + else { + return Ok(None); + }; + let dispatches = encoded.execution().kernel_dispatches(); + let ht_encode_us = encoded.stage_timings().ht_encode_us; + let outputs = encoded_ht_code_block_sets_from_cuda(encoded)?; + self.ht_code_block_dispatches = + self.ht_code_block_dispatches.saturating_add(dispatches); + if self.collect_profile { + self.ht_encode_us = self.ht_encode_us.saturating_add(ht_encode_us); + } + emit_cuda_encode_route!( + ("op", "encode_ht_code_block_sets"), + ("decision", "cuda_dispatch"), + ("jobs", jobs.len()), + ("dispatches", dispatches), + ); + return Ok(Some(outputs)); + } + #[cfg(not(feature = "cuda-runtime"))] + let _ = jobs; + Ok(None) + } + #[expect( clippy::too_many_lines, reason = "accelerator route preserves CUDA stage attempts, fallbacks, and counters" diff --git a/crates/j2k-cuda/src/encode/stage/diagnostics.rs b/crates/j2k-cuda/src/encode/stage/diagnostics.rs new file mode 100644 index 00000000..56a94348 --- /dev/null +++ b/crates/j2k-cuda/src/encode/stage/diagnostics.rs @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Test-only observations for CUDA encode routing and dispatch behavior. + +use super::CudaEncodeStageAccelerator; + +impl CudaEncodeStageAccelerator { + pub(crate) fn forward_rct_attempts(&self) -> usize { + self.forward_rct_attempts + } + + #[cfg(feature = "cuda-runtime")] + pub(crate) fn forward_ict_attempts(&self) -> usize { + self.forward_ict_attempts + } + + pub(crate) fn forward_dwt53_attempts(&self) -> usize { + self.forward_dwt53_attempts + } + + #[cfg(feature = "cuda-runtime")] + pub(crate) fn forward_dwt97_attempts(&self) -> usize { + self.forward_dwt97_attempts + } + + #[cfg(feature = "cuda-runtime")] + pub(crate) fn htj2k_tile_attempts(&self) -> usize { + self.htj2k_tile_attempts + } + + pub(crate) fn quantize_subband_attempts(&self) -> usize { + self.quantize_subband_attempts + } + + pub(crate) fn tier1_code_block_attempts(&self) -> usize { + self.tier1_code_block_attempts + } + + pub(crate) fn ht_code_block_attempts(&self) -> usize { + self.ht_code_block_attempts + } + + pub(crate) fn ht_subband_attempts(&self) -> usize { + self.ht_subband_attempts + } + + pub(crate) fn packetization_attempts(&self) -> usize { + self.packetization_attempts + } + + #[cfg(feature = "cuda-runtime")] + pub(crate) fn deinterleave_dispatches(&self) -> usize { + self.deinterleave_dispatches + } + + #[cfg(feature = "cuda-runtime")] + pub(crate) fn forward_rct_dispatches(&self) -> usize { + self.forward_rct_dispatches + } + + #[cfg(feature = "cuda-runtime")] + pub(crate) fn forward_ict_dispatches(&self) -> usize { + self.forward_ict_dispatches + } + + #[cfg(feature = "cuda-runtime")] + pub(crate) fn forward_dwt53_dispatches(&self) -> usize { + self.forward_dwt53_dispatches + } + + #[cfg(feature = "cuda-runtime")] + pub(crate) fn forward_dwt97_dispatches(&self) -> usize { + self.forward_dwt97_dispatches + } + + #[cfg(feature = "cuda-runtime")] + pub(crate) fn htj2k_tile_dispatches(&self) -> usize { + self.htj2k_tile_dispatches + } + + #[cfg(feature = "cuda-runtime")] + pub(crate) fn quantize_subband_dispatches(&self) -> usize { + self.quantize_subband_dispatches + } + + #[cfg(feature = "cuda-runtime")] + pub(crate) fn ht_code_block_dispatches(&self) -> usize { + self.ht_code_block_dispatches + } + + #[cfg(feature = "cuda-runtime")] + pub(crate) fn ht_subband_dispatches(&self) -> usize { + self.ht_subband_dispatches + } + + pub(crate) fn packetization_dispatches(&self) -> usize { + self.packetization_dispatches + } +} diff --git a/crates/j2k-cuda/src/encode/tests/htj2k.rs b/crates/j2k-cuda/src/encode/tests/htj2k.rs index 9a3d4635..e147bc56 100644 --- a/crates/j2k-cuda/src/encode/tests/htj2k.rs +++ b/crates/j2k-cuda/src/encode/tests/htj2k.rs @@ -4,6 +4,7 @@ use super::{ cuda_htj2k_encode_tables, CudaContext, CudaHtj2kEncodeCodeBlockJob, CudaHtj2kEncodeCodeBlockRegionJob, CudaJ2kQuantizeJob, J2kHtCodeBlockEncodeJob, + J2kHtCodeBlockSetEncodeJob, }; #[cfg(feature = "cuda-runtime")] use super::{ @@ -88,6 +89,51 @@ fn cuda_htj2k_codeblock_preserves_requested_refinement_passes_when_runtime_requi assert_eq!(accelerator.ht_code_block_dispatches(), 1); } +#[cfg(feature = "cuda-runtime")] +#[test] +fn cuda_htj2k_codeblock_candidates_preserve_exact_pass_boundaries_when_runtime_required() { + if !j2k_test_support::cuda_runtime_gate(module_path!()) { + return; + } + + let coefficients = [0, 7, -6, 3, 5, 2, -1, 4, 6, -3, 0, 1, 2, 0, 5, -7]; + let jobs = [ + J2kHtCodeBlockSetEncodeJob { + coefficients: &coefficients, + width: 4, + height: 4, + total_bitplanes: 6, + cleanup_bitplane: 2, + target_coding_passes: 3, + }, + J2kHtCodeBlockSetEncodeJob { + coefficients: &coefficients, + width: 4, + height: 4, + total_bitplanes: 6, + cleanup_bitplane: 1, + target_coding_passes: 3, + }, + ]; + let mut accelerator = CudaEncodeStageAccelerator::default(); + + let encoded = accelerator + .encode_ht_code_block_sets(&jobs) + .expect("CUDA HT candidate hook") + .expect("CUDA HT candidate output"); + + assert_eq!(encoded.len(), 2); + for (candidate, missing) in encoded.iter().zip([3, 4]) { + assert_eq!(candidate.num_coding_passes, 3); + assert_eq!(candidate.num_zero_bitplanes, missing); + assert_eq!( + candidate.cleanup_length + candidate.sigprop_length + candidate.magref_length, + u32::try_from(candidate.data.len()).expect("candidate payload fits u32") + ); + assert!(candidate.sigprop_length > 0); + } +} + #[cfg(feature = "cuda-runtime")] #[test] fn cuda_htj2k_codeblock_batch_uses_single_dispatch_when_runtime_required() { diff --git a/crates/j2k-cuda/src/encode/tests/mod.rs b/crates/j2k-cuda/src/encode/tests/mod.rs index 3047f144..2282c65a 100644 --- a/crates/j2k-cuda/src/encode/tests/mod.rs +++ b/crates/j2k-cuda/src/encode/tests/mod.rs @@ -40,7 +40,7 @@ use j2k::{ #[cfg(feature = "cuda-runtime")] use j2k::{ J2kDeinterleaveMctToF32Job, J2kDeinterleaveToF32Job, J2kHtCodeBlockEncodeJob, - J2kResidentEncodeInputError, + J2kHtCodeBlockSetEncodeJob, J2kResidentEncodeInputError, }; use j2k::{ J2kEncodeStageAccelerator, J2kEncodeStageError, J2kHtSubbandEncodeJob, diff --git a/crates/j2k-cuda/tests/bench_harness.rs b/crates/j2k-cuda/tests/bench_harness.rs index ebe3b901..230baaf1 100644 --- a/crates/j2k-cuda/tests/bench_harness.rs +++ b/crates/j2k-cuda/tests/bench_harness.rs @@ -230,6 +230,29 @@ fn cuda_htj2k_encode_bench_accepts_external_staged_pnm_sources() { } } +#[test] +fn cuda_htj2k_encode_bench_measures_cleanup_and_refinement_paths() { + let bench = include_str!("../benches/htj2k_encode.rs"); + + assert!(!bench.contains("j2k_cuda_htj2k_encode_cooperative_requested")); + + for expected in [ + "cpu_scalar_cleanup", + "cpu_scalar_refinement", + "cuda_host_staged_cleanup", + "cuda_host_staged_refinement", + "cuda_resident_cleanup", + "cuda_resident_refinement", + "encode_ht_code_block_scalar_with_passes", + "target_coding_passes: 3", + ] { + assert!( + bench.contains(expected), + "CUDA HTJ2K encode benchmark is missing refinement workload marker `{expected}`" + ); + } +} + #[test] fn cuda_htj2k_decode_profile_example_uses_batch_entrypoint() { let path = concat!( diff --git a/crates/j2k-metal/Cargo.toml b/crates/j2k-metal/Cargo.toml index 51f088f7..608dc4dc 100644 --- a/crates/j2k-metal/Cargo.toml +++ b/crates/j2k-metal/Cargo.toml @@ -81,3 +81,8 @@ test = false name = "resident_packetization" harness = false test = false + +[[bench]] +name = "htj2k_candidates" +harness = false +test = false diff --git a/crates/j2k-metal/benches/htj2k_candidates.rs b/crates/j2k-metal/benches/htj2k_candidates.rs new file mode 100644 index 00000000..09668397 --- /dev/null +++ b/crates/j2k-metal/benches/htj2k_candidates.rs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Correctness-gated end-to-end CPU versus Metal HT Tier-1 encode benchmark. +//! +//! Budgeted lossy profiles exercise candidate sets. Reversible lossless +//! profiles are controls because that public path does not accept byte budgets. + +#[cfg(target_os = "macos")] +#[path = "htj2k_candidates/case.rs"] +mod case; +#[cfg(target_os = "macos")] +#[path = "htj2k_candidates/runner.rs"] +mod runner; + +#[cfg(not(target_os = "macos"))] +fn main() { + assert!( + std::env::var_os("J2K_REQUIRE_METAL_BENCH").is_none(), + "J2K Metal HTJ2K candidate benchmark requires macOS" + ); + eprintln!("J2K Metal HTJ2K candidate benchmark skipped outside macOS"); +} + +#[cfg(target_os = "macos")] +fn main() { + runner::run(); +} diff --git a/crates/j2k-metal/benches/htj2k_candidates/case.rs b/crates/j2k-metal/benches/htj2k_candidates/case.rs new file mode 100644 index 00000000..c7a9281a --- /dev/null +++ b/crates/j2k-metal/benches/htj2k_candidates/case.rs @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use j2k::{ + encode_j2k_lossless, encode_j2k_lossless_with_accelerator, encode_j2k_lossy, + encode_j2k_lossy_with_accelerator, EncodeBackendPreference, J2kBlockCodingMode, + J2kEncodeValidation, J2kLosslessEncodeOptions, J2kLosslessSamples, J2kLossyEncodeOptions, + J2kLossySamples, J2kQualityLayer, J2kRateTarget, +}; +use j2k_core::{BackendKind, PixelFormat}; +use j2k_metal::MetalEncodeStageAccelerator; + +pub(crate) const SMALL_TILE_SIDE: u32 = 256; +pub(crate) const MEDIUM_TILE_SIDE: u32 = 512; +pub(crate) const LARGE_TILE_SIDE: u32 = 1024; +const COMPONENTS: u16 = 3; +const BIT_DEPTH: u8 = 8; + +#[derive(Debug, Clone, Copy)] +pub(crate) enum Profile { + LosslessTwoLayers, + LosslessThreeLayers, + LossyTwoBudgets, + LossyThreeBudgets, +} + +impl Profile { + pub(crate) const fn id(self) -> &'static str { + match self { + Self::LosslessTwoLayers => "lossless-2layers", + Self::LosslessThreeLayers => "lossless-3layers", + Self::LossyTwoBudgets => "lossy-1_4bpp", + Self::LossyThreeBudgets => "lossy-0p5_2_6bpp", + } + } + + const fn is_lossless(self) -> bool { + matches!(self, Self::LosslessTwoLayers | Self::LosslessThreeLayers) + } + + const fn expects_candidate_sets(self) -> bool { + matches!(self, Self::LossyTwoBudgets | Self::LossyThreeBudgets) + } + + const fn quality_layer_count(self) -> u8 { + match self { + Self::LosslessTwoLayers | Self::LossyTwoBudgets => 2, + Self::LosslessThreeLayers | Self::LossyThreeBudgets => 3, + } + } +} + +pub(crate) struct Workload { + pub(crate) id: String, + pub(crate) side: u32, + pub(crate) profile: Profile, + pub(crate) pixels: Vec, +} + +pub(crate) struct EncodeOutput { + pub(crate) codestream: Vec, + pub(crate) candidate_set_dispatches: usize, + pub(crate) ht_dispatches: usize, +} + +pub(crate) struct Preflight { + pub(crate) codestream_bytes: usize, + pub(crate) candidate_set_dispatches: usize, + pub(crate) ht_dispatches: usize, + pub(crate) psnr_db: Option, +} + +pub(crate) fn workloads() -> Vec { + let mut workloads = Vec::new(); + workloads + .try_reserve_exact(12) + .expect("benchmark workload table allocation"); + for side in [SMALL_TILE_SIDE, MEDIUM_TILE_SIDE, LARGE_TILE_SIDE] { + let size = match side { + SMALL_TILE_SIDE => "small", + MEDIUM_TILE_SIDE => "medium", + LARGE_TILE_SIDE => "large", + _ => unreachable!("benchmark tile table contains an unknown side"), + }; + let pixels = textured_rgb8(side); + for profile in [ + Profile::LosslessTwoLayers, + Profile::LosslessThreeLayers, + Profile::LossyTwoBudgets, + Profile::LossyThreeBudgets, + ] { + workloads.push(Workload { + id: format!("{size}-{side}x{side}-{}", profile.id()), + side, + profile, + pixels: pixels.clone(), + }); + } + } + workloads +} + +pub(crate) fn encode_cpu(workload: &Workload) -> Result { + if workload.profile.is_lossless() { + let encoded = encode_j2k_lossless( + lossless_samples(workload)?, + &lossless_options(workload, EncodeBackendPreference::CpuOnly), + ) + .map_err(|error| error.to_string())?; + Ok(EncodeOutput { + codestream: encoded.codestream, + candidate_set_dispatches: 0, + ht_dispatches: encoded.dispatch_report.ht_code_block, + }) + } else { + let encoded = encode_j2k_lossy( + lossy_samples(workload)?, + &lossy_options(workload, EncodeBackendPreference::CpuOnly), + ) + .map_err(|error| error.to_string())?; + Ok(EncodeOutput { + codestream: encoded.codestream, + candidate_set_dispatches: 0, + ht_dispatches: encoded.dispatch_report.ht_code_block, + }) + } +} + +pub(crate) fn encode_metal( + workload: &Workload, + accelerator: &mut MetalEncodeStageAccelerator, +) -> Result { + let candidates_before = accelerator.ht_candidate_set_dispatches(); + let (codestream, ht_dispatches) = if workload.profile.is_lossless() { + let encoded = encode_j2k_lossless_with_accelerator( + lossless_samples(workload)?, + &lossless_options(workload, EncodeBackendPreference::Auto), + BackendKind::Metal, + accelerator, + ) + .map_err(|error| error.to_string())?; + (encoded.codestream, encoded.dispatch_report.ht_code_block) + } else { + let encoded = encode_j2k_lossy_with_accelerator( + lossy_samples(workload)?, + &lossy_options(workload, EncodeBackendPreference::Auto), + BackendKind::Metal, + accelerator, + ) + .map_err(|error| error.to_string())?; + (encoded.codestream, encoded.dispatch_report.ht_code_block) + }; + Ok(EncodeOutput { + codestream, + candidate_set_dispatches: accelerator + .ht_candidate_set_dispatches() + .saturating_sub(candidates_before), + ht_dispatches, + }) +} + +pub(crate) fn preflight( + workload: &Workload, + accelerator: &mut MetalEncodeStageAccelerator, +) -> Preflight { + let cpu = encode_cpu(workload) + .unwrap_or_else(|error| panic!("CPU preflight {} failed: {error}", workload.id)); + let metal = encode_metal(workload, accelerator) + .unwrap_or_else(|error| panic!("Metal preflight {} failed: {error}", workload.id)); + + assert_output_parity(workload, &cpu.codestream, &metal.codestream); + assert!( + metal.ht_dispatches > 0, + "Metal did not dispatch HT Tier-1 for {}", + workload.id + ); + if workload.profile.expects_candidate_sets() { + assert!( + metal.candidate_set_dispatches > 0, + "Metal did not dispatch candidate sets for {}", + workload.id + ); + } else { + assert_eq!( + metal.candidate_set_dispatches, 0, + "lossless control unexpectedly dispatched candidate sets for {}", + workload.id + ); + } + + let psnr_db = if workload.profile.is_lossless() { + verify_lossless_roundtrip(workload, &cpu.codestream); + None + } else { + Some(verify_lossy_parity( + workload, + &cpu.codestream, + &metal.codestream, + )) + }; + Preflight { + codestream_bytes: cpu.codestream.len(), + candidate_set_dispatches: metal.candidate_set_dispatches, + ht_dispatches: metal.ht_dispatches, + psnr_db, + } +} + +fn assert_output_parity(workload: &Workload, cpu: &[u8], metal: &[u8]) { + if cpu == metal { + return; + } + let first_difference = cpu + .iter() + .zip(metal) + .position(|(cpu_byte, metal_byte)| cpu_byte != metal_byte) + .map(|index| (index, cpu[index], metal[index])); + panic!( + "CPU and Metal codestreams differ for {}: cpu_len={}, metal_len={}, first_difference={first_difference:?}", + workload.id, + cpu.len(), + metal.len(), + ); +} + +fn lossless_samples(workload: &Workload) -> Result, String> { + J2kLosslessSamples::new( + &workload.pixels, + workload.side, + workload.side, + COMPONENTS, + BIT_DEPTH, + false, + ) + .map_err(|error| error.to_string()) +} + +fn lossy_samples(workload: &Workload) -> Result, String> { + J2kLossySamples::new( + &workload.pixels, + workload.side, + workload.side, + COMPONENTS, + BIT_DEPTH, + false, + ) + .map_err(|error| error.to_string()) +} + +fn lossless_options( + workload: &Workload, + backend: EncodeBackendPreference, +) -> J2kLosslessEncodeOptions { + J2kLosslessEncodeOptions::default() + .with_backend(backend) + .with_block_coding_mode(J2kBlockCodingMode::HighThroughput) + .with_max_decomposition_levels(Some(5)) + .with_tile_size(Some((workload.side, workload.side))) + .with_quality_layers(workload.profile.quality_layer_count()) + .with_validation(J2kEncodeValidation::External) +} + +fn lossy_options(workload: &Workload, backend: EncodeBackendPreference) -> J2kLossyEncodeOptions { + let layer_budgets: &[f64] = match workload.profile { + Profile::LossyTwoBudgets => &[1.0, 4.0], + Profile::LossyThreeBudgets => &[0.5, 2.0, 6.0], + Profile::LosslessTwoLayers | Profile::LosslessThreeLayers => { + panic!("lossy options requested for a lossless profile") + } + }; + let quality_layers = layer_budgets + .iter() + .copied() + .map(|budget| J2kQualityLayer::new(J2kRateTarget::BitsPerPixel(budget))) + .collect(); + let mut options = J2kLossyEncodeOptions::default() + .with_backend(backend) + .with_block_coding_mode(J2kBlockCodingMode::HighThroughput) + .with_max_decomposition_levels(Some(5)) + .with_tile_size(Some((workload.side, workload.side))) + .with_quality_layers(quality_layers) + .with_validation(J2kEncodeValidation::External); + options.psnr_iteration_budget = 1; + options +} + +fn verify_lossless_roundtrip(workload: &Workload, codestream: &[u8]) { + let decoded = decode_rgb8(workload, codestream); + assert_eq!( + decoded, workload.pixels, + "lossless round trip differs for {}", + workload.id + ); +} + +fn verify_lossy_parity(workload: &Workload, cpu: &[u8], metal: &[u8]) -> f64 { + let cpu_decoded = decode_rgb8(workload, cpu); + let metal_decoded = decode_rgb8(workload, metal); + assert_eq!( + cpu_decoded, metal_decoded, + "lossy decoded outputs differ for {}", + workload.id + ); + let squared_error = workload + .pixels + .iter() + .zip(&cpu_decoded) + .map(|(&source, &decoded)| { + let error = f64::from(source) - f64::from(decoded); + error * error + }) + .sum::(); + let sample_count = f64::from(workload.side) * f64::from(workload.side) * f64::from(COMPONENTS); + let mse = squared_error / sample_count; + let psnr = if mse == 0.0 { + f64::INFINITY + } else { + 10.0 * (255.0_f64 * 255.0 / mse).log10() + }; + assert!( + psnr > 10.0, + "lossy preflight PSNR is implausibly low for {}: {psnr:.3} dB", + workload.id + ); + psnr +} + +fn decode_rgb8(workload: &Workload, codestream: &[u8]) -> Vec { + let stride = usize::try_from(workload.side) + .expect("tile side fits usize") + .checked_mul(usize::from(COMPONENTS)) + .expect("RGB stride fits usize"); + let output_len = stride + .checked_mul(usize::try_from(workload.side).expect("tile side fits usize")) + .expect("decoded RGB length fits usize"); + let mut pixels = Vec::new(); + pixels + .try_reserve_exact(output_len) + .expect("decoded RGB benchmark allocation"); + pixels.resize(output_len, 0_u8); + let mut decoder = j2k::J2kDecoder::new(codestream) + .unwrap_or_else(|error| panic!("decode setup {} failed: {error}", workload.id)); + decoder + .decode_into(&mut pixels, stride, PixelFormat::Rgb8) + .unwrap_or_else(|error| panic!("decode {} failed: {error}", workload.id)); + pixels +} + +fn textured_rgb8(side: u32) -> Vec { + let pixel_count = usize::try_from(side) + .expect("tile side fits usize") + .checked_mul(usize::try_from(side).unwrap()) + .expect("tile pixel count fits usize"); + let sample_count = pixel_count + .checked_mul(usize::from(COMPONENTS)) + .expect("tile sample count fits usize"); + let mut pixels = Vec::new(); + pixels + .try_reserve_exact(sample_count) + .expect("benchmark source allocation"); + for y in 0..side { + for x in 0..side { + let checker = ((x / 17) ^ (y / 13)) & 1; + pixels.push(((x * 13 + y * 7 + checker * 61) & 0xff) as u8); + pixels.push(((x * 3 + y * 19 + (x ^ y) * 5) & 0xff) as u8); + pixels.push(((x * 23 + y * 11 + ((x * y) >> 4)) & 0xff) as u8); + } + } + pixels +} diff --git a/crates/j2k-metal/benches/htj2k_candidates/runner.rs b/crates/j2k-metal/benches/htj2k_candidates/runner.rs new file mode 100644 index 00000000..e35f3daf --- /dev/null +++ b/crates/j2k-metal/benches/htj2k_candidates/runner.rs @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use std::time::Duration; + +use criterion::{Criterion, Throughput}; +use j2k_metal::MetalEncodeStageAccelerator; + +use super::case::{encode_cpu, encode_metal, preflight, workloads}; + +pub(crate) fn run() { + let mut criterion = Criterion::default() + .sample_size(10) + .warm_up_time(Duration::from_secs(1)) + .measurement_time(Duration::from_secs(3)) + .configure_from_args(); + + for workload in workloads() { + // Hold transform and packetization work on CPU so the measured routes + // differ only at the HT code-block/candidate-set boundary. + let mut preflight_accelerator = MetalEncodeStageAccelerator::for_ht_code_block_encode(); + let checked = preflight(&workload, &mut preflight_accelerator); + eprintln!( + "HTJ2K_CANDIDATE_PREFLIGHT case={} tile={}x{} codestream_bytes={} candidate_set_dispatches={} ht_dispatches={} psnr_db={}", + workload.id, + workload.side, + workload.side, + checked.codestream_bytes, + checked.candidate_set_dispatches, + checked.ht_dispatches, + checked + .psnr_db + .map_or_else(|| "lossless".to_string(), |value| format!("{value:.3}")), + ); + + let mut group = criterion.benchmark_group(format!("htj2k-candidates/{}", workload.id)); + group.throughput(Throughput::Elements(1)); + group.bench_function("cpu", |bencher| { + bencher.iter(|| { + std::hint::black_box( + encode_cpu(&workload).expect("measured CPU HTJ2K candidate workload"), + ) + }); + }); + + let mut accelerator = MetalEncodeStageAccelerator::for_ht_code_block_encode(); + group.bench_function("metal", |bencher| { + bencher.iter(|| { + std::hint::black_box( + encode_metal(&workload, &mut accelerator) + .expect("measured Metal HTJ2K candidate workload"), + ) + }); + }); + group.finish(); + } + criterion.final_summary(); +} diff --git a/crates/j2k-metal/benches/resident_packetization.rs b/crates/j2k-metal/benches/resident_packetization.rs index 85f6d462..a82683fb 100644 --- a/crates/j2k-metal/benches/resident_packetization.rs +++ b/crates/j2k-metal/benches/resident_packetization.rs @@ -1,26 +1,14 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 #[cfg(target_os = "macos")] -use criterion::{criterion_group, criterion_main, Criterion, Throughput}; +#[path = "resident_packetization/batch_compare.rs"] +mod batch_compare; #[cfg(target_os = "macos")] -use j2k::{ - EncodeBackendPreference, J2kBlockCodingMode, J2kEncodeValidation, J2kLosslessEncodeOptions, -}; +#[path = "resident_packetization/packetization.rs"] +mod packetization; #[cfg(target_os = "macos")] -use j2k_core::{DeviceSubmission, PixelFormat}; -#[cfg(target_os = "macos")] -use j2k_metal::{ - benchmark_private_buffer_with_bytes, submit_lossless_batch_to_metal, MetalBackendSession, - MetalEncodeInputStaging, MetalLosslessBufferEncodeBatchOutcome, - MetalLosslessEncodeBatchRequest, MetalLosslessEncodeConfig, MetalLosslessEncodeTile, -}; -#[cfg(target_os = "macos")] -use j2k_native::{DecodeSettings, Image}; - -#[cfg(target_os = "macos")] -const DIMENSION: u32 = 512; -#[cfg(target_os = "macos")] -const BATCH_SIZE: usize = 16; +#[path = "resident_packetization/support.rs"] +mod support; #[cfg(not(target_os = "macos"))] fn main() { @@ -32,137 +20,15 @@ fn main() { } #[cfg(target_os = "macos")] -fn options(block_coding_mode: J2kBlockCodingMode) -> J2kLosslessEncodeOptions { - J2kLosslessEncodeOptions::default() - .with_backend(EncodeBackendPreference::RequireDevice) - .with_block_coding_mode(block_coding_mode) - .with_max_decomposition_levels(Some(3)) - .with_validation(J2kEncodeValidation::External) -} - -#[cfg(target_os = "macos")] -fn run_batch( - session: &MetalBackendSession, - tiles: &[MetalLosslessEncodeTile<'_>], - options: &J2kLosslessEncodeOptions, -) -> MetalLosslessBufferEncodeBatchOutcome { - let outcome = submit_lossless_batch_to_metal( - MetalLosslessEncodeBatchRequest { - tiles, - staging: MetalEncodeInputStaging::AlreadyPaddedContiguous, - config: MetalLosslessEncodeConfig::default(), - }, - options, - session, - ) - .expect("submit resident packetization benchmark batch") - .wait() - .expect("complete resident packetization benchmark batch"); - assert_eq!(outcome.outcomes.len(), BATCH_SIZE); - assert!( - outcome - .outcomes - .iter() - .all(|item| item.resident.packetization_used), - "benchmark must exercise resident packetization" - ); - outcome -} - -#[cfg(target_os = "macos")] -fn probe_exact_output( - session: &MetalBackendSession, - tiles: &[MetalLosslessEncodeTile<'_>], - options: &J2kLosslessEncodeOptions, - pixels: &[u8], -) -> (String, usize) { - let outcome = run_batch(session, tiles, options); - - let mut framed = Vec::new(); - let mut encoded_bytes = 0usize; - let mut first = None; - for item in &outcome.outcomes { - let codestream = item - .encoded - .codestream_bytes() - .expect("packetization probe codestream is CPU-readable"); - if let Some(expected) = &first { - assert_eq!(&codestream, expected, "repeated tiles must encode exactly"); - } else { - let decoded = Image::new(&codestream, &DecodeSettings::default()) - .expect("packetization probe codestream parses") - .decode_native() - .expect("packetization probe codestream decodes"); - assert_eq!(decoded.data, pixels); - first = Some(codestream.clone()); - } - encoded_bytes = encoded_bytes - .checked_add(codestream.len()) - .expect("packetization probe encoded-byte count fits usize"); - framed.extend_from_slice( - &u64::try_from(codestream.len()) - .expect("packetization probe codestream length fits u64") - .to_le_bytes(), - ); - framed.extend_from_slice(&codestream); - } - ( - j2k_test_support::auto_routing_sha256(&framed), - encoded_bytes, - ) -} - -#[cfg(target_os = "macos")] -fn bench_resident_packetization(criterion: &mut Criterion) { - let session = MetalBackendSession::system_default() - .expect("resident packetization benchmark requires a Metal device"); - let pixels = j2k_test_support::patterned_rgb8(DIMENSION, DIMENSION); - let input = benchmark_private_buffer_with_bytes(&session, &pixels) - .expect("upload resident packetization benchmark input"); - // SAFETY: `input` belongs to `session`, was fully initialized above, and - // remains immutable until every synchronous benchmark submission returns. - let tile = unsafe { - MetalLosslessEncodeTile::from_buffer( - &input, - 0, - (DIMENSION, DIMENSION), - usize::try_from(DIMENSION).expect("dimension fits usize") * 3, - (DIMENSION, DIMENSION), - PixelFormat::Rgb8, - ) - }; - let tiles = vec![tile; BATCH_SIZE]; - - let mut group = criterion.benchmark_group("metal_resident_packetization"); - group.throughput(Throughput::Bytes( - u64::from(DIMENSION) * u64::from(DIMENSION) * 3 * BATCH_SIZE as u64, - )); - for (label, block_coding_mode) in [ - ("classic", J2kBlockCodingMode::Classic), - ("ht", J2kBlockCodingMode::HighThroughput), - ] { - let options = options(block_coding_mode); - let (hash, encoded_bytes) = probe_exact_output(&session, &tiles, &options, &pixels); - eprintln!( - "j2k_metal_packetization_probe coding={label} batch_size={BATCH_SIZE} size={DIMENSION}x{DIMENSION} output_sha256={hash} encoded_bytes={encoded_bytes}" - ); - group.bench_function(label, |bencher| { - bencher.iter(|| { - let outcome = run_batch(&session, &tiles, &options); - std::hint::black_box( - outcome - .outcomes - .iter() - .map(|item| item.encoded.byte_len()) - .sum::(), - ) - }); - }); - } - group.finish(); +fn main() { + use std::time::Duration; + + let mut criterion = criterion::Criterion::default() + .sample_size(10) + .warm_up_time(Duration::from_secs(1)) + .measurement_time(Duration::from_secs(3)) + .configure_from_args(); + packetization::bench(&mut criterion); + batch_compare::bench(&mut criterion); + criterion.final_summary(); } - -#[cfg(target_os = "macos")] -criterion_group!(benches, bench_resident_packetization); -#[cfg(target_os = "macos")] -criterion_main!(benches); diff --git a/crates/j2k-metal/benches/resident_packetization/batch_compare.rs b/crates/j2k-metal/benches/resident_packetization/batch_compare.rs new file mode 100644 index 00000000..de16a65b --- /dev/null +++ b/crates/j2k-metal/benches/resident_packetization/batch_compare.rs @@ -0,0 +1,229 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use criterion::{Criterion, Throughput}; +use j2k::{ + encode_j2k_lossless, EncodeBackendPreference, J2kBlockCodingMode, J2kLosslessEncodeOptions, + J2kLosslessSamples, +}; +use j2k_core::PixelFormat; +use j2k_metal::{ + benchmark_private_buffer_with_bytes, encode_lossless_batch_with_report, MetalBackendSession, + MetalEncodeInputStaging, MetalLosslessEncodeBatchRequest, MetalLosslessEncodeConfig, + MetalLosslessEncodeOutcome, MetalLosslessEncodeTile, +}; +use j2k_native::{DecodeSettings, Image}; +use rayon::prelude::*; + +use super::support::{options, run_device_batch, DIMENSION}; + +const BATCH_SIZES: [usize; 3] = [1, 4, 16]; + +fn encode_cpu_tile(pixels: &[u8], options: &J2kLosslessEncodeOptions) -> Vec { + let samples = J2kLosslessSamples::new(pixels, DIMENSION, DIMENSION, 3, 8, false) + .expect("valid resident batch CPU samples"); + encode_j2k_lossless(samples, options) + .expect("resident batch CPU encode") + .codestream +} + +fn encode_cpu_serial( + pixels: &[u8], + batch_size: usize, + options: &J2kLosslessEncodeOptions, +) -> Vec> { + (0..batch_size) + .map(|_| encode_cpu_tile(pixels, options)) + .collect() +} + +fn encode_cpu_parallel( + pixels: &[u8], + batch_size: usize, + options: &J2kLosslessEncodeOptions, +) -> Vec> { + (0..batch_size) + .into_par_iter() + .map(|_| encode_cpu_tile(pixels, options)) + .collect() +} + +fn run_host_batch( + session: &MetalBackendSession, + tiles: &[MetalLosslessEncodeTile<'_>], + batch_size: usize, + options: &J2kLosslessEncodeOptions, +) -> Vec { + let outcomes = encode_lossless_batch_with_report( + MetalLosslessEncodeBatchRequest { + tiles, + staging: MetalEncodeInputStaging::AlreadyPaddedContiguous, + config: MetalLosslessEncodeConfig { + gpu_encode_inflight_tiles: Some(batch_size), + gpu_encode_memory_budget_bytes: None, + }, + }, + options, + session, + ) + .expect("resident HTJ2K host-output batch"); + assert_eq!(outcomes.len(), batch_size); + assert!(outcomes.iter().all(|outcome| { + outcome.resident.coefficient_prep_used + && outcome.resident.packetization_used + && outcome.resident.codestream_assembly_used + })); + outcomes +} + +fn framed_codestreams(codestreams: impl IntoIterator>) -> (String, usize) { + let mut framed = Vec::new(); + let mut encoded_bytes = 0usize; + for codestream in codestreams { + encoded_bytes = encoded_bytes + .checked_add(codestream.len()) + .expect("resident batch encoded byte total fits usize"); + framed.extend_from_slice( + &u64::try_from(codestream.len()) + .expect("resident batch codestream length fits u64") + .to_le_bytes(), + ); + framed.extend_from_slice(&codestream); + } + ( + j2k_test_support::auto_routing_sha256(&framed), + encoded_bytes, + ) +} + +fn verify_decodes_to_source(codestream: &[u8], pixels: &[u8], route: &str) { + let decoded = Image::new(codestream, &DecodeSettings::default()) + .unwrap_or_else(|error| panic!("{route} codestream parses: {error}")) + .decode_native() + .unwrap_or_else(|error| panic!("{route} codestream decodes: {error}")); + assert_eq!(decoded.data, pixels, "{route} decoded pixels differ"); +} + +fn verify_cpu_metal_batch( + session: &MetalBackendSession, + tiles: &[MetalLosslessEncodeTile<'_>], + pixels: &[u8], + batch_size: usize, + cpu_options: &J2kLosslessEncodeOptions, + metal_options: &J2kLosslessEncodeOptions, +) -> (String, String, usize, usize, usize) { + let cpu_serial = encode_cpu_serial(pixels, batch_size, cpu_options); + let cpu_parallel = encode_cpu_parallel(pixels, batch_size, cpu_options); + assert_eq!(cpu_parallel, cpu_serial, "parallel CPU output differs"); + verify_decodes_to_source(&cpu_serial[0], pixels, "CPU"); + + let device = run_device_batch(session, tiles, metal_options, Some(batch_size)); + assert_eq!(device.stats.effective_inflight_tiles, batch_size); + assert!(device.stats.max_observed_inflight_tiles <= batch_size); + if batch_size > 1 { + assert!( + device.stats.max_observed_inflight_tiles > 1, + "resident Metal batch did not overlap tiles" + ); + } + let device_codestreams = device + .outcomes + .iter() + .map(|outcome| { + outcome + .encoded + .codestream_bytes() + .expect("resident batch device codestream is readable") + }) + .collect::>(); + verify_decodes_to_source(&device_codestreams[0], pixels, "Metal"); + + let host = run_host_batch(session, tiles, batch_size, metal_options); + for (host_outcome, device_codestream) in host.iter().zip(&device_codestreams) { + assert_eq!( + &host_outcome.encoded.codestream, device_codestream, + "host and device Metal batch routes differ" + ); + } + + let (cpu_hash, cpu_bytes) = framed_codestreams(cpu_serial); + let (metal_hash, metal_bytes) = framed_codestreams(device_codestreams); + ( + cpu_hash, + metal_hash, + cpu_bytes, + metal_bytes, + device.stats.max_observed_inflight_tiles, + ) +} + +fn total_codestream_bytes(codestreams: &[Vec]) -> usize { + codestreams.iter().map(Vec::len).sum() +} + +pub(crate) fn bench(criterion: &mut Criterion) { + let session = + MetalBackendSession::system_default().expect("resident batch benchmark needs Metal"); + let pixels = j2k_test_support::patterned_rgb8(DIMENSION, DIMENSION); + let input = benchmark_private_buffer_with_bytes(&session, &pixels) + .expect("upload resident batch benchmark input"); + // SAFETY: `input` belongs to `session`, is fully initialized, and remains + // immutable until all synchronous benchmark submissions finish. + let tile = unsafe { + MetalLosslessEncodeTile::from_buffer( + &input, + 0, + (DIMENSION, DIMENSION), + usize::try_from(DIMENSION).expect("dimension fits usize") * 3, + (DIMENSION, DIMENSION), + PixelFormat::Rgb8, + ) + }; + let metal_options = options(J2kBlockCodingMode::HighThroughput); + let cpu_options = metal_options.with_backend(EncodeBackendPreference::CpuOnly); + + for batch_size in BATCH_SIZES { + let tiles = std::iter::repeat_n(tile, batch_size).collect::>(); + let (cpu_hash, metal_hash, cpu_bytes, metal_bytes, max_inflight) = verify_cpu_metal_batch( + &session, + &tiles, + &pixels, + batch_size, + &cpu_options, + &metal_options, + ); + eprintln!( + "j2k_metal_resident_batch_probe batch_size={batch_size} size={DIMENSION}x{DIMENSION} cpu_sha256={cpu_hash} metal_sha256={metal_hash} cpu_bytes={cpu_bytes} metal_bytes={metal_bytes} max_inflight={max_inflight}" + ); + + let mut group = criterion.benchmark_group(format!( + "htj2k-resident-batch/{DIMENSION}x{DIMENSION}/batch-{batch_size}" + )); + group.throughput(Throughput::Elements( + u64::try_from(batch_size).expect("batch size fits u64"), + )); + group.bench_function("cpu-serial", |bencher| { + bencher.iter(|| { + let codestreams = encode_cpu_serial(&pixels, batch_size, &cpu_options); + std::hint::black_box(total_codestream_bytes(&codestreams)) + }); + }); + group.bench_function("cpu-parallel", |bencher| { + bencher.iter(|| { + let codestreams = encode_cpu_parallel(&pixels, batch_size, &cpu_options); + std::hint::black_box(total_codestream_bytes(&codestreams)) + }); + }); + group.bench_function("metal-resident", |bencher| { + bencher.iter(|| { + let outcomes = run_host_batch(&session, &tiles, batch_size, &metal_options); + std::hint::black_box( + outcomes + .iter() + .map(|outcome| outcome.encoded.codestream.len()) + .sum::(), + ) + }); + }); + group.finish(); + } +} diff --git a/crates/j2k-metal/benches/resident_packetization/packetization.rs b/crates/j2k-metal/benches/resident_packetization/packetization.rs new file mode 100644 index 00000000..89fc67c8 --- /dev/null +++ b/crates/j2k-metal/benches/resident_packetization/packetization.rs @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use criterion::{Criterion, Throughput}; +use j2k::J2kBlockCodingMode; +use j2k_core::PixelFormat; +use j2k_metal::{ + benchmark_private_buffer_with_bytes, MetalBackendSession, MetalLosslessEncodeTile, +}; +use j2k_native::{DecodeSettings, Image}; + +use super::support::{options, run_device_batch, DIMENSION}; + +const PACKETIZATION_BATCH_SIZE: usize = 16; + +fn probe_exact_output( + session: &MetalBackendSession, + tiles: &[MetalLosslessEncodeTile<'_>], + options: &j2k::J2kLosslessEncodeOptions, + pixels: &[u8], +) -> (String, usize) { + let outcome = run_device_batch(session, tiles, options, None); + + let mut framed = Vec::new(); + let mut encoded_bytes = 0usize; + let mut first = None; + for item in &outcome.outcomes { + let codestream = item + .encoded + .codestream_bytes() + .expect("packetization probe codestream is CPU-readable"); + if let Some(expected) = &first { + assert_eq!(&codestream, expected, "repeated tiles must encode exactly"); + } else { + let decoded = Image::new(&codestream, &DecodeSettings::default()) + .expect("packetization probe codestream parses") + .decode_native() + .expect("packetization probe codestream decodes"); + assert_eq!(decoded.data, pixels); + first = Some(codestream.clone()); + } + encoded_bytes = encoded_bytes + .checked_add(codestream.len()) + .expect("packetization probe encoded-byte count fits usize"); + framed.extend_from_slice( + &u64::try_from(codestream.len()) + .expect("packetization probe codestream length fits u64") + .to_le_bytes(), + ); + framed.extend_from_slice(&codestream); + } + ( + j2k_test_support::auto_routing_sha256(&framed), + encoded_bytes, + ) +} + +pub(crate) fn bench(criterion: &mut Criterion) { + let session = MetalBackendSession::system_default() + .expect("resident packetization benchmark requires a Metal device"); + let pixels = j2k_test_support::patterned_rgb8(DIMENSION, DIMENSION); + let input = benchmark_private_buffer_with_bytes(&session, &pixels) + .expect("upload resident packetization benchmark input"); + // SAFETY: `input` belongs to `session`, was fully initialized above, and + // remains immutable until every synchronous benchmark submission returns. + let tile = unsafe { + MetalLosslessEncodeTile::from_buffer( + &input, + 0, + (DIMENSION, DIMENSION), + usize::try_from(DIMENSION).expect("dimension fits usize") * 3, + (DIMENSION, DIMENSION), + PixelFormat::Rgb8, + ) + }; + let tiles = std::iter::repeat_n(tile, PACKETIZATION_BATCH_SIZE).collect::>(); + + let mut group = criterion.benchmark_group("metal_resident_packetization"); + group.throughput(Throughput::Bytes( + u64::from(DIMENSION) + * u64::from(DIMENSION) + * 3 + * u64::try_from(PACKETIZATION_BATCH_SIZE).expect("batch size fits u64"), + )); + for (label, block_coding_mode) in [ + ("classic", J2kBlockCodingMode::Classic), + ("ht", J2kBlockCodingMode::HighThroughput), + ] { + let options = options(block_coding_mode); + let (hash, encoded_bytes) = probe_exact_output(&session, &tiles, &options, &pixels); + eprintln!( + "j2k_metal_packetization_probe coding={label} batch_size={PACKETIZATION_BATCH_SIZE} size={DIMENSION}x{DIMENSION} output_sha256={hash} encoded_bytes={encoded_bytes}" + ); + group.bench_function(label, |bencher| { + bencher.iter(|| { + let outcome = run_device_batch(&session, &tiles, &options, None); + std::hint::black_box( + outcome + .outcomes + .iter() + .map(|item| item.encoded.byte_len()) + .sum::(), + ) + }); + }); + } + group.finish(); +} diff --git a/crates/j2k-metal/benches/resident_packetization/support.rs b/crates/j2k-metal/benches/resident_packetization/support.rs new file mode 100644 index 00000000..d1ded050 --- /dev/null +++ b/crates/j2k-metal/benches/resident_packetization/support.rs @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use j2k::{ + EncodeBackendPreference, J2kBlockCodingMode, J2kEncodeValidation, J2kLosslessEncodeOptions, +}; +use j2k_core::DeviceSubmission; +use j2k_metal::{ + submit_lossless_batch_to_metal, MetalBackendSession, MetalEncodeInputStaging, + MetalLosslessBufferEncodeBatchOutcome, MetalLosslessEncodeBatchRequest, + MetalLosslessEncodeConfig, MetalLosslessEncodeTile, +}; + +pub(crate) const DIMENSION: u32 = 512; + +pub(crate) fn options(block_coding_mode: J2kBlockCodingMode) -> J2kLosslessEncodeOptions { + J2kLosslessEncodeOptions::default() + .with_backend(EncodeBackendPreference::RequireDevice) + .with_block_coding_mode(block_coding_mode) + .with_max_decomposition_levels(Some(3)) + .with_validation(J2kEncodeValidation::External) +} + +pub(crate) fn run_device_batch( + session: &MetalBackendSession, + tiles: &[MetalLosslessEncodeTile<'_>], + options: &J2kLosslessEncodeOptions, + inflight_tiles: Option, +) -> MetalLosslessBufferEncodeBatchOutcome { + let outcome = submit_lossless_batch_to_metal( + MetalLosslessEncodeBatchRequest { + tiles, + staging: MetalEncodeInputStaging::AlreadyPaddedContiguous, + config: MetalLosslessEncodeConfig { + gpu_encode_inflight_tiles: inflight_tiles, + gpu_encode_memory_budget_bytes: None, + }, + }, + options, + session, + ) + .expect("submit resident packetization benchmark batch") + .wait() + .expect("complete resident packetization benchmark batch"); + assert_eq!(outcome.outcomes.len(), tiles.len()); + assert!( + outcome + .outcomes + .iter() + .all(|item| item.resident.packetization_used), + "benchmark must exercise resident packetization" + ); + outcome +} diff --git a/crates/j2k-metal/src/encode/stage_accelerator.rs b/crates/j2k-metal/src/encode/stage_accelerator.rs index 27504fd9..603d3016 100644 --- a/crates/j2k-metal/src/encode/stage_accelerator.rs +++ b/crates/j2k-metal/src/encode/stage_accelerator.rs @@ -7,12 +7,12 @@ use j2k::J2kEncodeStageError; #[cfg(target_os = "macos")] use j2k::{EncodeBackendPreference, J2kLosslessEncodeOptions}; use j2k::{ - EncodedHtJ2kCodeBlock, EncodedJ2kCodeBlock, J2kDeinterleaveMctToF32Job, - J2kDeinterleaveToF32Job, J2kEncodeContext, J2kEncodeDispatchReport, J2kEncodeStageAccelerator, - J2kEncodeStageResult, J2kForwardDwt53Job, J2kForwardDwt53Output, J2kForwardDwt97Job, - J2kForwardDwt97Output, J2kForwardIctJob, J2kForwardRctJob, J2kHtCodeBlockEncodeJob, - J2kHtj2kTileEncodeJob, J2kPacketizationEncodeJob, J2kQuantizeSubbandJob, - J2kTier1CodeBlockEncodeJob, + EncodedHtJ2kCodeBlock, EncodedHtJ2kCodeBlockSet, EncodedJ2kCodeBlock, + J2kDeinterleaveMctToF32Job, J2kDeinterleaveToF32Job, J2kEncodeContext, J2kEncodeDispatchReport, + J2kEncodeStageAccelerator, J2kEncodeStageResult, J2kForwardDwt53Job, J2kForwardDwt53Output, + J2kForwardDwt97Job, J2kForwardDwt97Output, J2kForwardIctJob, J2kForwardRctJob, + J2kHtCodeBlockEncodeJob, J2kHtCodeBlockSetEncodeJob, J2kHtj2kTileEncodeJob, + J2kPacketizationEncodeJob, J2kQuantizeSubbandJob, J2kTier1CodeBlockEncodeJob, }; #[cfg(target_os = "macos")] use j2k_core::PixelFormat; @@ -57,6 +57,7 @@ pub struct MetalEncodeStageAccelerator { quantize_subband_dispatches: usize, tier1_code_block_dispatches: usize, ht_code_block_dispatches: usize, + ht_candidate_set_dispatches: usize, packetization_dispatches: usize, } @@ -90,6 +91,7 @@ impl Default for MetalEncodeStageAccelerator { quantize_subband_dispatches: 0, tier1_code_block_dispatches: 0, ht_code_block_dispatches: 0, + ht_candidate_set_dispatches: 0, packetization_dispatches: 0, } } @@ -197,6 +199,16 @@ impl MetalEncodeStageAccelerator { } } + /// Number of successful HT candidate-set batch dispatches. + /// + /// This diagnostic distinguishes the budgeted multi-layer candidate path + /// from ordinary HT code-block dispatches in correctness-checked benches. + #[must_use] + #[doc(hidden)] + pub const fn ht_candidate_set_dispatches(&self) -> usize { + self.ht_candidate_set_dispatches + } + #[cfg(all(test, target_os = "macos"))] pub(super) fn for_forward_dwt97_encode() -> Self { Self { @@ -833,6 +845,39 @@ impl J2kEncodeStageAccelerator for MetalEncodeStageAccelerator { } } + fn encode_ht_code_block_sets( + &mut self, + jobs: &[J2kHtCodeBlockSetEncodeJob<'_>], + ) -> J2kEncodeStageResult>> { + self.ht_code_block_attempts = self.ht_code_block_attempts.saturating_add(jobs.len()); + if !self + .dispatch_stages + .contains(MetalEncodeDispatchStages::HT_CODE_BLOCK) + || self.auto_host_output_force_cpu_fallback + { + let _ = jobs; + return Ok(None); + } + #[cfg(target_os = "macos")] + { + let encoded = metal_dispatch_option( + compute::encode_ht_code_block_sets(jobs), + "HTJ2K candidate-set batch encode", + )?; + if encoded.is_some() && !jobs.is_empty() { + self.ht_code_block_dispatches = self.ht_code_block_dispatches.saturating_add(1); + self.ht_candidate_set_dispatches = + self.ht_candidate_set_dispatches.saturating_add(1); + } + Ok(encoded) + } + #[cfg(not(target_os = "macos"))] + { + let _ = jobs; + Ok(None) + } + } + fn encode_htj2k_tile( &mut self, job: J2kHtj2kTileEncodeJob<'_>, diff --git a/crates/j2k-metal/src/encode/tests.rs b/crates/j2k-metal/src/encode/tests.rs index 1dec3101..3f904ff9 100644 --- a/crates/j2k-metal/src/encode/tests.rs +++ b/crates/j2k-metal/src/encode/tests.rs @@ -16,8 +16,8 @@ use j2k::{ #[cfg(target_os = "macos")] use j2k::{ encode_j2k_lossy_with_accelerator, J2kBlockCodingMode, J2kEncodeValidation, - J2kLossyEncodeOptions, J2kLossySamples, J2kMarkerSegment, J2kProgressionOrder, - ReversibleTransform, + J2kLossyEncodeOptions, J2kLossySamples, J2kMarkerSegment, J2kProgressionOrder, J2kQualityLayer, + J2kRateTarget, ReversibleTransform, }; #[cfg(target_os = "macos")] use j2k::{ diff --git a/crates/j2k-metal/src/encode/tests/kernels.rs b/crates/j2k-metal/src/encode/tests/kernels.rs index e8de7641..c9241d58 100644 --- a/crates/j2k-metal/src/encode/tests/kernels.rs +++ b/crates/j2k-metal/src/encode/tests/kernels.rs @@ -441,6 +441,47 @@ fn metal_htj2k_lossy_facade_require_device_dispatches_supported_stages() { assert_eq!(accelerator.packetization_dispatches(), 1); } +#[cfg(target_os = "macos")] +#[test] +fn metal_htj2k_quality_layers_use_native_candidates_and_match_scalar_output() { + if !should_run_metal_runtime() { + return; + } + + let pixels: Vec = (0..128 * 128) + .map(|idx| u8::try_from((idx * 29 + idx / 7 + 11) & 0xFF).expect("masked pixel")) + .collect(); + let samples = J2kLossySamples::new(&pixels, 128, 128, 1, 8, false).expect("valid samples"); + let options = J2kLossyEncodeOptions::default() + .with_backend(EncodeBackendPreference::Auto) + .with_block_coding_mode(J2kBlockCodingMode::HighThroughput) + .with_max_decomposition_levels(Some(0)) + .with_quality_layers(vec![ + J2kQualityLayer::new(J2kRateTarget::Bytes(512)), + J2kQualityLayer::new(J2kRateTarget::Bytes(20_000)), + ]) + .with_validation(J2kEncodeValidation::CpuRoundTrip); + let scalar = j2k::encode_j2k_lossy( + samples, + &options + .clone() + .with_backend(EncodeBackendPreference::CpuOnly), + ) + .expect("scalar bounded HTJ2K encode"); + let mut accelerator = MetalEncodeStageAccelerator::default(); + let encoded = + encode_j2k_lossy_with_accelerator(samples, &options, BackendKind::Metal, &mut accelerator) + .expect("hybrid Metal bounded HTJ2K encode"); + + assert_eq!(encoded.codestream, scalar.codestream); + Image::new(&encoded.codestream, &DecodeSettings::strict()) + .expect("bounded HTJ2K codestream parses strictly") + .decode_native() + .expect("bounded HTJ2K codestream decodes strictly"); + assert!(accelerator.quantize_subband_dispatches() > 0); + assert!(accelerator.ht_code_block_dispatches() > 0); +} + #[cfg(target_os = "macos")] #[test] fn metal_htj2k_lossy_rgb_facade_reports_forward_ict_dispatch() { @@ -942,6 +983,73 @@ fn metal_htj2k_cleanup_kernel_matches_scalar_oracle() { assert_eq!(gpu.num_zero_bitplanes, cpu.num_zero_bitplanes); } +#[cfg(target_os = "macos")] +#[test] +fn metal_htj2k_candidate_sets_preserve_exact_refinement_boundaries() { + if !should_run_metal_runtime() { + return; + } + + let coeffs: Vec = (0..64) + .map(|idx| { + let value = ((idx * 23 + 9) & 0xff) - 127; + if idx % 11 == 0 { + 0 + } else { + value + } + }) + .collect(); + let jobs = [ + j2k::J2kHtCodeBlockSetEncodeJob { + coefficients: &coeffs, + width: 8, + height: 8, + total_bitplanes: 8, + cleanup_bitplane: 2, + target_coding_passes: 3, + }, + j2k::J2kHtCodeBlockSetEncodeJob { + coefficients: &coeffs, + width: 8, + height: 8, + total_bitplanes: 8, + cleanup_bitplane: 1, + target_coding_passes: 3, + }, + ]; + let mut accelerator = MetalEncodeStageAccelerator::for_ht_code_block_encode(); + let encoded = accelerator + .encode_ht_code_block_sets(&jobs) + .expect("Metal candidate-set dispatch") + .expect("Metal candidate-set output"); + + assert_eq!(encoded.len(), 2); + for (output, expected_zero_bitplanes) in encoded.iter().zip([5, 6]) { + assert_eq!(output.num_coding_passes, 3); + assert_eq!(output.num_zero_bitplanes, expected_zero_bitplanes); + assert!(output.cleanup_length > 0); + assert!(output.sigprop_length > 0); + assert!(output.magref_length > 0); + assert_eq!( + output.data.len(), + (output.cleanup_length + output.sigprop_length + output.magref_length) as usize + ); + } + let scalar = j2k_native::encode_ht_code_block_scalar_with_passes(&coeffs, 8, 8, 8, 3) + .expect("scalar refinement encode"); + assert_eq!(encoded[1].cleanup_length, scalar.cleanup_length); + assert_eq!(encoded[1].data, scalar.data); + assert_eq!( + encoded[1].sigprop_length + encoded[1].magref_length, + scalar.refinement_length + ); + assert_eq!(encoded[1].num_coding_passes, scalar.num_coding_passes); + assert_eq!(encoded[1].num_zero_bitplanes, scalar.num_zero_bitplanes); + assert_eq!(accelerator.ht_code_block_dispatches(), 1); + assert_eq!(accelerator.ht_candidate_set_dispatches(), 1); +} + #[cfg(target_os = "macos")] #[test] fn metal_tier2_packetization_kernel_matches_scalar_oracle() { diff --git a/crates/j2k-metal/src/encode_bitstream_ht.metal b/crates/j2k-metal/src/encode_bitstream_ht.metal index 6a413e2e..3d78a47e 100644 --- a/crates/j2k-metal/src/encode_bitstream_ht.metal +++ b/crates/j2k-metal/src/encode_bitstream_ht.metal @@ -6,11 +6,12 @@ constant uint J2K_HT_VLC_SIZE = 3072u - J2K_HT_MEL_SIZE; constant uint J2K_HT_MS_SIZE = ((16384u * 16u) + 14u) / 15u; constant uint J2K_HT_MEL_OFFSET = J2K_HT_MS_SIZE; constant uint J2K_HT_VLC_OFFSET = J2K_HT_MS_SIZE + J2K_HT_MEL_SIZE; - struct J2kHtEncodeParams { uint width; uint height; uint total_bitplanes; + uint cleanup_bitplane; + uint target_coding_passes; uint output_capacity; }; @@ -20,9 +21,9 @@ struct J2kHtEncodeStatus { uint data_len; uint num_coding_passes; uint num_zero_bitplanes; - uint reserved0; - uint reserved1; - uint reserved2; + uint cleanup_length; + uint sigprop_length; + uint magref_length; }; struct J2kHtMelEncoder { @@ -111,9 +112,9 @@ inline void j2k_set_ht_encode_status( status->data_len = data_len; status->num_coding_passes = passes; status->num_zero_bitplanes = zbp; - status->reserved0 = 0u; - status->reserved1 = 0u; - status->reserved2 = 0u; + status->cleanup_length = 0u; + status->sigprop_length = 0u; + status->magref_length = 0u; } inline void j2k_set_ht_encode_status_with_segments( @@ -123,18 +124,18 @@ inline void j2k_set_ht_encode_status_with_segments( uint data_len, uint passes, uint zbp, - uint ms_len, - uint mel_len, - uint vlc_len + uint cleanup_len, + uint sigprop_len, + uint magref_len ) { status->code = code; status->detail = detail; status->data_len = data_len; status->num_coding_passes = passes; status->num_zero_bitplanes = zbp; - status->reserved0 = ms_len; - status->reserved1 = mel_len; - status->reserved2 = vlc_len; + status->cleanup_length = cleanup_len; + status->sigprop_length = sigprop_len; + status->magref_length = magref_len; } inline uint j2k_ht_aligned_sign_magnitude(int coefficient, uint total_bitplanes) { @@ -781,6 +782,9 @@ inline void j2k_encode_ht_code_block_impl_with_max_and_assembly( if (params.width == 0u || params.height == 0u || params.total_bitplanes == 0u || params.total_bitplanes > J2K_HT_MAX_BITPLANES || + params.cleanup_bitplane >= params.total_bitplanes || + params.target_coding_passes == 0u || params.target_coding_passes > 3u || + (params.cleanup_bitplane == 0u && params.target_coding_passes > 1u) || params.width * params.height > J2K_HT_MAX_SAMPLES || params.output_capacity < j2k_ht_output_size(params)) { j2k_set_ht_encode_status(status, J2K_ENCODE_STATUS_UNSUPPORTED, 1u, 0u, 0u, 0u); @@ -798,8 +802,19 @@ inline void j2k_encode_ht_code_block_impl_with_max_and_assembly( return; } - const uint missing_msbs = params.total_bitplanes - 1u; + const uint missing_msbs = params.total_bitplanes - params.cleanup_bitplane - 1u; const uint p = 30u - missing_msbs; + const uint cleanup_threshold = 1u << params.cleanup_bitplane; + const uint refinement_mask = params.cleanup_bitplane == 0u + ? 0u + : 1u << (params.cleanup_bitplane - 1u); + uint significant_count = 0u; + if (params.target_coding_passes == 3u) { + for (uint sample = 0u; sample < params.width * params.height; ++sample) { + significant_count += uint( + j2k_classic_magnitude(coefficients[sample]) >= cleanup_threshold); + } + } thread J2kHtMelEncoder mel; thread J2kHtVlcEncoder vlc; @@ -913,8 +928,33 @@ inline void j2k_encode_ht_code_block_impl_with_max_and_assembly( const uint ms_len = ms.pos; const uint mel_len = mel.pos; const uint vlc_len = vlc.pos; - const uint total_len = ms_len + mel_len + vlc_len; - if (total_len < 2u || total_len > params.output_capacity) { + const uint cleanup_len = ms_len + mel_len + vlc_len; + uint sigprop_len = 0u; + uint magref_len = 0u; + if (params.target_coding_passes == 2u) { + sigprop_len = 1u; + } else if (params.target_coding_passes == 3u) { + uint actual_sigprop_len = 0u; + if (j2k_ht_write_sigprop_segment( + coefficients, params.width, params.width, params.height, + cleanup_threshold, refinement_mask, out + cleanup_len, + 0xFFFFFFFFu, actual_sigprop_len, false) == 0u) { + j2k_set_ht_encode_status(status, J2K_ENCODE_STATUS_UNSUPPORTED, 6u, 0u, 0u, 0u); + return; + } + sigprop_len = max(1u, actual_sigprop_len); + uint actual_magref_len = 0u; + if (j2k_ht_write_magref_segment( + coefficients, params.width, params.width, params.height, + cleanup_threshold, refinement_mask, out + cleanup_len + sigprop_len, + 0xFFFFFFFFu, significant_count, actual_magref_len, false) == 0u) { + j2k_set_ht_encode_status(status, J2K_ENCODE_STATUS_UNSUPPORTED, 7u, 0u, 0u, 0u); + return; + } + magref_len = max(1u, actual_magref_len); + } + const uint total_len = cleanup_len + sigprop_len + magref_len; + if (cleanup_len < 2u || total_len > params.output_capacity) { j2k_set_ht_encode_status(status, J2K_ENCODE_STATUS_FAIL, 4u, 0u, 0u, 0u); return; } @@ -928,11 +968,37 @@ inline void j2k_encode_ht_code_block_impl_with_max_and_assembly( out[ms_len + mel_len + idx] = out[vlc.offset + vlc_start + idx]; } - const uint last = total_len - 1u; - const uint prev = total_len - 2u; + const uint last = cleanup_len - 1u; + const uint prev = cleanup_len - 2u; const uint locator_bytes = mel_len + vlc_len; out[last] = uchar(locator_bytes >> 4u); out[prev] = uchar((out[prev] & uchar(0xF0u)) | uchar(locator_bytes & 0x0Fu)); + + for (uint idx = 0u; idx < sigprop_len + magref_len; ++idx) { + out[cleanup_len + idx] = uchar(0u); + } + if (params.target_coding_passes == 3u) { + uint actual_sigprop_len = 0u; + if (j2k_ht_write_sigprop_segment( + coefficients, params.width, params.width, params.height, + cleanup_threshold, refinement_mask, out + cleanup_len, + sigprop_len, actual_sigprop_len, true) == 0u) { + j2k_set_ht_encode_status( + status, J2K_ENCODE_STATUS_UNSUPPORTED, 6u, 0u, 0u, 0u); + return; + } + uint actual_magref_len = 0u; + if (j2k_ht_write_magref_segment( + coefficients, params.width, params.width, params.height, + cleanup_threshold, refinement_mask, + out + cleanup_len + sigprop_len, magref_len, + significant_count, actual_magref_len, true) == 0u || + actual_magref_len > magref_len) { + j2k_set_ht_encode_status( + status, J2K_ENCODE_STATUS_UNSUPPORTED, 7u, 0u, 0u, 0u); + return; + } + } } j2k_set_ht_encode_status_with_segments( @@ -940,11 +1006,11 @@ inline void j2k_encode_ht_code_block_impl_with_max_and_assembly( J2K_ENCODE_STATUS_OK, max_magnitude, total_len, - 1u, + params.target_coding_passes, missing_msbs, - ms_len, - mel_len, - vlc_len + cleanup_len, + sigprop_len, + magref_len ); } @@ -1004,6 +1070,8 @@ struct J2kHtEncodeBatchJob { uint width; uint height; uint total_bitplanes; + uint cleanup_bitplane; + uint target_coding_passes; uint output_capacity; }; @@ -1050,6 +1118,8 @@ kernel void j2k_encode_ht_code_blocks( params.width = job.width; params.height = job.height; params.total_bitplanes = job.total_bitplanes; + params.cleanup_bitplane = job.cleanup_bitplane; + params.target_coding_passes = job.target_coding_passes; params.output_capacity = job.output_capacity; j2k_encode_ht_code_block_impl( coefficients + job.coefficient_offset, diff --git a/crates/j2k-metal/src/encode_bitstream_ht_refinement.metal b/crates/j2k-metal/src/encode_bitstream_ht_refinement.metal new file mode 100644 index 00000000..0efb5c3c --- /dev/null +++ b/crates/j2k-metal/src/encode_bitstream_ht_refinement.metal @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +// HT SigProp/MagRef segment writers shared by the single-block and batch kernels. + +constant uint J2K_HT_SIGPROP_SCRATCH = 513u; +constant uint J2K_HT_SIGPROP_SPREAD_MASKS[16] = { + 0x33u, 0x76u, 0xECu, 0xC8u, 0x330u, 0x760u, 0xEC0u, 0xC80u, + 0x3300u, 0x7600u, 0xEC00u, 0xC800u, 0x33000u, 0x76000u, 0xEC000u, 0xC8000u +}; + +struct J2kHtSigPropWriter { + uint pos; + uint used_bits; + uint previous_was_ff; + uint capacity; + uchar tmp; + uint failed; +}; + +inline uint j2k_ht_sigprop_spread_mask(uint bit) { + return bit < 16u ? J2K_HT_SIGPROP_SPREAD_MASKS[bit] : 0u; +} + +inline void j2k_ht_sigprop_writer_init( + thread J2kHtSigPropWriter &writer, + uint capacity +) { + writer.pos = 0u; + writer.used_bits = 0u; + writer.previous_was_ff = 0u; + writer.capacity = capacity; + writer.tmp = uchar(0u); + writer.failed = 0u; +} + +inline void j2k_ht_sigprop_write_bit( + thread J2kHtSigPropWriter &writer, + device uchar *out, + uint bit, + bool write_output +) { + const uint max_bits = writer.previous_was_ff != 0u ? 7u : 8u; + writer.tmp |= uchar((bit & 1u) << writer.used_bits); + writer.used_bits += 1u; + if (writer.used_bits < max_bits) { + return; + } + if (writer.pos >= writer.capacity) { + writer.failed = 1u; + return; + } + if (write_output) { + out[writer.pos] = writer.tmp; + } + writer.previous_was_ff = writer.tmp == uchar(0xFFu) ? 1u : 0u; + writer.tmp = uchar(0u); + writer.used_bits = 0u; + writer.pos += 1u; +} + +inline void j2k_ht_sigprop_finish( + thread J2kHtSigPropWriter &writer, + device uchar *out, + bool write_output +) { + if (writer.used_bits == 0u) { + return; + } + if (writer.pos >= writer.capacity) { + writer.failed = 1u; + return; + } + if (write_output) { + out[writer.pos] = writer.tmp; + } + writer.pos += 1u; + writer.tmp = uchar(0u); + writer.used_bits = 0u; +} + +inline uint j2k_ht_sigprop_cleanup_sig16( + device const int *coefficients, + uint coefficient_stride, + uint width, + uint height, + uint x_base, + uint y_base, + uint cleanup_threshold +) { + uint mask = 0u; + for (uint col = 0u; col < 4u; ++col) { + const uint x = x_base + col; + if (x >= width) { + continue; + } + for (uint row = 0u; row < 4u; ++row) { + const uint y = y_base + row; + if (y < height && + j2k_classic_magnitude(coefficients[y * coefficient_stride + x]) >= cleanup_threshold) { + mask |= 1u << (col * 4u + row); + } + } + } + return mask; +} + +inline uint j2k_ht_sigprop_target_sig16( + device const int *coefficients, + uint coefficient_stride, + uint width, + uint height, + uint x_base, + uint y_base, + uint cleanup_threshold, + uint refinement_mask +) { + uint mask = 0u; + for (uint col = 0u; col < 4u; ++col) { + const uint x = x_base + col; + if (x >= width) { + continue; + } + for (uint row = 0u; row < 4u; ++row) { + const uint y = y_base + row; + if (y >= height) { + continue; + } + const uint magnitude = j2k_classic_magnitude(coefficients[y * coefficient_stride + x]); + if (magnitude < cleanup_threshold && (magnitude & refinement_mask) != 0u) { + mask |= 1u << (col * 4u + row); + } + } + } + return mask; +} + +inline uint j2k_ht_sigprop_coefficient_sign( + device const int *coefficients, + uint coefficient_stride, + uint x_base, + uint y_base, + uint bit +) { + const uint col = bit >> 2u; + const uint row = bit & 3u; + return coefficients[(y_base + row) * coefficient_stride + x_base + col] < 0 ? 1u : 0u; +} + +inline uint j2k_ht_write_sigprop_segment( + device const int *coefficients, + uint coefficient_stride, + uint width, + uint height, + uint cleanup_threshold, + uint refinement_mask, + device uchar *out, + uint capacity, + thread uint &bytes_written, + bool write_output +) { + const uint group_count = (width + 3u) >> 2u; + if (group_count + 8u > J2K_HT_SIGPROP_SCRATCH) { + return 0u; + } + thread ushort prev_row_sig[J2K_HT_SIGPROP_SCRATCH]; + for (uint idx = 0u; idx < group_count + 2u; ++idx) { + prev_row_sig[idx] = ushort(0u); + } + thread J2kHtSigPropWriter writer; + j2k_ht_sigprop_writer_init(writer, capacity); + + for (uint y = 0u; y < height; y += 4u) { + uint pattern = 0xFFFFu; + if (height - y < 4u) { + pattern = 0x7777u; + if (height - y < 3u) { + pattern = 0x3333u; + if (height - y < 2u) { + pattern = 0x1111u; + } + } + } + uint prev = 0u; + for (uint x = 0u; x < width; x += 4u) { + uint col_pattern = pattern; + if (x + 4u > width) { + col_pattern >>= (x + 4u - width) * 4u; + } + const uint idx = x >> 2u; + const uint ps = uint(prev_row_sig[idx]) | (uint(prev_row_sig[idx + 1u]) << 16u); + const uint ns = j2k_ht_sigprop_cleanup_sig16( + coefficients, coefficient_stride, width, height, x, y + 4u, cleanup_threshold) + | (j2k_ht_sigprop_cleanup_sig16( + coefficients, coefficient_stride, width, height, + x + 4u, y + 4u, cleanup_threshold) << 16u); + uint u = (ps & 0x88888888u) >> 3u; + u |= (ns & 0x11111111u) << 3u; + const uint cs = j2k_ht_sigprop_cleanup_sig16( + coefficients, coefficient_stride, width, height, x, y, cleanup_threshold) + | (j2k_ht_sigprop_cleanup_sig16( + coefficients, coefficient_stride, width, height, + x + 4u, y, cleanup_threshold) << 16u); + uint mbr = cs; + mbr |= (cs & 0x77777777u) << 1u; + mbr |= (cs & 0xEEEEEEEEu) >> 1u; + mbr |= u; + const uint t_mbr = mbr; + mbr |= t_mbr << 4u; + mbr |= t_mbr >> 4u; + mbr |= prev >> 12u; + mbr &= col_pattern; + mbr &= ~cs; + + uint new_sig = 0u; + const uint target_sig = j2k_ht_sigprop_target_sig16( + coefficients, coefficient_stride, width, height, x, y, + cleanup_threshold, refinement_mask) & col_pattern; + if (mbr != 0u) { + uint candidates = mbr; + uint processed = 0u; + const uint inv_sig = ~cs & col_pattern; + while (candidates != 0u) { + const uint bit = ctz(candidates); + const uint sample_mask = 1u << bit; + candidates &= ~sample_mask; + processed |= sample_mask; + const uint desired = (target_sig & sample_mask) != 0u ? 1u : 0u; + j2k_ht_sigprop_write_bit(writer, out, desired, write_output); + if (writer.failed != 0u) { + return 0u; + } + if (desired != 0u) { + new_sig |= sample_mask; + candidates |= j2k_ht_sigprop_spread_mask(bit) & inv_sig & ~processed; + } + } + uint sign_bits = new_sig; + while (sign_bits != 0u) { + const uint bit = ctz(sign_bits); + const uint sample_mask = 1u << bit; + sign_bits &= ~sample_mask; + j2k_ht_sigprop_write_bit( + writer, out, + j2k_ht_sigprop_coefficient_sign( + coefficients, coefficient_stride, x, y, bit), + write_output); + if (writer.failed != 0u) { + return 0u; + } + } + } + const uint combined_sig = new_sig | cs; + prev_row_sig[idx] = ushort(combined_sig & 0xFFFFu); + prev_row_sig[idx + 1u] = ushort((combined_sig >> 16u) & 0xFFFFu); + const uint t = combined_sig; + uint next_prev = combined_sig; + next_prev |= (t & 0x7777u) << 1u; + next_prev |= (t & 0xEEEEu) >> 1u; + prev = (next_prev | u) & 0xF000u; + } + } + j2k_ht_sigprop_finish(writer, out, write_output); + if (writer.failed != 0u) { + return 0u; + } + bytes_written = writer.pos; + return 1u; +} + +inline uint j2k_ht_write_magref_segment( + device const int *coefficients, + uint coefficient_stride, + uint width, + uint height, + uint cleanup_threshold, + uint refinement_mask, + device uchar *out, + uint capacity, + uint expected_bits, + thread uint &bytes_written, + bool write_output +) { + uint bit_idx = 0u; + uint byte_from_end = 0u; + uint used_bits = 0u; + uint unstuff = 1u; + uchar current = uchar(0u); + for (uint y = 0u; y < height; y += 4u) { + for (uint x_base = 0u; x_base < width; x_base += 8u) { + for (uint col = 0u; col < 8u; ++col) { + const uint x = x_base + col; + if (x >= width) { + continue; + } + for (uint row = 0u; row < 4u; ++row) { + const uint yy = y + row; + if (yy >= height) { + continue; + } + const uint magnitude = + j2k_classic_magnitude(coefficients[yy * coefficient_stride + x]); + if (magnitude < cleanup_threshold) { + continue; + } + current |= uchar(uint((magnitude & refinement_mask) != 0u) << used_bits); + used_bits += 1u; + bit_idx += 1u; + const bool stuffed = + unstuff != 0u && used_bits == 7u && (current & uchar(0x7Fu)) == uchar(0x7Fu); + if (stuffed || used_bits == 8u) { + if (byte_from_end >= capacity) { + return 0u; + } + if (write_output) { + out[capacity - 1u - byte_from_end] = current; + } + byte_from_end += 1u; + unstuff = current > uchar(0x8Fu) ? 1u : 0u; + current = uchar(0u); + used_bits = 0u; + } + } + } + } + } + if (used_bits != 0u) { + if (byte_from_end >= capacity) { + return 0u; + } + if (write_output) { + out[capacity - 1u - byte_from_end] = current; + } + byte_from_end += 1u; + } + bytes_written = byte_from_end; + return bit_idx == expected_bits ? 1u : 0u; +} diff --git a/crates/j2k-metal/src/engine.rs b/crates/j2k-metal/src/engine.rs index ff8594cd..a0900e4b 100644 --- a/crates/j2k-metal/src/engine.rs +++ b/crates/j2k-metal/src/engine.rs @@ -12,11 +12,12 @@ use j2k_native::{ idwt_required_input_windows, idwt_required_output_margin, pack_j2k_code_block_scalar_from_tier1_tokens, ColorSpace as NativeColorSpace, DecodedComponents as NativeDecodedComponents, EncodeProgressionOrder, EncodedHtJ2kCodeBlock, - EncodedJ2kCodeBlock, HtCodeBlockDecodeJob, HtSubBandDecodeJob, J2kCodeBlockDecodeJob, - J2kCodeBlockSegment, J2kDeinterleaveMctToF32Job, J2kDeinterleaveToF32Job, J2kDirectBandId, - J2kDirectGrayscalePlan, J2kDirectGrayscaleStep, J2kDirectIdwtStep, J2kDirectStoreStep, - J2kForwardDwt53Level, J2kForwardDwt53Output, J2kForwardDwt97Level, J2kForwardDwt97Output, - J2kHtCodeBlockEncodeJob, J2kInverseMctJob, J2kPacketizationBlockCodingMode, + EncodedHtJ2kCodeBlockSet, EncodedJ2kCodeBlock, HtCodeBlockDecodeJob, HtSubBandDecodeJob, + J2kCodeBlockDecodeJob, J2kCodeBlockSegment, J2kDeinterleaveMctToF32Job, + J2kDeinterleaveToF32Job, J2kDirectBandId, J2kDirectGrayscalePlan, J2kDirectGrayscaleStep, + J2kDirectIdwtStep, J2kDirectStoreStep, J2kForwardDwt53Level, J2kForwardDwt53Output, + J2kForwardDwt97Level, J2kForwardDwt97Output, J2kHtCodeBlockEncodeJob, + J2kHtCodeBlockSetEncodeJob, J2kInverseMctJob, J2kPacketizationBlockCodingMode, J2kPacketizationEncodeJob, J2kPacketizationPacketDescriptor, J2kQuantizeSubbandJob, J2kRequiredBandRegion, J2kSingleDecompositionIdwtJob, J2kStoreComponentJob, J2kSubBandDecodeJob, J2kTier1CodeBlockEncodeJob, J2kTier1TokenSegment, J2kWaveletTransform, @@ -384,7 +385,8 @@ mod tier1_encode; pub(crate) use self::tier1_encode::{ encode_classic_tier1_code_block, encode_classic_tier1_code_blocks, encode_classic_tier1_prepared_device_code_blocks_resident, encode_ht_cleanup_code_block, - encode_ht_cleanup_code_blocks, encode_ht_prepared_device_code_blocks_resident, + encode_ht_cleanup_code_blocks, encode_ht_code_block_sets, + encode_ht_prepared_device_code_blocks_resident, read_resident_ht_tier1_code_blocks_for_cpu_packetization, }; #[cfg(all(target_os = "macos", test))] diff --git a/crates/j2k-metal/src/engine/abi.rs b/crates/j2k-metal/src/engine/abi.rs index 2935f9af..6f96f4a7 100644 --- a/crates/j2k-metal/src/engine/abi.rs +++ b/crates/j2k-metal/src/engine/abi.rs @@ -534,8 +534,11 @@ pub(crate) const J2K_HT_ENCODE_VLC_SIZE: usize = 3072 - J2K_HT_ENCODE_MEL_SIZE; #[cfg(target_os = "macos")] pub(crate) const J2K_HT_ENCODE_MS_SIZE: usize = (16_384usize * 16).div_ceil(15); #[cfg(target_os = "macos")] -pub(crate) const J2K_HT_ENCODE_BASE_OUTPUT_SIZE: usize = - J2K_HT_ENCODE_MS_SIZE + J2K_HT_ENCODE_MEL_SIZE + J2K_HT_ENCODE_VLC_SIZE; +pub(crate) const J2K_HT_ENCODE_BASE_OUTPUT_SIZE: usize = J2K_HT_ENCODE_MS_SIZE + + J2K_HT_ENCODE_MEL_SIZE + + J2K_HT_ENCODE_VLC_SIZE + + J2K_HT_ENCODE_MAX_SAMPLES.div_ceil(8) + + J2K_HT_ENCODE_MAX_SAMPLES.div_ceil(7); #[cfg(target_os = "macos")] pub(crate) const J2K_HT_ENCODE_MAX_SAMPLES: usize = 16_384; #[cfg(target_os = "macos")] @@ -757,6 +760,8 @@ pub(crate) struct J2kHtEncodeParams { pub(crate) width: u32, pub(crate) height: u32, pub(crate) total_bitplanes: u32, + pub(crate) cleanup_bitplane: u32, + pub(crate) target_coding_passes: u32, pub(crate) output_capacity: u32, } @@ -769,6 +774,8 @@ pub(crate) struct J2kHtEncodeBatchJob { pub(crate) width: u32, pub(crate) height: u32, pub(crate) total_bitplanes: u32, + pub(crate) cleanup_bitplane: u32, + pub(crate) target_coding_passes: u32, pub(crate) output_capacity: u32, } @@ -781,9 +788,9 @@ pub(crate) struct J2kHtEncodeStatus { pub(crate) data_len: u32, pub(crate) num_coding_passes: u32, pub(crate) num_zero_bitplanes: u32, - pub(crate) reserved0: u32, - pub(crate) reserved1: u32, - pub(crate) reserved2: u32, + pub(crate) cleanup_length: u32, + pub(crate) sigprop_length: u32, + pub(crate) magref_length: u32, } #[cfg(target_os = "macos")] @@ -1117,7 +1124,7 @@ mod gpu_readback_abi_tests { offset_of!(J2kClassicTier1PassPlanCounters, raw_bits_by_pass), 256 ); - assert_eq!(offset_of!(J2kHtEncodeStatus, reserved2), 28); + assert_eq!(offset_of!(J2kHtEncodeStatus, magref_length), 28); assert_eq!( offset_of!(J2kPacketEncodeStatus, payload_copy_large_jobs), 28 diff --git a/crates/j2k-metal/src/engine/encode_capacity.rs b/crates/j2k-metal/src/engine/encode_capacity.rs index 49ad5db2..cefb2727 100644 --- a/crates/j2k-metal/src/engine/encode_capacity.rs +++ b/crates/j2k-metal/src/engine/encode_capacity.rs @@ -152,9 +152,16 @@ pub(super) fn ht_encode_output_capacity(width: u32, height: u32) -> Result(count: usize, context: &'static str) -> Result { @@ -34,6 +33,10 @@ fn checked_type_buffer_bytes(count: usize, context: &'static str) -> Result(output, output_offset, data_len, "HTJ2K encode payload")? }; + let cleanup_length = status.cleanup_length; + let refinement_length = status + .sigprop_length + .checked_add(status.magref_length) + .ok_or_else(|| Error::MetalKernel { + message: "HTJ2K Metal refinement length overflow".to_string(), + })?; + let expected_length = cleanup_length + .checked_add(refinement_length) + .and_then(|length| usize::try_from(length).ok()) + .ok_or_else(|| Error::MetalKernel { + message: "HTJ2K Metal segment length overflow".to_string(), + })?; + if data_len != expected_length { + return Err(Error::MetalKernel { + message: "HTJ2K Metal segment lengths do not match output length".to_string(), + }); + } Ok(EncodedHtJ2kCodeBlock { data, - cleanup_length: status.data_len, - refinement_length: 0, + cleanup_length, + refinement_length, num_coding_passes: u8::try_from(status.num_coding_passes).map_err(|_| { Error::MetalKernel { message: "HTJ2K Metal encode pass count exceeds u8".to_string(), @@ -982,178 +1005,6 @@ pub(crate) fn read_resident_ht_tier1_code_blocks_for_cpu_packetization( }) } -#[cfg(target_os = "macos")] -pub(crate) fn encode_ht_cleanup_code_blocks( - jobs: &[J2kHtCodeBlockEncodeJob<'_>], -) -> Result, Error> { - with_runtime(|runtime| encode_ht_cleanup_code_blocks_with_runtime(runtime, jobs)) -} - -#[cfg(target_os = "macos")] -pub(super) fn encode_ht_cleanup_code_blocks_with_runtime( - runtime: &MetalRuntime, - jobs: &[J2kHtCodeBlockEncodeJob<'_>], -) -> Result, Error> { - let blocks = encode_ht_cleanup_code_blocks_with_runtime_and_statuses(runtime, jobs)?; - let mut budget = crate::batch_allocation::BatchMetadataBudget::new( - "HTJ2K Metal encoded block projection metadata", - ); - budget.account_capacity::<(EncodedHtJ2kCodeBlock, J2kHtEncodeStatus)>(blocks.capacity())?; - budget.preflight(&[crate::batch_allocation::BatchMetadataRequest::of::< - EncodedHtJ2kCodeBlock, - >(blocks.len())])?; - let mut encoded = budget.try_vec(blocks.len(), "HTJ2K Metal encoded block results")?; - for (block, _status) in blocks { - encoded.push(block); - } - Ok(encoded) -} - -#[cfg(target_os = "macos")] -#[expect( - clippy::too_many_lines, - reason = "HT batch dispatch keeps output/status slice ownership aligned" -)] -pub(super) fn encode_ht_cleanup_code_blocks_with_runtime_and_statuses( - runtime: &MetalRuntime, - jobs: &[J2kHtCodeBlockEncodeJob<'_>], -) -> Result, Error> { - if jobs.is_empty() { - return Ok(Vec::new()); - } - if jobs.iter().any(|job| job.target_coding_passes != 1) { - return Err(Error::MetalKernel { - message: "HTJ2K Metal cleanup encode supports one coding pass".to_string(), - }); - } - - let coefficient_count = jobs.iter().try_fold(0usize, |total, job| { - let width = usize::try_from(job.width).map_err(|_| Error::MetalKernel { - message: "HTJ2K Metal encode width exceeds usize".to_string(), - })?; - let height = usize::try_from(job.height).map_err(|_| Error::MetalKernel { - message: "HTJ2K Metal encode height exceeds usize".to_string(), - })?; - let count = width - .checked_mul(height) - .ok_or_else(|| Error::MetalKernel { - message: "HTJ2K Metal encode coefficient count overflow".to_string(), - })?; - total.checked_add(count).ok_or_else(|| { - Error::from(j2k_core::BatchInfrastructureError::AllocationTooLarge { - what: "HTJ2K Metal encode coefficients", - requested: usize::MAX, - cap: j2k_core::DEFAULT_MAX_HOST_ALLOCATION_BYTES, - }) - }) - })?; - let mut budget = - crate::batch_allocation::BatchMetadataBudget::new("HTJ2K Metal Tier-1 encode batch"); - let mut coefficients = budget.try_vec(coefficient_count, "HTJ2K Metal encode coefficients")?; - let mut batch_jobs = budget.try_vec(jobs.len(), "HTJ2K Metal encode batch jobs")?; - let mut output_capacity_total = 0usize; - - for job in jobs { - let output_capacity = ht_encode_output_capacity(job.width, job.height)?; - let output_capacity_u32 = - u32::try_from(output_capacity).map_err(|_| Error::MetalKernel { - message: "HTJ2K Metal encode output capacity exceeds u32".to_string(), - })?; - let expected_coefficients = usize::try_from(job.width) - .ok() - .and_then(|w| { - usize::try_from(job.height) - .ok() - .and_then(|h| w.checked_mul(h)) - }) - .ok_or_else(|| Error::MetalKernel { - message: "HTJ2K Metal encode coefficient count overflow".to_string(), - })?; - if job.coefficients.len() < expected_coefficients { - return Err(Error::MetalKernel { - message: "HTJ2K Metal encode coefficient slice is too small".to_string(), - }); - } - let coefficient_offset = - u32::try_from(coefficients.len()).map_err(|_| Error::MetalKernel { - message: "HTJ2K Metal encode coefficient table exceeds u32".to_string(), - })?; - coefficients.extend_from_slice(&job.coefficients[..expected_coefficients]); - let output_offset = - u32::try_from(output_capacity_total).map_err(|_| Error::MetalKernel { - message: "HTJ2K Metal encode output table exceeds u32".to_string(), - })?; - batch_jobs.push(J2kHtEncodeBatchJob { - coefficient_offset, - output_offset, - width: job.width, - height: job.height, - total_bitplanes: u32::from(job.total_bitplanes), - output_capacity: output_capacity_u32, - }); - output_capacity_total = output_capacity_total - .checked_add(output_capacity) - .ok_or_else(|| Error::MetalKernel { - message: "HTJ2K Metal encode output buffer overflow".to_string(), - })?; - } - - let coefficient_buffer = copied_slice_buffer(&runtime.device, &coefficients)?; - let job_buffer = copied_slice_buffer(&runtime.device, &batch_jobs)?; - let output = new_shared_buffer(&runtime.device, output_capacity_total.max(1))?; - let status_buffer = zeroed_shared_buffer( - &runtime.device, - checked_type_buffer_bytes::( - jobs.len(), - "HTJ2K Metal encode status buffer", - )?, - )?; - let job_count = u32::try_from(batch_jobs.len()).map_err(|_| Error::MetalKernel { - message: "HTJ2K Metal encode job count exceeds u32".to_string(), - })?; - - let command_buffer = new_command_buffer(&runtime.queue)?; - label_command_buffer(&command_buffer, "j2k htj2k tier1 batch"); - let encoder = new_compute_command_encoder(&command_buffer)?; - label_compute_encoder(&encoder, "HTJ2K Tier-1 encode"); - let pipeline = &runtime.ht_encode_code_blocks; - encoder.setComputePipelineState(pipeline); - encoder.set_buffer(0, Some(&coefficient_buffer), 0); - encoder.set_buffer(1, Some(&output), 0); - encoder.set_buffer(2, Some(&job_buffer), 0); - encoder.set_buffer(3, Some(&runtime.ht_vlc_encode_table0), 0); - encoder.set_buffer(4, Some(&runtime.ht_vlc_encode_table1), 0); - encoder.set_buffer(5, Some(&runtime.ht_uvlc_encode_table), 0); - encoder.set_buffer(6, Some(&status_buffer), 0); - encoder.set_bytes::(7, &job_count); - dispatch_1d_pipeline(&encoder, pipeline, u64::from(job_count)); - encoder.endEncoding(); - commit_and_wait_metal(&command_buffer)?; - - let statuses = checked_buffer_slice::( - &status_buffer, - jobs.len(), - "HT encode statuses", - )?; - let mut results = budget.try_vec(jobs.len(), "J2K Metal HT Tier-1 encoded blocks")?; - for (idx, status) in statuses.iter().copied().enumerate() { - let batch_job = batch_jobs[idx]; - let encoded_block = read_ht_encoded_code_block( - status, - &output, - usize::try_from(batch_job.output_offset).map_err(|_| Error::MetalKernel { - message: "HTJ2K Metal encode output offset exceeds usize".to_string(), - })?, - usize::try_from(batch_job.output_capacity).map_err(|_| Error::MetalKernel { - message: "HTJ2K Metal encode output capacity exceeds usize".to_string(), - })?, - )?; - results.push((encoded_block, status)); - } - - Ok(results) -} - #[cfg(target_os = "macos")] pub(crate) fn encode_ht_cleanup_code_block( job: J2kHtCodeBlockEncodeJob<'_>, @@ -1188,6 +1039,8 @@ pub(crate) fn encode_ht_cleanup_code_block( width: job.width, height: job.height, total_bitplanes: u32::from(job.total_bitplanes), + cleanup_bitplane: 0, + target_coding_passes: 1, output_capacity: output_capacity_u32, }; let coefficients = @@ -1210,40 +1063,6 @@ pub(crate) fn encode_ht_cleanup_code_block( commit_and_wait_metal(&command_buffer)?; let status = checked_buffer_read::(&status_buffer, "HT encode status")?; - if status.code != J2K_ENCODE_STATUS_OK { - return Err(encode_status_error( - "HTJ2K cleanup", - status.code, - status.detail, - )); - } - let data_len = usize::try_from(status.data_len).map_err(|_| Error::MetalKernel { - message: "HTJ2K Metal encode length exceeds usize".to_string(), - })?; - if data_len > output_capacity { - return Err(Error::MetalKernel { - message: "HTJ2K Metal encode length exceeds output buffer".to_string(), - }); - } - let data = if data_len == 0 { - Vec::new() - } else { - checked_buffer_slice::(&output, data_len, "HT encode payload")? - }; - Ok(EncodedHtJ2kCodeBlock { - data, - cleanup_length: status.data_len, - refinement_length: 0, - num_coding_passes: u8::try_from(status.num_coding_passes).map_err(|_| { - Error::MetalKernel { - message: "HTJ2K Metal encode pass count exceeds u8".to_string(), - } - })?, - num_zero_bitplanes: u8::try_from(status.num_zero_bitplanes).map_err(|_| { - Error::MetalKernel { - message: "HTJ2K Metal encode zero bitplanes exceeds u8".to_string(), - } - })?, - }) + read_ht_encoded_code_block(status, &output, 0, output_capacity) }) } diff --git a/crates/j2k-metal/src/engine/tier1_encode/ht_batch.rs b/crates/j2k-metal/src/engine/tier1_encode/ht_batch.rs new file mode 100644 index 00000000..7cb4114d --- /dev/null +++ b/crates/j2k-metal/src/engine/tier1_encode/ht_batch.rs @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Host-staged Metal HT Tier-1 batches and candidate-set projection. + +use j2k_metal_support::dispatch_1d_pipeline; + +use crate::metal_types::prelude::*; +use crate::profile_env::{label_command_buffer, label_compute_encoder}; + +use super::super::abi::{J2kHtEncodeBatchJob, J2kHtEncodeStatus}; +use super::super::{ + checked_buffer_slice, commit_and_wait_metal, copied_slice_buffer, ht_encode_output_capacity, + new_command_buffer, new_compute_command_encoder, new_shared_buffer, with_runtime, + zeroed_shared_buffer, EncodedHtJ2kCodeBlock, EncodedHtJ2kCodeBlockSet, Error, + J2kHtCodeBlockEncodeJob, J2kHtCodeBlockSetEncodeJob, MetalRuntime, +}; +use super::{checked_type_buffer_bytes, read_ht_encoded_code_block}; + +#[derive(Clone, Copy)] +struct MetalHtCodeBlockJob<'a> { + coefficients: &'a [i32], + width: u32, + height: u32, + total_bitplanes: u8, + cleanup_bitplane: u8, + target_coding_passes: u8, +} + +trait MetalHtCodeBlockJobSource { + fn metal_job(&self) -> MetalHtCodeBlockJob<'_>; +} + +impl MetalHtCodeBlockJobSource for J2kHtCodeBlockEncodeJob<'_> { + fn metal_job(&self) -> MetalHtCodeBlockJob<'_> { + MetalHtCodeBlockJob { + coefficients: self.coefficients, + width: self.width, + height: self.height, + total_bitplanes: self.total_bitplanes, + cleanup_bitplane: 0, + target_coding_passes: self.target_coding_passes, + } + } +} + +impl MetalHtCodeBlockJobSource for J2kHtCodeBlockSetEncodeJob<'_> { + fn metal_job(&self) -> MetalHtCodeBlockJob<'_> { + MetalHtCodeBlockJob { + coefficients: self.coefficients, + width: self.width, + height: self.height, + total_bitplanes: self.total_bitplanes, + cleanup_bitplane: self.cleanup_bitplane, + target_coding_passes: self.target_coding_passes, + } + } +} + +pub(crate) fn encode_ht_cleanup_code_blocks( + jobs: &[J2kHtCodeBlockEncodeJob<'_>], +) -> Result, Error> { + with_runtime(|runtime| encode_ht_cleanup_code_blocks_with_runtime(runtime, jobs)) +} + +fn encode_ht_cleanup_code_blocks_with_runtime( + runtime: &MetalRuntime, + jobs: &[J2kHtCodeBlockEncodeJob<'_>], +) -> Result, Error> { + let blocks = encode_ht_cleanup_code_blocks_with_runtime_and_statuses(runtime, jobs)?; + let mut budget = crate::batch_allocation::BatchMetadataBudget::new( + "HTJ2K Metal encoded block projection metadata", + ); + budget.account_capacity::<(EncodedHtJ2kCodeBlock, J2kHtEncodeStatus)>(blocks.capacity())?; + budget.preflight(&[crate::batch_allocation::BatchMetadataRequest::of::< + EncodedHtJ2kCodeBlock, + >(blocks.len())])?; + let mut encoded = budget.try_vec(blocks.len(), "HTJ2K Metal encoded block results")?; + for (block, _status) in blocks { + encoded.push(block); + } + Ok(encoded) +} + +fn encode_ht_cleanup_code_blocks_with_runtime_and_statuses( + runtime: &MetalRuntime, + jobs: &[J2kHtCodeBlockEncodeJob<'_>], +) -> Result, Error> { + if jobs.is_empty() { + return Ok(Vec::new()); + } + if jobs.iter().any(|job| job.target_coding_passes != 1) { + return Err(Error::MetalKernel { + message: "HTJ2K Metal cleanup encode supports one coding pass".to_string(), + }); + } + + encode_ht_code_block_jobs_with_runtime_and_statuses(runtime, jobs) +} + +fn encode_ht_code_block_jobs_with_runtime_and_statuses( + runtime: &MetalRuntime, + jobs: &[J], +) -> Result, Error> { + if jobs.is_empty() { + return Ok(Vec::new()); + } + + let mut budget = + crate::batch_allocation::BatchMetadataBudget::new("HTJ2K Metal Tier-1 encode batch"); + let HtBatchStaging { + coefficients, + batch_jobs, + output_capacity_total, + } = prepare_ht_batch(jobs, &mut budget)?; + + let coefficient_buffer = copied_slice_buffer(&runtime.device, &coefficients)?; + let job_buffer = copied_slice_buffer(&runtime.device, &batch_jobs)?; + let output = new_shared_buffer(&runtime.device, output_capacity_total.max(1))?; + let status_buffer = zeroed_shared_buffer( + &runtime.device, + checked_type_buffer_bytes::( + jobs.len(), + "HTJ2K Metal encode status buffer", + )?, + )?; + let job_count = u32::try_from(batch_jobs.len()).map_err(|_| Error::MetalKernel { + message: "HTJ2K Metal encode job count exceeds u32".to_string(), + })?; + + let command_buffer = new_command_buffer(&runtime.queue)?; + label_command_buffer(&command_buffer, "j2k htj2k tier1 batch"); + let encoder = new_compute_command_encoder(&command_buffer)?; + label_compute_encoder(&encoder, "HTJ2K Tier-1 encode"); + let pipeline = &runtime.ht_encode_code_blocks; + encoder.setComputePipelineState(pipeline); + encoder.set_buffer(0, Some(&coefficient_buffer), 0); + encoder.set_buffer(1, Some(&output), 0); + encoder.set_buffer(2, Some(&job_buffer), 0); + encoder.set_buffer(3, Some(&runtime.ht_vlc_encode_table0), 0); + encoder.set_buffer(4, Some(&runtime.ht_vlc_encode_table1), 0); + encoder.set_buffer(5, Some(&runtime.ht_uvlc_encode_table), 0); + encoder.set_buffer(6, Some(&status_buffer), 0); + encoder.set_bytes::(7, &job_count); + dispatch_1d_pipeline(&encoder, pipeline, u64::from(job_count)); + encoder.endEncoding(); + commit_and_wait_metal(&command_buffer)?; + + let statuses = checked_buffer_slice::( + &status_buffer, + jobs.len(), + "HT encode statuses", + )?; + let mut results = budget.try_vec(jobs.len(), "J2K Metal HT Tier-1 encoded blocks")?; + for (index, status) in statuses.iter().copied().enumerate() { + let batch_job = batch_jobs[index]; + let encoded_block = read_ht_encoded_code_block( + status, + &output, + usize::try_from(batch_job.output_offset).map_err(|_| Error::MetalKernel { + message: "HTJ2K Metal encode output offset exceeds usize".to_string(), + })?, + usize::try_from(batch_job.output_capacity).map_err(|_| Error::MetalKernel { + message: "HTJ2K Metal encode output capacity exceeds usize".to_string(), + })?, + )?; + results.push((encoded_block, status)); + } + + Ok(results) +} + +struct HtBatchStaging { + coefficients: Vec, + batch_jobs: Vec, + output_capacity_total: usize, +} + +fn prepare_ht_batch( + jobs: &[J], + budget: &mut crate::batch_allocation::BatchMetadataBudget, +) -> Result { + let coefficient_count = jobs.iter().try_fold(0usize, |total, source| { + total + .checked_add(ht_job_coefficient_count(source.metal_job())?) + .ok_or_else(|| { + Error::from(j2k_core::BatchInfrastructureError::AllocationTooLarge { + what: "HTJ2K Metal encode coefficients", + requested: usize::MAX, + cap: j2k_core::DEFAULT_MAX_HOST_ALLOCATION_BYTES, + }) + }) + })?; + let mut coefficients = budget.try_vec(coefficient_count, "HTJ2K Metal encode coefficients")?; + let mut batch_jobs = budget.try_vec(jobs.len(), "HTJ2K Metal encode batch jobs")?; + let mut output_capacity_total = 0usize; + + for source in jobs { + let job = source.metal_job(); + let expected_coefficients = ht_job_coefficient_count(job)?; + if job.coefficients.len() < expected_coefficients { + return Err(Error::MetalKernel { + message: "HTJ2K Metal encode coefficient slice is too small".to_string(), + }); + } + let output_capacity = ht_encode_output_capacity(job.width, job.height)?; + let coefficient_offset = checked_u32( + coefficients.len(), + "HTJ2K Metal encode coefficient table exceeds u32", + )?; + let output_offset = checked_u32( + output_capacity_total, + "HTJ2K Metal encode output table exceeds u32", + )?; + batch_jobs.push(J2kHtEncodeBatchJob { + coefficient_offset, + output_offset, + width: job.width, + height: job.height, + total_bitplanes: u32::from(job.total_bitplanes), + cleanup_bitplane: u32::from(job.cleanup_bitplane), + target_coding_passes: u32::from(job.target_coding_passes), + output_capacity: checked_u32( + output_capacity, + "HTJ2K Metal encode output capacity exceeds u32", + )?, + }); + coefficients.extend_from_slice(&job.coefficients[..expected_coefficients]); + output_capacity_total = output_capacity_total + .checked_add(output_capacity) + .ok_or_else(|| Error::MetalKernel { + message: "HTJ2K Metal encode output buffer overflow".to_string(), + })?; + } + Ok(HtBatchStaging { + coefficients, + batch_jobs, + output_capacity_total, + }) +} + +fn ht_job_coefficient_count(job: MetalHtCodeBlockJob<'_>) -> Result { + usize::try_from(job.width) + .ok() + .and_then(|width| { + usize::try_from(job.height) + .ok() + .and_then(|height| width.checked_mul(height)) + }) + .ok_or_else(|| Error::MetalKernel { + message: "HTJ2K Metal encode coefficient count overflow".to_string(), + }) +} + +fn checked_u32(value: usize, message: &'static str) -> Result { + u32::try_from(value).map_err(|_| Error::MetalKernel { + message: message.to_string(), + }) +} + +pub(crate) fn encode_ht_code_block_sets( + jobs: &[J2kHtCodeBlockSetEncodeJob<'_>], +) -> Result, Error> { + with_runtime(|runtime| { + let blocks = encode_ht_code_block_jobs_with_runtime_and_statuses(runtime, jobs)?; + let mut budget = crate::batch_allocation::BatchMetadataBudget::new( + "HTJ2K Metal candidate-set projection", + ); + budget.account_capacity::<(EncodedHtJ2kCodeBlock, J2kHtEncodeStatus)>(blocks.capacity())?; + let mut output = budget.try_vec(blocks.len(), "HTJ2K Metal candidate-set results")?; + for (block, status) in blocks { + output.push(EncodedHtJ2kCodeBlockSet { + data: block.data, + cleanup_length: block.cleanup_length, + sigprop_length: status.sigprop_length, + magref_length: status.magref_length, + num_coding_passes: block.num_coding_passes, + num_zero_bitplanes: block.num_zero_bitplanes, + }); + } + Ok(output) + }) +} diff --git a/crates/j2k-metal/tests/bench_harness.rs b/crates/j2k-metal/tests/bench_harness.rs index 22d8a1b3..2fd21ec1 100644 --- a/crates/j2k-metal/tests/bench_harness.rs +++ b/crates/j2k-metal/tests/bench_harness.rs @@ -44,7 +44,7 @@ fn j2k_metal_declares_the_audited_routing_and_decode_stage_benches() { assert_eq!( cargo.matches("[[bench]]").count(), - 4, + 5, "j2k-metal must keep all audited benchmark targets" ); assert!( @@ -65,6 +65,10 @@ fn j2k_metal_declares_the_audited_routing_and_decode_stage_benches() { ), "j2k-metal must keep the resident packetization benchmark explicit" ); + assert!( + cargo.contains("[[bench]]\nname = \"htj2k_candidates\"\nharness = false\ntest = false"), + "j2k-metal must keep the end-to-end HTJ2K candidate benchmark explicit" + ); for target in ["device_upload", "compare", "encode_stages"] { assert!( @@ -97,6 +101,12 @@ fn j2k_metal_benches_directory_matches_the_audited_targets() { manifest_dir().join("benches/auto_routing/runner.rs"), manifest_dir().join("benches/auto_routing.rs"), manifest_dir().join("benches/decode_stages.rs"), + manifest_dir().join("benches/htj2k_candidates/case.rs"), + manifest_dir().join("benches/htj2k_candidates/runner.rs"), + manifest_dir().join("benches/htj2k_candidates.rs"), + manifest_dir().join("benches/resident_packetization/batch_compare.rs"), + manifest_dir().join("benches/resident_packetization/packetization.rs"), + manifest_dir().join("benches/resident_packetization/support.rs"), manifest_dir().join("benches/resident_packetization.rs"), manifest_dir().join("benches/transform_stages.rs"), ], @@ -104,17 +114,65 @@ fn j2k_metal_benches_directory_matches_the_audited_targets() { ); } +#[test] +fn htj2k_candidate_bench_covers_geometry_modes_budgets_and_correctness() { + let bench = [ + manifest_dir().join("benches/htj2k_candidates.rs"), + manifest_dir().join("benches/htj2k_candidates/case.rs"), + manifest_dir().join("benches/htj2k_candidates/runner.rs"), + ] + .into_iter() + .map(|path| { + std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("must read {}: {error}", path.display())) + }) + .collect::>() + .join("\n"); + + for expected in [ + "const SMALL_TILE_SIDE: u32 = 256", + "const MEDIUM_TILE_SIDE: u32 = 512", + "const LARGE_TILE_SIDE: u32 = 1024", + "LosslessTwoLayers", + "LosslessThreeLayers", + "LossyTwoBudgets", + "LossyThreeBudgets", + "J2kRateTarget::BitsPerPixel", + "candidate_set_dispatches", + "verify_lossless_roundtrip", + "verify_lossy_parity", + "assert_output_parity", + ] { + assert!( + bench.contains(expected), + "HTJ2K candidate benchmark is missing `{expected}`" + ); + } +} + #[test] fn resident_packetization_bench_has_exact_output_probes() { - let path = manifest_dir().join("benches/resident_packetization.rs"); - let source = std::fs::read_to_string(&path) - .unwrap_or_else(|error| panic!("must read {}: {error}", path.display())); + let root = manifest_dir().join("benches/resident_packetization.rs"); + let mut paths = bench_sources_under(&manifest_dir().join("benches/resident_packetization")); + paths.push(root); + let source = paths + .into_iter() + .map(|path| { + std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("must read {}: {error}", path.display())) + }) + .collect::>() + .join("\n"); for expected in [ - "const BATCH_SIZE: usize = 16", + "const BATCH_SIZES: [usize; 3] = [1, 4, 16]", "J2kBlockCodingMode::Classic", "J2kBlockCodingMode::HighThroughput", "submit_lossless_batch_to_metal", + "encode_lossless_batch_with_report", + "encode_cpu_parallel", + "gpu_encode_inflight_tiles: Some(batch_size)", + "verify_cpu_metal_batch", "codestream_bytes()", "auto_routing_sha256", ] { diff --git a/crates/j2k-native/benches/tier1_bitplane.rs b/crates/j2k-native/benches/tier1_bitplane.rs index a5523192..48632e0b 100644 --- a/crates/j2k-native/benches/tier1_bitplane.rs +++ b/crates/j2k-native/benches/tier1_bitplane.rs @@ -2,8 +2,9 @@ use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; use j2k_native::{ collect_ht_cleanup_encode_distribution, decode_ht_code_block_scalar, decode_ht_code_block_scalar_with_workspace_midpoint, decode_j2k_code_block_scalar, - encode_ht_code_block_scalar, encode_j2k_code_block_scalar_with_style, DecodeSettings, - DecoderContext, HtCodeBlockDecodeJob, HtCodeBlockDecodeWorkspace, HtCodeBlockDecoder, Image, + encode_ht_code_block_scalar, encode_ht_code_block_scalar_with_passes_and_workspace, + encode_j2k_code_block_scalar_with_style, DecodeSettings, DecoderContext, HtCodeBlockDecodeJob, + HtCodeBlockDecodeWorkspace, HtCodeBlockDecoder, HtCodeBlockEncodeWorkspace, Image, J2kCodeBlockDecodeJob, J2kCodeBlockStyle, J2kSubBandType, Result, }; use rayon::prelude::*; @@ -500,7 +501,7 @@ fn bench_htj2k_cleanup_encode(c: &mut Criterion) { let coefficients = generated_coefficients(width, height, seed); group.bench_with_input( - BenchmarkId::new("encode_64x64", seed), + BenchmarkId::new("encode_64x64_fresh", seed), &coefficients, |b, coefficients| { b.iter(|| { @@ -515,6 +516,43 @@ fn bench_htj2k_cleanup_encode(c: &mut Criterion) { }); }, ); + let mut workspace = HtCodeBlockEncodeWorkspace::try_new().expect("HT encode workspace"); + group.bench_with_input( + BenchmarkId::new("encode_64x64_reused", seed), + &coefficients, + |b, coefficients| { + b.iter(|| { + let encoded = encode_ht_code_block_scalar_with_passes_and_workspace( + std::hint::black_box(coefficients), + width, + height, + total_bitplanes, + 1, + &mut workspace, + ) + .expect("encode HTJ2K code block with reused workspace"); + std::hint::black_box(encoded); + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("encode_64x64_three_pass_reused", seed), + &coefficients, + |b, coefficients| { + b.iter(|| { + let encoded = encode_ht_code_block_scalar_with_passes_and_workspace( + std::hint::black_box(coefficients), + width, + height, + total_bitplanes, + 3, + &mut workspace, + ) + .expect("encode three-pass HTJ2K code block with reused workspace"); + std::hint::black_box(encoded); + }); + }, + ); } group.finish(); } diff --git a/crates/j2k-native/src/image.rs b/crates/j2k-native/src/image.rs index 4fdb9b91..675fed10 100644 --- a/crates/j2k-native/src/image.rs +++ b/crates/j2k-native/src/image.rs @@ -2,7 +2,7 @@ use alloc::vec::Vec; -use crate::error::err; +use crate::error::{err, ValidationError}; use crate::j2c::{self, Header}; use crate::jp2::colr::EnumeratedColorspace; use crate::jp2::{self, DecodedImage, ImageBoxes}; @@ -123,6 +123,19 @@ pub struct Image<'a> { pub(crate) color_space: ColorSpace, } +/// Scoped region decoder that retains one parsed tile graph across calls. +/// +/// This implementation-facing session is used by bounded row decode so each +/// stripe repeats only the ROI decode work, not codestream tile parsing. +#[doc(hidden)] +pub struct PreparedRegionDecoder<'image, 'context, 'a> { + image: &'image Image<'a>, + decoder_context: &'context mut DecoderContext<'a>, + tiles: j2c::ParsedTiles<'a>, + retained_image_bytes: usize, + retained_session_bytes: usize, +} + #[derive(Clone, Copy)] pub(crate) struct ImageSource<'a> { encoded_input: &'a [u8], @@ -165,6 +178,35 @@ impl ImageProperties { } impl<'a> Image<'a> { + /// Parse and retain the tile graph for repeated region decode calls. + /// + /// # Errors + /// + /// Returns an error when tile parsing or aggregate allocation validation fails. + #[doc(hidden)] + pub fn prepare_region_decoder_with_context<'image, 'context>( + &'image self, + decoder_context: &'context mut DecoderContext<'a>, + ) -> Result> { + let retained_image_bytes = self.retained_metadata_bytes()?; + let tiles = j2c::prepare_region_tiles( + self.codestream, + &self.header, + retained_image_bytes, + decoder_context, + )?; + let retained_session_bytes = retained_image_bytes + .checked_add(tiles.metadata_owner_bytes()) + .ok_or(ValidationError::ImageTooLarge)?; + Ok(PreparedRegionDecoder { + image: self, + decoder_context, + tiles, + retained_image_bytes, + retained_session_bytes, + }) + } + pub(crate) fn from_parsed_parts( source: ImageSource<'a>, header: Header<'a>, @@ -753,7 +795,6 @@ impl<'a> Image<'a> { round_irreversible_output: bool, retained_baseline_bytes: usize, ) -> Result> { - let settings = &self.settings; let mut ht_decoder = ht_decoder; decoder_context.set_output_region(output_region); decoder_context.set_round_irreversible_output(round_irreversible_output); @@ -767,6 +808,15 @@ impl<'a> Image<'a> { decoder_context.set_output_region(None); decoder_context.set_round_irreversible_output(false); decode_result?; + self.finish_decoded_image(decoder_context, retained_baseline_bytes) + } + + fn finish_decoded_image<'ctx>( + &self, + decoder_context: &'ctx mut DecoderContext<'a>, + retained_baseline_bytes: usize, + ) -> Result> { + let settings = &self.settings; let mut decoded_image = DecodedImage { decoded_components: &mut decoder_context.tile_decode_context.channel_data, boxes: &self.boxes, @@ -799,3 +849,43 @@ impl<'a> Image<'a> { Ok(decoded_image) } } + +impl PreparedRegionDecoder<'_, '_, '_> { + /// Decode one source-coordinate region using the retained tile graph. + /// + /// # Errors + /// + /// Returns an error when the region, component precision, or codestream is invalid. + pub fn decode_region_components( + &mut self, + roi: (u32, u32, u32, u32), + ) -> Result> { + validate_roi((self.image.width(), self.image.height()), roi)?; + self.image.validate_component_plane_precision()?; + self.decoder_context.set_output_region(Some(roi)); + self.decoder_context.set_round_irreversible_output(false); + let decode_result = j2c::decode_preparsed( + &self.image.header, + self.retained_image_bytes, + &self.tiles, + self.decoder_context, + ); + self.decoder_context.set_output_region(None); + decode_result?; + let (_x, _y, width, height) = roi; + let decoded_image = self + .image + .finish_decoded_image(self.decoder_context, self.retained_session_bytes)?; + let DecodedImage { + decoded_components, + boxes: _, + } = decoded_image; + self.image + .try_borrow_component_planes_with_retained_baseline( + decoded_components.as_slice(), + decoded_components.capacity(), + (width, height), + self.retained_session_bytes, + ) + } +} diff --git a/crates/j2k-native/src/image/contract_tests.rs b/crates/j2k-native/src/image/contract_tests.rs index d2ff3639..9cd5fe53 100644 --- a/crates/j2k-native/src/image/contract_tests.rs +++ b/crates/j2k-native/src/image/contract_tests.rs @@ -4,7 +4,7 @@ use super::*; use crate::jp2::cdef::ChannelDefinitionBox; use crate::jp2::colr::{CieLab, ColorSpace as NativeColorSpace}; use crate::jp2::pclr::{PaletteBox, PaletteColumn}; -use crate::{encode, EncodeOptions}; +use crate::{encode, encode_htj2k, EncodeOptions}; use alloc::vec; fn gray_fixture() -> (Vec, Vec) { @@ -189,6 +189,68 @@ fn image_output_entrypoints_preserve_pixels_regions_and_metadata() { assert_eq!(ht_components.planes()[0].samples(), expected_samples); } +#[test] +fn prepared_region_decoder_parses_tile_graph_once_across_regions() { + let (samples, encoded) = gray_fixture(); + let image = Image::new(&encoded, &DecodeSettings::strict()).expect("fixture parses"); + let mut context = DecoderContext::default(); + crate::j2c::reset_tile_parse_calls(); + + let mut decoder = image + .prepare_region_decoder_with_context(&mut context) + .expect("prepare region decoder"); + for roi in [(0, 0, 8, 2), (0, 2, 8, 3), (0, 5, 8, 3)] { + let components = decoder + .decode_region_components(roi) + .expect("decode prepared region"); + let expected = expected_crop(&samples, roi) + .into_iter() + .map(f32::from) + .collect::>(); + assert_eq!(components.planes()[0].samples(), expected); + } + + assert_eq!(crate::j2c::tile_parse_calls(), 1); +} + +#[test] +fn prepared_region_decoder_reuses_one_htj2k_tile_graph() { + let samples = (0_u8..64).map(|value| value * 3).collect::>(); + let encoded = encode_htj2k( + &samples, + 8, + 8, + 1, + 8, + false, + &EncodeOptions { + num_decomposition_levels: 0, + reversible: true, + ..EncodeOptions::default() + }, + ) + .expect("HTJ2K fixture encodes"); + let image = Image::new(&encoded, &DecodeSettings::strict()).expect("fixture parses"); + let mut context = DecoderContext::default(); + crate::j2c::reset_tile_parse_calls(); + + let mut decoder = image + .prepare_region_decoder_with_context(&mut context) + .expect("prepare HTJ2K region decoder"); + for roi in [(0, 0, 8, 1), (0, 1, 8, 4), (0, 5, 8, 3)] { + let components = decoder + .decode_region_components(roi) + .expect("decode prepared HTJ2K region"); + let expected = expected_crop(&samples, roi) + .into_iter() + .map(f32::from) + .collect::>(); + assert_eq!(components.planes()[0].samples(), expected); + } + + assert_eq!(crate::j2c::tile_parse_calls(), 1); +} + #[test] fn direct_device_plane_reuse_rejects_each_host_postprocess_requirement() { let (_, encoded) = gray_fixture(); diff --git a/crates/j2k-native/src/image/output_api.rs b/crates/j2k-native/src/image/output_api.rs index d0095561..3ae6c7dd 100644 --- a/crates/j2k-native/src/image/output_api.rs +++ b/crates/j2k-native/src/image/output_api.rs @@ -315,6 +315,21 @@ impl<'a> Image<'a> { dimensions: (u32, u32), ) -> Result> { let retained_image_bytes = self.retained_metadata_bytes()?; + self.try_borrow_component_planes_with_retained_baseline( + components, + component_owner_capacity, + dimensions, + retained_image_bytes, + ) + } + + pub(super) fn try_borrow_component_planes_with_retained_baseline<'ctx>( + &self, + components: &'ctx [ComponentData], + component_owner_capacity: usize, + dimensions: (u32, u32), + retained_image_bytes: usize, + ) -> Result> { let mut budget = NativeOutputBudget::for_decoded_channels( retained_image_bytes, components, diff --git a/crates/j2k-native/src/j2c/bitplane_encode.rs b/crates/j2k-native/src/j2c/bitplane_encode.rs index 9c7d6985..9c0975fe 100644 --- a/crates/j2k-native/src/j2c/bitplane_encode.rs +++ b/crates/j2k-native/src/j2c/bitplane_encode.rs @@ -49,6 +49,12 @@ pub(crate) struct EncodedCodeBlock { pub(crate) ht_cleanup_length: u32, /// HTJ2K refinement segment length in bytes when this block uses HT coding. pub(crate) ht_refinement_length: u32, + /// Significance-propagation prefix length within the HT refinement segment. + pub(crate) ht_sigprop_length: u32, + /// Magnitude-refinement suffix length within the HT refinement segment. + pub(crate) ht_magref_length: u32, + /// Per-pass squared-error reductions used by HT post-compression rate control. + pub(crate) ht_distortion_deltas: [f64; 3], } #[derive(Debug, Clone, Copy)] @@ -145,6 +151,9 @@ pub(crate) fn try_encode_code_block_with_style_view( num_zero_bitplanes: total_bitplanes, ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], }); } @@ -277,6 +286,9 @@ pub(crate) fn try_encode_code_block_with_style_view( num_zero_bitplanes, ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], }) } diff --git a/crates/j2k-native/src/j2c/decode.rs b/crates/j2k-native/src/j2c/decode.rs index 1646ad4a..ff31c637 100644 --- a/crates/j2k-native/src/j2c/decode.rs +++ b/crates/j2k-native/src/j2c/decode.rs @@ -15,7 +15,7 @@ use super::idwt::IDWTOutput; use super::progression::{progression_iterator, ProgressionData}; use super::roi::{tile_intersects_output_region, RoiPlan}; use super::tag_tree::TagNode; -use super::tile::{ComponentTile, ResolutionTile, Tile}; +use super::tile::{ComponentTile, ParsedTiles, ResolutionTile, Tile}; use super::{bitplane, build, idwt, mct, segment, tile, ComponentData}; use crate::error::{ bail, ColorError, DecodeError, DecodingError, DirectPlanUnsupportedReason, Result, TileError, @@ -82,6 +82,15 @@ pub(crate) fn decode_with_capacity_retry<'a>( retained_image_bytes: usize, ctx: &mut DecoderContext<'a>, ht_decoder: &mut Option<&mut dyn HtCodeBlockDecoder>, +) -> Result<()> { + run_decode_with_capacity_retry(ctx, |ctx| { + decode(data, header, retained_image_bytes, ctx, ht_decoder) + }) +} + +fn run_decode_with_capacity_retry<'a>( + ctx: &mut DecoderContext<'a>, + mut decode: impl FnMut(&mut DecoderContext<'a>) -> Result<()>, ) -> Result<()> { ctx.storage.release_all_allocations(); let retained_component_bytes = ctx.tile_decode_context.retained_channel_bytes()?; @@ -96,11 +105,11 @@ pub(crate) fn decode_with_capacity_retry<'a>( retained_idwt_bytes, ); - let first_result = decode(data, header, retained_image_bytes, ctx, ht_decoder); + let first_result = decode(ctx); let result = ctx.retry_without_retained_scratch_on_capacity( retained_scratch_bytes, first_result, - |ctx| decode(data, header, retained_image_bytes, ctx, ht_decoder), + &mut decode, ); ctx.storage.release_all_allocations(); @@ -115,6 +124,63 @@ pub(crate) fn decode_with_capacity_retry<'a>( result } +pub(crate) fn prepare_region_tiles<'a>( + data: &'a [u8], + header: &Header<'a>, + retained_image_bytes: usize, + ctx: &mut DecoderContext<'a>, +) -> Result> { + // The parsed graph remains live for the complete region session. Start it + // from a fresh lifetime-free workspace so its baseline is exact and later + // stripe allocations can account the retained graph explicitly. + ctx.release_reusable_allocations(); + let mut reader = BitReader::new(data); + let tiles = tile::parse(&mut reader, header, retained_image_bytes)?; + if tiles.is_empty() { + bail!(TileError::Invalid); + } + Ok(tiles) +} + +pub(crate) fn decode_preparsed_with_capacity_retry<'a>( + header: &Header<'a>, + retained_image_bytes: usize, + tiles: &ParsedTiles<'a>, + ctx: &mut DecoderContext<'a>, +) -> Result<()> { + run_decode_with_capacity_retry(ctx, |ctx| { + decode_preparsed(header, retained_image_bytes, tiles, ctx) + }) +} + +fn decode_preparsed<'a>( + header: &Header<'a>, + retained_image_bytes: usize, + tiles: &ParsedTiles<'a>, + ctx: &mut DecoderContext<'a>, +) -> Result<()> { + let reused_baseline = ctx.prepare_reused_decode_baseline(retained_image_bytes)?; + let structural_workspace_bytes = reused_baseline + .parser_live + .checked_add(tiles.metadata_owner_bytes()) + .ok_or(ValidationError::ImageTooLarge)?; + if structural_workspace_bytes > crate::DEFAULT_MAX_DECODE_BYTES { + bail!(ValidationError::ImageTooLarge); + } + let profile_enabled = profile::profile_stages_enabled(); + decode_parsed_tiles( + header, + tiles, + structural_workspace_bytes, + reused_baseline, + ctx, + &mut None, + profile_enabled, + DecodeProfileTimings::default(), + profile::profile_now(profile_enabled), + ) +} + fn decode<'a>( data: &'a [u8], header: &Header<'a>, @@ -150,14 +216,41 @@ fn decode<'a>( let tiles = parsed_tiles?; profile_timings.parse_tiles_us += profile::elapsed_us(stage_start); + decode_parsed_tiles( + header, + &tiles, + tiles.structural_workspace_bytes(), + reused_baseline, + ctx, + ht_decoder, + profile_enabled, + profile_timings, + total_start, + ) +} + +#[expect( + clippy::too_many_arguments, + reason = "the parsed decode boundary keeps graph ownership, allocation baseline, adapter, and profiling state explicit" +)] +fn decode_parsed_tiles<'a>( + header: &Header<'a>, + tiles: &ParsedTiles<'a>, + structural_workspace_bytes: usize, + reused_baseline: reuse::ReusedDecodeBaseline, + ctx: &mut DecoderContext<'a>, + ht_decoder: &mut Option<&mut dyn HtCodeBlockDecoder>, + profile_enabled: bool, + mut profile_timings: DecodeProfileTimings, + total_start: Option, +) -> Result<()> { if tiles.is_empty() { bail!(TileError::Invalid); } - let retained_decode_baseline = ctx.reset( header, &tiles[0], - tiles.structural_workspace_bytes(), + structural_workspace_bytes, reused_baseline.channel_capacity, )?; let retained_decode_base_without_scratch = retained_decode_baseline diff --git a/crates/j2k-native/src/j2c/encode.rs b/crates/j2k-native/src/j2c/encode.rs index 50960d89..90fde0e3 100644 --- a/crates/j2k-native/src/j2c/encode.rs +++ b/crates/j2k-native/src/j2c/encode.rs @@ -240,6 +240,112 @@ pub fn encode( ) } +/// Encode an irreversible HTJ2K codestream using OpenHTJ2K-compatible +/// Qfactor quantization. +/// +/// Qfactor is an opt-in visual quantization profile for grayscale or +/// three-component RGB input. It changes the expounded QCD/QCC step tuples; +/// it is not a byte-rate target and is not signaled separately in the +/// codestream. +/// +/// # Errors +/// +/// Returns an error unless `qfactor` is in `1..=100`, irreversible HT block +/// coding is selected, and the input is grayscale or three-component RGB +/// with the multi-component transform enabled. +#[expect( + clippy::too_many_arguments, + reason = "this codec boundary keeps geometry, sample representation, and quantization policy explicit" +)] +pub fn encode_htj2k_with_qfactor( + pixels: &[u8], + width: u32, + height: u32, + num_components: u16, + bit_depth: u8, + signed: bool, + qfactor: u8, + options: &EncodeOptions, +) -> crate::EncodeResult> { + let mut accelerator = CpuOnlyJ2kEncodeStageAccelerator; + encode_htj2k_with_qfactor_and_accelerator( + pixels, + width, + height, + num_components, + bit_depth, + signed, + qfactor, + options, + &mut accelerator, + ) +} + +/// Accelerator-aware counterpart to [`encode_htj2k_with_qfactor`]. +#[doc(hidden)] +#[expect( + clippy::too_many_arguments, + reason = "this codec boundary keeps geometry, sample representation, quantization policy, and accelerator explicit" +)] +pub fn encode_htj2k_with_qfactor_and_accelerator( + pixels: &[u8], + width: u32, + height: u32, + num_components: u16, + bit_depth: u8, + signed: bool, + qfactor: u8, + options: &EncodeOptions, + accelerator: &mut impl J2kEncodeStageAccelerator, +) -> crate::EncodeResult> { + validate_openhtj2k_qfactor_request(qfactor, num_components, options)?; + let session = NativeEncodeSession::try_new_with_openhtj2k_qfactor( + NativeEncodeRetainedInput::none(), + qfactor, + )?; + let component_sample_info = [EncodeComponentSampleInfo { bit_depth, signed }; 3]; + encode_with_accelerator_and_component_sample_info_for_session( + pixels, + width, + height, + num_components, + bit_depth, + signed, + options, + &component_sample_info[..usize::from(num_components)], + &session, + accelerator, + ) +} + +fn validate_openhtj2k_qfactor_request( + qfactor: u8, + num_components: u16, + options: &EncodeOptions, +) -> crate::EncodeResult<()> { + if !(1..=100).contains(&qfactor) { + return Err(crate::EncodeError::InvalidInput { + what: "OpenHTJ2K Qfactor must be in 1..=100", + }); + } + if options.reversible || !options.use_ht_block_coding { + return Err(crate::EncodeError::InvalidInput { + what: "OpenHTJ2K Qfactor requires irreversible HT block coding", + }); + } + if options.guard_bits != 1 { + return Err(crate::EncodeError::InvalidInput { + what: "OpenHTJ2K Qfactor requires one quantization guard bit", + }); + } + if !matches!(num_components, 1 | 3) || (num_components == 3 && !options.use_mct) { + return Err(crate::EncodeError::InvalidInput { + what: "OpenHTJ2K Qfactor requires grayscale or three-component RGB with MCT", + }); + } + Ok(()) +} + /// Encode pixel data into a JPEG 2000 codestream using optional encode-stage hooks. /// /// Stage hooks may accelerate forward RCT, forward 5/3 DWT, Tier-1 code-block @@ -600,6 +706,16 @@ fn ht_target_coding_passes_for_options( } } +fn requested_guard_bits(options: &EncodeOptions, use_mct: bool, openhtj2k_qfactor: bool) -> u8 { + if openhtj2k_qfactor { + 1 + } else if options.reversible && !use_mct { + options.guard_bits + } else { + options.guard_bits.max(2) + } +} + enum PreparedCodeBlockCoefficients { I32(Vec), I64(Vec), diff --git a/crates/j2k-native/src/j2c/encode/multitile/plan.rs b/crates/j2k-native/src/j2c/encode/multitile/plan.rs index b1e01cdd..238c39f2 100644 --- a/crates/j2k-native/src/j2c/encode/multitile/plan.rs +++ b/crates/j2k-native/src/j2c/encode/multitile/plan.rs @@ -10,10 +10,11 @@ use super::super::tile_parts::{encoded_tile_parts_retained_bytes, EncodedTilePar use super::super::{ adjust_component_step_sizes_for_guard_delta, adjust_reversible_step_sizes_for_guard_delta, max_total_bitplanes_for_components, maximum_decomposition_levels, quantize, - reversible_guard_bits_for_marker_limit, validate_precinct_exponents_for_options, - validate_roi_encode_options_nonallocating, BlockCodingMode, EncodeComponentSampleInfo, - EncodeOptions, EncodeParams, EncodeRoiRegion, NativeEncodePipelineError, - NativeEncodePipelineResult, NativeEncodeSession, QuantStepSize, MAX_RAW_PIXEL_ENCODE_BIT_DEPTH, + requested_guard_bits, reversible_guard_bits_for_marker_limit, + validate_precinct_exponents_for_options, validate_roi_encode_options_nonallocating, + BlockCodingMode, EncodeComponentSampleInfo, EncodeOptions, EncodeParams, EncodeRoiRegion, + NativeEncodePipelineError, NativeEncodePipelineResult, NativeEncodeSession, QuantStepSize, + MAX_RAW_PIXEL_ENCODE_BIT_DEPTH, }; use super::ownership::encode_options_retained_bytes; @@ -101,7 +102,11 @@ pub(super) fn build_loop_plan( validate_precinct_exponents_for_options(request.options, num_levels) .map_err(NativeEncodePipelineError::invalid_input)?; let use_mct = request.options.use_mct && matches!(request.num_components, 3 | 4); - let requested_guard_bits = requested_guard_bits(request.options, use_mct); + let requested_guard_bits = requested_guard_bits( + request.options, + use_mct, + request.session.openhtj2k_qfactor().is_some(), + ); let high_bit_exact = request.bit_depth > MAX_RAW_PIXEL_ENCODE_BIT_DEPTH; let guard_bits = if high_bit_exact && request.options.reversible { reversible_guard_bits_for_marker_limit(request.bit_depth, num_levels, requested_guard_bits) @@ -121,6 +126,7 @@ pub(super) fn build_loop_plan( guard_bits, request.options, request.component_sample_info, + request.session, )?; if request.options.reversible && guard_delta != 0 { adjust_reversible_step_sizes_for_guard_delta(&mut step_sizes, guard_delta) @@ -256,13 +262,18 @@ fn build_final_plan_owners( use_mct: bool, guard_bits: u8, ) -> NativeEncodePipelineResult { - let guard_delta = guard_bits.saturating_sub(requested_guard_bits(request.options, use_mct)); + let guard_delta = guard_bits.saturating_sub(requested_guard_bits( + request.options, + use_mct, + request.session.openhtj2k_qfactor().is_some(), + )); let (mut step_sizes, mut component_step_sizes) = build_step_graph( request.bit_depth, num_levels, guard_bits, request.options, request.component_sample_info, + request.session, )?; if request.options.reversible && guard_delta != 0 { adjust_reversible_step_sizes_for_guard_delta(&mut step_sizes, guard_delta) @@ -326,22 +337,15 @@ fn build_final_plan_owners( }) } -fn requested_guard_bits(options: &EncodeOptions, use_mct: bool) -> u8 { - if options.reversible && !use_mct { - options.guard_bits - } else { - options.guard_bits.max(2) - } -} - fn build_step_graph( bit_depth: u8, num_levels: u8, guard_bits: u8, options: &EncodeOptions, component_sample_info: &[EncodeComponentSampleInfo], + session: &NativeEncodeSession<'_>, ) -> NativeEncodePipelineResult<(Vec, Vec>)> { - let step_sizes = try_step_sizes(bit_depth, num_levels, guard_bits, options)?; + let step_sizes = try_step_sizes(bit_depth, num_levels, guard_bits, options, 0, session)?; let outer_bytes = checked_element_bytes::>( component_sample_info.len(), "multi-tile component step owners", @@ -350,12 +354,14 @@ fn build_step_graph( component_steps .try_reserve_exact(component_sample_info.len()) .map_err(|_| host_allocation_failed("multi-tile component step owners", outer_bytes))?; - for info in component_sample_info { + for (component, info) in component_sample_info.iter().enumerate() { component_steps.push(try_step_sizes( info.bit_depth, num_levels, guard_bits, options, + component, + session, )?); } Ok((step_sizes, component_steps)) @@ -366,6 +372,8 @@ fn try_step_sizes( num_levels: u8, guard_bits: u8, options: &EncodeOptions, + component: usize, + session: &NativeEncodeSession<'_>, ) -> NativeEncodePipelineResult> { let count = usize::from(num_levels) * 3 + 1; let bytes = checked_element_bytes::(count, "multi-tile step sizes")?; @@ -373,15 +381,21 @@ fn try_step_sizes( steps .try_reserve_exact(count) .map_err(|_| host_allocation_failed("multi-tile step sizes", bytes))?; - quantize::append_step_sizes_with_irreversible_profile( - &mut steps, - bit_depth, - num_levels, - options.reversible, - guard_bits, - options.irreversible_quantization_scale, - options.irreversible_quantization_subband_scales, - ); + if let Some(qfactor) = session.openhtj2k_qfactor() { + quantize::append_openhtj2k_qfactor_step_sizes( + &mut steps, bit_depth, num_levels, qfactor, component, + )?; + } else { + quantize::append_step_sizes_with_irreversible_profile( + &mut steps, + bit_depth, + num_levels, + options.reversible, + guard_bits, + options.irreversible_quantization_scale, + options.irreversible_quantization_subband_scales, + ); + } Ok(steps) } diff --git a/crates/j2k-native/src/j2c/encode/multitile/plan/tests.rs b/crates/j2k-native/src/j2c/encode/multitile/plan/tests.rs index ae1c0d1f..d01cd06b 100644 --- a/crates/j2k-native/src/j2c/encode/multitile/plan/tests.rs +++ b/crates/j2k-native/src/j2c/encode/multitile/plan/tests.rs @@ -49,8 +49,8 @@ fn loop_plan_accepts_exact_observed_peak_and_rejects_cap_minus_one() { let discovery = session_with_cap(crate::DEFAULT_MAX_CODEC_BYTES); let discovered = build_loop_plan(&loop_request(&options, &component_info, &discovery)) .expect("discover loop plan"); - let (steps, component_steps) = - build_step_graph(8, 1, 1, &options, &component_info).expect("discover step graph"); + let (steps, component_steps) = build_step_graph(8, 1, 1, &options, &component_info, &discovery) + .expect("discover step graph"); let step_peak = step_graph_retained_bytes(&steps, &component_steps).expect("step bytes"); let exact_cap = step_peak.max(discovered.retained_bytes()); @@ -96,7 +96,7 @@ fn final_plan_accepts_exact_observed_peak_and_rejects_cap_minus_one() { }) .expect("discover final plan"); let (steps, component_steps) = - build_step_graph(8, 1, 1, &options, &[]).expect("discover step graph"); + build_step_graph(8, 1, 1, &options, &[], &discovery).expect("discover step graph"); let exact_cap = step_graph_retained_bytes(&steps, &component_steps).expect("step bytes") + encode_params_retained_bytes(&discovered.params).expect("parameter bytes") + discovered.quant_params.capacity() * core::mem::size_of::<(u16, u16)>(); diff --git a/crates/j2k-native/src/j2c/encode/packet_plan/accelerator_metadata/tests.rs b/crates/j2k-native/src/j2c/encode/packet_plan/accelerator_metadata/tests.rs index eeca98cb..6b853590 100644 --- a/crates/j2k-native/src/j2c/encode/packet_plan/accelerator_metadata/tests.rs +++ b/crates/j2k-native/src/j2c/encode/packet_plan/accelerator_metadata/tests.rs @@ -21,6 +21,9 @@ fn packet_fixture() -> Vec { data, ht_cleanup_length: 2, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: 1, classic_segment_lengths: Vec::new(), num_zero_bitplanes: 0, diff --git a/crates/j2k-native/src/j2c/encode/prepared_packets/layered.rs b/crates/j2k-native/src/j2c/encode/prepared_packets/layered.rs index a40e68ef..5d607be9 100644 --- a/crates/j2k-native/src/j2c/encode/prepared_packets/layered.rs +++ b/crates/j2k-native/src/j2c/encode/prepared_packets/layered.rs @@ -2,6 +2,7 @@ //! Fallible multi-layer Tier-1 and rate-control state machine. +use super::super::rate_control::classic_rate_target_tolerance; use super::super::tier1_allocation::{prepared_packets_ownership, Tier1PhaseTracker}; use super::super::{ EncodeProgressionOrder, J2kEncodeStageAccelerator, J2kPacketizationPacketDescriptor, @@ -70,12 +71,24 @@ fn encode_prepared_resolution_packets_layered_accounted( let source_bytes = source.total()?; let packet_count = prepared_packets.len(); let mut tracker = Tier1PhaseTracker::new(session, retained_base_bytes); + let selected_ht_candidates = if let Some(&target) = quality_layer_byte_targets.last() { + ht::try_select_tile_ht_candidates( + &prepared_packets, + target.saturating_add(classic_rate_target_tolerance(target)), + source_bytes, + &mut tracker, + accelerator, + )? + } else { + Vec::new() + }; + let mut rate_control = + LayeredRateControlState::try_with_selected_ht_candidates(selected_ht_candidates)?; let (mut layered_packets, _) = tracker.try_vec::( packet_count, - [source_bytes], + [source_bytes, rate_control.owner_bytes()?], "layered packet owners", )?; - let mut rate_control = LayeredRateControlState::default(); for prepared_packet in prepared_packets { append_layered_prepared_packet( @@ -93,6 +106,7 @@ fn encode_prepared_resolution_packets_layered_accounted( accelerator, )?; } + rate_control.ensure_selected_ht_candidates_consumed()?; let layered_packet_capacity = layered_packets.capacity(); apply_budget_assignments( diff --git a/crates/j2k-native/src/j2c/encode/prepared_packets/layered/ht.rs b/crates/j2k-native/src/j2c/encode/prepared_packets/layered/ht.rs index bd3b696a..4c67d114 100644 --- a/crates/j2k-native/src/j2c/encode/prepared_packets/layered/ht.rs +++ b/crates/j2k-native/src/j2c/encode/prepared_packets/layered/ht.rs @@ -15,6 +15,7 @@ use super::state::LayeredRateControlState; mod prepare; use prepare::try_encode_layered_ht_output; +pub(super) use prepare::try_select_tile_ht_candidates; #[derive(Clone, Copy)] pub(super) struct LayeredHtContext<'a, 'session> { @@ -55,7 +56,7 @@ pub(super) fn encode_layered_ht_subband( let mut output = try_encode_layered_ht_output( subband, layered_subband, - rate_control.owner_bytes()?, + rate_control, context, tracker, accelerator, @@ -68,6 +69,9 @@ pub(super) fn encode_layered_ht_subband( num_zero_bitplanes: block.num_zero_bitplanes, ht_cleanup_length: block.ht_cleanup_length, ht_refinement_length: block.ht_refinement_length, + ht_sigprop_length: block.ht_sigprop_length, + ht_magref_length: block.ht_magref_length, + ht_distortion_deltas: block.ht_distortion_deltas, }; let current_payload_bytes = encoded.data.capacity(); let other_ht_payload_bytes = output @@ -194,6 +198,7 @@ fn ht_segment_layers( block_index: rate_control.ht_block_index, segment_index: segment_idx, rate: ht_segment_rate(encoded, segment_idx)?, + distortion_delta: encoded.ht_distortion_deltas[segment_idx], }); rate_control.ht_locations.push(HtSegmentLocation { packet_idx: context.packet_idx, diff --git a/crates/j2k-native/src/j2c/encode/prepared_packets/layered/ht/prepare.rs b/crates/j2k-native/src/j2c/encode/prepared_packets/layered/ht/prepare.rs index b62da59b..4cac1d03 100644 --- a/crates/j2k-native/src/j2c/encode/prepared_packets/layered/ht/prepare.rs +++ b/crates/j2k-native/src/j2c/encode/prepared_packets/layered/ht/prepare.rs @@ -7,13 +7,18 @@ use super::super::super::super::tier1_allocation::{ prepared_subbands_ownership, subband_precincts_ownership, Tier1PhaseTracker, }; use super::super::super::super::{ - encode_prepared_subbands_for_session, CodeBlockPacketData, J2kEncodeStageAccelerator, - LayeredPreparedSubband, NativeEncodePipelineError, NativeEncodePipelineResult, - PreparedEncodeSubband, Vec, + encode_prepared_subbands_for_session, ht_block_encode, BlockCodingMode, CodeBlockPacketData, + J2kEncodeStageAccelerator, LayeredPreparedSubband, NativeEncodePipelineError, + NativeEncodePipelineResult, PreparedCodeBlockCoefficients, PreparedEncodeSubband, Vec, }; use super::super::ownership::{checked_sum, layered_block_build_owner_bytes}; +use super::super::state::LayeredRateControlState; use super::LayeredHtContext; +mod tile; +use tile::eligible_subband; +pub(in crate::j2c::encode::prepared_packets::layered) use tile::try_select_tile_ht_candidates; + pub(super) struct LayeredHtOutput { pub(super) blocks: Vec, pub(super) structural_bytes: usize, @@ -21,14 +26,22 @@ pub(super) struct LayeredHtOutput { pub(super) other_source_bytes: usize, } +#[derive(Clone, Copy)] +pub(super) struct CandidateOwnerBytes { + layered_live: usize, + subband: usize, + structural: usize, +} + pub(super) fn try_encode_layered_ht_output( subband: PreparedEncodeSubband, layered_subband: &LayeredPreparedSubband, - rate_control_owner_bytes: usize, + rate_control: &mut LayeredRateControlState, context: LayeredHtContext<'_, '_>, tracker: &mut Tier1PhaseTracker<'_, '_>, accelerator: &mut impl J2kEncodeStageAccelerator, ) -> NativeEncodePipelineResult { + let rate_control_owner_bytes = rate_control.owner_bytes()?; let subband_bytes = prepared_subbands_ownership(core::slice::from_ref(&subband), 0)?.total()?; let other_source_bytes = context.source_bytes.checked_sub(subband_bytes).ok_or( crate::EncodeError::InternalInvariant { @@ -46,6 +59,16 @@ pub(super) fn try_encode_layered_ht_output( [layered_owners, rate_control_owner_bytes], "layered HT subband owners", )?; + if !context.quality_layer_byte_targets.is_empty() && eligible_subband(&subband) { + return try_encode_bounded_ht_output( + subband, + subband_bytes, + other_source_bytes, + layered_live, + rate_control, + tracker, + ); + } let (mut one_subband, _) = tracker.try_vec::( 1, [layered_live], @@ -90,3 +113,222 @@ pub(super) fn try_encode_layered_ht_output( other_source_bytes, }) } + +fn try_encode_bounded_ht_output( + subband: PreparedEncodeSubband, + subband_bytes: usize, + other_source_bytes: usize, + layered_live: usize, + rate_control: &mut LayeredRateControlState, + tracker: &mut Tier1PhaseTracker<'_, '_>, +) -> NativeEncodePipelineResult { + let block_count = subband.code_blocks.len(); + let (mut blocks, structural_bytes) = tracker.try_vec::( + block_count, + [layered_live, subband_bytes], + "bounded HT candidate output owners", + )?; + let mut remaining_payload_bytes = 0usize; + for _block in subband.code_blocks { + let selected = rate_control.take_selected_ht_candidate()?; + let payload_bytes = selected.data.capacity(); + let packet_block = packet_block_from_ht_candidate(selected); + remaining_payload_bytes = checked_add_bytes( + remaining_payload_bytes, + payload_bytes, + "bounded HT selected payload", + )?; + blocks.push(packet_block); + } + Ok(LayeredHtOutput { + blocks, + structural_bytes, + remaining_payload_bytes, + other_source_bytes, + }) +} + +pub(super) fn try_accelerated_candidate_outputs( + subband: &PreparedEncodeSubband, + cleanup_bitplanes: &[u8], + owners: CandidateOwnerBytes, + tracker: &mut Tier1PhaseTracker<'_, '_>, + accelerator: &mut impl J2kEncodeStageAccelerator, +) -> NativeEncodePipelineResult>> { + let job_count = subband + .code_blocks + .len() + .checked_mul(cleanup_bitplanes.len()) + .ok_or(crate::EncodeError::ArithmeticOverflow { + what: "bounded HT candidate job count", + })?; + let (mut jobs, job_bytes) = tracker.try_vec::>( + job_count, + [owners.layered_live, owners.subband, owners.structural], + "bounded HT candidate job descriptors", + )?; + for block in &subband.code_blocks { + let PreparedCodeBlockCoefficients::I32(coefficients) = &block.coefficients else { + return Err(NativeEncodePipelineError::internal_invariant( + "bounded HT candidates require i32 coefficients", + )); + }; + for &cleanup_bitplane in cleanup_bitplanes { + jobs.push(crate::J2kHtCodeBlockSetEncodeJob { + coefficients, + width: block.width, + height: block.height, + total_bitplanes: subband.total_bitplanes, + cleanup_bitplane, + target_coding_passes: if cleanup_bitplane == 0 { 1 } else { 3 }, + }); + } + } + tracker.check( + [ + owners.layered_live, + owners.subband, + owners.structural, + job_bytes, + ], + "bounded HT accelerator candidate jobs", + )?; + let outputs = accelerator + .encode_ht_code_block_sets(&jobs) + .map_err(|source| crate::EncodeError::Accelerator { + operation: "HT Tier-1 candidate-set batch encode", + source, + })?; + if outputs + .as_ref() + .is_some_and(|values| values.len() != job_count) + { + return Err(candidate_accelerator_error( + "accelerated HT candidate-set batch length mismatch", + )); + } + Ok(outputs.map(Vec::into_iter)) +} + +fn packet_block_from_ht_candidate( + selected: super::super::super::super::bitplane_encode::EncodedCodeBlock, +) -> CodeBlockPacketData { + CodeBlockPacketData { + data: selected.data, + ht_cleanup_length: selected.ht_cleanup_length, + ht_refinement_length: selected.ht_refinement_length, + ht_sigprop_length: selected.ht_sigprop_length, + ht_magref_length: selected.ht_magref_length, + ht_distortion_deltas: selected.ht_distortion_deltas, + num_coding_passes: selected.num_coding_passes, + classic_segment_lengths: Vec::new(), + num_zero_bitplanes: selected.num_zero_bitplanes, + previously_included: false, + l_block: 3, + block_coding_mode: BlockCodingMode::HighThroughput, + } +} + +pub(super) fn accelerated_candidates_for_block( + outputs: &mut alloc::vec::IntoIter, + coefficients: &[i32], + width: u32, + height: u32, + total_bitplanes: u8, + cleanup_bitplanes: &[u8], +) -> NativeEncodePipelineResult> +{ + let mut candidates = Vec::new(); + candidates + .try_reserve_exact(cleanup_bitplanes.len()) + .map_err(|_| crate::EncodeError::HostAllocationFailed { + what: "accelerated HT candidate owners", + bytes: cleanup_bitplanes.len().saturating_mul(core::mem::size_of::< + super::super::super::super::bitplane_encode::EncodedCodeBlock, + >()), + })?; + for &cleanup_bitplane in cleanup_bitplanes { + let output = outputs.next().ok_or_else(|| { + NativeEncodePipelineError::internal_invariant("accelerated HT candidate is missing") + })?; + candidates.push(validated_accelerated_candidate( + output, + coefficients, + width, + height, + total_bitplanes, + cleanup_bitplane, + )?); + } + Ok(candidates) +} + +fn validated_accelerated_candidate( + output: crate::EncodedHtJ2kCodeBlockSet, + coefficients: &[i32], + width: u32, + height: u32, + total_bitplanes: u8, + cleanup_bitplane: u8, +) -> NativeEncodePipelineResult { + let expected_passes = if cleanup_bitplane == 0 { 1 } else { 3 }; + let refinement_length = output + .sigprop_length + .checked_add(output.magref_length) + .ok_or_else(|| candidate_accelerator_error("HT candidate refinement length overflow"))?; + let expected_length = output + .cleanup_length + .checked_add(refinement_length) + .and_then(|length| usize::try_from(length).ok()) + .ok_or_else(|| candidate_accelerator_error("HT candidate payload length overflow"))?; + let input_is_zero = coefficients.iter().all(|coefficient| *coefficient == 0); + let valid_empty = output.num_coding_passes == 0 + && input_is_zero + && output.data.is_empty() + && output.cleanup_length == 0 + && refinement_length == 0 + && output.num_zero_bitplanes == total_bitplanes; + let expected_missing = total_bitplanes - cleanup_bitplane - 1; + let valid_nonempty = output.num_coding_passes == expected_passes + && !input_is_zero + && output.data.len() == expected_length + && output.cleanup_length > 0 + && output.num_zero_bitplanes == expected_missing + && (expected_passes == 1 || refinement_length > 0); + if !valid_empty && !valid_nonempty { + return Err(candidate_accelerator_error( + "accelerated HT candidate metadata mismatch", + )); + } + let distortion = if valid_empty { + [0.0; 3] + } else { + ht_block_encode::code_block_set_distortion_deltas( + coefficients, + width, + height, + cleanup_bitplane, + expected_passes, + )? + }; + Ok( + super::super::super::super::bitplane_encode::EncodedCodeBlock { + data: output.data, + num_coding_passes: output.num_coding_passes, + num_zero_bitplanes: output.num_zero_bitplanes, + ht_cleanup_length: output.cleanup_length, + ht_refinement_length: refinement_length, + ht_sigprop_length: output.sigprop_length, + ht_magref_length: output.magref_length, + ht_distortion_deltas: distortion, + }, + ) +} + +fn candidate_accelerator_error(detail: &'static str) -> NativeEncodePipelineError { + crate::EncodeError::Accelerator { + operation: "HT Tier-1 candidate-set batch encode", + source: crate::J2kEncodeStageError::internal_invariant(detail), + } + .into() +} diff --git a/crates/j2k-native/src/j2c/encode/prepared_packets/layered/ht/prepare/tile.rs b/crates/j2k-native/src/j2c/encode/prepared_packets/layered/ht/prepare/tile.rs new file mode 100644 index 00000000..380c0013 --- /dev/null +++ b/crates/j2k-native/src/j2c/encode/prepared_packets/layered/ht/prepare/tile.rs @@ -0,0 +1,267 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Whole-tile HT candidate generation and final-set selection. + +use super::{ + accelerated_candidates_for_block, try_accelerated_candidate_outputs, CandidateOwnerBytes, +}; +use crate::j2c::bitplane_encode::EncodedCodeBlock; +use crate::j2c::encode::allocation::{checked_add_bytes, checked_element_bytes}; +use crate::j2c::encode::tier1_allocation::Tier1PhaseTracker; +use crate::j2c::encode::{ + ht_block_encode, BlockCodingMode, J2kEncodeStageAccelerator, NativeEncodePipelineError, + NativeEncodePipelineResult, PreparedCodeBlockCoefficients, PreparedEncodeSubband, + PreparedResolutionPacket, Vec, +}; + +struct TileCandidates { + blocks: Vec, + ranges: Vec, + block_structural_bytes: usize, + range_bytes: usize, + payload_bytes: usize, +} + +pub(in crate::j2c::encode::prepared_packets::layered) fn try_select_tile_ht_candidates( + packets: &[PreparedResolutionPacket], + final_target: u64, + source_bytes: usize, + tracker: &mut Tier1PhaseTracker<'_, '_>, + accelerator: &mut impl J2kEncodeStageAccelerator, +) -> NativeEncodePipelineResult> { + let (block_count, candidate_count) = tile_candidate_counts(packets)?; + if block_count == 0 { + return Ok(Vec::new()); + } + let inventory = try_generate_tile_candidates( + packets, + block_count, + candidate_count, + source_bytes, + tracker, + accelerator, + )?; + let selector_workspace = + ht_block_encode::tile_candidate_selection_workspace_bytes(candidate_count, block_count)?; + tracker.check( + [ + source_bytes, + inventory.block_structural_bytes, + inventory.range_bytes, + inventory.payload_bytes, + selector_workspace, + ht_block_encode::HtEncodeWorkspace::ALLOCATION_BYTES, + ], + "whole-tile HT candidate selection", + )?; + let selections = ht_block_encode::select_tile_code_block_candidates( + &inventory.blocks, + &inventory.ranges, + final_target, + )?; + if selections.len() != block_count { + return Err(NativeEncodePipelineError::internal_invariant( + "whole-tile HT selection count mismatch", + )); + } + try_materialize_tile_selection(inventory, selections, block_count, source_bytes, tracker) +} + +fn try_generate_tile_candidates( + packets: &[PreparedResolutionPacket], + block_count: usize, + candidate_count: usize, + source_bytes: usize, + tracker: &mut Tier1PhaseTracker<'_, '_>, + accelerator: &mut impl J2kEncodeStageAccelerator, +) -> NativeEncodePipelineResult { + let (mut candidates, candidate_structural_bytes) = tracker.try_vec::( + candidate_count, + [source_bytes], + "whole-tile HT candidate owners", + )?; + let (mut ranges, range_bytes) = tracker.try_vec::( + block_count, + [source_bytes, candidate_structural_bytes], + "whole-tile HT candidate ranges", + )?; + let mut workspace = ht_block_encode::HtEncodeWorkspace::try_new()?; + let mut candidate_payload_bytes = 0usize; + + for packet in packets { + for subband in &packet.subbands { + if !eligible_subband(subband) { + continue; + } + let cleanup_bitplanes = + ht_block_encode::candidate_cleanup_bitplanes(subband.total_bitplanes)?; + let mut accelerated = try_accelerated_candidate_outputs( + subband, + cleanup_bitplanes, + CandidateOwnerBytes { + layered_live: source_bytes, + subband: candidate_structural_bytes, + structural: range_bytes, + }, + tracker, + accelerator, + )?; + for block in &subband.code_blocks { + let PreparedCodeBlockCoefficients::I32(coefficients) = &block.coefficients else { + return Err(NativeEncodePipelineError::internal_invariant( + "bounded HT candidates require i32 coefficients", + )); + }; + let block_candidates = if let Some(outputs) = accelerated.as_mut() { + accelerated_candidates_for_block( + outputs, + coefficients, + block.width, + block.height, + subband.total_bitplanes, + cleanup_bitplanes, + )? + } else { + ht_block_encode::try_encode_code_block_candidate_sets_with_workspace( + coefficients, + block.width, + block.height, + subband.total_bitplanes, + &mut workspace, + )? + }; + let start = candidates.len(); + for candidate in block_candidates { + candidate_payload_bytes = checked_add_bytes( + candidate_payload_bytes, + candidate.data.capacity(), + "whole-tile HT candidate payload", + )?; + candidates.push(candidate); + } + ranges.push(ht_block_encode::HtCandidateRange { + start, + len: candidates.len() - start, + }); + } + if accelerated + .as_mut() + .is_some_and(|outputs| outputs.next().is_some()) + { + return Err(NativeEncodePipelineError::internal_invariant( + "accelerated HT candidate batch has trailing outputs", + )); + } + } + } + if candidates.len() != candidate_count || ranges.len() != block_count { + return Err(NativeEncodePipelineError::internal_invariant( + "whole-tile HT candidate count mismatch", + )); + } + Ok(TileCandidates { + blocks: candidates, + ranges, + block_structural_bytes: candidate_structural_bytes, + range_bytes, + payload_bytes: candidate_payload_bytes, + }) +} + +fn try_materialize_tile_selection( + inventory: TileCandidates, + selections: Vec, + block_count: usize, + source_bytes: usize, + tracker: &mut Tier1PhaseTracker<'_, '_>, +) -> NativeEncodePipelineResult> { + let selection_bytes = checked_element_bytes::( + selections.capacity(), + "whole-tile HT selections", + )?; + let (mut selected, selected_structural_bytes) = tracker.try_vec::( + block_count, + [ + source_bytes, + inventory.block_structural_bytes, + inventory.range_bytes, + inventory.payload_bytes, + selection_bytes, + ], + "whole-tile selected HT blocks", + )?; + let mut selections = selections.into_iter().peekable(); + for (candidate_index, mut candidate) in inventory.blocks.into_iter().enumerate() { + let Some(selection) = selections.peek().copied() else { + break; + }; + if selection.candidate_index != candidate_index { + continue; + } + selections.next(); + if selection.num_coding_passes != 0 { + candidate = ht_block_encode::truncate_code_block_candidate( + candidate, + selection.num_coding_passes, + )?; + } + selected.push(candidate); + } + if selections.next().is_some() || selected.len() != block_count { + return Err(NativeEncodePipelineError::internal_invariant( + "whole-tile HT selected candidate is missing", + )); + } + tracker.check( + [source_bytes, selected_structural_bytes] + .into_iter() + .chain(selected.iter().map(|candidate| candidate.data.capacity())), + "whole-tile selected HT candidate owners", + )?; + selected.reverse(); + Ok(selected) +} + +pub(super) fn eligible_subband(subband: &PreparedEncodeSubband) -> bool { + subband.block_coding_mode == BlockCodingMode::HighThroughput + && subband.preencoded_ht_code_blocks.is_none() + && subband + .code_blocks + .iter() + .all(|block| matches!(block.coefficients, PreparedCodeBlockCoefficients::I32(_))) +} + +fn tile_candidate_counts( + packets: &[PreparedResolutionPacket], +) -> NativeEncodePipelineResult<(usize, usize)> { + let mut block_count = 0usize; + let mut candidate_count = 0usize; + for packet in packets { + for subband in &packet.subbands { + if !eligible_subband(subband) { + continue; + } + let candidates_per_block = + ht_block_encode::candidate_cleanup_bitplanes(subband.total_bitplanes)?.len(); + block_count = block_count.checked_add(subband.code_blocks.len()).ok_or( + crate::EncodeError::ArithmeticOverflow { + what: "whole-tile HT candidate block count", + }, + )?; + candidate_count = candidate_count + .checked_add( + subband + .code_blocks + .len() + .checked_mul(candidates_per_block) + .ok_or(crate::EncodeError::ArithmeticOverflow { + what: "whole-tile HT candidate count", + })?, + ) + .ok_or(crate::EncodeError::ArithmeticOverflow { + what: "whole-tile HT candidate count", + })?; + } + } + Ok((block_count, candidate_count)) +} diff --git a/crates/j2k-native/src/j2c/encode/prepared_packets/layered/state.rs b/crates/j2k-native/src/j2c/encode/prepared_packets/layered/state.rs index 32a9515a..03d50a3a 100644 --- a/crates/j2k-native/src/j2c/encode/prepared_packets/layered/state.rs +++ b/crates/j2k-native/src/j2c/encode/prepared_packets/layered/state.rs @@ -2,9 +2,11 @@ //! Owned candidate/location state retained only through budget assignment. +use super::super::super::allocation::checked_element_bytes; use super::super::super::{ - ClassicSegmentAssignmentCandidate, ClassicSegmentLocation, HtSegmentAssignmentCandidate, - HtSegmentLocation, Vec, + bitplane_encode, ClassicSegmentAssignmentCandidate, ClassicSegmentLocation, + HtSegmentAssignmentCandidate, HtSegmentLocation, NativeEncodePipelineError, + NativeEncodePipelineResult, Vec, }; use super::ownership::checked_sum; @@ -20,16 +22,71 @@ pub(super) struct LayeredRateControlState { pub(super) ht_locations: Vec, pub(super) ht_location_bytes: usize, pub(super) ht_block_index: usize, + selected_ht_candidates: Vec, + selected_ht_payload_bytes: usize, } impl LayeredRateControlState { + pub(super) fn try_with_selected_ht_candidates( + selected_ht_candidates: Vec, + ) -> NativeEncodePipelineResult { + let selected_ht_payload_bytes = + selected_ht_candidates + .iter() + .try_fold(0usize, |total, candidate| { + total.checked_add(candidate.data.capacity()).ok_or( + crate::EncodeError::ArithmeticOverflow { + what: "selected HT candidate payload ownership", + }, + ) + })?; + Ok(Self { + selected_ht_candidates, + selected_ht_payload_bytes, + ..Self::default() + }) + } + + pub(super) fn take_selected_ht_candidate( + &mut self, + ) -> NativeEncodePipelineResult { + let candidate = self.selected_ht_candidates.pop().ok_or_else(|| { + NativeEncodePipelineError::internal_invariant( + "whole-tile selected HT candidate is missing", + ) + })?; + self.selected_ht_payload_bytes = self + .selected_ht_payload_bytes + .checked_sub(candidate.data.capacity()) + .ok_or(crate::EncodeError::InternalInvariant { + what: "selected HT candidate payload ownership underflowed", + })?; + Ok(candidate) + } + + pub(super) fn ensure_selected_ht_candidates_consumed(&self) -> NativeEncodePipelineResult<()> { + if self.selected_ht_candidates.is_empty() && self.selected_ht_payload_bytes == 0 { + Ok(()) + } else { + Err(NativeEncodePipelineError::internal_invariant( + "whole-tile selected HT candidates were not consumed", + )) + } + } + pub(super) fn owner_bytes(&self) -> Result { + let selected_structural = checked_element_bytes::( + self.selected_ht_candidates.capacity(), + "selected HT candidate owners", + )?; checked_sum( [ self.classic_candidate_bytes, self.classic_location_bytes, self.ht_candidate_bytes, self.ht_location_bytes, + selected_structural, + self.selected_ht_payload_bytes, ], "layered rate-control owners", ) diff --git a/crates/j2k-native/src/j2c/encode/rate_control.rs b/crates/j2k-native/src/j2c/encode/rate_control.rs index d7511156..9be5bb86 100644 --- a/crates/j2k-native/src/j2c/encode/rate_control.rs +++ b/crates/j2k-native/src/j2c/encode/rate_control.rs @@ -73,11 +73,12 @@ pub(super) struct ClassicSegmentLocation { pub(super) segment_idx: usize, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub(super) struct HtSegmentAssignmentCandidate { pub(super) block_index: usize, pub(super) segment_index: usize, pub(super) rate: u64, + pub(super) distortion_delta: f64, } #[derive(Debug, Clone, Copy)] @@ -182,6 +183,6 @@ impl ClassicLayerBudgetAllocator { } } -fn classic_rate_target_tolerance(target: u64) -> u64 { +pub(super) fn classic_rate_target_tolerance(target: u64) -> u64 { (target / 100).max(512) } diff --git a/crates/j2k-native/src/j2c/encode/rate_control/assignment.rs b/crates/j2k-native/src/j2c/encode/rate_control/assignment.rs index 542fec73..20c2d8ec 100644 --- a/crates/j2k-native/src/j2c/encode/rate_control/assignment.rs +++ b/crates/j2k-native/src/j2c/encode/rate_control/assignment.rs @@ -2,7 +2,8 @@ //! Shared ordering and monotonicity rules for layer assignment. -use super::super::Ordering; +use super::super::{NativeEncodePipelineError, NativeEncodePipelineResult, Ordering}; +use super::HtSegmentAssignmentCandidate; use super::{ClassicSegmentAssignmentCandidate, LayeredPreparedBlock, LayeredPreparedPacket}; mod accounted; @@ -16,6 +17,31 @@ pub(in crate::j2c::encode) use legacy::{ assign_classic_segment_layers_by_slope, assign_ht_segment_layers_by_budget, }; +fn ordered_candidate_block_count( + positions: impl IntoIterator, + invalid_order: &'static str, +) -> NativeEncodePipelineResult { + let mut previous: Option<(usize, usize)> = None; + for position @ (block_index, segment_index) in positions { + let valid = match previous { + None => segment_index == 0, + Some((previous_block, previous_segment)) if block_index == previous_block => { + previous_segment.checked_add(1) == Some(segment_index) + } + Some((previous_block, _)) => block_index > previous_block && segment_index == 0, + }; + if !valid { + return Err(NativeEncodePipelineError::internal_invariant(invalid_order)); + } + previous = Some(position); + } + previous.map_or(Ok(0), |(block_index, _)| { + block_index.checked_add(1).ok_or_else(|| { + NativeEncodePipelineError::arithmetic_overflow("PCRD candidate block count") + }) + }) +} + fn compare_classic_segment_candidates( candidates: &[ClassicSegmentAssignmentCandidate], left: usize, @@ -23,8 +49,32 @@ fn compare_classic_segment_candidates( ) -> Ordering { let left_candidate = candidates[left]; let right_candidate = candidates[right]; - pcrd_slope(right_candidate) - .partial_cmp(&pcrd_slope(left_candidate)) + pcrd_slope(right_candidate.rate, right_candidate.distortion_delta) + .partial_cmp(&pcrd_slope( + left_candidate.rate, + left_candidate.distortion_delta, + )) + .unwrap_or(Ordering::Equal) + .then_with(|| left_candidate.block_index.cmp(&right_candidate.block_index)) + .then_with(|| { + left_candidate + .segment_index + .cmp(&right_candidate.segment_index) + }) +} + +fn compare_ht_segment_candidates( + candidates: &[HtSegmentAssignmentCandidate], + left: usize, + right: usize, +) -> Ordering { + let left_candidate = candidates[left]; + let right_candidate = candidates[right]; + pcrd_slope(right_candidate.rate, right_candidate.distortion_delta) + .partial_cmp(&pcrd_slope( + left_candidate.rate, + left_candidate.distortion_delta, + )) .unwrap_or(Ordering::Equal) .then_with(|| left_candidate.block_index.cmp(&right_candidate.block_index)) .then_with(|| { @@ -36,13 +86,13 @@ fn compare_classic_segment_candidates( #[expect( clippy::cast_precision_loss, - reason = "the codec float domain intentionally receives bounded integer samples or metadata at this rounding boundary" + reason = "PCRD rates are bounded payload sizes and f64 provides stable ordering for the supported block limits" )] -fn pcrd_slope(candidate: ClassicSegmentAssignmentCandidate) -> f64 { - if candidate.rate == 0 { +fn pcrd_slope(rate: u64, distortion_delta: f64) -> f64 { + if rate == 0 { return f64::INFINITY; } - candidate.distortion_delta / candidate.rate as f64 + distortion_delta / rate as f64 } fn enforce_classic_assignment_monotonicity( @@ -65,6 +115,26 @@ fn enforce_classic_assignment_monotonicity( } } +fn enforce_ht_assignment_monotonicity( + candidates: &[HtSegmentAssignmentCandidate], + assignments: &mut [usize], +) { + for candidate_idx in 0..candidates.len() { + let candidate = candidates[candidate_idx]; + let min_layer = candidates + .iter() + .enumerate() + .filter(|(_, prior)| { + prior.block_index == candidate.block_index + && prior.segment_index <= candidate.segment_index + }) + .map(|(prior_idx, _)| assignments[prior_idx]) + .max() + .unwrap_or(0); + assignments[candidate_idx] = assignments[candidate_idx].max(min_layer); + } +} + pub(in crate::j2c::encode) fn enforce_classic_segment_layer_monotonicity( layered_packets: &mut [LayeredPreparedPacket], ) { diff --git a/crates/j2k-native/src/j2c/encode/rate_control/assignment/accounted.rs b/crates/j2k-native/src/j2c/encode/rate_control/assignment/accounted.rs index 57e567b0..c2134eff 100644 --- a/crates/j2k-native/src/j2c/encode/rate_control/assignment/accounted.rs +++ b/crates/j2k-native/src/j2c/encode/rate_control/assignment/accounted.rs @@ -8,6 +8,10 @@ use super::super::super::{NativeEncodePipelineError, NativeEncodePipelineResult, use super::super::{ classic_rate_target_tolerance, ClassicLayerBudgetAllocator, HtSegmentAssignmentCandidate, }; +use super::{ + compare_ht_segment_candidates, enforce_ht_assignment_monotonicity, + ordered_candidate_block_count, +}; mod classic; pub(in crate::j2c::encode) use classic::assign_classic_segment_layers_by_slope_accounted; @@ -15,7 +19,7 @@ pub(in crate::j2c::encode) use classic::assign_classic_segment_layers_by_slope_a struct HtAssignmentWorkspace { allocator: ClassicLayerBudgetAllocator, assignments: Vec, - candidate_order: Vec, + block_frontiers: Vec>, block_min_layers: Vec, } @@ -27,7 +31,7 @@ pub(in crate::j2c::encode) fn assign_ht_segment_layers_by_budget_accounted( tracker: &mut Tier1PhaseTracker<'_, '_>, retained_live_bytes: usize, ) -> NativeEncodePipelineResult> { - validate_ht_assignment_inputs( + let block_count = validate_ht_assignment_inputs( candidates, candidate_capacity, layer_count, @@ -38,13 +42,22 @@ pub(in crate::j2c::encode) fn assign_ht_segment_layers_by_budget_accounted( candidate_capacity, layer_count, cumulative_targets, + block_count, tracker, retained_live_bytes, )?; - for candidate_idx in workspace.candidate_order { - let candidate = candidates.get(candidate_idx).ok_or_else(|| { - NativeEncodePipelineError::internal_invariant("HTJ2K segment candidate index mismatch") - })?; + for _ in 0..candidates.len() { + let candidate_idx = workspace + .block_frontiers + .iter() + .filter_map(|candidate| *candidate) + .min_by(|&left, &right| compare_ht_segment_candidates(candidates, left, right)) + .ok_or_else(|| { + NativeEncodePipelineError::internal_invariant( + "HTJ2K PCRD candidate queue underflow", + ) + })?; + let candidate = candidates[candidate_idx]; let min_layer = *workspace .block_min_layers .get(candidate.block_index) @@ -58,18 +71,15 @@ pub(in crate::j2c::encode) fn assign_ht_segment_layers_by_budget_accounted( .assign_segment(min_layer, candidate.rate) .map_err(NativeEncodePipelineError::arithmetic_overflow)?; workspace.assignments[candidate_idx] = layer; - workspace.block_min_layers[candidate.block_index] = layer; - } - for (candidate, assignment) in candidates.iter().zip(&mut workspace.assignments) { - *assignment = *workspace - .block_min_layers - .get(candidate.block_index) - .ok_or_else(|| { - NativeEncodePipelineError::internal_invariant( - "HTJ2K segment candidate block index mismatch", - ) - })?; + let block_index = candidate.block_index; + workspace.block_min_layers[block_index] = layer; + workspace.block_frontiers[block_index] = candidate_idx.checked_add(1).filter(|&next| { + candidates + .get(next) + .is_some_and(|candidate| candidate.block_index == block_index) + }); } + enforce_ht_assignment_monotonicity(candidates, &mut workspace.assignments); Ok(workspace.assignments) } @@ -78,7 +88,7 @@ fn validate_ht_assignment_inputs( candidate_capacity: usize, layer_count: usize, cumulative_targets: &[u64], -) -> NativeEncodePipelineResult<()> { +) -> NativeEncodePipelineResult { if !cumulative_targets.is_empty() && cumulative_targets.len() != layer_count { return Err(NativeEncodePipelineError::invalid_input( "quality layer byte target count must match quality layer count", @@ -94,7 +104,12 @@ fn validate_ht_assignment_inputs( "HT PCRD candidate capacity is smaller than its length", )); } - Ok(()) + ordered_candidate_block_count( + candidates + .iter() + .map(|candidate| (candidate.block_index, candidate.segment_index)), + "HTJ2K PCRD candidates are not grouped in segment order", + ) } fn try_ht_assignment_workspace( @@ -102,6 +117,7 @@ fn try_ht_assignment_workspace( candidate_capacity: usize, layer_count: usize, cumulative_targets: &[u64], + block_count: usize, tracker: &mut Tier1PhaseTracker<'_, '_>, retained_live_bytes: usize, ) -> NativeEncodePipelineResult { @@ -132,62 +148,41 @@ fn try_ht_assignment_workspace( cumulative_targets: targets, cumulative_used: used, }; - let block_count = ht_assignment_block_count(candidates)?; - let (mut assignments, assignment_bytes) = tracker.try_vec::( - candidates.len(), - [fixed, target_bytes, used_bytes], - "HT PCRD segment assignments", - )?; - assignments.resize(candidates.len(), layer_count.saturating_sub(1)); - let (mut candidate_order, order_bytes) = tracker.try_vec::( - candidates.len(), - [fixed, target_bytes, used_bytes, assignment_bytes], - "HT PCRD candidate order", - )?; - candidate_order.extend(0..candidates.len()); - candidate_order - .sort_by_key(|&idx| (candidates[idx].block_index, candidates[idx].segment_index)); let (mut block_min_layers, block_min_bytes) = tracker.try_vec::( block_count, - [ - fixed, - target_bytes, - used_bytes, - assignment_bytes, - order_bytes, - ], + [fixed, target_bytes, used_bytes], "HT PCRD block minimum layers", )?; block_min_layers.resize(block_count, 0); + let (mut block_frontiers, frontier_bytes) = tracker.try_vec::>( + block_count, + [fixed, target_bytes, used_bytes, block_min_bytes], + "HT PCRD block frontiers", + )?; + block_frontiers.resize(block_count, None); + for (candidate_index, candidate) in candidates.iter().enumerate() { + if candidate.segment_index == 0 { + block_frontiers[candidate.block_index] = Some(candidate_index); + } + } + let live = [ + fixed, + target_bytes, + used_bytes, + block_min_bytes, + frontier_bytes, + ]; + let (mut assignments, assignment_bytes) = + tracker.try_vec::(candidates.len(), live, "HT PCRD segment assignments")?; + assignments.resize(candidates.len(), layer_count.saturating_sub(1)); tracker.check( - [ - fixed, - target_bytes, - used_bytes, - assignment_bytes, - order_bytes, - block_min_bytes, - ], + live.into_iter().chain([assignment_bytes]), "HT PCRD workspace", )?; Ok(HtAssignmentWorkspace { allocator, assignments, - candidate_order, + block_frontiers, block_min_layers, }) } - -fn ht_assignment_block_count( - candidates: &[HtSegmentAssignmentCandidate], -) -> NativeEncodePipelineResult { - candidates - .iter() - .map(|candidate| candidate.block_index) - .max() - .map_or(Ok(0usize), |index| { - index.checked_add(1).ok_or_else(|| { - NativeEncodePipelineError::arithmetic_overflow("HT PCRD block count") - }) - }) -} diff --git a/crates/j2k-native/src/j2c/encode/rate_control/assignment/accounted/classic.rs b/crates/j2k-native/src/j2c/encode/rate_control/assignment/accounted/classic.rs index 000cb9e2..c5b7fd75 100644 --- a/crates/j2k-native/src/j2c/encode/rate_control/assignment/accounted/classic.rs +++ b/crates/j2k-native/src/j2c/encode/rate_control/assignment/accounted/classic.rs @@ -8,20 +8,14 @@ use super::super::super::super::{NativeEncodePipelineError, NativeEncodePipeline use super::super::super::{ classic_rate_target_tolerance, ClassicLayerBudgetAllocator, ClassicSegmentAssignmentCandidate, }; +use super::super::ordered_candidate_block_count; use super::super::{compare_classic_segment_candidates, enforce_classic_assignment_monotonicity}; struct ClassicAssignmentWorkspace { allocator: ClassicLayerBudgetAllocator, - block_candidates: Vec>, + block_frontiers: Vec>, block_min_layers: Vec, assignments: Vec, - next_block_segment: Vec, -} - -struct ClassicCandidateGraph { - blocks: Vec>, - outer_bytes: usize, - nested_bytes: usize, } pub(in crate::j2c::encode) fn assign_classic_segment_layers_by_slope_accounted( @@ -32,7 +26,7 @@ pub(in crate::j2c::encode) fn assign_classic_segment_layers_by_slope_accounted( tracker: &mut Tier1PhaseTracker<'_, '_>, retained_live_bytes: usize, ) -> NativeEncodePipelineResult> { - validate_classic_assignment_inputs( + let block_count = validate_classic_assignment_inputs( candidates, candidate_capacity, layer_count, @@ -46,18 +40,16 @@ pub(in crate::j2c::encode) fn assign_classic_segment_layers_by_slope_accounted( candidate_capacity, layer_count, cumulative_targets, + block_count, tracker, retained_live_bytes, )?; for _ in 0..candidates.len() { let candidate_idx = workspace - .block_candidates + .block_frontiers .iter() - .enumerate() - .filter_map(|(block_idx, block)| { - block.get(workspace.next_block_segment[block_idx]).copied() - }) + .filter_map(|candidate| *candidate) .min_by(|&left, &right| compare_classic_segment_candidates(candidates, left, right)) .ok_or_else(|| { NativeEncodePipelineError::internal_invariant( @@ -76,15 +68,13 @@ pub(in crate::j2c::encode) fn assign_classic_segment_layers_by_slope_accounted( .assign_segment(min_layer, candidate.rate) .map_err(NativeEncodePipelineError::arithmetic_overflow)?; workspace.assignments[candidate_idx] = layer; - workspace.block_min_layers[candidate.block_index] = layer; - workspace.next_block_segment[candidate.block_index] = workspace.next_block_segment - [candidate.block_index] - .checked_add(1) - .ok_or_else(|| { - NativeEncodePipelineError::arithmetic_overflow( - "classic PCRD segment index overflow", - ) - })?; + let block_index = candidate.block_index; + workspace.block_min_layers[block_index] = layer; + workspace.block_frontiers[block_index] = candidate_idx.checked_add(1).filter(|&next| { + candidates + .get(next) + .is_some_and(|candidate| candidate.block_index == block_index) + }); } enforce_classic_assignment_monotonicity(candidates, &mut workspace.assignments); Ok(workspace.assignments) @@ -95,7 +85,7 @@ fn validate_classic_assignment_inputs( candidate_capacity: usize, layer_count: usize, cumulative_targets: &[u64], -) -> NativeEncodePipelineResult<()> { +) -> NativeEncodePipelineResult { if !cumulative_targets.is_empty() && cumulative_targets.len() != layer_count { return Err(NativeEncodePipelineError::invalid_input( "quality layer byte target count must match quality layer count", @@ -111,7 +101,12 @@ fn validate_classic_assignment_inputs( "classic PCRD candidate capacity is smaller than its length", )); } - Ok(()) + ordered_candidate_block_count( + candidates + .iter() + .map(|candidate| (candidate.block_index, candidate.segment_index)), + "classic PCRD candidates are not grouped in segment order", + ) } fn try_classic_assignment_workspace( @@ -119,6 +114,7 @@ fn try_classic_assignment_workspace( candidate_capacity: usize, layer_count: usize, cumulative_targets: &[u64], + block_count: usize, tracker: &mut Tier1PhaseTracker<'_, '_>, retained_live_bytes: usize, ) -> NativeEncodePipelineResult { @@ -126,14 +122,6 @@ fn try_classic_assignment_workspace( candidate_capacity, "classic PCRD candidates", )?; - let block_count = candidates - .iter() - .map(|candidate| candidate.block_index) - .max() - .and_then(|max| max.checked_add(1)) - .ok_or_else(|| { - NativeEncodePipelineError::arithmetic_overflow("classic PCRD block count") - })?; let fixed = checked_add_bytes( retained_live_bytes, candidate_bytes, @@ -141,21 +129,18 @@ fn try_classic_assignment_workspace( )?; let (allocator, target_bytes, used_bytes) = try_classic_budget_allocator(cumulative_targets, fixed, tracker)?; - let graph = try_classic_candidate_graph( - candidates, + let (mut block_frontiers, frontier_bytes) = tracker.try_vec::>( block_count, - fixed, - target_bytes, - used_bytes, - tracker, + [fixed, target_bytes, used_bytes], + "classic PCRD block frontiers", )?; - let live = [ - fixed, - target_bytes, - used_bytes, - graph.outer_bytes, - graph.nested_bytes, - ]; + block_frontiers.resize(block_count, None); + for (candidate_index, candidate) in candidates.iter().enumerate() { + if candidate.segment_index == 0 { + block_frontiers[candidate.block_index] = Some(candidate_index); + } + } + let live = [fixed, target_bytes, used_bytes, frontier_bytes]; let (mut block_min_layers, block_min_bytes) = tracker.try_vec::(block_count, live, "classic PCRD block minimum layers")?; block_min_layers.resize(block_count, 0); @@ -165,23 +150,15 @@ fn try_classic_assignment_workspace( "classic PCRD segment assignments", )?; assignments.resize(candidates.len(), layer_count.saturating_sub(1)); - let (mut next_block_segment, next_bytes) = tracker.try_vec::( - block_count, - live.into_iter().chain([block_min_bytes, assignment_bytes]), - "classic PCRD next block segments", - )?; - next_block_segment.resize(block_count, 0); tracker.check( - live.into_iter() - .chain([block_min_bytes, assignment_bytes, next_bytes]), + live.into_iter().chain([block_min_bytes, assignment_bytes]), "classic PCRD workspace", )?; Ok(ClassicAssignmentWorkspace { allocator, - block_candidates: graph.blocks, + block_frontiers, block_min_layers, assignments, - next_block_segment, }) } @@ -213,68 +190,3 @@ fn try_classic_budget_allocator( used_bytes, )) } - -fn try_classic_candidate_graph( - candidates: &[ClassicSegmentAssignmentCandidate], - block_count: usize, - fixed: usize, - target_bytes: usize, - used_bytes: usize, - tracker: &mut Tier1PhaseTracker<'_, '_>, -) -> NativeEncodePipelineResult { - let (mut counts, count_bytes) = tracker.try_vec::( - block_count, - [fixed, target_bytes, used_bytes], - "classic PCRD block segment counts", - )?; - counts.resize(block_count, 0); - for candidate in candidates { - let count = counts.get_mut(candidate.block_index).ok_or_else(|| { - NativeEncodePipelineError::internal_invariant("classic PCRD block index mismatch") - })?; - *count = count - .checked_add(1) - .ok_or(crate::EncodeError::ArithmeticOverflow { - what: "classic PCRD block segment count", - })?; - } - let (mut blocks, outer_bytes) = tracker.try_vec::>( - block_count, - [fixed, target_bytes, used_bytes, count_bytes], - "classic PCRD block candidate owners", - )?; - let mut nested_bytes = 0usize; - for &count in &counts { - let (indices, bytes) = tracker.try_vec::( - count, - [ - fixed, - target_bytes, - used_bytes, - count_bytes, - outer_bytes, - nested_bytes, - ], - "classic PCRD block candidates", - )?; - nested_bytes = - checked_add_bytes(nested_bytes, bytes, "classic PCRD block candidate graph")?; - blocks.push(indices); - } - for (candidate_idx, candidate) in candidates.iter().enumerate() { - blocks - .get_mut(candidate.block_index) - .ok_or_else(|| { - NativeEncodePipelineError::internal_invariant("classic PCRD block index mismatch") - })? - .push(candidate_idx); - } - for block in &mut blocks { - block.sort_by_key(|&idx| candidates[idx].segment_index); - } - Ok(ClassicCandidateGraph { - blocks, - outer_bytes, - nested_bytes, - }) -} diff --git a/crates/j2k-native/src/j2c/encode/rate_control/assignment/legacy.rs b/crates/j2k-native/src/j2c/encode/rate_control/assignment/legacy.rs index d482b95e..28f54143 100644 --- a/crates/j2k-native/src/j2c/encode/rate_control/assignment/legacy.rs +++ b/crates/j2k-native/src/j2c/encode/rate_control/assignment/legacy.rs @@ -8,7 +8,10 @@ use super::super::super::Vec; use super::super::{ ClassicLayerBudgetAllocator, ClassicSegmentAssignmentCandidate, HtSegmentAssignmentCandidate, }; -use super::{compare_classic_segment_candidates, enforce_classic_assignment_monotonicity}; +use super::{ + compare_classic_segment_candidates, compare_ht_segment_candidates, + enforce_classic_assignment_monotonicity, enforce_ht_assignment_monotonicity, +}; pub(in crate::j2c::encode) fn assign_classic_segment_layers_by_slope( candidates: &[ClassicSegmentAssignmentCandidate], @@ -76,26 +79,31 @@ pub(in crate::j2c::encode) fn assign_ht_segment_layers_by_budget( ) -> Result, &'static str> { let mut allocator = ClassicLayerBudgetAllocator::new(cumulative_targets, layer_count)?; let mut assignments = vec![layer_count.saturating_sub(1); candidates.len()]; - let mut candidate_order = Vec::new(); - candidate_order - .try_reserve_exact(candidates.len()) - .map_err(|_| "HTJ2K candidate-order allocation failed")?; - candidate_order.extend(0..candidates.len()); - candidate_order - .sort_by_key(|&idx| (candidates[idx].block_index, candidates[idx].segment_index)); - let mut block_min_layers = vec![ - 0usize; - candidates + let block_count = candidates + .iter() + .map(|candidate| candidate.block_index) + .max() + .map_or(0, |idx| idx + 1); + let mut block_candidates = vec![Vec::new(); block_count]; + for (candidate_idx, candidate) in candidates.iter().enumerate() { + block_candidates + .get_mut(candidate.block_index) + .ok_or("HTJ2K segment candidate block index mismatch")? + .push(candidate_idx); + } + for block in &mut block_candidates { + block.sort_by_key(|&idx| candidates[idx].segment_index); + } + let mut block_min_layers = vec![0usize; block_count]; + let mut next_block_segment = vec![0usize; block_count]; + for _ in 0..candidates.len() { + let candidate_idx = block_candidates .iter() - .map(|candidate| candidate.block_index) - .max() - .map_or(0, |idx| idx + 1) - ]; - - for candidate_idx in candidate_order { - let candidate = candidates - .get(candidate_idx) - .ok_or("HTJ2K segment candidate index mismatch")?; + .enumerate() + .filter_map(|(block_idx, block)| block.get(next_block_segment[block_idx]).copied()) + .min_by(|&left, &right| compare_ht_segment_candidates(candidates, left, right)) + .ok_or("HTJ2K PCRD candidate queue underflow")?; + let candidate = candidates[candidate_idx]; let min_layer = *block_min_layers .get(candidate.block_index) .ok_or("HTJ2K segment candidate block index mismatch")?; @@ -104,13 +112,10 @@ pub(in crate::j2c::encode) fn assign_ht_segment_layers_by_budget( if let Some(block_layer) = block_min_layers.get_mut(candidate.block_index) { *block_layer = layer; } + if let Some(next) = next_block_segment.get_mut(candidate.block_index) { + *next = next.checked_add(1).ok_or("HTJ2K segment index overflow")?; + } } - - for (candidate, assignment) in candidates.iter().zip(&mut assignments) { - *assignment = *block_min_layers - .get(candidate.block_index) - .ok_or("HTJ2K segment candidate block index mismatch")?; - } - + enforce_ht_assignment_monotonicity(candidates, &mut assignments); Ok(assignments) } diff --git a/crates/j2k-native/src/j2c/encode/rate_control/contributions.rs b/crates/j2k-native/src/j2c/encode/rate_control/contributions.rs index 03067a74..05764e36 100644 --- a/crates/j2k-native/src/j2c/encode/rate_control/contributions.rs +++ b/crates/j2k-native/src/j2c/encode/rate_control/contributions.rs @@ -21,6 +21,7 @@ pub(in crate::j2c::encode) fn ht_segment_count( match encoded.num_coding_passes { 0 => 0, 1 => 1, + 3 if encoded.ht_sigprop_length > 0 && encoded.ht_magref_length > 0 => 3, _ => 2, } } @@ -31,6 +32,8 @@ pub(in crate::j2c::encode) fn ht_segment_rate( ) -> NativeEncodePipelineResult { match segment_idx { 0 if encoded.num_coding_passes > 0 => Ok(u64::from(encoded.ht_cleanup_length)), + 1 if ht_segment_count(encoded) == 3 => Ok(u64::from(encoded.ht_sigprop_length)), + 2 if ht_segment_count(encoded) == 3 => Ok(u64::from(encoded.ht_magref_length)), 1 if encoded.num_coding_passes > 1 => Ok(u64::from(encoded.ht_refinement_length)), _ => Err(NativeEncodePipelineError::internal_invariant( "HTJ2K segment index out of range", diff --git a/crates/j2k-native/src/j2c/encode/rate_control/contributions/classic/build.rs b/crates/j2k-native/src/j2c/encode/rate_control/contributions/classic/build.rs index 4c371656..4cbcde4f 100644 --- a/crates/j2k-native/src/j2c/encode/rate_control/contributions/classic/build.rs +++ b/crates/j2k-native/src/j2c/encode/rate_control/contributions/classic/build.rs @@ -94,6 +94,9 @@ pub(super) fn build_classic_layer_contribution( data, ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: plan.coding_passes, classic_segment_lengths: segment_lengths, num_zero_bitplanes: encoded.num_zero_bitplanes, diff --git a/crates/j2k-native/src/j2c/encode/rate_control/contributions/ht.rs b/crates/j2k-native/src/j2c/encode/rate_control/contributions/ht.rs index 95f2e8ef..aeb26cd5 100644 --- a/crates/j2k-native/src/j2c/encode/rate_control/contributions/ht.rs +++ b/crates/j2k-native/src/j2c/encode/rate_control/contributions/ht.rs @@ -13,6 +13,133 @@ use super::{ht_segment_count, ht_target_layer}; mod layout; use layout::{ht_contribution_layout, HtContributionLayout}; +#[derive(Clone, Copy)] +struct HtLayerSelection { + has_cleanup: bool, + has_sigprop: bool, + has_magref: bool, + payload_len: usize, + refinement_len: usize, +} + +fn ht_layer_selection( + encoded: &bitplane_encode::EncodedCodeBlock, + layout: HtContributionLayout, + segment_layers: &[usize], + layer_idx: usize, +) -> NativeEncodePipelineResult { + // Emit one selected HT set atomically in the layer containing its final + // selected pass. This keeps the cleanup boundary unambiguous for external + // decoders while different code-blocks can still enter different layers. + let set_layer = segment_layers.last().copied(); + let has_cleanup = set_layer == Some(layer_idx); + let has_sigprop = encoded.num_coding_passes > 1 && set_layer == Some(layer_idx); + let has_magref = layout.split_refinement && set_layer == Some(layer_idx); + let refinement_len = usize::from(has_sigprop) + .checked_mul(layout.sigprop_len) + .and_then(|bytes| bytes.checked_add(usize::from(has_magref) * layout.magref_len)) + .ok_or_else(|| { + NativeEncodePipelineError::arithmetic_overflow("HTJ2K layer refinement length overflow") + })?; + let payload_len = usize::from(has_cleanup) + .checked_mul(layout.cleanup_len) + .and_then(|bytes| bytes.checked_add(refinement_len)) + .ok_or_else(|| { + NativeEncodePipelineError::arithmetic_overflow( + "HTJ2K layer contribution payload overflow", + ) + })?; + Ok(HtLayerSelection { + has_cleanup, + has_sigprop, + has_magref, + payload_len, + refinement_len, + }) +} + +fn append_ht_layer_payload( + encoded: &bitplane_encode::EncodedCodeBlock, + layout: HtContributionLayout, + selection: HtLayerSelection, + data: &mut Vec, +) -> NativeEncodePipelineResult { + let mut passes = 0u8; + if selection.has_cleanup { + data.extend_from_slice(encoded.data.get(..layout.cleanup_len).ok_or_else(|| { + NativeEncodePipelineError::internal_invariant("HTJ2K cleanup segment range invalid") + })?); + passes = 1; + } + if selection.has_sigprop { + data.extend_from_slice( + encoded + .data + .get(layout.cleanup_len..layout.sigprop_end) + .ok_or_else(|| { + NativeEncodePipelineError::internal_invariant( + "HTJ2K SigProp pass range invalid", + ) + })?, + ); + passes = passes + .checked_add(if layout.split_refinement { + 1 + } else { + encoded.num_coding_passes - 1 + }) + .ok_or_else(|| { + NativeEncodePipelineError::arithmetic_overflow( + "HTJ2K packet contribution pass count overflow", + ) + })?; + } + if selection.has_magref { + data.extend_from_slice( + encoded + .data + .get(layout.sigprop_end..layout.refinement_end) + .ok_or_else(|| { + NativeEncodePipelineError::internal_invariant("HTJ2K MagRef pass range invalid") + })?, + ); + passes = passes.checked_add(1).ok_or_else(|| { + NativeEncodePipelineError::arithmetic_overflow( + "HTJ2K packet contribution pass count overflow", + ) + })?; + } + Ok(passes) +} + +fn ht_packet_contribution( + encoded: &bitplane_encode::EncodedCodeBlock, + selection: HtLayerSelection, + data: Vec, + num_coding_passes: u8, +) -> NativeEncodePipelineResult { + Ok(CodeBlockPacketData { + data, + ht_cleanup_length: if selection.has_cleanup { + encoded.ht_cleanup_length + } else { + 0 + }, + ht_refinement_length: u32::try_from(selection.refinement_len).map_err(|_| { + NativeEncodePipelineError::arithmetic_overflow("HTJ2K layer refinement length overflow") + })?, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], + num_coding_passes, + classic_segment_lengths: Vec::new(), + num_zero_bitplanes: encoded.num_zero_bitplanes, + previously_included: false, + l_block: 3, + block_coding_mode: BlockCodingMode::HighThroughput, + }) +} + #[cfg(test)] pub(in crate::j2c::encode) fn ht_layer_contributions( encoded: &bitplane_encode::EncodedCodeBlock, @@ -20,81 +147,26 @@ pub(in crate::j2c::encode) fn ht_layer_contributions( segment_layers: &[usize], ) -> NativeEncodePipelineResult> { let layout = ht_contribution_layout(encoded, num_layers, segment_layers)?; - let HtContributionLayout { - layer_count, - cleanup_len, - refinement_len, - refinement_end, - } = layout; - let mut contributions = Vec::new(); - contributions.try_reserve_exact(layer_count).map_err(|_| { - crate::EncodeError::HostAllocationFailed { + contributions + .try_reserve_exact(layout.layer_count) + .map_err(|_| crate::EncodeError::HostAllocationFailed { what: "HTJ2K layer contribution owners", - bytes: layer_count.saturating_mul(core::mem::size_of::()), - } - })?; - for layer_idx in 0..layer_count { - let has_cleanup = segment_layers.first() == Some(&layer_idx); - let has_refinement = - encoded.num_coding_passes > 1 && segment_layers.get(1) == Some(&layer_idx); - let payload_len = usize::from(has_cleanup) - .checked_mul(cleanup_len) - .and_then(|bytes| bytes.checked_add(usize::from(has_refinement) * refinement_len)) - .ok_or_else(|| { - NativeEncodePipelineError::arithmetic_overflow( - "HTJ2K layer contribution payload overflow", - ) - })?; + bytes: layout + .layer_count + .saturating_mul(core::mem::size_of::()), + })?; + for layer_idx in 0..layout.layer_count { + let selection = ht_layer_selection(encoded, layout, segment_layers, layer_idx)?; let mut data = Vec::new(); - data.try_reserve_exact(payload_len).map_err(|_| { + data.try_reserve_exact(selection.payload_len).map_err(|_| { crate::EncodeError::HostAllocationFailed { what: "HTJ2K layer contribution payload", - bytes: payload_len, + bytes: selection.payload_len, } })?; - let mut num_coding_passes = 0u8; - if has_cleanup { - data.extend_from_slice(encoded.data.get(..cleanup_len).ok_or_else(|| { - NativeEncodePipelineError::internal_invariant("HTJ2K cleanup segment range invalid") - })?); - num_coding_passes = 1; - } - if has_refinement { - data.extend_from_slice(encoded.data.get(cleanup_len..refinement_end).ok_or_else( - || { - NativeEncodePipelineError::internal_invariant( - "HTJ2K refinement segment range invalid", - ) - }, - )?); - num_coding_passes = num_coding_passes - .checked_add(encoded.num_coding_passes - 1) - .ok_or_else(|| { - NativeEncodePipelineError::arithmetic_overflow( - "HTJ2K packet contribution pass count overflow", - ) - })?; - } - contributions.push(CodeBlockPacketData { - data, - ht_cleanup_length: if has_cleanup { - encoded.ht_cleanup_length - } else { - 0 - }, - ht_refinement_length: if has_refinement { - encoded.ht_refinement_length - } else { - 0 - }, - num_coding_passes, - classic_segment_lengths: Vec::new(), - num_zero_bitplanes: encoded.num_zero_bitplanes, - previously_included: false, - l_block: 3, - block_coding_mode: BlockCodingMode::HighThroughput, - }); + let passes = append_ht_layer_payload(encoded, layout, selection, &mut data)?; + contributions.push(ht_packet_contribution(encoded, selection, data, passes)?); } Ok(contributions) } @@ -145,34 +217,19 @@ pub(in crate::j2c::encode) fn ht_layer_contributions_accounted( .into()); } let layout = ht_contribution_layout(encoded, num_layers, segment_layers)?; - let HtContributionLayout { - layer_count, - cleanup_len, - refinement_len, - refinement_end, - } = layout; - let encoded_bytes = encoded.data.capacity(); let layer_metadata_bytes = checked_element_bytes::(segment_layer_capacity, "HT segment-layer metadata")?; let (mut contributions, contribution_owner_bytes) = tracker.try_vec::( - layer_count, + layout.layer_count, [retained_live_bytes, encoded_bytes, layer_metadata_bytes], "HT layer contribution owners", )?; let mut contribution_payload_bytes = 0usize; - for layer_idx in 0..layer_count { - let has_cleanup = segment_layers.first() == Some(&layer_idx); - let has_refinement = - encoded.num_coding_passes > 1 && segment_layers.get(1) == Some(&layer_idx); - let payload_len = usize::from(has_cleanup) - .checked_mul(cleanup_len) - .and_then(|bytes| bytes.checked_add(usize::from(has_refinement) * refinement_len)) - .ok_or(crate::EncodeError::ArithmeticOverflow { - what: "HT layer contribution payload", - })?; + for layer_idx in 0..layout.layer_count { + let selection = ht_layer_selection(encoded, layout, segment_layers, layer_idx)?; let (mut data, data_bytes) = tracker.try_vec::( - payload_len, + selection.payload_len, [ retained_live_bytes, encoded_bytes, @@ -182,53 +239,13 @@ pub(in crate::j2c::encode) fn ht_layer_contributions_accounted( ], "HT layer contribution payload", )?; - let mut num_coding_passes = 0u8; - if has_cleanup { - data.extend_from_slice(encoded.data.get(..cleanup_len).ok_or_else(|| { - NativeEncodePipelineError::internal_invariant("HTJ2K cleanup segment range invalid") - })?); - num_coding_passes = 1; - } - if has_refinement { - data.extend_from_slice(encoded.data.get(cleanup_len..refinement_end).ok_or_else( - || { - NativeEncodePipelineError::internal_invariant( - "HTJ2K refinement segment range invalid", - ) - }, - )?); - num_coding_passes = num_coding_passes - .checked_add(encoded.num_coding_passes - 1) - .ok_or_else(|| { - NativeEncodePipelineError::arithmetic_overflow( - "HTJ2K packet contribution pass count overflow", - ) - })?; - } + let passes = append_ht_layer_payload(encoded, layout, selection, &mut data)?; contribution_payload_bytes = checked_add_bytes( contribution_payload_bytes, data_bytes, "HT layer contribution payload graph", )?; - contributions.push(CodeBlockPacketData { - data, - ht_cleanup_length: if has_cleanup { - encoded.ht_cleanup_length - } else { - 0 - }, - ht_refinement_length: if has_refinement { - encoded.ht_refinement_length - } else { - 0 - }, - num_coding_passes, - classic_segment_lengths: Vec::new(), - num_zero_bitplanes: encoded.num_zero_bitplanes, - previously_included: false, - l_block: 3, - block_coding_mode: BlockCodingMode::HighThroughput, - }); + contributions.push(ht_packet_contribution(encoded, selection, data, passes)?); } Ok(contributions) } diff --git a/crates/j2k-native/src/j2c/encode/rate_control/contributions/ht/layout.rs b/crates/j2k-native/src/j2c/encode/rate_control/contributions/ht/layout.rs index d4a2079f..29b80901 100644 --- a/crates/j2k-native/src/j2c/encode/rate_control/contributions/ht/layout.rs +++ b/crates/j2k-native/src/j2c/encode/rate_control/contributions/ht/layout.rs @@ -11,8 +11,11 @@ use super::super::ht_segment_count; pub(super) struct HtContributionLayout { pub(super) layer_count: usize, pub(super) cleanup_len: usize, - pub(super) refinement_len: usize, + pub(super) sigprop_len: usize, + pub(super) magref_len: usize, + pub(super) sigprop_end: usize, pub(super) refinement_end: usize, + pub(super) split_refinement: bool, } pub(super) fn ht_contribution_layout( @@ -46,6 +49,21 @@ pub(super) fn ht_contribution_layout( let refinement_len = usize::try_from(encoded.ht_refinement_length).map_err(|_| { NativeEncodePipelineError::arithmetic_overflow("HTJ2K refinement segment length overflow") })?; + let sigprop_len = usize::try_from(encoded.ht_sigprop_length).map_err(|_| { + NativeEncodePipelineError::arithmetic_overflow("HTJ2K SigProp pass length overflow") + })?; + let magref_len = usize::try_from(encoded.ht_magref_length).map_err(|_| { + NativeEncodePipelineError::arithmetic_overflow("HTJ2K MagRef pass length overflow") + })?; + if sigprop_len.checked_add(magref_len) != Some(refinement_len) { + return Err(NativeEncodePipelineError::internal_invariant( + "HTJ2K refinement pass lengths do not match the segment length", + )); + } + let split_refinement = ht_segment_count(encoded) == 3; + let sigprop_end = cleanup_len.checked_add(sigprop_len).ok_or_else(|| { + NativeEncodePipelineError::arithmetic_overflow("HTJ2K SigProp pass range overflow") + })?; let refinement_end = cleanup_len.checked_add(refinement_len).ok_or_else(|| { NativeEncodePipelineError::arithmetic_overflow("HTJ2K refinement segment range overflow") })?; @@ -67,7 +85,10 @@ pub(super) fn ht_contribution_layout( Ok(HtContributionLayout { layer_count, cleanup_len, - refinement_len, + sigprop_len, + magref_len, + sigprop_end, refinement_end, + split_refinement, }) } diff --git a/crates/j2k-native/src/j2c/encode/retained_input.rs b/crates/j2k-native/src/j2c/encode/retained_input.rs index 32517641..9ad9a6f3 100644 --- a/crates/j2k-native/src/j2c/encode/retained_input.rs +++ b/crates/j2k-native/src/j2c/encode/retained_input.rs @@ -49,6 +49,7 @@ impl<'a> NativeEncodeRetainedInput<'a> { pub(crate) struct NativeEncodeSession<'a> { retained_input: NativeEncodeRetainedInput<'a>, cap: usize, + openhtj2k_qfactor: Option, } /// Checked bytes retained by one encode phase before accepting backend output. @@ -129,9 +130,23 @@ impl<'a> NativeEncodeSession<'a> { Ok(Self { retained_input, cap, + openhtj2k_qfactor: None, }) } + pub(crate) fn try_new_with_openhtj2k_qfactor( + retained_input: NativeEncodeRetainedInput<'a>, + qfactor: u8, + ) -> EncodeResult { + let mut session = Self::try_new(retained_input)?; + session.openhtj2k_qfactor = Some(qfactor); + Ok(session) + } + + pub(crate) const fn openhtj2k_qfactor(&self) -> Option { + self.openhtj2k_qfactor + } + pub(crate) fn checked_phase( &self, phase_bytes: usize, diff --git a/crates/j2k-native/src/j2c/encode/retained_input/child_session.rs b/crates/j2k-native/src/j2c/encode/retained_input/child_session.rs index f728a5bb..0f7e4090 100644 --- a/crates/j2k-native/src/j2c/encode/retained_input/child_session.rs +++ b/crates/j2k-native/src/j2c/encode/retained_input/child_session.rs @@ -27,6 +27,7 @@ impl NativeEncodeSession<'_> { retained_bytes, ), cap: self.cap, + openhtj2k_qfactor: self.openhtj2k_qfactor, }) } } diff --git a/crates/j2k-native/src/j2c/encode/single_tile/plan/build.rs b/crates/j2k-native/src/j2c/encode/single_tile/plan/build.rs index 90943445..647b03f0 100644 --- a/crates/j2k-native/src/j2c/encode/single_tile/plan/build.rs +++ b/crates/j2k-native/src/j2c/encode/single_tile/plan/build.rs @@ -4,7 +4,7 @@ use crate::j2c::encode::allocation::checked_element_bytes; use crate::j2c::encode::{ - ht_target_coding_passes_for_options, maximum_decomposition_levels, + ht_target_coding_passes_for_options, maximum_decomposition_levels, requested_guard_bits, reversible_guard_bits_for_marker_limit, BlockCodingMode, CodeBlockGeometry, EncodeComponentSampleInfo, EncodeOptions, EncodeRoiRegion, NativeEncodePipelineError, NativeEncodePipelineResult, NativeEncodeSession, @@ -81,7 +81,7 @@ pub(in crate::j2c::encode::single_tile) fn build_single_tile_plan( "single-tile component sampling", )?; let mut construction = PlanConstruction::new(session, component_sampling_bytes); - let geometry = resolve_geometry(&request, high_bit_exact, code_block_geometry)?; + let geometry = resolve_geometry(&request, high_bit_exact, code_block_geometry, session)?; let owners = try_build_plan_owners(&request, geometry, component_sampling, &mut construction)?; let PlanOwners { step_sizes, @@ -117,13 +117,18 @@ fn resolve_geometry( request: &BuildRequest<'_>, high_bit_exact: bool, code_block_geometry: CodeBlockGeometry, + session: &NativeEncodeSession<'_>, ) -> NativeEncodePipelineResult { let use_mct = request.options.use_mct && matches!(request.num_components, 3 | 4); let num_levels = request .options .num_decomposition_levels .min(maximum_decomposition_levels(request.width, request.height)); - let requested_guard_bits = requested_guard_bits(request.options, use_mct); + let requested_guard_bits = requested_guard_bits( + request.options, + use_mct, + session.openhtj2k_qfactor().is_some(), + ); let guard_bits = if high_bit_exact && request.options.reversible { reversible_guard_bits_for_marker_limit(request.bit_depth, num_levels, requested_guard_bits) .map_err(NativeEncodePipelineError::unsupported)? @@ -143,15 +148,3 @@ fn resolve_geometry( ), }) } - -fn requested_guard_bits(options: &EncodeOptions, use_mct: bool) -> u8 { - if options.reversible { - if use_mct { - options.guard_bits.max(2) - } else { - options.guard_bits - } - } else { - options.guard_bits.max(2) - } -} diff --git a/crates/j2k-native/src/j2c/encode/single_tile/plan/build/owners.rs b/crates/j2k-native/src/j2c/encode/single_tile/plan/build/owners.rs index 53c15941..e1237828 100644 --- a/crates/j2k-native/src/j2c/encode/single_tile/plan/build/owners.rs +++ b/crates/j2k-native/src/j2c/encode/single_tile/plan/build/owners.rs @@ -42,6 +42,7 @@ pub(super) fn try_build_plan_owners( request.options.reversible, geometry.guard_bits, request.options, + 0, )?; if request.options.reversible && geometry.guard_delta != 0 { adjust_reversible_step_sizes_for_guard_delta(&mut step_sizes, geometry.guard_delta) diff --git a/crates/j2k-native/src/j2c/encode/single_tile/plan/construction.rs b/crates/j2k-native/src/j2c/encode/single_tile/plan/construction.rs index 0f2c6427..e517103d 100644 --- a/crates/j2k-native/src/j2c/encode/single_tile/plan/construction.rs +++ b/crates/j2k-native/src/j2c/encode/single_tile/plan/construction.rs @@ -95,6 +95,7 @@ impl<'session, 'input> PlanConstruction<'session, 'input> { reversible: bool, guard_bits: u8, options: &EncodeOptions, + component: usize, ) -> NativeEncodePipelineResult> { let count = usize::from(num_levels) .checked_mul(3) @@ -103,15 +104,21 @@ impl<'session, 'input> PlanConstruction<'session, 'input> { what: "single-tile quantization step count", })?; let mut steps = self.try_vec(count, "single-tile quantization steps")?; - quantize::append_step_sizes_with_irreversible_profile( - &mut steps, - bit_depth, - num_levels, - reversible, - guard_bits, - options.irreversible_quantization_scale, - options.irreversible_quantization_subband_scales, - ); + if let Some(qfactor) = self.session.openhtj2k_qfactor() { + quantize::append_openhtj2k_qfactor_step_sizes( + &mut steps, bit_depth, num_levels, qfactor, component, + )?; + } else { + quantize::append_step_sizes_with_irreversible_profile( + &mut steps, + bit_depth, + num_levels, + reversible, + guard_bits, + options.irreversible_quantization_scale, + options.irreversible_quantization_subband_scales, + ); + } Ok(steps) } @@ -127,13 +134,14 @@ impl<'session, 'input> PlanConstruction<'session, 'input> { component_info.len(), "single-tile component step owners", )?; - for info in component_info { + for (component, info) in component_info.iter().enumerate() { components.push(self.try_step_sizes( info.bit_depth, num_levels, reversible, guard_bits, options, + component, )?); } Ok(components) diff --git a/crates/j2k-native/src/j2c/encode/tier1_driver.rs b/crates/j2k-native/src/j2c/encode/tier1_driver.rs index 570d33c2..2119bad1 100644 --- a/crates/j2k-native/src/j2c/encode/tier1_driver.rs +++ b/crates/j2k-native/src/j2c/encode/tier1_driver.rs @@ -244,6 +244,7 @@ fn encode_ht_serial( ) -> NativeEncodePipelineResult<()> { let mut packet_payload_bytes = 0usize; let mut job_index = 0usize; + let mut workspace = None; for (subband, precinct) in prepared_subbands.iter().zip(precincts) { for _block in &subband.code_blocks { let job = jobs.get(job_index).ok_or_else(|| { @@ -251,7 +252,7 @@ fn encode_ht_serial( })?; let wave_fixed = [fixed[0], fixed[1], fixed[2], fixed[3], packet_payload_bytes]; check_ht_wave(core::slice::from_ref(job), tracker, &wave_fixed, 1)?; - let encoded = encode_ht_code_block_typed(job, accelerator)?; + let encoded = encode_ht_code_block_typed(job, accelerator, &mut workspace)?; packet_payload_bytes = checked_add_bytes( packet_payload_bytes, encoded.data.capacity(), @@ -575,6 +576,7 @@ fn total_block_count( fn encode_ht_code_block_typed( job: &crate::J2kHtCodeBlockEncodeJob<'_>, accelerator: &mut impl J2kEncodeStageAccelerator, + workspace: &mut Option, ) -> NativeEncodePipelineResult { if let Some(encoded) = accelerator.encode_ht_code_block(*job).map_err(|source| { crate::EncodeError::Accelerator { @@ -584,13 +586,22 @@ fn encode_ht_code_block_typed( })? { return validated_ht_output(encoded, job); } - Ok(ht_block_encode::try_encode_code_block_with_passes( - job.coefficients, - job.width, - job.height, - job.total_bitplanes, - job.target_coding_passes, - )?) + if workspace.is_none() { + *workspace = Some(ht_block_encode::HtEncodeWorkspace::try_new()?); + } + let workspace = workspace + .as_mut() + .ok_or_else(|| NativeEncodePipelineError::internal_invariant("HT workspace is missing"))?; + Ok( + ht_block_encode::try_encode_code_block_with_passes_in_workspace( + job.coefficients, + job.width, + job.height, + job.total_bitplanes, + job.target_coding_passes, + workspace, + )?, + ) } fn encode_tier1_code_block_accounted( diff --git a/crates/j2k-native/src/j2c/encode/tier1_driver/cpu/waves.rs b/crates/j2k-native/src/j2c/encode/tier1_driver/cpu/waves.rs index e43872bc..44f337b8 100644 --- a/crates/j2k-native/src/j2c/encode/tier1_driver/cpu/waves.rs +++ b/crates/j2k-native/src/j2c/encode/tier1_driver/cpu/waves.rs @@ -11,6 +11,7 @@ use super::validate_ht_cpu_jobs; #[cfg(feature = "parallel")] use rayon::prelude::{ IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, + ParallelSlice, ParallelSliceMut, }; pub(in crate::j2c::encode::tier1_driver) type Tier1CpuSlot = @@ -29,37 +30,48 @@ pub(in crate::j2c::encode::tier1_driver) fn encode_ht_cpu_results_accounted( )?; encoded.resize_with(jobs.len(), || None); + if jobs.is_empty() { + return Ok(encoded); + } + let parallel = cfg!(feature = "parallel") && jobs.len() >= HT_CPU_PARALLEL_FALLBACK_MIN_JOBS; let wave_size = cpu_worker_limit(jobs.len(), parallel).max(1); + let (mut workspaces, workspace_owner_bytes) = tracker + .try_vec::( + wave_size, + fixed.into_iter().chain([outer_bytes]), + "bounded CPU HT Tier-1 workspace owners", + )?; + let workspace_bytes = checked_ht_workspace_bytes(wave_size)?; let mut retained_payload_bytes = 0usize; #[cfg(feature = "parallel")] if parallel { - let full_fixed = [fixed[0], fixed[1], fixed[2], fixed[3], outer_bytes]; + let full_fixed = [ + fixed[0], + fixed[1], + fixed[2], + fixed[3], + outer_bytes, + workspace_owner_bytes, + ]; // Charging every job's output and scratch makes this deliberately // independent of Rayon scheduling. If that conservative frontier is // too large, the bounded worker-sized waves below remain available. if try_check_full_ht_wave(jobs, tracker, &full_fixed)? { - encoded - .par_iter_mut() - .zip(jobs.par_iter()) - .for_each(|(slot, job)| { - *slot = Some(ht_block_encode::try_encode_code_block_with_passes( - job.coefficients, - job.width, - job.height, - job.total_bitplanes, - job.target_coding_passes, - )); - }); + try_fill_ht_workspaces(&mut workspaces, wave_size)?; + encode_ht_parallel_with_workspaces(jobs, &mut encoded, &mut workspaces); retained_payload_bytes = checked_wave_payload_bytes( retained_payload_bytes, &mut encoded, "bounded CPU HT Tier-1 payload", )?; tracker.check( - fixed - .into_iter() - .chain([outer_bytes, retained_payload_bytes]), + fixed.into_iter().chain([ + outer_bytes, + workspace_owner_bytes, + workspace_bytes, + retained_payload_bytes, + ]), "bounded CPU HT Tier-1 output", )?; return Ok(encoded); @@ -73,28 +85,23 @@ pub(in crate::j2c::encode::tier1_driver) fn encode_ht_cpu_results_accounted( fixed[3], outer_bytes, retained_payload_bytes, + workspace_owner_bytes, + checked_ht_workspace_bytes(wave_size - job_wave.len())?, ]; check_ht_wave(job_wave, tracker, &wave_fixed, wave_size)?; + if workspaces.is_empty() { + try_fill_ht_workspaces(&mut workspaces, wave_size)?; + } + #[cfg(feature = "parallel")] if parallel { - slot_wave - .par_iter_mut() - .zip(job_wave.par_iter()) - .for_each(|(slot, job)| { - *slot = Some(ht_block_encode::try_encode_code_block_with_passes( - job.coefficients, - job.width, - job.height, - job.total_bitplanes, - job.target_coding_passes, - )); - }); + encode_ht_parallel_with_workspaces(job_wave, slot_wave, &mut workspaces); } else { - encode_ht_wave_serial(job_wave, slot_wave); + encode_ht_wave_serial(job_wave, slot_wave, &mut workspaces[0]); } #[cfg(not(feature = "parallel"))] - encode_ht_wave_serial(job_wave, slot_wave); + encode_ht_wave_serial(job_wave, slot_wave, &mut workspaces[0]); retained_payload_bytes = checked_wave_payload_bytes( retained_payload_bytes, @@ -102,9 +109,12 @@ pub(in crate::j2c::encode::tier1_driver) fn encode_ht_cpu_results_accounted( "bounded CPU HT Tier-1 payload", )?; tracker.check( - fixed - .into_iter() - .chain([outer_bytes, retained_payload_bytes]), + fixed.into_iter().chain([ + outer_bytes, + workspace_owner_bytes, + workspace_bytes, + retained_payload_bytes, + ]), "bounded CPU HT Tier-1 output", )?; } @@ -187,15 +197,57 @@ pub(in crate::j2c::encode::tier1_driver) fn encode_classic_cpu_results_accounted Ok(encoded) } -fn encode_ht_wave_serial(jobs: &[crate::J2kHtCodeBlockEncodeJob<'_>], slots: &mut [Tier1CpuSlot]) { +fn try_fill_ht_workspaces( + workspaces: &mut Vec, + count: usize, +) -> NativeEncodePipelineResult<()> { + for _ in 0..count { + workspaces.push(ht_block_encode::HtEncodeWorkspace::try_new()?); + } + Ok(()) +} + +fn checked_ht_workspace_bytes(count: usize) -> NativeEncodePipelineResult { + count + .checked_mul(ht_block_encode::HtEncodeWorkspace::ALLOCATION_BYTES) + .ok_or(crate::EncodeError::ArithmeticOverflow { + what: "HTJ2K CPU workspace bytes", + }) + .map_err(Into::into) +} + +#[cfg(feature = "parallel")] +fn encode_ht_parallel_with_workspaces( + jobs: &[crate::J2kHtCodeBlockEncodeJob<'_>], + slots: &mut [Tier1CpuSlot], + workspaces: &mut [ht_block_encode::HtEncodeWorkspace], +) { + let chunk_len = jobs.len().div_ceil(workspaces.len()); + slots + .par_chunks_mut(chunk_len) + .zip(jobs.par_chunks(chunk_len)) + .zip(workspaces.par_iter_mut()) + .for_each(|((slot_chunk, job_chunk), workspace)| { + encode_ht_wave_serial(job_chunk, slot_chunk, workspace); + }); +} + +fn encode_ht_wave_serial( + jobs: &[crate::J2kHtCodeBlockEncodeJob<'_>], + slots: &mut [Tier1CpuSlot], + workspace: &mut ht_block_encode::HtEncodeWorkspace, +) { for (slot, job) in slots.iter_mut().zip(jobs) { - *slot = Some(ht_block_encode::try_encode_code_block_with_passes( - job.coefficients, - job.width, - job.height, - job.total_bitplanes, - job.target_coding_passes, - )); + *slot = Some( + ht_block_encode::try_encode_code_block_with_passes_in_workspace( + job.coefficients, + job.width, + job.height, + job.total_bitplanes, + job.target_coding_passes, + workspace, + ), + ); } } @@ -252,6 +304,68 @@ mod tests { use super::*; use crate::j2c::encode::{NativeEncodeRetainedInput, NativeEncodeSession}; + #[test] + fn accounted_ht_cpu_results_match_fresh_workspaces_across_many_blocks() { + let coefficient_blocks: Vec> = (0..64usize) + .map(|seed| { + let side = if seed.is_multiple_of(3) { 8 } else { 64 }; + (0..side * side) + .map(|index| { + if (index + seed).is_multiple_of(13) { + 0 + } else { + i32::try_from(((index * 29) ^ (seed * 11)) & 0x01ff) + .expect("masked coefficient fits i32") + - 255 + } + }) + .collect() + }) + .collect(); + let jobs: Vec<_> = coefficient_blocks + .iter() + .enumerate() + .map(|(index, coefficients)| { + let side = if index.is_multiple_of(3) { 8 } else { 64 }; + crate::J2kHtCodeBlockEncodeJob { + coefficients, + width: side, + height: side, + total_bitplanes: 9, + target_coding_passes: 1, + } + }) + .collect(); + let expected: Vec<_> = jobs + .iter() + .map(|job| { + ht_block_encode::try_encode_code_block_with_passes( + job.coefficients, + job.width, + job.height, + job.total_bitplanes, + job.target_coding_passes, + ) + .expect("fresh-workspace encode") + }) + .collect(); + let session = NativeEncodeSession::try_new(NativeEncodeRetainedInput::none()) + .expect("HT CPU session"); + let mut tracker = Tier1PhaseTracker::new(&session, 0); + + let actual = encode_ht_cpu_results_accounted(&jobs, &mut tracker, [0; 4]) + .expect("accounted HT CPU encode"); + + for (slot, expected) in actual.into_iter().zip(expected) { + let actual = slot.expect("worker slot").expect("reused-workspace encode"); + assert_eq!(actual.data, expected.data); + assert_eq!(actual.num_coding_passes, expected.num_coding_passes); + assert_eq!(actual.num_zero_bitplanes, expected.num_zero_bitplanes); + assert_eq!(actual.ht_cleanup_length, expected.ht_cleanup_length); + assert_eq!(actual.ht_refinement_length, expected.ht_refinement_length); + } + } + #[test] fn full_ht_wave_falls_back_when_only_one_worker_frontier_fits() { let coefficients = [1_i32; 16]; diff --git a/crates/j2k-native/src/j2c/encode/tier1_driver/output.rs b/crates/j2k-native/src/j2c/encode/tier1_driver/output.rs index 091b1565..360f786d 100644 --- a/crates/j2k-native/src/j2c/encode/tier1_driver/output.rs +++ b/crates/j2k-native/src/j2c/encode/tier1_driver/output.rs @@ -130,6 +130,17 @@ pub(super) fn push_packet_block( } else { 0 }, + ht_sigprop_length: if block_coding_mode == BlockCodingMode::HighThroughput { + encoded.ht_sigprop_length + } else { + 0 + }, + ht_magref_length: if block_coding_mode == BlockCodingMode::HighThroughput { + encoded.ht_magref_length + } else { + 0 + }, + ht_distortion_deltas: encoded.ht_distortion_deltas, num_coding_passes: encoded.num_coding_passes, classic_segment_lengths: Vec::new(), num_zero_bitplanes: encoded.num_zero_bitplanes, @@ -149,6 +160,9 @@ pub(super) fn ht_encoded_code_block_from_accelerator( num_zero_bitplanes: encoded.num_zero_bitplanes, ht_cleanup_length: encoded.cleanup_length, ht_refinement_length: encoded.refinement_length, + ht_sigprop_length: encoded.refinement_length, + ht_magref_length: 0, + ht_distortion_deltas: [f64::EPSILON; 3], } } @@ -161,5 +175,8 @@ pub(super) fn encoded_code_block_from_accelerator( num_zero_bitplanes: encoded.missing_bit_planes, ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], } } diff --git a/crates/j2k-native/src/j2c/encode_tests.rs b/crates/j2k-native/src/j2c/encode_tests.rs index 35a58e9b..7bf6ace4 100644 --- a/crates/j2k-native/src/j2c/encode_tests.rs +++ b/crates/j2k-native/src/j2c/encode_tests.rs @@ -4,9 +4,9 @@ use super::{ assign_classic_segment_layers_by_slope, assign_ht_segment_layers_by_budget, bitplane_encode, copy_code_block_coefficients, deinterleave_rgb8_unsigned_to_f32, deinterleave_to_f32, downcast_i64_coefficients_to_i32, encode, encode_all_ht_code_blocks_parallel, - encode_all_ht_code_blocks_serial_cpu, encode_htj2k, encode_precomputed_htj2k_53, - encode_precomputed_htj2k_53_with_accelerator, encode_precomputed_htj2k_97, - encode_precomputed_htj2k_97_batch_with_accelerator, + encode_all_ht_code_blocks_serial_cpu, encode_htj2k, encode_htj2k_with_qfactor, + encode_precomputed_htj2k_53, encode_precomputed_htj2k_53_with_accelerator, + encode_precomputed_htj2k_97, encode_precomputed_htj2k_97_batch_with_accelerator, encode_precomputed_htj2k_97_with_accelerator, encode_preencoded_htj2k_97, encode_preencoded_htj2k_97_compact_owned_with_accelerator, encode_prepared_subbands, encode_prequantized_htj2k_97, encode_prequantized_htj2k_97_with_accelerator, @@ -30,6 +30,71 @@ use super::{ use crate::{DecodeSettings, EncodeError, Image, PrequantizedHtj2k97CodeBlock}; use alloc::{vec, vec::Vec}; +#[test] +fn qfactor_encode_writes_openhtj2k_compatible_qcd_and_qcc() { + let mut pixels = Vec::with_capacity(64 * 64 * 3); + for index in 0_usize..64 * 64 { + let sample = u8::try_from(index & 0xff).expect("masked fixture sample fits u8"); + pixels.extend_from_slice(&[sample, sample.wrapping_mul(3), sample.wrapping_mul(7)]); + } + let options = EncodeOptions { + reversible: false, + use_ht_block_coding: true, + num_decomposition_levels: 5, + validate_high_throughput_codestream: false, + ..EncodeOptions::default() + }; + let codestream = encode_htj2k_with_qfactor(&pixels, 64, 64, 3, 8, false, 90, &options) + .expect("Qfactor encode"); + + let qcd = marker_payload(&codestream, 0x5c).expect("QCD marker"); + assert_eq!(qcd[0], 0x22); + assert_eq!( + &qcd[1..], + &[ + 0x65, 0xdf, 0x65, 0xb4, 0x65, 0xb4, 0x65, 0x8b, 0x5d, 0xc9, 0x5d, 0xc9, 0x5d, 0xad, + 0x56, 0x0f, 0x56, 0x0f, 0x56, 0x25, 0x40, 0x08, 0x40, 0x08, 0x41, 0x0b, 0x3d, 0xab, + 0x3d, 0xab, 0x33, 0x7e, + ] + ); + let qcc = qcc_payload(&codestream, 1).expect("component-one QCC marker"); + assert_eq!(qcc[0], 1); + assert_eq!(qcc[1], 0x22); + assert_eq!(&qcc[2..6], &[0x65, 0x4f, 0x66, 0xdb]); +} + +fn marker_payload(codestream: &[u8], marker: u8) -> Option<&[u8]> { + let offset = codestream + .windows(2) + .position(|bytes| bytes == [0xff, marker])?; + let length = usize::from(u16::from_be_bytes([ + *codestream.get(offset + 2)?, + *codestream.get(offset + 3)?, + ])); + codestream.get(offset + 4..offset + 2 + length) +} + +fn qcc_payload(codestream: &[u8], component: u8) -> Option<&[u8]> { + let mut offset = 0; + while let Some(relative) = codestream + .get(offset..)? + .windows(2) + .position(|bytes| bytes == [0xff, 0x5d]) + { + let marker_offset = offset + relative; + let length = usize::from(u16::from_be_bytes([ + *codestream.get(marker_offset + 2)?, + *codestream.get(marker_offset + 3)?, + ])); + let payload = codestream.get(marker_offset + 4..marker_offset + 2 + length)?; + if payload.first() == Some(&component) { + return Some(payload); + } + offset = marker_offset + 2 + length; + } + None +} + fn test_preencoded_subband_payload(marker: u8) -> PreencodedHtj2k97Subband { PreencodedHtj2k97Subband { sub_band_type: J2kSubBandType::LowLow, @@ -2359,16 +2424,19 @@ fn ht_layer_assignment_uses_segment_budget_before_block_index() { block_index: 0, segment_index: 0, rate: 900, + distortion_delta: 900.0, }, HtSegmentAssignmentCandidate { block_index: 1, segment_index: 0, rate: 200, + distortion_delta: 2_000.0, }, HtSegmentAssignmentCandidate { block_index: 2, segment_index: 0, rate: 200, + distortion_delta: 1_000.0, }, ]; @@ -2389,11 +2457,13 @@ fn ht_layer_assignment_keeps_refinement_after_cleanup() { block_index: 0, segment_index: 0, rate: 200, + distortion_delta: 2_000.0, }, HtSegmentAssignmentCandidate { block_index: 0, segment_index: 1, rate: 50, + distortion_delta: 50.0, }, ]; @@ -2408,17 +2478,19 @@ fn ht_layer_assignment_keeps_refinement_after_cleanup() { } #[test] -fn ht_layer_assignment_keeps_complete_ht_set_together() { +fn ht_layer_assignment_uses_pass_slopes_without_breaking_dependencies() { let candidates = vec![ HtSegmentAssignmentCandidate { block_index: 0, segment_index: 0, rate: 700, + distortion_delta: 2_000.0, }, HtSegmentAssignmentCandidate { block_index: 0, segment_index: 1, rate: 100, + distortion_delta: 100.0, }, ]; @@ -2427,36 +2499,93 @@ fn ht_layer_assignment_keeps_complete_ht_set_together() { assert_eq!( assignments, - vec![1, 1], - "cleanup and refinement from one HT set must remain in the same quality layer" + vec![0, 1], + "HT pass budgeting may cross layer boundaries before atomic set emission" ); } #[test] -fn ht_layer_contributions_split_cleanup_and_refinement_across_layers() { +fn ht_layer_assignment_prefers_the_highest_legal_slope() { + let candidates = vec![ + HtSegmentAssignmentCandidate { + block_index: 0, + segment_index: 0, + rate: 700, + distortion_delta: 20.0, + }, + HtSegmentAssignmentCandidate { + block_index: 1, + segment_index: 0, + rate: 700, + distortion_delta: 2_000.0, + }, + ]; + + let assignments = assign_ht_segment_layers_by_budget(&candidates, 2, &[256, 2_000]) + .expect("HTJ2K segment assignment"); + + assert_eq!(assignments, vec![1, 0]); +} + +#[test] +fn ht_layer_contributions_emit_the_selected_ht_set_atomically() { let encoded = bitplane_encode::EncodedCodeBlock { data: vec![0x11, 0x22, 0x33, 0x44, 0x55], num_coding_passes: 3, num_zero_bitplanes: 2, ht_cleanup_length: 3, ht_refinement_length: 2, + ht_sigprop_length: 2, + ht_magref_length: 0, + ht_distortion_deltas: [1.0; 3], }; - let contributions = ht_layer_contributions(&encoded, 2, &[0, 1]).expect("split HT layers"); + let contributions = ht_layer_contributions(&encoded, 2, &[0, 1]).expect("layered HT set"); assert_eq!(contributions.len(), 2); - assert_eq!(contributions[0].data, vec![0x11, 0x22, 0x33]); - assert_eq!(contributions[0].ht_cleanup_length, 3); + assert!(contributions[0].data.is_empty()); + assert_eq!(contributions[0].ht_cleanup_length, 0); assert_eq!(contributions[0].ht_refinement_length, 0); - assert_eq!(contributions[0].num_coding_passes, 1); - assert_eq!(contributions[1].data, vec![0x44, 0x55]); - assert_eq!(contributions[1].ht_cleanup_length, 0); + assert_eq!(contributions[0].num_coding_passes, 0); + assert_eq!(contributions[1].data, encoded.data); + assert_eq!(contributions[1].ht_cleanup_length, 3); assert_eq!(contributions[1].ht_refinement_length, 2); - assert_eq!(contributions[1].num_coding_passes, 2); + assert_eq!(contributions[1].num_coding_passes, 3); +} + +#[test] +fn ht_layer_contributions_preserve_refinement_metadata_when_co_located() { + let encoded = bitplane_encode::EncodedCodeBlock { + data: vec![0x11, 0x22, 0x33, 0x44, 0x55, 0x66], + num_coding_passes: 3, + num_zero_bitplanes: 2, + ht_cleanup_length: 3, + ht_refinement_length: 3, + ht_sigprop_length: 1, + ht_magref_length: 2, + ht_distortion_deltas: [1.0; 3], + }; + + let contributions = + ht_layer_contributions(&encoded, 3, &[0, 1, 2]).expect("layered HT pass assignment"); + + assert_eq!(contributions.len(), 3); + assert!(contributions[0].data.is_empty()); + assert_eq!(contributions[0].ht_cleanup_length, 0); + assert_eq!(contributions[0].ht_refinement_length, 0); + assert_eq!(contributions[0].num_coding_passes, 0); + assert!(contributions[1].data.is_empty()); + assert_eq!(contributions[1].ht_cleanup_length, 0); + assert_eq!(contributions[1].ht_refinement_length, 0); + assert_eq!(contributions[1].num_coding_passes, 0); + assert_eq!(contributions[2].data, encoded.data); + assert_eq!(contributions[2].ht_cleanup_length, 3); + assert_eq!(contributions[2].ht_refinement_length, 3); + assert_eq!(contributions[2].num_coding_passes, 3); } #[test] -fn htj2k_lossy_quality_layers_decode_split_refinement_layer() { +fn htj2k_lossy_quality_layers_decode_atomic_ht_sets() { let width = 32; let height = 32; let pixels = gradient_u8(width, height); @@ -2499,3 +2628,139 @@ fn htj2k_lossy_quality_layers_decode_split_refinement_layer() { max_abs_error(&pixels, &decoded.data) ); } + +#[test] +fn htj2k_bounded_candidate_rate_control_emits_one_decodable_set() { + let width = 32; + let height = 32; + let pixels = gradient_u8(width, height); + let codestream = encode_htj2k( + &pixels, + width, + height, + 1, + 8, + false, + &EncodeOptions { + num_decomposition_levels: 0, + reversible: false, + guard_bits: 2, + num_layers: 3, + quality_layer_byte_targets: vec![128, 512, 2_048], + ..Default::default() + }, + ) + .expect("bounded HT candidate encode"); + + let image = Image::new( + &codestream, + &DecodeSettings { + resolve_palette_indices: true, + strict: true, + target_resolution: None, + }, + ) + .expect("parse bounded HT candidate codestream"); + let decoded = image + .decode_native() + .expect("decode bounded HT candidate codestream"); + assert_eq!(decoded.width, width); + assert_eq!(decoded.height, height); + assert_not_flat_128(&decoded.data); +} + +#[test] +fn htj2k_bounded_rate_control_offers_two_set_jobs_to_accelerators() { + #[derive(Default)] + struct CandidateCountingAccelerator { + set_jobs: usize, + } + + impl crate::J2kEncodeStageAccelerator for CandidateCountingAccelerator { + fn encode_ht_code_block_sets( + &mut self, + jobs: &[crate::J2kHtCodeBlockSetEncodeJob<'_>], + ) -> crate::J2kEncodeStageResult>> { + self.set_jobs += jobs.len(); + Ok(None) + } + } + + let pixels = gradient_u8(32, 32); + let options = EncodeOptions { + use_ht_block_coding: true, + num_decomposition_levels: 0, + reversible: false, + guard_bits: 2, + num_layers: 3, + quality_layer_byte_targets: vec![128, 512, 2_048], + ..Default::default() + }; + let mut accelerator = CandidateCountingAccelerator::default(); + + encode_with_accelerator(&pixels, 32, 32, 1, 8, false, &options, &mut accelerator) + .expect("bounded HT accelerated candidate encode"); + + assert!(accelerator.set_jobs >= 2); + assert_eq!(accelerator.set_jobs % 2, 0); +} + +#[test] +fn htj2k_bounded_rate_control_accepts_exact_accelerator_sets() { + #[derive(Default)] + struct ExactSetAccelerator; + + impl crate::J2kEncodeStageAccelerator for ExactSetAccelerator { + fn encode_ht_code_block_sets( + &mut self, + jobs: &[crate::J2kHtCodeBlockSetEncodeJob<'_>], + ) -> crate::J2kEncodeStageResult>> { + let mut workspace = crate::j2c::ht_block_encode::HtEncodeWorkspace::try_new() + .map_err(|error| crate::J2kEncodeStageError::backend("test", "workspace", error))?; + jobs.iter() + .map(|job| { + crate::j2c::ht_block_encode::try_encode_code_block_set_with_workspace( + job.coefficients, + job.width, + job.height, + job.total_bitplanes, + job.cleanup_bitplane, + job.target_coding_passes, + &mut workspace, + ) + .map(|encoded| crate::EncodedHtJ2kCodeBlockSet { + data: encoded.data, + cleanup_length: encoded.ht_cleanup_length, + sigprop_length: encoded.ht_sigprop_length, + magref_length: encoded.ht_magref_length, + num_coding_passes: encoded.num_coding_passes, + num_zero_bitplanes: encoded.num_zero_bitplanes, + }) + .map_err(|error| { + crate::J2kEncodeStageError::backend("test", "candidate encode", error) + }) + }) + .collect::, _>>() + .map(Some) + } + } + + let pixels = gradient_u8(32, 32); + let options = EncodeOptions { + use_ht_block_coding: true, + num_decomposition_levels: 0, + reversible: false, + guard_bits: 2, + num_layers: 3, + quality_layer_byte_targets: vec![128, 512, 2_048], + ..Default::default() + }; + let mut accelerator = ExactSetAccelerator; + let codestream = + encode_with_accelerator(&pixels, 32, 32, 1, 8, false, &options, &mut accelerator) + .expect("accelerated exact-set encode"); + + Image::new(&codestream, &DecodeSettings::default()) + .and_then(|image| image.decode_native()) + .expect("decode accelerated exact-set codestream"); +} diff --git a/crates/j2k-native/src/j2c/ht_block_encode.rs b/crates/j2k-native/src/j2c/ht_block_encode.rs index c60f3875..b9585d23 100644 --- a/crates/j2k-native/src/j2c/ht_block_encode.rs +++ b/crates/j2k-native/src/j2c/ht_block_encode.rs @@ -1,21 +1,29 @@ //! Scalar HTJ2K block encoding. - mod allocation; mod cleanup; +mod distortion; mod distribution; mod emit; mod facade; mod quad; mod refinement; +mod workspace; mod writers; - pub(crate) use allocation::ht_worker_allocation; pub(crate) use distribution::collect_encode_distribution; -pub(crate) use facade::effective_coding_passes; +pub(crate) use facade::try_encode_code_block_with_passes_in_workspace; +pub(crate) use facade::{ + candidate_cleanup_bitplanes, code_block_set_distortion_deltas, effective_coding_passes, + select_tile_code_block_candidates, tile_candidate_selection_workspace_bytes, + truncate_code_block_candidate, try_encode_code_block_candidate_sets_with_workspace, + HtCandidateRange, HtCandidateSelection, +}; #[cfg(test)] -pub(crate) use facade::{encode_code_block, encode_code_block_with_passes}; +pub(crate) use facade::{ + encode_code_block, encode_code_block_with_passes, try_encode_code_block_set_with_workspace, +}; pub(crate) use facade::{try_encode_code_block, try_encode_code_block_with_passes}; - +pub(crate) use workspace::HtEncodeWorkspace; #[cfg(test)] mod golden_tests; #[cfg(test)] diff --git a/crates/j2k-native/src/j2c/ht_block_encode/cleanup.rs b/crates/j2k-native/src/j2c/ht_block_encode/cleanup.rs index 28c2a7c5..97e60df8 100644 --- a/crates/j2k-native/src/j2c/ht_block_encode/cleanup.rs +++ b/crates/j2k-native/src/j2c/ht_block_encode/cleanup.rs @@ -4,7 +4,8 @@ use alloc::vec::Vec; use super::emit::{encode_first_quad_pair, encode_non_initial_quad_pair}; use super::quad::{FirstQuadPairRequest, NonInitialQuadPairRequest, QuadMarkerRows, QuadPairState}; -use super::writers::{terminate_mel_vlc, MagSgnEncoder, MelEncoder, VlcEncoder}; +use super::workspace::HtEncodeWorkspace; +use super::writers::terminate_mel_vlc; #[cfg(test)] use crate::j2c::coefficient_view::legacy_coefficient_view_error; use crate::j2c::coefficient_view::CoefficientBlockView; @@ -34,10 +35,26 @@ pub(super) fn encode_cleanup_segment_from_coefficients( .map_err(legacy_coefficient_view_error) } +#[cfg(test)] pub(super) fn try_encode_cleanup_segment_from_view( coefficients: CoefficientBlockView<'_, i32>, missing_msbs: u8, total_bitplanes: u8, +) -> EncodeResult> { + let mut workspace = HtEncodeWorkspace::try_new()?; + try_encode_cleanup_segment_from_view_in_workspace( + coefficients, + missing_msbs, + total_bitplanes, + &mut workspace, + ) +} + +pub(super) fn try_encode_cleanup_segment_from_view_in_workspace( + coefficients: CoefficientBlockView<'_, i32>, + missing_msbs: u8, + total_bitplanes: u8, + workspace: &mut HtEncodeWorkspace, ) -> EncodeResult> { let source = I32CleanupBlockView::new( coefficients, @@ -48,6 +65,7 @@ pub(super) fn try_encode_cleanup_segment_from_view( missing_msbs, coefficients.width(), coefficients.height(), + workspace, ) } @@ -58,8 +76,15 @@ pub(super) fn encode_cleanup_segment( width: usize, height: usize, ) -> Result, &'static str> { - try_encode_cleanup_segment_from_source(coefficients, missing_msbs, width, height) - .map_err(legacy_coefficient_view_error) + let mut workspace = HtEncodeWorkspace::try_new().map_err(|_| "HTJ2K workspace allocation")?; + try_encode_cleanup_segment_from_source( + coefficients, + missing_msbs, + width, + height, + &mut workspace, + ) + .map_err(legacy_coefficient_view_error) } #[expect( @@ -72,10 +97,14 @@ fn try_encode_cleanup_segment_from_source( missing_msbs: u8, width: usize, height: usize, + workspace: &mut HtEncodeWorkspace, ) -> EncodeResult> { - let mut mel = MelEncoder::try_new()?; - let mut vlc = VlcEncoder::try_new()?; - let mut ms = MagSgnEncoder::try_new()?; + workspace.reset_cleanup(); + let HtEncodeWorkspace { + mel, + vlc, + mag_sgn: ms, + } = workspace; let p = 30_u32.saturating_sub(u32::from(missing_msbs)); let stride = width; @@ -112,9 +141,9 @@ fn try_encode_cleanup_segment_from_source( s: &mut s, }, }, - &mut mel, - &mut vlc, - &mut ms, + mel, + vlc, + ms, ) .map_err(ht_cleanup_invariant)?; x += 4; @@ -161,9 +190,9 @@ fn try_encode_cleanup_segment_from_source( s: &mut s, }, }, - &mut mel, - &mut vlc, - &mut ms, + mel, + vlc, + ms, ) .map_err(ht_cleanup_invariant)?; x += 4; @@ -172,7 +201,7 @@ fn try_encode_cleanup_segment_from_source( y += 2; } - terminate_mel_vlc(&mut mel, &mut vlc).map_err(ht_cleanup_invariant)?; + terminate_mel_vlc(mel, vlc).map_err(ht_cleanup_invariant)?; ms.terminate().map_err(ht_cleanup_invariant)?; let total_len = ms.pos + mel.pos + vlc.pos; diff --git a/crates/j2k-native/src/j2c/ht_block_encode/distortion.rs b/crates/j2k-native/src/j2c/ht_block_encode/distortion.rs new file mode 100644 index 00000000..b3eaa5ad --- /dev/null +++ b/crates/j2k-native/src/j2c/ht_block_encode/distortion.rs @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Per-pass squared-error reduction for HT PCRD candidates. + +use crate::j2c::coefficient_view::CoefficientBlockView; + +pub(super) fn pass_distortion_deltas( + coefficients: CoefficientBlockView<'_, i32>, + cleanup_bitplane: u8, + num_coding_passes: u8, +) -> [f64; 3] { + let cleanup_mask = !((1u64 << cleanup_bitplane) - 1); + let refinement_mask = cleanup_bitplane + .checked_sub(1) + .map_or(0, |bitplane| 1u64 << bitplane); + let mut deltas = [0.0; 3]; + + for &coefficient in coefficients.rows().flatten() { + let magnitude = u64::from(coefficient.unsigned_abs()); + let cleanup = magnitude & cleanup_mask; + let sigprop = if cleanup == 0 { + magnitude & refinement_mask + } else { + cleanup + }; + let magref = if cleanup == 0 { + sigprop + } else { + cleanup | (magnitude & refinement_mask) + }; + deltas[0] += squared_error(magnitude, 0) - squared_error(magnitude, cleanup); + if num_coding_passes > 1 { + deltas[1] += squared_error(magnitude, cleanup) - squared_error(magnitude, sigprop); + } + if num_coding_passes > 2 { + deltas[2] += squared_error(magnitude, sigprop) - squared_error(magnitude, magref); + } + } + for delta in deltas.iter_mut().take(usize::from(num_coding_passes)) { + *delta = delta.max(f64::EPSILON); + } + deltas +} + +#[expect( + clippy::cast_precision_loss, + reason = "PCRD distortion is intentionally accumulated in f64 after bounded integer reconstruction" +)] +fn squared_error(magnitude: u64, reconstruction: u64) -> f64 { + let error = magnitude.saturating_sub(reconstruction) as f64; + error * error +} diff --git a/crates/j2k-native/src/j2c/ht_block_encode/facade.rs b/crates/j2k-native/src/j2c/ht_block_encode/facade.rs index 3c0ff102..1ac6bc4b 100644 --- a/crates/j2k-native/src/j2c/ht_block_encode/facade.rs +++ b/crates/j2k-native/src/j2c/ht_block_encode/facade.rs @@ -1,17 +1,42 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 -use alloc::vec::Vec; - use super::super::bitplane_encode::EncodedCodeBlock; -use super::allocation::ht_worker_allocation; -use super::cleanup::{max_nonzero_magnitude_view, try_encode_cleanup_segment_from_view}; -use super::refinement::try_encode_refinement_segment_view; -use crate::j2c::coefficient_view::CoefficientBlockView; -use crate::j2c::encode::allocation::try_untracked_vec; -use crate::{EncodeError, EncodeResult}; +use super::workspace::HtEncodeWorkspace; +use crate::{j2c::coefficient_view::CoefficientBlockView, EncodeResult}; + +mod candidates; +mod core; +mod tile_candidates; +pub(crate) use candidates::{ + candidate_cleanup_bitplanes, code_block_set_distortion_deltas, truncate_code_block_candidate, + try_encode_code_block_candidate_sets_with_workspace, +}; +pub(crate) use tile_candidates::{ + select_tile_code_block_candidates, tile_candidate_selection_workspace_bytes, HtCandidateRange, + HtCandidateSelection, +}; pub(super) const MAX_HT_BITPLANES: u8 = 31; +fn validate_set_request( + total_bitplanes: u8, + cleanup_bitplane: u8, + target_coding_passes: u8, +) -> crate::EncodeResult<()> { + let what = if !(1..=MAX_HT_BITPLANES).contains(&total_bitplanes) { + "HTJ2K scalar encoder currently supports 1..=31 bitplanes" + } else if !(1..=3).contains(&target_coding_passes) { + "HTJ2K scalar encoder currently supports cleanup, sigprop, and one magref refinement pass" + } else if cleanup_bitplane >= total_bitplanes { + "HTJ2K cleanup bitplane must be below the configured bitplane count" + } else if cleanup_bitplane == 0 && target_coding_passes > 1 { + "HTJ2K cleanup bitplane zero cannot carry refinement passes" + } else { + return Ok(()); + }; + Err(crate::EncodeError::InvalidInput { what }) +} + pub(crate) const fn effective_coding_passes(total_bitplanes: u8, requested: u8) -> u8 { if requested >= 2 && total_bitplanes > 1 { requested @@ -35,107 +60,88 @@ pub(crate) fn try_encode_code_block_with_passes( height: u32, total_bitplanes: u8, target_coding_passes: u8, +) -> EncodeResult { + let mut workspace = HtEncodeWorkspace::try_new()?; + try_encode_code_block_with_passes_in_workspace( + coefficients, + width, + height, + total_bitplanes, + target_coding_passes, + &mut workspace, + ) +} + +pub(crate) fn try_encode_code_block_with_passes_in_workspace( + coefficients: &[i32], + width: u32, + height: u32, + total_bitplanes: u8, + target_coding_passes: u8, + workspace: &mut HtEncodeWorkspace, +) -> EncodeResult { + let coefficients = + CoefficientBlockView::try_contiguous(coefficients, width as usize, height as usize)?; + try_encode_code_block_view_in_workspace( + coefficients, + total_bitplanes, + target_coding_passes, + workspace, + ) +} + +pub(crate) fn try_encode_code_block_set_with_workspace( + coefficients: &[i32], + width: u32, + height: u32, + total_bitplanes: u8, + cleanup_bitplane: u8, + target_coding_passes: u8, + workspace: &mut HtEncodeWorkspace, ) -> EncodeResult { let coefficients = CoefficientBlockView::try_contiguous(coefficients, width as usize, height as usize)?; - try_encode_code_block_view(coefficients, total_bitplanes, target_coding_passes) + core::try_encode_code_block_set_view_in_workspace( + coefficients, + total_bitplanes, + cleanup_bitplane, + target_coding_passes, + true, + workspace, + ) } -#[expect( - clippy::cast_possible_truncation, - reason = "a u32 magnitude has at most 32 bitplanes, which always fits the u8 metadata field" -)] +#[cfg(test)] pub(crate) fn try_encode_code_block_view( coefficients: CoefficientBlockView<'_, i32>, total_bitplanes: u8, target_coding_passes: u8, ) -> EncodeResult { - if total_bitplanes == 0 || total_bitplanes > MAX_HT_BITPLANES { - return Err(EncodeError::InvalidInput { - what: "HTJ2K scalar encoder currently supports 1..=31 bitplanes", - }); - } - if target_coding_passes == 0 || target_coding_passes > 3 { - return Err(EncodeError::InvalidInput { - what: "HTJ2K scalar encoder currently supports cleanup, sigprop, and one magref refinement pass", - }); - } - let allocation = ht_worker_allocation( - coefficients.width(), - coefficients.height(), + let mut workspace = HtEncodeWorkspace::try_new()?; + try_encode_code_block_view_in_workspace( + coefficients, + total_bitplanes, target_coding_passes, - )?; - - let Some(max_magnitude) = max_nonzero_magnitude_view(coefficients) else { - return Ok(EncodedCodeBlock { - data: Vec::new(), - num_coding_passes: 0, - num_zero_bitplanes: total_bitplanes, - ht_cleanup_length: 0, - ht_refinement_length: 0, - }); - }; - - let block_bitplanes = (u32::BITS - max_magnitude.leading_zeros()) as u8; - if block_bitplanes > total_bitplanes { - return Err(EncodeError::InvalidInput { - what: "HTJ2K block magnitude exceeds configured bitplane count", - }); - } - - let effective_coding_passes = effective_coding_passes(total_bitplanes, target_coding_passes); - let cleanup_bitplanes = if effective_coding_passes >= 2 { 2 } else { 1 }; - let missing_msbs = total_bitplanes.saturating_sub(cleanup_bitplanes); - let cleanup = - try_encode_cleanup_segment_from_view(coefficients, missing_msbs, total_bitplanes)?; - if cleanup.len() > allocation.cleanup_bytes { - return Err(EncodeError::InternalInvariant { - what: "HTJ2K cleanup segment exceeded its checked bound", - }); - } - let ht_cleanup_length = - u32::try_from(cleanup.len()).map_err(|_| EncodeError::InternalInvariant { - what: "HTJ2K cleanup segment exceeds u32 length", - })?; - let refinement = if effective_coding_passes > 1 { - try_encode_refinement_segment_view( - coefficients, - 1_i32 << (cleanup_bitplanes - 1), - effective_coding_passes, - allocation, - )? - } else { - Vec::new() - }; - let ht_refinement_length = - u32::try_from(refinement.len()).map_err(|_| EncodeError::InternalInvariant { - what: "HTJ2K refinement segment exceeds u32 length", - })?; - let combined_len = - cleanup - .len() - .checked_add(refinement.len()) - .ok_or(EncodeError::ArithmeticOverflow { - what: "HTJ2K combined block payload", - })?; - if combined_len > allocation.output_bytes { - return Err(EncodeError::InternalInvariant { - what: "HTJ2K block output exceeded its checked bound", - }); - } - let mut data = try_untracked_vec(combined_len, "HTJ2K block output")?; - data.extend_from_slice(&cleanup); - data.extend_from_slice(&refinement); - - Ok(EncodedCodeBlock { - data, - num_coding_passes: effective_coding_passes, - num_zero_bitplanes: missing_msbs, - ht_cleanup_length, - ht_refinement_length, - }) + &mut workspace, + ) } +fn try_encode_code_block_view_in_workspace( + coefficients: CoefficientBlockView<'_, i32>, + total_bitplanes: u8, + target_coding_passes: u8, + workspace: &mut HtEncodeWorkspace, +) -> EncodeResult { + let cleanup_bitplane = u8::from(target_coding_passes >= 2 && total_bitplanes > 1); + core::try_encode_code_block_set_view_in_workspace( + coefficients, + total_bitplanes, + cleanup_bitplane, + target_coding_passes, + false, + workspace, + ) +} #[cfg(test)] mod legacy; #[cfg(test)] diff --git a/crates/j2k-native/src/j2c/ht_block_encode/facade/candidates.rs b/crates/j2k-native/src/j2c/ht_block_encode/facade/candidates.rs new file mode 100644 index 00000000..f1472c2d --- /dev/null +++ b/crates/j2k-native/src/j2c/ht_block_encode/facade/candidates.rs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Bounded consecutive-set generation for HT post-compression selection. + +use alloc::vec::Vec; + +use super::try_encode_code_block_set_with_workspace; +use crate::j2c::bitplane_encode::EncodedCodeBlock; +use crate::j2c::coefficient_view::CoefficientBlockView; +use crate::j2c::ht_block_encode::distortion::pass_distortion_deltas; +use crate::j2c::ht_block_encode::workspace::HtEncodeWorkspace; +use crate::{EncodeError, EncodeResult}; + +pub(crate) fn candidate_cleanup_bitplanes(total_bitplanes: u8) -> EncodeResult<&'static [u8]> { + match total_bitplanes { + 0 => Err(EncodeError::InvalidInput { + what: "HTJ2K scalar encoder currently supports 1..=31 bitplanes", + }), + 1 => Ok(&[0]), + 2 => Ok(&[1]), + _ => Ok(&[2, 1]), + } +} + +pub(crate) fn code_block_set_distortion_deltas( + coefficients: &[i32], + width: u32, + height: u32, + cleanup_bitplane: u8, + num_coding_passes: u8, +) -> EncodeResult<[f64; 3]> { + let coefficients = + CoefficientBlockView::try_contiguous(coefficients, width as usize, height as usize)?; + Ok(pass_distortion_deltas( + coefficients, + cleanup_bitplane, + num_coding_passes, + )) +} + +pub(crate) fn try_encode_code_block_candidate_sets_with_workspace( + coefficients: &[i32], + width: u32, + height: u32, + total_bitplanes: u8, + workspace: &mut HtEncodeWorkspace, +) -> EncodeResult> { + let cleanup_bitplanes = candidate_cleanup_bitplanes(total_bitplanes)?; + let mut candidates = Vec::new(); + candidates + .try_reserve_exact(cleanup_bitplanes.len()) + .map_err(|_| EncodeError::HostAllocationFailed { + what: "HTJ2K candidate set owners", + bytes: cleanup_bitplanes + .len() + .saturating_mul(core::mem::size_of::()), + })?; + for &cleanup_bitplane in cleanup_bitplanes { + candidates.push(try_encode_code_block_set_with_workspace( + coefficients, + width, + height, + total_bitplanes, + cleanup_bitplane, + if cleanup_bitplane == 0 { 1 } else { 3 }, + workspace, + )?); + } + Ok(candidates) +} + +pub(crate) fn truncate_code_block_candidate( + mut candidate: EncodedCodeBlock, + num_coding_passes: u8, +) -> EncodeResult { + if num_coding_passes == 0 || num_coding_passes > candidate.num_coding_passes { + return Err(EncodeError::InternalInvariant { + what: "HTJ2K selected candidate pass count is invalid", + }); + } + let refinement_length = match num_coding_passes { + 1 => 0, + 2 => candidate.ht_sigprop_length, + _ => candidate.ht_refinement_length, + }; + let data_length = candidate + .ht_cleanup_length + .checked_add(refinement_length) + .and_then(|length| usize::try_from(length).ok()) + .ok_or(EncodeError::ArithmeticOverflow { + what: "HTJ2K selected candidate length", + })?; + candidate.data.truncate(data_length); + candidate.num_coding_passes = num_coding_passes; + candidate.ht_refinement_length = refinement_length; + if num_coding_passes < 3 { + candidate.ht_magref_length = 0; + candidate.ht_distortion_deltas[2] = 0.0; + } + if num_coding_passes < 2 { + candidate.ht_sigprop_length = 0; + candidate.ht_distortion_deltas[1] = 0.0; + } + Ok(candidate) +} diff --git a/crates/j2k-native/src/j2c/ht_block_encode/facade/core.rs b/crates/j2k-native/src/j2c/ht_block_encode/facade/core.rs new file mode 100644 index 00000000..0f3a6ffb --- /dev/null +++ b/crates/j2k-native/src/j2c/ht_block_encode/facade/core.rs @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +use alloc::vec::Vec; + +use super::validate_set_request; +use crate::j2c::bitplane_encode::EncodedCodeBlock; +use crate::j2c::coefficient_view::CoefficientBlockView; +use crate::j2c::encode::allocation::try_untracked_vec; +use crate::j2c::ht_block_encode::allocation::ht_worker_allocation; +use crate::j2c::ht_block_encode::cleanup::{ + max_nonzero_magnitude_view, try_encode_cleanup_segment_from_view_in_workspace, +}; +use crate::j2c::ht_block_encode::distortion::pass_distortion_deltas; +use crate::j2c::ht_block_encode::refinement::{ + try_encode_refinement_segment_view, EncodedRefinementSegment, +}; +use crate::j2c::ht_block_encode::workspace::HtEncodeWorkspace; +use crate::{EncodeError, EncodeResult}; + +pub(super) fn try_encode_code_block_set_view_in_workspace( + coefficients: CoefficientBlockView<'_, i32>, + total_bitplanes: u8, + cleanup_bitplane: u8, + target_coding_passes: u8, + collect_distortion: bool, + workspace: &mut HtEncodeWorkspace, +) -> EncodeResult { + validate_set_request(total_bitplanes, cleanup_bitplane, target_coding_passes)?; + let allocation = ht_worker_allocation( + coefficients.width(), + coefficients.height(), + target_coding_passes, + )?; + + let Some(max_magnitude) = max_nonzero_magnitude_view(coefficients) else { + return Ok(EncodedCodeBlock { + data: Vec::new(), + num_coding_passes: 0, + num_zero_bitplanes: total_bitplanes, + ht_cleanup_length: 0, + ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], + }); + }; + + let block_bitplanes = crate::math::bit_width_u32(max_magnitude); + if block_bitplanes > total_bitplanes { + return Err(EncodeError::InvalidInput { + what: "HTJ2K block magnitude exceeds configured bitplane count", + }); + } + + let effective_coding_passes = if cleanup_bitplane == 0 { + 1 + } else { + target_coding_passes + }; + let missing_msbs = total_bitplanes + .checked_sub(cleanup_bitplane) + .and_then(|value| value.checked_sub(1)) + .ok_or(EncodeError::InvalidInput { + what: "HTJ2K cleanup bitplane is outside the configured bitplane range", + })?; + let cleanup = try_encode_cleanup_segment_from_view_in_workspace( + coefficients, + missing_msbs, + total_bitplanes, + workspace, + )?; + if cleanup.len() > allocation.cleanup_bytes { + return Err(EncodeError::InternalInvariant { + what: "HTJ2K cleanup segment exceeded its checked bound", + }); + } + let ht_cleanup_length = + u32::try_from(cleanup.len()).map_err(|_| EncodeError::InternalInvariant { + what: "HTJ2K cleanup segment exceeds u32 length", + })?; + let refinement = if effective_coding_passes > 1 { + try_encode_refinement_segment_view( + coefficients, + cleanup_bitplane, + effective_coding_passes, + allocation, + )? + } else { + EncodedRefinementSegment::default() + }; + let ht_refinement_length = + u32::try_from(refinement.data.len()).map_err(|_| EncodeError::InternalInvariant { + what: "HTJ2K refinement segment exceeds u32 length", + })?; + let combined_len = cleanup.len().checked_add(refinement.data.len()).ok_or( + EncodeError::ArithmeticOverflow { + what: "HTJ2K combined block payload", + }, + )?; + if combined_len > allocation.output_bytes { + return Err(EncodeError::InternalInvariant { + what: "HTJ2K block output exceeded its checked bound", + }); + } + let mut data = try_untracked_vec(combined_len, "HTJ2K block output")?; + data.extend_from_slice(&cleanup); + data.extend_from_slice(&refinement.data); + + Ok(EncodedCodeBlock { + data, + num_coding_passes: effective_coding_passes, + num_zero_bitplanes: missing_msbs, + ht_cleanup_length, + ht_refinement_length, + ht_sigprop_length: refinement.sigprop_length, + ht_magref_length: refinement.magref_length, + ht_distortion_deltas: if collect_distortion { + pass_distortion_deltas(coefficients, cleanup_bitplane, effective_coding_passes) + } else { + [0.0; 3] + }, + }) +} diff --git a/crates/j2k-native/src/j2c/ht_block_encode/facade/tile_candidates.rs b/crates/j2k-native/src/j2c/ht_block_encode/facade/tile_candidates.rs new file mode 100644 index 00000000..bbdeea79 --- /dev/null +++ b/crates/j2k-native/src/j2c/ht_block_encode/facade/tile_candidates.rs @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Whole-tile convex selection across alternate HT sets. + +use alloc::vec::Vec; +use core::cmp::Ordering; + +use crate::j2c::bitplane_encode::EncodedCodeBlock; +use crate::{EncodeError, EncodeResult}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct HtCandidateRange { + pub(crate) start: usize, + pub(crate) len: usize, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct HtCandidateSelection { + pub(crate) candidate_index: usize, + pub(crate) num_coding_passes: u8, +} + +#[derive(Clone, Copy, Debug)] +struct HtCandidatePoint { + candidate_index: usize, + num_coding_passes: u8, + rate: u64, + distortion: f64, +} + +pub(crate) fn tile_candidate_selection_workspace_bytes( + candidate_count: usize, + family_count: usize, +) -> EncodeResult { + let point_count = candidate_count + .checked_mul(9) + .ok_or(EncodeError::ArithmeticOverflow { + what: "HTJ2K tile candidate workspace point count", + })?; + let point_bytes = point_count + .checked_mul(core::mem::size_of::()) + .ok_or(EncodeError::ArithmeticOverflow { + what: "HTJ2K tile candidate workspace points", + })?; + let range_bytes = family_count + .checked_mul(core::mem::size_of::()) + .ok_or(EncodeError::ArithmeticOverflow { + what: "HTJ2K tile candidate workspace ranges", + })?; + let selection_bytes = family_count + .checked_mul(core::mem::size_of::()) + .ok_or(EncodeError::ArithmeticOverflow { + what: "HTJ2K tile candidate workspace selections", + })?; + let frontier_bytes = family_count + .checked_mul(core::mem::size_of::()) + .ok_or(EncodeError::ArithmeticOverflow { + what: "HTJ2K tile candidate workspace frontiers", + })?; + point_bytes + .checked_add(range_bytes) + .and_then(|bytes| bytes.checked_add(selection_bytes)) + .and_then(|bytes| bytes.checked_add(frontier_bytes)) + .ok_or(EncodeError::ArithmeticOverflow { + what: "HTJ2K tile candidate workspace", + }) +} + +pub(crate) fn select_tile_code_block_candidates( + candidates: &[EncodedCodeBlock], + ranges: &[HtCandidateRange], + byte_budget: u64, +) -> EncodeResult> { + let point_capacity = + candidates + .len() + .checked_mul(3) + .ok_or(EncodeError::ArithmeticOverflow { + what: "HTJ2K tile candidate point count", + })?; + let mut hull_points = Vec::new(); + hull_points.try_reserve_exact(point_capacity).map_err(|_| { + EncodeError::HostAllocationFailed { + what: "HTJ2K tile candidate points", + bytes: point_capacity.saturating_mul(core::mem::size_of::()), + } + })?; + let mut hull_ranges = Vec::new(); + hull_ranges + .try_reserve_exact(ranges.len()) + .map_err(|_| EncodeError::HostAllocationFailed { + what: "HTJ2K tile candidate hull ranges", + bytes: ranges + .len() + .saturating_mul(core::mem::size_of::()), + })?; + + for &range in ranges { + let family = candidate_family(candidates, range)?; + let start = hull_points.len(); + append_candidate_hull(family, range.start, &mut hull_points)?; + hull_ranges.push(HtCandidateRange { + start, + len: hull_points.len() - start, + }); + } + + allocate_hull_points(&hull_points, &hull_ranges, byte_budget) +} + +fn allocate_hull_points( + hull_points: &[HtCandidatePoint], + hull_ranges: &[HtCandidateRange], + byte_budget: u64, +) -> EncodeResult> { + let mut selections = Vec::new(); + selections + .try_reserve_exact(hull_ranges.len()) + .map_err(|_| EncodeError::HostAllocationFailed { + what: "HTJ2K tile candidate selections", + bytes: hull_ranges + .len() + .saturating_mul(core::mem::size_of::()), + })?; + let mut frontiers = Vec::new(); + frontiers + .try_reserve_exact(hull_ranges.len()) + .map_err(|_| EncodeError::HostAllocationFailed { + what: "HTJ2K tile candidate frontiers", + bytes: hull_ranges + .len() + .saturating_mul(core::mem::size_of::()), + })?; + let mut used = 0u64; + for range in hull_ranges { + let first = *hull_points + .get(range.start) + .ok_or(EncodeError::InternalInvariant { + what: "HTJ2K tile candidate hull is empty", + })?; + used = used + .checked_add(first.rate) + .ok_or(EncodeError::ArithmeticOverflow { + what: "HTJ2K tile candidate cleanup budget", + })?; + selections.push(selection(first)); + frontiers.push(1); + } + + while let Some((family, point, delta_rate)) = best_fitting_edge( + hull_points, + hull_ranges, + &frontiers, + byte_budget.saturating_sub(used), + )? { + used = used + .checked_add(delta_rate) + .ok_or(EncodeError::ArithmeticOverflow { + what: "HTJ2K tile candidate selected bytes", + })?; + selections[family] = selection(point); + frontiers[family] += 1; + } + Ok(selections) +} + +fn best_fitting_edge( + points: &[HtCandidatePoint], + ranges: &[HtCandidateRange], + frontiers: &[usize], + remaining: u64, +) -> EncodeResult> { + let mut best = None::<(usize, HtCandidatePoint, u64, f64)>; + for (family, range) in ranges.iter().enumerate() { + let frontier = frontiers[family]; + if frontier >= range.len { + continue; + } + let previous = points[range.start + frontier - 1]; + let next = points[range.start + frontier]; + let delta_rate = + next.rate + .checked_sub(previous.rate) + .ok_or(EncodeError::InternalInvariant { + what: "HTJ2K tile candidate hull rate is not monotonic", + })?; + if delta_rate > remaining { + continue; + } + let slope = edge_slope(previous, next); + let replace = best.is_none_or(|(best_family, best_point, _, best_slope)| { + slope + .total_cmp(&best_slope) + .then_with(|| best_family.cmp(&family)) + .then_with(|| best_point.candidate_index.cmp(&next.candidate_index)) + .then_with(|| best_point.num_coding_passes.cmp(&next.num_coding_passes)) + .is_gt() + }); + if replace { + best = Some((family, next, delta_rate, slope)); + } + } + Ok(best.map(|(family, point, rate, _)| (family, point, rate))) +} + +fn candidate_family( + candidates: &[EncodedCodeBlock], + range: HtCandidateRange, +) -> EncodeResult<&[EncodedCodeBlock]> { + let end = range + .start + .checked_add(range.len) + .ok_or(EncodeError::ArithmeticOverflow { + what: "HTJ2K tile candidate family range", + })?; + if range.len == 0 || end > candidates.len() { + return Err(EncodeError::InternalInvariant { + what: "HTJ2K tile candidate family range is invalid", + }); + } + Ok(&candidates[range.start..end]) +} + +fn append_candidate_hull( + candidates: &[EncodedCodeBlock], + candidate_offset: usize, + output: &mut Vec, +) -> EncodeResult<()> { + let hull_start = output.len(); + let mut points = candidate_points(candidates, candidate_offset)?; + points.sort_by(|left, right| { + left.rate + .cmp(&right.rate) + .then_with(|| right.distortion.total_cmp(&left.distortion)) + .then_with(|| left.candidate_index.cmp(&right.candidate_index)) + .then_with(|| left.num_coding_passes.cmp(&right.num_coding_passes)) + }); + let nondominated = nondominated_points(points)?; + for point in nondominated { + while output.len() >= hull_start + 2 { + let left = output[output.len() - 2]; + let middle = output[output.len() - 1]; + if edge_slope(left, middle).total_cmp(&edge_slope(middle, point)) == Ordering::Greater { + break; + } + output.pop(); + } + output.push(point); + } + Ok(()) +} + +fn candidate_points( + candidates: &[EncodedCodeBlock], + candidate_offset: usize, +) -> EncodeResult> { + let capacity = candidates + .len() + .checked_mul(3) + .ok_or(EncodeError::ArithmeticOverflow { + what: "HTJ2K block candidate point count", + })?; + let mut points = Vec::new(); + points + .try_reserve_exact(capacity) + .map_err(|_| EncodeError::HostAllocationFailed { + what: "HTJ2K block candidate points", + bytes: capacity.saturating_mul(core::mem::size_of::()), + })?; + for (local_index, candidate) in candidates.iter().enumerate() { + if candidate.num_coding_passes == 0 { + points.push(HtCandidatePoint { + candidate_index: candidate_offset + local_index, + num_coding_passes: 0, + rate: 0, + distortion: 0.0, + }); + continue; + } + let mut rate = 0u64; + let mut distortion = 0.0; + for pass in 0..candidate.num_coding_passes { + rate = rate.checked_add(pass_rate(candidate, pass)).ok_or( + EncodeError::ArithmeticOverflow { + what: "HTJ2K candidate cumulative rate", + }, + )?; + let delta = candidate.ht_distortion_deltas[usize::from(pass)]; + if !delta.is_finite() || delta < 0.0 { + return Err(EncodeError::InternalInvariant { + what: "HTJ2K candidate distortion is invalid", + }); + } + distortion += delta; + points.push(HtCandidatePoint { + candidate_index: candidate_offset + local_index, + num_coding_passes: pass + 1, + rate, + distortion, + }); + } + } + Ok(points) +} + +fn nondominated_points(points: Vec) -> EncodeResult> { + let mut output = Vec::new(); + output + .try_reserve_exact(points.len()) + .map_err(|_| EncodeError::HostAllocationFailed { + what: "HTJ2K nondominated candidate points", + bytes: points + .len() + .saturating_mul(core::mem::size_of::()), + })?; + let mut best_distortion = f64::NEG_INFINITY; + let mut previous_rate = None; + for point in points { + if previous_rate == Some(point.rate) { + continue; + } + previous_rate = Some(point.rate); + if point.distortion <= best_distortion { + continue; + } + best_distortion = point.distortion; + output.push(point); + } + Ok(output) +} + +fn pass_rate(candidate: &EncodedCodeBlock, pass: u8) -> u64 { + u64::from(match pass { + 0 => candidate.ht_cleanup_length, + 1 => candidate.ht_sigprop_length, + _ => candidate.ht_magref_length, + }) +} + +#[expect( + clippy::cast_precision_loss, + reason = "an HT point sums at most three u32 segment lengths, which is exactly representable in f64" +)] +fn edge_slope(left: HtCandidatePoint, right: HtCandidatePoint) -> f64 { + let rate = right.rate - left.rate; + if rate == 0 { + f64::INFINITY + } else { + (right.distortion - left.distortion) / rate as f64 + } +} + +fn selection(point: HtCandidatePoint) -> HtCandidateSelection { + HtCandidateSelection { + candidate_index: point.candidate_index, + num_coding_passes: point.num_coding_passes, + } +} diff --git a/crates/j2k-native/src/j2c/ht_block_encode/golden_tests.rs b/crates/j2k-native/src/j2c/ht_block_encode/golden_tests.rs index 1c6b34a8..f7e5b9ae 100644 --- a/crates/j2k-native/src/j2c/ht_block_encode/golden_tests.rs +++ b/crates/j2k-native/src/j2c/ht_block_encode/golden_tests.rs @@ -287,7 +287,7 @@ fn validation_error_text_and_empty_block_result_remain_exact() { #[test] fn encoder_modules_remain_focused_without_broad_suppressions() { const ROOT: &str = include_str!("../ht_block_encode.rs"); - const MODULES: [(&str, &str, usize); 12] = [ + const MODULES: [(&str, &str, usize); 15] = [ ("allocation", include_str!("allocation.rs"), 220), ( "allocation/refinement", @@ -299,6 +299,17 @@ fn encoder_modules_remain_focused_without_broad_suppressions() { ("distribution", include_str!("distribution.rs"), 390), ("emit", include_str!("emit.rs"), 270), ("facade", include_str!("facade.rs"), 150), + ( + "facade/candidates", + include_str!("facade/candidates.rs"), + 180, + ), + ( + "facade/tile_candidates", + include_str!("facade/tile_candidates.rs"), + 360, + ), + ("facade/core", include_str!("facade/core.rs"), 130), ("facade/legacy", include_str!("facade/legacy.rs"), 60), ("quad", include_str!("quad.rs"), 500), ("refinement", include_str!("refinement.rs"), 420), diff --git a/crates/j2k-native/src/j2c/ht_block_encode/refinement.rs b/crates/j2k-native/src/j2c/ht_block_encode/refinement.rs index 4c071ccf..34ab08f6 100644 --- a/crates/j2k-native/src/j2c/ht_block_encode/refinement.rs +++ b/crates/j2k-native/src/j2c/ht_block_encode/refinement.rs @@ -13,15 +13,34 @@ const SIGPROP_SPREAD_MASKS: [u32; 16] = [ 0x76000, 0xEC000, 0xC8000, ]; +#[derive(Default)] +pub(super) struct EncodedRefinementSegment { + pub(super) data: Vec, + pub(super) sigprop_length: u32, + pub(super) magref_length: u32, +} + mod writers; use writers::{ForwardRefinementBitWriter, ReverseRefinementBitWriter}; pub(super) fn try_encode_refinement_segment_view( coefficients: CoefficientBlockView<'_, i32>, - cleanup_significance_threshold: i32, + cleanup_bitplane: u8, num_coding_passes: u8, allocation: HtWorkerAllocation, -) -> EncodeResult> { +) -> EncodeResult { + let cleanup_significance_threshold = + 1_i32 + .checked_shl(u32::from(cleanup_bitplane)) + .ok_or(EncodeError::InvalidInput { + what: "HTJ2K cleanup bitplane exceeds the refinement coefficient range", + })?; + let refinement_mask = + 1_u32 + .checked_shl(u32::from(cleanup_bitplane - 1)) + .ok_or(EncodeError::InvalidInput { + what: "HTJ2K refinement bitplane exceeds the coefficient range", + })?; let width = coefficients.width(); let height = coefficients.height(); let width_u32 = u32::try_from(width).map_err(|_| EncodeError::InvalidInput { @@ -63,6 +82,7 @@ pub(super) fn try_encode_refinement_segment_view( width_u32, height_u32, mstr, + refinement_mask, allocation, )?; let magref = if num_coding_passes > 2 { @@ -72,6 +92,7 @@ pub(super) fn try_encode_refinement_segment_view( width_u32, height_u32, mstr, + refinement_mask, allocation, )? } else { @@ -92,7 +113,17 @@ pub(super) fn try_encode_refinement_segment_view( let mut refinement = try_untracked_vec(combined_len, "HTJ2K refinement segment")?; refinement.extend_from_slice(&sigprop); refinement.extend_from_slice(&magref); - Ok(refinement) + Ok(EncodedRefinementSegment { + data: refinement, + sigprop_length: u32::try_from(sigprop.len()).map_err(|_| { + EncodeError::InternalInvariant { + what: "HTJ2K SigProp segment exceeds u32 length", + } + })?, + magref_length: u32::try_from(magref.len()).map_err(|_| EncodeError::InternalInvariant { + what: "HTJ2K MagRef segment exceeds u32 length", + })?, + }) } #[expect( @@ -153,6 +184,7 @@ fn write_sigprop_refinement_bits( width: u32, height: u32, mstr: usize, + refinement_mask: u32, allocation: HtWorkerAllocation, ) -> EncodeResult> { let mut prev_row_sig = try_untracked_vec_filled( @@ -213,7 +245,7 @@ fn write_sigprop_refinement_bits( processed |= sample_mask; let coeff = coefficient_for_sigprop_bit(coefficients, x, y, bit)?; - let significant = coeff != 0; + let significant = coeff.unsigned_abs() & refinement_mask != 0; writer.push_bit(significant)?; if significant { new_sig |= sample_mask; @@ -253,6 +285,7 @@ fn write_magref_refinement_bits( width: u32, height: u32, mstr: usize, + refinement_mask: u32, allocation: HtWorkerAllocation, ) -> EncodeResult> { let mut writer = @@ -274,7 +307,7 @@ fn write_magref_refinement_bits( if (sig & sample_mask) != 0 { let bit = sample_mask.trailing_zeros(); let coeff = coefficient_for_sigprop_bit(coefficients, x, y, bit)?; - writer.push_bit((coeff.unsigned_abs() & 1) != 0)?; + writer.push_bit((coeff.unsigned_abs() & refinement_mask) != 0)?; } sample_mask <<= 1; } diff --git a/crates/j2k-native/src/j2c/ht_block_encode/tests.rs b/crates/j2k-native/src/j2c/ht_block_encode/tests.rs index cd4dc466..171c7539 100644 --- a/crates/j2k-native/src/j2c/ht_block_encode/tests.rs +++ b/crates/j2k-native/src/j2c/ht_block_encode/tests.rs @@ -9,7 +9,51 @@ use super::cleanup::{ encode_cleanup_segment_from_coefficients, }; use super::distribution::collect_encode_distribution; -use super::facade::{encode_code_block, encode_code_block_view, encode_code_block_with_passes}; +use super::facade::{ + encode_code_block, encode_code_block_view, encode_code_block_with_passes, + select_tile_code_block_candidates, try_encode_code_block_candidate_sets_with_workspace, + try_encode_code_block_set_with_workspace, try_encode_code_block_with_passes_in_workspace, + HtCandidateRange, +}; +use super::workspace::HtEncodeWorkspace; + +#[test] +fn reusable_workspace_is_byte_exact_across_shrinking_and_growing_blocks() { + let large = (0_i32..64 * 64) + .map(|index| match index % 5 { + 0 => 0, + 1 => index & 255, + 2 => -(index & 127), + 3 => 31 - (index & 63), + _ => index & 7, + }) + .collect::>(); + let small = [0, 3, -2, 1]; + let mut workspace = HtEncodeWorkspace::try_new().expect("HT workspace allocation"); + + for (coefficients, width, height) in [ + (large.as_slice(), 64, 64), + (small.as_slice(), 2, 2), + (large.as_slice(), 64, 64), + ] { + let expected = encode_code_block_with_passes(coefficients, width, height, 10, 1) + .expect("fresh-workspace encode"); + let actual = try_encode_code_block_with_passes_in_workspace( + coefficients, + width, + height, + 10, + 1, + &mut workspace, + ) + .expect("reused-workspace encode"); + assert_eq!(actual.data, expected.data); + assert_eq!(actual.num_coding_passes, expected.num_coding_passes); + assert_eq!(actual.num_zero_bitplanes, expected.num_zero_bitplanes); + assert_eq!(actual.ht_cleanup_length, expected.ht_cleanup_length); + assert_eq!(actual.ht_refinement_length, expected.ht_refinement_length); + } +} #[test] fn ht_strided_block_is_byte_exact_for_cleanup_and_refinement_passes() { @@ -231,3 +275,128 @@ fn test_encode_cleanup_only_nonzero_block() { assert_eq!(encoded.num_zero_bitplanes, 4); assert!(encoded.data.len() >= 2); } + +#[test] +fn explicit_cleanup_bitplane_selects_the_requested_ht_set() { + let coefficients = [0, 7, -6, 3]; + let mut workspace = HtEncodeWorkspace::try_new().expect("HT workspace allocation"); + + let coarse = + try_encode_code_block_set_with_workspace(&coefficients, 2, 2, 8, 2, 3, &mut workspace) + .expect("encode p=2 HT set"); + let fine = + try_encode_code_block_set_with_workspace(&coefficients, 2, 2, 8, 1, 3, &mut workspace) + .expect("encode p=1 HT set"); + + assert_eq!(coarse.num_zero_bitplanes, 5); + assert_eq!(fine.num_zero_bitplanes, 6); + assert_eq!(coarse.num_coding_passes, 3); + assert_eq!(fine.num_coding_passes, 3); + assert_eq!( + coarse.ht_sigprop_length + coarse.ht_magref_length, + coarse.ht_refinement_length + ); + assert_eq!( + fine.ht_sigprop_length + fine.ht_magref_length, + fine.ht_refinement_length + ); + assert!(coarse.ht_distortion_deltas.iter().all(|delta| *delta > 0.0)); + assert!(fine.ht_distortion_deltas.iter().all(|delta| *delta > 0.0)); + assert_ne!(coarse.data, fine.data); +} + +#[test] +fn explicit_cleanup_bitplane_rejects_impossible_refinement() { + let mut workspace = HtEncodeWorkspace::try_new().expect("HT workspace allocation"); + let error = try_encode_code_block_set_with_workspace(&[1], 1, 1, 1, 0, 2, &mut workspace) + .expect_err("p=0 has no lower refinement bitplane"); + + assert!(matches!(error, crate::EncodeError::InvalidInput { .. })); +} + +#[test] +fn bounded_candidate_generation_produces_two_consecutive_ht_sets() { + let coefficients = [0, 7, -6, 3]; + let mut workspace = HtEncodeWorkspace::try_new().expect("HT workspace allocation"); + + let candidates = + try_encode_code_block_candidate_sets_with_workspace(&coefficients, 2, 2, 8, &mut workspace) + .expect("encode bounded HT candidates"); + + assert_eq!(candidates.len(), 2); + assert_eq!(candidates[0].num_zero_bitplanes, 5); + assert_eq!(candidates[1].num_zero_bitplanes, 6); + assert!(candidates + .iter() + .all(|candidate| candidate.num_coding_passes == 3)); +} + +#[test] +fn tile_candidate_selection_spends_the_shared_budget_on_the_best_block() { + let candidates = [ + synthetic_candidate(2, 4, [10.0, 10.0, 0.0]), + synthetic_candidate(2, 1, [10.0, 100.0, 0.0]), + ]; + let ranges = [ + HtCandidateRange { start: 0, len: 1 }, + HtCandidateRange { start: 1, len: 1 }, + ]; + + let selected = select_tile_code_block_candidates(&candidates, &ranges, 5) + .expect("tile candidate selection"); + + assert_eq!(selected[0].candidate_index, 0); + assert_eq!(selected[0].num_coding_passes, 1); + assert_eq!(selected[1].candidate_index, 1); + assert_eq!(selected[1].num_coding_passes, 2); +} + +#[test] +fn tile_candidate_hull_can_switch_to_a_better_alternate_set() { + let candidates = [ + synthetic_candidate(3, 2, [30.0, 5.0, 0.0]), + synthetic_candidate(6, 0, [90.0, 0.0, 0.0]), + ]; + let ranges = [HtCandidateRange { start: 0, len: 2 }]; + + let selected = select_tile_code_block_candidates(&candidates, &ranges, 6) + .expect("tile candidate selection"); + + assert_eq!(selected[0].candidate_index, 1); + assert_eq!(selected[0].num_coding_passes, 1); +} + +#[test] +fn tile_candidate_selection_keeps_the_cheapest_cleanup_when_under_budget() { + let candidates = [ + synthetic_candidate(5, 0, [20.0, 0.0, 0.0]), + synthetic_candidate(7, 0, [100.0, 0.0, 0.0]), + ]; + let ranges = [HtCandidateRange { start: 0, len: 2 }]; + + let selected = select_tile_code_block_candidates(&candidates, &ranges, 1) + .expect("tile candidate selection"); + + assert_eq!(selected[0].candidate_index, 0); + assert_eq!(selected[0].num_coding_passes, 1); +} + +fn synthetic_candidate( + cleanup_length: u32, + refinement_length: u32, + distortion: [f64; 3], +) -> crate::j2c::bitplane_encode::EncodedCodeBlock { + let sigprop_length = refinement_length.min(1); + let magref_length = refinement_length - sigprop_length; + let num_coding_passes = 1 + u8::from(sigprop_length != 0) + u8::from(magref_length != 0); + crate::j2c::bitplane_encode::EncodedCodeBlock { + data: vec![0; (cleanup_length + refinement_length) as usize], + num_coding_passes, + num_zero_bitplanes: 0, + ht_cleanup_length: cleanup_length, + ht_refinement_length: refinement_length, + ht_sigprop_length: sigprop_length, + ht_magref_length: magref_length, + ht_distortion_deltas: distortion, + } +} diff --git a/crates/j2k-native/src/j2c/ht_block_encode/workspace.rs b/crates/j2k-native/src/j2c/ht_block_encode/workspace.rs new file mode 100644 index 00000000..982a39cf --- /dev/null +++ b/crates/j2k-native/src/j2c/ht_block_encode/workspace.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use super::writers::{MagSgnEncoder, MelEncoder, VlcEncoder}; +use super::writers::{MEL_SIZE, MS_SIZE, VLC_SIZE}; +use crate::EncodeResult; + +/// Reusable scalar cleanup-pass reservoirs owned once per CPU worker. +pub(crate) struct HtEncodeWorkspace { + pub(super) mel: MelEncoder, + pub(super) vlc: VlcEncoder, + pub(super) mag_sgn: MagSgnEncoder, +} + +impl HtEncodeWorkspace { + pub(crate) const ALLOCATION_BYTES: usize = MEL_SIZE + VLC_SIZE + MS_SIZE; + + pub(crate) fn try_new() -> EncodeResult { + Ok(Self { + mel: MelEncoder::try_new()?, + vlc: VlcEncoder::try_new()?, + mag_sgn: MagSgnEncoder::try_new()?, + }) + } + + pub(super) fn reset_cleanup(&mut self) { + self.mel.reset(); + self.vlc.reset(); + self.mag_sgn.reset(); + } +} diff --git a/crates/j2k-native/src/j2c/ht_block_encode/writers.rs b/crates/j2k-native/src/j2c/ht_block_encode/writers.rs index f3d906cb..df1d38c0 100644 --- a/crates/j2k-native/src/j2c/ht_block_encode/writers.rs +++ b/crates/j2k-native/src/j2c/ht_block_encode/writers.rs @@ -38,6 +38,15 @@ impl MelEncoder { }) } + pub(super) fn reset(&mut self) { + self.pos = 0; + self.remaining_bits = 8; + self.tmp = 0; + self.run = 0; + self.k = 0; + self.threshold = 1; + } + pub(super) fn emit_bit(&mut self, bit: bool) -> Result<(), &'static str> { self.tmp = (self.tmp << 1) | u8::from(bit); self.remaining_bits -= 1; @@ -109,6 +118,15 @@ impl VlcEncoder { }) } + pub(super) fn reset(&mut self) { + self.pos = 1; + self.used_bits = 4; + self.tmp = 0x0F; + self.last_greater_than_8f = true; + let last = self.buffer.len() - 1; + self.buffer[last] = 0xFF; + } + #[expect( clippy::cast_possible_truncation, reason = "the mask is bounded by the available byte bits before packing into the VLC reservoir" @@ -179,6 +197,13 @@ impl MagSgnEncoder { }) } + pub(super) fn reset(&mut self) { + self.pos = 0; + self.max_bits = 8; + self.used_bits = 0; + self.tmp = 0; + } + #[expect( clippy::cast_possible_truncation, clippy::inline_always, diff --git a/crates/j2k-native/src/j2c/mod.rs b/crates/j2k-native/src/j2c/mod.rs index add311aa..9e3dee21 100644 --- a/crates/j2k-native/src/j2c/mod.rs +++ b/crates/j2k-native/src/j2c/mod.rs @@ -29,6 +29,9 @@ mod tag_tree; pub(crate) mod tag_tree_encode; mod tile; +#[cfg(test)] +pub(crate) use tile::{reset_tile_parse_calls, tile_parse_calls}; + use alloc::vec::Vec; use super::jp2::colr::{ColorSpace, ColorSpecificationBox, EnumeratedColorspace}; @@ -47,11 +50,13 @@ pub(crate) use decode::{ build_direct_color_plan, build_direct_grayscale_plan, build_referenced_classic_color_plan, build_referenced_classic_grayscale_plan, build_referenced_classic_rgba_plan, build_referenced_htj2k_color_plan, build_referenced_htj2k_grayscale_plan, - build_referenced_htj2k_rgba_plan, decode_with_capacity_retry as decode, + build_referenced_htj2k_rgba_plan, decode_preparsed_with_capacity_retry as decode_preparsed, + decode_with_capacity_retry as decode, prepare_region_tiles, }; pub use decode::{CpuDecodeParallelism, DecoderContext, DecoderWorkspace, DecoderWorkspaceStats}; pub use recode::Reversible53CoefficientImage; pub(crate) use segment::MAX_BITPLANE_COUNT; +pub(crate) use tile::ParsedTiles; pub(crate) struct ParsedCodestream<'a> { pub(crate) header: Header<'a>, diff --git a/crates/j2k-native/src/j2c/packet_encode.rs b/crates/j2k-native/src/j2c/packet_encode.rs index 4aeb340f..5d162ba4 100644 --- a/crates/j2k-native/src/j2c/packet_encode.rs +++ b/crates/j2k-native/src/j2c/packet_encode.rs @@ -15,6 +15,9 @@ pub(crate) struct CodeBlockPacketData { pub(crate) data: Vec, pub(crate) ht_cleanup_length: u32, pub(crate) ht_refinement_length: u32, + pub(crate) ht_sigprop_length: u32, + pub(crate) ht_magref_length: u32, + pub(crate) ht_distortion_deltas: [f64; 3], pub(crate) num_coding_passes: u8, pub(crate) classic_segment_lengths: Vec, pub(crate) num_zero_bitplanes: u8, diff --git a/crates/j2k-native/src/j2c/packet_encode/tests.rs b/crates/j2k-native/src/j2c/packet_encode/tests.rs index 9a97c49e..563ed087 100644 --- a/crates/j2k-native/src/j2c/packet_encode/tests.rs +++ b/crates/j2k-native/src/j2c/packet_encode/tests.rs @@ -77,6 +77,9 @@ fn test_empty_packet() { data: Vec::new(), ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: 0, classic_segment_lengths: Vec::new(), num_zero_bitplanes: 31, @@ -101,6 +104,9 @@ fn malformed_packet_layout_returns_an_error() { data: Vec::new(), ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: 0, classic_segment_lengths: Vec::new(), num_zero_bitplanes: 0, @@ -142,6 +148,9 @@ fn test_non_empty_packet() { data: vec![0x12, 0x34, 0x56], ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: 1, classic_segment_lengths: Vec::new(), num_zero_bitplanes: 20, @@ -182,6 +191,9 @@ fn packet_header_round_trips_varied_8x8_codeblock_lengths() { data: vec![0; len], ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: 1 + 3 * (8 - num_zero_bitplanes) - 2, classic_segment_lengths: Vec::new(), num_zero_bitplanes, @@ -252,6 +264,9 @@ fn packet_header_trailing_ff_stuffs_zero_before_body() { data: vec![0x80; len], ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: 1, classic_segment_lengths: Vec::new(), num_zero_bitplanes: 0, @@ -334,6 +349,9 @@ fn classic_pass_terminated_lengths_share_one_lblock_increment() { ], ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: u8::try_from(lengths.len()).expect("pass count fits u8"), classic_segment_lengths: lengths.to_vec(), num_zero_bitplanes: 0, @@ -385,6 +403,9 @@ fn test_multi_subband_packet() { data: vec![0x10, 0x20], ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: 1, classic_segment_lengths: Vec::new(), num_zero_bitplanes: 20, @@ -400,6 +421,9 @@ fn test_multi_subband_packet() { data: vec![0x30, 0x40], ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: 1, classic_segment_lengths: Vec::new(), num_zero_bitplanes: 22, @@ -415,6 +439,9 @@ fn test_multi_subband_packet() { data: vec![0x50], ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: 1, classic_segment_lengths: Vec::new(), num_zero_bitplanes: 24, @@ -486,6 +513,9 @@ fn test_non_empty_ht_packet() { data: vec![0x12, 0x34, 0x56], ht_cleanup_length: 3, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: 1, classic_segment_lengths: Vec::new(), num_zero_bitplanes: 20, @@ -511,6 +541,9 @@ fn ht_packet_header_round_trips_refinement_pass_count_and_length() { data: payload.clone(), ht_cleanup_length: 3, ht_refinement_length: 2, + ht_sigprop_length: 1, + ht_magref_length: 1, + ht_distortion_deltas: [0.0; 3], num_coding_passes: 3, classic_segment_lengths: Vec::new(), num_zero_bitplanes: 2, @@ -574,6 +607,9 @@ fn ht_packet_segment_lengths_reject_overflowing_refinement_sum() { data: vec![0x12], ht_cleanup_length: u32::MAX, ht_refinement_length: 1, + ht_sigprop_length: 1, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: 3, classic_segment_lengths: Vec::new(), num_zero_bitplanes: 2, @@ -599,6 +635,9 @@ fn single_block_packet(data: Vec, previously_included: bool) -> ResolutionPa data, ht_cleanup_length: 0, ht_refinement_length: 0, + ht_sigprop_length: 0, + ht_magref_length: 0, + ht_distortion_deltas: [0.0; 3], num_coding_passes: 1, classic_segment_lengths: Vec::new(), num_zero_bitplanes: 0, diff --git a/crates/j2k-native/src/j2c/quantize.rs b/crates/j2k-native/src/j2c/quantize.rs index 5efc47d9..83612bd7 100644 --- a/crates/j2k-native/src/j2c/quantize.rs +++ b/crates/j2k-native/src/j2c/quantize.rs @@ -270,6 +270,147 @@ pub(crate) fn append_step_sizes_with_irreversible_profile( } } +const OPENHTJ2K_D97_ENERGY_NORMS: [(f64, f64); 16] = [ + (1.965_907_314_575_295_7, 2.080_871_927_589_849), + (4.122_409_873_969_023, 3.868_863_224_131_922), + (8.416_744_177_952_724, 8.317_022_299_806_517), + (16.935_572_073_021_724, 17.201_929_112_787_134), + (33.924_926_802_207_46, 34.746_895_711_342_454), + (67.877_165_259_517_36, 69.675_395_886_752_16), + (135.768_047_117_209_76, 139.443_143_900_563_17), + (271.542_960_998_980_6, 278.932_688_221_656_86), + (543.089_356_530_109_5, 557.888_607_932_094_2), + (1_086.180_430_479_691_7, 1_115.788_835_859_533_3), + (2_172.361_719_689_337, 2_231.583_482_284_095_7), + (4_344.723_868_746_226, 4_463.169_869_925_348), + (8_689.447_952_176_557, 8_926.341_192_539_1), + (17_378.896_011_694_746, 17_852.683_111_423_37), + (34_757.792_077_060_57, 35_705.366_586_020_52), + (69_515.584_180_957_42, 71_410.733_353_619_97), +]; + +const OPENHTJ2K_VISUAL_WEIGHTS_444: [[f64; 15]; 3] = [ + [ + 0.0901, 0.2758, 0.2758, 0.7018, 0.8378, 0.8378, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + ], + [ + 0.0263, 0.0863, 0.0863, 0.1362, 0.2564, 0.2564, 0.3346, 0.4691, 0.4691, 0.5444, 0.6523, + 0.6523, 0.7078, 0.7797, 0.7797, + ], + [ + 0.0773, 0.1835, 0.1835, 0.2598, 0.4130, 0.4130, 0.5040, 0.6464, 0.6464, 0.7220, 0.8254, + 0.8254, 0.8769, 0.9424, 0.9424, + ], +]; + +const OPENHTJ2K_COLOR_GAINS: [f64; 3] = [1.7321, 1.8051, 1.5734]; + +/// Append the expounded 9/7 QCD or QCC tuples used by `OpenHTJ2K`'s Qfactor +/// profile for grayscale or 4:4:4 YCbCr content. +pub(crate) fn append_openhtj2k_qfactor_step_sizes( + step_sizes: &mut Vec, + bit_depth: u8, + num_decompositions: u8, + qfactor: u8, + component: usize, +) -> EncodeResult<()> { + if !(1..=100).contains(&qfactor) { + return Err(EncodeError::InvalidInput { + what: "OpenHTJ2K Qfactor must be in 1..=100", + }); + } + let Some(visual_weights) = OPENHTJ2K_VISUAL_WEIGHTS_444.get(component) else { + return Err(EncodeError::InvalidInput { + what: "OpenHTJ2K Qfactor supports grayscale or three-component 4:4:4 input", + }); + }; + let level_count = usize::from(num_decompositions); + if level_count > OPENHTJ2K_D97_ENERGY_NORMS.len() { + return Err(EncodeError::Unsupported { + what: "OpenHTJ2K Qfactor supports at most 16 decomposition levels", + }); + } + + let qfactor = f64::from(qfactor); + let magnitude = if qfactor < 50.0 { + 50.0 / qfactor + } else { + 2.0 * (1.0 - qfactor / 100.0) + }; + let mut power = 1.0; + let mut alpha = 0.04; + if qfactor >= 97.0 { + power = 0.0; + alpha = 0.10; + } else if qfactor > 65.0 { + let magnitude_t0 = 2.0 * (1.0 - 65.0 / 100.0); + let magnitude_t1 = 2.0 * (1.0 - 97.0 / 100.0); + power = (libm::log(magnitude_t1) - libm::log(magnitude)) + / (libm::log(magnitude_t1) - libm::log(magnitude_t0)); + alpha = 0.10 * libm::pow(0.04 / 0.10, power); + } + + let epsilon = libm::sqrt(0.5) / libm::scalbn(1.0, i32::from(bit_depth)); + let delta_reference = alpha * magnitude * OPENHTJ2K_COLOR_GAINS[0] + epsilon; + let component_gain = OPENHTJ2K_COLOR_GAINS[component]; + let start = step_sizes.len(); + for (level, &(low, high)) in OPENHTJ2K_D97_ENERGY_NORMS[..level_count].iter().enumerate() { + let base = level * 3; + for (offset, wmse) in [high * high, low * high, high * low] + .into_iter() + .enumerate() + { + let weight = visual_weights.get(base + offset).copied().unwrap_or(1.0); + let delta = + delta_reference / (libm::sqrt(wmse) * libm::pow(weight, power) * component_gain); + step_sizes.push(openhtj2k_step_from_normalized_delta(delta)); + } + } + let low_energy = if level_count == 0 { + 1.0 + } else { + let low = OPENHTJ2K_D97_ENERGY_NORMS[level_count - 1].0; + low * low + }; + let low_delta = delta_reference / (libm::sqrt(low_energy) * component_gain); + step_sizes.push(openhtj2k_step_from_normalized_delta(low_delta)); + step_sizes[start..].reverse(); + Ok(()) +} + +#[expect( + clippy::cast_possible_truncation, + reason = "the OpenHTJ2K Qfactor conversion clamps exponent and mantissa to their marker widths" +)] +fn openhtj2k_step_from_normalized_delta(mut delta: f64) -> QuantStepSize { + let mut exponent = 0_i32; + // The marker exponent saturates at 31, so any additional normalization + // iterations cannot affect the encoded result. + for _ in 0..=31 { + if delta >= 1.0 { + break; + } + delta *= 2.0; + exponent += 1; + } + let mut mantissa = libm::floor((delta - 1.0) * 2048.0 + 0.5) as i32; + if mantissa >= 2048 { + mantissa = 0; + exponent -= 1; + } + if exponent > 31 { + exponent = 31; + mantissa = 0; + } else if exponent < 0 { + exponent = 0; + mantissa = 2047; + } + QuantStepSize { + exponent: u16::try_from(exponent).expect("Qfactor exponent was clamped to 0..=31"), + mantissa: u16::try_from(mantissa).expect("Qfactor mantissa was clamped to 0..=2047"), + } +} + /// Quantize wavelet coefficients for a single subband. /// /// For lossless: converts f32 to i32 (round to nearest integer). @@ -366,6 +507,53 @@ pub(crate) fn try_quantize_subband( mod tests { use super::*; + #[test] + fn openhtj2k_qfactor_90_matches_reference_qcd_and_qcc() { + let expected = [ + [ + 0x65df, 0x65b4, 0x65b4, 0x658b, 0x5dc9, 0x5dc9, 0x5dad, 0x560f, 0x560f, 0x5625, + 0x4008, 0x4008, 0x410b, 0x3dab, 0x3dab, 0x337e, + ], + [ + 0x654f, 0x66db, 0x66db, 0x6764, 0x5027, 0x5027, 0x50d7, 0x49c6, 0x49c6, 0x4b9b, + 0x45c4, 0x45c4, 0x39b0, 0x3396, 0x3396, 0x2a15, + ], + [ + 0x6745, 0x6788, 0x6788, 0x67e6, 0x5056, 0x5056, 0x50d5, 0x4995, 0x4995, 0x4ae4, + 0x4481, 0x4481, 0x3819, 0x3130, 0x3130, 0x35a3, + ], + ]; + + for (component, expected) in expected.iter().enumerate() { + let mut actual = Vec::new(); + append_openhtj2k_qfactor_step_sizes(&mut actual, 8, 5, 90, component) + .expect("valid OpenHTJ2K Qfactor profile"); + let actual = actual + .iter() + .map(|step| (step.exponent << 11) | step.mantissa) + .collect::>(); + assert_eq!(actual, expected); + } + } + + #[test] + fn openhtj2k_normalized_step_conversion_saturates_at_marker_exponent() { + assert_eq!( + openhtj2k_step_from_normalized_delta(0.75), + QuantStepSize { + exponent: 1, + mantissa: 1024, + } + ); + assert_eq!( + openhtj2k_step_from_normalized_delta(f64::MIN_POSITIVE), + QuantStepSize { + exponent: 31, + mantissa: 0, + } + ); + } + #[test] fn test_lossless_quantize() { let coeffs = vec![10.0, -5.0, 3.7, -8.2, 0.0]; diff --git a/crates/j2k-native/src/j2c/tile.rs b/crates/j2k-native/src/j2c/tile.rs index a9b7b888..ddb2167f 100644 --- a/crates/j2k-native/src/j2c/tile.rs +++ b/crates/j2k-native/src/j2c/tile.rs @@ -18,6 +18,21 @@ use metadata::{inherit_tile_metadata, TileMetadataBudget}; pub(crate) use parsed::ParsedTiles; use tile_part::parse_tile_part; +#[cfg(test)] +std::thread_local! { + static TILE_PARSE_CALLS: core::cell::Cell = const { core::cell::Cell::new(0) }; +} + +#[cfg(test)] +pub(crate) fn reset_tile_parse_calls() { + TILE_PARSE_CALLS.set(0); +} + +#[cfg(test)] +pub(crate) fn tile_parse_calls() -> u64 { + TILE_PARSE_CALLS.get() +} + fn ceil_div_by_power_of_two(value: u32, exponent: u8) -> u32 { if exponent == 0 { value @@ -178,6 +193,9 @@ pub(crate) fn parse<'a>( main_header: &Header<'a>, retained_image_bytes: usize, ) -> Result> { + #[cfg(test)] + TILE_PARSE_CALLS.set(TILE_PARSE_CALLS.get().saturating_add(1)); + let mut metadata_budget = TileMetadataBudget::for_image(main_header, retained_image_bytes)?; let num_tiles = usize::try_from(main_header.size_data.num_tiles()) .map_err(|_| ValidationError::ImageTooLarge)?; @@ -219,7 +237,11 @@ pub(crate) fn parse<'a>( } metadata_budget.validate_owner_graph(&tiles)?; - Ok(ParsedTiles::new(tiles, metadata_budget.retained_bytes())) + Ok(ParsedTiles::new( + tiles, + metadata_budget.retained_bytes(), + metadata_budget.retained_owner_bytes(), + )) } /// A tile, instantiated to a specific component. diff --git a/crates/j2k-native/src/j2c/tile/metadata.rs b/crates/j2k-native/src/j2c/tile/metadata.rs index cbb0ea45..ca211945 100644 --- a/crates/j2k-native/src/j2c/tile/metadata.rs +++ b/crates/j2k-native/src/j2c/tile/metadata.rs @@ -55,6 +55,10 @@ impl TileMetadataBudget { self.retained_bytes } + pub(super) fn retained_owner_bytes(&self) -> usize { + self.retained_bytes - self.retained_image_bytes + } + pub(super) fn transaction(&mut self) -> TileMetadataTransaction<'_> { TileMetadataTransaction { budget: self, diff --git a/crates/j2k-native/src/j2c/tile/parsed.rs b/crates/j2k-native/src/j2c/tile/parsed.rs index d0d60bcc..c94ee832 100644 --- a/crates/j2k-native/src/j2c/tile/parsed.rs +++ b/crates/j2k-native/src/j2c/tile/parsed.rs @@ -10,19 +10,29 @@ use super::Tile; pub(crate) struct ParsedTiles<'a> { tiles: Vec>, structural_workspace_bytes: usize, + metadata_owner_bytes: usize, } impl<'a> ParsedTiles<'a> { - pub(super) fn new(tiles: Vec>, structural_workspace_bytes: usize) -> Self { + pub(super) fn new( + tiles: Vec>, + structural_workspace_bytes: usize, + metadata_owner_bytes: usize, + ) -> Self { Self { tiles, structural_workspace_bytes, + metadata_owner_bytes, } } pub(crate) fn structural_workspace_bytes(&self) -> usize { self.structural_workspace_bytes } + + pub(crate) fn metadata_owner_bytes(&self) -> usize { + self.metadata_owner_bytes + } } impl<'a> Deref for ParsedTiles<'a> { diff --git a/crates/j2k-native/src/lib.rs b/crates/j2k-native/src/lib.rs index 23bb22ee..d9439ca0 100644 --- a/crates/j2k-native/src/lib.rs +++ b/crates/j2k-native/src/lib.rs @@ -233,7 +233,8 @@ pub use error::{ #[cfg(test)] pub(crate) use j2c::encode::NativeEncodeRetainedInput; pub use j2c::encode::{ - encode, encode_component_planes_53, encode_htj2k, encode_precomputed_htj2k_53, + encode, encode_component_planes_53, encode_htj2k, encode_htj2k_with_qfactor, + encode_htj2k_with_qfactor_and_accelerator, encode_precomputed_htj2k_53, encode_precomputed_htj2k_53_with_accelerator, encode_precomputed_htj2k_53_with_accelerator_and_max_host_bytes, encode_precomputed_htj2k_53_with_mct, encode_precomputed_htj2k_53_with_mct_and_accelerator, @@ -265,25 +266,25 @@ pub use j2c::{ #[doc(hidden)] pub use j2k_types::{ sort_packet_descriptors_for_progression, CpuOnlyJ2kEncodeStageAccelerator, - EncodedHtJ2kCodeBlock, EncodedJ2kCodeBlock, IrreversibleQuantizationStep, - IrreversibleQuantizationSubbandScales, J2kCodeBlockSegment, J2kCodeBlockStyle, - J2kDeinterleaveMctToF32Job, J2kDeinterleaveToF32Job, J2kEncodeContext, J2kEncodeDispatchReport, - J2kEncodeStageAccelerator, J2kEncodeStageError, J2kEncodeStageErrorKind, J2kEncodeStageResult, - J2kForwardDwt53Job, J2kForwardDwt53Level, J2kForwardDwt53Output, J2kForwardDwt97Job, - J2kForwardDwt97Level, J2kForwardDwt97Output, J2kForwardIctJob, J2kForwardRctJob, - J2kHtCodeBlockEncodeJob, J2kHtSubbandEncodeJob, J2kHtj2kTileEncodeJob, - J2kPacketizationBlockCodingMode, J2kPacketizationCodeBlock, J2kPacketizationEncodeJob, - J2kPacketizationPacketDescriptor, J2kPacketizationProgressionOrder, J2kPacketizationResolution, - J2kPacketizationSubband, J2kQuantizeSubbandJob, J2kResidentEncodeInput, - J2kResidentEncodeInputError, J2kResidentHtj2kTileEncodeJob, J2kSubBandType, - J2kTier1CodeBlockEncodeJob, PrecomputedHtj2k53Component, PrecomputedHtj2k53Image, - PrecomputedHtj2k97Component, PrecomputedHtj2k97Image, PreencodedHtj2k97CodeBlock, - PreencodedHtj2k97CompactCodeBlock, PreencodedHtj2k97CompactComponent, - PreencodedHtj2k97CompactImage, PreencodedHtj2k97CompactResolution, - PreencodedHtj2k97CompactSubband, PreencodedHtj2k97Component, PreencodedHtj2k97Image, - PreencodedHtj2k97Resolution, PreencodedHtj2k97Subband, PrequantizedHtj2k97CodeBlock, - PrequantizedHtj2k97Component, PrequantizedHtj2k97Image, PrequantizedHtj2k97Resolution, - PrequantizedHtj2k97Subband, + EncodedHtJ2kCodeBlock, EncodedHtJ2kCodeBlockSet, EncodedJ2kCodeBlock, + IrreversibleQuantizationStep, IrreversibleQuantizationSubbandScales, J2kCodeBlockSegment, + J2kCodeBlockStyle, J2kDeinterleaveMctToF32Job, J2kDeinterleaveToF32Job, J2kEncodeContext, + J2kEncodeDispatchReport, J2kEncodeStageAccelerator, J2kEncodeStageError, + J2kEncodeStageErrorKind, J2kEncodeStageResult, J2kForwardDwt53Job, J2kForwardDwt53Level, + J2kForwardDwt53Output, J2kForwardDwt97Job, J2kForwardDwt97Level, J2kForwardDwt97Output, + J2kForwardIctJob, J2kForwardRctJob, J2kHtCodeBlockEncodeJob, J2kHtCodeBlockSetEncodeJob, + J2kHtSubbandEncodeJob, J2kHtj2kTileEncodeJob, J2kPacketizationBlockCodingMode, + J2kPacketizationCodeBlock, J2kPacketizationEncodeJob, J2kPacketizationPacketDescriptor, + J2kPacketizationProgressionOrder, J2kPacketizationResolution, J2kPacketizationSubband, + J2kQuantizeSubbandJob, J2kResidentEncodeInput, J2kResidentEncodeInputError, + J2kResidentHtj2kTileEncodeJob, J2kSubBandType, J2kTier1CodeBlockEncodeJob, + PrecomputedHtj2k53Component, PrecomputedHtj2k53Image, PrecomputedHtj2k97Component, + PrecomputedHtj2k97Image, PreencodedHtj2k97CodeBlock, PreencodedHtj2k97CompactCodeBlock, + PreencodedHtj2k97CompactComponent, PreencodedHtj2k97CompactImage, + PreencodedHtj2k97CompactResolution, PreencodedHtj2k97CompactSubband, + PreencodedHtj2k97Component, PreencodedHtj2k97Image, PreencodedHtj2k97Resolution, + PreencodedHtj2k97Subband, PrequantizedHtj2k97CodeBlock, PrequantizedHtj2k97Component, + PrequantizedHtj2k97Image, PrequantizedHtj2k97Resolution, PrequantizedHtj2k97Subband, }; mod j2c; @@ -409,11 +410,12 @@ pub use scalar::{ decode_j2k_code_block_scalar_with_workspace_midpoint_profiled, decode_j2k_code_block_scalar_with_workspace_profiled, decode_j2k_sub_band_scalar, encode_ht_code_block_scalar, encode_ht_code_block_scalar_with_passes, - encode_j2k_code_block_scalar_with_style, encode_j2k_packetization_scalar, - forward_dwt53_reference, forward_dwt97_reference, forward_ict_reference, forward_rct_reference, - pack_j2k_code_block_scalar_from_tier1_tokens, quantize_reversible_reference, - quantize_subband_reference, try_deinterleave_reference, HtCodeBlockDecodeProfile, - HtCodeBlockDecodeWorkspace, J2kCodeBlockDecodeProfile, J2kCodeBlockDecodeWorkspace, + encode_ht_code_block_scalar_with_passes_and_workspace, encode_j2k_code_block_scalar_with_style, + encode_j2k_packetization_scalar, forward_dwt53_reference, forward_dwt97_reference, + forward_ict_reference, forward_rct_reference, pack_j2k_code_block_scalar_from_tier1_tokens, + quantize_reversible_reference, quantize_subband_reference, try_deinterleave_reference, + HtCodeBlockDecodeProfile, HtCodeBlockDecodeWorkspace, HtCodeBlockEncodeWorkspace, + J2kCodeBlockDecodeProfile, J2kCodeBlockDecodeWorkspace, }; /// JP2 signature box: 00 00 00 0C 6A 50 20 20 @@ -422,7 +424,7 @@ pub(crate) const JP2_MAGIC: &[u8] = b"\x00\x00\x00\x0C\x6A\x50\x20\x20"; pub(crate) const CODESTREAM_MAGIC: &[u8] = b"\xFF\x4F\xFF\x51"; mod image; -pub use image::{DecodeSettings, Image}; +pub use image::{DecodeSettings, Image, PreparedRegionDecoder}; #[cfg(test)] mod tests; diff --git a/crates/j2k-native/src/scalar.rs b/crates/j2k-native/src/scalar.rs index 514b410d..f91f2ded 100644 --- a/crates/j2k-native/src/scalar.rs +++ b/crates/j2k-native/src/scalar.rs @@ -26,10 +26,11 @@ pub use self::classic_decode::{ mod encode; pub use self::encode::{ collect_ht_cleanup_encode_distribution, encode_ht_code_block_scalar, - encode_ht_code_block_scalar_with_passes, encode_j2k_code_block_scalar_with_style, - encode_j2k_packetization_scalar, forward_dwt53_reference, forward_dwt97_reference, - forward_ict_reference, forward_rct_reference, pack_j2k_code_block_scalar_from_tier1_tokens, - quantize_reversible_reference, quantize_subband_reference, try_deinterleave_reference, + encode_ht_code_block_scalar_with_passes, encode_ht_code_block_scalar_with_passes_and_workspace, + encode_j2k_code_block_scalar_with_style, encode_j2k_packetization_scalar, + forward_dwt53_reference, forward_dwt97_reference, forward_ict_reference, forward_rct_reference, + pack_j2k_code_block_scalar_from_tier1_tokens, quantize_reversible_reference, + quantize_subband_reference, try_deinterleave_reference, HtCodeBlockEncodeWorkspace, }; mod ht_decode; pub use self::ht_decode::{ diff --git a/crates/j2k-native/src/scalar/encode.rs b/crates/j2k-native/src/scalar/encode.rs index 695e3d26..30547ddc 100644 --- a/crates/j2k-native/src/scalar/encode.rs +++ b/crates/j2k-native/src/scalar/encode.rs @@ -9,6 +9,26 @@ use super::{ }; use crate::{DecodingError, EncodeError, EncodeResult}; +/// Reusable scalar HTJ2K encode reservoirs for backend experimentation. +#[doc(hidden)] +pub struct HtCodeBlockEncodeWorkspace { + inner: j2c::ht_block_encode::HtEncodeWorkspace, +} + +impl HtCodeBlockEncodeWorkspace { + /// Allocate the fixed MEL, VLC, and magnitude/sign reservoirs once. + /// + /// # Errors + /// + /// Returns a typed allocation error if any fixed reservoir cannot be + /// allocated within the codec's host-memory constraints. + pub fn try_new() -> EncodeResult { + Ok(Self { + inner: j2c::ht_block_encode::HtEncodeWorkspace::try_new()?, + }) + } +} + /// Adapter scalar classic J2K encoder helper for backend experimentation. #[doc(hidden)] pub fn encode_j2k_code_block_scalar_with_style( @@ -111,13 +131,40 @@ pub fn encode_ht_code_block_scalar_with_passes( total_bitplanes, target_coding_passes, )?; - Ok(EncodedHtJ2kCodeBlock { + Ok(encoded_ht_code_block_from_internal(encoded)) +} + +/// Adapter scalar HTJ2K encoder helper that reuses caller-owned reservoirs. +#[doc(hidden)] +pub fn encode_ht_code_block_scalar_with_passes_and_workspace( + coefficients: &[i32], + width: u32, + height: u32, + total_bitplanes: u8, + target_coding_passes: u8, + workspace: &mut HtCodeBlockEncodeWorkspace, +) -> EncodeResult { + let encoded = j2c::ht_block_encode::try_encode_code_block_with_passes_in_workspace( + coefficients, + width, + height, + total_bitplanes, + target_coding_passes, + &mut workspace.inner, + )?; + Ok(encoded_ht_code_block_from_internal(encoded)) +} + +fn encoded_ht_code_block_from_internal( + encoded: j2c::bitplane_encode::EncodedCodeBlock, +) -> EncodedHtJ2kCodeBlock { + EncodedHtJ2kCodeBlock { data: encoded.data, cleanup_length: encoded.ht_cleanup_length, refinement_length: encoded.ht_refinement_length, num_coding_passes: encoded.num_coding_passes, num_zero_bitplanes: encoded.num_zero_bitplanes, - }) + } } /// Adapter HTJ2K cleanup-encode distribution helper for benchmark tuning. diff --git a/crates/j2k-types/src/dispatch/accelerator.rs b/crates/j2k-types/src/dispatch/accelerator.rs index 20371a07..db9524f5 100644 --- a/crates/j2k-types/src/dispatch/accelerator.rs +++ b/crates/j2k-types/src/dispatch/accelerator.rs @@ -6,11 +6,11 @@ use alloc::vec::Vec; use super::{J2kEncodeDispatchReport, J2kEncodeStageResult}; use crate::{ - EncodedHtJ2kCodeBlock, EncodedJ2kCodeBlock, J2kForwardDwt53Job, J2kForwardDwt53Output, - J2kForwardDwt97Job, J2kForwardDwt97Output, J2kForwardIctJob, J2kForwardRctJob, - J2kHtCodeBlockEncodeJob, J2kHtSubbandEncodeJob, J2kPacketizationEncodeJob, - J2kPacketizationProgressionOrder, J2kQuantizeSubbandJob, J2kResidentHtj2kTileEncodeJob, - J2kTier1CodeBlockEncodeJob, + EncodedHtJ2kCodeBlock, EncodedHtJ2kCodeBlockSet, EncodedJ2kCodeBlock, J2kForwardDwt53Job, + J2kForwardDwt53Output, J2kForwardDwt97Job, J2kForwardDwt97Output, J2kForwardIctJob, + J2kForwardRctJob, J2kHtCodeBlockEncodeJob, J2kHtCodeBlockSetEncodeJob, J2kHtSubbandEncodeJob, + J2kPacketizationEncodeJob, J2kPacketizationProgressionOrder, J2kQuantizeSubbandJob, + J2kResidentHtj2kTileEncodeJob, J2kTier1CodeBlockEncodeJob, }; /// Pixel deinterleave and level-shift job supplied to an accelerator. @@ -203,6 +203,15 @@ pub trait J2kEncodeStageAccelerator { Ok(None) } + /// Optionally encode exact HT cleanup/refinement-set candidates in one dispatch. + #[doc(hidden)] + fn encode_ht_code_block_sets( + &mut self, + _jobs: &[J2kHtCodeBlockSetEncodeJob<'_>], + ) -> J2kEncodeStageResult>> { + Ok(None) + } + /// Optionally quantize and encode one HTJ2K cleanup/refinement subband. fn encode_ht_subband( &mut self, @@ -256,3 +265,28 @@ impl J2kEncodeStageAccelerator for CpuOnlyJ2kEncodeStageAccelerator { true } } + +#[cfg(test)] +mod tests { + use super::{CpuOnlyJ2kEncodeStageAccelerator, J2kEncodeStageAccelerator}; + use crate::J2kHtCodeBlockSetEncodeJob; + + #[test] + fn legacy_accelerators_decline_explicit_ht_sets_by_default() { + let coefficients = [7_i32]; + let jobs = [J2kHtCodeBlockSetEncodeJob { + coefficients: &coefficients, + width: 1, + height: 1, + total_bitplanes: 3, + cleanup_bitplane: 1, + target_coding_passes: 3, + }]; + let mut accelerator = CpuOnlyJ2kEncodeStageAccelerator; + + assert!(accelerator + .encode_ht_code_block_sets(&jobs) + .expect("default set hook") + .is_none()); + } +} diff --git a/crates/j2k-types/src/lib.rs b/crates/j2k-types/src/lib.rs index a1b9e2ff..692467c4 100644 --- a/crates/j2k-types/src/lib.rs +++ b/crates/j2k-types/src/lib.rs @@ -73,8 +73,9 @@ pub use resident::{ }; mod tier1; pub use tier1::{ - EncodedHtJ2kCodeBlock, EncodedJ2kCodeBlock, J2kCodeBlockSegment, J2kCodeBlockStyle, - J2kHtCodeBlockEncodeJob, J2kHtSubbandEncodeJob, J2kSubBandType, J2kTier1CodeBlockEncodeJob, + EncodedHtJ2kCodeBlock, EncodedHtJ2kCodeBlockSet, EncodedJ2kCodeBlock, J2kCodeBlockSegment, + J2kCodeBlockStyle, J2kHtCodeBlockEncodeJob, J2kHtCodeBlockSetEncodeJob, J2kHtSubbandEncodeJob, + J2kSubBandType, J2kTier1CodeBlockEncodeJob, }; mod transform; diff --git a/crates/j2k-types/src/tier1/htj2k.rs b/crates/j2k-types/src/tier1/htj2k.rs index 005971c2..41dcb0a1 100644 --- a/crates/j2k-types/src/tier1/htj2k.rs +++ b/crates/j2k-types/src/tier1/htj2k.rs @@ -19,6 +19,24 @@ pub struct EncodedHtJ2kCodeBlock { pub num_zero_bitplanes: u8, } +/// Encoded payload and exact pass boundaries for one expert HT set candidate. +#[doc(hidden)] +#[derive(Debug)] +pub struct EncodedHtJ2kCodeBlockSet { + /// Combined cleanup, `SigProp`, and `MagRef` bytes. + pub data: Vec, + /// Cleanup segment length in bytes. + pub cleanup_length: u32, + /// `SigProp` prefix length within the refinement segment. + pub sigprop_length: u32, + /// `MagRef` suffix length within the refinement segment. + pub magref_length: u32, + /// Number of passes present from this set. + pub num_coding_passes: u8, + /// Missing most-significant planes for this cleanup pass. + pub num_zero_bitplanes: u8, +} + /// HTJ2K code-block encode job. #[derive(Debug, Clone, Copy)] pub struct J2kHtCodeBlockEncodeJob<'a> { @@ -39,6 +57,27 @@ pub struct J2kHtCodeBlockEncodeJob<'a> { pub target_coding_passes: u8, } +/// Expert HTJ2K job that selects one exact cleanup/refinement set. +/// +/// This is used by bounded FBCOT candidate generation. Ordinary callers +/// should use [`J2kHtCodeBlockEncodeJob`]. +#[doc(hidden)] +#[derive(Debug, Clone, Copy)] +pub struct J2kHtCodeBlockSetEncodeJob<'a> { + /// Quantized coefficients in row-major order. + pub coefficients: &'a [i32], + /// Code-block width in samples. + pub width: u32, + /// Code-block height in samples. + pub height: u32, + /// Total bitplanes for this subband/code block. + pub total_bitplanes: u8, + /// Least-significant magnitude bitplane represented by the cleanup pass. + pub cleanup_bitplane: u8, + /// Number of passes from this set to encode, in the range 1 through 3. + pub target_coding_passes: u8, +} + /// HTJ2K cleanup/refinement encode job for one unquantized subband. #[derive(Debug, Clone, Copy)] pub struct J2kHtSubbandEncodeJob<'a> { @@ -65,3 +104,4 @@ pub struct J2kHtSubbandEncodeJob<'a> { } crate::move_only::assert_move_only!(EncodedHtJ2kCodeBlock); +crate::move_only::assert_move_only!(EncodedHtJ2kCodeBlockSet); diff --git a/crates/j2k-types/src/tier1/mod.rs b/crates/j2k-types/src/tier1/mod.rs index fa8ee8a8..61777c9b 100644 --- a/crates/j2k-types/src/tier1/mod.rs +++ b/crates/j2k-types/src/tier1/mod.rs @@ -9,4 +9,7 @@ pub use classic::{ EncodedJ2kCodeBlock, J2kCodeBlockSegment, J2kCodeBlockStyle, J2kSubBandType, J2kTier1CodeBlockEncodeJob, }; -pub use htj2k::{EncodedHtJ2kCodeBlock, J2kHtCodeBlockEncodeJob, J2kHtSubbandEncodeJob}; +pub use htj2k::{ + EncodedHtJ2kCodeBlock, EncodedHtJ2kCodeBlockSet, J2kHtCodeBlockEncodeJob, + J2kHtCodeBlockSetEncodeJob, J2kHtSubbandEncodeJob, +}; diff --git a/crates/j2k/src/decode.rs b/crates/j2k/src/decode.rs index c8581f8f..5ea7e347 100644 --- a/crates/j2k/src/decode.rs +++ b/crates/j2k/src/decode.rs @@ -15,7 +15,8 @@ mod output; mod settings; mod srgb8; use output::{ - can_decode_u8_directly, write_components_u8_output, write_u16_output, write_u8_output, + can_decode_u8_directly, write_components_u16_output, write_components_u8_output, + write_u16_output, write_u8_output, }; pub use settings::DecodeSettings; pub(crate) use srgb8::decode_image_srgb8; @@ -145,6 +146,30 @@ pub(crate) fn decode_image_region_into_with_native_context<'a>( } } +pub(crate) fn decode_prepared_image_region_into( + decoder: &mut j2k_native::PreparedRegionDecoder<'_, '_, '_>, + out: &mut [u8], + stride: usize, + fmt: PixelFormat, + roi: Rect, +) -> Result<(), J2kError> { + let components = decoder + .decode_region_components((roi.x, roi.y, roi.w, roi.h)) + .map_err(J2kError::from_native_decode_error)?; + match fmt { + PixelFormat::Rgb8 | PixelFormat::Rgba8 | PixelFormat::Gray8 => { + write_components_u8_output(&components, out, stride, fmt) + } + PixelFormat::Rgb16 | PixelFormat::Rgba16 | PixelFormat::Gray16 => { + write_components_u16_output(&components, out, stride, fmt) + } + _ => Err(Unsupported { + what: "pixel format is not yet supported by j2k", + } + .into()), + } +} + pub(crate) fn validate_buffer( dims: (u32, u32), out_len: usize, diff --git a/crates/j2k/src/decode/output.rs b/crates/j2k/src/decode/output.rs index 42158c87..daf84267 100644 --- a/crates/j2k/src/decode/output.rs +++ b/crates/j2k/src/decode/output.rs @@ -5,5 +5,5 @@ mod u16; mod u8; -pub(super) use u16::write_u16_output; +pub(super) use u16::{write_components_u16_output, write_u16_output}; pub(super) use u8::{can_decode_u8_directly, write_components_u8_output, write_u8_output}; diff --git a/crates/j2k/src/decode/output/u16.rs b/crates/j2k/src/decode/output/u16.rs index 131235ec..1ebe6c54 100644 --- a/crates/j2k/src/decode/output/u16.rs +++ b/crates/j2k/src/decode/output/u16.rs @@ -2,10 +2,166 @@ //! Sixteen-bit channel-layout conversion for native decoded samples. -use crate::backend::{ColorSpace, RawBitmap}; +use crate::backend::{ColorSpace, DecodedComponents as NativeDecodedComponents, RawBitmap}; use crate::J2kError; use j2k_core::{PixelFormat, Unsupported}; +use super::u8::{component_sample_count, validate_component_planes}; + +pub(in crate::decode) fn write_components_u16_output( + components: &NativeDecodedComponents<'_>, + out: &mut [u8], + stride: usize, + fmt: PixelFormat, +) -> Result<(), J2kError> { + let dims = components.dimensions(); + let expected_samples = component_sample_count(dims)?; + let width = dims.0 as usize; + let height = dims.1 as usize; + let planes = components.planes(); + match ( + components.color_space(), + components.has_alpha(), + planes.len(), + fmt, + ) { + (ColorSpace::Gray, false, 1, PixelFormat::Gray16) => { + validate_component_planes(&planes[..1], expected_samples)?; + write_component_rows_u16(&planes[0], out, stride, width, height); + Ok(()) + } + (ColorSpace::RGB, false, 3, PixelFormat::Rgb16) + | (ColorSpace::RGB, true, 4, PixelFormat::Rgb16) => { + validate_component_planes(&planes[..3], expected_samples)?; + write_rgb_component_rows_u16(planes, out, stride, width, height); + Ok(()) + } + (ColorSpace::RGB, false, 3, PixelFormat::Rgba16) => { + validate_component_planes(&planes[..3], expected_samples)?; + write_rgba_component_rows_u16(planes, out, stride, width, height, true); + Ok(()) + } + (ColorSpace::RGB, true, 4, PixelFormat::Rgba16) => { + validate_component_planes(&planes[..4], expected_samples)?; + write_rgba_component_rows_u16(planes, out, stride, width, height, false); + Ok(()) + } + _ => Err(Unsupported { + what: "backend color space cannot be mapped to requested 16-bit pixel format", + } + .into()), + } +} + +fn write_component_rows_u16( + plane: &j2k_native::ComponentPlane<'_>, + out: &mut [u8], + stride: usize, + width: usize, + height: usize, +) { + for y in 0..height { + let src = &plane.samples()[y * width..(y + 1) * width]; + let dst = &mut out[y * stride..y * stride + width * 2]; + for (sample, destination) in src.iter().zip(dst.chunks_exact_mut(2)) { + destination.copy_from_slice( + &component_sample_as_u16(*sample, plane.bit_depth(), plane.signed()).to_le_bytes(), + ); + } + } +} + +fn write_rgb_component_rows_u16( + planes: &[j2k_native::ComponentPlane<'_>], + out: &mut [u8], + stride: usize, + width: usize, + height: usize, +) { + write_color_component_rows_u16(planes, out, stride, width, height, 3, false); +} + +fn write_rgba_component_rows_u16( + planes: &[j2k_native::ComponentPlane<'_>], + out: &mut [u8], + stride: usize, + width: usize, + height: usize, + synthesize_alpha: bool, +) { + write_color_component_rows_u16(planes, out, stride, width, height, 4, synthesize_alpha); +} + +fn write_color_component_rows_u16( + planes: &[j2k_native::ComponentPlane<'_>], + out: &mut [u8], + stride: usize, + width: usize, + height: usize, + output_channels: usize, + synthesize_alpha: bool, +) { + for y in 0..height { + let row = y * width; + let destination = &mut out[y * stride..y * stride + width * output_channels * 2]; + for x in 0..width { + let pixel = &mut destination[x * output_channels * 2..(x + 1) * output_channels * 2]; + for channel in 0..3 { + let sample = component_sample_as_u16( + planes[channel].samples()[row + x], + planes[channel].bit_depth(), + planes[channel].signed(), + ); + pixel[channel * 2..channel * 2 + 2].copy_from_slice(&sample.to_le_bytes()); + } + if output_channels == 4 { + let alpha = if synthesize_alpha { + opaque_alpha_u16( + usize::from(planes[0].bit_depth()).div_ceil(8), + planes[0].bit_depth(), + ) + } else { + component_sample_as_u16( + planes[3].samples()[row + x], + planes[3].bit_depth(), + planes[3].signed(), + ) + }; + pixel[6..8].copy_from_slice(&alpha.to_le_bytes()); + } + } + } +} + +#[expect( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "decoded samples are rounded and clamped to the component's declared integer representation" +)] +fn component_sample_as_u16(sample: f32, bit_depth: u8, signed: bool) -> u16 { + let rounded = sample.round(); + if signed { + if bit_depth <= 8 { + let magnitude_bits = u32::from(bit_depth.saturating_sub(1)); + let min = -(1_i16 << magnitude_bits); + let max = (1_i16 << magnitude_bits) - 1; + return widen_u8_sample_to_u16( + (rounded.clamp(f32::from(min), f32::from(max)) as i8) as u8, + bit_depth, + ); + } + let magnitude_bits = u32::from(bit_depth.min(16).saturating_sub(1)); + let min = i16::try_from(-(1_i32 << magnitude_bits)).unwrap_or(i16::MIN); + let max = i16::try_from((1_i32 << magnitude_bits) - 1).unwrap_or(i16::MAX); + return (rounded.clamp(f32::from(min), f32::from(max)) as i16) as u16; + } + if bit_depth <= 8 { + return widen_u8_sample_to_u16(rounded.clamp(0.0, f32::from(u8::MAX)) as u8, bit_depth); + } + let max = u16::try_from((1_u32 << u32::from(bit_depth.min(16))) - 1).unwrap_or(u16::MAX); + rounded.clamp(0.0, f32::from(max)) as u16 +} + pub(in crate::decode) fn write_u16_output( color_space: &ColorSpace, has_alpha: bool, diff --git a/crates/j2k/src/decode/output/u8.rs b/crates/j2k/src/decode/output/u8.rs index 9d04e6df..622fe8e1 100644 --- a/crates/j2k/src/decode/output/u8.rs +++ b/crates/j2k/src/decode/output/u8.rs @@ -67,7 +67,7 @@ pub(in crate::decode) fn write_components_u8_output( } } -fn component_sample_count(dims: (u32, u32)) -> Result { +pub(super) fn component_sample_count(dims: (u32, u32)) -> Result { (dims.0 as usize) .checked_mul(dims.1 as usize) .ok_or(J2kError::DimensionOverflow { @@ -76,7 +76,7 @@ fn component_sample_count(dims: (u32, u32)) -> Result { }) } -fn validate_component_planes( +pub(super) fn validate_component_planes( planes: &[j2k_native::ComponentPlane<'_>], expected_samples: usize, ) -> Result<(), J2kError> { diff --git a/crates/j2k/src/encode/accelerator.rs b/crates/j2k/src/encode/accelerator.rs index 26bed71a..0b057db5 100644 --- a/crates/j2k/src/encode/accelerator.rs +++ b/crates/j2k/src/encode/accelerator.rs @@ -165,18 +165,32 @@ pub(super) fn encode_lossy_with_native_accelerator( quantization_scale: f32, accelerator: &mut impl J2kEncodeStageAccelerator, ) -> Result, J2kError> { - let options = native_lossy_options(samples, options, quantization_scale)?; - j2k_native::encode_with_accelerator( - samples.data, - samples.width, - samples.height, - samples.components, - samples.bit_depth, - samples.signed, - &options, - accelerator, - ) - .map_err(|source| { + let native_options = native_lossy_options(samples, options, quantization_scale)?; + let encode_result = if let Some(qfactor) = options.qfactor { + j2k_native::encode_htj2k_with_qfactor_and_accelerator( + samples.data, + samples.width, + samples.height, + samples.components, + samples.bit_depth, + samples.signed, + qfactor, + &native_options, + accelerator, + ) + } else { + j2k_native::encode_with_accelerator( + samples.data, + samples.width, + samples.height, + samples.components, + samples.bit_depth, + samples.signed, + &native_options, + accelerator, + ) + }; + encode_result.map_err(|source| { J2kError::from_native_encode_error_with_context( source, "accelerated native JPEG 2000 lossy encode failed", diff --git a/crates/j2k/src/encode/contracts.rs b/crates/j2k/src/encode/contracts.rs index f9d7b45a..b2f6e04b 100644 --- a/crates/j2k/src/encode/contracts.rs +++ b/crates/j2k/src/encode/contracts.rs @@ -403,6 +403,12 @@ pub struct J2kLossyEncodeOptions { pub rate_target: Option, /// Cumulative quality layer targets. pub quality_layers: Vec, + /// Optional OpenHTJ2K-compatible visual quality factor (`1..=100`). + /// + /// This is available only with high-throughput block coding and cannot be + /// combined with byte, bits-per-pixel, or PSNR targets. `None` preserves + /// the established quantization and rate-control behavior. + pub qfactor: Option, /// Optional tile width and height. pub tile_size: Option<(u32, u32)>, /// Optional maximum number of complete packets to place in each tile-part. @@ -428,6 +434,7 @@ impl Default for J2kLossyEncodeOptions { max_decomposition_levels: None, rate_target: None, quality_layers: Vec::new(), + qfactor: None, tile_size: None, tile_part_packet_limit: None, precinct_exponents: Vec::new(), @@ -500,6 +507,13 @@ impl J2kLossyEncodeOptions { self } + /// Return options with an OpenHTJ2K-compatible visual quality factor. + #[must_use] + pub fn with_qfactor(mut self, qfactor: Option) -> Self { + self.qfactor = qfactor; + self + } + /// Return options with a different tile size. #[must_use] pub fn with_tile_size(mut self, tile_size: Option<(u32, u32)>) -> Self { diff --git a/crates/j2k/src/encode/lossy.rs b/crates/j2k/src/encode/lossy.rs index 46b124c6..d3b06225 100644 --- a/crates/j2k/src/encode/lossy.rs +++ b/crates/j2k/src/encode/lossy.rs @@ -49,17 +49,33 @@ pub(super) fn encode_cpu_lossy( options: &J2kLossyEncodeOptions, quantization_scale: f32, ) -> Result, J2kError> { - let options = native_lossy_options(samples, options, quantization_scale)?; - j2k_native::encode( - samples.data, - samples.width, - samples.height, - samples.components, - samples.bit_depth, - samples.signed, - &options, - ) - .map_err(|source| { + let native_options = native_lossy_options(samples, options, quantization_scale)?; + let encode_result = options.qfactor.map_or_else( + || { + j2k_native::encode( + samples.data, + samples.width, + samples.height, + samples.components, + samples.bit_depth, + samples.signed, + &native_options, + ) + }, + |qfactor| { + j2k_native::encode_htj2k_with_qfactor( + samples.data, + samples.width, + samples.height, + samples.components, + samples.bit_depth, + samples.signed, + qfactor, + &native_options, + ) + }, + ); + encode_result.map_err(|source| { J2kError::from_native_encode_error_with_context( source, "native JPEG 2000 lossy encode failed", @@ -283,6 +299,24 @@ pub(super) fn lossy_quality_layer_byte_targets( } pub(super) fn validate_lossy_options(options: &J2kLossyEncodeOptions) -> Result<(), J2kError> { + if let Some(qfactor) = options.qfactor { + if !(1..=100).contains(&qfactor) { + return Err(J2kError::Unsupported(Unsupported { + what: "OpenHTJ2K Qfactor must be in 1..=100", + })); + } + if options.block_coding_mode != J2kBlockCodingMode::HighThroughput { + return Err(J2kError::Unsupported(Unsupported { + what: "OpenHTJ2K Qfactor requires high-throughput block coding", + })); + } + if options.rate_target.is_some() || !options.quality_layers.is_empty() { + return Err(J2kError::Unsupported(Unsupported { + what: + "OpenHTJ2K Qfactor cannot be combined with lossy rate or quality-layer targets", + })); + } + } if options.quality_layers.len() > 32 { return Err(J2kError::Unsupported(Unsupported { what: "JPEG 2000 lossy encode supports 1-32 quality layers", diff --git a/crates/j2k/src/encode/roi.rs b/crates/j2k/src/encode/roi.rs index d088cf75..0aa3e445 100644 --- a/crates/j2k/src/encode/roi.rs +++ b/crates/j2k/src/encode/roi.rs @@ -3,6 +3,7 @@ //! Lossless and lossy ROI encode orchestration. use alloc::{string::ToString, vec::Vec}; +use j2k_core::Unsupported; use j2k_native::EncodeRoiRegion as NativeEncodeRoiRegion; use super::accelerator::resolve_encode_backend; @@ -114,6 +115,11 @@ pub(super) fn encode_lossy( roi_regions: &[J2kRoiRegion], ) -> Result { validate_lossy_options(options)?; + if options.qfactor.is_some() { + return Err(J2kError::Unsupported(Unsupported { + what: "OpenHTJ2K Qfactor ROI encode is not supported", + })); + } high_bit::validate_lossy_options(samples, options)?; let native_roi_regions = native_roi_regions_for_samples( samples.width, diff --git a/crates/j2k/src/lib.rs b/crates/j2k/src/lib.rs index 14200394..f0a43df4 100644 --- a/crates/j2k/src/lib.rs +++ b/crates/j2k/src/lib.rs @@ -35,20 +35,20 @@ mod adapter; pub use adapter::device_plan::{DeviceDecodePlan, DeviceDecodeRequest}; #[doc(hidden)] pub use j2k_types::{ - CpuOnlyJ2kEncodeStageAccelerator, EncodedHtJ2kCodeBlock, EncodedJ2kCodeBlock, - IrreversibleQuantizationStep, IrreversibleQuantizationSubbandScales, J2kCodeBlockSegment, - J2kCodeBlockStyle, J2kDeinterleaveMctToF32Job, J2kDeinterleaveToF32Job, J2kEncodeContext, - J2kEncodeDispatchReport, J2kEncodeStageAccelerator, J2kEncodeStageError, + CpuOnlyJ2kEncodeStageAccelerator, EncodedHtJ2kCodeBlock, EncodedHtJ2kCodeBlockSet, + EncodedJ2kCodeBlock, IrreversibleQuantizationStep, IrreversibleQuantizationSubbandScales, + J2kCodeBlockSegment, J2kCodeBlockStyle, J2kDeinterleaveMctToF32Job, J2kDeinterleaveToF32Job, + J2kEncodeContext, J2kEncodeDispatchReport, J2kEncodeStageAccelerator, J2kEncodeStageError, J2kEncodeStageErrorKind, J2kEncodeStageResult, J2kForwardDwt53Job, J2kForwardDwt53Level, J2kForwardDwt53Output, J2kForwardDwt97Job, J2kForwardDwt97Level, J2kForwardDwt97Output, - J2kForwardIctJob, J2kForwardRctJob, J2kHtCodeBlockEncodeJob, J2kHtSubbandEncodeJob, - J2kHtj2kTileEncodeJob, J2kPacketizationBlockCodingMode, J2kPacketizationCodeBlock, - J2kPacketizationEncodeJob, J2kPacketizationPacketDescriptor, J2kPacketizationProgressionOrder, - J2kPacketizationResolution, J2kPacketizationSubband, J2kQuantizeSubbandJob, - J2kResidentEncodeInput, J2kResidentEncodeInputError, J2kResidentHtj2kTileEncodeJob, - J2kSubBandType, J2kTier1CodeBlockEncodeJob, PrecomputedHtj2k53Component, - PrecomputedHtj2k53Image, PrecomputedHtj2k97Component, PrecomputedHtj2k97Image, - PreencodedHtj2k97CodeBlock, PreencodedHtj2k97CompactCodeBlock, + J2kForwardIctJob, J2kForwardRctJob, J2kHtCodeBlockEncodeJob, J2kHtCodeBlockSetEncodeJob, + J2kHtSubbandEncodeJob, J2kHtj2kTileEncodeJob, J2kPacketizationBlockCodingMode, + J2kPacketizationCodeBlock, J2kPacketizationEncodeJob, J2kPacketizationPacketDescriptor, + J2kPacketizationProgressionOrder, J2kPacketizationResolution, J2kPacketizationSubband, + J2kQuantizeSubbandJob, J2kResidentEncodeInput, J2kResidentEncodeInputError, + J2kResidentHtj2kTileEncodeJob, J2kSubBandType, J2kTier1CodeBlockEncodeJob, + PrecomputedHtj2k53Component, PrecomputedHtj2k53Image, PrecomputedHtj2k97Component, + PrecomputedHtj2k97Image, PreencodedHtj2k97CodeBlock, PreencodedHtj2k97CompactCodeBlock, PreencodedHtj2k97CompactComponent, PreencodedHtj2k97CompactImage, PreencodedHtj2k97CompactResolution, PreencodedHtj2k97CompactSubband, PreencodedHtj2k97Component, PreencodedHtj2k97Image, PreencodedHtj2k97Resolution, diff --git a/crates/j2k/src/view/rows.rs b/crates/j2k/src/view/rows.rs index 449a2e55..912a6883 100644 --- a/crates/j2k/src/view/rows.rs +++ b/crates/j2k/src/view/rows.rs @@ -3,7 +3,11 @@ //! Bounded row-decode planning, scratch ownership, and sink emission. use super::{J2kDecoder, J2kRowDecodeOptions}; -use crate::{decode::J2kDecodeOutcome, scratch::J2kScratchPool, J2kError}; +use crate::{ + decode::{decode_prepared_image_region_into, J2kDecodeOutcome}, + scratch::J2kScratchPool, + J2kError, +}; use alloc::vec::Vec; use j2k_core::{ BufferError, DecodeRowsError, PixelFormat, Rect, RowSink, DEFAULT_MAX_HOST_ALLOCATION_BYTES, @@ -82,6 +86,15 @@ impl J2kDecoder<'_> { ); let (stripe_rows, max_stripe_len) = bounded_row_stripe_layout(row_bytes, height, options) .map_err(DecodeRowsError::Decode)?; + self.ensure_image().map_err(DecodeRowsError::Decode)?; + let (Some(image), native_context) = (self.image.as_ref(), &mut self.native_context) else { + return Err(DecodeRowsError::Decode(J2kError::internal_backend( + "internal image cache missing", + ))); + }; + let mut decoder = image + .prepare_region_decoder_with_context(native_context) + .map_err(|error| DecodeRowsError::Decode(J2kError::from_native_decode_error(error)))?; let mut pool = J2kScratchPool::new(); let mut y = 0_u32; while y < height { @@ -94,7 +107,8 @@ impl J2kDecoder<'_> { let stripe = pool .packed_bytes(max_stripe_len) .map_err(|error| DecodeRowsError::Decode(J2kError::Buffer(error)))?; - self.decode_region_into_cached( + decode_prepared_image_region_into( + &mut decoder, &mut stripe[..stripe_len], row_bytes, fmt, @@ -154,46 +168,99 @@ impl J2kDecoder<'_> { let options = options.with_max_stripe_bytes(options.max_stripe_bytes().min(packed_cap)); let (stripe_rows, max_stripe_len) = bounded_row_stripe_layout(row_bytes, height, options) .map_err(DecodeRowsError::Decode)?; - let mut pool = J2kScratchPool::new(); - let mut y = 0_u32; - while y < height { - let rows = stripe_rows.min(height - y); - let stripe_len = row_bytes.checked_mul(rows as usize).ok_or_else(|| { - DecodeRowsError::Decode(J2kError::Buffer(BufferError::SizeOverflow { - what: "J2K bounded row decode stripe buffer", - })) - })?; - let (packed, row) = pool - .packed_bytes_and_row_u16(max_stripe_len, samples_per_row) - .map_err(|error| DecodeRowsError::Decode(J2kError::Buffer(error)))?; - self.decode_region_into_cached( - &mut packed[..stripe_len], - row_bytes, - fmt, - Rect { - x: 0, - y, - w: width, - h: rows, - }, - ) - .map_err(DecodeRowsError::Decode)?; - for row_index in 0..rows { - let start = row_index as usize * row_bytes; - let packed_row = &packed[start..start + row_bytes]; - for (dst, src) in row.iter_mut().zip(packed_row.chunks_exact(2)) { - *dst = u16::from_le_bytes([src[0], src[1]]); - } - sink.write_row(y + row_index, row) - .map_err(DecodeRowsError::Sink)?; + let layout = U16StripeLayout { + width, + height, + row_bytes, + samples_per_row, + stripe_rows, + max_stripe_len, + }; + if self.info.bit_depth > 24 { + return self.decode_rows_u16_exact_reparsed(sink, fmt, layout); + } + self.ensure_image().map_err(DecodeRowsError::Decode)?; + let (Some(image), native_context) = (self.image.as_ref(), &mut self.native_context) else { + return Err(DecodeRowsError::Decode(J2kError::internal_backend( + "internal image cache missing", + ))); + }; + let mut decoder = image + .prepare_region_decoder_with_context(native_context) + .map_err(|error| DecodeRowsError::Decode(J2kError::from_native_decode_error(error)))?; + pump_u16_rows(sink, layout, |packed, region| { + decode_prepared_image_region_into(&mut decoder, packed, row_bytes, fmt, region) + }) + } + + fn decode_rows_u16_exact_reparsed>( + &mut self, + sink: &mut R, + fmt: PixelFormat, + layout: U16StripeLayout, + ) -> Result> { + // Exact >24-bit decode uses the integer sidecar and its established + // full-decode crop path. Keep that compatibility path until the + // prepared region session can borrow exact integer component planes. + pump_u16_rows(sink, layout, |packed, region| { + self.decode_region_into_cached(packed, layout.row_bytes, fmt, region) + .map(|_| ()) + }) + } +} + +#[derive(Clone, Copy)] +struct U16StripeLayout { + width: u32, + height: u32, + row_bytes: usize, + samples_per_row: usize, + stripe_rows: u32, + max_stripe_len: usize, +} + +fn pump_u16_rows>( + sink: &mut R, + layout: U16StripeLayout, + mut decode_stripe: impl FnMut(&mut [u8], Rect) -> Result<(), J2kError>, +) -> Result> { + let mut pool = J2kScratchPool::new(); + let mut y = 0_u32; + while y < layout.height { + let rows = layout.stripe_rows.min(layout.height - y); + let stripe_len = layout.row_bytes.checked_mul(rows as usize).ok_or_else(|| { + DecodeRowsError::Decode(J2kError::Buffer(BufferError::SizeOverflow { + what: "J2K bounded row decode stripe buffer", + })) + })?; + let (packed, row) = pool + .packed_bytes_and_row_u16(layout.max_stripe_len, layout.samples_per_row) + .map_err(|error| DecodeRowsError::Decode(J2kError::Buffer(error)))?; + decode_stripe( + &mut packed[..stripe_len], + Rect { + x: 0, + y, + w: layout.width, + h: rows, + }, + ) + .map_err(DecodeRowsError::Decode)?; + for row_index in 0..rows { + let start = row_index as usize * layout.row_bytes; + let packed_row = &packed[start..start + layout.row_bytes]; + for (dst, src) in row.iter_mut().zip(packed_row.chunks_exact(2)) { + *dst = u16::from_le_bytes([src[0], src[1]]); } - y += rows; + sink.write_row(y + row_index, row) + .map_err(DecodeRowsError::Sink)?; } - Ok(j2k_core::DecodeOutcome::new( - Rect::full(self.info.dimensions), - Vec::new(), - )) + y += rows; } + Ok(j2k_core::DecodeOutcome::new( + Rect::full((layout.width, layout.height)), + Vec::new(), + )) } fn row_format_u8(info: &j2k_core::Info) -> Result { diff --git a/crates/j2k/tests/decode.rs b/crates/j2k/tests/decode.rs index 88c00afd..598d3abe 100644 --- a/crates/j2k/tests/decode.rs +++ b/crates/j2k/tests/decode.rs @@ -1,10 +1,11 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 use j2k::{ - encode_j2k_lossless_components, DecodeSettings as FacadeDecodeSettings, + encode_j2k_lossless, encode_j2k_lossless_components, DecodeSettings as FacadeDecodeSettings, EncodeBackendPreference, J2kBlockCodingMode, J2kCodec, J2kComponentPlane, J2kContext, J2kDecodeWarning, J2kDecoder, J2kError, J2kLosslessComponentPlane, J2kLosslessComponentSamples, - J2kLosslessEncodeOptions, J2kRowDecodeOptions, J2kView, ReversibleTransform, + J2kLosslessEncodeOptions, J2kLosslessSamples, J2kRowDecodeOptions, J2kView, + ReversibleTransform, }; use j2k_core::{ BufferError, CodecContext, Downscale, ImageDecodeRows, PixelFormat, Rect, RowSink, @@ -1408,6 +1409,77 @@ fn decode_rows_u16_matches_full_gray16_decode() { assert_eq!(collected, full); } +#[test] +fn decode_rows_u16_matches_full_decode_for_signed_samples() { + for (pixels, bit_depth) in [ + ( + [-10_i8, -1, 0, 12] + .into_iter() + .map(|sample| sample.to_le_bytes()[0]) + .collect::>(), + 8, + ), + ( + [-300_i16, -1, 0, 300] + .into_iter() + .flat_map(i16::to_le_bytes) + .collect::>(), + 16, + ), + ] { + let codestream = encode_signed_codestream(&pixels, 2, 2, bit_depth); + let mut decoder = J2kDecoder::new(&codestream).expect("decoder"); + let mut full = [0_u8; 8]; + decoder + .decode_into(&mut full, 4, PixelFormat::Gray16) + .expect("full signed decode"); + + let mut sink = CollectRowsU16::default(); + decoder + .decode_rows_u16_bounded(&mut sink, J2kRowDecodeOptions::new(1)) + .expect("signed row decode"); + let rows = sink + .rows + .into_iter() + .flat_map(u16::to_le_bytes) + .collect::>(); + assert_eq!(rows, full); + } +} + +#[test] +fn decode_rows_u16_preserves_exact_high_bit_fallback() { + let pixels = [0_u32, 1, 0x00ff_ffff, 0x1fff_ffff] + .into_iter() + .flat_map(unsigned_29_bytes) + .collect::>(); + let samples = J2kLosslessSamples::new(&pixels, 2, 2, 1, 29, false).expect("29-bit samples"); + let codestream = encode_j2k_lossless( + samples, + &J2kLosslessEncodeOptions::default() + .with_cpu_only_backend() + .with_max_decomposition_levels(Some(0)), + ) + .expect("29-bit encode") + .codestream; + let mut decoder = J2kDecoder::new(&codestream).expect("decoder"); + let mut full = [0_u8; 8]; + decoder + .decode_into(&mut full, 4, PixelFormat::Gray16) + .expect("full high-bit decode"); + + let mut sink = CollectRowsU16::default(); + decoder + .decode_rows_u16_bounded(&mut sink, J2kRowDecodeOptions::new(1)) + .expect("high-bit row decode"); + let rows = sink + .rows + .into_iter() + .flat_map(u16::to_le_bytes) + .collect::>(); + assert_eq!(rows, full); +} + #[test] fn decode_rows_u8_bounded_matches_full_rgba8_decode_one_row_at_a_time() { let pixels: Vec = (0_u8..32).collect(); diff --git a/crates/j2k/tests/encode_lossy.rs b/crates/j2k/tests/encode_lossy.rs index c2e0bddf..f7a5c061 100644 --- a/crates/j2k/tests/encode_lossy.rs +++ b/crates/j2k/tests/encode_lossy.rs @@ -2,15 +2,39 @@ use j2k::{ encode_j2k_lossy, encode_j2k_lossy_with_accelerator, encode_j2k_lossy_with_roi_regions, - EncodeBackendPreference, J2kBlockCodingMode, J2kEncodeValidation, J2kLossyEncodeOptions, - J2kLossySamples, J2kMarkerSegment, J2kProgressionOrder, J2kQualityLayer, J2kRateTarget, - J2kRoiRegion, -}; -use j2k::{ - EncodedHtJ2kCodeBlock, J2kDeinterleaveToF32Job, J2kEncodeDispatchReport, - J2kEncodeStageAccelerator, J2kEncodeStageError, J2kEncodeStageResult, J2kHtCodeBlockEncodeJob, - J2kPacketizationEncodeJob, J2kQuantizeSubbandJob, + EncodeBackendPreference, EncodedHtJ2kCodeBlock, EncodedHtJ2kCodeBlockSet, J2kBlockCodingMode, + J2kDeinterleaveToF32Job, J2kEncodeDispatchReport, J2kEncodeStageAccelerator, + J2kEncodeStageError, J2kEncodeStageResult, J2kEncodeValidation, J2kHtCodeBlockEncodeJob, + J2kHtCodeBlockSetEncodeJob, J2kLossyEncodeOptions, J2kLossySamples, J2kMarkerSegment, + J2kPacketizationEncodeJob, J2kProgressionOrder, J2kQualityLayer, J2kQuantizeSubbandJob, + J2kRateTarget, J2kRoiRegion, }; + +#[test] +fn htj2k_qfactor_is_opt_in_and_round_trips() { + let pixels = (0_usize..64 * 64 * 3) + .map(|index| masked_u8(index.wrapping_mul(29))) + .collect::>(); + let samples = + j2k::J2kLossySamples::new(&pixels, 64, 64, 3, 8, false).expect("valid RGB samples"); + let encoded = j2k::encode_j2k_lossy( + samples, + &J2kLossyEncodeOptions::default() + .with_block_coding_mode(J2kBlockCodingMode::HighThroughput) + .with_qfactor(Some(90)), + ) + .expect("Qfactor HTJ2K encode"); + + assert!(encoded + .codestream + .windows(2) + .any(|bytes| bytes == [0xff, 0x5d])); + let decoded = decode_native(&encoded.codestream); + assert_eq!( + (decoded.width, decoded.height, decoded.num_components), + (64, 64, 3) + ); +} use j2k_core::{BackendKind, CodecError}; use j2k_native::{DecodeSettings, Image}; @@ -42,6 +66,153 @@ fn public_encoded_ht(block: j2k_native::EncodedHtJ2kCodeBlock) -> EncodedHtJ2kCo block } +#[derive(Default)] +struct FullHtj2kLossyAccelerator { + deinterleave: usize, + quantize_subband: usize, + ht_code_block: usize, + packetization: usize, +} + +impl J2kEncodeStageAccelerator for FullHtj2kLossyAccelerator { + fn dispatch_report(&self) -> J2kEncodeDispatchReport { + J2kEncodeDispatchReport { + deinterleave: self.deinterleave, + quantize_subband: self.quantize_subband, + ht_code_block: self.ht_code_block, + packetization: self.packetization, + ..J2kEncodeDispatchReport::default() + } + } + + fn encode_deinterleave( + &mut self, + job: J2kDeinterleaveToF32Job<'_>, + ) -> J2kEncodeStageResult>>> { + self.deinterleave = self.deinterleave.saturating_add(1); + assert_eq!(job.bit_depth, 8); + assert!(!job.signed); + Ok(Some(vec![job + .pixels + .iter() + .map(|sample| f32::from(*sample) - 128.0) + .collect()])) + } + + #[expect( + clippy::cast_possible_truncation, + reason = "mock accelerator fixture coefficients are rounded within the i32 domain" + )] + fn encode_quantize_subband( + &mut self, + job: J2kQuantizeSubbandJob<'_>, + ) -> J2kEncodeStageResult>> { + self.quantize_subband = self.quantize_subband.saturating_add(1); + Ok(Some( + job.coefficients + .iter() + .map(|sample| sample.round() as i32) + .collect(), + )) + } + + fn encode_ht_code_block( + &mut self, + job: J2kHtCodeBlockEncodeJob<'_>, + ) -> J2kEncodeStageResult> { + self.ht_code_block = self.ht_code_block.saturating_add(1); + assert_eq!(job.target_coding_passes, 3); + j2k_native::encode_ht_code_block_scalar_with_passes( + job.coefficients, + job.width, + job.height, + job.total_bitplanes, + job.target_coding_passes, + ) + .map(public_encoded_ht) + .map(Some) + .map_err(|source| { + J2kEncodeStageError::backend("native scalar", "HT Tier-1 refinement encode", source) + }) + } + + fn encode_ht_code_block_sets( + &mut self, + jobs: &[J2kHtCodeBlockSetEncodeJob<'_>], + ) -> J2kEncodeStageResult>> { + self.ht_code_block = self.ht_code_block.saturating_add(1); + jobs.iter() + .copied() + .map(scalar_ht_candidate_set) + .collect::>>() + .map(Some) + } + + fn encode_packetization( + &mut self, + _job: J2kPacketizationEncodeJob<'_>, + ) -> J2kEncodeStageResult>> { + self.packetization = self.packetization.saturating_add(1); + Ok(None) + } +} + +fn scalar_ht_candidate_set( + job: J2kHtCodeBlockSetEncodeJob<'_>, +) -> J2kEncodeStageResult { + let shift = job.cleanup_bitplane.saturating_sub(1); + let shifted = job + .coefficients + .iter() + .map(|coefficient| { + let magnitude = coefficient.unsigned_abs() >> shift; + let magnitude = i32::try_from(magnitude).expect("bounded HT magnitude"); + if coefficient.is_negative() { + -magnitude + } else { + magnitude + } + }) + .collect::>(); + let shifted_bitplanes = job + .total_bitplanes + .checked_sub(shift) + .expect("candidate cleanup bitplane is in range"); + let full = + scalar_ht_candidate_passes(&shifted, shifted_bitplanes, job, job.target_coding_passes)?; + let sigprop_length = if job.target_coding_passes > 1 { + scalar_ht_candidate_passes(&shifted, shifted_bitplanes, job, 2)?.refinement_length + } else { + 0 + }; + Ok(EncodedHtJ2kCodeBlockSet { + data: full.data, + cleanup_length: full.cleanup_length, + sigprop_length, + magref_length: full.refinement_length - sigprop_length, + num_coding_passes: full.num_coding_passes, + num_zero_bitplanes: full.num_zero_bitplanes, + }) +} + +fn scalar_ht_candidate_passes( + coefficients: &[i32], + total_bitplanes: u8, + job: J2kHtCodeBlockSetEncodeJob<'_>, + passes: u8, +) -> J2kEncodeStageResult { + j2k_native::encode_ht_code_block_scalar_with_passes( + coefficients, + job.width, + job.height, + total_bitplanes, + passes, + ) + .map_err(|source| { + J2kEncodeStageError::backend("native scalar", "HT Tier-1 candidate encode", source) + }) +} + fn plt_packet_length_count(codestream: &[u8]) -> usize { plt_packet_lengths(codestream).len() } @@ -950,85 +1121,6 @@ fn cpu_htj2k_lossy_three_quality_layers_use_three_pass_segment_granularity() { #[test] fn accelerator_facade_htj2k_lossy_multilayer_require_device_checks_supported_stages() { - #[derive(Default)] - struct FullHtj2kLossyAccelerator { - deinterleave: usize, - quantize_subband: usize, - ht_code_block: usize, - packetization: usize, - } - - impl J2kEncodeStageAccelerator for FullHtj2kLossyAccelerator { - fn dispatch_report(&self) -> J2kEncodeDispatchReport { - J2kEncodeDispatchReport { - deinterleave: self.deinterleave, - quantize_subband: self.quantize_subband, - ht_code_block: self.ht_code_block, - packetization: self.packetization, - ..J2kEncodeDispatchReport::default() - } - } - - fn encode_deinterleave( - &mut self, - job: J2kDeinterleaveToF32Job<'_>, - ) -> J2kEncodeStageResult>>> { - self.deinterleave = self.deinterleave.saturating_add(1); - assert_eq!(job.bit_depth, 8); - assert!(!job.signed); - let mut component = Vec::with_capacity(job.num_pixels); - for &sample in job.pixels { - component.push(f32::from(sample) - 128.0); - } - Ok(Some(vec![component])) - } - - #[expect( - clippy::cast_possible_truncation, - reason = "mock accelerator fixture coefficients are rounded within the i32 domain" - )] - fn encode_quantize_subband( - &mut self, - job: J2kQuantizeSubbandJob<'_>, - ) -> J2kEncodeStageResult>> { - self.quantize_subband = self.quantize_subband.saturating_add(1); - Ok(Some( - job.coefficients - .iter() - .map(|sample| sample.round() as i32) - .collect(), - )) - } - - fn encode_ht_code_block( - &mut self, - job: J2kHtCodeBlockEncodeJob<'_>, - ) -> J2kEncodeStageResult> { - self.ht_code_block = self.ht_code_block.saturating_add(1); - assert_eq!(job.target_coding_passes, 3); - j2k_native::encode_ht_code_block_scalar_with_passes( - job.coefficients, - job.width, - job.height, - job.total_bitplanes, - job.target_coding_passes, - ) - .map(public_encoded_ht) - .map(Some) - .map_err(|source| { - J2kEncodeStageError::backend("native scalar", "HT Tier-1 refinement encode", source) - }) - } - - fn encode_packetization( - &mut self, - _job: J2kPacketizationEncodeJob<'_>, - ) -> J2kEncodeStageResult>> { - self.packetization = self.packetization.saturating_add(1); - Ok(None) - } - } - let pixels: Vec = (0..32 * 32) .map(|index| masked_u8(index * 47 + index / 31)) .collect(); diff --git a/docs/benchmark-corpora.md b/docs/benchmark-corpora.md index f6eec65d..77259a30 100644 --- a/docs/benchmark-corpora.md +++ b/docs/benchmark-corpora.md @@ -274,6 +274,44 @@ The workflow fails closed when either is absent. ## Running All Available Corpora +For low-overhead HTJ2K decode comparisons, prepare both pinned reference +libraries and run the in-process batch harness. It decodes the same selected +bytes through J2K, OpenHTJ2K 0.19.0, and OpenJPH 0.31.0, checks output parity +before timing, and records the linked versions and library paths: + +```bash +scripts/prepare-openhtj2k-reference.sh +scripts/prepare-openjph-reference.sh + +J2K_OPENHTJ2K_SOURCE_DIR="$PWD/target/t803/openhtj2k-v0.19.0" \ +J2K_OPENHTJ2K_LIB_DIR="$PWD/target/t803/openhtj2k-v0.19.0/build-reference" \ +J2K_OPENJPH_SOURCE_DIR="$PWD/target/reference/openjph-0.31.0" \ +J2K_OPENJPH_LIB_DIR="$PWD/target/reference/openjph-0.31.0/build-reference/src/core" \ +J2K_BATCH_COMPARE_THREADS=1 \ +cargo run -p j2k-compare --release --bin jp2k_batch_compare -- \ + corpus/vendor/openjph 1 16 +``` + +The default parity tolerance is one byte value. Set +`J2K_BATCH_COMPARE_MAX_ABS_DIFF=0` for bit-exact corpora. + +For focused HTJ2K encoder interoperability, the pinned preparation script now +builds both CLI tools. The matrix encodes Gray8, RGB8, and unsigned Gray16 with +reversible 5/3 and Qfactor 90 through both producers, validates raw codestream +and JPH inputs with both decoders, and reports median encode time, size, PSNR, +and cross-decoder sample delta: + +```bash +scripts/prepare-openjph-reference.sh +J2K_OPENJPH_MATRIX_REPEATS=5 \ +cargo run -p j2k-compare --release --bin jp2k_encode_compare -- --openjph-matrix +``` + +Lossless rows fail unless both decoders reproduce the source exactly; +irreversible rows fail when the decoders differ by more than one sample value. +The timing rows compare an in-process encoder with a CLI process and are +supporting context, not a standalone throughput claim. + Place or symlink each decoded corpus of J2K/JP2/JPH files into separate directories, then pass a platform path-list. The harness walks configured directories recursively and fails if a configured directory contains no @@ -363,6 +401,13 @@ The adoption bundle currently contains these classes of evidence: - `cpu-public-api-encode` and `cpu-public-api-decode`: Criterion component microbenchmarks for J2K's public CPU encode/decode surfaces. These are not external encoder comparisons. +- `j2k-native`'s `tier1_bitplane` bench includes deterministic 64x64 HTJ2K + cleanup-only and cleanup/SigProp/MagRef encode rows. Run the focused local + diagnostic with + `cargo bench -p j2k-native --bench tier1_bitplane -- 'htj2k_cleanup_encode/encode_64x64_'`. + The result is a component microbenchmark, not an end-to-end or external-codec + claim; record CPU, OS, revision, Criterion sample policy, and both generated + seeds when using it as optimization evidence. - `cuda-htj2k-decode`: Criterion CPU-vs-CUDA HTJ2K decode rows. When `--fixtures` and `--manifest` are supplied, the adoption runner passes the same pinned external fixture manifest through `J2K_CUDA_DECODE_INPUT_DIRS` @@ -539,6 +584,19 @@ runs, so CUDA encode can use the same canonical PNM pixels as the CPU encoder matrix. Non-PNM source formats should be staged by `jp2k_encode_compare` or recorded in the encode manifest before CUDA encode benchmarking. +The CUDA HT code-block microbenchmark includes cleanup-only and three-pass +cleanup/SigProp/MagRef rows for the CPU scalar oracle plus host-staged and +resident CUDA inputs. Every timed row verifies successful status and the +requested coding-pass count before accepting its byte-count result. + +A 128-lane coefficient-analysis prototype was rejected and removed after an +RTX 4070 SUPER A/B run (20 Criterion samples, 500 ms warmup, one-second target) +found no statistically detectable gain. The three-pass point estimates changed +by +0.16% for host-staged input and +0.62% for resident input (`p=0.99` and +`p=0.95`); both confidence intervals crossed zero broadly. CUDA entropy writing +therefore remains serial within each code block, and no CUDA HT encode +throughput improvement is claimed. + Metal auto-routing encode rows use the same staged source convention through `J2K_METAL_ENCODE_INPUT_DIRS` and `J2K_METAL_ENCODE_MANIFEST` when `--metal` is requested. External Metal rows are emitted as `mode=lossless_external` in diff --git a/docs/env-vars.md b/docs/env-vars.md index 9de7e2f8..a9a1b321 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -6,7 +6,7 @@ or test-only implementation details and must not be treated as user controls. Stability values: -- Stable: supported for the published v0.9.x contract. +- Stable: supported for the published v0.10.x contract. - Experimental: accepted for diagnostics or adapter tuning, but may change before 1.0. - Test/CI: supported only for repository tests, CI, and release validation. @@ -125,7 +125,13 @@ override it. | `J2K_OPENJPEG_COMPRESS_BIN` | Override for OpenJPEG `opj_compress` in J2K parity tests and encoder benchmark reports. | `opj_compress` on `PATH` | Benchmark | | `J2K_OPENHTJ2K_DEC_BIN` | Absolute path to the pinned `open_htj2k_dec` executable used by the T.803 CPU encoder matrix's HT+RGN interoperability case. `scripts/prepare-openhtj2k-reference.sh` sets it in CI. | Required when the selected matrix contains the OpenHTJ2K case | Test/CI | | `J2K_OPENHTJ2K_SOURCE_DIR` | Absolute path to the clean official OpenHTJ2K 0.19.0 source checkout at the pinned commit. The runner verifies its origin, tag, commit, and tracked-file status before executing the decoder. | Required with `J2K_OPENHTJ2K_DEC_BIN` | Test/CI | +| `J2K_OPENHTJ2K_LIB_DIR` | Directory containing the pinned OpenHTJ2K 0.19.0 static library used by the in-process comparator. `scripts/prepare-openhtj2k-reference.sh` emits it with the source and CLI paths. | Not linked unless both source and library artifacts are available | Test/benchmark | +| `J2K_OPENHTJ2K_VERSION` | Build-script metadata for the exact linked OpenHTJ2K tag; consumers should not set it directly. | Exact source tag, or `unknown` | Build metadata | | `J2K_OPENJPH_EXPAND_BIN` | Override for OpenJPH `ojph_expand` in optional fixture comparator rows. | `ojph_expand` on `PATH`, `/opt/homebrew/bin/ojph_expand`, or `/usr/local/bin/ojph_expand` | Benchmark | +| `J2K_OPENJPH_COMPRESS_BIN` | Override for the pinned OpenJPH `ojph_compress` used by `jp2k_encode_compare --openjph-matrix`. `scripts/prepare-openjph-reference.sh` builds and emits both OpenJPH CLI paths. | Pinned `target/reference/openjph-0.31.0` build, then `ojph_compress` on `PATH` | Benchmark | +| `J2K_OPENJPH_SOURCE_DIR` | Absolute path to the clean official OpenJPH 0.31.0 source checkout at the pinned commit used by the in-process comparator. `scripts/prepare-openjph-reference.sh` creates and validates it. | Not linked unless set | Test/benchmark | +| `J2K_OPENJPH_LIB_DIR` | Directory containing the pinned OpenJPH 0.31.0 static library. `scripts/prepare-openjph-reference.sh` emits it with the source and CLI paths. | Not linked unless both source and library artifacts are available | Test/benchmark | +| `J2K_OPENJPH_VERSION` | Build-script metadata for the exact linked OpenJPH tag; consumers should not set it directly. | Exact source tag, or `unknown` | Build metadata | | `J2K_KDU_EXPAND_BIN` | Override for Kakadu `kdu_expand` in optional fixture comparator rows. | `kdu_expand` on `PATH`, `/opt/homebrew/bin/kdu_expand`, or `/usr/local/bin/kdu_expand` | Benchmark | | `J2K_KDU_COMPRESS_BIN` | Override for Kakadu `kdu_compress` in optional encoder comparator rows. | `kdu_compress` on `PATH`, `/opt/homebrew/bin/kdu_compress`, or `/usr/local/bin/kdu_compress` | Benchmark | | `J2K_GROK_BIN` | Override for Grok `grk_decompress` in J2K parity tests. | `grk_decompress` on `PATH` | Test/CI | @@ -177,8 +183,10 @@ override it. | `J2K_ENCODE_COMPARE_MANIFEST` | Optional TSV manifest for external encode source images. Requires `path` and `corpus_category`; supports `corpus_name`, `license_status`, `source_command`, and `input_fnv1a64`. Publication runs require the decoded-pixel hash pin. | Not set | Benchmark | | `J2K_ENCODE_COMPARE_INCLUDE_GENERATED` | Set to `0`, `false`, `no`, or `off` to omit generated smoke source images from `jp2k_encode_compare` external-corpus publication runs. | Generated source images included | Benchmark | | `J2K_ENCODE_COMPARE_ENCODERS` | Comma-separated encoder filter for local smoke checks, with values `j2k`, `openjpeg`, `grok`, and optional `kakadu`/`kdu`. Any filter blocks publication eligibility. | All default encoders | Benchmark | +| `J2K_OPENJPH_MATRIX_REPEATS` | Positive encode repeat count per producer/profile/format cell in `jp2k_encode_compare --openjph-matrix`; the median is reported. Parity is checked outside the timed encode operation. | `3` | Benchmark | | `J2K_BATCH_COMPARE_REPEATS` | Repeat count for `jp2k_batch_compare`. | Tool default | Benchmark | -| `J2K_BATCH_COMPARE_THREADS` | Worker count for `jp2k_batch_compare`. | Tool default | Benchmark | +| `J2K_BATCH_COMPARE_THREADS` | Outer worker count shared by J2K and the in-process reference rows in `jp2k_batch_compare`. Reference decoders remain single-threaded per tile to avoid nested oversubscription. | Tool default | Benchmark | +| `J2K_BATCH_COMPARE_MAX_ABS_DIFF` | Maximum allowed per-byte difference between J2K and each linked OpenHTJ2K/OpenJPH in-process preflight. The benchmark exits before timing when the gate fails. | `1` | Benchmark | | `J2K_ROI_COMPARE_REPEATS` | Repeat count for `jp2k_roi_batch_compare`. | Tool default | Benchmark | | `J2K_ROI_COMPARE_THREADS` | Worker count for `jp2k_roi_batch_compare`. | Tool default | Benchmark | | `J2K_CUDA_DECODE_FORMATS` | Comma-separated CUDA J2K decode benchmark output formats such as `gray8,rgb8,rgba8`. | Harness default | Benchmark | diff --git a/docs/public-support.md b/docs/public-support.md index abf1a953..9a1a7d32 100644 --- a/docs/public-support.md +++ b/docs/public-support.md @@ -25,11 +25,13 @@ documented non-Part-1/non-Part-15 `Out of scope` status. | part1-roi-maxshift | Done | `j2k-native::j2c::roi`, RGN parsing, decode/encode quantization | Existing RGN marker metadata, `roi_shift` decode plumbing, scalar code-block helpers | `roi_maxshift_inverse_preserves_background_and_unshifts_roi_coefficients`; `classic_scalar_decode_applies_nonzero_roi_maxshift`; `tile_header_rgn_marker_with_zero_shift_is_a_noop`; `encode_whole_component_roi_maxshift_roundtrips_and_writes_rgn`; `encode_rectangular_roi_maxshift_roundtrips_and_writes_rgn`; `encode_rejects_ambiguous_whole_component_and_rectangular_roi`; `cpu_lossless_rectangular_roi_roundtrips_and_writes_rgn`; `cpu_lossless_multi_tile_rectangular_roi_roundtrips_and_writes_rgn`; `cpu_lossless_htj2k_rectangular_roi_roundtrips_at_31_coded_bitplanes`; `cpu_lossless_classic_high_bit_rectangular_roi_roundtrips_at_50_coded_bitplanes`; `cpu_lossless_classic_high_bit_rectangular_roi_rejects_56_coded_bitplanes_explicitly`; `cpu_lossy_rectangular_roi_writes_rgn_and_decodes`; `tile_header_rgn_with_explicit_style_is_rejected`; `main_header_rgn_with_explicit_style_is_rejected`; `iso_p0_03_tile_header_roi_maxshift_matches_reference_when_available` | OpenJPEG/Grok ROI fixture parity | Decode-side maxshift handling is covered from marker parsing through code-block coefficient inverse shift, including optional ISO p0_03 parity. Native encode can emit whole-component maxshift ROI by shifting all coefficients for selected components, and rectangular ROI requests by mapping reference-grid rectangles to component-grid coefficient windows before scalar code-block encoding, while writing matching RGN markers. The public lossless and lossy facades expose rectangular ROI encode, and lossless multi-tile ROI clips reference-grid regions to each tile before assembling the final codestream. HTJ2K lossless ROI round-trips at the 31 coded-bitplane edge, classic high-bit ROI round-trips at 50 coded bitplanes, and requests beyond the classic 55/HT 31 coded-bitplane packet limits reject as `Unsupported` with the native limit reason. External always-on ROI fixtures remain incomplete. | | jp2-color-component-metadata | Done | `j2k-native::jp2`, `j2k::parse::boxes`, JP2 writer support | Existing `ihdr`, `colr`, `pclr`, `cmap`, `cdef`, `icc` modules; SIZ metadata; `wrap_j2k_codestream`; `J2kSupportInfo::file_metadata`; `J2kFileColorSpec::from_inspected`; `J2kFileColorSpec::from_file_metadata`; `J2kFileBoxMetadata::from_file_metadata` | `wrap_writes_bpcc_for_mixed_precision_and_signedness`; `wrap_allows_icc_color_spec_for_non_enumerated_component_counts`; `wrap_preserves_inspected_icc_metadata_when_rewrapping`; `wrap_preserves_inspected_icc_metadata_when_rewrapping_jph`; `wrap_preserves_multiple_colr_boxes_when_rewrapping`; `wrap_writes_cdef_for_explicit_srgb_alpha`; `wrap_preserves_premultiplied_alpha_cdef_and_decodes_rgba`; `inspect_and_decode_jp2_palette_component_mapping_metadata`; `inspect_and_decode_jp2_signed_palette_metadata`; `wrap_writes_palette_component_mapping_and_channel_definitions`; `missing_ihdr_returns_invalid_box`; `missing_colr_returns_invalid_box`; `invalid_ihdr_compression_type_returns_invalid_box`; `ihdr_dimension_mismatch_returns_invalid_box`; `ihdr_bpc_mismatch_returns_invalid_box`; `bpcc_precision_mismatch_returns_invalid_box`; `premultiplied_opacity_cdef_sets_alpha`; `unspecified_cdef_association_decodes`; `decode_rgba16_roi_scaled_and_region_scaled_preserve_alpha`; `inspect_rejects_missing_colr`; `inspect_rejects_invalid_ihdr_compression_type`; `inspect_rejects_ihdr_dimension_mismatch`; `inspect_rejects_ihdr_bpc_mismatch`; `inspect_rejects_bpcc_precision_mismatch`; `recode_jph_preserves_input_icc_color_spec`; `recode_jph_preserves_multiple_colr_boxes_for_coefficient_path`; `recode_jph_preserves_channel_definition_metadata_for_coefficient_path` | OpenJPEG/Grok JP2 metadata parity; `icc_roundtrip_deferred` tracks external fixture coverage | JP2/JPH wrapping now writes IHDR, one or more COLR boxes, BPCC for mixed precision/signedness, CDEF for explicit sRGB/premultiplied alpha, and explicit PCLR/CMAP/CDEF metadata through the public wrapper options. For paletted output, wrapper IHDR/BPCC metadata is derived from resolved image components rather than raw codestream component count. Public inspect preserves COLR/BPCC/PCLR/CMAP/CDEF/ICC metadata, including signed PCLR columns. Public helpers can borrow directly representable inspected COLR/PCLR/CMAP/CDEF metadata for JP2 and JPH rewrapping, and public/native decode validates required IHDR/COLR presence, IHDR compression type, full-resolution dimensions, and BPC/BPCC precision consistency against the resolved JP2 image components without rejecting reduced-resolution decode targets. Native decode resolves JP2 palette/component mapping for covered still-image cases, accepts premultiplied opacity and unspecified CDEF associations, including signed palette columns through component-plane output; J2K-to-JPH coefficient recode preserves directly representable COLR/ICC/CDEF metadata, including multiple COLR boxes. Broader external JP2/JPH metadata parity remains incomplete. | | part15-ht-refinement-decode | Done | `j2k-native::j2c::ht_block_decode`, segment/packet decode, `j2k` public facade | Existing HT cleanup/refinement decoder, packet metadata, OpenHTJ2K fixtures, public `J2kDecoder::decode_into` | `openhtj2k_ds0_ht_12_b11`, `openhtj2k_ds0_ht_09_b11`; `public_decode_matches_openhtj2k_refinement_fixtures` | OpenJPH and Kakadu context rows plus OpenJPEG/Grok where supported | Native refinement vectors and public facade decode coverage exist for the pinned OpenHTJ2K fixtures. The complete selected official T.803 v3 Part 15 decoder matrix is now blocking candidate evidence in `docs/t803-conformance.md`; additional non-ETS comparator expansion remains adoption evidence. | -| part15-ht-refinement-encode | Done | `j2k-native::j2c::ht_block_encode`, packet encode, rate/layer logic | Existing HT encode tables, packetization jobs, quality layer options | `cpu_htj2k_lossy_reports_rate_granularity`; `cpu_htj2k_lossy_three_quality_layers_use_three_pass_segment_granularity`; `ht_target_coding_passes_tracks_ht_quality_layers`; `preencoded_htj2k97_preserves_refinement_segments_in_packet_body`; `prequantized_htj2k97_accepts_empty_high_subbands`; `ht_cpu_fallback_encodes_two_pass_sigprop_refinement`; `ht_cpu_fallback_sigprop_refinement_encodes_new_significance_bits`; `ht_cpu_fallback_encodes_three_pass_magref_refinement`; `ht_cpu_fallback_rejects_unsupported_refinement_pass_count`; `accelerator_facade_ht_lossless_quality_layers_keep_cleanup_only`; `ht_layer_contributions_split_cleanup_and_refinement_across_layers`; `htj2k_lossy_quality_layers_decode_split_refinement_layer` | OpenJPH/Kakadu decode validation; OpenJPEG/Grok where supported | HTJ2K packetization and public lossy encode report segment/layer granularity, empty high subbands remain valid in prequantized HT packetization for degenerate decompositions, the preencoded HTJ2K 9/7 path preserves caller-supplied cleanup+refinement payloads through packetization, and the CPU fallback now emits real HT significance-propagation plus one magnitude-refinement pass for scalar irreversible/layered jobs with three-or-more quality layers while rejecting pass counts above the Part 15 cleanup/SigProp/MagRef boundary. Reversible HT jobs retain cleanup-only coding so the lossless path keeps its final cleanup bitplane; additional requested quality layers remain legal packet layers. The existing packet decoder and packetizer represent cleanup and refinement as separate HT segment layer contributions, so layered irreversible HTJ2K output can place refinement in a later non-empty packet contribution and strict native decode accepts that layout. HT block encoding covers the Part 15 cleanup plus optional SigProp/MagRef pass set; external decoder parity remains incomplete. | +| part15-ht-refinement-encode | Done | `j2k-native::j2c::ht_block_encode`, packet encode, whole-tile PCRD/layer logic, CUDA and Metal encode stages | Exact cleanup/SigProp/MagRef lengths, bounded consecutive-set generation, tile-wide convex-hull selection, distortion deltas, encode-stage candidate-set hook | `explicit_cleanup_bitplane_selects_the_requested_ht_set`; `bounded_candidate_generation_produces_two_consecutive_ht_sets`; `tile_candidate_selection_spends_the_shared_budget_on_the_best_block`; `tile_candidate_hull_can_switch_to_a_better_alternate_set`; `tile_candidate_selection_keeps_the_cheapest_cleanup_when_under_budget`; `cpu_htj2k_lossy_reports_rate_granularity`; `cpu_htj2k_lossy_three_quality_layers_use_three_pass_segment_granularity`; `ht_target_coding_passes_tracks_ht_quality_layers`; `preencoded_htj2k97_preserves_refinement_segments_in_packet_body`; `prequantized_htj2k97_accepts_empty_high_subbands`; `ht_cpu_fallback_encodes_two_pass_sigprop_refinement`; `ht_cpu_fallback_sigprop_refinement_encodes_new_significance_bits`; `ht_cpu_fallback_encodes_three_pass_magref_refinement`; `ht_cpu_fallback_rejects_unsupported_refinement_pass_count`; `accelerator_facade_ht_lossless_quality_layers_keep_cleanup_only`; `ht_layer_contributions_emit_the_selected_ht_set_atomically`; `ht_layer_contributions_preserve_refinement_metadata_when_co_located`; `htj2k_lossy_quality_layers_decode_atomic_ht_sets`; `htj2k_bounded_candidate_rate_control_emits_one_decodable_set`; `htj2k_bounded_rate_control_offers_two_set_jobs_to_accelerators`; `htj2k_bounded_rate_control_accepts_exact_accelerator_sets`; `cuda_htj2k_codeblock_candidates_preserve_exact_pass_boundaries_when_runtime_required`; `metal_htj2k_quality_layers_use_native_candidates_and_match_scalar_output`; `metal_htj2k_candidate_sets_preserve_exact_refinement_boundaries`; `all_t804_supported_cpu_matrix_cases_decode_with_openjpeg` | Balanced OpenJPH encoder/cross-decoder matrix; strict CUDA runtime lane; strict Metal lane | Byte-targeted lossy HTJ2K generates consecutive alternate HT sets, scores legal truncation points with per-pass distortion reduction, builds a convex frontier for each block, and spends one shared tile payload budget on the best legal frontier transitions. It emits exactly one selected set per block and preserves cleanup/SigProp/MagRef dependencies and boundaries. A selected set is emitted atomically in the layer containing its final selected pass, while different blocks can enter different layers; this preserves unambiguous cleanup boundaries across external decoders. Cleanup-only encoding does not pay distortion-scoring overhead. CUDA preserves native candidate boundaries, but entropy writing remains serial within each code block; a 128-lane coefficient-analysis prototype was removed after RTX A/B testing found no measurable end-to-end gain. Metal natively emits the richer candidate sets with exact segment metadata. The focused OpenJPH matrix covers Gray8, RGB8, and unsigned Gray16 for reversible 5/3 and Qfactor 90, through raw codestream and JPH inputs, requiring exact lossless output and at most one sample-value cross-decoder delta for irreversible output. | +| part15-openhtj2k-qfactor | Done | `j2k-native::j2c::quantize`, native/facade lossy encode | `J2kLossyEncodeOptions::with_qfactor`, native Qfactor entry points, expounded QCD/QCC writer | `openhtj2k_qfactor_90_matches_reference_qcd_and_qcc`; `qfactor_encode_writes_openhtj2k_compatible_qcd_and_qcc`; `htj2k_qfactor_is_opt_in_and_round_trips` | In-process OpenHTJ2K 0.19 decode and marker-tuple oracle | Opt-in grayscale and three-component 4:4:4 RGB encode reproduces OpenHTJ2K's per-resolution luma/chroma visual weighting and QCD/QCC tuples for Qfactor `1..=100`. Existing defaults and rate-target behavior are unchanged. Qfactor is not combined with byte/BPP/PSNR targets or ROI encode, and the implementation rejects more than 16 decomposition levels rather than extrapolating the reference profile. | | jph-wrapper | Done | `j2k::parse::boxes`, `j2k-core::CompressedPayloadKind`, JP2/JPH writer path | Existing JP2 signature/box parser, native JP2 decode path, passthrough model, `wrap_j2k_codestream` | `inspect_ht_jph_reports_file_wrapper`; `inspect_ht_jp2_rejects_file_type_mismatch`; `wrap_ht_codestream_as_jph_inspects_and_decodes`; `wrap_jph_rejects_classic_codestream`; `inspect_rejects_jp2_file_type_with_ht_codestream`; `jph_file_type_rejects_classic_codestream`; `jp2_file_type_rejects_htj2k_codestream`; `jph_file_type_accepts_htj2k_codestream`; `test_write_ht_lossless_codestream_headers`; `materializes_source_images_into_decode_and_encode_manifests` | OpenJPH/Kakadu JPH fixture context rows | JPH inspect classification, JPH write wrapping, JP2/JPH codestream-brand validation in both the public parser and native JP2 reader, and native decode through the existing JP2 reader are covered for minimal still-image files. Public inspect/view tests now use JPH for HTJ2K files and reject HT codestreams mislabeled as JP2. The HTJ2K codestream writer sets the Part 15 Rsiz capability bit, and adoption materialization emits JPH wrappers for HTJ2K rather than invalid HTJ2K-in-JP2 files; broader JPH metadata preservation and external JPH fixture parity remain publication evidence. | | j2k-to-htj2k-recode | Done | `j2k::recode`, `j2k-native::j2c::recode`, encode fallback | Existing coefficient-domain reversible 5/3 recode, native `decode_native`, native component-plane decode, public typed lossless HTJ2K encode, `wrap_j2k_codestream` | `lossy_97_source_uses_pixel_preserving_recode`; `signed_source_uses_pixel_preserving_recode`; `four_component_source_uses_pixel_preserving_recode`; `mixed_typed_source_uses_pixel_preserving_recode`; `high_bit_source_uses_pixel_preserving_recode`; `lossy_sampled_source_uses_pixel_fallback_and_preserves_sampling`; `raw_htj2k_lossless_can_be_wrapped_as_jph_without_reencode`; `recode_can_emit_jph_file_wrapper`; `recode_jph_preserves_input_icc_color_spec`; `recode_jph_preserves_channel_definition_metadata_for_coefficient_path`; `recode_jph_drops_palette_metadata_on_pixel_fallback`; `recode_jph_drops_component_mapping_metadata_on_sampled_pixel_fallback`; `recode_subsampled_classic_53_uses_coefficient_path_and_preserves_sampling` | OpenJPEG/Grok/OpenJPH/Kakadu recode validation rows | Coefficient-domain recode remains the preferred path where valid and now preserves directly representable JPH COLR/ICC/CDEF metadata because codestream component semantics are unchanged. Already-HTJ2K raw codestreams can be copied unchanged into a JPH wrapper, preserving HT segment/layer details. Valid sampled reversible 5/3 classic sources now stay on the coefficient-preserving path and keep codestream SIZ sampling in the HTJ2K output. Pixel-preserving fallback carries `u16` component counts, preserves mixed bit depth and mixed signedness through native component-plane decode plus public typed HTJ2K encode, preserves sampled lossy component grids through component-plane fallback, routes palette/component-mapped file inputs away from coefficient recode when codestream component semantics are not the output image semantics, covers sampled direct component mappings through resolved-pixel fallback, covers 29-bit high-bit fallback by forcing the current no-DWT HT-safe route, keeps directly representable non-palette COLR/ICC metadata, and intentionally drops palette/component-mapping/channel-definition boxes plus palette-dependent COLR when the fallback must operate on resolved pixels. External recode parity remains publication evidence, not an additional repo-local support blocker. | | benchmark-publication-gates | Done | `xtask adoption-report`, `docs/benchmark-corpora.md`, comparator harnesses | Existing adoption benchmark/report tooling | `cargo run -p xtask --features adoption -- adoption-report` | Requires external corpora, `publication_eligible`, OpenJPEG, Grok, OpenJPH, and Kakadu availability where claimed | Gate policy exists; it accepts adoption reports only when independent external evidence is included. | | external-speed-comparisons | Done | `j2k-compare`, `xtask adoption-benchmark`, `xtask adoption-report` | Existing fixture, batch, ROI, and encode comparator binaries | `cargo xtask bench-build`; adoption benchmark smoke rows | Speed reports must include codec, container, profile, operation, corpus, tool version, thread policy, batch size, skipped rows, and publication eligibility | OpenJPH and Kakadu remain optional context rows and must be labeled separately from default OpenJPEG/Grok/J2K rows. | +| bounded-row-decode | Done | `j2k-native::PreparedRegionDecoder`, `j2k::J2kDecoder` row facade | Scoped parsed-tile ownership, reusable decoder context, bounded packed-row scratch | `prepared_region_decoder_parses_tile_graph_once_across_regions`; `prepared_region_decoder_reuses_one_htj2k_tile_graph`; `decode_rows_u8_matches_full_rgb8_decode`; `decode_rows_u16_matches_full_gray16_decode`; `decode_rows_u16_matches_full_decode_for_signed_samples`; `decode_rows_u16_preserves_exact_high_bit_fallback`; `decode_rows_u8_matches_full_gray8_decode_for_htj2k`; one-row-at-a-time RGBA8/RGBA16 parity | CPU classic and HTJ2K row parity | Through the exact `f32` component-plane ceiling, one bounded row operation parses the tile graph once and reuses it across stripes for classic JPEG 2000 and HTJ2K. Parsed metadata remains in every stripe's aggregate allocation baseline; component, Tier-1, and IDWT owners remain reusable without retaining graphs across unrelated images. Above 24-bit precision, row decode preserves the existing exact-integer full-decode/crop fallback until the prepared session can borrow integer sidecar planes. | | jpx_part2_deferred | Out of scope | N/A | N/A | N/A | N/A | JPX/Part 2 is explicitly Out of scope for the Part 1 plus Part 15 support claim unless required for standard JP2/JPH still-image correctness. | ## Owned batch codec boundary diff --git a/docs/release-evidence/public-api/public-api-review-0.10.0.yml b/docs/release-evidence/public-api/public-api-review-0.10.0.yml index 68c02c7d..c1288792 100644 --- a/docs/release-evidence/public-api/public-api-review-0.10.0.yml +++ b/docs/release-evidence/public-api/public-api-review-0.10.0.yml @@ -79,10 +79,10 @@ reviews: j2k-types: removed_fingerprint: "none" added_fingerprint: "fnv1a64:f7bf662d894cec3c" - hidden_count: 87 - hidden_fingerprint: "fnv1a64:6924f5f92bd20fd3" + hidden_count: 103 + hidden_fingerprint: "fnv1a64:71b5df4e9aaebdcb" rationale: "Reviewed the complete ordinary j2k-types diff: it adds the shared typed prepared-plan geometry, owned accelerator jobs, allocation errors, and focused ownership-module surface required by A1, A2, and A6." - hidden_rationale: "Reviewed all 87 hidden j2k-types items after extracting transform, tier1, packetization, dispatch, and prepared-plan owners; the exact implementation-facing inventory is recorded here." + hidden_rationale: "Reviewed all 103 hidden j2k-types items after extracting transform, tier1, packetization, dispatch, and prepared-plan owners and adding the exact HT candidate-set accelerator job/result contract; the exact implementation-facing inventory is recorded here." j2k-codec-math: removed_fingerprint: "none" added_fingerprint: "fnv1a64:f2313be308412362" @@ -105,10 +105,10 @@ reviews: j2k-cuda-j2k-engine: removed_fingerprint: "none" added_fingerprint: "fnv1a64:8df2711346a99013" - hidden_count: 695 - hidden_fingerprint: "fnv1a64:809f589a3836ab70" + hidden_count: 700 + hidden_fingerprint: "fnv1a64:6e386ed2a046fefb" rationale: "Reviewed the complete first-release ordinary j2k-cuda-j2k-engine surface: its five entry points expose only the borrowed-context engine boundary and J2K kernel availability checks." - hidden_rationale: "Reviewed all 695 hidden J2K engine items after classic, HTJ2K, transform, store, encode, and ML kernel ownership moved out of the low-level runtime; the exact inventory is recorded here." + hidden_rationale: "Reviewed all 700 hidden J2K engine items after classic, HTJ2K, transform, store, encode, and ML kernel ownership moved out of the low-level runtime; the added HT encode accessors expose exact cleanup, SigProp, and MagRef boundaries without changing the compact status ABI." j2k-cuda-jpeg-engine: removed_fingerprint: "none" added_fingerprint: "fnv1a64:a77ba816ebfa6aad" @@ -132,11 +132,11 @@ reviews: hidden_rationale: "Reviewed the complete hidden Metal support inventory; retained ownership, checked allocation, command submission, and audited unsafe boundaries are unchanged from v0.9.0." j2k-native: removed_fingerprint: "fnv1a64:c43cf8aa5bb2b495" - added_fingerprint: "fnv1a64:09475401b85d7ab4" - hidden_count: 544 - hidden_fingerprint: "fnv1a64:30485095f90fe9fb" - rationale: "Reviewed the complete ordinary j2k-native diff: three signature paths now expose their focused j2k-types owners, matching added signatures with the same semantics, and DecodePlanAllocationError gains a typed conversion." - hidden_rationale: "Reviewed all 544 hidden native-codec items after prepared-plan, packing, allocation, and error ownership consolidation; the exact decode, transform, color, and SIMD inventory is recorded here." + added_fingerprint: "fnv1a64:94b2d8d0f672d116" + hidden_count: 555 + hidden_fingerprint: "fnv1a64:8bafdf926d55a460" + rationale: "Reviewed the complete ordinary j2k-native diff: three signature paths now expose their focused j2k-types owners, matching added signatures retain their semantics, DecodePlanAllocationError gains a typed conversion, and the additive HTJ2K Qfactor entry point validates the explicit visual-quality profile." + hidden_rationale: "Reviewed all 555 hidden native-codec items after prepared-plan, packing, allocation, and error ownership consolidation plus bounded HT candidate encoding, reusable scalar workspace, and scoped parse-once region decode; the exact implementation-facing inventory is recorded here." j2k-jpeg: removed_fingerprint: "none" added_fingerprint: "none" @@ -153,11 +153,11 @@ reviews: hidden_rationale: "Reviewed the complete hidden tile-codec inventory; codec, scratch, and source-preserving error boundaries are unchanged from v0.9.0." j2k: removed_fingerprint: "fnv1a64:2fe0a6167211371d" - added_fingerprint: "fnv1a64:1dd75ca4382819dc" - hidden_count: 106 - hidden_fingerprint: "fnv1a64:ae08920eeb566bb7" - rationale: "Reviewed the complete ordinary facade diff: accelerator and dispatch-report signature paths now expose focused j2k-types modules, while typed Classic and HT prepared-plan geometry accessors replace downcast-oriented use." - hidden_rationale: "Reviewed all 106 hidden j2k facade items after typed prepared plans and shared geometry were adopted; retained-plan, validation, accelerator-context, and typed-error implementation contracts are covered." + added_fingerprint: "fnv1a64:02fef903d0c01b87" + hidden_count: 108 + hidden_fingerprint: "fnv1a64:01e74ad6c3ff416e" + rationale: "Reviewed the complete ordinary facade diff: accelerator and dispatch-report signature paths now expose focused j2k-types modules, typed Classic and HT prepared-plan geometry accessors replace downcast-oriented use, and lossy HTJ2K gains an opt-in Qfactor field and builder." + hidden_rationale: "Reviewed all 108 hidden j2k facade items after typed prepared plans, shared geometry, and exact HT candidate-set re-exports were adopted; retained-plan, validation, accelerator-context, and typed-error implementation contracts are covered." j2k-transcode: removed_fingerprint: "fnv1a64:7d3992c196a64906" added_fingerprint: "fnv1a64:8392552cc2b1fe45" @@ -203,10 +203,10 @@ reviews: j2k-cuda: removed_fingerprint: "fnv1a64:8c14b4c94b6777f3" added_fingerprint: "fnv1a64:711c42364b8efc73" - hidden_count: 190 - hidden_fingerprint: "fnv1a64:4da267f54ebaba53" + hidden_count: 191 + hidden_fingerprint: "fnv1a64:82214b7c64fc5d9c" rationale: "Reviewed the complete ordinary J2K CUDA adapter diff: two dispatch-report signatures now expose the focused j2k-types defining module while retaining the same returned and stored report contract." - hidden_rationale: "Reviewed all 190 hidden J2K CUDA adapter items after codec execution moved behind the J2K engine; completion state, session usability, device identity, ordering, pooling, and guarded interop are covered." + hidden_rationale: "Reviewed all 191 hidden J2K CUDA adapter items after codec execution moved behind the J2K engine and exact HT candidate-set batching was added; completion state, pass boundaries, session usability, device identity, ordering, pooling, and guarded interop are covered." j2k-ml: removed_fingerprint: "none" added_fingerprint: "none" diff --git a/docs/release-evidence/public-api/reviewed-public-api-diff-0.10.0.md b/docs/release-evidence/public-api/reviewed-public-api-diff-0.10.0.md index 13028b79..add6b174 100644 --- a/docs/release-evidence/public-api/reviewed-public-api-diff-0.10.0.md +++ b/docs/release-evidence/public-api/reviewed-public-api-diff-0.10.0.md @@ -15,32 +15,32 @@ This report is generated by `cargo xtask semver --write-report`. Normal `cargo x | --- | --- | --- | --- | ---: | ---: | --- | --- | ---: | --- | | `j2k-core` | `0.9.0` | `0.10.0` | `major` | 32 | 0 | `none` | `fnv1a64:0f9439b457b6d9f8` | 223 | `fnv1a64:5eeeb03b82ab7efe` | | `j2k-profile` | `0.9.0` | `0.10.0` | `major` | 0 | 0 | `none` | `none` | 117 | `fnv1a64:a1546f56ae38f2e3` | -| `j2k-types` | `0.9.0` | `0.10.0` | `major` | 216 | 0 | `none` | `fnv1a64:f7bf662d894cec3c` | 87 | `fnv1a64:6924f5f92bd20fd3` | +| `j2k-types` | `0.9.0` | `0.10.0` | `major` | 216 | 0 | `none` | `fnv1a64:f7bf662d894cec3c` | 103 | `fnv1a64:71b5df4e9aaebdcb` | | `j2k-codec-math` | `0.9.0` | `0.10.0` | `major` | 1 | 0 | `none` | `fnv1a64:f2313be308412362` | 0 | `none` | | `j2k-cuda-build-support` | `new/unpublished` | `0.10.0` | `new` | 10 | 0 | `none` | `fnv1a64:336672b34c3be7a1` | 0 | `none` | | `j2k-cuda-runtime` | `0.9.0` | `0.10.0` | `major` | 30 | 1 | `fnv1a64:de2b8b0fd86c7229` | `fnv1a64:54c7a62865f7dec4` | 251 | `fnv1a64:20a941ec03c4ff5f` | -| `j2k-cuda-j2k-engine` | `new/unpublished` | `0.10.0` | `new` | 5 | 0 | `none` | `fnv1a64:8df2711346a99013` | 695 | `fnv1a64:809f589a3836ab70` | +| `j2k-cuda-j2k-engine` | `new/unpublished` | `0.10.0` | `new` | 5 | 0 | `none` | `fnv1a64:8df2711346a99013` | 700 | `fnv1a64:6e386ed2a046fefb` | | `j2k-cuda-jpeg-engine` | `new/unpublished` | `0.10.0` | `new` | 11 | 0 | `none` | `fnv1a64:a77ba816ebfa6aad` | 176 | `fnv1a64:e88101299bf042a3` | | `j2k-cuda-transcode-engine` | `new/unpublished` | `0.10.0` | `new` | 6 | 0 | `none` | `fnv1a64:4557f66a34d3d69f` | 84 | `fnv1a64:b2fcaa9b0171aba7` | | `j2k-metal-support` | `0.9.0` | `0.10.0` | `major` | 0 | 0 | `none` | `none` | 11 | `fnv1a64:02139606f1202938` | -| `j2k-native` | `0.9.0` | `0.10.0` | `major` | 5 | 3 | `fnv1a64:c43cf8aa5bb2b495` | `fnv1a64:09475401b85d7ab4` | 544 | `fnv1a64:30485095f90fe9fb` | +| `j2k-native` | `0.9.0` | `0.10.0` | `major` | 6 | 3 | `fnv1a64:c43cf8aa5bb2b495` | `fnv1a64:94b2d8d0f672d116` | 555 | `fnv1a64:8bafdf926d55a460` | | `j2k-jpeg` | `0.9.0` | `0.10.0` | `major` | 0 | 0 | `none` | `none` | 574 | `fnv1a64:b3e5271cb5d45bac` | | `j2k-tilecodec` | `0.9.0` | `0.10.0` | `major` | 0 | 0 | `none` | `none` | 32 | `fnv1a64:eb677842559d9b0e` | -| `j2k` | `0.9.0` | `0.10.0` | `major` | 11 | 4 | `fnv1a64:2fe0a6167211371d` | `fnv1a64:1dd75ca4382819dc` | 106 | `fnv1a64:ae08920eeb566bb7` | +| `j2k` | `0.9.0` | `0.10.0` | `major` | 13 | 4 | `fnv1a64:2fe0a6167211371d` | `fnv1a64:02fef903d0c01b87` | 108 | `fnv1a64:01e74ad6c3ff416e` | | `j2k-transcode` | `0.9.0` | `0.10.0` | `major` | 9 | 9 | `fnv1a64:7d3992c196a64906` | `fnv1a64:8392552cc2b1fe45` | 458 | `fnv1a64:449a76476549c1e2` | | `j2k-transcode-cuda` | `0.9.0` | `0.10.0` | `major` | 0 | 0 | `none` | `none` | 20 | `fnv1a64:be22ca1d5e12b043` | | `j2k-jpeg-metal` | `0.9.0` | `0.10.0` | `major` | 0 | 0 | `none` | `none` | 83 | `fnv1a64:79397ffc221464c3` | | `j2k-metal` | `0.9.0` | `0.10.0` | `major` | 3 | 0 | `none` | `fnv1a64:07ac16c3eaae6cb8` | 316 | `fnv1a64:62663f75c0f926b3` | | `j2k-transcode-metal` | `0.9.0` | `0.10.0` | `major` | 1 | 1 | `fnv1a64:2f6cfda5651faaa3` | `fnv1a64:3983c4b78178182b` | 49 | `fnv1a64:b4a8b53d5e10bb00` | | `j2k-jpeg-cuda` | `0.9.0` | `0.10.0` | `major` | 0 | 0 | `none` | `none` | 133 | `fnv1a64:9aa724bde7af1618` | -| `j2k-cuda` | `0.9.0` | `0.10.0` | `major` | 2 | 2 | `fnv1a64:8c14b4c94b6777f3` | `fnv1a64:711c42364b8efc73` | 190 | `fnv1a64:4da267f54ebaba53` | +| `j2k-cuda` | `0.9.0` | `0.10.0` | `major` | 2 | 2 | `fnv1a64:8c14b4c94b6777f3` | `fnv1a64:711c42364b8efc73` | 191 | `fnv1a64:82214b7c64fc5d9c` | | `j2k-ml` | `0.9.0` | `0.10.0` | `major` | 0 | 0 | `none` | `none` | 0 | `none` | | `j2k-mpsgraph` | `new/unpublished` | `0.10.0` | `new` | 93 | 0 | `none` | `fnv1a64:40cc7c000d014f84` | 0 | `none` | ## New packages without a 0.9.0 registry baseline - `j2k-cuda-build-support` `0.10.0`: 10 ordinary public API items, fingerprint `fnv1a64:336672b34c3be7a1`; 0 rustdoc-hidden public API items, full-inventory fingerprint `none`. -- `j2k-cuda-j2k-engine` `0.10.0`: 5 ordinary public API items, fingerprint `fnv1a64:8df2711346a99013`; 695 rustdoc-hidden public API items, full-inventory fingerprint `fnv1a64:809f589a3836ab70`. +- `j2k-cuda-j2k-engine` `0.10.0`: 5 ordinary public API items, fingerprint `fnv1a64:8df2711346a99013`; 700 rustdoc-hidden public API items, full-inventory fingerprint `fnv1a64:6e386ed2a046fefb`. - `j2k-cuda-jpeg-engine` `0.10.0`: 11 ordinary public API items, fingerprint `fnv1a64:a77ba816ebfa6aad`; 176 rustdoc-hidden public API items, full-inventory fingerprint `fnv1a64:e88101299bf042a3`. - `j2k-cuda-transcode-engine` `0.10.0`: 6 ordinary public API items, fingerprint `fnv1a64:4557f66a34d3d69f`; 84 rustdoc-hidden public API items, full-inventory fingerprint `fnv1a64:b2fcaa9b0171aba7`. - `j2k-mpsgraph` `0.10.0`: 93 ordinary public API items, fingerprint `fnv1a64:40cc7c000d014f84`; 0 rustdoc-hidden public API items, full-inventory fingerprint `none`. @@ -106,7 +106,7 @@ None. ### `j2k-types` -Baseline items: 374. Candidate items: 590. Computed release type: `major`. Rustdoc-hidden candidate items: 87. Full hidden-inventory fingerprint: `fnv1a64:6924f5f92bd20fd3`. +Baseline items: 374. Candidate items: 590. Computed release type: `major`. Rustdoc-hidden candidate items: 103. Full hidden-inventory fingerprint: `fnv1a64:71b5df4e9aaebdcb`. #### Removed or changed baseline API items @@ -406,7 +406,7 @@ None. ### `j2k-native` -Baseline items: 406. Candidate items: 408. Computed release type: `major`. Rustdoc-hidden candidate items: 544. Full hidden-inventory fingerprint: `fnv1a64:30485095f90fe9fb`. +Baseline items: 406. Candidate items: 409. Computed release type: `major`. Rustdoc-hidden candidate items: 555. Full hidden-inventory fingerprint: `fnv1a64:8bafdf926d55a460`. #### Removed or changed baseline API items @@ -421,6 +421,7 @@ pub j2k_native::Reversible53CoefficientImage::image: j2k_types::PrecomputedHtj2k ```text impl core::convert::From for j2k_native::DecodeError pub fn j2k_native::DecodeError::from(j2k_types::decode_plan::allocation::DecodePlanAllocationError) -> Self +pub fn j2k_native::encode_htj2k_with_qfactor(&[u8], u32, u32, u16, u8, bool, u8, &j2k_native::EncodeOptions) -> j2k_native::EncodeResult> pub j2k_native::EncodeError::Accelerator::source: j2k_types::dispatch::error::J2kEncodeStageError pub j2k_native::EncodeOptions::irreversible_quantization_subband_scales: j2k_types::transform::quantization::IrreversibleQuantizationSubbandScales pub j2k_native::Reversible53CoefficientImage::image: j2k_types::prepared_plan::PrecomputedHtj2k53Image @@ -452,7 +453,7 @@ None. ### `j2k` -Baseline items: 780. Candidate items: 787. Computed release type: `major`. Rustdoc-hidden candidate items: 106. Full hidden-inventory fingerprint: `fnv1a64:ae08920eeb566bb7`. +Baseline items: 780. Candidate items: 789. Computed release type: `major`. Rustdoc-hidden candidate items: 108. Full hidden-inventory fingerprint: `fnv1a64:01e74ad6c3ff416e`. #### Removed or changed baseline API items @@ -466,6 +467,7 @@ pub j2k::EncodedLossyJ2k::dispatch_report: j2k_types::J2kEncodeDispatchReport #### Added candidate API items ```text +pub fn j2k::J2kLossyEncodeOptions::with_qfactor(self, core::option::Option) -> Self pub fn j2k::PreparedClassicPlan::geometry(&self) -> &j2k::ClassicPreparedGeometry pub fn j2k::PreparedClassicPlan::image_geometry(&self) -> j2k::PreparedImageGeometry<'_> pub fn j2k::PreparedHtj2kPlan::geometry(&self) -> &j2k::Htj2kPreparedGeometry @@ -474,6 +476,7 @@ pub fn j2k::encode_j2k_lossless_with_accelerator(j2k::J2kLosslessSamples<'_>, &j pub fn j2k::encode_j2k_lossy_with_accelerator(j2k::J2kLossySamples<'_>, &j2k::J2kLossyEncodeOptions, j2k_core::backend::BackendKind, &mut impl j2k_types::dispatch::accelerator::J2kEncodeStageAccelerator) -> core::result::Result pub j2k::EncodedJ2k::dispatch_report: j2k_types::dispatch::report::J2kEncodeDispatchReport pub j2k::EncodedLossyJ2k::dispatch_report: j2k_types::dispatch::report::J2kEncodeDispatchReport +pub j2k::J2kLossyEncodeOptions::qfactor: core::option::Option pub type j2k::ClassicPreparedGeometry = j2k_types::decode_plan::referenced::J2kReferencedClassicPlan pub type j2k::Htj2kPreparedGeometry = j2k_types::decode_plan::referenced::J2kReferencedHtj2kPlan pub type j2k::PreparedImageGeometry<'a> = j2k_types::decode_plan::referenced::J2kReferencedImageGeometry<'a> @@ -581,7 +584,7 @@ None. ### `j2k-cuda` -Baseline items: 247. Candidate items: 247. Computed release type: `major`. Rustdoc-hidden candidate items: 190. Full hidden-inventory fingerprint: `fnv1a64:4da267f54ebaba53`. +Baseline items: 247. Candidate items: 247. Computed release type: `major`. Rustdoc-hidden candidate items: 191. Full hidden-inventory fingerprint: `fnv1a64:82214b7c64fc5d9c`. #### Removed or changed baseline API items diff --git a/docs/release.md b/docs/release.md index 82e7330b..793c8212 100644 --- a/docs/release.md +++ b/docs/release.md @@ -1,6 +1,6 @@ # Release Policy -The `j2k` 0.9.0 public crate release is published and security-supported. It is +The `j2k` 0.10.0 public crate release is published and security-supported. It is the latest published line and carries the release-scoped Part 1 and selected Part 15 T.803 decoder evidence described in [`T.803 conformance`](t803-conformance.md). @@ -12,7 +12,8 @@ evidence. | Version | Distribution state | Security support | | --- | --- | --- | -| `0.9.0` | Published on crates.io from annotated tag `v0.9.0`, with reviewed `objc2-metal` API-break evidence. | Latest supported release. | +| `0.10.0` | Published on crates.io from annotated tag `v0.10.0`, with reviewed architecture-transition API evidence. | Latest supported release. | +| `0.9.0` | Published on crates.io from annotated tag `v0.9.0`, with reviewed `objc2-metal` API-break evidence. | Supported. | | `0.8.1` | Previous crates.io release from annotated tag `v0.8.1`. | Supported. | | `0.8.0` | Previous crates.io release from annotated tag `v0.8.0`. | Supported. | | `0.7.5` | Previous crates.io release. Its `j2k-ml` CPU feature works, but its CUDA and Metal features have the clean-consumer defect described below. | Supported, with the stated `j2k-ml` accelerator exception. | @@ -87,9 +88,9 @@ directly; the obsolete helper and unreachable raw-message-send errors are removed. The break ledger enumerates every removed item in the four affected Metal crates. The one-time transition was consumed by `0.9.0`. -The staged `0.10.0` pre-1.0 minor candidate compares directly with published +The published `0.10.0` pre-1.0 minor release compares directly with published `v0.9.0` at peeled commit -`b197f01ab4b9271f1cbc36921755a5b9d588bd5a`. Its provisional +`b197f01ab4b9271f1cbc36921755a5b9d588bd5a`. Its [reviewed API report](release-evidence/public-api/reviewed-public-api-diff-0.10.0.md) and [review configuration](release-evidence/public-api/public-api-review-0.10.0.yml) record the intentional architecture transition. Most generated removals are @@ -97,7 +98,7 @@ canonical defining-path changes whose supported root re-exports remain. The break ledger also records moving `transcode_kernels_built` from the low-level CUDA runtime to the CUDA transcode engine and generalizing the Metal resident codestream handoff to `DeviceCodestream`. This one-time transition applies only -to the `0.10.0` candidate and must be disabled after publication. +to the `0.10.0` release and must be disabled after publication. Version `0.7.3` retained the API contract introduced by `0.7.1`, which intentionally contracted parts of the published pre-1.0 `0.6.2` API. It does diff --git a/docs/stable-api-1.0.implementation-public-api.txt b/docs/stable-api-1.0.implementation-public-api.txt index a794315d..27b0785d 100644 --- a/docs/stable-api-1.0.implementation-public-api.txt +++ b/docs/stable-api-1.0.implementation-public-api.txt @@ -64,6 +64,7 @@ pub type j2k::J2kDecoder<'_>::Warning = j2k::J2kDecodeWarning pub type j2k::J2kDecoder<'a>::View = j2k::J2kView<'a> pub use j2k::CpuOnlyJ2kEncodeStageAccelerator pub use j2k::EncodedHtJ2kCodeBlock +pub use j2k::EncodedHtJ2kCodeBlockSet pub use j2k::EncodedJ2kCodeBlock pub use j2k::IrreversibleQuantizationStep pub use j2k::IrreversibleQuantizationSubbandScales @@ -86,6 +87,7 @@ pub use j2k::J2kForwardDwt97Output pub use j2k::J2kForwardIctJob pub use j2k::J2kForwardRctJob pub use j2k::J2kHtCodeBlockEncodeJob +pub use j2k::J2kHtCodeBlockSetEncodeJob pub use j2k::J2kHtSubbandEncodeJob pub use j2k::J2kHtj2kTileEncodeJob pub use j2k::J2kPacketizationBlockCodingMode @@ -1565,6 +1567,7 @@ pub fn j2k_cuda::CudaEncodeStageAccelerator::encode_forward_dwt97(&mut self, j2k pub fn j2k_cuda::CudaEncodeStageAccelerator::encode_forward_ict(&mut self, j2k_types::transform::mct::J2kForwardIctJob<'_>) -> j2k_types::dispatch::error::J2kEncodeStageResult pub fn j2k_cuda::CudaEncodeStageAccelerator::encode_forward_rct(&mut self, j2k_types::transform::mct::J2kForwardRctJob<'_>) -> j2k_types::dispatch::error::J2kEncodeStageResult pub fn j2k_cuda::CudaEncodeStageAccelerator::encode_ht_code_block(&mut self, j2k_types::tier1::htj2k::J2kHtCodeBlockEncodeJob<'_>) -> j2k_types::dispatch::error::J2kEncodeStageResult> +pub fn j2k_cuda::CudaEncodeStageAccelerator::encode_ht_code_block_sets(&mut self, &[j2k_types::tier1::htj2k::J2kHtCodeBlockSetEncodeJob<'_>]) -> j2k_types::dispatch::error::J2kEncodeStageResult>> pub fn j2k_cuda::CudaEncodeStageAccelerator::encode_ht_code_blocks(&mut self, &[j2k_types::tier1::htj2k::J2kHtCodeBlockEncodeJob<'_>]) -> j2k_types::dispatch::error::J2kEncodeStageResult>> pub fn j2k_cuda::CudaEncodeStageAccelerator::encode_ht_subband(&mut self, j2k_types::tier1::htj2k::J2kHtSubbandEncodeJob<'_>) -> j2k_types::dispatch::error::J2kEncodeStageResult>> pub fn j2k_cuda::CudaEncodeStageAccelerator::encode_htj2k_tile(&mut self, j2k_types::dispatch::accelerator::J2kHtj2kTileEncodeJob<'_>) -> j2k_types::dispatch::error::J2kEncodeStageResult>> @@ -2288,6 +2291,7 @@ impl core::fmt::Display for j2k_native::ResidentHtj2kEncodeError impl core::fmt::Display for j2k_native::packet_math::HtSegmentLengthError impl j2k_native::HtCodeBlockDecodeProfile impl j2k_native::HtCodeBlockDecodeWorkspace +impl j2k_native::HtCodeBlockEncodeWorkspace impl j2k_native::HtSigPropBenchmarkState impl j2k_native::Image<'_> impl j2k_native::J2kCodeBlockDecodeProfile @@ -2296,6 +2300,7 @@ impl j2k_native::J2kDirectCpuScratch impl j2k_native::J2kDirectDecodedComponents<'_> impl j2k_native::J2kRequiredBandRegion impl j2k_native::Jp2ChannelDefinition +impl j2k_native::PreparedRegionDecoder<'_, '_, '_> impl j2k_native::Reversible53CoefficientImage impl j2k_native::packet_math::HtSegmentLengthError impl<'a> j2k_native::J2kDirectDecodedPlane<'a> @@ -2337,6 +2342,7 @@ pub fn j2k_native::HtCodeBlockDecoder::decode_single_decomposition_idwt_with_nor pub fn j2k_native::HtCodeBlockDecoder::decode_store_component(&mut self, j2k_native::J2kStoreComponentJob<'_>) -> j2k_native::Result pub fn j2k_native::HtCodeBlockDecoder::decode_sub_band(&mut self, j2k_native::HtSubBandDecodeJob<'_>, &mut [f32]) -> j2k_native::Result pub fn j2k_native::HtCodeBlockDecoder::decode_sub_band_with_midpoint(&mut self, j2k_native::HtSubBandDecodeJob<'_>, &mut [f32], bool) -> j2k_native::Result +pub fn j2k_native::HtCodeBlockEncodeWorkspace::try_new() -> j2k_native::EncodeResult pub fn j2k_native::HtSigPropBenchmarkState::output_len(&self) -> usize pub fn j2k_native::Image<'_>::decoded_samples_equal(&self, &j2k_native::Image<'_>) -> j2k_native::Result pub fn j2k_native::Image<'_>::decoded_samples_equal_with_retained_bytes(&self, &j2k_native::Image<'_>, &alloc::vec::Vec) -> j2k_native::Result @@ -2354,6 +2360,7 @@ pub fn j2k_native::Image<'a>::decode_reversible_53_coefficients(&self) -> j2k_na 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>::prepare_region_decoder_with_context<'image, 'context>(&'image self, &'context mut j2k_native::DecoderContext<'a>) -> j2k_native::Result> pub fn j2k_native::Image<'a>::primary_icc_profile(&self) -> core::option::Option<&[u8]> 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 @@ -2376,6 +2383,7 @@ pub fn j2k_native::J2kRequiredBandRegion::union(self, Self) -> Self pub fn j2k_native::J2kRequiredBandRegion::width(self) -> u32 pub fn j2k_native::NativeComponentPlane::allocated_bytes(&self) -> usize pub fn j2k_native::NativeComponentPlane::into_parts(self) -> j2k_native::NativeComponentPlaneParts +pub fn j2k_native::PreparedRegionDecoder<'_, '_, '_>::decode_region_components(&mut self, (u32, u32, u32, u32)) -> j2k_native::Result> pub fn j2k_native::ResidentHtj2kEncodeError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result pub fn j2k_native::ResidentHtj2kEncodeError::source(&self) -> core::option::Option<&(dyn core::error::Error + 'static)> pub fn j2k_native::Reversible53CoefficientImage::encode_htj2k(&self, &j2k_native::EncodeOptions) -> j2k_native::EncodeResult> @@ -2396,6 +2404,8 @@ pub fn j2k_native::decode_j2k_code_block_scalar_with_workspace_profiled(j2k_nati pub fn j2k_native::decode_j2k_sub_band_scalar(j2k_native::J2kSubBandDecodeJob<'_>, &mut [f32]) -> j2k_native::Result<()> pub fn j2k_native::encode_ht_code_block_scalar(&[i32], u32, u32, u8) -> j2k_native::EncodeResult pub fn j2k_native::encode_ht_code_block_scalar_with_passes(&[i32], u32, u32, u8, u8) -> j2k_native::EncodeResult +pub fn j2k_native::encode_ht_code_block_scalar_with_passes_and_workspace(&[i32], u32, u32, u8, u8, &mut j2k_native::HtCodeBlockEncodeWorkspace) -> j2k_native::EncodeResult +pub fn j2k_native::encode_htj2k_with_qfactor_and_accelerator(&[u8], u32, u32, u16, u8, bool, u8, &j2k_native::EncodeOptions, &mut impl j2k_types::dispatch::accelerator::J2kEncodeStageAccelerator) -> j2k_native::EncodeResult> pub fn j2k_native::encode_j2k_code_block_scalar_with_style(&[i32], u32, u32, j2k_types::tier1::classic::J2kSubBandType, u8, j2k_types::tier1::classic::J2kCodeBlockStyle) -> j2k_native::EncodeResult pub fn j2k_native::encode_j2k_packetization_scalar(j2k_types::packetization::jobs::J2kPacketizationEncodeJob<'_>) -> j2k_native::EncodeResult> pub fn j2k_native::encode_precomputed_htj2k_53(&j2k_types::prepared_plan::PrecomputedHtj2k53Image, &j2k_native::EncodeOptions) -> j2k_native::EncodeResult> @@ -2707,6 +2717,7 @@ pub struct j2k_native::HtCleanupEncodeDistribution pub struct j2k_native::HtCodeBlockBatchJob<'a> pub struct j2k_native::HtCodeBlockDecodeJob<'a> pub struct j2k_native::HtCodeBlockDecodeWorkspace +pub struct j2k_native::HtCodeBlockEncodeWorkspace pub struct j2k_native::HtSigPropBenchmarkState(_) pub struct j2k_native::HtSubBandDecodeJob<'a> pub struct j2k_native::J2kCodeBlockBatchJob<'a> @@ -2734,11 +2745,13 @@ pub struct j2k_native::Jp2ImageHeaderMetadata pub struct j2k_native::Jp2PaletteColumn pub struct j2k_native::Jp2PaletteMetadata pub struct j2k_native::NativeComponentPlaneParts +pub struct j2k_native::PreparedRegionDecoder<'image, 'context, 'a> pub trait j2k_native::HtCodeBlockDecoder pub use j2k_native::CpuOnlyJ2kEncodeStageAccelerator pub use j2k_native::DEFAULT_MAX_CODEC_BYTES pub use j2k_native::DEFAULT_MAX_DECODE_BYTES pub use j2k_native::EncodedHtJ2kCodeBlock +pub use j2k_native::EncodedHtJ2kCodeBlockSet pub use j2k_native::EncodedJ2kCodeBlock pub use j2k_native::HtCodeBlockPayloadRanges pub use j2k_native::HtOwnedCodeBlockBatchJob @@ -2773,6 +2786,7 @@ pub use j2k_native::J2kForwardDwt97Output pub use j2k_native::J2kForwardIctJob pub use j2k_native::J2kForwardRctJob pub use j2k_native::J2kHtCodeBlockEncodeJob +pub use j2k_native::J2kHtCodeBlockSetEncodeJob pub use j2k_native::J2kHtSubbandEncodeJob pub use j2k_native::J2kHtj2kTileEncodeJob pub use j2k_native::J2kOwnedCodeBlockBatchJob @@ -2856,6 +2870,7 @@ pub fn j2k_types::CpuOnlyJ2kEncodeStageAccelerator::encode_forward_dwt97(&mut se pub fn j2k_types::CpuOnlyJ2kEncodeStageAccelerator::encode_forward_ict(&mut self, j2k_types::J2kForwardIctJob<'_>) -> j2k_types::J2kEncodeStageResult pub fn j2k_types::CpuOnlyJ2kEncodeStageAccelerator::encode_forward_rct(&mut self, j2k_types::J2kForwardRctJob<'_>) -> j2k_types::J2kEncodeStageResult pub fn j2k_types::CpuOnlyJ2kEncodeStageAccelerator::encode_ht_code_block(&mut self, j2k_types::J2kHtCodeBlockEncodeJob<'_>) -> j2k_types::J2kEncodeStageResult> +pub fn j2k_types::CpuOnlyJ2kEncodeStageAccelerator::encode_ht_code_block_sets(&mut self, &[j2k_types::J2kHtCodeBlockSetEncodeJob<'_>]) -> j2k_types::J2kEncodeStageResult>> pub fn j2k_types::CpuOnlyJ2kEncodeStageAccelerator::encode_ht_code_blocks(&mut self, &[j2k_types::J2kHtCodeBlockEncodeJob<'_>]) -> j2k_types::J2kEncodeStageResult>> pub fn j2k_types::CpuOnlyJ2kEncodeStageAccelerator::encode_ht_subband(&mut self, j2k_types::J2kHtSubbandEncodeJob<'_>) -> j2k_types::J2kEncodeStageResult>> pub fn j2k_types::CpuOnlyJ2kEncodeStageAccelerator::encode_htj2k_tile(&mut self, j2k_types::J2kHtj2kTileEncodeJob<'_>) -> j2k_types::J2kEncodeStageResult>> @@ -2868,6 +2883,7 @@ pub fn j2k_types::CpuOnlyJ2kEncodeStageAccelerator::ht_subband_maximum_cleanup_m pub fn j2k_types::CpuOnlyJ2kEncodeStageAccelerator::ht_tile_required_magnitude_bound(&self) -> core::option::Option pub fn j2k_types::CpuOnlyJ2kEncodeStageAccelerator::prefer_parallel_cpu_code_block_fallback(&self) -> bool pub fn j2k_types::CpuOnlyJ2kEncodeStageAccelerator::prefer_parallel_cpu_tile_encode(&self) -> bool +pub fn j2k_types::J2kEncodeStageAccelerator::encode_ht_code_block_sets(&mut self, &[j2k_types::J2kHtCodeBlockSetEncodeJob<'_>]) -> j2k_types::J2kEncodeStageResult>> pub fn j2k_types::J2kEncodeStageAccelerator::encode_resident_htj2k_tile(&mut self, j2k_types::J2kResidentHtj2kTileEncodeJob<'_>) -> j2k_types::J2kEncodeStageResult>> pub fn j2k_types::J2kResidentEncodeInput::new(u32, u32, u16, u8, bool) -> core::result::Result pub fn j2k_types::J2kResidentEncodeInputError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result @@ -2875,6 +2891,18 @@ pub fn j2k_types::encode_geometry::EncodeDwtLevelDimensionsIter::next(&mut self) pub fn j2k_types::encode_geometry::EncodeDwtLevelDimensionsIter::size_hint(&self) -> (usize, core::option::Option) pub fn j2k_types::encode_geometry::code_block_exponent(u32) -> core::result::Result pub fn j2k_types::encode_geometry::sort_packet_descriptors_for_progression(&mut [j2k_types::J2kPacketizationPacketDescriptor], j2k_types::J2kPacketizationProgressionOrder) +pub j2k_types::EncodedHtJ2kCodeBlockSet::cleanup_length: u32 +pub j2k_types::EncodedHtJ2kCodeBlockSet::data: alloc::vec::Vec +pub j2k_types::EncodedHtJ2kCodeBlockSet::magref_length: u32 +pub j2k_types::EncodedHtJ2kCodeBlockSet::num_coding_passes: u8 +pub j2k_types::EncodedHtJ2kCodeBlockSet::num_zero_bitplanes: u8 +pub j2k_types::EncodedHtJ2kCodeBlockSet::sigprop_length: u32 +pub j2k_types::J2kHtCodeBlockSetEncodeJob::cleanup_bitplane: u8 +pub j2k_types::J2kHtCodeBlockSetEncodeJob::coefficients: &'a [i32] +pub j2k_types::J2kHtCodeBlockSetEncodeJob::height: u32 +pub j2k_types::J2kHtCodeBlockSetEncodeJob::target_coding_passes: u8 +pub j2k_types::J2kHtCodeBlockSetEncodeJob::total_bitplanes: u8 +pub j2k_types::J2kHtCodeBlockSetEncodeJob::width: u32 pub j2k_types::J2kResidentEncodeInputError::AddressSpaceOverflow pub j2k_types::J2kResidentEncodeInputError::ComponentCountOutOfRange pub j2k_types::J2kResidentEncodeInputError::ComponentCountOutOfRange::num_components: u16 @@ -2904,6 +2932,8 @@ pub j2k_types::encode_geometry::EncodeDwtLevelDimensions::high_width: u32 pub j2k_types::encode_geometry::EncodeDwtLevelDimensions::low_height: u32 pub j2k_types::encode_geometry::EncodeDwtLevelDimensions::low_width: u32 pub mod j2k_types::encode_geometry +pub struct j2k_types::EncodedHtJ2kCodeBlockSet +pub struct j2k_types::J2kHtCodeBlockSetEncodeJob<'a> pub struct j2k_types::J2kResidentEncodeInput pub struct j2k_types::J2kResidentHtj2kTileEncodeJob<'a> pub struct j2k_types::encode_geometry::EncodeCodeBlockDimensions @@ -3408,10 +3438,12 @@ pub fn j2k_cuda_j2k_engine::CudaDwt97Output::transformed(&self) -> &[f32] pub fn j2k_cuda_j2k_engine::CudaHtj2kCompactEncodedCodeBlock::cleanup_length(&self) -> u32 pub fn j2k_cuda_j2k_engine::CudaHtj2kCompactEncodedCodeBlock::execution(&self) -> j2k_cuda_runtime::execution::queued::CudaExecutionStats pub fn j2k_cuda_j2k_engine::CudaHtj2kCompactEncodedCodeBlock::into_parts(self) -> (core::ops::range::Range, u32, u32, u8, u8) +pub fn j2k_cuda_j2k_engine::CudaHtj2kCompactEncodedCodeBlock::magref_length(&self) -> u32 pub fn j2k_cuda_j2k_engine::CudaHtj2kCompactEncodedCodeBlock::num_coding_passes(&self) -> u8 pub fn j2k_cuda_j2k_engine::CudaHtj2kCompactEncodedCodeBlock::num_zero_bitplanes(&self) -> u8 pub fn j2k_cuda_j2k_engine::CudaHtj2kCompactEncodedCodeBlock::payload_range(&self) -> core::ops::range::Range pub fn j2k_cuda_j2k_engine::CudaHtj2kCompactEncodedCodeBlock::refinement_length(&self) -> u32 +pub fn j2k_cuda_j2k_engine::CudaHtj2kCompactEncodedCodeBlock::sigprop_length(&self) -> u32 pub fn j2k_cuda_j2k_engine::CudaHtj2kCompactEncodedCodeBlock::stage_timings(&self) -> j2k_cuda_j2k_engine::CudaHtj2kEncodeStageTimings pub fn j2k_cuda_j2k_engine::CudaHtj2kCompactEncodedCodeBlock::status(&self) -> j2k_cuda_j2k_engine::CudaHtj2kEncodeStatus pub fn j2k_cuda_j2k_engine::CudaHtj2kCompactEncodedCodeBlocks::code_blocks(&self) -> &[j2k_cuda_j2k_engine::CudaHtj2kCompactEncodedCodeBlock] @@ -3430,10 +3462,13 @@ pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodeStatus::is_ok(self) -> bool pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock::cleanup_length(&self) -> u32 pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock::data(&self) -> &[u8] pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock::execution(&self) -> j2k_cuda_runtime::execution::queued::CudaExecutionStats +pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock::into_exact_parts(self) -> (alloc::vec::Vec, u32, u32, u32, u8, u8) pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock::into_parts(self) -> (alloc::vec::Vec, u32, u32, u8, u8) +pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock::magref_length(&self) -> u32 pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock::num_coding_passes(&self) -> u8 pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock::num_zero_bitplanes(&self) -> u8 pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock::refinement_length(&self) -> u32 +pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock::sigprop_length(&self) -> u32 pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock::stage_timings(&self) -> j2k_cuda_j2k_engine::CudaHtj2kEncodeStageTimings pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock::status(&self) -> j2k_cuda_j2k_engine::CudaHtj2kEncodeStatus pub fn j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlocks::code_blocks(&self) -> &[j2k_cuda_j2k_engine::CudaHtj2kEncodedCodeBlock] diff --git a/docs/stable-api-1.0.md b/docs/stable-api-1.0.md index 11fead09..88580135 100644 --- a/docs/stable-api-1.0.md +++ b/docs/stable-api-1.0.md @@ -96,7 +96,7 @@ release gates complete. [v0.8.0-api-report]: https://github.com/frames-sg/j2k/blob/v0.8.0/engineering/reviewed-public-api-diff-0.8.0.md -The currently published stable contract is the `0.9.x` line. Version `0.8.0` +The currently published stable contract is the `0.10.x` line. Version `0.8.0` intentionally changed the strict-decoding behavior and one warning variant under Cargo's pre-1.0 compatibility rules. It does not claim source or behavior compatibility with `0.7.x`; its exact breaks and migrations are in the review diff --git a/docs/stable-api-1.0.public-api.txt b/docs/stable-api-1.0.public-api.txt index 4e597c9b..1b134929 100644 --- a/docs/stable-api-1.0.public-api.txt +++ b/docs/stable-api-1.0.public-api.txt @@ -285,6 +285,7 @@ pub fn j2k::J2kLossyEncodeOptions::with_cpu_only_backend(self) -> Self pub fn j2k::J2kLossyEncodeOptions::with_marker_segments(self, alloc::vec::Vec) -> Self pub fn j2k::J2kLossyEncodeOptions::with_max_decomposition_levels(self, core::option::Option) -> Self pub fn j2k::J2kLossyEncodeOptions::with_progression(self, j2k::J2kProgressionOrder) -> Self +pub fn j2k::J2kLossyEncodeOptions::with_qfactor(self, core::option::Option) -> Self pub fn j2k::J2kLossyEncodeOptions::with_quality_layers(self, alloc::vec::Vec) -> Self pub fn j2k::J2kLossyEncodeOptions::with_rate_target(self, core::option::Option) -> Self pub fn j2k::J2kLossyEncodeOptions::with_strict_device_backend(self) -> Self @@ -622,6 +623,7 @@ pub j2k::J2kLossyEncodeOptions::precinct_exponents: alloc::vec::Vec<(u8, u8)> pub j2k::J2kLossyEncodeOptions::progression: j2k::J2kProgressionOrder pub j2k::J2kLossyEncodeOptions::psnr_iteration_budget: u8 pub j2k::J2kLossyEncodeOptions::psnr_tolerance_db: f64 +pub j2k::J2kLossyEncodeOptions::qfactor: core::option::Option pub j2k::J2kLossyEncodeOptions::quality_layers: alloc::vec::Vec pub j2k::J2kLossyEncodeOptions::rate_target: core::option::Option pub j2k::J2kLossyEncodeOptions::tile_part_packet_limit: core::option::Option @@ -3780,6 +3782,7 @@ pub fn j2k_native::ValidationError::fmt(&self, &mut core::fmt::Formatter<'_>) -> pub fn j2k_native::encode(&[u8], u32, u32, u16, u8, bool, &j2k_native::EncodeOptions) -> j2k_native::EncodeResult> pub fn j2k_native::encode_component_planes_53(&[j2k_native::EncodeComponentPlane<'_>], u32, u32, u8, bool, &j2k_native::EncodeOptions) -> j2k_native::EncodeResult> pub fn j2k_native::encode_htj2k(&[u8], u32, u32, u16, u8, bool, &j2k_native::EncodeOptions) -> j2k_native::EncodeResult> +pub fn j2k_native::encode_htj2k_with_qfactor(&[u8], u32, u32, u16, u8, bool, u8, &j2k_native::EncodeOptions) -> j2k_native::EncodeResult> pub fn j2k_native::encode_typed_component_planes_53(&[j2k_native::EncodeTypedComponentPlane<'_>], u32, u32, &j2k_native::EncodeOptions) -> j2k_native::EncodeResult> pub fn j2k_native::encode_with_roi_regions(&[u8], u32, u32, u16, u8, bool, &j2k_native::EncodeOptions, &[j2k_native::EncodeRoiRegion]) -> j2k_native::EncodeResult> pub fn j2k_native::inspect_htj2k_capabilities(&[u8]) -> core::result::Result, j2k_native::J2kCodestreamHeaderError> diff --git a/docs/unsafe-audit.md b/docs/unsafe-audit.md index 0e3fae9a..4ab8f7f9 100644 --- a/docs/unsafe-audit.md +++ b/docs/unsafe-audit.md @@ -12,6 +12,8 @@ moved, or removed. | `crates/j2k-alloc-probe/src/lib.rs` | Dev-only process-global allocation measurement across caller and joined Rayon worker threads. | Every allocator operation delegates unchanged to `System`; atomic bookkeeping never dereferences allocation pointers. Arming and epoch states prevent rejected or delayed operations from resetting or leaking across measurement windows, snapshots wait for admitted reporters, and deallocations never reduce the gross-byte budget. | Allocator self-tests cover allocation, reallocation, panic recovery, concurrent rejection, delayed cross-epoch observation, and a property-generated family of pre-existing-free traces; codec probes exercise warm decode reuse and bounded encode behavior. | | `crates/j2k-core/src/accelerator.rs` | Shared GPU ABI marker trait and byte-view helpers for host/device struct transfers. | Implementers are plain-data GPU ABI values with stable layout and valid byte views. | Core API tests plus backend layout/parity tests for GPU ABI structs. | | `crates/j2k-core/src/backend.rs` | CPU feature detection and backend probing. | CPU intrinsics are called only behind matching architecture/configuration checks. | Cross-architecture CI matrix and backend feature tests. | +| `crates/j2k-compare/src/openhtj2k.rs` | Optional in-process C ABI boundary for the pinned OpenHTJ2K decoder shim. | The borrowed input remains live for the synchronous call; writable output metadata points to initialized locals; a non-null shim allocation is bounded by checked dimensions and the shared output cap before slice construction, and its guard calls the paired free function exactly once on success and every later error. | `output_len_is_bounded_before_slice_construction`, `decodes_pinned_gray8_fixture_in_process`, and `decodes_native_openhtj2k_qfactor_rgb_within_one_lsb`, plus strict all-target Clippy when the pinned library is linked. | +| `crates/j2k-compare/src/openjph.rs` | Optional in-process C ABI boundary for the pinned OpenJPH decoder shim. | The borrowed input remains live for the synchronous call; writable output metadata points to initialized locals; a non-null shim allocation is bounded by checked dimensions and the shared output cap before slice construction, and its guard calls the paired free function exactly once on success and every later error. | `output_len_is_bounded_before_slice_construction` and `openjph_in_process_tests::decodes_pinned_gray8_fixture_in_process`, plus strict all-target Clippy when the pinned library is linked. | | `crates/j2k-cuda-j2k-engine/src/bytes.rs` | Padding-free CUDA ABI markers shared by J2K engine jobs. | Compile-time field-offset walks prove each `repr(C)` numeric record has no padding and accepts every bit pattern before it is exposed as bytes. | ABI layout tests, generated-kernel metadata tests, and the strict CUDA validation lane. | | `crates/j2k-cuda-j2k-engine/src/bytes/htj2k_encode_abi.rs` | CUDA ABI markers for HTJ2K encode records. | Every marked `repr(C)` record is padding-free by compile-time field-offset checks, contains only all-bit-pattern numeric fields, and initializes reserved fields. | HTJ2K encode ABI, metadata, and NVIDIA parity tests. | | `crates/j2k-cuda-j2k-engine/src/bytes/j2k_abi.rs` | CUDA ABI markers for J2K transform, quantization, packetization, and store records. | Compile-time layout walks cover the full initialized `repr(C)` representation and all fields accept every bit pattern. | J2K ABI and generated-PTX metadata tests plus strict CUDA parity. | @@ -41,7 +43,7 @@ moved, or removed. | `crates/j2k-cuda-j2k-engine/src/cuda_oxide_j2k_decode_store/simt/src/transform.rs` | Register-only inline PTX for exact binary32 inverse color-transform FMA. | Each unsafe block binds only finite `f32` register operands and emits one `fma.rn.f32`; it has no memory operands, control-flow effects, or lane-participation requirement. | Exact Classic irreversible CPU/CUDA parity across native layouts and requests, plus generated PTX validation on the strict NVIDIA lane. | | `crates/j2k-cuda-j2k-engine/src/cuda_oxide_j2k_dequantize/simt/src/main.rs` | cuda-oxide device J2K HTJ2K dequantize kernels. | Device pointers refer to live, launch-bounded coefficient buffers or job-buffer device pointers; each block reads only its own job and each thread strides inside that job's sample count. | HTJ2K dequantize parity and metadata tests when cuda-oxide PTX is generated plus strict GPU validation workflow. | | `crates/j2k-cuda-j2k-engine/src/cuda_oxide_htj2k_decode/simt/src/main.rs` | cuda-oxide device HTJ2K cleanup/refinement decode kernels. | Device pointers refer to live payload, lookup-table, job, output, status, and timing buffers; per-thread stack scratch is bounded by the validated code-block width, height, and stride constants before raw pointer access or status writes. | HTJ2K decode metadata tests when cuda-oxide PTX is generated, focused strict runtime tests on a self-hosted CUDA runner, and HTJ2K CUDA smoke coverage. | -| `crates/j2k-cuda-j2k-engine/src/cuda_oxide_htj2k_encode/simt/src/main.rs` | cuda-oxide device HTJ2K cleanup/refinement codeblock encode kernels. | Device pointers refer to live coefficient, lookup-table, scratch-output, and status buffers; each block owns one codeblock, thread 0 exits on invalid shapes before stack-scratch encode work, and status rows surface unsupported/failure shapes without silent fallback. | HTJ2K encode metadata tests when cuda-oxide PTX is generated, focused strict runtime tests on a self-hosted CUDA runner, and HTJ2K codeblock parity tests. | +| `crates/j2k-cuda-j2k-engine/src/cuda_oxide_htj2k_encode/simt/src/main.rs` | cuda-oxide device HTJ2K cleanup/refinement codeblock encode kernels. | Device pointers refer to live coefficient, lookup-table, scratch-output, and status buffers and each block owns one codeblock. Only lane zero reads coefficients or enters the serial entropy writer; status rows surface unsupported/failure shapes without silent fallback. | `htj2k_launch_geometry_matches_codeblock_work`, HTJ2K encode metadata tests when cuda-oxide PTX is generated, focused strict runtime tests on a self-hosted CUDA runner, and HTJ2K codeblock parity tests. | | `crates/j2k-cuda-jpeg-engine/src/cuda_oxide_jpeg_decode/simt/src/main.rs` | cuda-oxide device baseline JPEG entropy decode and RGB8 output kernels for 4:2:0, 4:2:2, and 4:4:4 resident inputs. | Device pointers refer to live entropy, Huffman-table, checkpoint, output, and status buffers. Host preflight proves the exact sampling-derived MCU grid, a strict checkpoint partition from MCU zero, left-aligned bounded bit state, canonical role-correct tables, and a pitched last RGB byte within `u32`; the device repeats those bounds with checked arithmetic and stores a nonzero status on every defensive rejection before raw pointer reads or writes. | Pure adversarial plan/table/address tests, generated-kernel metadata and PTX assembly checks, exact-source 4:2:0/4:2:2/4:4:4 owned/caller-buffer parity on the NVIDIA runner, and GPU validation with `J2K_REQUIRE_CUDA_JPEG_HARDWARE_DECODE`. | | `crates/j2k-cuda-jpeg-engine/src/cuda_oxide_jpeg_encode/simt/src/main.rs` | cuda-oxide device baseline JPEG MCU-parallel coefficient precompute and ordered per-tile entropy kernels for resident input tiles. | Host preflight validates input and entropy allocation ranges, disjoint batch outputs, sampling/MCU geometry, every kernel-side `u32` expression, nonzero quantizers, and canonical prefix-free non-all-ones Huffman codes before driver work. The checked staging plan additionally proves packed coefficient scratch size/indexing and one-work-item-per-MCU launch bounds. Device pointers then refer to live validated buffers, and status rows surface missing symbols or capacity exhaustion. | Pure adversarial layout/table/boundary and staging-plan tests, generated-kernel metadata tests, the fixed-hash Gray/RGB sampling/restart/quality matrix with dual decoding and determinism checks, nonzero-offset single and batch round trips, and strict resident CUDA encode tests on the RTX runner. | | `crates/j2k-cuda-j2k-engine/src/cuda_oxide_j2k_idwt/simt/src/main.rs` | cuda-oxide device J2K generic inverse-DWT kernels. | Before allocation or driver work, Rust validates coherent output/band rectangles, parity-derived band geometry, every input/output allocation, context ownership, alias rules, iteration bounds, and the maximum `u32` linear index. Device pointers then refer to live launch-bounded buffers retained through completion. | Pure full-job and adversarial geometry tests, queued ownership/alias tests, IDWT parity and metadata tests when cuda-oxide PTX is generated, plus strict GPU validation workflow. | @@ -117,7 +119,8 @@ moved, or removed. | `crates/j2k-cuda/tests/batch_decoder_api/support.rs` | Unsafe test helper that forms a lifetime-bound external view over an owned CUDA buffer and forwards it to `submit_batch_into`. | The caller supplies the exact live same-context pointer and length under an exclusive mutable borrow and must retain the allocation without host or unordered device access until the returned submission is waited or dropped; the view itself never takes allocation ownership. | Multi-tile external parity, mixed-decomposition lifetime tests, the 1,000-batch reuse soak, and drop-safe external lifecycle tests all exercise the helper contract. | | `crates/j2k-cuda/tests/batch_decoder_api/signed_rgb.rs` | Test-only external allocation handoff for exact signed RGB batch decoding. | The CUDA allocation remains live and exclusively borrowed while the destination view exists; submitted work is waited before host readback or allocation reuse; every request/layout case compares the completed destination against the CPU integer oracle. | Independent OpenJPH signed 8/12/16-bit RGB Full/ROI/reduced parity and resident/external destination regressions on NVIDIA hardware. | | `crates/j2k-cuda/src/surface.rs` | CUDA surface batch downloads into preallocated host output buffers. | Batch range math is checked and host Vec length is set only after successful device copy. | CUDA surface and batch download tests. | -| `crates/j2k-metal/benches/resident_packetization.rs` | Benchmark-only construction of a borrowed resident Metal encode tile. | The input belongs to the benchmark session, is fully initialized before construction, stays immutable, and remains live until every synchronous encode submission returns. | Resident packetization startup checksum/decode probe and benchmark build gates. | +| `crates/j2k-metal/benches/resident_packetization/batch_compare.rs` | Benchmark-only construction of a borrowed resident Metal encode tile for CPU/Metal batch comparisons. | The input belongs to the benchmark session, is fully initialized before construction, stays immutable, and remains live until every synchronous encode submission returns. | Resident batch checksum/decode probe, CPU/Metal output comparison, and benchmark build gates. | +| `crates/j2k-metal/benches/resident_packetization/packetization.rs` | Benchmark-only construction of a borrowed resident Metal encode tile for packetization measurements. | The input belongs to the benchmark session, is fully initialized before construction, stays immutable, and remains live until every synchronous encode submission returns. | Resident packetization startup checksum/decode probe and benchmark build gates. | | `crates/j2k-metal/src/batch_decoder.rs` | Codec-owned/external Metal batch destination adoption and immutable resident-batch raw access. | Codec-owned destinations wrap fresh same-device buffers exclusively and remain retained by pending work; external destinations have validated layout, range, alignment, and device identity. Completed buffers are adopted only after producer completion, and raw resident access is read-only while an owner remains live and no writer overlaps it. | Gray/RGB/RGBA NCHW/NHWC resident and external parity, destination bounds/device tests, continuation/drop-safe session tests, and strict Metal hardware validation. | | `crates/j2k-metal/src/batch_decoder/contracts.rs` | Unsafe read-only Metal buffer access from a completed `MetalResidentBatch`. | A resident batch is constructed only after successful producer completion and is logically immutable; callers bind the handle only for reads, retain the batch or a clone through consumer completion, and exclude every CPU or GPU writer while any resident owner exists. | `prepared_htj2k_rgba_nhwc_resident_group_is_exact_and_uses_one_allocation`, NCHW resident parity, prepared-session resident output tests, and the Metal raw-resource policy. | | `crates/j2k-metal/src/batch_decoder/encoder_count_tests.rs` | Test-only exclusive destination adoption and checked host readback for the one-command-buffer/one-encoder structural matrix. | Each allocation is fresh and has one logical codec writer; the pending group retains the destination through successful completion, and the checked read occurs only after that guard is released. The selected range covers the complete Gray/RGB/RGBA batch. | `external_groups_use_one_producer_command_buffer_and_compute_encoder` checks classic and HT groups of one and eight images for exact CPU parity and one producer encoder. | diff --git a/scripts/prepare-openhtj2k-reference.sh b/scripts/prepare-openhtj2k-reference.sh index 29eac276..3a7fb110 100755 --- a/scripts/prepare-openhtj2k-reference.sh +++ b/scripts/prepare-openhtj2k-reference.sh @@ -3,38 +3,22 @@ set -euo pipefail +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +reference_common="reference-build-common.sh" +source "${script_dir}/${reference_common}" + version="0.19.0" source_url="https://github.com/osamu620/OpenHTJ2K.git" source_commit="e0f7ae853220d1e359c438b0bb6ad6cb2b3899db" source_dir="${1:-target/t803/openhtj2k-v${version}}" build_dir="${source_dir}/build-reference" -if [[ -e "${source_dir}" && ! -d "${source_dir}/.git" ]]; then - echo "OpenHTJ2K target exists but is not a Git checkout: ${source_dir}" >&2 - exit 1 -fi -if [[ ! -d "${source_dir}/.git" ]]; then - mkdir -p "$(dirname "${source_dir}")" - git clone \ - --branch "v${version}" \ - --depth 1 \ - --filter=blob:none \ - --single-branch \ - "${source_url}" \ - "${source_dir}" -fi - -actual_commit="$(git -C "${source_dir}" rev-parse HEAD)" -actual_tag="$(git -C "${source_dir}" describe --tags --exact-match HEAD)" -actual_remote="$(git -C "${source_dir}" remote get-url origin)" -tracked_changes="$(git -C "${source_dir}" status --porcelain --untracked-files=no)" -if [[ "${actual_commit}" != "${source_commit}" \ - || "${actual_tag}" != "v${version}" \ - || "${actual_remote}" != "${source_url}" \ - || -n "${tracked_changes}" ]]; then - echo "OpenHTJ2K checkout does not match the clean pinned official source" >&2 - exit 1 -fi +reference_prepare_checkout \ + "OpenHTJ2K" \ + "${source_dir}" \ + "${source_url}" \ + "v${version}" \ + "${source_commit}" cmake \ -S "${source_dir}" \ @@ -48,35 +32,23 @@ cmake \ --target open_htj2k_dec \ --parallel 2 -decoder="" -for candidate in \ +decoder="$(reference_find_artifact \ + "OpenHTJ2K build did not produce open_htj2k_dec" \ "${build_dir}/bin/open_htj2k_dec" \ "${build_dir}/bin/open_htj2k_dec.exe" \ - "${build_dir}/bin/Release/open_htj2k_dec.exe"; do - if [[ -f "${candidate}" ]]; then - decoder="${candidate}" - break - fi -done -if [[ -z "${decoder}" ]]; then - echo "OpenHTJ2K build did not produce open_htj2k_dec" >&2 - exit 1 -fi - -source_dir="$(cd "${source_dir}" && pwd -P)" -decoder="$(cd "$(dirname "${decoder}")" && pwd -P)/$(basename "${decoder}")" -if command -v cygpath >/dev/null 2>&1 \ - && [[ "${RUNNER_OS:-}" == "Windows" || "${OSTYPE:-}" == msys* ]]; then - source_dir="$(cygpath -w "${source_dir}")" - decoder="$(cygpath -w "${decoder}")" -fi - -if [[ -n "${GITHUB_ENV:-}" ]]; then - { - echo "J2K_OPENHTJ2K_DEC_BIN=${decoder}" - echo "J2K_OPENHTJ2K_SOURCE_DIR=${source_dir}" - } >> "${GITHUB_ENV}" -else - echo "J2K_OPENHTJ2K_DEC_BIN=${decoder}" - echo "J2K_OPENHTJ2K_SOURCE_DIR=${source_dir}" -fi + "${build_dir}/bin/Release/open_htj2k_dec.exe")" + +library="$(reference_find_artifact \ + "OpenHTJ2K build did not produce the static reference library" \ + "${build_dir}/libopenhtj2k.a" \ + "${build_dir}/openhtj2k.lib" \ + "${build_dir}/Release/openhtj2k.lib")" + +source_dir="$(reference_canonical_dir "${source_dir}")" +decoder="$(reference_canonical_file "${decoder}")" +lib_dir="$(reference_canonical_dir "$(dirname "${library}")")" + +reference_emit_env \ + "J2K_OPENHTJ2K_DEC_BIN=${decoder}" \ + "J2K_OPENHTJ2K_SOURCE_DIR=${source_dir}" \ + "J2K_OPENHTJ2K_LIB_DIR=${lib_dir}" diff --git a/scripts/prepare-openjph-reference.sh b/scripts/prepare-openjph-reference.sh new file mode 100755 index 00000000..e872a9aa --- /dev/null +++ b/scripts/prepare-openjph-reference.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT OR Apache-2.0 + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +reference_common="reference-build-common.sh" +source "${script_dir}/${reference_common}" + +version="0.31.0" +source_url="https://github.com/aous72/OpenJPH.git" +source_commit="c68064d0e4cad8e96bab9a068f6cc4e7799744fc" +source_dir="${1:-target/reference/openjph-${version}}" +build_dir="${source_dir}/build-reference" + +reference_prepare_checkout \ + "OpenJPH" \ + "${source_dir}" \ + "${source_url}" \ + "${version}" \ + "${source_commit}" + +cmake \ + -S "${source_dir}" \ + -B "${build_dir}" \ + -DBUILD_SHARED_LIBS=OFF \ + -DCMAKE_BUILD_TYPE=Release \ + -DOJPH_BUILD_EXECUTABLES=ON \ + -DOJPH_ENABLE_TIFF_SUPPORT=OFF +cmake \ + --build "${build_dir}" \ + --config Release \ + --target ojph_expand ojph_compress \ + --parallel 2 + +expand="$(reference_find_artifact \ + "OpenJPH build did not produce ojph_expand" \ + "${build_dir}/src/apps/ojph_expand/ojph_expand" \ + "${build_dir}/src/apps/ojph_expand/ojph_expand.exe" \ + "${build_dir}/src/apps/ojph_expand/Release/ojph_expand.exe")" + +compress="$(reference_find_artifact \ + "OpenJPH build did not produce ojph_compress" \ + "${build_dir}/src/apps/ojph_compress/ojph_compress" \ + "${build_dir}/src/apps/ojph_compress/ojph_compress.exe" \ + "${build_dir}/src/apps/ojph_compress/Release/ojph_compress.exe")" + +library="$(reference_find_artifact \ + "OpenJPH build did not produce the static reference library" \ + "${build_dir}/src/core/libopenjph.a" \ + "${build_dir}/src/core/openjph.lib" \ + "${build_dir}/src/core/Release/openjph.lib")" + +source_dir="$(reference_canonical_dir "${source_dir}")" +expand="$(reference_canonical_file "${expand}")" +compress="$(reference_canonical_file "${compress}")" +lib_dir="$(reference_canonical_dir "$(dirname "${library}")")" + +reference_emit_env \ + "J2K_OPENJPH_EXPAND_BIN=${expand}" \ + "J2K_OPENJPH_COMPRESS_BIN=${compress}" \ + "J2K_OPENJPH_SOURCE_DIR=${source_dir}" \ + "J2K_OPENJPH_LIB_DIR=${lib_dir}" diff --git a/scripts/reference-build-common.sh b/scripts/reference-build-common.sh new file mode 100644 index 00000000..5d7eb95b --- /dev/null +++ b/scripts/reference-build-common.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MIT OR Apache-2.0 + +# Narrow shared mechanics for pinned external reference builds. Callers retain +# ownership of source pins, CMake flags, artifact candidates, and environment names. + +reference_prepare_checkout() { + local label="$1" + local source_dir="$2" + local source_url="$3" + local source_tag="$4" + local source_commit="$5" + + if [[ -e "${source_dir}" && ! -d "${source_dir}/.git" ]]; then + echo "${label} target exists but is not a Git checkout: ${source_dir}" >&2 + return 1 + fi + if [[ ! -d "${source_dir}/.git" ]]; then + mkdir -p "$(dirname "${source_dir}")" + git clone \ + --branch "${source_tag}" \ + --depth 1 \ + --filter=blob:none \ + --single-branch \ + "${source_url}" \ + "${source_dir}" + fi + + local actual_commit + local actual_tag + local actual_remote + local tracked_changes + actual_commit="$(git -C "${source_dir}" rev-parse HEAD)" + actual_tag="$(git -C "${source_dir}" describe --tags --exact-match HEAD)" + actual_remote="$(git -C "${source_dir}" remote get-url origin)" + tracked_changes="$(git -C "${source_dir}" status --porcelain --untracked-files=no)" + if [[ "${actual_commit}" != "${source_commit}" \ + || "${actual_tag}" != "${source_tag}" \ + || "${actual_remote}" != "${source_url}" \ + || -n "${tracked_changes}" ]]; then + echo "${label} checkout does not match the clean pinned official source" >&2 + return 1 + fi +} + +reference_find_artifact() { + local missing_message="$1" + shift + local candidate + for candidate in "$@"; do + if [[ -f "${candidate}" ]]; then + printf '%s\n' "${candidate}" + return 0 + fi + done + echo "${missing_message}" >&2 + return 1 +} + +reference_canonical_dir() { + local path + path="$(cd "$1" && pwd -P)" + reference_platform_path "${path}" +} + +reference_canonical_file() { + local path + path="$(cd "$(dirname "$1")" && pwd -P)/$(basename "$1")" + reference_platform_path "${path}" +} + +reference_platform_path() { + local path="$1" + if command -v cygpath >/dev/null 2>&1 \ + && [[ "${RUNNER_OS:-}" == "Windows" || "${OSTYPE:-}" == msys* ]]; then + cygpath -w "${path}" + else + printf '%s\n' "${path}" + fi +} + +reference_emit_env() { + if [[ -n "${GITHUB_ENV:-}" ]]; then + printf '%s\n' "$@" >> "${GITHUB_ENV}" + else + printf '%s\n' "$@" + fi +} diff --git a/scripts/tests/test_openhtj2k_reference.py b/scripts/tests/test_openhtj2k_reference.py index 070820e8..2a6197c7 100644 --- a/scripts/tests/test_openhtj2k_reference.py +++ b/scripts/tests/test_openhtj2k_reference.py @@ -19,6 +19,7 @@ def test_prepare_script_pins_the_official_source_and_parses_as_bash(self): self.assertIn('version="0.19.0"', source) self.assertIn("J2K_OPENHTJ2K_DEC_BIN", source) self.assertIn("J2K_OPENHTJ2K_SOURCE_DIR", source) + self.assertIn("J2K_OPENHTJ2K_LIB_DIR", source) subprocess.run(["bash", "-n", str(SCRIPT)], check=True) def test_cpu_evidence_lanes_prepare_the_reference_before_running_t803(self): diff --git a/scripts/tests/test_openjph_reference.py b/scripts/tests/test_openjph_reference.py new file mode 100644 index 00000000..e6d5ed89 --- /dev/null +++ b/scripts/tests/test_openjph_reference.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: MIT OR Apache-2.0 + +import subprocess +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT = ROOT / "scripts" / "prepare-openjph-reference.sh" +COMMON = ROOT / "scripts" / "reference-build-common.sh" + + +class OpenJphReferenceTests(unittest.TestCase): + def test_shared_reference_build_functions_load_in_strict_bash(self): + subprocess.run( + [ + "bash", + "-euo", + "pipefail", + "-c", + 'source "$1"; declare -F reference_prepare_checkout ' + "reference_find_artifact reference_canonical_file reference_emit_env >/dev/null", + "bash", + str(COMMON), + ], + check=True, + ) + + def test_prepare_script_pins_library_and_cli_from_official_source(self): + source = SCRIPT.read_text(encoding="utf-8") + + self.assertIn("https://github.com/aous72/OpenJPH.git", source) + self.assertIn("c68064d0e4cad8e96bab9a068f6cc4e7799744fc", source) + self.assertIn('version="0.31.0"', source) + self.assertIn("J2K_OPENJPH_EXPAND_BIN", source) + self.assertIn("J2K_OPENJPH_COMPRESS_BIN", source) + self.assertIn("--target ojph_expand ojph_compress", source) + self.assertIn("J2K_OPENJPH_SOURCE_DIR", source) + self.assertIn("J2K_OPENJPH_LIB_DIR", source) + subprocess.run(["bash", "-n", str(SCRIPT)], check=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/xtask/tests/repo_lint_support/phase_order_policy.rs b/xtask/tests/repo_lint_support/phase_order_policy.rs index 30d8789c..08646ab3 100644 --- a/xtask/tests/repo_lint_support/phase_order_policy.rs +++ b/xtask/tests/repo_lint_support/phase_order_policy.rs @@ -55,9 +55,18 @@ fn native_decode_preflights_and_propagates_before_roi_or_allocation() { fn native_context_and_tile_owner_handoffs_remain_transactional() { let decode = read("crates/j2k-native/src/j2c/decode.rs"); FunctionCalls::parse("native decode", &decode, "decode").assert_ordered( - "retained component accounting before parse and reset", - &["prepare_reused_decode_baseline", "tile::parse", "reset"], + "retained component accounting before parse and parsed-owner handoff", + &[ + "prepare_reused_decode_baseline", + "tile::parse", + "decode_parsed_tiles", + ], ); + FunctionCalls::parse("native parsed tile decode", &decode, "decode_parsed_tiles") + .assert_ordered( + "parsed tile owners reset the context before tile execution", + &["reset", "decode_tile"], + ); let tile = read("crates/j2k-native/src/j2c/tile.rs"); FunctionCalls::parse("native tile parser", &tile, "parse").assert_ordered( diff --git a/xtask/tests/repo_lint_support/source_size_policy.rs b/xtask/tests/repo_lint_support/source_size_policy.rs index 72590b27..0c7bcac0 100644 --- a/xtask/tests/repo_lint_support/source_size_policy.rs +++ b/xtask/tests/repo_lint_support/source_size_policy.rs @@ -58,11 +58,6 @@ const REVIEWED_LARGE_RUST_MODULES: &[(&str, usize, &str)] = &[ 1_645, "cohesive architecture-specific JPEG backend", ), - ( - "crates/j2k-metal/src/engine/tier1_encode.rs", - 1_249, - "review trigger for the active Metal Tier-1 engine", - ), ]; const REVIEWED_LARGE_SHADER_MODULES: &[(&str, usize, &str)] = &[