Skip to main content

oximedia_transcode/
scene_cut.rs

1//! Scene cut detection for smart encoding decisions.
2//!
3//! Provides histogram-based and threshold-based scene change detection
4//! that can guide encoder I-frame placement.
5
6#![allow(dead_code)]
7#![allow(clippy::cast_precision_loss)]
8
9/// Algorithm used to detect scene cuts.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum CutDetectionMethod {
12    /// Simple per-pixel difference threshold.
13    Threshold,
14    /// Colour histogram comparison.
15    Histogram,
16    /// Edge-map difference between frames.
17    EdgeDiff,
18    /// Phase correlation of luminance planes.
19    PhaseCor,
20}
21
22impl CutDetectionMethod {
23    /// Returns the typical false-positive rate (0.0–1.0) for this method.
24    #[must_use]
25    pub fn typical_false_positive_rate(&self) -> f32 {
26        match self {
27            Self::Threshold => 0.15,
28            Self::Histogram => 0.08,
29            Self::EdgeDiff => 0.05,
30            Self::PhaseCor => 0.03,
31        }
32    }
33}
34
35/// A detected scene cut at a specific frame.
36#[derive(Debug, Clone)]
37pub struct SceneCut {
38    /// Frame index (0-based) where the cut was detected.
39    pub frame: u64,
40    /// Confidence value in the range `[0.0, 1.0]`.
41    pub confidence: f32,
42    /// Detection method that produced this cut.
43    pub method: CutDetectionMethod,
44}
45
46impl SceneCut {
47    /// Creates a new `SceneCut`.
48    #[must_use]
49    pub fn new(frame: u64, confidence: f32, method: CutDetectionMethod) -> Self {
50        Self {
51            frame,
52            confidence,
53            method,
54        }
55    }
56
57    /// Returns `true` if the confidence exceeds the hard-cut threshold (0.85).
58    #[must_use]
59    pub fn is_hard_cut(&self) -> bool {
60        self.confidence > 0.85
61    }
62}
63
64/// Computes a normalised histogram difference between two histograms.
65///
66/// Both slices must be the same length; returns `0.0` if empty.
67/// The result is the sum of absolute differences divided by the total
68/// number of pixels represented in `hist_a`.
69#[must_use]
70pub fn compute_histogram_diff(hist_a: &[u32], hist_b: &[u32]) -> f32 {
71    if hist_a.is_empty() || hist_a.len() != hist_b.len() {
72        return 0.0;
73    }
74    let total: u64 = hist_a.iter().map(|&v| u64::from(v)).sum();
75    if total == 0 {
76        return 0.0;
77    }
78    let sad: u64 = hist_a
79        .iter()
80        .zip(hist_b.iter())
81        .map(|(&a, &b)| (i64::from(a) - i64::from(b)).unsigned_abs())
82        .sum();
83    sad as f32 / total as f32
84}
85
86/// Detects scene cuts in a sequence of per-frame histograms.
87#[derive(Debug, Clone)]
88pub struct SceneCutDetector {
89    /// Detection method to use.
90    pub method: CutDetectionMethod,
91    /// Confidence threshold above which a candidate is reported as a cut.
92    pub threshold: f32,
93}
94
95impl Default for SceneCutDetector {
96    fn default() -> Self {
97        Self {
98            method: CutDetectionMethod::Histogram,
99            threshold: 0.4,
100        }
101    }
102}
103
104impl SceneCutDetector {
105    /// Creates a new detector with the given method and threshold.
106    #[must_use]
107    pub fn new(method: CutDetectionMethod, threshold: f32) -> Self {
108        Self { method, threshold }
109    }
110
111    /// Analyses consecutive frame histograms and returns detected cuts.
112    ///
113    /// Each element of `frame_histograms` is the histogram for one frame.
114    /// Consecutive pairs are compared; if the normalised difference exceeds
115    /// `self.threshold` a [`SceneCut`] is recorded at the later frame index.
116    #[must_use]
117    pub fn detect_cuts(&self, frame_histograms: &[Vec<u32>]) -> Vec<SceneCut> {
118        let mut cuts = Vec::new();
119        for (i, pair) in frame_histograms.windows(2).enumerate() {
120            let diff = compute_histogram_diff(&pair[0], &pair[1]);
121            if diff >= self.threshold {
122                // Clamp confidence to [0.0, 1.0]
123                let confidence = diff.min(1.0);
124                cuts.push(SceneCut::new((i + 1) as u64, confidence, self.method));
125            }
126        }
127        cuts
128    }
129
130    /// Convenience method: returns the number of cuts in a slice.
131    #[must_use]
132    pub fn cut_count(cuts: &[SceneCut]) -> usize {
133        cuts.len()
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    // --- CutDetectionMethod ---
142
143    #[test]
144    fn test_threshold_fpr() {
145        assert!((CutDetectionMethod::Threshold.typical_false_positive_rate() - 0.15).abs() < 1e-6);
146    }
147
148    #[test]
149    fn test_histogram_fpr() {
150        assert!((CutDetectionMethod::Histogram.typical_false_positive_rate() - 0.08).abs() < 1e-6);
151    }
152
153    #[test]
154    fn test_edge_diff_fpr() {
155        assert!((CutDetectionMethod::EdgeDiff.typical_false_positive_rate() - 0.05).abs() < 1e-6);
156    }
157
158    #[test]
159    fn test_phase_cor_fpr() {
160        assert!((CutDetectionMethod::PhaseCor.typical_false_positive_rate() - 0.03).abs() < 1e-6);
161    }
162
163    // --- SceneCut ---
164
165    #[test]
166    fn test_scene_cut_is_hard_cut_true() {
167        let cut = SceneCut::new(5, 0.90, CutDetectionMethod::Histogram);
168        assert!(cut.is_hard_cut());
169    }
170
171    #[test]
172    fn test_scene_cut_is_hard_cut_false() {
173        let cut = SceneCut::new(5, 0.80, CutDetectionMethod::Histogram);
174        assert!(!cut.is_hard_cut());
175    }
176
177    #[test]
178    fn test_scene_cut_boundary_085() {
179        // Exactly 0.85 is NOT a hard cut (strictly greater than)
180        let cut = SceneCut::new(1, 0.85, CutDetectionMethod::Threshold);
181        assert!(!cut.is_hard_cut());
182    }
183
184    // --- compute_histogram_diff ---
185
186    #[test]
187    fn test_histogram_diff_identical() {
188        let h = vec![10u32, 20, 30, 40];
189        assert!((compute_histogram_diff(&h, &h) - 0.0).abs() < 1e-6);
190    }
191
192    #[test]
193    fn test_histogram_diff_completely_different() {
194        let a = vec![100u32, 0, 0, 0];
195        let b = vec![0u32, 100, 0, 0];
196        // SAD = 200, total = 100 → ratio = 2.0, clamped later at display but raw = 2.0
197        let diff = compute_histogram_diff(&a, &b);
198        assert!((diff - 2.0).abs() < 1e-6);
199    }
200
201    #[test]
202    fn test_histogram_diff_empty() {
203        assert_eq!(compute_histogram_diff(&[], &[]), 0.0);
204    }
205
206    #[test]
207    fn test_histogram_diff_length_mismatch() {
208        let a = vec![1u32, 2];
209        let b = vec![1u32, 2, 3];
210        assert_eq!(compute_histogram_diff(&a, &b), 0.0);
211    }
212
213    // --- SceneCutDetector ---
214
215    #[test]
216    fn test_default_detector() {
217        let det = SceneCutDetector::default();
218        assert_eq!(det.method, CutDetectionMethod::Histogram);
219        assert!((det.threshold - 0.4).abs() < 1e-6);
220    }
221
222    #[test]
223    fn test_detect_no_cuts_identical_frames() {
224        let det = SceneCutDetector::default();
225        let frame = vec![50u32; 256];
226        let histograms = vec![frame.clone(), frame.clone(), frame.clone()];
227        let cuts = det.detect_cuts(&histograms);
228        assert!(cuts.is_empty());
229    }
230
231    #[test]
232    fn test_detect_single_cut() {
233        let det = SceneCutDetector::new(CutDetectionMethod::Histogram, 0.4);
234        // Frame 0: all pixels in bin 0; Frame 1: completely different distribution
235        let frame_a = {
236            let mut h = vec![0u32; 256];
237            h[0] = 1000;
238            h
239        };
240        let frame_b = {
241            let mut h = vec![0u32; 256];
242            h[255] = 1000;
243            h
244        };
245        let histograms = vec![frame_a, frame_b];
246        let cuts = det.detect_cuts(&histograms);
247        assert_eq!(cuts.len(), 1);
248        assert_eq!(cuts[0].frame, 1);
249    }
250
251    #[test]
252    fn test_cut_count_helper() {
253        let cuts = vec![
254            SceneCut::new(1, 0.9, CutDetectionMethod::Histogram),
255            SceneCut::new(5, 0.95, CutDetectionMethod::Histogram),
256        ];
257        assert_eq!(SceneCutDetector::cut_count(&cuts), 2);
258    }
259}