1use visual_cortex_capture::FrameView;
6
7use crate::detector::{Detector, DetectorError, DetectorOutput};
8use crate::stability_mask::{is_moving, Signature, SIG_LEN};
9
10pub struct ContentSignature {
21 tolerance: u8,
22 anchor: Option<Signature>,
23 out: String,
24}
25
26impl Default for ContentSignature {
27 fn default() -> Self {
28 Self::new()
29 }
30}
31
32impl ContentSignature {
33 pub fn new() -> Self {
34 Self {
35 tolerance: 10,
36 anchor: None,
37 out: String::new(),
38 }
39 }
40
41 pub fn tolerance(mut self, tol: u8) -> Self {
45 self.tolerance = tol;
46 self
47 }
48}
49
50fn region_signature(view: &FrameView<'_>) -> Signature {
53 let (w, h) = (view.width() as usize, view.height() as usize);
54 let mut sub_sum = [0u64; 16];
55 let mut sub_cnt = [0u64; 16];
56 let mut sum = 0u64;
57 let mut sum_sq = 0u64;
58 let mut cnt = 0u64;
59 for (y, row) in view.rows().enumerate() {
60 let sr = (y * 4) / h.max(1);
61 for (x, px) in row.chunks_exact(4).enumerate() {
62 let sc = (x * 4) / w.max(1);
63 let luma = (px[2] as u32 * 299 + px[1] as u32 * 587 + px[0] as u32 * 114) / 1000;
64 let si = sr.min(3) * 4 + sc.min(3);
65 sub_sum[si] += luma as u64;
66 sub_cnt[si] += 1;
67 sum += luma as u64;
68 sum_sq += (luma as u64) * (luma as u64);
69 cnt += 1;
70 }
71 }
72 let mut sig = [0u8; SIG_LEN];
73 let mean = sum.checked_div(cnt).unwrap_or(0) as u8;
74 for i in 0..16 {
75 sig[i] = sub_sum[i].checked_div(sub_cnt[i]).map_or(mean, |v| v as u8);
76 }
77 sig[16] = if cnt == 0 {
78 0
79 } else {
80 let m = sum as f64 / cnt as f64;
81 let var = (sum_sq as f64 / cnt as f64) - m * m;
82 var.max(0.0).sqrt().min(255.0) as u8
83 };
84 Signature(sig)
85}
86
87fn hex(sig: &Signature) -> String {
88 let mut s = String::with_capacity(SIG_LEN * 2);
89 for b in sig.0 {
90 use std::fmt::Write;
91 let _ = write!(s, "{b:02x}");
92 }
93 s
94}
95
96impl Detector for ContentSignature {
97 fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError> {
98 let sig = region_signature(view);
99 let changed = match &self.anchor {
100 Some(anchor) => is_moving(&sig, anchor, self.tolerance),
101 None => true,
102 };
103 if changed {
104 self.out = hex(&sig);
105 self.anchor = Some(sig);
106 }
107 Ok(DetectorOutput::Text(self.out.clone()))
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use visual_cortex_capture::{Frame, PxRect};
115
116 fn eval(det: &mut ContentSignature, frame: &Frame) -> String {
117 let view = frame
118 .view(PxRect {
119 x: 0,
120 y: 0,
121 w: frame.width(),
122 h: frame.height(),
123 })
124 .unwrap();
125 match det.evaluate(&view).unwrap() {
126 DetectorOutput::Text(t) => t,
127 other => panic!("expected Text, got {other:?}"),
128 }
129 }
130
131 #[test]
132 fn jitter_within_tolerance_is_invisible() {
133 let mut det = ContentSignature::new();
134 let a = Frame::solid(16, 16, [100, 100, 100, 255]);
135 let jitter = Frame::solid(16, 16, [106, 106, 106, 255]);
136 let first = eval(&mut det, &a);
137 assert_eq!(eval(&mut det, &jitter), first, "jitter re-emits verbatim");
138 assert_eq!(eval(&mut det, &a), first);
139 }
140
141 #[test]
142 fn content_swap_changes_the_fingerprint() {
143 let mut det = ContentSignature::new();
144 let a = Frame::solid(16, 16, [40, 40, 40, 255]);
145 let b = Frame::solid(16, 16, [200, 200, 200, 255]);
146 let fa = eval(&mut det, &a);
147 let fb = eval(&mut det, &b);
148 assert_ne!(fa, fb, "swap must fire");
149 }
150
151 #[test]
152 fn cumulative_drift_is_anchored_not_chained() {
153 let mut det = ContentSignature::new();
156 let first = eval(&mut det, &Frame::solid(16, 16, [60, 60, 60, 255]));
157 let mut changed_at = None;
158 for i in 1..8u8 {
159 let l = 60 + 6 * i;
160 let out = eval(&mut det, &Frame::solid(16, 16, [l, l, l, 255]));
161 if out != first {
162 changed_at = Some(i);
163 break;
164 }
165 }
166 assert!(changed_at.is_some(), "anchored drift must eventually fire");
167 assert!(changed_at.unwrap() >= 2, "but not on sub-tolerance steps");
168 }
169
170 #[test]
171 fn non_square_regions_keep_horizontal_structure() {
172 let wide = Frame::from_fn(64, 8, |x, _| {
175 if x < 32 {
176 [20, 20, 20, 255]
177 } else {
178 [220, 220, 220, 255]
179 }
180 });
181 let view = wide
182 .view(PxRect {
183 x: 0,
184 y: 0,
185 w: 64,
186 h: 8,
187 })
188 .unwrap();
189 let sig = region_signature(&view);
190 assert!(sig.0[0] < 40 && sig.0[1] < 40, "left sub-cells dark");
191 assert!(sig.0[2] > 200 && sig.0[3] > 200, "right sub-cells bright");
192 }
193}