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}