use crate::error::{SceneError, SceneResult};
#[derive(Debug, Clone, PartialEq)]
pub struct AdaptiveConfig {
pub base_threshold: f32,
pub min_threshold: f32,
pub max_threshold: f32,
pub window_size: usize,
pub entropy_weight: f32,
pub variance_weight: f32,
pub spread_weight: f32,
pub histogram_bins: usize,
}
impl Default for AdaptiveConfig {
fn default() -> Self {
Self {
base_threshold: 0.35,
min_threshold: 0.15,
max_threshold: 0.55,
window_size: 30,
entropy_weight: 0.5,
variance_weight: 0.3,
spread_weight: 0.2,
histogram_bins: 256,
}
}
}
impl AdaptiveConfig {
pub fn validate(&self) -> SceneResult<()> {
if self.min_threshold > self.max_threshold {
return Err(SceneError::InvalidParameter(format!(
"min_threshold ({}) must be ≤ max_threshold ({})",
self.min_threshold, self.max_threshold
)));
}
if !(0.0..=1.0).contains(&self.base_threshold) {
return Err(SceneError::InvalidParameter(format!(
"base_threshold {} out of [0, 1]",
self.base_threshold
)));
}
if !(0.0..=1.0).contains(&self.min_threshold) {
return Err(SceneError::InvalidParameter(format!(
"min_threshold {} out of [0, 1]",
self.min_threshold
)));
}
if !(0.0..=1.0).contains(&self.max_threshold) {
return Err(SceneError::InvalidParameter(format!(
"max_threshold {} out of [0, 1]",
self.max_threshold
)));
}
if self.window_size == 0 {
return Err(SceneError::InvalidParameter(
"window_size must be > 0".into(),
));
}
if self.histogram_bins < 2 {
return Err(SceneError::InvalidParameter(
"histogram_bins must be ≥ 2".into(),
));
}
let weight_sum = self.entropy_weight + self.variance_weight + self.spread_weight;
if (weight_sum - 1.0).abs() > 0.01 {
return Err(SceneError::InvalidParameter(format!(
"entropy_weight + variance_weight + spread_weight must sum to 1.0, got {weight_sum}"
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ComplexityScore {
pub entropy: f32,
pub variance: f32,
pub spread: f32,
pub overall: f32,
}
pub fn compute_complexity_from_histogram(
histogram: &[u64],
config: &AdaptiveConfig,
) -> SceneResult<ComplexityScore> {
if histogram.is_empty() {
return Err(SceneError::InsufficientData(
"histogram must not be empty".into(),
));
}
let total: u64 = histogram.iter().sum();
if total == 0 {
return Ok(ComplexityScore {
entropy: 0.0,
variance: 0.0,
spread: 0.0,
overall: 0.0,
});
}
let n = histogram.len() as f64;
let total_f = total as f64;
let probs: Vec<f64> = histogram.iter().map(|&c| c as f64 / total_f).collect();
let raw_entropy: f64 = probs
.iter()
.filter(|&&p| p > 0.0)
.map(|&p| -p * p.ln())
.sum();
let max_entropy = n.ln();
let norm_entropy = if max_entropy > 0.0 {
(raw_entropy / max_entropy).clamp(0.0, 1.0)
} else {
0.0
};
let mean_prob = 1.0 / n; let raw_variance: f64 = probs.iter().map(|&p| (p - mean_prob).powi(2)).sum::<f64>() / n;
let max_variance = (1.0 / n) * (1.0 - 1.0 / n);
let norm_variance = if max_variance > 0.0 {
(1.0 - (raw_variance / max_variance).clamp(0.0, 1.0)) as f64
} else {
0.0
};
let spread_score = compute_iqr_spread(&probs);
let overall = (norm_entropy as f32 * config.entropy_weight)
+ (norm_variance as f32 * config.variance_weight)
+ (spread_score * config.spread_weight);
Ok(ComplexityScore {
entropy: norm_entropy as f32,
variance: norm_variance as f32,
spread: spread_score,
overall: overall.clamp(0.0, 1.0),
})
}
fn compute_iqr_spread(probs: &[f64]) -> f32 {
let n = probs.len();
if n < 4 {
return 0.5;
}
let mut cdf = Vec::with_capacity(n);
let mut cumulative = 0.0f64;
for &p in probs {
cumulative += p;
cdf.push(cumulative);
}
let q1 = cdf.iter().position(|&c| c >= 0.25).unwrap_or(0) as f64;
let q3 = cdf.iter().position(|&c| c >= 0.75).unwrap_or(n - 1) as f64;
let iqr = q3 - q1;
((iqr / (n as f64 / 2.0)).clamp(0.0, 1.0)) as f32
}
pub fn build_intensity_histogram(pixels: &[u8], bins: usize) -> Vec<u64> {
assert!(bins >= 2, "bins must be ≥ 2");
let mut hist = vec![0u64; bins];
let scale = bins as f32 / 256.0;
for &px in pixels {
let idx = ((px as f32 * scale) as usize).min(bins - 1);
hist[idx] += 1;
}
hist
}
#[derive(Debug, Clone)]
pub struct FrameDiff {
pub frame_index: u64,
pub diff: f32,
pub complexity: Option<ComplexityScore>,
}
impl FrameDiff {
#[must_use]
pub fn new(frame_index: u64, diff: f32) -> Self {
Self {
frame_index,
diff: diff.clamp(0.0, 1.0),
complexity: None,
}
}
#[must_use]
pub fn with_complexity(frame_index: u64, diff: f32, complexity: ComplexityScore) -> Self {
Self {
frame_index,
diff: diff.clamp(0.0, 1.0),
complexity: Some(complexity),
}
}
}
#[derive(Debug, Clone)]
pub struct AdaptiveSceneCut {
pub frame_index: u64,
pub diff: f32,
pub threshold_used: f32,
pub complexity: Option<ComplexityScore>,
}
#[derive(Debug, Clone, Copy)]
struct WindowEntry {
complexity: f32,
}
pub struct AdaptiveSceneDetector {
config: AdaptiveConfig,
frames: Vec<FrameDiff>,
complexity_window: std::collections::VecDeque<WindowEntry>,
}
impl AdaptiveSceneDetector {
pub fn new(config: AdaptiveConfig) -> Self {
Self {
config,
frames: Vec::new(),
complexity_window: std::collections::VecDeque::new(),
}
}
pub fn configure(&mut self, config: AdaptiveConfig) -> SceneResult<()> {
config.validate()?;
self.config = config;
Ok(())
}
#[must_use]
pub const fn config(&self) -> &AdaptiveConfig {
&self.config
}
pub fn push_frame(&mut self, frame: FrameDiff) {
if let Some(score) = frame.complexity {
self.complexity_window.push_back(WindowEntry {
complexity: score.overall,
});
while self.complexity_window.len() > self.config.window_size {
self.complexity_window.pop_front();
}
}
self.frames.push(frame);
}
#[must_use]
pub fn current_threshold(&self) -> f32 {
self.threshold_for_window_mean(self.window_mean())
}
fn window_mean(&self) -> Option<f32> {
if self.complexity_window.is_empty() {
None
} else {
let sum: f32 = self.complexity_window.iter().map(|e| e.complexity).sum();
Some(sum / self.complexity_window.len() as f32)
}
}
fn threshold_for_window_mean(&self, mean: Option<f32>) -> f32 {
match mean {
None => self.config.base_threshold,
Some(m) => {
let m = m.clamp(0.0, 1.0);
self.config.min_threshold
+ m * (self.config.max_threshold - self.config.min_threshold)
}
}
}
#[must_use]
pub fn detect(&self) -> Vec<AdaptiveSceneCut> {
let mut cuts = Vec::new();
let mut window: std::collections::VecDeque<f32> = std::collections::VecDeque::new();
for frame in &self.frames {
let mean = if window.is_empty() {
None
} else {
let sum: f32 = window.iter().sum();
Some(sum / window.len() as f32)
};
let threshold = self.threshold_for_window_mean(mean);
if frame.diff >= threshold {
cuts.push(AdaptiveSceneCut {
frame_index: frame.frame_index,
diff: frame.diff,
threshold_used: threshold,
complexity: frame.complexity,
});
}
if let Some(score) = frame.complexity {
window.push_back(score.overall);
while window.len() > self.config.window_size {
window.pop_front();
}
}
}
cuts
}
pub fn detect_with_pixels(
&mut self,
frame_data: &[(u64, f32, &[u8])],
) -> SceneResult<Vec<AdaptiveSceneCut>> {
self.reset();
for &(frame_index, diff, pixels) in frame_data {
let histogram = build_intensity_histogram(pixels, self.config.histogram_bins);
let complexity = compute_complexity_from_histogram(&histogram, &self.config)?;
self.push_frame(FrameDiff::with_complexity(frame_index, diff, complexity));
}
Ok(self.detect())
}
pub fn reset(&mut self) {
self.frames.clear();
self.complexity_window.clear();
}
#[must_use]
pub fn frame_count(&self) -> usize {
self.frames.len()
}
}
impl Default for AdaptiveSceneDetector {
fn default() -> Self {
Self::new(AdaptiveConfig::default())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SceneChange {
pub frame_idx: u64,
pub confidence: f64,
pub is_flash: bool,
}
pub struct RollingSceneDetector {
window_size: usize,
k_factor: f64,
min_scene_duration: u32,
history: std::collections::VecDeque<f64>,
frame_idx: u64,
last_cut_frame: Option<u64>,
}
impl RollingSceneDetector {
pub fn new(window_size: usize, k_factor: f64, min_scene_duration: u32) -> Self {
let window_size = window_size.max(2);
Self {
window_size,
k_factor,
min_scene_duration,
history: std::collections::VecDeque::with_capacity(window_size),
frame_idx: 0,
last_cut_frame: None,
}
}
pub fn push_frame_diff(&mut self, diff: f64) -> Option<SceneChange> {
let threshold = self.adaptive_threshold();
let idx = self.frame_idx;
self.frame_idx += 1;
let result = if diff > threshold {
let frames_since_last = match self.last_cut_frame {
None => u64::MAX,
Some(last) => idx.saturating_sub(last),
};
if frames_since_last >= self.min_scene_duration as u64 {
let is_flash = diff >= 2.0 * threshold && self.history_mean() < threshold;
let confidence = if threshold > 0.0 {
((diff - threshold) / threshold).clamp(0.0, 1.0)
} else {
1.0
};
self.last_cut_frame = Some(idx);
Some(SceneChange {
frame_idx: idx,
confidence,
is_flash,
})
} else {
None
}
} else {
None
};
self.history.push_back(diff);
if self.history.len() > self.window_size {
self.history.pop_front();
}
result
}
pub fn adaptive_threshold(&self) -> f64 {
let n = self.history.len();
if n < 2 {
return 0.3;
}
let mean = self.history_mean();
let variance = self
.history
.iter()
.map(|&x| {
let d = x - mean;
d * d
})
.sum::<f64>()
/ n as f64;
let stddev = variance.sqrt();
(mean + self.k_factor * stddev).max(0.0)
}
fn history_mean(&self) -> f64 {
let n = self.history.len();
if n == 0 {
return 0.0;
}
self.history.iter().sum::<f64>() / n as f64
}
#[must_use]
pub fn frame_count(&self) -> u64 {
self.frame_idx
}
pub fn reset(&mut self) {
self.history.clear();
self.frame_idx = 0;
self.last_cut_frame = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn uniform_pixels(n_pixels: usize) -> Vec<u8> {
(0..n_pixels).map(|i| (i % 256) as u8).collect()
}
fn constant_pixels(value: u8, n_pixels: usize) -> Vec<u8> {
vec![value; n_pixels]
}
#[test]
fn test_config_default_is_valid() {
assert!(AdaptiveConfig::default().validate().is_ok());
}
#[test]
fn test_config_min_greater_than_max_is_invalid() {
let cfg = AdaptiveConfig {
min_threshold: 0.6,
max_threshold: 0.4,
..Default::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_config_base_threshold_out_of_range() {
let cfg = AdaptiveConfig {
base_threshold: 1.5,
..Default::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_config_zero_window_size_is_invalid() {
let cfg = AdaptiveConfig {
window_size: 0,
..Default::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_config_bad_weights_is_invalid() {
let cfg = AdaptiveConfig {
entropy_weight: 0.5,
variance_weight: 0.5,
spread_weight: 0.5, ..Default::default()
};
assert!(cfg.validate().is_err());
}
#[test]
fn test_uniform_histogram_has_high_entropy() {
let hist: Vec<u64> = vec![100; 256];
let config = AdaptiveConfig::default();
let score = compute_complexity_from_histogram(&hist, &config)
.expect("uniform histogram should compute");
assert!(
score.entropy > 0.99,
"uniform histogram entropy should be ≈ 1, got {}",
score.entropy
);
}
#[test]
fn test_single_bin_histogram_has_zero_entropy() {
let mut hist = vec![0u64; 256];
hist[128] = 1000;
let config = AdaptiveConfig::default();
let score = compute_complexity_from_histogram(&hist, &config)
.expect("single-bin histogram should compute");
assert!(
score.entropy < 0.01,
"single-bin histogram entropy should be ≈ 0, got {}",
score.entropy
);
}
#[test]
fn test_empty_histogram_returns_error() {
let config = AdaptiveConfig::default();
assert!(compute_complexity_from_histogram(&[], &config).is_err());
}
#[test]
fn test_all_zero_histogram_returns_zero_complexity() {
let hist = vec![0u64; 64];
let config = AdaptiveConfig::default();
let score = compute_complexity_from_histogram(&hist, &config)
.expect("zero histogram should compute");
assert_eq!(score.overall, 0.0);
}
#[test]
fn test_uniform_pixels_give_higher_complexity_than_constant() {
let cfg = AdaptiveConfig::default();
let bins = cfg.histogram_bins;
let uniform_hist = build_intensity_histogram(&uniform_pixels(4096), bins);
let constant_hist = build_intensity_histogram(&constant_pixels(128, 4096), bins);
let uniform_score = compute_complexity_from_histogram(&uniform_hist, &cfg)
.expect("uniform histogram should compute");
let constant_score = compute_complexity_from_histogram(&constant_hist, &cfg)
.expect("constant histogram should compute");
assert!(
uniform_score.overall > constant_score.overall,
"uniform frame (score={}) should be more complex than constant frame (score={})",
uniform_score.overall,
constant_score.overall
);
}
#[test]
fn test_intensity_histogram_bin_count() {
let pixels = uniform_pixels(512);
let hist = build_intensity_histogram(&pixels, 64);
assert_eq!(hist.len(), 64);
}
#[test]
fn test_intensity_histogram_pixel_sum_equals_input_len() {
let pixels = uniform_pixels(1024);
let hist = build_intensity_histogram(&pixels, 256);
let total: u64 = hist.iter().sum();
assert_eq!(total, 1024);
}
#[test]
fn test_detector_default_no_cuts_on_stable_sequence() {
let mut det = AdaptiveSceneDetector::default();
for i in 0..20u64 {
det.push_frame(FrameDiff::new(i, 0.02));
}
assert!(
det.detect().is_empty(),
"stable sequence should produce no cuts"
);
}
#[test]
fn test_detector_detects_hard_cut() {
let mut det = AdaptiveSceneDetector::default();
for i in 0..10u64 {
det.push_frame(FrameDiff::new(i, 0.04));
}
det.push_frame(FrameDiff::new(10, 0.95)); for i in 11..20u64 {
det.push_frame(FrameDiff::new(i, 0.03));
}
let cuts = det.detect();
assert_eq!(cuts.len(), 1);
assert_eq!(cuts[0].frame_index, 10);
}
#[test]
fn test_detector_reset_clears_state() {
let mut det = AdaptiveSceneDetector::default();
det.push_frame(FrameDiff::new(0, 0.9));
det.reset();
assert_eq!(det.frame_count(), 0);
assert!(det.detect().is_empty());
}
#[test]
fn test_configure_updates_thresholds() {
let mut det = AdaptiveSceneDetector::default();
let new_cfg = AdaptiveConfig {
base_threshold: 0.1,
min_threshold: 0.05,
max_threshold: 0.2,
..AdaptiveConfig::default()
};
det.configure(new_cfg.clone())
.expect("valid config should apply");
assert_eq!(det.config().base_threshold, 0.1);
assert_eq!(det.config().min_threshold, 0.05);
assert_eq!(det.config().max_threshold, 0.2);
}
#[test]
fn test_configure_with_invalid_config_returns_error() {
let mut det = AdaptiveSceneDetector::default();
let bad_cfg = AdaptiveConfig {
min_threshold: 0.8,
max_threshold: 0.2, ..AdaptiveConfig::default()
};
assert!(det.configure(bad_cfg).is_err());
}
#[test]
fn test_high_complexity_window_raises_threshold() {
let cfg = AdaptiveConfig {
min_threshold: 0.1,
max_threshold: 0.6,
window_size: 5,
..AdaptiveConfig::default()
};
let mut det = AdaptiveSceneDetector::new(cfg);
let high_score = ComplexityScore {
entropy: 1.0,
variance: 1.0,
spread: 1.0,
overall: 1.0,
};
for i in 0..5u64 {
det.push_frame(FrameDiff::with_complexity(i, 0.01, high_score));
}
let threshold = det.current_threshold();
assert!(
threshold > 0.4,
"high-complexity window should push threshold toward max (0.6), got {threshold}"
);
}
#[test]
fn test_low_complexity_window_lowers_threshold() {
let cfg = AdaptiveConfig {
min_threshold: 0.1,
max_threshold: 0.6,
window_size: 5,
..AdaptiveConfig::default()
};
let mut det = AdaptiveSceneDetector::new(cfg);
let low_score = ComplexityScore {
entropy: 0.0,
variance: 0.0,
spread: 0.0,
overall: 0.0,
};
for i in 0..5u64 {
det.push_frame(FrameDiff::with_complexity(i, 0.01, low_score));
}
let threshold = det.current_threshold();
assert!(
threshold < 0.2,
"low-complexity window should keep threshold near min (0.1), got {threshold}"
);
}
#[test]
fn test_detect_with_pixels_finds_cut_in_pixel_data() {
let cfg = AdaptiveConfig {
base_threshold: 0.35,
min_threshold: 0.15,
max_threshold: 0.55,
window_size: 10,
..AdaptiveConfig::default()
};
let mut det = AdaptiveSceneDetector::new(cfg);
let n_pixels = 512;
let uniform = uniform_pixels(n_pixels);
let constant = constant_pixels(0, n_pixels);
let frame_data: Vec<(u64, f32, &[u8])> = vec![
(0, 0.02, &uniform),
(1, 0.03, &uniform),
(2, 0.02, &uniform),
(3, 0.90, &constant), (4, 0.02, &constant),
];
let cuts = det
.detect_with_pixels(&frame_data)
.expect("detection should succeed");
assert!(!cuts.is_empty(), "should detect at least one cut");
assert!(
cuts.iter().any(|c| c.frame_index == 3),
"cut should be at frame 3"
);
}
#[test]
fn test_multiple_cuts_detected_in_order() {
let mut det = AdaptiveSceneDetector::default();
for i in 0..30u64 {
let diff = if i == 5 || i == 15 || i == 25 {
0.95
} else {
0.02
};
det.push_frame(FrameDiff::new(i, diff));
}
let cuts = det.detect();
assert_eq!(cuts.len(), 3);
assert_eq!(cuts[0].frame_index, 5);
assert_eq!(cuts[1].frame_index, 15);
assert_eq!(cuts[2].frame_index, 25);
}
#[test]
fn test_sliding_window_respects_window_size() {
let cfg = AdaptiveConfig {
window_size: 3,
..AdaptiveConfig::default()
};
let mut det = AdaptiveSceneDetector::new(cfg);
let high = ComplexityScore {
entropy: 1.0,
variance: 1.0,
spread: 1.0,
overall: 1.0,
};
for i in 0..10u64 {
det.push_frame(FrameDiff::with_complexity(i, 0.01, high));
}
assert_eq!(det.complexity_window.len(), 3);
}
#[test]
fn test_threshold_used_is_stored_in_cut() {
let mut det = AdaptiveSceneDetector::default();
det.push_frame(FrameDiff::new(0, 0.95));
let cuts = det.detect();
assert!(!cuts.is_empty());
assert!(
(cuts[0].threshold_used - det.config().base_threshold).abs() < 1e-6,
"threshold_used should equal base_threshold when window is empty, got {}",
cuts[0].threshold_used
);
}
#[test]
fn test_rolling_empty_history_returns_default_threshold() {
let det = RollingSceneDetector::new(30, 2.0, 5);
let t = det.adaptive_threshold();
assert!(
(t - 0.3).abs() < 1e-9,
"empty history should return 0.3, got {t}"
);
}
#[test]
fn test_rolling_below_threshold_returns_none() {
let mut det = RollingSceneDetector::new(10, 2.0, 1);
for i in 0..10u64 {
det.push_frame_diff(0.05 + (i as f64) * 0.01);
}
assert!(
det.push_frame_diff(0.10).is_none(),
"value within range should not trigger a cut"
);
}
#[test]
fn test_rolling_hard_cut_detected() {
let mut det = RollingSceneDetector::new(10, 2.0, 1);
for _ in 0..10 {
det.push_frame_diff(0.02);
}
let cut = det.push_frame_diff(0.90);
assert!(cut.is_some(), "large diff should trigger a cut");
let c = cut.expect("large diff should produce a cut result");
assert_eq!(c.frame_idx, 10, "cut should be at frame 10 (0-indexed)");
}
#[test]
fn test_rolling_confidence_in_unit_range() {
let mut det = RollingSceneDetector::new(10, 2.0, 1);
for _ in 0..10 {
det.push_frame_diff(0.02);
}
let cut = det.push_frame_diff(0.90).expect("should detect cut");
assert!(
(0.0..=1.0).contains(&cut.confidence),
"confidence must be in [0,1], got {}",
cut.confidence
);
}
#[test]
fn test_rolling_min_scene_duration_suppresses_rapid_cuts() {
let mut det = RollingSceneDetector::new(30, 0.5, 10); for _ in 0..30 {
det.push_frame_diff(0.05);
}
let cut1 = det.push_frame_diff(0.95);
assert!(cut1.is_some(), "first cut should be detected");
let cut2 = det.push_frame_diff(0.95);
assert!(
cut2.is_none(),
"cut within min_scene_duration should be suppressed"
);
}
#[test]
fn test_rolling_min_scene_duration_allows_cut_after_cooldown() {
let mut det = RollingSceneDetector::new(30, 0.5, 5);
for _ in 0..30 {
det.push_frame_diff(0.05);
}
let cut1 = det.push_frame_diff(0.95);
assert!(cut1.is_some());
for _ in 0..5 {
det.push_frame_diff(0.05);
}
let cut2 = det.push_frame_diff(0.95);
assert!(cut2.is_some(), "cut after cooldown should be detected");
}
#[test]
fn test_rolling_flash_suppression_marks_is_flash() {
let mut det = RollingSceneDetector::new(20, 2.0, 1);
for _ in 0..20 {
det.push_frame_diff(0.01);
}
let threshold = det.adaptive_threshold();
let spike = threshold * 2.5;
let cut = det.push_frame_diff(spike).expect("should detect cut");
assert!(
cut.is_flash,
"extreme spike against quiet baseline should be flagged as flash (threshold={threshold:.4}, spike={spike:.4})"
);
}
#[test]
fn test_rolling_normal_cut_is_not_flash() {
let mut det = RollingSceneDetector::new(20, 1.5, 1);
for i in 0..20 {
det.push_frame_diff(0.1 + (i as f64) * 0.005);
}
let threshold = det.adaptive_threshold();
let diff = threshold * 1.5; let cut = det.push_frame_diff(diff).expect("should detect cut");
assert!(
!cut.is_flash,
"moderate cut above baseline should not be flagged as flash (threshold={threshold:.4}, diff={diff:.4})"
);
}
#[test]
fn test_rolling_mean_stddev_threshold_calculation() {
let mut det = RollingSceneDetector::new(4, 1.0, 1);
det.push_frame_diff(0.1);
det.push_frame_diff(0.2);
det.push_frame_diff(0.3);
det.push_frame_diff(0.4);
let t = det.adaptive_threshold();
let expected = 0.25_f64 + (0.0125_f64).sqrt();
assert!(
(t - expected).abs() < 1e-9,
"expected threshold {expected:.9}, got {t:.9}"
);
}
#[test]
fn test_rolling_window_eviction_respects_window_size() {
let mut det = RollingSceneDetector::new(5, 2.0, 1);
for _ in 0..20 {
det.push_frame_diff(0.1);
}
assert_eq!(
det.history.len(),
5,
"history deque should be capped at window_size"
);
}
#[test]
fn test_rolling_reset_clears_state() {
let mut det = RollingSceneDetector::new(10, 2.0, 1);
for _ in 0..10 {
det.push_frame_diff(0.05);
}
det.push_frame_diff(0.90);
det.reset();
assert_eq!(det.frame_count(), 0);
assert!(det.history.is_empty());
assert!(
(det.adaptive_threshold() - 0.3).abs() < 1e-9,
"threshold should revert to 0.3 after reset"
);
}
#[test]
fn test_rolling_frame_idx_monotonically_increases() {
let mut det = RollingSceneDetector::new(10, 2.0, 1);
for i in 0..5u64 {
det.push_frame_diff(0.05);
assert_eq!(det.frame_count(), i + 1);
}
}
}