Skip to main content

visual_cortex_vision/
content_signature.rs

1//! Region content fingerprinting (issue #17): the stability mask's 17-byte
2//! block signature exposed as a standalone detector for "did the content in
3//! this region actually change?" watching (slot icons, portraits).
4
5use visual_cortex_capture::FrameView;
6
7use crate::detector::{Detector, DetectorError, DetectorOutput};
8use crate::stability_mask::{is_moving, Signature, SIG_LEN};
9
10/// Compact content fingerprint over the whole watched region: a 4x4 grid of
11/// sub-cell mean lumas plus a contrast byte (the same signature the
12/// stability mask compares blocks with, scaled to the region).
13///
14/// Emits the signature as lowercase hex `DetectorOutput::Text`. Comparison
15/// is tolerance-aware and ANCHORED: while the current signature stays
16/// within `tolerance` of the last *changed-to* content (fewer than two
17/// components moving beyond it), the previous output is re-emitted
18/// verbatim — capture jitter and sub-tolerance drift can never fire
19/// `Changed` events, and there is no quantization-boundary flicker.
20pub 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    /// Per-component comparison tolerance on the 0-255 signature bytes
42    /// (default 10). At least two components must exceed it for the
43    /// fingerprint to count as changed.
44    pub fn tolerance(mut self, tol: u8) -> Self {
45        self.tolerance = tol;
46        self
47    }
48}
49
50/// One 17-byte signature over an arbitrary-sized view: 4x4 sub-cells with
51/// per-axis scaling (non-square regions keep their structure) + contrast.
52fn 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        // Drift of +6 luma per tick: each step is within tolerance of the
154        // PREVIOUS tick but must eventually exceed the anchor.
155        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        // Left half dark, right half bright, on a wide region: sub-cells
173        // must capture the split (per-axis scaling, not max-dim blocks).
174        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}