oximedia_codec/scene_change_idr.rs
1//! Scene-change detection and adaptive I-frame (IDR) insertion.
2//!
3//! This module analyses consecutive frames and inserts an IDR/key-frame
4//! whenever a scene change is detected. The detector computes a histogram-
5//! based difference metric; if the difference exceeds a configurable threshold
6//! an IDR frame is forced into the bitstream.
7//!
8//! # Design
9//!
10//! - **Histogram diff** — build 256-bin luma histograms for consecutive frames
11//! and compute the L1 distance normalised to [0, 1].
12//! - **Threshold** — if the normalised difference exceeds `threshold` (default
13//! 0.45) the frame is flagged as a scene change.
14//! - **Minimum IDR interval** — a configurable minimum number of frames between
15//! forced IDRs prevents excessive refresh after a hard cut.
16//! - **Force-IDR callback** — the `SceneChangeIdrController` emits
17//! `FrameDecision::ForceIdr` when a scene change is detected so the encoder
18//! can act immediately.
19
20#![allow(dead_code)]
21#![allow(clippy::cast_precision_loss)]
22
23// ---------------------------------------------------------------------------
24// Configuration
25// ---------------------------------------------------------------------------
26
27/// Configuration for the scene-change IDR controller.
28#[derive(Debug, Clone)]
29pub struct SceneChangeIdrConfig {
30 /// Normalised histogram-diff threshold in [0.0, 1.0].
31 ///
32 /// Values above this trigger an IDR insertion. Default: `0.45`.
33 pub threshold: f32,
34 /// Minimum number of inter frames between two forced IDRs.
35 ///
36 /// Prevents rapid IDR insertion after a hard cut sequence. Default: `12`.
37 pub min_idr_interval: u32,
38 /// Maximum GOP length between regular (non-scene-change) IDRs.
39 ///
40 /// An IDR is also inserted when this limit is reached. Default: `250`.
41 pub max_gop_length: u32,
42 /// If `true` the controller also inserts IDRs at the very first frame.
43 /// Default: `true`.
44 pub force_first_idr: bool,
45}
46
47impl Default for SceneChangeIdrConfig {
48 fn default() -> Self {
49 Self {
50 threshold: 0.45,
51 min_idr_interval: 12,
52 max_gop_length: 250,
53 force_first_idr: true,
54 }
55 }
56}
57
58// ---------------------------------------------------------------------------
59// Frame decision
60// ---------------------------------------------------------------------------
61
62/// Decision returned for each frame by the IDR controller.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum FrameDecision {
65 /// Encode frame as a regular inter (P or B) frame.
66 Inter,
67 /// Force this frame to be an IDR / key-frame.
68 ForceIdr,
69}
70
71impl FrameDecision {
72 /// Returns `true` if the decision requires an IDR frame.
73 #[must_use]
74 pub fn is_idr(self) -> bool {
75 matches!(self, Self::ForceIdr)
76 }
77}
78
79// ---------------------------------------------------------------------------
80// Luma histogram helper
81// ---------------------------------------------------------------------------
82
83/// 256-bin normalised luma histogram.
84#[derive(Debug, Clone)]
85struct LumaHistogram {
86 bins: [f32; 256],
87}
88
89impl LumaHistogram {
90 /// Build a histogram from a raw luma slice.
91 fn from_luma(luma: &[u8]) -> Self {
92 let mut counts = [0u64; 256];
93 for &p in luma {
94 counts[p as usize] += 1;
95 }
96 let n = luma.len().max(1) as f32;
97 let mut bins = [0.0f32; 256];
98 for (b, &c) in bins.iter_mut().zip(counts.iter()) {
99 *b = c as f32 / n;
100 }
101 Self { bins }
102 }
103
104 /// L1 distance between two histograms, normalised to [0, 1].
105 fn l1_distance(&self, other: &LumaHistogram) -> f32 {
106 self.bins
107 .iter()
108 .zip(other.bins.iter())
109 .map(|(&a, &b)| (a - b).abs())
110 .sum::<f32>()
111 / 2.0 // divide by 2: max L1 for normalised histograms is 2
112 }
113}
114
115// ---------------------------------------------------------------------------
116// Controller
117// ---------------------------------------------------------------------------
118
119/// Scene-change-aware IDR frame insertion controller.
120///
121/// Call [`SceneChangeIdrController::push_frame`] with the luma plane of each
122/// input frame to receive a [`FrameDecision`].
123#[derive(Debug)]
124pub struct SceneChangeIdrController {
125 cfg: SceneChangeIdrConfig,
126 /// Previous frame's histogram.
127 prev_hist: Option<LumaHistogram>,
128 /// Number of frames since the last IDR.
129 frames_since_idr: u32,
130 /// Total frames processed.
131 frame_count: u64,
132 /// Number of scene-change IDRs inserted.
133 scene_change_count: u32,
134 /// Number of regular (GOP-boundary) IDRs inserted.
135 regular_idr_count: u32,
136 /// Log of frame indices at which IDRs were inserted.
137 idr_positions: Vec<u64>,
138}
139
140impl SceneChangeIdrController {
141 /// Create a new controller with the given configuration.
142 #[must_use]
143 pub fn new(cfg: SceneChangeIdrConfig) -> Self {
144 Self {
145 cfg,
146 prev_hist: None,
147 frames_since_idr: 0,
148 frame_count: 0,
149 scene_change_count: 0,
150 regular_idr_count: 0,
151 idr_positions: Vec::new(),
152 }
153 }
154
155 /// Create a controller with default configuration.
156 #[must_use]
157 pub fn default_controller() -> Self {
158 Self::new(SceneChangeIdrConfig::default())
159 }
160
161 /// Analyse a frame and return the encoding decision.
162 ///
163 /// `luma` is the raw luma plane (8-bit, row-major). Width and height are
164 /// only needed for informational purposes; the analysis works on the flat
165 /// slice.
166 pub fn push_frame(&mut self, luma: &[u8]) -> FrameDecision {
167 let current_hist = LumaHistogram::from_luma(luma);
168 let current_index = self.frame_count;
169 self.frame_count += 1;
170
171 // Always IDR the first frame if configured
172 if self.cfg.force_first_idr && current_index == 0 {
173 self.prev_hist = Some(current_hist);
174 self.frames_since_idr = 0;
175 self.regular_idr_count += 1;
176 self.idr_positions.push(current_index);
177 return FrameDecision::ForceIdr;
178 }
179
180 // Regular GOP-boundary IDR
181 if self.frames_since_idr >= self.cfg.max_gop_length {
182 self.prev_hist = Some(current_hist);
183 self.frames_since_idr = 0;
184 self.regular_idr_count += 1;
185 self.idr_positions.push(current_index);
186 return FrameDecision::ForceIdr;
187 }
188
189 // Scene-change detection
190 let decision = if let Some(ref prev) = self.prev_hist {
191 let diff = prev.l1_distance(¤t_hist);
192 if diff >= self.cfg.threshold && self.frames_since_idr >= self.cfg.min_idr_interval {
193 self.scene_change_count += 1;
194 self.idr_positions.push(current_index);
195 FrameDecision::ForceIdr
196 } else {
197 FrameDecision::Inter
198 }
199 } else {
200 // No previous histogram: first frame without force_first_idr
201 self.idr_positions.push(current_index);
202 FrameDecision::ForceIdr
203 };
204
205 self.prev_hist = Some(current_hist);
206
207 if decision == FrameDecision::ForceIdr {
208 self.frames_since_idr = 0;
209 } else {
210 self.frames_since_idr += 1;
211 }
212
213 decision
214 }
215
216 /// Total number of frames processed.
217 #[must_use]
218 pub fn frame_count(&self) -> u64 {
219 self.frame_count
220 }
221
222 /// Number of scene-change-triggered IDR insertions.
223 #[must_use]
224 pub fn scene_change_count(&self) -> u32 {
225 self.scene_change_count
226 }
227
228 /// Number of regular (GOP-boundary) IDR insertions.
229 #[must_use]
230 pub fn regular_idr_count(&self) -> u32 {
231 self.regular_idr_count
232 }
233
234 /// All frame indices at which IDR frames were inserted.
235 #[must_use]
236 pub fn idr_positions(&self) -> &[u64] {
237 &self.idr_positions
238 }
239
240 /// Reset the controller state (useful when starting a new encode session).
241 pub fn reset(&mut self) {
242 self.prev_hist = None;
243 self.frames_since_idr = 0;
244 self.frame_count = 0;
245 self.scene_change_count = 0;
246 self.regular_idr_count = 0;
247 self.idr_positions.clear();
248 }
249
250 /// Access the current configuration.
251 #[must_use]
252 pub fn config(&self) -> &SceneChangeIdrConfig {
253 &self.cfg
254 }
255}
256
257// ---------------------------------------------------------------------------
258// SceneChangeDetector — simple stateless scene-change test
259// ---------------------------------------------------------------------------
260
261/// A stateless scene-change detector that classifies a single histogram
262/// difference value against a configurable threshold.
263///
264/// This is a lightweight helper for callers that compute histogram differences
265/// externally (e.g. from a motion-estimation pass) and just need to decide
266/// whether to force an I-frame.
267///
268/// For a stateful, full-featured detector with GOP-boundary management see
269/// [`SceneChangeIdrController`].
270#[derive(Debug, Clone, Default)]
271pub struct SceneChangeDetector;
272
273impl SceneChangeDetector {
274 /// Create a new `SceneChangeDetector`.
275 #[must_use]
276 pub fn new() -> Self {
277 Self
278 }
279
280 /// Classify a pre-computed histogram difference as a scene change.
281 ///
282 /// # Parameters
283 /// - `hist_diff` – normalised histogram distance in [0.0, 1.0].
284 /// A value of 0.0 means identical frames; 1.0 means maximally different.
285 /// - `threshold` – decision boundary; values **strictly above** the
286 /// threshold are considered scene changes. Typical value: `0.45`.
287 ///
288 /// # Returns
289 /// `true` if `hist_diff > threshold` (scene change detected).
290 #[must_use]
291 pub fn is_scene_change(hist_diff: f32, threshold: f32) -> bool {
292 hist_diff > threshold
293 }
294}
295
296// ---------------------------------------------------------------------------
297// Tests
298// ---------------------------------------------------------------------------
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 fn uniform_luma(value: u8, size: usize) -> Vec<u8> {
305 vec![value; size]
306 }
307
308 fn ramp_luma(size: usize) -> Vec<u8> {
309 (0..size).map(|i| (i % 256) as u8).collect()
310 }
311
312 #[test]
313 fn test_first_frame_is_idr() {
314 let mut ctrl = SceneChangeIdrController::default_controller();
315 let luma = uniform_luma(128, 1920 * 1080);
316 let dec = ctrl.push_frame(&luma);
317 assert_eq!(dec, FrameDecision::ForceIdr);
318 }
319
320 #[test]
321 fn test_identical_frames_are_inter() {
322 let mut ctrl = SceneChangeIdrController::default_controller();
323 let luma = uniform_luma(100, 1024);
324 // First frame → IDR
325 ctrl.push_frame(&luma);
326 // Push min_idr_interval + 1 more identical frames
327 let min = ctrl.cfg.min_idr_interval as usize + 1;
328 for _ in 0..min {
329 let dec = ctrl.push_frame(&luma);
330 assert_eq!(dec, FrameDecision::Inter);
331 }
332 }
333
334 #[test]
335 fn test_hard_cut_triggers_idr() {
336 let cfg = SceneChangeIdrConfig {
337 threshold: 0.30,
338 min_idr_interval: 2,
339 max_gop_length: 500,
340 force_first_idr: true,
341 };
342 let mut ctrl = SceneChangeIdrController::new(cfg);
343
344 // Feed several identical frames
345 let dark = uniform_luma(10, 1024);
346 ctrl.push_frame(&dark); // IDR
347 ctrl.push_frame(&dark); // inter
348 ctrl.push_frame(&dark); // inter (now frames_since_idr >= min_idr_interval)
349
350 // Now push a completely different (bright) frame
351 let bright = uniform_luma(250, 1024);
352 let dec = ctrl.push_frame(&bright);
353 assert_eq!(dec, FrameDecision::ForceIdr, "hard cut should force IDR");
354 }
355
356 #[test]
357 fn test_gop_boundary_triggers_idr() {
358 let cfg = SceneChangeIdrConfig {
359 threshold: 0.99, // effectively no scene-change detection
360 min_idr_interval: 0,
361 max_gop_length: 5,
362 force_first_idr: false,
363 };
364 let mut ctrl = SceneChangeIdrController::new(cfg);
365 let luma = ramp_luma(1024);
366
367 let decisions: Vec<FrameDecision> = (0..10).map(|_| ctrl.push_frame(&luma)).collect();
368
369 // With max_gop_length = 5, IDR should appear at frame 0 (no hist) and
370 // after 5 inter frames (frames_since_idr reaches 5) → frame 6.
371 // Frame 0: IDR (no prev), frames_since_idr=0
372 // Frames 1-5: Inter (frames_since_idr increments from 0 to 5)
373 // Frame 6: frames_since_idr=5 >= max_gop_length=5 → IDR
374 let idr_indices: Vec<usize> = decisions
375 .iter()
376 .enumerate()
377 .filter(|(_, &d)| d == FrameDecision::ForceIdr)
378 .map(|(i, _)| i)
379 .collect();
380
381 assert!(
382 idr_indices.contains(&0),
383 "frame 0 should be IDR (no prev hist)"
384 );
385 assert!(
386 idr_indices.contains(&6),
387 "frame 6 should be IDR (GOP boundary after 5 inter frames)"
388 );
389 }
390
391 #[test]
392 fn test_reset_clears_state() {
393 let mut ctrl = SceneChangeIdrController::default_controller();
394 let luma = uniform_luma(100, 64);
395 ctrl.push_frame(&luma);
396 ctrl.push_frame(&luma);
397 ctrl.reset();
398 assert_eq!(ctrl.frame_count(), 0);
399 assert!(ctrl.idr_positions().is_empty());
400 }
401
402 #[test]
403 fn test_idr_positions_logged() {
404 let mut ctrl = SceneChangeIdrController::default_controller();
405 let luma = uniform_luma(100, 64);
406 ctrl.push_frame(&luma); // first IDR → pos 0
407 assert_eq!(ctrl.idr_positions(), &[0]);
408 }
409
410 #[test]
411 fn test_frame_decision_is_idr_helper() {
412 assert!(FrameDecision::ForceIdr.is_idr());
413 assert!(!FrameDecision::Inter.is_idr());
414 }
415
416 #[test]
417 fn scene_change_detector_above_threshold() {
418 assert!(SceneChangeDetector::is_scene_change(0.6, 0.45));
419 }
420
421 #[test]
422 fn scene_change_detector_at_threshold_is_not_change() {
423 // Strictly greater than threshold → at threshold is NOT a scene change
424 assert!(!SceneChangeDetector::is_scene_change(0.45, 0.45));
425 }
426
427 #[test]
428 fn scene_change_detector_below_threshold() {
429 assert!(!SceneChangeDetector::is_scene_change(0.2, 0.45));
430 }
431
432 #[test]
433 fn scene_change_detector_zero_diff() {
434 assert!(!SceneChangeDetector::is_scene_change(0.0, 0.45));
435 }
436
437 #[test]
438 fn scene_change_detector_max_diff() {
439 assert!(SceneChangeDetector::is_scene_change(1.0, 0.0));
440 }
441
442 #[test]
443 fn test_min_idr_interval_respected() {
444 let cfg = SceneChangeIdrConfig {
445 threshold: 0.01, // very sensitive
446 min_idr_interval: 10,
447 max_gop_length: 500,
448 force_first_idr: true,
449 };
450 let mut ctrl = SceneChangeIdrController::new(cfg);
451
452 let dark = uniform_luma(0, 512);
453 let bright = uniform_luma(255, 512);
454
455 ctrl.push_frame(&dark); // IDR
456 // Immediately switch — should be suppressed by min_idr_interval
457 for _ in 0..5 {
458 let dec = ctrl.push_frame(&bright);
459 assert_eq!(
460 dec,
461 FrameDecision::Inter,
462 "min_idr_interval should block early IDR"
463 );
464 }
465 }
466}