Skip to main content

visual_cortex_vision/
detector.rs

1use visual_cortex_capture::FrameView;
2
3use crate::ocr::TextSpan;
4
5/// What a detector produced this tick.
6#[derive(Debug, Clone)]
7#[non_exhaustive]
8pub enum DetectorOutput {
9    /// Gates and presence checks. `Bool(false)` means "no match" / "gate closed".
10    Bool(bool),
11    /// OCR'd text.
12    Text(String),
13    /// A parsed numeric value (HP, gold, timer).
14    ///
15    /// Must never be NaN: change detection compares outputs with `PartialEq`,
16    /// and NaN != NaN would fire a spurious change event every tick. Detectors
17    /// that fail to parse a number must return `None` or an `Err` instead.
18    Number(f64),
19    /// A template-match confidence score in 0.0..=1.0. Must never be NaN
20    /// (same rule as `Number`).
21    Score(f32),
22    /// OCR'd text with per-span geometry and confidence preserved
23    /// (bbox-ordered, the same order `Text` mode joins in).
24    ///
25    /// **Equality — and therefore Change-mode transition detection — compares
26    /// span *texts* only.** Bounding boxes and confidences jitter frame to
27    /// frame under real OCR; if they participated in `PartialEq`, a static
28    /// tooltip would fire a change event on every tick.
29    Spans(Vec<TextSpan>),
30    /// Nothing detected this tick.
31    None,
32}
33
34/// Manual impl: identical to the derive for every variant except `Spans`,
35/// which compares the ordered sequence of span texts (see the variant doc).
36impl PartialEq for DetectorOutput {
37    fn eq(&self, other: &Self) -> bool {
38        match (self, other) {
39            (DetectorOutput::Bool(a), DetectorOutput::Bool(b)) => a == b,
40            (DetectorOutput::Text(a), DetectorOutput::Text(b)) => a == b,
41            (DetectorOutput::Number(a), DetectorOutput::Number(b)) => a == b,
42            (DetectorOutput::Score(a), DetectorOutput::Score(b)) => a == b,
43            (DetectorOutput::Spans(a), DetectorOutput::Spans(b)) => {
44                a.len() == b.len() && a.iter().zip(b).all(|(x, y)| x.text == y.text)
45            }
46            (DetectorOutput::None, DetectorOutput::None) => true,
47            _ => false,
48        }
49    }
50}
51
52impl DetectorOutput {
53    /// The numeric value, if this output is numeric. Text is NOT parsed here —
54    /// parsing is the detector's job.
55    pub fn as_number(&self) -> Option<f64> {
56        match self {
57            DetectorOutput::Number(n) => Some(*n),
58            DetectorOutput::Score(s) => Some(*s as f64),
59            _ => None,
60        }
61    }
62}
63
64#[derive(Debug, thiserror::Error)]
65pub enum DetectorError {
66    #[error("ocr error: {0}")]
67    Ocr(String),
68    #[error("{0}")]
69    Other(String),
70}
71
72/// Evaluates one region of one frame. Built-in detectors, gates, and
73/// user-defined detectors all implement this same trait.
74pub trait Detector: Send + 'static {
75    fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError>;
76
77    /// Heavy detectors (OCR, ML inference) are routed to the blocking thread
78    /// pool by the watcher loop so they never stall the async scheduler.
79    /// Cheap pixel-math detectors keep the default `false`.
80    fn is_heavy(&self) -> bool {
81        false
82    }
83}
84
85/// Adapt a closure into a `Detector`. See [`from_fn`].
86pub struct FnDetector<F>(F);
87
88impl<F> Detector for FnDetector<F>
89where
90    F: FnMut(&FrameView<'_>) -> Result<DetectorOutput, DetectorError> + Send + 'static,
91{
92    fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError> {
93        (self.0)(view)
94    }
95}
96
97/// Build a detector from a closure: `from_fn(|view| Ok(DetectorOutput::Bool(true)))`.
98pub fn from_fn<F>(f: F) -> FnDetector<F>
99where
100    F: FnMut(&FrameView<'_>) -> Result<DetectorOutput, DetectorError> + Send + 'static,
101{
102    FnDetector(f)
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use visual_cortex_capture::{Frame, PxRect};
109
110    #[test]
111    fn from_fn_adapts_a_closure() {
112        let mut det =
113            from_fn(|view: &FrameView<'_>| Ok(DetectorOutput::Number(view.width() as f64)));
114        let frame = Frame::solid(8, 8, [0, 0, 0, 255]);
115        let view = frame
116            .view(PxRect {
117                x: 0,
118                y: 0,
119                w: 4,
120                h: 4,
121            })
122            .unwrap();
123        assert_eq!(det.evaluate(&view).unwrap(), DetectorOutput::Number(4.0));
124    }
125
126    #[test]
127    fn spans_equality_is_text_only() {
128        use crate::ocr::TextSpan;
129        use visual_cortex_capture::PxRect;
130        let span = |text: &str, x: u32, conf: f32| TextSpan {
131            text: text.to_string(),
132            confidence: conf,
133            bbox: PxRect {
134                x,
135                y: 0,
136                w: 10,
137                h: 8,
138            },
139        };
140        // Same texts, different geometry/confidence (OCR jitter): EQUAL.
141        let a = DetectorOutput::Spans(vec![span("Doombringer", 2, 0.98)]);
142        let b = DetectorOutput::Spans(vec![span("Doombringer", 3, 0.91)]);
143        assert_eq!(a, b, "bbox/confidence jitter must not register as change");
144        // Different text: NOT equal.
145        let c = DetectorOutput::Spans(vec![span("Stormclaw", 2, 0.98)]);
146        assert_ne!(a, c);
147        // Different span count: NOT equal.
148        let d = DetectorOutput::Spans(vec![span("Doombringer", 2, 0.98), span("Sword", 2, 0.98)]);
149        assert_ne!(a, d);
150        // Spans never compares equal to other variants, and as_number is None.
151        assert_ne!(a, DetectorOutput::Text("Doombringer".to_string()));
152        assert_eq!(a.as_number(), None);
153    }
154
155    #[test]
156    fn non_spans_equality_semantics_unchanged() {
157        assert_eq!(DetectorOutput::Bool(true), DetectorOutput::Bool(true));
158        assert_ne!(DetectorOutput::Bool(true), DetectorOutput::Bool(false));
159        assert_eq!(
160            DetectorOutput::Text("x".to_string()),
161            DetectorOutput::Text("x".to_string())
162        );
163        assert_eq!(DetectorOutput::Number(3.5), DetectorOutput::Number(3.5));
164        assert_ne!(DetectorOutput::Number(3.5), DetectorOutput::Score(3.5));
165        assert_eq!(DetectorOutput::None, DetectorOutput::None);
166    }
167
168    #[test]
169    fn as_number_extracts_numeric_outputs() {
170        assert_eq!(DetectorOutput::Number(3.5).as_number(), Some(3.5));
171        assert_eq!(DetectorOutput::Score(0.5).as_number(), Some(0.5));
172        assert_eq!(DetectorOutput::Bool(true).as_number(), None);
173        assert_eq!(DetectorOutput::Text("7".into()).as_number(), None);
174    }
175
176    #[test]
177    fn detector_trait_is_object_safe() {
178        let mut det: Box<dyn Detector> = Box::new(from_fn(|_| Ok(DetectorOutput::Bool(true))));
179        let frame = Frame::solid(2, 2, [0, 0, 0, 255]);
180        let view = frame
181            .view(PxRect {
182                x: 0,
183                y: 0,
184                w: 2,
185                h: 2,
186            })
187            .unwrap();
188        assert_eq!(det.evaluate(&view).unwrap(), DetectorOutput::Bool(true));
189    }
190}