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
21impl OcrEngine for Box<dyn OcrEngine> {
23 fn recognize(&mut self, view: &FrameView<'_>) -> Result<Vec<TextSpan>, DetectorError> {
24 (**self).recognize(view)
25 }
26}
27
28use crate::detector::{Detector, DetectorOutput};
29
30type NumberParser = Box<dyn Fn(&str) -> Option<f64> + Send + 'static>;
31
32enum OcrMode {
33 Text,
34 Number(NumberParser),
35 Spans,
36}
37
38type SpanFilter = Box<dyn Fn(&TextSpan) -> bool + Send + 'static>;
50
51pub struct OcrDetector {
52 engine: Box<dyn OcrEngine>,
53 mode: OcrMode,
54 filters: Vec<SpanFilter>,
55}
56
57impl OcrDetector {
58 pub fn text(engine: impl OcrEngine) -> Self {
59 Self {
60 engine: Box::new(engine),
61 mode: OcrMode::Text,
62 filters: Vec::new(),
63 }
64 }
65
66 pub fn with_span_filter(mut self, filter: impl Fn(&TextSpan) -> bool + Send + 'static) -> Self {
71 self.filters.push(Box::new(filter));
72 self
73 }
74
75 pub fn number(
76 engine: impl OcrEngine,
77 parser: impl Fn(&str) -> Option<f64> + Send + 'static,
78 ) -> Self {
79 Self {
80 engine: Box::new(engine),
81 mode: OcrMode::Number(Box::new(parser)),
82 filters: Vec::new(),
83 }
84 }
85
86 pub fn spans(engine: impl OcrEngine) -> Self {
87 Self {
88 engine: Box::new(engine),
89 mode: OcrMode::Spans,
90 filters: Vec::new(),
91 }
92 }
93}
94
95impl Detector for OcrDetector {
96 fn evaluate(&mut self, view: &FrameView<'_>) -> Result<DetectorOutput, DetectorError> {
97 let mut spans = self.engine.recognize(view)?;
98 spans.retain(|s| self.filters.iter().all(|keep| keep(s)));
99 if spans.is_empty() {
100 return Ok(DetectorOutput::None);
101 }
102 spans.sort_by_key(|s| (s.bbox.y, s.bbox.x));
103 if matches!(self.mode, OcrMode::Spans) {
104 return Ok(DetectorOutput::Spans(spans));
105 }
106 let joined = spans
107 .iter()
108 .map(|s| s.text.as_str())
109 .collect::<Vec<_>>()
110 .join("\n");
111 match &self.mode {
112 OcrMode::Text => Ok(DetectorOutput::Text(joined)),
113 OcrMode::Number(parser) => Ok(match parser(&joined) {
114 Some(n) if n.is_finite() => DetectorOutput::Number(n),
115 _ => DetectorOutput::None,
116 }),
117 OcrMode::Spans => unreachable!("handled above"),
118 }
119 }
120
121 fn is_heavy(&self) -> bool {
122 true
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use visual_cortex_capture::{Frame, PxRect};
130
131 struct FixedOcr(Vec<TextSpan>);
132
133 impl OcrEngine for FixedOcr {
134 fn recognize(&mut self, _view: &FrameView<'_>) -> Result<Vec<TextSpan>, DetectorError> {
135 Ok(self.0.clone())
136 }
137 }
138
139 #[test]
140 fn trait_is_object_safe_and_returns_spans() {
141 let span = TextSpan {
142 text: "HP 300".to_string(),
143 confidence: 0.98,
144 bbox: PxRect {
145 x: 1,
146 y: 2,
147 w: 40,
148 h: 10,
149 },
150 };
151 let mut engine: Box<dyn OcrEngine> = Box::new(FixedOcr(vec![span]));
152 let frame = Frame::solid(64, 16, [0, 0, 0, 255]);
153 let view = frame
154 .view(PxRect {
155 x: 0,
156 y: 0,
157 w: 64,
158 h: 16,
159 })
160 .unwrap();
161 let spans = engine.recognize(&view).unwrap();
162 assert_eq!(spans.len(), 1);
163 assert_eq!(spans[0].text, "HP 300");
164 assert!(spans[0].confidence > 0.9);
165 assert_eq!(spans[0].bbox.w, 40);
166 }
167
168 fn view_frame() -> Frame {
169 Frame::solid(64, 32, [0, 0, 0, 255])
170 }
171
172 fn full_view(frame: &Frame) -> FrameView<'_> {
173 frame
174 .view(PxRect {
175 x: 0,
176 y: 0,
177 w: frame.width(),
178 h: frame.height(),
179 })
180 .unwrap()
181 }
182
183 fn span(text: &str, x: u32, y: u32) -> TextSpan {
184 TextSpan {
185 text: text.to_string(),
186 confidence: 0.9,
187 bbox: PxRect { x, y, w: 10, h: 8 },
188 }
189 }
190
191 #[test]
192 fn text_mode_sorts_spans_and_joins_lines() {
193 use crate::detector::{Detector, DetectorOutput};
194 let mut det = OcrDetector::text(FixedOcr(vec![
196 span("Sword", 2, 20),
197 span("Doombringer", 2, 2),
198 span("Unique", 30, 2),
199 ]));
200 let frame = view_frame();
201 assert_eq!(
202 det.evaluate(&full_view(&frame)).unwrap(),
203 DetectorOutput::Text("Doombringer\nUnique\nSword".to_string())
204 );
205 assert!(det.is_heavy());
206 }
207
208 #[test]
209 fn text_mode_empty_spans_is_none() {
210 use crate::detector::{Detector, DetectorOutput};
211 let mut det = OcrDetector::text(FixedOcr(vec![]));
212 let frame = view_frame();
213 assert_eq!(
214 det.evaluate(&full_view(&frame)).unwrap(),
215 DetectorOutput::None
216 );
217 }
218
219 #[test]
220 fn spans_mode_preserves_metadata_in_bbox_order() {
221 use crate::detector::{Detector, DetectorOutput};
222 let mut det = OcrDetector::spans(FixedOcr(vec![
223 span("Sword", 2, 20),
224 span("Doombringer", 2, 2),
225 ]));
226 let frame = view_frame();
227 let DetectorOutput::Spans(spans) = det.evaluate(&full_view(&frame)).unwrap() else {
228 panic!("expected Spans output");
229 };
230 assert_eq!(spans.len(), 2);
232 assert_eq!(spans[0].text, "Doombringer");
233 assert_eq!(spans[0].bbox.y, 2);
234 assert_eq!(spans[1].text, "Sword");
235 assert_eq!(spans[1].bbox.y, 20);
236 assert!(spans.iter().all(|s| s.confidence > 0.0));
237 assert!(det.is_heavy());
238 }
239
240 #[test]
241 fn spans_mode_empty_is_none() {
242 use crate::detector::{Detector, DetectorOutput};
243 let mut det = OcrDetector::spans(FixedOcr(vec![]));
244 let frame = view_frame();
245 assert_eq!(
246 det.evaluate(&full_view(&frame)).unwrap(),
247 DetectorOutput::None
248 );
249 }
250
251 #[test]
252 fn number_mode_parses_and_guards() {
253 use crate::detector::{Detector, DetectorOutput};
254 let mut det = OcrDetector::number(FixedOcr(vec![span("HP 1,234", 0, 0)]), |t| {
255 crate::patterns::number()(t)
256 });
257 let frame = view_frame();
258 assert_eq!(
259 det.evaluate(&full_view(&frame)).unwrap(),
260 DetectorOutput::Number(1234.0)
261 );
262
263 let mut det = OcrDetector::number(FixedOcr(vec![span("no digits", 0, 0)]), |t| {
265 crate::patterns::number()(t)
266 });
267 assert_eq!(
268 det.evaluate(&full_view(&frame)).unwrap(),
269 DetectorOutput::None
270 );
271
272 let mut det = OcrDetector::number(FixedOcr(vec![span("x", 0, 0)]), |_| Some(f64::NAN));
274 assert_eq!(
275 det.evaluate(&full_view(&frame)).unwrap(),
276 DetectorOutput::None
277 );
278 }
279
280 #[test]
281 fn engine_errors_propagate_unchanged() {
282 use crate::detector::Detector;
283
284 struct FailingOcr;
285 impl OcrEngine for FailingOcr {
286 fn recognize(&mut self, _view: &FrameView<'_>) -> Result<Vec<TextSpan>, DetectorError> {
287 Err(DetectorError::Ocr("engine exploded".to_string()))
288 }
289 }
290
291 let mut det = OcrDetector::text(FailingOcr);
292 let frame = view_frame();
293 let err = det.evaluate(&full_view(&frame)).unwrap_err();
294 assert!(matches!(err, DetectorError::Ocr(msg) if msg == "engine exploded"));
295 }
296}