Skip to main content

visual_cortex_vision/
detector.rs

1use visual_cortex_capture::FrameView;
2
3/// What a detector produced this tick.
4#[derive(Debug, Clone, PartialEq)]
5#[non_exhaustive]
6pub enum DetectorOutput {
7    /// Gates and presence checks. `Bool(false)` means "no match" / "gate closed".
8    Bool(bool),
9    /// OCR'd text.
10    Text(String),
11    /// A parsed numeric value (HP, gold, timer).
12    ///
13    /// Must never be NaN: change detection compares outputs with `PartialEq`,
14    /// and NaN != NaN would fire a spurious change event every tick. Detectors
15    /// that fail to parse a number must return `None` or an `Err` instead.
16    Number(f64),
17    /// A template-match confidence score in 0.0..=1.0. Must never be NaN
18    /// (same rule as `Number`).
19    Score(f32),
20    /// Nothing detected this tick.
21    None,
22}
23
24impl DetectorOutput {
25    /// The numeric value, if this output is numeric. Text is NOT parsed here —
26    /// parsing is the detector's job.
27    pub fn as_number(&self) -> Option<f64> {
28        match self {
29            DetectorOutput::Number(n) => Some(*n),
30            DetectorOutput::Score(s) => Some(*s as f64),
31            _ => None,
32        }
33    }
34}
35
36#[derive(Debug, thiserror::Error)]
37pub enum DetectorError {
38    #[error("ocr error: {0}")]
39    Ocr(String),
40    #[error("{0}")]
41    Other(String),
42}
43
44/// Evaluates one region of one frame. Built-in detectors, gates, and
45/// user-defined detectors all implement this same trait.
46pub trait Detector: Send + 'static {
47    fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError>;
48
49    /// Heavy detectors (OCR, ML inference) are routed to the blocking thread
50    /// pool by the watcher loop so they never stall the async scheduler.
51    /// Cheap pixel-math detectors keep the default `false`.
52    fn is_heavy(&self) -> bool {
53        false
54    }
55}
56
57/// Adapt a closure into a `Detector`. See [`from_fn`].
58pub struct FnDetector<F>(F);
59
60impl<F> Detector for FnDetector<F>
61where
62    F: FnMut(&FrameView<'_>) -> Result<DetectorOutput, DetectorError> + Send + 'static,
63{
64    fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError> {
65        (self.0)(view)
66    }
67}
68
69/// Build a detector from a closure: `from_fn(|view| Ok(DetectorOutput::Bool(true)))`.
70pub fn from_fn<F>(f: F) -> FnDetector<F>
71where
72    F: FnMut(&FrameView<'_>) -> Result<DetectorOutput, DetectorError> + Send + 'static,
73{
74    FnDetector(f)
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use visual_cortex_capture::{Frame, PxRect};
81
82    #[test]
83    fn from_fn_adapts_a_closure() {
84        let mut det =
85            from_fn(|view: &FrameView<'_>| Ok(DetectorOutput::Number(view.width() as f64)));
86        let frame = Frame::solid(8, 8, [0, 0, 0, 255]);
87        let view = frame
88            .view(PxRect {
89                x: 0,
90                y: 0,
91                w: 4,
92                h: 4,
93            })
94            .unwrap();
95        assert_eq!(det.evaluate(&view).unwrap(), DetectorOutput::Number(4.0));
96    }
97
98    #[test]
99    fn as_number_extracts_numeric_outputs() {
100        assert_eq!(DetectorOutput::Number(3.5).as_number(), Some(3.5));
101        assert_eq!(DetectorOutput::Score(0.5).as_number(), Some(0.5));
102        assert_eq!(DetectorOutput::Bool(true).as_number(), None);
103        assert_eq!(DetectorOutput::Text("7".into()).as_number(), None);
104    }
105
106    #[test]
107    fn detector_trait_is_object_safe() {
108        let mut det: Box<dyn Detector> = Box::new(from_fn(|_| Ok(DetectorOutput::Bool(true))));
109        let frame = Frame::solid(2, 2, [0, 0, 0, 255]);
110        let view = frame
111            .view(PxRect {
112                x: 0,
113                y: 0,
114                w: 2,
115                h: 2,
116            })
117            .unwrap();
118        assert_eq!(det.evaluate(&view).unwrap(), DetectorOutput::Bool(true));
119    }
120}