M13.4: hevcenc sign data hiding, noise / QP 0 tests, README

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 05:19:19 -04:00
co-authored by Claude Opus 5.5
parent c9d5e7335e
commit 9e4942592c
5 changed files with 184 additions and 6 deletions
+88
View File
@@ -0,0 +1,88 @@
# effectcraft-hevcenc
Clean-room, pure-Rust HEVC / H.265 encoder for EffectCraft's MP4 export (layer L0, `std` only,
no `unsafe`, builds for `wasm32-unknown-unknown`).
## What it produces
- **Profiles:** Main (8-bit) and Main 10 (10-bit), 4:2:0, Main tier. `general_level_idc` is taken
from the config or chosen as the lowest level whose MaxLumaPs, MaxLumaSr (and, in bitrate mode,
MaxBR) fit (Table A.8).
- **Parameter sets:** VPS, SPS (with VUI: BT.709 colour description, `video_full_range_flag`,
timing info from the frame rate) and PPS, as raw NAL units (`Encoder::parameter_sets`) and as an
`hvcC` record (ISO/IEC 14496-15 §8.3.3.1, `lengthSizeMinusOne` 3, complete VPS / SPS / PPS
arrays, `Encoder::hvcc`).
- **Pictures:** one slice per picture; `IDR_N_LP` every `keyint` pictures (`1` = all intra, `0` =
only the first), `TRAIL_R` P pictures otherwise, each referencing the previous picture (one
short-term RPS in the SPS, `num_ref_idx_l0_active` 1, no reordering, pts = dts). Packets are
length-prefixed (4-byte big-endian) NAL units without parameter sets.
- **Picture size:** any even width / height; the coded size is padded to a multiple of 8 by edge
replication and cropped with the conformance window. Odd sizes are rejected with
`Error::InvalidConfig` (4:2:0 conformance-window offsets are in units of two luma samples).
## Coding tools
- 64x64 CTUs, coding units 64x64 .. 8x8 chosen by rate-distortion optimisation: every candidate is
actually predicted, transformed, quantised and reconstructed, and its rate is measured with a
CABAC bit estimator that evolves the same context states as the real coder. Split decisions
compare the best unsplit candidate with the recursively coded children (early exit after a skip).
- **Intra:** all 35 modes (SATD search on a coarse grid plus refinement and the MPMs), 2Nx2N at
8..32 and NxN (four 4x4 PUs) at 8x8, chroma in DM mode, MPM / remaining-mode coding, reference
substitution, [1 2 1] and strong (32x32) smoothing, DC / angular boundary filters.
- **Inter (P):** skip and merge (spatial candidates + zero candidates, `MaxNumMergeCand` 5) and AMVP
with mvd coding; integer motion search (predictor starts + square pattern, steps 16..1) and
half / quarter-sample refinement on pre-interpolated half-sample planes; the coded prediction uses
the normative 8-tap / 4-tap interpolation filters and reference-picture edge clamping.
- **Transforms:** DST-VII 4x4 (intra luma) and DCT 4..32 (inverse by even/odd decomposition,
bit-identical to the matrix form), dead-zone quantisation (1/3 intra, 1/6 inter), flat scaling,
sign data hiding, inferred transform splits only (`max_transform_hierarchy_depth_*` 0, so 64x64
CUs use four 32x32 TUs).
- **Entropy coding:** CABAC (context initialisation by slice QP and init type, decision / bypass /
terminate bins, flush), `end_of_slice_segment_flag`, byte alignment, emulation prevention.
- **In-loop filters:** the deblocking filter is on (the encoder deblocks its own reconstruction, so
references match the decoder); SAO is disabled.
- **Rate control:** `ConstantQp(qp)` or `Bitrate { kbps }`, a per-picture QP from a bits-x-Qstep
model per picture type (I pictures get roughly 4x the bits of a P picture) with a buffer that
spreads over- and undershoot over the following pictures.
Not implemented: B pictures / reordering, multiple references, TMVP, AMP and other inter partition
shapes, residual quadtree splits, transform skip, scaling lists, PCM, SAO, RDOQ, cu_qp_delta /
adaptive quantisation, tiles / WPP / multiple slices, range extensions (4:2:2, 4:4:4, > 10 bits).
## Measurements
On a shared, heavily loaded machine, opt-level 3: about 0.45 s of CPU per 1920x1080 picture
(ffmpeg `testsrc2`, QP 22 / 32). On ffmpeg's `mandelbrot` 1080p source at QP 32 the stream is
about 10 % larger at about 0.3 dB lower PSNR-Y than x265 `--preset medium` at the same QP without
psy tuning (I 40.6 kB / 43.2 dB vs 42.3 kB / 43.7 dB). The synthetic test image reaches
45.8 dB PSNR-Y at QP 22.
## Tests
`cargo test -p effectcraft-hevcenc`:
- every stream is decoded with the first-party `filmcraft-hevc` decoder and compared bit-exactly
with the encoder's reconstruction (`Encoder::last_reconstruction`, hidden API);
- when `ffmpeg` is on `PATH`, the Annex-B elementary stream is decoded with
`ffmpeg -v error -xerror` (no stderr output allowed) and compared bit-exactly as well (skipped
otherwise). ffmpeg is used only as an external test oracle;
- covered: 8-bit and 10-bit, all-intra and IP, QP 0 noise (escape codes), QP 0..51, strong
deblocking at high QP, sizes 8x8 .. 200x136 with cropping, POC wrap (300 pictures without IDR),
bitrate mode within 2x of the target, `hvcC` layout;
- `bench_1080p` (ignored) reports speed, size and PSNR on 1080p content generated by ffmpeg.
## Specifications
- ITU-T H.265 (V8, 08/2021) | ISO/IEC 23008-2, *High efficiency video coding*: 7 (syntax and
semantics), 8.4 - 8.7 (intra / inter prediction, scaling and transforms, deblocking), 9.3 (CABAC,
including the informative encoder description of 9.3.4), Annex A (profiles, tiers and levels),
Annex E (VUI).
- ISO/IEC 14496-15 (2019), §8.3.3.1 HEVCDecoderConfigurationRecord.
Implemented from the specification text. Constant tables (CABAC context layout and initValues,
rangeTabLPS / transIdx, scan orders, the DCT / DST matrices, intra angles, interpolation filters,
the chroma QP table, the deblocking beta / tC tables) and the decoder-side reconstruction
arithmetic (intra reference substitution / filtering / prediction, fractional interpolation,
deblocking) are mirrored from FilmCraft's first-party, spec-derived `filmcraft-hevc` decoder (same
organisation, MIT OR Apache-2.0), so that encoder and decoder reconstruct identically. No
third-party encoder or decoder source (x265, libde265, ffmpeg, HM, ...) was consulted.
+16 -5
View File
@@ -563,7 +563,14 @@ impl<'a> FrameCoder<'a> {
let off = self.coefs.len();
self.coefs.resize(off + n * n, 0);
let lv = &mut self.coefs[off..off + n * n];
let scan = match intra_mode {
Some(m) if log2 == 2 || (log2 == 3 && c == 0) => scan_idx_for_mode(m),
_ => 0,
};
let nz = transform::quantize(&coef[..n * n], lv, n, qp, intra, self.bd) > 0;
if nz {
transform::sign_hiding(&coef[..n * n], lv, log2, scan as usize, qp, self.bd);
}
if nz {
let d = &mut r[..n * n];
transform::dequantize(lv, d, n, qp, self.bd);
@@ -581,10 +588,6 @@ impl<'a> FrameCoder<'a> {
if nz && c == 0 {
self.fill(x, y, n, |b| b.flags |= F_CBF);
}
let scan = match intra_mode {
Some(m) if log2 == 2 || (log2 == 3 && c == 0) => scan_idx_for_mode(m),
_ => 0,
};
self.tbs.push(Tb { c: c as u8, lx: lpos.0 as u32, ly: lpos.1 as u32, log2, nz, off, scan });
}
@@ -1313,9 +1316,17 @@ fn residual_coding<S: Sink>(s: &mut S, lv: &[i32], log2: u32, c: usize, scan_idx
gt2 = (abs_at(k) > 2) as u32;
s.decision(GT2 + ctx_set + if c > 0 { 4 } else { 0 }, gt2);
}
for k in 0..nsig {
// sign data hiding: the sign of the first coefficient in scan order (sig[nsig - 1]) is
// inferred from the parity of the sum of absolute levels
let hidden = sig[0] as i32 - sig[nsig - 1] as i32 > 3;
let nsigns = if hidden { nsig - 1 } else { nsig };
for k in 0..nsigns {
s.bypass((at(xs, ys, sig[k] as usize) < 0) as u32);
}
if hidden {
let sum: u32 = (0..nsig).map(abs_at).sum();
debug_assert_eq!(at(xs, ys, sig[nsig - 1] as usize) < 0, sum & 1 == 1, "sign hiding parity");
}
let mut rice = 0u32;
for k in 0..nsig {
let mut base = 1 + gt1[k] as u32;
+1 -1
View File
@@ -189,7 +189,7 @@ pub fn pps() -> Vec<u8> {
w.put(0, 1); // dependent_slice_segments_enabled_flag
w.put(0, 1); // output_flag_present_flag
w.put(0, 3); // num_extra_slice_header_bits
w.put(0, 1); // sign_data_hiding_enabled_flag
w.put(1, 1); // sign_data_hiding_enabled_flag
w.put(0, 1); // cabac_init_present_flag
w.ue(0); // num_ref_idx_l0_default_active_minus1
w.ue(0); // num_ref_idx_l1_default_active_minus1
+50
View File
@@ -193,6 +193,56 @@ pub fn quantize(c: &[i32], l: &mut [i32], n: usize, qp: i32, intra: bool, bit_de
nz
}
/// Sign data hiding (7.3.8.11 signHidden): in every 4x4 sub-block whose first and last non-zero
/// coefficients (in scan order) are more than 3 positions apart, the sign of the first one is not
/// coded but inferred from the parity of the sum of absolute levels. Fix the parity where needed by
/// the +-1 level change that adds the least quantisation error, without moving the first / last
/// non-zero positions.
pub fn sign_hiding(c: &[i32], l: &mut [i32], log2: u32, scan_idx: usize, qp: i32, bit_depth: u32) {
use crate::tables::{SCAN_4, scan_order};
let n = 1usize << log2;
let tshift = 15 - bit_depth as i32 - log2 as i32;
let qbits = 14 + qp / 6 + tshift;
let scale = QUANT_SCALE[(qp % 6) as usize];
let sb_scan = scan_order(log2 - 2, scan_idx);
let pos = &SCAN_4[scan_idx];
for &(xs, ys) in sb_scan {
let idx = |p: usize| (ys as usize * 4 + pos[p].1 as usize) * n + xs as usize * 4 + pos[p].0 as usize;
let (Some(first), Some(last)) = ((0..16).find(|&p| l[idx(p)] != 0), (0..16).rev().find(|&p| l[idx(p)] != 0)) else { continue };
if last - first <= 3 {
continue;
}
let sum: i32 = (0..16).map(|p| l[idx(p)].abs()).sum();
let negative = l[idx(first)] < 0;
if negative == (sum & 1 == 1) {
continue;
}
// cost of +-1 changes in 1/256 units of quantisation error
let mut best: Option<(i64, usize, i32)> = None;
for p in first..=last {
let i = idx(p);
let q = (c[i] as i64).abs() * scale >> (qbits - 8);
let a = l[i].abs() as i64;
let err = |lv: i64| (q - (lv << 8)).abs();
let mut consider = |cost: i64, d: i32| {
if best.is_none_or(|b| cost < b.0) {
best = Some((cost, i, d));
}
};
if a < 32767 && (a > 0 || (p > first && p < last)) {
consider(err(a + 1) - err(a), 1);
}
if a > 1 || (a == 1 && p > first && p < last) {
consider(err(a - 1) - err(a), -1);
}
}
if let Some((_, i, d)) = best {
let a = l[i].abs() + d;
l[i] = if c[i] < 0 { -a } else { a };
}
}
}
/// Scaling process (8.6.2 / 8.6.3, flat scaling matrix): levels to scaled coefficients.
pub fn dequantize(l: &[i32], d: &mut [i32], n: usize, qp: i32, bit_depth: u32) {
let log2 = n.trailing_zeros();
+29
View File
@@ -216,6 +216,35 @@ fn odd_multiple_sizes_and_cropping() {
}
}
/// White noise at QP 0: large levels, escape codes with the maximum Rice parameter.
#[test]
fn noise_low_qp() {
let (w, h) = (48, 40);
let mut seed = 0x1234_5678u32;
let mut rnd = move || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
for (profile, bd) in [(Profile::Main, 8u32), (Profile::Main10, 10)] {
let max = (1u32 << bd) - 1;
let pics: Vec<Pic> = (0..3)
.map(|_| {
let mut p = synth(w, h, bd, 0, 0);
for v in p.y.iter_mut().chain(p.u.iter_mut()).chain(p.v.iter_mut()) {
*v = (rnd() % (max + 1)) as u16;
}
p
})
.collect();
let run = encode(cfg(w as u32, h as u32, 0, 2, profile), &pics);
assert!(run.psnr_y.iter().all(|&p| p > 45.0), "{:?}", run.psnr_y);
check_filmcraft(&run, w, h);
check_ffmpeg(&run, w, h, bd, &format!("noise{bd}"));
}
}
/// High QPs: strong deblocking, large skipped CUs.
#[test]
fn high_qp_deblocking() {