rusty_h264_encoder/rc.rs
1//! Average-bitrate rate control with a look-ahead complexity model.
2//!
3//! A frame-level controller that varies the per-frame quantization parameter to
4//! converge the average bitrate on a target. It combines a **look-ahead
5//! complexity estimate** (a cheap pre-encode score of *this* frame — see
6//! [`crate::lookahead`]) with a **leaky-bucket buffer** (correct accumulated
7//! over/undershoot). Using the upcoming frame's own complexity, rather than a
8//! lagging average of past frames, spends bits where they are needed and keeps
9//! quality steadier across complexity changes. The decoder needs no cooperation:
10//! each frame's QP rides in its `slice_qp_delta`, which conformant decoders honour.
11//!
12//! The model rests on the observation that coded bits are roughly inversely
13//! proportional to the quantizer step `Qstep`, and proportional to a frame's
14//! complexity. So `k = bits · Qstep / complexity` is a slowly-varying constant we
15//! learn (per frame type), then invert against the look-ahead complexity to pick
16//! a QP for the frame's bit budget.
17
18/// H.264 quantizer step for a QP: `Qstep` doubles every 6 QP (spec §8.6.1).
19fn qstep(qp: f64) -> f64 {
20 0.625 * 2f64.powf(qp / 6.0)
21}
22
23/// Quantizer-curve compression (x264's `qcomp`): 0 = constant bits per frame
24/// (varying quality), 1 = constant quality (bits ∝ complexity). The default
25/// blend spends ~`complexity^qcomp` bits, smoothing quality across complexity
26/// changes without fully sacrificing rate efficiency.
27const QCOMP: f64 = 0.6;
28
29/// Frame-level average-bitrate controller.
30#[derive(Debug, Clone)]
31pub struct RateControl {
32 /// Channel drain per coded frame, `bitrate / framerate` (bits).
33 target_per_frame: f64,
34 /// Leaky-bucket capacity (bits); ~one second of output.
35 buffer_size: f64,
36 /// Current buffer occupancy (bits); steered toward half-full.
37 fullness: f64,
38 /// Base/fallback QP (the configured `qp`), used before the model calibrates.
39 base_qp: f64,
40 qp_min: f64,
41 qp_max: f64,
42 /// Learned `bits · Qstep / complexity` per frame type (`0` = uninitialized).
43 /// I- and P-frame complexity scores live in different domains (spatial vs
44 /// motion-compensated SATD), so their constants are tracked separately.
45 k_p: f64,
46 k_i: f64,
47 /// Smoothed look-ahead complexity per frame type, the reference point for
48 /// the complexity-proportional bit allocation (`0` = uninitialized).
49 avg_c_p: f64,
50 avg_c_i: f64,
51 /// Last QP actually used, to limit frame-to-frame swing.
52 last_qp: f64,
53}
54
55impl RateControl {
56 /// Builds a controller for `bitrate` bits/sec at `framerate` fps, with `qp`
57 /// as the base/fallback quality. QP is clamped to a sane window around it.
58 pub fn new(bitrate: u32, framerate: f32, qp: u8) -> Self {
59 let bitrate = bitrate as f64;
60 let framerate = (framerate as f64).max(1.0);
61 let target_per_frame = bitrate / framerate;
62 let buffer_size = bitrate.max(target_per_frame * 2.0); // ≥ ~1s, ≥ 2 frames
63 RateControl {
64 target_per_frame,
65 buffer_size,
66 fullness: buffer_size * 0.5,
67 base_qp: qp as f64,
68 qp_min: (qp as f64 - 18.0).max(10.0),
69 qp_max: (qp as f64 + 18.0).min(51.0),
70 k_p: 0.0,
71 k_i: 0.0,
72 avg_c_p: 0.0,
73 avg_c_i: 0.0,
74 last_qp: qp as f64,
75 }
76 }
77
78 /// Picks the QP for a frame given its look-ahead `complexity` score (`is_idr`
79 /// selects the I model and a quality bump, since every later frame predicts
80 /// from the IDR).
81 pub fn pick_qp(&self, is_idr: bool, complexity: f64) -> u8 {
82 // Buffer-adjusted bit budget: spend less when the bucket is filling,
83 // draining any accumulated deviation over roughly a buffer's worth.
84 let deviation = self.fullness - self.buffer_size * 0.5;
85 let frames_to_correct = (self.buffer_size / self.target_per_frame).max(4.0);
86 let buf_target =
87 (self.target_per_frame - deviation / frames_to_correct).max(self.target_per_frame * 0.2);
88
89 // Complexity-proportional allocation: a frame `r×` the average complexity
90 // gets `r^qcomp ×` the budget (clamped so one frame can't drain the
91 // buffer). This is what holds quality steady across complexity changes.
92 let avg = if is_idr { self.avg_c_i } else { self.avg_c_p };
93 let budget = if avg > 0.0 {
94 buf_target * (complexity / avg).clamp(0.25, 4.0).powf(QCOMP)
95 } else {
96 buf_target
97 };
98
99 let k = if is_idr { self.k_i } else { self.k_p };
100 let qp = if k <= 0.0 {
101 // Not calibrated yet: lean on the base QP, nudged by buffer state.
102 self.base_qp + deviation / self.buffer_size * 8.0 - if is_idr { 2.0 } else { 0.0 }
103 } else {
104 // Predict this frame's bits·Qstep from its look-ahead complexity, then
105 // invert against the budget: Qstep = (k · complexity) / budget.
106 4.0 + 6.0 * (k * complexity / budget).log2()
107 };
108
109 // Limit per-frame swing for stable quality, then clamp to the window. A
110 // wider swing than the reactive model: the look-ahead means the change is
111 // driven by real complexity, not lag, so let it track more aggressively.
112 qp.clamp(self.last_qp - 6.0, self.last_qp + 6.0)
113 .clamp(self.qp_min, self.qp_max)
114 .round() as u8
115 }
116
117 /// Feeds back the bits a frame actually cost at its chosen QP and complexity,
118 /// recalibrating `k = bits · Qstep / complexity` and the average complexity.
119 pub fn update(&mut self, is_idr: bool, bits: usize, qp: u8, complexity: f64) {
120 let k_new = bits as f64 * qstep(qp as f64) / complexity.max(1.0);
121 let ema = |old: f64, new: f64| if old <= 0.0 { new } else { 0.5 * old + 0.5 * new };
122 if is_idr {
123 self.k_i = ema(self.k_i, k_new);
124 self.avg_c_i = ema(self.avg_c_i, complexity);
125 } else {
126 self.k_p = ema(self.k_p, k_new);
127 self.avg_c_p = ema(self.avg_c_p, complexity);
128 }
129
130 // Leaky bucket: add what we coded, drain the channel allotment.
131 self.fullness =
132 (self.fullness + bits as f64 - self.target_per_frame).clamp(0.0, self.buffer_size);
133 self.last_qp = qp as f64;
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn qstep_doubles_every_six_qp() {
143 assert!((qstep(28.0) / qstep(22.0) - 2.0).abs() < 1e-9);
144 }
145
146 #[test]
147 fn raises_qp_when_overshooting() {
148 let mut rc = RateControl::new(1_000_000, 30.0, 26);
149 let c = 1.0e6; // fixed look-ahead complexity
150 let first = rc.pick_qp(true, c);
151 // Feed back frames far larger than the per-frame budget repeatedly.
152 for _ in 0..30 {
153 let qp = rc.pick_qp(false, c);
154 rc.update(false, (rc.target_per_frame as usize) * 4, qp, c);
155 }
156 assert!(rc.pick_qp(false, c) > first, "QP should climb to curb overshoot");
157 }
158
159 #[test]
160 fn lowers_qp_when_undershooting() {
161 let mut rc = RateControl::new(1_000_000, 30.0, 40);
162 let c = 1.0e6;
163 let start = rc.pick_qp(false, c);
164 for _ in 0..30 {
165 let qp = rc.pick_qp(false, c);
166 rc.update(false, (rc.target_per_frame as usize) / 8, qp, c);
167 }
168 assert!(rc.pick_qp(false, c) < start, "QP should fall to use the budget");
169 }
170
171 #[test]
172 fn complex_frame_not_given_more_quality_than_simple() {
173 // Once calibrated, with qcomp < 1 a more complex frame is coded at no
174 // higher quality (no lower QP) than a simpler one at the same budget.
175 let mut rc = RateControl::new(2_000_000, 30.0, 26);
176 for _ in 0..20 {
177 let qp = rc.pick_qp(false, 1.0e6);
178 rc.update(false, rc.target_per_frame as usize, qp, 1.0e6);
179 }
180 let simple = rc.pick_qp(false, 0.5e6);
181 let complex = rc.pick_qp(false, 4.0e6);
182 assert!(complex >= simple);
183 }
184}