Skip to main content

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(&current_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}