visual-cortex-vision 0.1.0

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

/// What a detector produced this tick.
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum DetectorOutput {
    /// Gates and presence checks. `Bool(false)` means "no match" / "gate closed".
    Bool(bool),
    /// OCR'd text.
    Text(String),
    /// A parsed numeric value (HP, gold, timer).
    ///
    /// Must never be NaN: change detection compares outputs with `PartialEq`,
    /// and NaN != NaN would fire a spurious change event every tick. Detectors
    /// that fail to parse a number must return `None` or an `Err` instead.
    Number(f64),
    /// A template-match confidence score in 0.0..=1.0. Must never be NaN
    /// (same rule as `Number`).
    Score(f32),
    /// Nothing detected this tick.
    None,
}

impl DetectorOutput {
    /// The numeric value, if this output is numeric. Text is NOT parsed here —
    /// parsing is the detector's job.
    pub fn as_number(&self) -> Option<f64> {
        match self {
            DetectorOutput::Number(n) => Some(*n),
            DetectorOutput::Score(s) => Some(*s as f64),
            _ => None,
        }
    }
}

#[derive(Debug, thiserror::Error)]
pub enum DetectorError {
    #[error("ocr error: {0}")]
    Ocr(String),
    #[error("{0}")]
    Other(String),
}

/// Evaluates one region of one frame. Built-in detectors, gates, and
/// user-defined detectors all implement this same trait.
pub trait Detector: Send + 'static {
    fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError>;

    /// Heavy detectors (OCR, ML inference) are routed to the blocking thread
    /// pool by the watcher loop so they never stall the async scheduler.
    /// Cheap pixel-math detectors keep the default `false`.
    fn is_heavy(&self) -> bool {
        false
    }
}

/// Adapt a closure into a `Detector`. See [`from_fn`].
pub struct FnDetector<F>(F);

impl<F> Detector for FnDetector<F>
where
    F: FnMut(&FrameView<'_>) -> Result<DetectorOutput, DetectorError> + Send + 'static,
{
    fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError> {
        (self.0)(view)
    }
}

/// Build a detector from a closure: `from_fn(|view| Ok(DetectorOutput::Bool(true)))`.
pub fn from_fn<F>(f: F) -> FnDetector<F>
where
    F: FnMut(&FrameView<'_>) -> Result<DetectorOutput, DetectorError> + Send + 'static,
{
    FnDetector(f)
}

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

    #[test]
    fn from_fn_adapts_a_closure() {
        let mut det =
            from_fn(|view: &FrameView<'_>| Ok(DetectorOutput::Number(view.width() as f64)));
        let frame = Frame::solid(8, 8, [0, 0, 0, 255]);
        let view = frame
            .view(PxRect {
                x: 0,
                y: 0,
                w: 4,
                h: 4,
            })
            .unwrap();
        assert_eq!(det.evaluate(&view).unwrap(), DetectorOutput::Number(4.0));
    }

    #[test]
    fn as_number_extracts_numeric_outputs() {
        assert_eq!(DetectorOutput::Number(3.5).as_number(), Some(3.5));
        assert_eq!(DetectorOutput::Score(0.5).as_number(), Some(0.5));
        assert_eq!(DetectorOutput::Bool(true).as_number(), None);
        assert_eq!(DetectorOutput::Text("7".into()).as_number(), None);
    }

    #[test]
    fn detector_trait_is_object_safe() {
        let mut det: Box<dyn Detector> = Box::new(from_fn(|_| Ok(DetectorOutput::Bool(true))));
        let frame = Frame::solid(2, 2, [0, 0, 0, 255]);
        let view = frame
            .view(PxRect {
                x: 0,
                y: 0,
                w: 2,
                h: 2,
            })
            .unwrap();
        assert_eq!(det.evaluate(&view).unwrap(), DetectorOutput::Bool(true));
    }
}