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::luma;

/// Mean luma (0.0..=255.0) of the region, as `DetectorOutput::Number`.
/// Pair with `.on_threshold(...)` for "screen went dark"-style watchers.
pub struct Brightness;

impl Detector for Brightness {
    fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError> {
        let mut sum: u64 = 0;
        let mut count: u64 = 0;
        for row in view.rows() {
            for px in row.chunks_exact(4) {
                sum += luma(px[2], px[1], px[0]) as u64;
                count += 1;
            }
        }
        // Views are always at least 1x1, so count > 0.
        Ok(DetectorOutput::Number(sum as f64 / count as f64))
    }
}

/// Boolean detector/gate: at least `min_fraction` of the region's pixels are
/// within `tolerance` (per channel) of the target RGB color.
pub struct ColorMatch {
    rgb: [u8; 3],
    tolerance: u8,
    min_fraction: f32,
}

impl ColorMatch {
    /// Panics if `min_fraction` is outside `0.0..=1.0`.
    pub fn new(rgb: [u8; 3], tolerance: u8, min_fraction: f32) -> Self {
        assert!(
            (0.0..=1.0).contains(&min_fraction),
            "ColorMatch min_fraction must be in 0.0..=1.0"
        );
        Self {
            rgb,
            tolerance,
            min_fraction,
        }
    }
}

impl Detector for ColorMatch {
    fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError> {
        let [tr, tg, tb] = self.rgb;
        let tol = self.tolerance as i16;
        let mut hits: u64 = 0;
        let mut total: u64 = 0;
        for row in view.rows() {
            for px in row.chunks_exact(4) {
                let close = (px[2] as i16 - tr as i16).abs() <= tol
                    && (px[1] as i16 - tg as i16).abs() <= tol
                    && (px[0] as i16 - tb as i16).abs() <= tol;
                hits += close as u64;
                total += 1;
            }
        }
        Ok(DetectorOutput::Bool(
            hits as f32 / total as f32 >= self.min_fraction,
        ))
    }
}

/// A primary color channel, for [`ColorDominance`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Channel {
    Red,
    Green,
    Blue,
}

/// How much brighter a channel must be than both others to count as dominant.
const DOMINANCE_MARGIN: u8 = 30;
/// Channels dimmer than this never count as dominant (avoids "dominant black").
const MIN_CHANNEL_VALUE: u8 = 60;

/// Boolean detector/gate: at least `min_fraction` of pixels are dominated by
/// one channel (e.g. "the HP bar area is mostly red").
pub struct ColorDominance {
    channel: Channel,
    min_fraction: f32,
}

impl ColorDominance {
    /// Panics if `min_fraction` is outside `0.0..=1.0`.
    pub fn new(channel: Channel, min_fraction: f32) -> Self {
        assert!(
            (0.0..=1.0).contains(&min_fraction),
            "ColorDominance min_fraction must be in 0.0..=1.0"
        );
        Self {
            channel,
            min_fraction,
        }
    }

    pub fn red(min_fraction: f32) -> Self {
        Self::new(Channel::Red, min_fraction)
    }

    pub fn green(min_fraction: f32) -> Self {
        Self::new(Channel::Green, min_fraction)
    }

    pub fn blue(min_fraction: f32) -> Self {
        Self::new(Channel::Blue, min_fraction)
    }
}

impl Detector for ColorDominance {
    fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError> {
        let mut hits: u64 = 0;
        let mut total: u64 = 0;
        for row in view.rows() {
            for px in row.chunks_exact(4) {
                let (b, g, r) = (px[0], px[1], px[2]);
                let (value, o1, o2) = match self.channel {
                    Channel::Red => (r, g, b),
                    Channel::Green => (g, r, b),
                    Channel::Blue => (b, r, g),
                };
                let dominant = value >= MIN_CHANNEL_VALUE
                    && value >= o1.saturating_add(DOMINANCE_MARGIN)
                    && value >= o2.saturating_add(DOMINANCE_MARGIN);
                hits += dominant as u64;
                total += 1;
            }
        }
        Ok(DetectorOutput::Bool(
            hits as f32 / total as f32 >= self.min_fraction,
        ))
    }
}

#[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()
    }

    #[test]
    fn brightness_of_black_is_zero() {
        let f = Frame::solid(4, 4, [0, 0, 0, 255]);
        let mut det = Brightness;
        assert_eq!(
            det.evaluate(&full_view(&f)).unwrap(),
            DetectorOutput::Number(0.0)
        );
    }

    #[test]
    fn brightness_of_white_is_255() {
        let f = Frame::solid(4, 4, [255, 255, 255, 255]);
        let mut det = Brightness;
        assert_eq!(
            det.evaluate(&full_view(&f)).unwrap(),
            DetectorOutput::Number(255.0)
        );
    }

    #[test]
    fn brightness_averages_mixed_pixels() {
        // 2x1: one black, one white pixel -> exact mean 127.5
        let f = Frame::from_fn(2, 1, |x, _| {
            if x == 0 {
                [0, 0, 0, 255]
            } else {
                [255, 255, 255, 255]
            }
        });
        let mut det = Brightness;
        assert_eq!(
            det.evaluate(&full_view(&f)).unwrap(),
            DetectorOutput::Number(127.5)
        );
    }

    #[test]
    fn color_match_counts_fraction_within_tolerance() {
        // Left half pure red, right half pure blue (4x2).
        let f = Frame::from_fn(4, 2, |x, _| {
            if x < 2 {
                [0, 0, 255, 255] // red
            } else {
                [255, 0, 0, 255] // blue
            }
        });
        let mut half_red = ColorMatch::new([255, 0, 0], 10, 0.5);
        assert_eq!(
            half_red.evaluate(&full_view(&f)).unwrap(),
            DetectorOutput::Bool(true)
        );
        let mut mostly_red = ColorMatch::new([255, 0, 0], 10, 0.6);
        assert_eq!(
            mostly_red.evaluate(&full_view(&f)).unwrap(),
            DetectorOutput::Bool(false)
        );
    }

    #[test]
    fn color_dominance_detects_dominant_channel() {
        let red = Frame::solid(4, 4, [20, 20, 200, 255]); // BGRA: strong red
        assert_eq!(
            ColorDominance::red(0.9).evaluate(&full_view(&red)).unwrap(),
            DetectorOutput::Bool(true)
        );
        assert_eq!(
            ColorDominance::green(0.1)
                .evaluate(&full_view(&red))
                .unwrap(),
            DetectorOutput::Bool(false)
        );
    }

    #[test]
    fn color_dominance_rejects_gray() {
        // Equal channels: nothing dominates.
        let gray = Frame::solid(4, 4, [128, 128, 128, 255]);
        assert_eq!(
            ColorDominance::red(0.01)
                .evaluate(&full_view(&gray))
                .unwrap(),
            DetectorOutput::Bool(false)
        );
    }

    #[test]
    fn color_dominance_fraction_thresholds() {
        // Left half strong red, right half gray (4x2): red fraction is 0.5.
        let f = Frame::from_fn(4, 2, |x, _| {
            if x < 2 {
                [20, 20, 200, 255]
            } else {
                [90, 90, 90, 255]
            }
        });
        assert_eq!(
            ColorDominance::red(0.4).evaluate(&full_view(&f)).unwrap(),
            DetectorOutput::Bool(true)
        );
        assert_eq!(
            ColorDominance::red(0.6).evaluate(&full_view(&f)).unwrap(),
            DetectorOutput::Bool(false)
        );
    }

    #[test]
    #[should_panic]
    fn color_dominance_rejects_bad_fraction() {
        let _ = ColorDominance::red(1.5);
    }
}