Skip to main content

visual_cortex_vision/
ocr.rs

1use visual_cortex_capture::{FrameView, PxRect};
2
3use crate::detector::DetectorError;
4
5/// One recognized text region. `bbox` is relative to the evaluated view.
6#[derive(Debug, Clone, PartialEq)]
7pub struct TextSpan {
8    pub text: String,
9    /// Mean character confidence in 0.0..=1.0. Never NaN.
10    pub confidence: f32,
11    pub bbox: PxRect,
12}
13
14/// Pluggable OCR backend. The default ONNX/PaddleOCR implementation lives in
15/// the `visual-cortex-ocr-onnx` crate; any engine can slot in here. Returning an empty
16/// vec means "no text present" and is not an error.
17pub trait OcrEngine: Send + 'static {
18    fn recognize(&mut self, view: &FrameView<'_>) -> Result<Vec<TextSpan>, DetectorError>;
19}
20
21use crate::detector::{Detector, DetectorOutput};
22
23type NumberParser = Box<dyn Fn(&str) -> Option<f64> + Send + 'static>;
24
25enum OcrMode {
26    Text,
27    Number(NumberParser),
28}
29
30/// Adapts any [`OcrEngine`] into a [`Detector`].
31///
32/// Text mode sorts spans top-to-bottom then left-to-right and joins them with
33/// `\n` into `DetectorOutput::Text`; an empty span list becomes
34/// `DetectorOutput::None`, so, under `Mode::Change`, "text appeared" is the
35/// `None` -> `Text` transition.
36/// Number mode additionally parses the joined text; parse failures and
37/// non-finite values become `DetectorOutput::None` (the never-NaN invariant).
38pub struct OcrDetector {
39    engine: Box<dyn OcrEngine>,
40    mode: OcrMode,
41}
42
43impl OcrDetector {
44    pub fn text(engine: impl OcrEngine) -> Self {
45        Self {
46            engine: Box::new(engine),
47            mode: OcrMode::Text,
48        }
49    }
50
51    pub fn number(
52        engine: impl OcrEngine,
53        parser: impl Fn(&str) -> Option<f64> + Send + 'static,
54    ) -> Self {
55        Self {
56            engine: Box::new(engine),
57            mode: OcrMode::Number(Box::new(parser)),
58        }
59    }
60}
61
62impl Detector for OcrDetector {
63    fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError> {
64        let mut spans = self.engine.recognize(view)?;
65        if spans.is_empty() {
66            return Ok(DetectorOutput::None);
67        }
68        spans.sort_by_key(|s| (s.bbox.y, s.bbox.x));
69        let joined = spans
70            .iter()
71            .map(|s| s.text.as_str())
72            .collect::<Vec<_>>()
73            .join("\n");
74        match &self.mode {
75            OcrMode::Text => Ok(DetectorOutput::Text(joined)),
76            OcrMode::Number(parser) => Ok(match parser(&joined) {
77                Some(n) if n.is_finite() => DetectorOutput::Number(n),
78                _ => DetectorOutput::None,
79            }),
80        }
81    }
82
83    fn is_heavy(&self) -> bool {
84        true
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use visual_cortex_capture::{Frame, PxRect};
92
93    struct FixedOcr(Vec<TextSpan>);
94
95    impl OcrEngine for FixedOcr {
96        fn recognize(&mut self, _view: &FrameView<'_>) -> Result<Vec<TextSpan>, DetectorError> {
97            Ok(self.0.clone())
98        }
99    }
100
101    #[test]
102    fn trait_is_object_safe_and_returns_spans() {
103        let span = TextSpan {
104            text: "HP 300".to_string(),
105            confidence: 0.98,
106            bbox: PxRect {
107                x: 1,
108                y: 2,
109                w: 40,
110                h: 10,
111            },
112        };
113        let mut engine: Box<dyn OcrEngine> = Box::new(FixedOcr(vec![span]));
114        let frame = Frame::solid(64, 16, [0, 0, 0, 255]);
115        let view = frame
116            .view(PxRect {
117                x: 0,
118                y: 0,
119                w: 64,
120                h: 16,
121            })
122            .unwrap();
123        let spans = engine.recognize(&view).unwrap();
124        assert_eq!(spans.len(), 1);
125        assert_eq!(spans[0].text, "HP 300");
126        assert!(spans[0].confidence > 0.9);
127        assert_eq!(spans[0].bbox.w, 40);
128    }
129
130    fn view_frame() -> Frame {
131        Frame::solid(64, 32, [0, 0, 0, 255])
132    }
133
134    fn full_view(frame: &Frame) -> FrameView<'_> {
135        frame
136            .view(PxRect {
137                x: 0,
138                y: 0,
139                w: frame.width(),
140                h: frame.height(),
141            })
142            .unwrap()
143    }
144
145    fn span(text: &str, x: u32, y: u32) -> TextSpan {
146        TextSpan {
147            text: text.to_string(),
148            confidence: 0.9,
149            bbox: PxRect { x, y, w: 10, h: 8 },
150        }
151    }
152
153    #[test]
154    fn text_mode_sorts_spans_and_joins_lines() {
155        use crate::detector::{Detector, DetectorOutput};
156        // Deliberately out of order: second line first, then two spans on line one.
157        let mut det = OcrDetector::text(FixedOcr(vec![
158            span("Sword", 2, 20),
159            span("Doombringer", 2, 2),
160            span("Unique", 30, 2),
161        ]));
162        let frame = view_frame();
163        assert_eq!(
164            det.evaluate(&full_view(&frame)).unwrap(),
165            DetectorOutput::Text("Doombringer\nUnique\nSword".to_string())
166        );
167        assert!(det.is_heavy());
168    }
169
170    #[test]
171    fn text_mode_empty_spans_is_none() {
172        use crate::detector::{Detector, DetectorOutput};
173        let mut det = OcrDetector::text(FixedOcr(vec![]));
174        let frame = view_frame();
175        assert_eq!(
176            det.evaluate(&full_view(&frame)).unwrap(),
177            DetectorOutput::None
178        );
179    }
180
181    #[test]
182    fn number_mode_parses_and_guards() {
183        use crate::detector::{Detector, DetectorOutput};
184        let mut det = OcrDetector::number(FixedOcr(vec![span("HP 1,234", 0, 0)]), |t| {
185            crate::patterns::number()(t)
186        });
187        let frame = view_frame();
188        assert_eq!(
189            det.evaluate(&full_view(&frame)).unwrap(),
190            DetectorOutput::Number(1234.0)
191        );
192
193        // Parse failure -> None
194        let mut det = OcrDetector::number(FixedOcr(vec![span("no digits", 0, 0)]), |t| {
195            crate::patterns::number()(t)
196        });
197        assert_eq!(
198            det.evaluate(&full_view(&frame)).unwrap(),
199            DetectorOutput::None
200        );
201
202        // A parser returning NaN must be swallowed to None (never-NaN invariant).
203        let mut det = OcrDetector::number(FixedOcr(vec![span("x", 0, 0)]), |_| Some(f64::NAN));
204        assert_eq!(
205            det.evaluate(&full_view(&frame)).unwrap(),
206            DetectorOutput::None
207        );
208    }
209
210    #[test]
211    fn engine_errors_propagate_unchanged() {
212        use crate::detector::Detector;
213
214        struct FailingOcr;
215        impl OcrEngine for FailingOcr {
216            fn recognize(&mut self, _view: &FrameView<'_>) -> Result<Vec<TextSpan>, DetectorError> {
217                Err(DetectorError::Ocr("engine exploded".to_string()))
218            }
219        }
220
221        let mut det = OcrDetector::text(FailingOcr);
222        let frame = view_frame();
223        let err = det.evaluate(&full_view(&frame)).unwrap_err();
224        assert!(matches!(err, DetectorError::Ocr(msg) if msg == "engine exploded"));
225    }
226}