use visual_cortex_capture::FrameView;
use crate::detector::{Detector, DetectorError, DetectorOutput};
use crate::stability_mask::{is_moving, Signature, SIG_LEN};
pub struct ContentSignature {
tolerance: u8,
anchor: Option<Signature>,
out: String,
}
impl Default for ContentSignature {
fn default() -> Self {
Self::new()
}
}
impl ContentSignature {
pub fn new() -> Self {
Self {
tolerance: 10,
anchor: None,
out: String::new(),
}
}
pub fn tolerance(mut self, tol: u8) -> Self {
self.tolerance = tol;
self
}
}
fn region_signature(view: &FrameView<'_>) -> Signature {
let (w, h) = (view.width() as usize, view.height() as usize);
let mut sub_sum = [0u64; 16];
let mut sub_cnt = [0u64; 16];
let mut sum = 0u64;
let mut sum_sq = 0u64;
let mut cnt = 0u64;
for (y, row) in view.rows().enumerate() {
let sr = (y * 4) / h.max(1);
for (x, px) in row.chunks_exact(4).enumerate() {
let sc = (x * 4) / w.max(1);
let luma = (px[2] as u32 * 299 + px[1] as u32 * 587 + px[0] as u32 * 114) / 1000;
let si = sr.min(3) * 4 + sc.min(3);
sub_sum[si] += luma as u64;
sub_cnt[si] += 1;
sum += luma as u64;
sum_sq += (luma as u64) * (luma as u64);
cnt += 1;
}
}
let mut sig = [0u8; SIG_LEN];
let mean = sum.checked_div(cnt).unwrap_or(0) as u8;
for i in 0..16 {
sig[i] = sub_sum[i].checked_div(sub_cnt[i]).map_or(mean, |v| v as u8);
}
sig[16] = if cnt == 0 {
0
} else {
let m = sum as f64 / cnt as f64;
let var = (sum_sq as f64 / cnt as f64) - m * m;
var.max(0.0).sqrt().min(255.0) as u8
};
Signature(sig)
}
fn hex(sig: &Signature) -> String {
let mut s = String::with_capacity(SIG_LEN * 2);
for b in sig.0 {
use std::fmt::Write;
let _ = write!(s, "{b:02x}");
}
s
}
impl Detector for ContentSignature {
fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError> {
let sig = region_signature(view);
let changed = match &self.anchor {
Some(anchor) => is_moving(&sig, anchor, self.tolerance),
None => true,
};
if changed {
self.out = hex(&sig);
self.anchor = Some(sig);
}
Ok(DetectorOutput::Text(self.out.clone()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use visual_cortex_capture::{Frame, PxRect};
fn eval(det: &mut ContentSignature, frame: &Frame) -> String {
let view = frame
.view(PxRect {
x: 0,
y: 0,
w: frame.width(),
h: frame.height(),
})
.unwrap();
match det.evaluate(&view).unwrap() {
DetectorOutput::Text(t) => t,
other => panic!("expected Text, got {other:?}"),
}
}
#[test]
fn jitter_within_tolerance_is_invisible() {
let mut det = ContentSignature::new();
let a = Frame::solid(16, 16, [100, 100, 100, 255]);
let jitter = Frame::solid(16, 16, [106, 106, 106, 255]);
let first = eval(&mut det, &a);
assert_eq!(eval(&mut det, &jitter), first, "jitter re-emits verbatim");
assert_eq!(eval(&mut det, &a), first);
}
#[test]
fn content_swap_changes_the_fingerprint() {
let mut det = ContentSignature::new();
let a = Frame::solid(16, 16, [40, 40, 40, 255]);
let b = Frame::solid(16, 16, [200, 200, 200, 255]);
let fa = eval(&mut det, &a);
let fb = eval(&mut det, &b);
assert_ne!(fa, fb, "swap must fire");
}
#[test]
fn cumulative_drift_is_anchored_not_chained() {
let mut det = ContentSignature::new();
let first = eval(&mut det, &Frame::solid(16, 16, [60, 60, 60, 255]));
let mut changed_at = None;
for i in 1..8u8 {
let l = 60 + 6 * i;
let out = eval(&mut det, &Frame::solid(16, 16, [l, l, l, 255]));
if out != first {
changed_at = Some(i);
break;
}
}
assert!(changed_at.is_some(), "anchored drift must eventually fire");
assert!(changed_at.unwrap() >= 2, "but not on sub-tolerance steps");
}
#[test]
fn non_square_regions_keep_horizontal_structure() {
let wide = Frame::from_fn(64, 8, |x, _| {
if x < 32 {
[20, 20, 20, 255]
} else {
[220, 220, 220, 255]
}
});
let view = wide
.view(PxRect {
x: 0,
y: 0,
w: 64,
h: 8,
})
.unwrap();
let sig = region_signature(&view);
assert!(sig.0[0] < 40 && sig.0[1] < 40, "left sub-cells dark");
assert!(sig.0[2] > 200 && sig.0[3] > 200, "right sub-cells bright");
}
}