visual-cortex-vision 0.12.0

Detectors for visual-cortex: pixel/color conditions, template matching, the OcrEngine contract, and OCR text parsers.
Documentation
//! Region content fingerprinting (issue #17): the stability mask's 17-byte
//! block signature exposed as a standalone detector for "did the content in
//! this region actually change?" watching (slot icons, portraits).

use visual_cortex_capture::FrameView;

use crate::detector::{Detector, DetectorError, DetectorOutput};
use crate::stability_mask::{is_moving, Signature, SIG_LEN};

/// Compact content fingerprint over the whole watched region: a 4x4 grid of
/// sub-cell mean lumas plus a contrast byte (the same signature the
/// stability mask compares blocks with, scaled to the region).
///
/// Emits the signature as lowercase hex `DetectorOutput::Text`. Comparison
/// is tolerance-aware and ANCHORED: while the current signature stays
/// within `tolerance` of the last *changed-to* content (fewer than two
/// components moving beyond it), the previous output is re-emitted
/// verbatim — capture jitter and sub-tolerance drift can never fire
/// `Changed` events, and there is no quantization-boundary flicker.
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(),
        }
    }

    /// Per-component comparison tolerance on the 0-255 signature bytes
    /// (default 10). At least two components must exceed it for the
    /// fingerprint to count as changed.
    pub fn tolerance(mut self, tol: u8) -> Self {
        self.tolerance = tol;
        self
    }
}

/// One 17-byte signature over an arbitrary-sized view: 4x4 sub-cells with
/// per-axis scaling (non-square regions keep their structure) + contrast.
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() {
        // Drift of +6 luma per tick: each step is within tolerance of the
        // PREVIOUS tick but must eventually exceed the anchor.
        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() {
        // Left half dark, right half bright, on a wide region: sub-cells
        // must capture the split (per-axis scaling, not max-dim blocks).
        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");
    }
}