1use async_trait::async_trait;
29use std::io::Write as _;
30
31use super::ax_converter::AxConverter;
32use super::pipeline::{extract_ax_text_blocks, PerceptionError, PerceptionPipeline};
33use super::signals::SignalDetector;
34use super::ui_map::{ElementSource, TextBlock, UiElement, UiMap};
35use crate::models::{A11yNode, Bounds, Viewport};
36
37#[derive(Debug, Clone)]
40struct OcrRegion {
41 text: String,
42 bounds: Bounds,
43 confidence: f32,
44}
45
46pub struct VisionPerceptionPipeline {
48 converter: AxConverter,
49 signal_detector: SignalDetector,
50}
51
52impl VisionPerceptionPipeline {
53 pub fn new() -> Self {
54 Self {
55 converter: AxConverter::new(),
56 signal_detector: SignalDetector::new(),
57 }
58 }
59}
60
61impl Default for VisionPerceptionPipeline {
62 fn default() -> Self {
63 Self::new()
64 }
65}
66
67#[async_trait]
68impl PerceptionPipeline for VisionPerceptionPipeline {
69 async fn perceive(
70 &self,
71 screenshot: &[u8],
72 a11y_nodes: &[A11yNode],
73 url: &str,
74 viewport: Viewport,
75 ) -> Result<UiMap, PerceptionError> {
76 let mut elements = self.converter.convert(a11y_nodes);
77 let mut text_blocks = extract_ax_text_blocks(a11y_nodes);
78 let page_signals = self.signal_detector.detect(a11y_nodes);
79
80 if !screenshot.is_empty() && car_vision::is_available() {
83 let bytes = screenshot.to_vec();
84 let (cw, ch) = css_viewport_from_ax(a11y_nodes, viewport);
91 let ocr = tokio::task::spawn_blocking(move || run_ocr_blocking(&bytes, cw, ch)).await;
94 match ocr {
95 Ok(Ok(regions)) if !regions.is_empty() => {
96 merge_ocr(&mut elements, &mut text_blocks, ®ions);
97 }
98 Ok(Ok(_)) => {}
99 Ok(Err(e)) => tracing::debug!(error = %e, "OCR augmentation skipped"),
100 Err(e) => tracing::debug!(error = %e, "OCR task join failed"),
101 }
102 }
103
104 Ok(UiMap::new(
105 url.to_string(),
106 elements,
107 text_blocks,
108 page_signals,
109 viewport,
110 String::new(),
111 ))
112 }
113}
114
115fn css_viewport_from_ax(nodes: &[A11yNode], viewport: Viewport) -> (f64, f64) {
121 if let Some(root) = nodes.iter().find(|n| {
122 let r = n.role.to_lowercase();
123 (r.contains("webarea") || r == "rootwebarea")
124 && n.bounds.width > 0.0
125 && n.bounds.height > 0.0
126 }) {
127 return (root.bounds.width, root.bounds.height);
128 }
129 let max_x = nodes
130 .iter()
131 .map(|n| n.bounds.x + n.bounds.width)
132 .fold(0.0_f64, f64::max);
133 let max_y = nodes
134 .iter()
135 .map(|n| n.bounds.y + n.bounds.height)
136 .fold(0.0_f64, f64::max);
137 if max_x > 0.0 && max_y > 0.0 {
138 (max_x, max_y)
139 } else {
140 (viewport.width as f64, viewport.height as f64)
141 }
142}
143
144fn run_ocr_blocking(screenshot: &[u8], cw: f64, ch: f64) -> Result<Vec<OcrRegion>, String> {
148 let mut tmp = tempfile::Builder::new()
149 .suffix(".png")
150 .tempfile()
151 .map_err(|e| format!("temp file: {e}"))?;
152 tmp.write_all(screenshot)
153 .map_err(|e| format!("write screenshot: {e}"))?;
154 tmp.flush().map_err(|e| format!("flush: {e}"))?;
155
156 let config = car_vision::ocr::OcrConfig {
157 fast_path: true,
160 languages: Vec::new(),
161 language_correction: true,
162 minimum_text_height: 0.0,
164 };
165 let observations =
166 car_vision::ocr::recognize(tmp.path(), &config).map_err(|e| format!("ocr: {e}"))?;
167
168 Ok(observations
169 .into_iter()
170 .filter(|o| !o.text.trim().is_empty() && o.w > 0.0 && o.h > 0.0)
171 .map(|o| OcrRegion {
172 text: o.text.trim().to_string(),
173 bounds: Bounds::new(o.x * cw, (1.0 - o.y - o.h) * ch, o.w * cw, o.h * ch),
176 confidence: o.confidence,
177 })
178 .collect())
179}
180
181fn merge_ocr(elements: &mut [UiElement], text_blocks: &mut Vec<TextBlock>, regions: &[OcrRegion]) {
184 let mut known: Vec<String> = Vec::new();
187 for el in elements.iter() {
188 if let Some(n) = el.name.as_deref() {
189 known.push(n.trim().to_lowercase());
190 }
191 }
192 for tb in text_blocks.iter() {
193 known.push(tb.text.trim().to_lowercase());
194 }
195
196 for region in regions {
197 const MIN_CONTAINMENT: f64 = 0.60;
207 let target = elements
208 .iter_mut()
209 .filter(|el| {
210 el.role.is_interactable()
211 && el.is_interactable()
212 && el.name.as_deref().map(str::trim).unwrap_or("").is_empty()
213 && containment_ratio(®ion.bounds, &el.bounds) >= MIN_CONTAINMENT
214 })
215 .min_by(|a, b| {
216 let area = |b: &Bounds| b.width * b.height;
217 area(&a.bounds)
218 .partial_cmp(&area(&b.bounds))
219 .unwrap_or(std::cmp::Ordering::Equal)
220 });
221
222 if let Some(el) = target {
223 el.name = Some(region.text.clone());
224 el.source = ElementSource::Merged {
225 sources: vec![ElementSource::AccessibilityTree, ElementSource::Ocr],
226 };
227 el.confidence = ElementSource::Merged {
230 sources: Vec::new(),
231 }
232 .base_confidence();
233 known.push(region.text.to_lowercase());
234 continue;
235 }
236
237 let lc = region.text.to_lowercase();
243 if !known.iter().any(|k| k == &lc) {
244 text_blocks.push(TextBlock::from_ocr(
245 region.text.clone(),
246 region.bounds,
247 region.confidence,
248 ));
249 known.push(lc);
250 }
251 }
252}
253
254fn containment_ratio(region: &Bounds, el: &Bounds) -> f64 {
258 let ix = region.x.max(el.x);
259 let iy = region.y.max(el.y);
260 let ix2 = (region.x + region.width).min(el.x + el.width);
261 let iy2 = (region.y + region.height).min(el.y + el.height);
262 let inter = (ix2 - ix).max(0.0) * (iy2 - iy).max(0.0);
263 let region_area = region.width * region.height;
264 if region_area <= 0.0 {
265 0.0
266 } else {
267 inter / region_area
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::perception::ui_map::{TextSource, UiRole, UiState};
275
276 fn nameless_button(id: &str, b: Bounds) -> UiElement {
277 UiElement {
278 id: id.to_string(),
279 role: UiRole::Button,
280 name: None,
281 value: None,
282 bounds: b,
283 states: UiState::enabled(),
284 confidence: 0.9,
285 source: ElementSource::AccessibilityTree,
286 icon_type: None,
287 children: vec![],
288 ax_ref: Some(format!("ax-{id}")),
289 }
290 }
291
292 #[test]
293 fn label_recovery_fills_nameless_element_and_marks_merged() {
294 let mut els = vec![nameless_button(
295 "el_0",
296 Bounds::new(100.0, 100.0, 80.0, 30.0),
297 )];
298 let mut tbs: Vec<TextBlock> = vec![];
299 let regions = vec![OcrRegion {
301 text: "Submit".to_string(),
302 bounds: Bounds::new(110.0, 105.0, 60.0, 20.0),
303 confidence: 0.95,
304 }];
305 merge_ocr(&mut els, &mut tbs, ®ions);
306 assert_eq!(els[0].name.as_deref(), Some("Submit"));
307 assert!(matches!(els[0].source, ElementSource::Merged { .. }));
308 assert_eq!(els[0].ax_ref.as_deref(), Some("ax-el_0"));
310 assert!(tbs.is_empty());
312 }
313
314 #[test]
315 fn invisible_text_becomes_ocr_text_block() {
316 let mut els: Vec<UiElement> = vec![];
319 let mut tbs: Vec<TextBlock> = vec![];
320 let regions = vec![OcrRegion {
321 text: "Score: 42".to_string(),
322 bounds: Bounds::new(500.0, 20.0, 90.0, 18.0),
323 confidence: 0.88,
324 }];
325 merge_ocr(&mut els, &mut tbs, ®ions);
326 assert_eq!(tbs.len(), 1);
327 assert_eq!(tbs[0].text, "Score: 42");
328 assert_eq!(tbs[0].source, TextSource::Ocr);
329 }
330
331 #[test]
332 fn ocr_duplicate_of_ax_text_is_dropped() {
333 let mut els = vec![UiElement {
335 name: Some("Welcome back".to_string()),
336 ..nameless_button("el_0", Bounds::new(0.0, 0.0, 300.0, 40.0))
337 }];
338 let mut tbs: Vec<TextBlock> = vec![];
341 let regions = vec![OcrRegion {
342 text: "Welcome back".to_string(),
343 bounds: Bounds::new(800.0, 800.0, 100.0, 20.0), confidence: 0.9,
345 }];
346 merge_ocr(&mut els, &mut tbs, ®ions);
347 assert!(tbs.is_empty(), "duplicate of an AX name must be dropped");
348 }
349
350 #[test]
351 fn css_viewport_prefers_rootwebarea_over_passed_viewport() {
352 use crate::models::A11yNode;
353 let nodes = vec![A11yNode {
357 node_id: "root".into(),
358 role: "RootWebArea".into(),
359 name: None,
360 value: None,
361 bounds: Bounds::new(0.0, 0.0, 800.0, 600.0),
362 children: vec![],
363 focusable: false,
364 focused: false,
365 disabled: false,
366 }];
367 let vp = Viewport {
368 width: 1280,
369 height: 720,
370 device_pixel_ratio: 1.0,
371 };
372 assert_eq!(css_viewport_from_ax(&nodes, vp), (800.0, 600.0));
373 }
374
375 #[test]
376 fn css_viewport_falls_back_to_max_extent_then_viewport() {
377 use crate::models::A11yNode;
378 let nodes = vec![A11yNode {
380 node_id: "b".into(),
381 role: "button".into(),
382 name: Some("x".into()),
383 value: None,
384 bounds: Bounds::new(10.0, 20.0, 100.0, 30.0), children: vec![],
386 focusable: true,
387 focused: false,
388 disabled: false,
389 }];
390 let vp = Viewport {
391 width: 1280,
392 height: 720,
393 device_pixel_ratio: 1.0,
394 };
395 assert_eq!(css_viewport_from_ax(&nodes, vp), (110.0, 50.0));
396 assert_eq!(css_viewport_from_ax(&[], vp), (1280.0, 720.0));
398 }
399
400 #[test]
401 fn named_element_is_not_relabeled() {
402 let mut els = vec![UiElement {
405 name: Some("Sign in".to_string()),
406 ..nameless_button("el_0", Bounds::new(100.0, 100.0, 80.0, 30.0))
407 }];
408 let mut tbs: Vec<TextBlock> = vec![];
409 let regions = vec![OcrRegion {
410 text: "garbled ocr".to_string(),
411 bounds: Bounds::new(120.0, 108.0, 40.0, 14.0),
412 confidence: 0.4,
413 }];
414 merge_ocr(&mut els, &mut tbs, ®ions);
415 assert_eq!(els[0].name.as_deref(), Some("Sign in"));
416 assert!(matches!(els[0].source, ElementSource::AccessibilityTree));
417 }
418}