use visual_cortex_capture::FrameView;
use crate::detector::{Detector, DetectorError, DetectorOutput};
use crate::luma::view_to_luma;
const FLAT_EPSILON: f64 = 1e-6;
const FLAT_MEAN_TOLERANCE: f64 = 2.0;
pub struct TemplateMatcher {
width: u32,
height: u32,
mean: f64,
centered: Vec<f64>,
norm: f64,
}
impl TemplateMatcher {
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,
})
}
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 ®ion[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 {
if (self.mean - wmean).abs() <= FLAT_MEAN_TOLERANCE {
1.0
} else {
0.0
}
} else if self.norm < FLAT_EPSILON || wnorm < FLAT_EPSILON {
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(®ion, 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()
}
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()
}
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() {
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());
}
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());
}
}