visual-cortex-vision 0.10.0

Detectors for visual-cortex: pixel/color conditions, template matching, the OcrEngine contract, and OCR text parsers.
Documentation
use visual_cortex_capture::FrameView;

use crate::detector::{Detector, DetectorError, DetectorOutput};
use crate::luma::view_to_luma;

/// Variances below this are treated as "flat" (degenerate for NCC).
const FLAT_EPSILON: f64 = 1e-6;
/// Mean-luma difference (0..255 scale) considered equal for flat comparisons.
const FLAT_MEAN_TOLERANCE: f64 = 2.0;

/// Grayscale normalized cross-correlation template matcher.
///
/// `evaluate` slides the template over the region and returns the best match
/// as `DetectorOutput::Score` in `0.0..=1.0` (negative correlation clamps to
/// 0). Naive O(region * template) search — fine for the small regions and
/// icons this library targets; optimize (integral images / FFT) only if
/// profiling ever demands it.
///
/// Degenerate (flat) inputs fall back to mean comparison: a flat template
/// scores 1.0 on a window of the same mean brightness, else 0.0.
///
/// NCC is contrast-invariant: a dim copy of the pattern scores as high as a
/// bright one, so the score says nothing about absolute brightness/contrast.
/// A template larger than the watched region scores 0.0 ("not found") rather
/// than erroring — check your region size if a template watcher never fires.
pub struct TemplateMatcher {
    width: u32,
    height: u32,
    mean: f64,
    /// Template luma with the mean subtracted, row-major.
    centered: Vec<f64>,
    /// sqrt(sum(centered^2)) — 0.0 for a flat template.
    norm: f64,
}

impl TemplateMatcher {
    /// Build from a row-major grayscale buffer. Errors if `luma.len()` does
    /// not equal `width * height` or either dimension is zero.
    pub fn from_luma(width: u32, height: u32, luma: Vec<u8>) -> Result<Self, DetectorError> {
        let expected = width as usize * height as usize;
        if width == 0 || height == 0 || luma.len() != expected {
            return Err(DetectorError::Other(format!(
                "template must be non-empty and {width}x{height} = {expected} bytes, got {}",
                luma.len()
            )));
        }
        let mean = luma.iter().map(|&p| p as f64).sum::<f64>() / expected as f64;
        let centered: Vec<f64> = luma.iter().map(|&p| p as f64 - mean).collect();
        let norm = centered.iter().map(|d| d * d).sum::<f64>().sqrt();
        Ok(Self {
            width,
            height,
            mean,
            centered,
            norm,
        })
    }

    /// Build from encoded image bytes (PNG). Color images are converted to
    /// grayscale; matching is luma-only.
    /// Alpha is ignored (not composited); export icons with an opaque background.
    pub fn from_png_bytes(bytes: &[u8]) -> Result<Self, DetectorError> {
        let img = image::load_from_memory(bytes)
            .map_err(|e| DetectorError::Other(format!("template decode failed: {e}")))?
            .to_luma8();
        let (w, h) = img.dimensions();
        Self::from_luma(w, h, img.into_raw())
    }

    fn best_score(&self, region: &[u8], rw: usize, rh: usize) -> f32 {
        let (tw, th) = (self.width as usize, self.height as usize);
        if tw > rw || th > rh {
            return 0.0;
        }
        let area = (tw * th) as f64;
        let mut best = 0.0f64;
        for oy in 0..=(rh - th) {
            for ox in 0..=(rw - tw) {
                let mut sum = 0u64;
                for y in 0..th {
                    let start = (oy + y) * rw + ox;
                    for &p in &region[start..start + tw] {
                        sum += p as u64;
                    }
                }
                let wmean = sum as f64 / area;
                let mut dot = 0.0f64;
                let mut wnorm2 = 0.0f64;
                for y in 0..th {
                    let start = (oy + y) * rw + ox;
                    for x in 0..tw {
                        let wv = region[start + x] as f64 - wmean;
                        dot += self.centered[y * tw + x] * wv;
                        wnorm2 += wv * wv;
                    }
                }
                let wnorm = wnorm2.sqrt();
                let score = if self.norm < FLAT_EPSILON && wnorm < FLAT_EPSILON {
                    // Both flat: match iff the brightness matches.
                    if (self.mean - wmean).abs() <= FLAT_MEAN_TOLERANCE {
                        1.0
                    } else {
                        0.0
                    }
                } else if self.norm < FLAT_EPSILON || wnorm < FLAT_EPSILON {
                    // One flat, one textured: no meaningful correlation.
                    0.0
                } else {
                    (dot / (self.norm * wnorm)).max(0.0)
                };
                if score > best {
                    best = score;
                }
            }
        }
        best.min(1.0) as f32
    }
}

