mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
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:
co-authored by
Claude Opus 5.5
parent
c9d5e7335e
commit
9e4942592c
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user