use core::time::Duration;
use derive_more::{Display, IsVariant};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::frame::{HsvFrame, LumaFrame, RgbFrame, Timebase, Timestamp};
use std::vec::Vec;
use super::{round_64, sqrt_64};
mod arch;
use arch::{bgr_to_hsv_planes, mean_abs_diff, sobel};
pub const DEFAULT_WEIGHTS: Components = Components::new(1.0, 1.0, 1.0, 0.0);
pub const LUMA_ONLY_WEIGHTS: Components = Components::new(0.0, 0.0, 1.0, 0.0);
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Components {
delta_hue: f64,
delta_sat: f64,
delta_lum: f64,
delta_edges: f64,
}
impl Components {
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn new(delta_hue: f64, delta_sat: f64, delta_lum: f64, delta_edges: f64) -> Self {
Self {
delta_hue,
delta_sat,
delta_lum,
delta_edges,
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn delta_hue(&self) -> f64 {
self.delta_hue
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_delta_hue(mut self, val: f64) -> Self {
self.delta_hue = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_delta_hue(&mut self, val: f64) -> &mut Self {
self.delta_hue = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn delta_sat(&self) -> f64 {
self.delta_sat
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_delta_sat(mut self, val: f64) -> Self {
self.delta_sat = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_delta_sat(&mut self, val: f64) -> &mut Self {
self.delta_sat = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn delta_lum(&self) -> f64 {
self.delta_lum
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_delta_lum(mut self, val: f64) -> Self {
self.delta_lum = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_delta_lum(&mut self, val: f64) -> &mut Self {
self.delta_lum = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn delta_edges(&self) -> f64 {
self.delta_edges
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_delta_edges(mut self, val: f64) -> Self {
self.delta_edges = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_delta_edges(&mut self, val: f64) -> &mut Self {
self.delta_edges = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn sum_abs(&self) -> f64 {
self.delta_hue.abs() + self.delta_sat.abs() + self.delta_lum.abs() + self.delta_edges.abs()
}
}
impl Default for Components {
#[cfg_attr(not(tarpaulin), inline(always))]
fn default() -> Self {
DEFAULT_WEIGHTS
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, IsVariant, Display)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[display("{}", self.as_str())]
#[non_exhaustive]
pub enum FilterMode {
Suppress,
#[default]
Merge,
}
impl FilterMode {
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn as_str(&self) -> &'static str {
match self {
Self::Suppress => "suppress",
Self::Merge => "merge",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, IsVariant, Error)]
#[non_exhaustive]
pub enum Error {
#[error("all component weights are zero")]
ZeroWeights,
#[error("kernel_size ({0}) must be an odd integer >= 3")]
InvalidKernelSize(u32),
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Options {
threshold: f64,
#[cfg_attr(feature = "serde", serde(with = "humantime_serde"))]
min_duration: Duration,
weights: Components,
filter_mode: FilterMode,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
kernel_size: Option<u32>,
initial_cut: bool,
simd: bool,
}
impl Default for Options {
#[cfg_attr(not(tarpaulin), inline(always))]
fn default() -> Self {
Self::new()
}
}
impl Options {
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn new() -> Self {
Self {
threshold: 27.0,
min_duration: Duration::from_secs(1),
weights: DEFAULT_WEIGHTS,
filter_mode: FilterMode::Merge,
kernel_size: None,
initial_cut: true,
simd: true,
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn threshold(&self) -> f64 {
self.threshold
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_threshold(mut self, val: f64) -> Self {
self.threshold = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_threshold(&mut self, val: f64) -> &mut Self {
self.threshold = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn min_duration(&self) -> Duration {
self.min_duration
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_min_duration(mut self, val: Duration) -> Self {
self.min_duration = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_min_duration(&mut self, val: Duration) -> &mut Self {
self.min_duration = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_min_frames(mut self, frames: u32, fps: Timebase) -> Self {
self.min_duration = fps.frames_to_duration(frames);
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_min_frames(&mut self, frames: u32, fps: Timebase) -> &mut Self {
self.min_duration = fps.frames_to_duration(frames);
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn weights(&self) -> Components {
self.weights
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_weights(mut self, val: Components) -> Self {
self.weights = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_weights(&mut self, val: Components) -> &mut Self {
self.weights = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn filter_mode(&self) -> FilterMode {
self.filter_mode
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_filter_mode(mut self, val: FilterMode) -> Self {
self.filter_mode = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_filter_mode(&mut self, val: FilterMode) -> &mut Self {
self.filter_mode = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn kernel_size(&self) -> Option<u32> {
self.kernel_size
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_kernel_size(mut self, val: Option<u32>) -> Self {
self.kernel_size = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_kernel_size(&mut self, val: Option<u32>) -> &mut Self {
self.kernel_size = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn initial_cut(&self) -> bool {
self.initial_cut
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_initial_cut(mut self, val: bool) -> Self {
self.initial_cut = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_initial_cut(&mut self, val: bool) -> &mut Self {
self.initial_cut = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn simd(&self) -> bool {
self.simd
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_simd(mut self, val: bool) -> Self {
self.simd = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_simd(&mut self, val: bool) -> &mut Self {
self.simd = val;
self
}
}
#[derive(Debug, Clone)]
pub struct Detector {
options: Options,
sum_abs_weights: f64,
edges_enabled: bool,
use_simd: bool,
has_previous: bool,
last_score: Option<f64>,
last_components: Option<Components>,
last_above: Option<Timestamp>,
merge_enabled: bool,
merge_triggered: bool,
merge_start: Option<Timestamp>,
width: u32,
height: u32,
kernel: u32,
prev_h: Vec<u8>,
prev_s: Vec<u8>,
prev_v: Vec<u8>,
prev_edges: Vec<u8>,
cur_h: Vec<u8>,
cur_s: Vec<u8>,
cur_v: Vec<u8>,
cur_edges: Vec<u8>,
sobel_mag: Vec<i32>,
sobel_dir: Vec<u8>,
nms_out: Vec<u8>,
dilate_tmp: Vec<u8>,
vh_r: Vec<u8>,
vh_s: Vec<u8>,
}
impl Detector {
#[cfg_attr(not(tarpaulin), inline(always))]
pub fn new(options: Options) -> Self {
Self::try_new(options).expect("invalid detector options")
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn try_new(options: Options) -> Result<Self, Error> {
let sum = options.weights.sum_abs();
if sum == 0.0 {
return Err(Error::ZeroWeights);
}
if let Some(k) = options.kernel_size {
if k < 3 || k % 2 == 0 {
return Err(Error::InvalidKernelSize(k));
}
}
let edges_enabled = options.weights.delta_edges != 0.0;
let use_simd = options.simd;
Ok(Self {
options,
sum_abs_weights: sum,
edges_enabled,
use_simd,
has_previous: false,
last_score: None,
last_components: None,
last_above: None,
merge_enabled: false,
merge_triggered: false,
merge_start: None,
width: 0,
height: 0,
kernel: 0,
prev_h: Vec::new(),
prev_s: Vec::new(),
prev_v: Vec::new(),
prev_edges: Vec::new(),
cur_h: Vec::new(),
cur_s: Vec::new(),
cur_v: Vec::new(),
cur_edges: Vec::new(),
sobel_mag: Vec::new(),
sobel_dir: Vec::new(),
nms_out: Vec::new(),
dilate_tmp: Vec::new(),
vh_r: Vec::new(),
vh_s: Vec::new(),
})
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn options(&self) -> &Options {
&self.options
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn last_score(&self) -> Option<f64> {
self.last_score
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn last_components(&self) -> Option<Components> {
self.last_components
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub fn clear(&mut self) {
self.has_previous = false;
self.last_score = None;
self.last_components = None;
self.last_above = None;
self.merge_enabled = false;
self.merge_triggered = false;
self.merge_start = None;
}
pub fn process_luma(&mut self, frame: LumaFrame<'_>) -> Option<Timestamp> {
let ts = frame.timestamp();
self.ensure_buffers(frame.width(), frame.height());
copy_plane(
&mut self.cur_v,
frame.data(),
frame.width(),
frame.height(),
frame.stride(),
);
for slot in self.cur_h.iter_mut() {
*slot = 0;
}
for slot in self.cur_s.iter_mut() {
*slot = 0;
}
self.process_inner(ts)
}
pub fn process_bgr(&mut self, frame: RgbFrame<'_>) -> Option<Timestamp> {
let ts = frame.timestamp();
self.ensure_buffers(frame.width(), frame.height());
bgr_to_hsv_planes(
&mut self.cur_h,
&mut self.cur_s,
&mut self.cur_v,
frame.data(),
frame.width(),
frame.height(),
frame.stride(),
self.use_simd,
);
self.process_inner(ts)
}
pub fn process_hsv(&mut self, frame: HsvFrame<'_>) -> Option<Timestamp> {
let ts = frame.timestamp();
self.ensure_buffers(frame.width(), frame.height());
copy_plane(
&mut self.cur_h,
frame.hue(),
frame.width(),
frame.height(),
frame.stride(),
);
copy_plane(
&mut self.cur_s,
frame.saturation(),
frame.width(),
frame.height(),
frame.stride(),
);
copy_plane(
&mut self.cur_v,
frame.value(),
frame.width(),
frame.height(),
frame.stride(),
);
self.process_inner(ts)
}
fn process_inner(&mut self, ts: Timestamp) -> Option<Timestamp> {
let n = (self.width as usize) * (self.height as usize);
if self.edges_enabled {
self.compute_edges();
}
let mut cut: Option<Timestamp> = None;
if self.has_previous {
let simd = self.use_simd;
let components = Components::new(
mean_abs_diff(&self.cur_h, &self.prev_h, n, simd),
mean_abs_diff(&self.cur_s, &self.prev_s, n, simd),
mean_abs_diff(&self.cur_v, &self.prev_v, n, simd),
if self.edges_enabled {
mean_abs_diff(&self.cur_edges, &self.prev_edges, n, simd)
} else {
0.0
},
);
let w = self.options.weights;
let score = (components.delta_hue() * w.delta_hue()
+ components.delta_sat() * w.delta_sat()
+ components.delta_lum() * w.delta_lum()
+ components.delta_edges() * w.delta_edges())
/ self.sum_abs_weights;
self.last_score = Some(score);
self.last_components = Some(components);
let above = score >= self.options.threshold;
cut = self.flash_filter(ts, above);
}
core::mem::swap(&mut self.prev_h, &mut self.cur_h);
core::mem::swap(&mut self.prev_s, &mut self.cur_s);
core::mem::swap(&mut self.prev_v, &mut self.cur_v);
if self.edges_enabled {
core::mem::swap(&mut self.prev_edges, &mut self.cur_edges);
}
self.has_previous = true;
cut
}
fn compute_edges(&mut self) {
if self.width < 3 || self.height < 3 {
for v in self.cur_edges.iter_mut() {
*v = 0;
}
return;
}
let median = median_u8(&self.cur_v);
let sigma = 1.0_f32 / 3.0;
let low = ((1.0 - sigma) * median as f32).max(0.0) as u8;
let high = ((1.0 + sigma) * median as f32).min(255.0) as u8;
self.sobel();
self.non_max_suppress();
self.hysteresis(low, high);
self.dilate();
}
fn sobel(&mut self) {
sobel(
&self.cur_v,
&mut self.sobel_mag,
&mut self.sobel_dir,
self.width as usize,
self.height as usize,
self.use_simd,
);
}
fn non_max_suppress(&mut self) {
let mag = &self.sobel_mag;
let dir = &self.sobel_dir;
let out = &mut self.nms_out;
let w = self.width as usize;
let h = self.height as usize;
for v in out.iter_mut() {
*v = 0;
}
for y in 1..h.saturating_sub(1) {
for x in 1..w.saturating_sub(1) {
let idx = y * w + x;
let m = mag[idx];
if m == 0 {
continue;
}
let (dx, dy): (isize, isize) = match dir[idx] {
0 => (1, 0), 1 => (1, 1), 2 => (0, 1), _ => (1, -1), };
let a = mag[((y as isize + dy) as usize) * w + (x as isize + dx) as usize];
let b = mag[((y as isize - dy) as usize) * w + (x as isize - dx) as usize];
if m >= a && m >= b {
out[idx] = m.min(255) as u8;
}
}
}
}
fn hysteresis(&mut self, low: u8, high: u8) {
let buf = &mut self.nms_out;
let mag_raw = &self.sobel_mag;
let w = self.width as usize;
let h = self.height as usize;
let high = high as i32;
let low = low as i32;
for i in 0..(w * h) {
if buf[i] == 0 {
continue;
}
let m = mag_raw[i];
if m >= high {
buf[i] = 2;
} else if m >= low {
buf[i] = 1;
} else {
buf[i] = 0;
}
}
let y_end = h.saturating_sub(1);
let x_end = w.saturating_sub(1);
for _ in 0..2 {
for y in 1..y_end {
for x in 1..x_end {
let idx = y * w + x;
if buf[idx] != 1 {
continue;
}
for (dy, dx) in [(-1i32, -1i32), (-1, 0), (-1, 1), (0, -1)] {
let ny = (y as i32 + dy) as usize;
let nx = (x as i32 + dx) as usize;
if buf[ny * w + nx] == 2 {
buf[idx] = 2;
break;
}
}
}
}
for y in (1..y_end).rev() {
for x in (1..x_end).rev() {
let idx = y * w + x;
if buf[idx] != 1 {
continue;
}
for (dy, dx) in [(1i32, 1i32), (1, 0), (1, -1), (0, 1)] {
let ny = (y as i32 + dy) as usize;
let nx = (x as i32 + dx) as usize;
if buf[ny * w + nx] == 2 {
buf[idx] = 2;
break;
}
}
}
}
}
for v in buf.iter_mut() {
*v = if *v == 2 { 255 } else { 0 };
}
}
fn dilate(&mut self) {
let input = &self.nms_out;
let out = &mut self.cur_edges;
let tmp = &mut self.dilate_tmp;
let vh_r = &mut self.vh_r;
let vh_s = &mut self.vh_s;
let w = self.width as usize;
let h = self.height as usize;
let k = self.kernel as usize;
debug_assert!(k >= 3 && k % 2 == 1);
debug_assert!(vh_r.len() >= w.max(h) && vh_s.len() >= w.max(h));
for y in 0..h {
let row_in = &input[y * w..y * w + w];
let row_out = &mut tmp[y * w..y * w + w];
van_herk_1d_contig(row_in, row_out, vh_r, vh_s, w, k);
}
for x in 0..w {
van_herk_1d_column(tmp, out, vh_r, vh_s, x, w, h, k);
}
}
fn flash_filter(&mut self, ts: Timestamp, above: bool) -> Option<Timestamp> {
if self.last_above.is_none() {
self.last_above = Some(virtual_seed(ts, &self.options));
}
let last_above_ts = self.last_above.expect("seeded above");
let min_length_met = ts
.duration_since(&last_above_ts)
.is_some_and(|d| d >= self.options.min_duration);
match self.options.filter_mode {
FilterMode::Suppress => {
if above && min_length_met {
self.last_above = Some(ts);
Some(ts)
} else {
None
}
}
FilterMode::Merge => self.filter_merge(ts, above, min_length_met),
}
}
fn filter_merge(
&mut self,
ts: Timestamp,
above: bool,
min_length_met: bool,
) -> Option<Timestamp> {
if above {
self.last_above = Some(ts);
}
if self.merge_triggered {
let merge_start = self.merge_start.expect("triggered implies start");
let last_above = self.last_above.expect("seeded above");
let num_merged = last_above
.duration_since(&merge_start)
.unwrap_or(Duration::ZERO);
if min_length_met && !above && num_merged >= self.options.min_duration {
self.merge_triggered = false;
return self.last_above;
}
return None;
}
if !above {
return None;
}
if min_length_met {
self.merge_enabled = true;
return Some(ts);
}
if self.merge_enabled {
self.merge_triggered = true;
self.merge_start = Some(ts);
}
None
}
fn ensure_buffers(&mut self, width: u32, height: u32) {
if self.width == width && self.height == height {
return;
}
self.width = width;
self.height = height;
self.kernel = self
.options
.kernel_size
.unwrap_or_else(|| auto_kernel_size(width, height));
let n = (width as usize) * (height as usize);
for v in [
&mut self.prev_h,
&mut self.prev_s,
&mut self.prev_v,
&mut self.cur_h,
&mut self.cur_s,
&mut self.cur_v,
] {
v.clear();
v.resize(n, 0);
}
if self.edges_enabled {
for v in [
&mut self.prev_edges,
&mut self.cur_edges,
&mut self.nms_out,
&mut self.dilate_tmp,
] {
v.clear();
v.resize(n, 0);
}
self.sobel_mag.clear();
self.sobel_mag.resize(n, 0);
self.sobel_dir.clear();
self.sobel_dir.resize(n, 0);
let vh_len = (width as usize).max(height as usize);
self.vh_r.clear();
self.vh_r.resize(vh_len, 0);
self.vh_s.clear();
self.vh_s.resize(vh_len, 0);
}
self.last_above = None;
self.merge_enabled = false;
self.merge_triggered = false;
self.merge_start = None;
self.has_previous = false;
self.last_score = None;
self.last_components = None;
}
}
fn virtual_seed(ts: Timestamp, options: &Options) -> Timestamp {
if options.initial_cut {
ts.saturating_sub_duration(options.min_duration)
} else {
ts
}
}
fn copy_plane(dst: &mut [u8], src: &[u8], width: u32, height: u32, stride: u32) {
let w = width as usize;
let h = height as usize;
let s = stride as usize;
for y in 0..h {
let dst_row = &mut dst[y * w..(y + 1) * w];
let src_row = &src[y * s..y * s + w];
dst_row.copy_from_slice(src_row);
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn auto_kernel_size(width: u32, height: u32) -> u32 {
let d = round_64(sqrt_64(width as f64 * height as f64) / 192.0) as u32;
let mut k = 4 + d;
if k % 2 == 0 {
k += 1;
}
k.max(3)
}
fn median_u8(buf: &[u8]) -> u8 {
let mut hist = [0u32; 256];
for &v in buf {
hist[v as usize] += 1;
}
let half = buf.len() as u32 / 2;
let mut cum = 0u32;
for (i, &c) in hist.iter().enumerate() {
cum += c;
if cum > half {
return i as u8;
}
}
255
}
#[allow(clippy::needless_range_loop)] fn van_herk_1d_contig(src: &[u8], dst: &mut [u8], r: &mut [u8], s: &mut [u8], n: usize, k: usize) {
let half = k / 2;
if n == 0 {
return;
}
if n <= 2 * half {
for p in 0..n {
let lo = p.saturating_sub(half);
let hi = (p + half + 1).min(n);
dst[p] = window_max_contig(src, lo, hi);
}
return;
}
let mut i = 0;
while i < n {
let end = (i + k).min(n);
r[i] = src[i];
for j in (i + 1)..end {
r[j] = r[j - 1].max(src[j]);
}
i = end;
}
let mut i = 0;
while i < n {
let end = (i + k).min(n);
s[end - 1] = src[end - 1];
for j in (i..(end - 1)).rev() {
s[j] = s[j + 1].max(src[j]);
}
i = end;
}
for p in 0..half {
dst[p] = window_max_contig(src, 0, p + half + 1);
}
for p in half..(n - half) {
let l = p - half;
let r_idx = p + half;
dst[p] = s[l].max(r[r_idx]);
}
for p in (n - half)..n {
dst[p] = window_max_contig(src, p - half, n);
}
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::needless_range_loop)]
fn van_herk_1d_column(
src: &[u8],
dst: &mut [u8],
r: &mut [u8],
s: &mut [u8],
x: usize,
w: usize,
h: usize,
k: usize,
) {
let half = k / 2;
if h == 0 {
return;
}
if h <= 2 * half {
for p in 0..h {
let lo = p.saturating_sub(half);
let hi = (p + half + 1).min(h);
dst[p * w + x] = window_max_column(src, lo, hi, x, w);
}
return;
}
let mut i = 0;
while i < h {
let end = (i + k).min(h);
r[i] = src[i * w + x];
for j in (i + 1)..end {
r[j] = r[j - 1].max(src[j * w + x]);
}
i = end;
}
let mut i = 0;
while i < h {
let end = (i + k).min(h);
s[end - 1] = src[(end - 1) * w + x];
for j in (i..(end - 1)).rev() {
s[j] = s[j + 1].max(src[j * w + x]);
}
i = end;
}
for p in 0..half {
dst[p * w + x] = window_max_column(src, 0, p + half + 1, x, w);
}
for p in half..(h - half) {
let l = p - half;
let r_idx = p + half;
dst[p * w + x] = s[l].max(r[r_idx]);
}
for p in (h - half)..h {
dst[p * w + x] = window_max_column(src, p - half, h, x, w);
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn window_max_contig(src: &[u8], lo: usize, hi: usize) -> u8 {
src[lo..hi].iter().copied().max().unwrap_or(0)
}
#[cfg_attr(not(tarpaulin), inline(always))]
fn window_max_column(src: &[u8], lo: usize, hi: usize, x: usize, w: usize) -> u8 {
let mut m = 0u8;
for i in lo..hi {
let v = src[i * w + x];
if v > m {
m = v;
}
}
m
}
#[cfg(all(test, feature = "std"))]
mod tests {
use super::{arch::bgr_to_hsv_pixel, *};
use core::num::NonZeroU32;
use std::vec;
const fn nz32(n: u32) -> NonZeroU32 {
match NonZeroU32::new(n) {
Some(v) => v,
None => panic!("zero"),
}
}
fn tb() -> Timebase {
Timebase::new(1, nz32(1000))
}
fn luma_frame<'a>(data: &'a [u8], w: u32, h: u32, pts: i64) -> LumaFrame<'a> {
LumaFrame::new(data, w, h, w, Timestamp::new(pts, tb()))
}
#[test]
fn components_sum_abs() {
let c = Components::new(1.0, -2.0, 0.5, 0.0);
assert_eq!(c.sum_abs(), 3.5);
}
#[test]
fn components_builders_round_trip() {
let c = Components::new(0.0, 0.0, 0.0, 0.0)
.with_delta_hue(1.0)
.with_delta_sat(2.0)
.with_delta_lum(3.0)
.with_delta_edges(4.0);
assert_eq!(c.delta_hue(), 1.0);
assert_eq!(c.delta_sat(), 2.0);
assert_eq!(c.delta_lum(), 3.0);
assert_eq!(c.delta_edges(), 4.0);
let mut c = Components::default();
c.set_delta_hue(5.0).set_delta_edges(6.0);
assert_eq!(c.delta_hue(), 5.0);
assert_eq!(c.delta_edges(), 6.0);
}
#[test]
fn try_new_rejects_zero_weights() {
let opts = Options::default().with_weights(Components::new(0.0, 0.0, 0.0, 0.0));
let err = Detector::try_new(opts).expect_err("should fail");
assert_eq!(err, Error::ZeroWeights);
}
#[test]
fn try_new_rejects_even_kernel() {
let opts = Options::default().with_kernel_size(Some(4));
let err = Detector::try_new(opts).expect_err("should fail");
assert_eq!(err, Error::InvalidKernelSize(4));
}
#[test]
fn bgr_to_hsv_pure_red() {
let (h, s, v) = bgr_to_hsv_pixel(0.0, 0.0, 255.0);
assert_eq!(h, 0);
assert_eq!(s, 255);
assert_eq!(v, 255);
}
#[test]
fn bgr_to_hsv_pure_green() {
let (h, s, v) = bgr_to_hsv_pixel(0.0, 255.0, 0.0);
assert_eq!(h, 60);
assert_eq!(s, 255);
assert_eq!(v, 255);
}
#[test]
fn bgr_to_hsv_pure_blue() {
let (h, s, v) = bgr_to_hsv_pixel(255.0, 0.0, 0.0);
assert_eq!(h, 120);
assert_eq!(s, 255);
assert_eq!(v, 255);
}
#[test]
fn bgr_to_hsv_grayscale() {
let (h, s, v) = bgr_to_hsv_pixel(128.0, 128.0, 128.0);
assert_eq!(h, 0);
assert_eq!(s, 0);
assert_eq!(v, 128);
}
#[test]
fn bgr_to_hsv_simd_matches_scalar() {
let w = 64u32;
let h = 16u32;
let mut src = vec![0u8; (w * h * 3) as usize];
let mut rng = 0x9E3779B9u32;
for v in src.iter_mut() {
rng = rng.wrapping_mul(1664525).wrapping_add(1013904223);
*v = (rng >> 24) as u8;
}
let corners: &[(u8, u8, u8)] = &[
(0, 0, 255), (0, 255, 0), (255, 0, 0), (0, 0, 0), (255, 255, 255), (128, 128, 128), (0, 255, 255), (255, 0, 255), ];
for (i, &(b, g, r)) in corners.iter().enumerate() {
src[i * 3] = b;
src[i * 3 + 1] = g;
src[i * 3 + 2] = r;
}
let n = (w * h) as usize;
let mut h_simd = vec![0u8; n];
let mut s_simd = vec![0u8; n];
let mut v_simd = vec![0u8; n];
bgr_to_hsv_planes(
&mut h_simd,
&mut s_simd,
&mut v_simd,
&src,
w,
h,
w * 3,
true,
);
let mut h_ref = vec![0u8; n];
let mut s_ref = vec![0u8; n];
let mut v_ref = vec![0u8; n];
for yy in 0..(h as usize) {
for xx in 0..(w as usize) {
let b = src[yy * (w as usize) * 3 + xx * 3] as f32;
let g = src[yy * (w as usize) * 3 + xx * 3 + 1] as f32;
let r = src[yy * (w as usize) * 3 + xx * 3 + 2] as f32;
let (hh, ss, vv) = bgr_to_hsv_pixel(b, g, r);
h_ref[yy * (w as usize) + xx] = hh;
s_ref[yy * (w as usize) + xx] = ss;
v_ref[yy * (w as usize) + xx] = vv;
}
}
assert_eq!(v_simd, v_ref, "V plane diverges");
for (i, (&a, &b)) in s_simd.iter().zip(s_ref.iter()).enumerate() {
let diff = (a as i16 - b as i16).abs();
assert!(diff <= 1, "S diverges at index {i}: simd={a} scalar={b}");
}
for (i, (&a, &b)) in h_simd.iter().zip(h_ref.iter()).enumerate() {
let diff = (a as i16 - b as i16).abs();
assert!(diff <= 1, "H diverges at index {i}: simd={a} scalar={b}");
}
}
#[test]
fn median_u8_basic() {
let v = vec![1u8, 2, 3, 4, 5];
assert_eq!(median_u8(&v), 3);
let v = vec![10u8; 100];
assert_eq!(median_u8(&v), 10);
}
fn naive_dilate(input: &[u8], w: usize, h: usize, k: usize) -> Vec<u8> {
let half = k / 2;
let mut out = vec![0u8; w * h];
for y in 0..h {
for x in 0..w {
let mut m = 0u8;
let yl = y.saturating_sub(half);
let yh = (y + half + 1).min(h);
let xl = x.saturating_sub(half);
let xh = (x + half + 1).min(w);
for yy in yl..yh {
for xx in xl..xh {
let v = input[yy * w + xx];
if v > m {
m = v;
}
}
}
out[y * w + x] = m;
}
}
out
}
#[test]
fn van_herk_dilate_matches_naive_square_input() {
let w = 16usize;
let h = 16usize;
let mut input = vec![0u8; w * h];
for (y, x) in [(0, 0), (0, 15), (15, 0), (15, 15), (7, 7), (3, 11)] {
input[y * w + x] = 255;
}
for &k in &[3usize, 5, 7, 11, 13] {
let mut out = vec![0u8; w * h];
let mut tmp = vec![0u8; w * h];
let mut vh_r = vec![0u8; w.max(h)];
let mut vh_s = vec![0u8; w.max(h)];
test_dilate(&input, &mut out, &mut tmp, &mut vh_r, &mut vh_s, w, h, k);
let expected = naive_dilate(&input, w, h, k);
assert_eq!(out, expected, "van-Herk vs naive mismatch at k={k}");
}
}
#[test]
fn van_herk_dilate_non_square_and_non_multiple_dims() {
let w = 17usize;
let h = 11usize;
let mut input = vec![0u8; w * h];
let mut rng = 0x9E3779B9u32;
for v in input.iter_mut() {
rng = rng.wrapping_mul(1664525).wrapping_add(1013904223);
*v = if rng > 0xC000_0000 { 255 } else { 0 };
}
for &k in &[3usize, 5, 9] {
let mut out = vec![0u8; w * h];
let mut tmp = vec![0u8; w * h];
let mut vh_r = vec![0u8; w.max(h)];
let mut vh_s = vec![0u8; w.max(h)];
test_dilate(&input, &mut out, &mut tmp, &mut vh_r, &mut vh_s, w, h, k);
let expected = naive_dilate(&input, w, h, k);
assert_eq!(
out, expected,
"van-Herk vs naive mismatch at k={k}, dims {w}x{h}"
);
}
}
#[allow(clippy::too_many_arguments)]
fn test_dilate(
input: &[u8],
out: &mut [u8],
tmp: &mut [u8],
vh_r: &mut [u8],
vh_s: &mut [u8],
w: usize,
h: usize,
k: usize,
) {
for y in 0..h {
let row_in = &input[y * w..y * w + w];
let row_out = &mut tmp[y * w..y * w + w];
van_herk_1d_contig(row_in, row_out, vh_r, vh_s, w, k);
}
for x in 0..w {
van_herk_1d_column(tmp, out, vh_r, vh_s, x, w, h, k);
}
}
#[test]
fn auto_kernel_size_reasonable() {
assert_eq!(auto_kernel_size(1920, 1080), 13);
assert_eq!(auto_kernel_size(1280, 720), 9);
assert_eq!(auto_kernel_size(640, 360), 7);
}
#[test]
fn identical_luma_frames_zero_score() {
let opts = Options::default()
.with_weights(LUMA_ONLY_WEIGHTS)
.with_min_duration(Duration::from_millis(0));
let mut det = Detector::new(opts);
let buf = vec![128u8; 32 * 32];
assert!(det.process_luma(luma_frame(&buf, 32, 32, 0)).is_none());
assert!(det.process_luma(luma_frame(&buf, 32, 32, 33)).is_none());
assert_eq!(det.last_score(), Some(0.0));
}
#[test]
fn very_different_luma_frames_exceed_threshold() {
let opts = Options::default()
.with_weights(LUMA_ONLY_WEIGHTS)
.with_min_duration(Duration::from_millis(0))
.with_threshold(10.0); let mut det = Detector::new(opts);
let a = vec![0u8; 32 * 32];
let b = vec![255u8; 32 * 32];
det.process_luma(luma_frame(&a, 32, 32, 0));
let cut = det.process_luma(luma_frame(&b, 32, 32, 33));
assert!(
cut.is_some(),
"black→white at 32×32 should exceed threshold=10"
);
}
#[test]
fn initial_cut_true_emits_first_detected_cut() {
let opts = Options::default()
.with_weights(LUMA_ONLY_WEIGHTS)
.with_threshold(10.0)
.with_initial_cut(true);
let mut det = Detector::new(opts);
let a = vec![0u8; 32 * 32];
let b = vec![255u8; 32 * 32];
det.process_luma(luma_frame(&a, 32, 32, 0));
let cut = det.process_luma(luma_frame(&b, 32, 32, 33));
assert!(cut.is_some(), "first cut should fire with initial_cut=true");
}
#[test]
fn initial_cut_false_suppresses_first_detected_cut() {
let opts = Options::default()
.with_weights(LUMA_ONLY_WEIGHTS)
.with_threshold(10.0)
.with_filter_mode(FilterMode::Suppress)
.with_initial_cut(false);
let mut det = Detector::new(opts);
let a = vec![0u8; 32 * 32];
let b = vec![255u8; 32 * 32];
det.process_luma(luma_frame(&a, 32, 32, 0));
let cut = det.process_luma(luma_frame(&b, 32, 32, 33));
assert!(
cut.is_none(),
"first cut should be suppressed with initial_cut=false"
);
}
#[test]
fn clear_resets_state() {
let opts = Options::default()
.with_weights(LUMA_ONLY_WEIGHTS)
.with_threshold(10.0)
.with_min_duration(Duration::from_millis(0));
let mut det = Detector::new(opts);
let a = vec![0u8; 32 * 32];
let b = vec![255u8; 32 * 32];
det.process_luma(luma_frame(&a, 32, 32, 0));
det.process_luma(luma_frame(&b, 32, 32, 33));
assert!(det.last_score().is_some());
det.clear();
assert!(det.last_score().is_none());
assert!(
det
.process_luma(luma_frame(&a, 32, 32, 1_000_000))
.is_none()
);
}
#[test]
fn resize_clears_last_score_and_components() {
let opts = Options::default()
.with_weights(LUMA_ONLY_WEIGHTS)
.with_min_duration(Duration::from_millis(0));
let mut det = Detector::new(opts);
let a = vec![0u8; 32 * 32];
let b = vec![255u8; 32 * 32];
det.process_luma(luma_frame(&a, 32, 32, 0));
det.process_luma(luma_frame(&b, 32, 32, 33));
assert!(det.last_score().is_some_and(|s| s > 0.0));
assert!(det.last_components().is_some());
let c = vec![128u8; 16 * 16];
det.process_luma(luma_frame(&c, 16, 16, 66));
assert!(
det.last_score().is_none(),
"resize must clear last_score — previous value was for old resolution"
);
assert!(det.last_components().is_none());
}
#[test]
fn zero_sized_frame_with_edges_does_not_panic() {
let opts = Options::default().with_weights(Components::new(1.0, 1.0, 1.0, 1.0));
let mut det = Detector::new(opts);
let empty: Vec<u8> = vec![];
det.process_luma(luma_frame(&empty, 0, 0, 0));
det.process_luma(luma_frame(&empty, 0, 0, 33));
let one = vec![128u8];
det.process_luma(luma_frame(&one, 1, 1, 66));
det.process_luma(luma_frame(&one, 1, 1, 99));
}
#[test]
fn components_builders_setters_and_sum_abs() {
let c = Components::new(1.0, -2.0, 3.5, -0.5);
assert_eq!(c.delta_hue(), 1.0);
assert_eq!(c.delta_sat(), -2.0);
assert_eq!(c.delta_lum(), 3.5);
assert_eq!(c.delta_edges(), -0.5);
assert_eq!(c.sum_abs(), 1.0 + 2.0 + 3.5 + 0.5);
assert_eq!(Components::default(), DEFAULT_WEIGHTS);
let built = Components::default()
.with_delta_hue(0.1)
.with_delta_sat(0.2)
.with_delta_lum(0.3)
.with_delta_edges(0.4);
assert_eq!(built.delta_hue(), 0.1);
assert_eq!(built.delta_sat(), 0.2);
assert_eq!(built.delta_lum(), 0.3);
assert_eq!(built.delta_edges(), 0.4);
let mut c = Components::default();
c.set_delta_hue(9.0)
.set_delta_sat(8.0)
.set_delta_lum(7.0)
.set_delta_edges(6.0);
assert_eq!(c, Components::new(9.0, 8.0, 7.0, 6.0));
}
#[test]
fn filter_mode_as_str_all_variants() {
assert_eq!(FilterMode::Suppress.as_str(), "suppress");
assert_eq!(FilterMode::Merge.as_str(), "merge");
assert_eq!(FilterMode::default(), FilterMode::Merge);
assert_eq!(format!("{}", FilterMode::Suppress), "suppress");
assert_eq!(format!("{}", FilterMode::Merge), "merge");
}
#[test]
fn options_accessors_builders_setters_roundtrip() {
let fps30 = Timebase::new(30, nz32(1));
let weights = Components::new(0.1, 0.2, 0.3, 0.4);
let opts = Options::default()
.with_threshold(42.0)
.with_min_duration(Duration::from_millis(333))
.with_weights(weights)
.with_filter_mode(FilterMode::Suppress)
.with_kernel_size(Some(7))
.with_initial_cut(false)
.with_simd(false);
assert_eq!(opts.threshold(), 42.0);
assert_eq!(opts.min_duration(), Duration::from_millis(333));
assert_eq!(opts.weights(), weights);
assert_eq!(opts.filter_mode(), FilterMode::Suppress);
assert_eq!(opts.kernel_size(), Some(7));
assert!(!opts.initial_cut());
assert!(!opts.simd());
let opts_frames = Options::default().with_min_frames(30, fps30);
assert_eq!(opts_frames.min_duration(), Duration::from_secs(1));
let mut opts = Options::default();
opts
.set_threshold(15.0)
.set_min_duration(Duration::from_secs(2))
.set_weights(LUMA_ONLY_WEIGHTS)
.set_filter_mode(FilterMode::Merge)
.set_kernel_size(None)
.set_initial_cut(true)
.set_simd(true);
assert_eq!(opts.threshold(), 15.0);
assert_eq!(opts.weights(), LUMA_ONLY_WEIGHTS);
assert_eq!(opts.filter_mode(), FilterMode::Merge);
assert_eq!(opts.kernel_size(), None);
assert!(opts.initial_cut());
assert!(opts.simd());
opts.set_min_frames(60, fps30);
assert_eq!(opts.min_duration(), Duration::from_secs(2));
}
#[test]
fn detector_options_and_component_accessors() {
let opts = Options::default()
.with_weights(LUMA_ONLY_WEIGHTS)
.with_min_duration(Duration::from_millis(0));
let mut det = Detector::new(opts.clone());
assert_eq!(det.options().threshold(), opts.threshold());
assert!(det.last_score().is_none());
assert!(det.last_components().is_none());
let a = vec![0u8; 32 * 32];
let b = vec![255u8; 32 * 32];
det.process_luma(luma_frame(&a, 32, 32, 0));
det.process_luma(luma_frame(&b, 32, 32, 33));
assert!(det.last_score().is_some());
assert!(det.last_components().is_some());
}
#[test]
fn process_bgr_and_process_hsv_accept_frames() {
use crate::frame::{HsvFrame, RgbFrame};
let tb = Timebase::new(1, nz32(1000));
let opts = Options::default().with_min_duration(Duration::from_millis(0));
let mut det = Detector::new(opts);
let bgr = vec![64u8; 32 * 32 * 3];
det.process_bgr(RgbFrame::new(&bgr, 32, 32, 32 * 3, Timestamp::new(0, tb)));
det.process_bgr(RgbFrame::new(&bgr, 32, 32, 32 * 3, Timestamp::new(33, tb)));
assert!(det.last_score().is_some());
det.clear();
let h = vec![30u8; 32 * 32];
let s = vec![40u8; 32 * 32];
let v = vec![50u8; 32 * 32];
det.process_hsv(HsvFrame::new(&h, &s, &v, 32, 32, 32, Timestamp::new(0, tb)));
det.process_hsv(HsvFrame::new(
&h,
&s,
&v,
32,
32,
32,
Timestamp::new(33, tb),
));
assert!(det.last_score().is_some());
}
#[test]
fn edges_enabled_runs_full_pipeline() {
let opts = Options::default()
.with_weights(Components::new(1.0, 1.0, 1.0, 1.0))
.with_min_duration(Duration::from_millis(0))
.with_kernel_size(Some(3));
let mut det = Detector::new(opts);
let mut a = vec![0u8; 32 * 32];
let mut b = vec![0u8; 32 * 32];
for (i, slot) in a.iter_mut().enumerate() {
*slot = if (i % 2) == 0 { 255 } else { 0 };
}
for (i, slot) in b.iter_mut().enumerate() {
*slot = if (i % 2) == 0 { 0 } else { 255 };
}
det.process_luma(luma_frame(&a, 32, 32, 0));
det.process_luma(luma_frame(&b, 32, 32, 33));
let comps = det.last_components().expect("components after two frames");
assert!(comps.delta_edges() > 0.0 || comps.delta_edges() == 0.0); }
#[test]
fn filter_mode_suppress_emits_above_threshold_after_min_duration() {
let opts = Options::default()
.with_weights(LUMA_ONLY_WEIGHTS)
.with_threshold(10.0)
.with_filter_mode(FilterMode::Suppress)
.with_min_duration(Duration::from_millis(0));
let mut det = Detector::new(opts);
let a = vec![0u8; 32 * 32];
let b = vec![255u8; 32 * 32];
det.process_luma(luma_frame(&a, 32, 32, 0));
let cut = det.process_luma(luma_frame(&b, 32, 32, 33));
assert!(
cut.is_some(),
"Suppress mode should emit above-threshold cut when gate met"
);
}
#[test]
fn error_display_messages() {
let e = Error::ZeroWeights;
assert!(format!("{e}").contains("zero"));
let e = Error::InvalidKernelSize(4);
assert!(format!("{e}").contains("4"));
}
#[test]
fn nms_exercises_diagonal_direction_arms() {
let mut a = vec![0u8; 8 * 8];
let mut b = vec![0u8; 8 * 8];
for y in 0..8 {
for x in 0..8 {
a[y * 8 + x] = ((x + y) * 16).min(255) as u8;
b[y * 8 + x] = ((7 - x + y) * 16).min(255) as u8;
}
}
let opts = Options::default()
.with_weights(Components::new(1.0, 1.0, 1.0, 1.0))
.with_min_duration(Duration::from_millis(0))
.with_kernel_size(Some(3));
let mut det = Detector::new(opts);
det.process_luma(luma_frame(&a, 8, 8, 0));
det.process_luma(luma_frame(&b, 8, 8, 33));
assert!(det.last_components().is_some());
}
#[test]
fn hysteresis_propagates_weak_pixels_through_both_passes() {
let mut a = vec![0u8; 16 * 16];
for y in 0..16 {
for x in 0..16 {
a[y * 16 + x] = (x * 16) as u8;
}
}
let mut b = vec![0u8; 16 * 16];
for y in 0..16 {
for x in 0..16 {
b[y * 16 + x] = (y * 16) as u8;
}
}
let opts = Options::default()
.with_weights(Components::new(1.0, 1.0, 1.0, 1.0))
.with_min_duration(Duration::from_millis(0))
.with_kernel_size(Some(3));
let mut det = Detector::new(opts);
det.process_luma(luma_frame(&a, 16, 16, 0));
det.process_luma(luma_frame(&b, 16, 16, 33));
let comps = det.last_components().expect("two frames → components set");
assert!(comps.delta_edges() >= 0.0);
}
#[test]
fn van_herk_short_path_triggered_by_small_frame_large_kernel() {
let a = vec![0u8; 9];
let b = vec![255u8; 9];
let opts = Options::default()
.with_weights(Components::new(1.0, 1.0, 1.0, 1.0))
.with_min_duration(Duration::from_millis(0))
.with_kernel_size(Some(5));
let mut det = Detector::new(opts);
det.process_luma(luma_frame(&a, 3, 3, 0));
det.process_luma(luma_frame(&b, 3, 3, 33));
assert!(det.last_score().is_some());
}
#[test]
fn merge_filter_holds_then_releases_cut_on_quiet_frame() {
let opts = Options::default()
.with_weights(LUMA_ONLY_WEIGHTS)
.with_threshold(10.0)
.with_filter_mode(FilterMode::Merge)
.with_min_duration(Duration::from_millis(100));
let mut det = Detector::new(opts);
let dim = vec![0u8; 32 * 32];
let bright = vec![255u8; 32 * 32];
det.process_luma(luma_frame(&dim, 32, 32, 0));
det.process_luma(luma_frame(&bright, 32, 32, 33));
det.process_luma(luma_frame(&bright, 32, 32, 66));
let _ = det.process_luma(luma_frame(&dim, 32, 32, 166));
assert!(det.last_score().is_some());
}
#[test]
fn scalar_dispatch_bgr_no_edges() {
let opts = Options::default()
.with_min_duration(Duration::from_millis(0))
.with_simd(false);
let mut det = Detector::new(opts);
let a = vec![64u8; 32 * 32 * 3];
let b = vec![200u8; 32 * 32 * 3];
let tb = Timebase::new(1, core::num::NonZeroU32::new(1000).unwrap());
det.process_bgr(RgbFrame::new(&a, 32, 32, 96, Timestamp::new(0, tb)));
det.process_bgr(RgbFrame::new(&b, 32, 32, 96, Timestamp::new(33, tb)));
assert!(det.last_score().is_some());
}
#[test]
fn scalar_dispatch_bgr_with_edges() {
let opts = Options::default()
.with_weights(Components::new(1.0, 1.0, 1.0, 1.0))
.with_min_duration(Duration::from_millis(0))
.with_kernel_size(Some(3))
.with_simd(false);
let mut det = Detector::new(opts);
let mut a = vec![0u8; 16 * 16 * 3];
let mut b = vec![0u8; 16 * 16 * 3];
for (i, v) in a.iter_mut().enumerate() {
*v = ((i * 7) % 256) as u8;
}
for (i, v) in b.iter_mut().enumerate() {
*v = ((i * 13 + 100) % 256) as u8;
}
let tb = Timebase::new(1, core::num::NonZeroU32::new(1000).unwrap());
det.process_bgr(RgbFrame::new(&a, 16, 16, 48, Timestamp::new(0, tb)));
det.process_bgr(RgbFrame::new(&b, 16, 16, 48, Timestamp::new(33, tb)));
assert!(det.last_score().is_some());
assert!(det.last_components().expect("components").delta_edges() >= 0.0);
}
#[test]
fn scalar_dispatch_luma_only() {
let opts = Options::default()
.with_weights(LUMA_ONLY_WEIGHTS)
.with_min_duration(Duration::from_millis(0))
.with_simd(false);
let mut det = Detector::new(opts);
let a = vec![0u8; 32 * 32];
let b = vec![255u8; 32 * 32];
det.process_luma(luma_frame(&a, 32, 32, 0));
det.process_luma(luma_frame(&b, 32, 32, 33));
assert!(det.last_score().is_some());
}
}