Skip to main content

car_browser/perception/
vision.rs

1//! Vision-augmented perception: accessibility tree + OCR fusion.
2//!
3//! The AX-only pipeline ([`super::pipeline::BasicPerceptionPipeline`]) is blind
4//! to anything the accessibility tree omits — canvas/WebGL-rendered UIs, text
5//! baked into images, and controls with an empty accessible name. This pipeline
6//! runs OCR over the screenshot and fuses the result with the AX elements two
7//! ways:
8//!
9//! - **Label recovery:** an interactable AX element with no accessible name
10//!   adopts the label from an OCR text region sitting on it, and its source
11//!   becomes [`ElementSource::Merged`]. The element's `ax_ref` (and therefore
12//!   deterministic execution) is unchanged — only the human-readable handle is
13//!   recovered.
14//! - **Invisible text:** OCR text not already present in the AX tree is added
15//!   as an `TextSource::Ocr` text block, so the agent can read canvas/image
16//!   text it otherwise couldn't see.
17//!
18//! OCR cannot tell whether a pixel region is *clickable*, so this pipeline never
19//! fabricates interactive elements out of OCR regions — doing so would invite
20//! the agent to "click" non-controls. A future visual element detector is what
21//! would legitimately populate [`ElementSource::VisualDetector`]; the merge slot
22//! is already here for it.
23//!
24//! OCR is gated at runtime by [`car_vision::is_available`] (Apple Vision on
25//! macOS, Tesseract CLI elsewhere). When no backend is present, or OCR fails,
26//! the pipeline degrades cleanly to AX-only output.
27
28use 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/// A recognized OCR text region, already converted into the top-left CSS-pixel
38/// space the AX-tree bounds use.
39#[derive(Debug, Clone)]
40struct OcrRegion {
41    text: String,
42    bounds: Bounds,
43    confidence: f32,
44}
45
46/// AX tree + OCR perception pipeline. See module docs.
47pub 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        // OCR augmentation is best-effort: never fail perception because OCR
81        // is unavailable or errored — fall back to the AX-only result.
82        if !screenshot.is_empty() && car_vision::is_available() {
83            let bytes = screenshot.to_vec();
84            // Scale OCR's normalized coords by the *actual* CSS viewport, taken
85            // from the AX tree (the same coordinate space the element bounds
86            // live in) — NOT the passed `viewport`, which some backends report
87            // from launch config rather than the live rendered size. Getting
88            // this wrong stretches OCR regions off their controls and breaks
89            // label recovery (caught by the live test).
90            let (cw, ch) = css_viewport_from_ax(a11y_nodes, viewport);
91            // car-vision's `recognize` is synchronous and (on macOS) crosses an
92            // FFI boundary, so run it off the async executor.
93            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, &regions);
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
115/// The CSS-pixel viewport the AX element bounds are expressed in. Prefers the
116/// root web-area node's size (Chrome reports it as the layout viewport); falls
117/// back to the maximum extent of all node bounds, then to the passed viewport.
118/// This is the coordinate space OCR regions must be scaled into so overlap with
119/// element bounds is meaningful.
120fn 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
144/// Write the screenshot to a temp PNG, run OCR, and convert each observation
145/// from Vision's normalized bottom-left space into top-left CSS pixels, scaled
146/// by the CSS viewport (`cw` × `ch`) the AX bounds use.
147fn 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        // Perception is latency-sensitive and the labels we recover are short;
158        // the fast path is the right trade here.
159        fast_path: true,
160        languages: Vec::new(),
161        language_correction: true,
162        // Drop sub-pixel noise text; it can't be a useful control label.
163        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            // Vision: normalized [0,1], origin bottom-left, y grows up. Flip Y
174            // and scale to CSS pixels to align with the AX-tree bounds.
175            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
181/// Fuse OCR regions into the AX-derived elements and text blocks. See module
182/// docs for the two behaviors (label recovery, invisible text).
183fn merge_ocr(elements: &mut [UiElement], text_blocks: &mut Vec<TextBlock>, regions: &[OcrRegion]) {
184    // Lowercased corpus of text the AX tree already exposes, for dedup of
185    // invisible-text candidates.
186    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        // Label recovery: the smallest nameless interactable element that the
198        // OCR region sits substantially *inside* adopts the OCR text. We require
199        // strong containment (≥60% of the region's area inside the element), not
200        // just a center hit — this is the load-bearing safety property. OCR
201        // bounds are viewport-space; the AX bounds are whatever `getBoxModel`
202        // reports. If those spaces ever diverge (e.g. a scrolled page where the
203        // AX bounds turn out document-relative), strong containment simply fails
204        // to match and the element is left AX-only — a missed enrichment, never
205        // a *wrong* label grafted onto the wrong control.
206        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(&region.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            // Two corroborating sources (AX existence + OCR label) — adopt the
228            // merged confidence prior.
229            el.confidence = ElementSource::Merged {
230                sources: Vec::new(),
231            }
232            .base_confidence();
233            known.push(region.text.to_lowercase());
234            continue;
235        }
236
237        // Invisible text: surface OCR text the AX tree doesn't already carry.
238        // Dedup on EXACT (case-insensitive) equality only — substring matching
239        // over-suppresses legitimately distinct text (the OCR token "OK" is a
240        // substring of an AX label "BOOK"). A rare duplicate line in context is
241        // far cheaper than silently dropping real on-screen text.
242        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
254/// Fraction of `region`'s area that lies inside `el` (0.0–1.0). Used to decide
255/// whether an OCR text region sits on a control. Returns 0 for a zero-area
256/// region.
257fn 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        // OCR text whose center (140,115) sits on the button.
300        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, &regions);
306        assert_eq!(els[0].name.as_deref(), Some("Submit"));
307        assert!(matches!(els[0].source, ElementSource::Merged { .. }));
308        // ax_ref (execution handle) is untouched.
309        assert_eq!(els[0].ax_ref.as_deref(), Some("ax-el_0"));
310        // Consumed as a label, not duplicated as a text block.
311        assert!(tbs.is_empty());
312    }
313
314    #[test]
315    fn invisible_text_becomes_ocr_text_block() {
316        // No element under the OCR region → it's canvas/image text the AX tree
317        // missed; surface it as an Ocr text block.
318        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, &regions);
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        // OCR re-reading text the AX tree already exposes must not double it.
334        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        // Element already named, so it's not a label-recovery target; the OCR
339        // region duplicating its text should be deduped, not added.
340        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), // far from the element
344            confidence: 0.9,
345        }];
346        merge_ocr(&mut els, &mut tbs, &regions);
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        // A backend that mis-reports the viewport (launch config 1280x720) but
354        // whose AX root web area is the real 800x600 layout viewport — OCR must
355        // scale by 800x600 or label recovery breaks (the live-test bug).
356        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        // No web-area node → use the max extent of element bounds.
379        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), // extent 110x50
385            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        // Empty tree → fall back to the passed viewport.
397        assert_eq!(css_viewport_from_ax(&[], vp), (1280.0, 720.0));
398    }
399
400    #[test]
401    fn named_element_is_not_relabeled() {
402        // A control that already has a good name must not be overwritten by an
403        // overlapping OCR region.
404        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, &regions);
415        assert_eq!(els[0].name.as_deref(), Some("Sign in"));
416        assert!(matches!(els[0].source, ElementSource::AccessibilityTree));
417    }
418}