open-standards/wai/wai-rs/src/jpegai_decode.rs
Transaction Science 09fa9d5341 wai-rs: a library decode() entry point for the JPEG-AI decoder
The whole-decoder logic lived in the end-to-end test's helper. Extract it into
`jpegai_decode`, a public `decode(codestream, weights, norm) -> Rgb` that composes
the validated stages — entropy front half, integer value path, synthesis, and the
post-filter chain — behind one call. The module is pure compute: the caller passes
the model weights (DecoderWeights) and normative tables (NormTables); it reads no
files.

So the decoder is now a callable API rather than a pipeline that only exists inside
a test. The end-to-end test is reduced to loading the weights, calling decode(),
and comparing — and gives the identical result, max|Δ| = 1 and 4.446% of samples
differing, confirming the extraction preserves behaviour.

decode() rejects unsupported geometries up front (returns None): the shipped
profile is 256×256 4:4:4 tools_0, luma 160ch / chroma 96ch. The domain and wiring
facts the composition depends on — z at 4×4 vs the 16×16 plane, the [0,1] eICCI
domain, EFElinear's inverted filters_best2, the shared SOZ stream — are documented
at their call sites so the next reader doesn't re-derive them.

107 neural_int tests pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-26 05:48:42 -04:00

230 lines
11 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The whole JPEG-AI decoder as one call: `decode(codestream, weights, norm) → Rgb`.
//!
//! This composes the stage functions validated across `jpegai_*` — the entropy front
//! half (`jpegai_mans`, `jpegai_pipeline`), the integer value path and synthesis
//! (`jpegai_synthesis`), and the post-filter chain (`jpegai_pipeline`, `jpegai_icci`) —
//! into a single entry point. It is pure compute: the caller supplies the model weights
//! and normative tables (see [`DecoderWeights`] / [`NormTables`]); this module reads no
//! files and does no I/O.
//!
//! Determinism, not float-exactness. ψ, the four MCM stages and synthesis are 16-bit
//! PTQ, so the decoded pixels differ from the float reference by ~1 LSB on a few percent
//! of samples. Everything is deterministic — integer arithmetic, or IEEE-754 float with
//! a fixed accumulation order — so the output is byte-identical on every machine, which
//! is the contract. See `tests/jpegai_e2e_full.rs`, which drives this and compares the
//! reference decode of `test256.bin` (max|Δ| = 1, ~4.4% of samples).
//!
//! Scope: the 256×256, 4:4:4, tools_0 profile the reference ships — luma 160 channels
//! (4 MCM stages), chroma 96 (no MCM). Other geometries are rejected up front.
use crate::jpegai_bitstream::{find_segment, parse_container, parse_picture_header,
parse_tools_header, Marker};
use crate::jpegai_icci::IcciNet;
use crate::jpegai_mans::{build_decode_transitions, cdf_from_pmf, MansDecoder};
use crate::jpegai_pipeline::{dequantize_resi, efl_chroma, efn_filter, gain_unit_scaler_vec,
lef_filter, lsbs_refine, reconstruct_yhat, reconstruct_yhat_uv, rvs_additive,
rvs_likely, sigma_index, skip_mask};
use crate::jpegai_synthesis::{decode_hyper, decode_hyper_scale, decode_uv_f32, decode_y_f32,
q16_from_symbols, IntNet, Weights};
/// The `z` symbol offset (de-bias) applied to the factorized hyper-latent.
const Z_OFFSET: i64 = 31;
/// One plane's model weights: the fully-integer hyper-scale net, the PTQ hyper decoder,
/// and the PTQ synthesis net.
pub struct PlaneWeights {
pub hyper_scale: IntNet,
pub hyper_decoder: Weights,
pub synthesis: Weights,
}
/// Every model weight the decode needs. Luma carries the four MCM stages; chroma has no
/// MCM (its hyper params are used directly as the means). `icci` is the whole eICCI net.
pub struct DecoderWeights {
pub luma: PlaneWeights,
pub chroma: PlaneWeights,
pub mcm_y: [Weights; 4],
pub icci: IcciNet,
}
/// The normative, image-independent tables. All are shipped decoder assets, not derived
/// per image: the me-tANS PMF/bounds, the factorized-`z` CDFs, the per-channel gain
/// vectors (`gain_unit_mlog`), the LSBS refinement tables, and the LEF op-point tables
/// selected at the picture's `model_id`.
pub struct NormTables {
pub pmf: Vec<Vec<i64>>,
pub bounds: Vec<u8>,
pub per_channel: usize,
pub cdfs_y: Vec<u8>,
pub cdfs_uv: Vec<u8>,
pub gain_y: Vec<i64>,
pub gain_uv: Vec<i64>,
pub lsbs_t0: Vec<i32>,
pub lsbs_t1: Vec<i32>,
pub lef_thr: [i64; 3],
pub lef_mag: [f32; 3],
}
/// A decoded RGB image, row-major `[h][w][3]` (`data[(y*w + x)*3 + c]`).
pub struct Rgb {
pub w: usize,
pub h: usize,
pub data: Vec<u8>,
}
/// Header gains are offset-binary Q11 around 32767.
fn deint(q: u32) -> f32 { (q as f32 - 32767.0) / 2048.0 }
fn q16(f: &[f32]) -> Vec<i64> { f.iter().map(|&x| (x as f64 * 65536.0).round() as i64).collect() }
/// Decode one plane's front half and value path → its LSBS-refined synthesis latent
/// (f32), returning also `i_s` and the RVS additive for LEF's sigma surface.
///
/// `z_pre_channels` is the channel count consumed from the SOZ substream *before* this
/// plane — one SOZ stream carries luma then chroma back to back through a single mANS
/// decoder, so chroma must skip the luma `z` to reach its own.
#[allow(clippy::too_many_arguments)]
fn decode_plane(
data: &[u8], marker: Marker, n: &NormTables, pw: &PlaneWeights,
cdfs: &[u8], gain: &[i64], mcm: Option<[&Weights; 4]>,
channels: usize, z_pre_channels: usize,
) -> (Vec<f32>, Vec<i64>, Vec<i64>) {
const H: usize = 16; // the latent plane is 16×16; z is at 4×4 and the nets upsample ×4
const W: usize = 16;
let segs = parse_container(data);
// SOZ → z (skip the prior plane's z first)
let soz = find_segment(&segs, Marker::Soz).expect("SOZ");
let mem = &data[soz.start..soz.start + soz.len];
let mut dz = MansDecoder::new(mem, &[mem.len() as u32]);
if z_pre_channels > 0 {
let mut skip = vec![0u8; z_pre_channels * n.per_channel];
dz.decode_factorize(&n.cdfs_y, &mut skip, z_pre_channels, n.per_channel);
}
let mut syms = vec![0u8; channels * n.per_channel];
dz.decode_factorize(cdfs, &mut syms, channels, n.per_channel);
let z: Vec<i64> = syms.iter().map(|&s| s as i64 - Z_OFFSET).collect();
// z → i_s → derived entropy inputs
let (i_s, ..) = decode_hyper_scale(&z, channels, 4, 4, &pw.hyper_scale);
let likely = rvs_likely(&i_s, gain, channels, H, W);
let grfs = grfs_flags(data, channels, marker);
let rvs = rvs_additive(&i_s, gain, &grfs, channels, H, W);
let sigma = sigma_index(&i_s, gain, &rvs, channels);
let mask = skip_mask(&i_s, gain, channels);
// entropy decode → y_q
let seg = find_segment(&segs, marker).expect("SORP/SORS");
let mem = &data[seg.start..seg.start + seg.len];
let flat: Vec<u32> = build_decode_transitions(&n.pmf, &cdf_from_pmf(&n.pmf))
.iter().flatten().copied().collect();
let mut dec = MansDecoder::new(mem, &[mem.len() as u32]);
dec.set_sgm_transitions(&flat, &n.bounds);
let plane = H * W;
let mut y_q = vec![0i16; channels * plane];
for c in 0..channels {
let mut row = vec![0i16; plane];
dec.decode_sgm(&sigma[c * plane..(c + 1) * plane], &mut row, &mask[c * plane..(c + 1) * plane]);
y_q[c * plane..(c + 1) * plane].copy_from_slice(&row);
}
// dequant → resi ; ψ ; reconstruct → ŷ ; LSBS → latent
let sv = gain_unit_scaler_vec(gain, 0);
let resi = dequantize_resi(&y_q, &likely, &grfs, &sv, channels);
let (psi, ..) = decode_hyper(&q16_from_symbols(&z), channels, 4, 4, &pw.hyper_decoder);
let resi_q = q16(&resi);
let yhat = match mcm {
Some(m) => reconstruct_yhat(&resi_q, &psi, channels, H, W, m),
None => reconstruct_yhat_uv(&resi_q, &psi, channels, H, W),
};
let yhat_f: Vec<f32> = yhat.iter().map(|&v| v as f64 as f32 / 65536.0).collect();
let lat = lsbs_refine(&yhat_f, &resi, &likely, &n.lsbs_t0, &n.lsbs_t1, channels, H, W);
(lat, i_s, rvs)
}
fn grfs_flags(data: &[u8], channels: usize, marker: Marker) -> Vec<bool> {
let segs = parse_container(data);
let ph = parse_picture_header(data, find_segment(&segs, Marker::Pih).unwrap().start_bit());
let comp = if marker == Marker::Sorp { &ph.components[0] } else { &ph.components[1] };
let mut f = comp.grfs_channel_flags.clone();
f.resize(channels, false);
f
}
/// Decode a JPEG-AI codestream to RGB. Returns `None` if the picture is not the
/// supported 256×256 4:4:4 tools_0 geometry.
pub fn decode(data: &[u8], w: &DecoderWeights, n: &NormTables) -> Option<Rgb> {
let segs = parse_container(data);
let ph = parse_picture_header(data, find_segment(&segs, Marker::Pih)?.start_bit());
let (img_h, img_w) = (ph.img_height as usize, ph.img_width as usize);
if (img_h, img_w) != (256, 256) || ph.components.len() != 2
|| ph.components[0].num_chs != 160 || ph.components[1].num_chs != 96 {
return None;
}
let (h, w_) = (img_h, img_w);
// ---- luma + chroma latents → pixel planes (float, pre-round) ----
let mcm = [&w.mcm_y[0], &w.mcm_y[1], &w.mcm_y[2], &w.mcm_y[3]];
let (y_lat, y_is, y_rvs) = decode_plane(data, Marker::Sorp, n, &w.luma,
&n.cdfs_y, &n.gain_y, Some(mcm), 160, 0);
let (uv_lat, ..) = decode_plane(data, Marker::Sors, n, &w.chroma,
&n.cdfs_uv, &n.gain_uv, None, 96, 160);
let mut yp = decode_y_f32(&q16(&y_lat), &w.luma.synthesis);
let mut cat = q16(&y_lat);
cat.extend(q16(&uv_lat));
let uv = decode_uv_f32(&cat, &w.chroma.synthesis);
let (mut up, mut vp) = (uv[..h * w_].to_vec(), uv[h * w_..].to_vec());
// ---- post-filters, parameters from the tools header ----
let ton = find_segment(&segs, Marker::Ton)?;
let th = parse_tools_header(data, ton.start_bit() + 2, (ph.c_ver, ph.c_hor) == (1, 1), 3, 1);
// EFElinear — `filters_best2` is the applied group (the naming is inverted).
if let Some(efl) = th.efl.as_ref() {
let f = &efl.filters_best2;
let (mu, mv) = (efl.b1[0] as f32 / 100.0, efl.b1[1] as f32 / 100.0);
up = efl_chroma(&up, &yp, deint(f.weights_u[0]) + 1.0, deint(f.weights_u2[0]), mu);
vp = efl_chroma(&vp, &yp, deint(f.weights_v[0]) + 1.0, deint(f.weights_v2[0]), mv);
}
// eICCI — [0,1] domain: normalise in, scale back out.
if th.icci_enabled {
let nrm = |p: &[f32]| p.iter().map(|&x| x / 255.0).collect::<Vec<f32>>();
let (yo, uo, vo) = w.icci.forward(&nrm(&yp), &nrm(&up), &nrm(&vp), h, w_);
let den = |p: Vec<f32>| p.into_iter().map(|x| x * 255.0).collect::<Vec<f32>>();
yp = den(yo); up = den(uo); vp = den(vo);
}
// EFEnonlinear — per the header, applied to the enabled chroma plane(s).
if let Some(efn) = th.efn.as_ref() {
if efn.nonlinear_v_enabled {
let wq: Vec<i64> = efn.weights_v.iter().map(|&x| x as i64).collect();
vp = efn_filter(&yp, &vp, &wq, efn.luma_min[0] as i64, efn.luma_max[0] as i64);
}
if efn.nonlinear_u_enabled {
let wq: Vec<i64> = efn.weights_u.iter().map(|&x| x as i64).collect();
up = efn_filter(&yp, &up, &wq, efn.luma_min[0] as i64, efn.luma_max[0] as i64);
}
}
// LEF — luma; sigma is the raw scale surface (i_s + gain + rvs) at the named channel.
if th.lef_enabled {
let ch = th.lef_ch_idx as usize;
let sigma: Vec<f32> = (0..16 * 16)
.map(|p| (y_is[ch * 256 + p] + n.gain_y[ch] + y_rvs[ch * 256 + p]) as f32).collect();
let thr = [n.lef_thr[0] as f32, n.lef_thr[1] as f32, n.lef_thr[2] as f32];
yp = lef_filter(&yp, &sigma, 16, 16, thr, n.lef_mag, h, w_);
}
// ---- BT.709 (full-range) → RGB ----
let (kr, kg, kb) = (0.2126f32, 0.7152f32, 0.0722f32);
let (kby, kry) = (1.8556f32, 1.5748f32);
let mut out = vec![0u8; 3 * h * w_];
for i in 0..h * w_ {
let (y01, u01, v01) = (yp[i] / 255.0, up[i] / 255.0 - 0.5, vp[i] / 255.0 - 0.5);
let rgb = [y01 + kry * v01,
y01 - (kb * kby / kg) * u01 - (kr * kry / kg) * v01,
y01 + kby * u01];
for (c, &val) in rgb.iter().enumerate() {
out[i * 3 + c] = (val * 255.0).round().clamp(0.0, 255.0) as u8;
}
}
Some(Rgb { w: w_, h, data: out })
}