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::ocr::TextSpan;

/// What a detector produced this tick.
#[derive(Debug, Clone)]
#[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),
    /// OCR'd text with per-span geometry and confidence preserved
    /// (bbox-ordered, the same order `Text` mode joins in).
    ///
    /// **Equality — and therefore Change-mode transition detection — compares
    /// span *texts* only.** Bounding boxes and confidences jitter frame to
    /// frame under real OCR; if they participated in `PartialEq`, a static
    /// tooltip would fire a change event on every tick.
    Spans(Vec<TextSpan>),
    /// Nothing detected this tick.
    None,
}

/// Manual impl: identical to the derive for every variant except `Spans`,
/// which compares the ordered sequence of span texts (see the variant doc).
impl PartialEq for DetectorOutput {
    fn eq(&self, other: &Self) -> bool {
        match (self, other) {
            (DetectorOutput::Bool(a), DetectorOutput::Bool(b)) => a == b,
            (DetectorOutput::Text(a), DetectorOutput::Text(b)) => a == b,
            (DetectorOutput::Number(a), DetectorOutput::Number(b)) => a == b,
            (DetectorOutput::Score(a), DetectorOutput::Score(b)) => a == b,
            (DetectorOutput::Spans(a), DetectorOutput::Spans(b)) => {
                a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.text == y.text)
            }
            (DetectorOutput::None, DetectorOutput::None) => true,
            _ => false,
        }
    }
}

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 spans_equality_is_text_only() {
        use crate::ocr::TextSpan;
        use visual_cortex_capture::PxRect;
        let span = |text: &str, x: u32, conf: f32| TextSpan {
            text: text.to_string(),
            confidence: conf,
            bbox: PxRect {
                x,
                y: 0,
                w: 10,
                h: 8,
            },
        };
        // Same texts, different geometry/confidence (OCR jitter): EQUAL.
        let a = DetectorOutput::Spans(vec![span("Doombringer", 2, 0.98)]);
        let b = DetectorOutput::Spans(vec![span("Doombringer", 3, 0.91)]);
        assert_eq!(a, b, "bbox/confidence jitter must not register as change");
        // Different text: NOT equal.
        let c = DetectorOutput::Spans(vec![span("Stormclaw", 2, 0.98)]);
        assert_ne!(a, c);
        // Different span count: NOT equal.
        let d = DetectorOutput::Spans(vec![span("Doombringer", 2, 0.98), span("Sword", 2, 0.98)]);
        assert_ne!(a, d);
        // Spans never compares equal to other variants, and as_number is None.
        assert_ne!(a, DetectorOutput::Text("Doombringer".to_string()));
        assert_eq!(a.as_number(), None);
    }

    #[test]
    fn non_spans_equality_semantics_unchanged() {
        assert_eq!(DetectorOutput::Bool(true), DetectorOutput::Bool(true));
        assert_ne!(DetectorOutput::Bool(true), DetectorOutput::Bool(false));
        assert_eq!(
            DetectorOutput::Text("x".to_string()),
            DetectorOutput::Text("x".to_string())
        );
        assert_eq!(DetectorOutput::Number(3.5), DetectorOutput::Number(3.5));
        assert_ne!(DetectorOutput::Number(3.5), DetectorOutput::Score(3.5));
        assert_eq!(DetectorOutput::None, DetectorOutput::None);
    }

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