Skip to main content

car_inference/tasks/
grounding.rs

1//! Visual grounding — extract structured object-localization output
2//! from a VL model's text response.
3//!
4//! Qwen2.5-VL is trained to emit bounding boxes inline in its text
5//! using Qwen-specific delimiters:
6//!
7//! ```text
8//! <|object_ref_start|>person<|object_ref_end|><|box_start|>(123,456),(789,1011)<|box_end|>
9//! ```
10//!
11//! The two spans pair 1:1: every `<|object_ref_*|>` label binds to the
12//! following `<|box_*|>` coordinate pair (xyxy, top-left + bottom-right
13//! in pixel space of the *input image*). Some outputs omit the label
14//! span when the user's prompt already named the object.
15//!
16//! This module provides a request/result surface for "ask a VL model
17//! to localize things" and a parser that turns Qwen's text output into
18//! `Vec<BoundingBox>`.
19
20use crate::tasks::generate::ContentBlock;
21use serde::{Deserialize, Serialize};
22
23/// A request for structured visual grounding.
24///
25/// Unlike [`crate::GenerateRequest`], this is the dedicated entry
26/// point for localization tasks — callers get back typed
27/// [`BoundingBox`]es rather than having to grep them out of a text
28/// response. Internally it still runs through the generate path and
29/// parses Qwen-style spans from the output; the dedicated type exists
30/// so callers can express intent explicitly and so future routing can
31/// prefer models with the `Grounding` capability.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct GroundRequest {
34    /// Image content to ground against. Exactly one image is expected
35    /// — multi-image grounding is provider-specific and not yet
36    /// standardized across VL models.
37    pub image: ContentBlock,
38    /// Free-form text prompt describing what to localize
39    /// (e.g. "Find all the street signs").
40    pub prompt: String,
41    /// Optional model override. When unset, routes to the preferred
42    /// model declaring the `Grounding` capability.
43    #[serde(default)]
44    pub model: Option<String>,
45    /// Optional explicit label list. When provided, callers can
46    /// post-filter the returned boxes to these label names. Not
47    /// currently sent to the model (Qwen2.5-VL derives labels from
48    /// the prompt); reserved for providers that accept a controlled
49    /// vocabulary on the request.
50    #[serde(default)]
51    pub labels: Option<Vec<String>>,
52}
53
54/// Result of a [`GroundRequest`].
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct GroundResult {
57    /// Detected objects with their pixel-space xyxy boxes.
58    pub boxes: Vec<BoundingBox>,
59    /// Raw model output text, preserved so callers can see any
60    /// non-box narration the model produced (e.g. "I can't see any
61    /// street signs in this image").
62    pub raw_text: String,
63    /// Name of the model that produced this result, when known.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub model_used: Option<String>,
66}
67
68/// A single detection emitted by a VL model's grounding head.
69///
70/// Coordinates are in *pixel space of the input image*, following
71/// Qwen2.5-VL's **inclusive xyxy** convention — the same as COCO and
72/// standard detection reference implementations. Both corners are
73/// inclusive: width is `x2 - x1 + 1`, height is `y2 - y1 + 1`.
74/// `label` is the model-supplied object reference; may be empty when
75/// the prompt already named the subject ("find the dog").
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
77pub struct BoundingBox {
78    /// Inclusive left pixel.
79    pub x1: u32,
80    /// Inclusive top pixel.
81    pub y1: u32,
82    /// Inclusive right pixel (width = `x2 - x1 + 1`).
83    pub x2: u32,
84    /// Inclusive bottom pixel (height = `y2 - y1 + 1`).
85    pub y2: u32,
86    /// Object label the model attached to this box. Empty when no
87    /// `<|object_ref_*|>` span preceded the box.
88    #[serde(default, skip_serializing_if = "String::is_empty")]
89    pub label: String,
90    /// Model-reported confidence in `[0.0, 1.0]` when available.
91    /// Qwen2.5-VL does not currently emit per-box confidences inline;
92    /// this field exists for forward compat with richer backends.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub confidence: Option<f32>,
95}
96
97impl BoundingBox {
98    /// Pixel width of this box. Inclusive xyxy convention means a
99    /// single-pixel box has width 1, not 0, so we add 1.
100    pub fn width(&self) -> u32 {
101        self.x2.saturating_sub(self.x1).saturating_add(1)
102    }
103
104    /// Pixel height of this box. Same inclusive convention as [`width`](crate::tasks::grounding::BoundingBox::width).
105    pub fn height(&self) -> u32 {
106        self.y2.saturating_sub(self.y1).saturating_add(1)
107    }
108
109    /// Pixel area. Convenience for ranking / IoU callers.
110    pub fn area(&self) -> u64 {
111        self.width() as u64 * self.height() as u64
112    }
113}
114
115/// Parse Qwen2.5-VL grounding spans out of a model text response.
116///
117/// Recognizes the upstream delimiter pairs
118/// (`<|object_ref_start|>...<|object_ref_end|>` followed by
119/// `<|box_start|>(x1,y1),(x2,y2)<|box_end|>`) and returns one
120/// [`BoundingBox`] per `<|box_*|>` span. Returns an empty vec when
121/// the output contains no boxes — typical for "just describe"
122/// prompts.
123///
124/// Robust to the common variant where the model emits a bare
125/// `<|box_*|>` span without a preceding label (the box gets an empty
126/// `label`), and to extra whitespace inside the coordinate tuple.
127pub fn parse_boxes(text: &str) -> Vec<BoundingBox> {
128    const LABEL_OPEN: &str = "<|object_ref_start|>";
129    const LABEL_CLOSE: &str = "<|object_ref_end|>";
130    const BOX_OPEN: &str = "<|box_start|>";
131    const BOX_CLOSE: &str = "<|box_end|>";
132
133    // Fast-path: plain text responses (the overwhelming majority of
134    // generate calls) have zero `<|box_start|>` occurrences. Bail
135    // before allocating the output Vec so the ungated parser cost
136    // on the generate hot path is a single `str::find`.
137    if !text.contains(BOX_OPEN) {
138        return Vec::new();
139    }
140
141    let mut out = Vec::new();
142    let mut rest = text;
143    while let Some(box_pos) = rest.find(BOX_OPEN) {
144        // Everything before this box, look backward for a label span.
145        let before = &rest[..box_pos];
146        let label = if let Some(lo) = before.rfind(LABEL_OPEN) {
147            if let Some(lc) = before[lo + LABEL_OPEN.len()..].find(LABEL_CLOSE) {
148                let start = lo + LABEL_OPEN.len();
149                let end = start + lc;
150                before[start..end].trim().to_string()
151            } else {
152                String::new()
153            }
154        } else {
155            String::new()
156        };
157
158        let coord_start = box_pos + BOX_OPEN.len();
159        let close_rel = match rest[coord_start..].find(BOX_CLOSE) {
160            Some(p) => p,
161            None => break, // Unterminated box — bail.
162        };
163        let coord_body = &rest[coord_start..coord_start + close_rel];
164        if let Some(b) = parse_coord_pair(coord_body, label) {
165            out.push(b);
166        }
167        rest = &rest[coord_start + close_rel + BOX_CLOSE.len()..];
168    }
169    out
170}
171
172/// Parse `(x1,y1),(x2,y2)` — tolerant of whitespace inside and around
173/// the pairs. Returns `None` if any coordinate fails to parse.
174fn parse_coord_pair(s: &str, label: String) -> Option<BoundingBox> {
175    // Collect numeric tokens in order; first four are x1,y1,x2,y2.
176    let mut nums = Vec::with_capacity(4);
177    let mut cur = String::new();
178    for ch in s.chars() {
179        if ch.is_ascii_digit() {
180            cur.push(ch);
181        } else if !cur.is_empty() {
182            nums.push(cur.parse::<u32>().ok()?);
183            cur.clear();
184            if nums.len() == 4 {
185                break;
186            }
187        }
188    }
189    if !cur.is_empty() && nums.len() < 4 {
190        nums.push(cur.parse::<u32>().ok()?);
191    }
192    if nums.len() < 4 {
193        return None;
194    }
195    Some(BoundingBox {
196        x1: nums[0],
197        y1: nums[1],
198        x2: nums[2],
199        y2: nums[3],
200        label,
201        confidence: None,
202    })
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn empty_text_yields_no_boxes() {
211        assert!(parse_boxes("").is_empty());
212        assert!(parse_boxes("just a plain description").is_empty());
213    }
214
215    #[test]
216    fn single_labeled_box_parses() {
217        let text =
218            "<|object_ref_start|>dog<|object_ref_end|><|box_start|>(10,20),(110,220)<|box_end|>";
219        let boxes = parse_boxes(text);
220        assert_eq!(boxes.len(), 1);
221        assert_eq!(boxes[0].label, "dog");
222        assert_eq!(boxes[0].x1, 10);
223        assert_eq!(boxes[0].y1, 20);
224        assert_eq!(boxes[0].x2, 110);
225        assert_eq!(boxes[0].y2, 220);
226    }
227
228    #[test]
229    fn box_without_label_has_empty_label() {
230        let text = "The object is at <|box_start|>(1,2),(3,4)<|box_end|> pixels.";
231        let boxes = parse_boxes(text);
232        assert_eq!(boxes.len(), 1);
233        assert!(boxes[0].label.is_empty());
234    }
235
236    #[test]
237    fn multiple_boxes_pair_with_preceding_labels() {
238        let text = "<|object_ref_start|>a<|object_ref_end|><|box_start|>(0,0),(1,1)<|box_end|> \
239                    <|object_ref_start|>b<|object_ref_end|><|box_start|>(2,2),(3,3)<|box_end|>";
240        let boxes = parse_boxes(text);
241        assert_eq!(boxes.len(), 2);
242        assert_eq!(boxes[0].label, "a");
243        assert_eq!(boxes[1].label, "b");
244    }
245
246    #[test]
247    fn whitespace_inside_coordinate_tuple_is_tolerated() {
248        let text = "<|box_start|>( 10 , 20 ),( 30 , 40 )<|box_end|>";
249        let boxes = parse_boxes(text);
250        assert_eq!(boxes.len(), 1);
251        assert_eq!(boxes[0].x1, 10);
252        assert_eq!(boxes[0].x2, 30);
253    }
254
255    #[test]
256    fn unterminated_box_is_dropped_not_crashing() {
257        let text = "<|box_start|>(1,2),(3,4 -- and then no close tag";
258        assert!(parse_boxes(text).is_empty());
259    }
260
261    #[test]
262    fn malformed_coords_yield_no_box() {
263        // Too few numbers — drop this one.
264        let text = "<|box_start|>(1,2,3)<|box_end|>";
265        assert!(parse_boxes(text).is_empty());
266    }
267
268    #[test]
269    fn inclusive_xyxy_semantics_width_height_area() {
270        // Qwen2.5-VL emits inclusive xyxy: a single-pixel box means
271        // width=1, not 0. (Neo flagged the original exclusive-variant
272        // docs as a systematic −1 drift on every downstream metric.)
273        let single = BoundingBox {
274            x1: 10,
275            y1: 20,
276            x2: 10,
277            y2: 20,
278            label: String::new(),
279            confidence: None,
280        };
281        assert_eq!(single.width(), 1);
282        assert_eq!(single.height(), 1);
283        assert_eq!(single.area(), 1);
284
285        let wide = BoundingBox {
286            x1: 0,
287            y1: 0,
288            x2: 99,
289            y2: 49,
290            label: String::new(),
291            confidence: None,
292        };
293        assert_eq!(wide.width(), 100);
294        assert_eq!(wide.height(), 50);
295        assert_eq!(wide.area(), 5_000);
296    }
297
298    #[test]
299    fn boxes_mixed_with_prose_parse_cleanly() {
300        let text = "Here is a picture of a dog <|object_ref_start|>Labrador<|object_ref_end|>\
301                    <|box_start|>(100,200),(300,400)<|box_end|> and its toy \
302                    <|object_ref_start|>ball<|object_ref_end|><|box_start|>(10,20),(40,50)<|box_end|>.";
303        let boxes = parse_boxes(text);
304        assert_eq!(boxes.len(), 2);
305        assert_eq!(boxes[0].label, "Labrador");
306        assert_eq!(boxes[1].label, "ball");
307    }
308}