1use visual_cortex_capture::{FrameView, PxRect};
2
3use crate::detector::DetectorError;
4
5#[derive(Debug, Clone, PartialEq)]
7pub struct TextSpan {
8 pub text: String,
9 pub confidence: f32,
11 pub bbox: PxRect,
12}
13
14pub 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
30pub 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 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 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 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}