impl Detector for TemplateMatcher {
    fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError> {
        let (region, w, h) = view_to_luma(view);
        Ok(DetectorOutput::Score(
            self.best_score(&region, w as usize, h as usize),
        ))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use visual_cortex_capture::{Frame, PxRect};

    fn full_view(frame: &Frame) -> FrameView<'_> {
        frame
            .view(PxRect {
                x: 0,
                y: 0,
                w: frame.width(),
                h: frame.height(),
            })
            .unwrap()
    }

    /// 4x4 black/white checkerboard as raw luma.
    fn checker_luma() -> Vec<u8> {
        (0..16u32)
            .map(|i| {
                let (x, y) = (i % 4, i / 4);
                if (x + y) % 2 == 0 {
                    255
                } else {
                    0
                }
            })
            .collect()
    }

    /// 12x10 dark frame with the checkerboard stamped at (5, 3).
    fn frame_with_icon() -> Frame {
        Frame::from_fn(12, 10, |x, y| {
            if (5..9).contains(&x) && (3..7).contains(&y) {
                if ((x - 5) + (y - 3)) % 2 == 0 {
                    [255, 255, 255, 255]
                } else {
                    [0, 0, 0, 255]
                }
            } else {
                [30, 30, 30, 255]
            }
        })
    }

    #[test]
    fn exact_match_scores_one() {
        let mut det = TemplateMatcher::from_luma(4, 4, checker_luma()).unwrap();
        let f = frame_with_icon();
        let DetectorOutput::Score(s) = det.evaluate(&full_view(&f)).unwrap() else {
            panic!("expected Score");
        };
        assert!(s > 0.99, "expected near-perfect match, got {s}");
        assert!(s <= 1.0);
    }

    #[test]
    fn absent_template_scores_low() {
        let mut det = TemplateMatcher::from_luma(4, 4, checker_luma()).unwrap();
        let f = Frame::solid(12, 10, [30, 30, 30, 255]);
        let DetectorOutput::Score(s) = det.evaluate(&full_view(&f)).unwrap() else {
            panic!("expected Score");
        };
        assert!(s < 0.1, "uniform region must not match, got {s}");
    }

    #[test]
    fn template_larger_than_region_scores_zero() {
        let mut det = TemplateMatcher::from_luma(8, 8, vec![128; 64]).unwrap();
        let f = Frame::solid(4, 4, [30, 30, 30, 255]);
        assert_eq!(
            det.evaluate(&full_view(&f)).unwrap(),
            DetectorOutput::Score(0.0)
        );
    }

    #[test]
    fn flat_template_matches_equal_flat_region_only() {
        // Flat gray template: NCC is degenerate, falls back to mean comparison.
        let mut det = TemplateMatcher::from_luma(2, 2, vec![100; 4]).unwrap();
        let same = Frame::from_fn(4, 4, |_, _| [100, 100, 100, 255]);
        let DetectorOutput::Score(s) = det.evaluate(&full_view(&same)).unwrap() else {
            panic!("expected Score");
        };
        assert_eq!(s, 1.0);

        let brighter = Frame::from_fn(4, 4, |_, _| [200, 200, 200, 255]);
        let DetectorOutput::Score(s) = det.evaluate(&full_view(&brighter)).unwrap() else {
            panic!("expected Score");
        };
        assert_eq!(s, 0.0);
    }

    #[test]
    fn from_luma_validates_input() {
        assert!(TemplateMatcher::from_luma(4, 4, vec![0; 15]).is_err());
        assert!(TemplateMatcher::from_luma(0, 4, vec![]).is_err());
    }

    /// The checkerboard encoded as PNG bytes, via the image crate.
    fn checker_png() -> Vec<u8> {
        let img = image::ImageBuffer::from_fn(4, 4, |x, y| {
            if (x + y) % 2 == 0 {
                image::Luma([255u8])
            } else {
                image::Luma([0u8])
            }
        });
        let mut out = Vec::new();
        img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png)
            .expect("png encode");
        out
    }

    #[test]
    fn png_roundtrip_matches_like_raw_luma() {
        let mut det = TemplateMatcher::from_png_bytes(&checker_png()).unwrap();
        let f = frame_with_icon();
        let DetectorOutput::Score(s) = det.evaluate(&full_view(&f)).unwrap() else {
            panic!("expected Score");
        };
        assert!(s > 0.99, "png-loaded template must match, got {s}");
    }

    #[test]
    fn invalid_png_bytes_error() {
        assert!(TemplateMatcher::from_png_bytes(b"definitely not a png").is_err());
    }
}