rpx_benchmark.tasks.robot_vqa_v2

RPX Robot VQA v2 — three context modes, all GT from recorded data.

Every question can be asked in three ways:

  • image_only: [reference photo] + [scene frame]
  • image_text: [reference photo] + text description + [scene frame]
  • text_only: text description only + [scene frame]

The ground truth is IDENTICAL across modes — it comes from masks + questionnaire + bounding boxes, never from any VLM or LLM.

The model being evaluated receives the prompt in one mode and must produce the answer. Comparing accuracy across modes reveals which prompting strategy is most robust for robot deployment.

GT sources:

  • Instance masks (SAM2 + human refinement) → visibility, bbox, count
  • FewSOL questionnaire (MTurk) → category, color, material, shape
  • Bounding box centroids (from masks) → spatial relations
  • Phase diff (clutter vs clean) → state changes
  • Single-object scenes → reference images
  1"""RPX Robot VQA v2 — three context modes, all GT from recorded data.
  2
  3Every question can be asked in three ways:
  4  - image_only:    [reference photo] + [scene frame]
  5  - image_text:    [reference photo] + text description + [scene frame]
  6  - text_only:     text description only + [scene frame]
  7
  8The ground truth is IDENTICAL across modes — it comes from masks +
  9questionnaire + bounding boxes, never from any VLM or LLM.
 10
 11The model being evaluated receives the prompt in one mode and must
 12produce the answer. Comparing accuracy across modes reveals which
 13prompting strategy is most robust for robot deployment.
 14
 15GT sources:
 16  - Instance masks (SAM2 + human refinement) → visibility, bbox, count
 17  - FewSOL questionnaire (MTurk) → category, color, material, shape
 18  - Bounding box centroids (from masks) → spatial relations
 19  - Phase diff (clutter vs clean) → state changes
 20  - Single-object scenes → reference images
 21"""
 22
 23from __future__ import annotations
 24
 25import hashlib
 26import random
 27from dataclasses import dataclass, field
 28from typing import Dict, List, Optional, Tuple
 29
 30
 31@dataclass
 32class RobotVQA:
 33    """One question with all three context modes.
 34
 35    The model is evaluated in ONE mode at a time. The GT is the same
 36    regardless of mode.
 37    """
 38
 39    scene_id: str
 40    phase: int
 41    frame_id: str
 42    task: str  # recognition | attribute | spatial | state | counting
 43
 44    # The question in each context mode
 45    image_only_prompt: str  # what to show/ask with reference image only
 46    image_text_prompt: str  # what to show/ask with reference image + text
 47    text_only_prompt: str  # what to ask with text only
 48
 49    reference_image: Optional[str]  # path to single-object reference (None for text-only tasks)
 50    scene_frame: str  # path to the scene frame being evaluated
 51
 52    correct_answer: str
 53    options: List[str]  # 4-choice MCQ
 54    correct_index: int
 55
 56    robot_operation: str  # what pipeline stage this tests
 57    metadata: Dict = field(default_factory=dict)
 58
 59
 60def _seed(s: str, p: int, f: str, tag: str) -> int:
 61    return int(hashlib.md5(f"{s}/{p}/{f}/{tag}".encode()).hexdigest()[:8], 16)
 62
 63
 64def _shuffle(correct: str, distractors: List[str], seed: int) -> Tuple[List[str], int]:
 65    opts = [correct] + distractors[:3]
 66    random.Random(seed).shuffle(opts)
 67    return opts, opts.index(correct)
 68
 69
 70# ─── Recognition: "Is this object in the scene?" ──────────────────────────
 71
 72
 73def gen_recognition(
 74    visible: List[Dict],  # objects visible in this frame
 75    ref_images: Dict[str, str],  # {category: path} from single-object scenes
 76    absent_cats: List[str],  # categories NOT visible
 77    questionnaire: Dict[str, Dict],  # {category: {color, material, shape}}
 78    scene_id: str,
 79    phase: int,
 80    frame_id: str,
 81    scene_frame: str,
 82) -> List[RobotVQA]:
 83    """Object recognition in three modes.
 84
 85    Same GT (yes/no from mask visibility), different prompts.
 86    """
 87    pairs = []
 88    vis_cats = {o["category"] for o in visible}
 89
 90    # Positive examples
 91    for cat in sorted(vis_cats)[:3]:
 92        if cat not in ref_images:
 93            continue
 94        attrs = questionnaire.get(cat, {})
 95        color = attrs.get("color", "")
 96        material = attrs.get("material", "")
 97        desc = f"{color} {material} {cat}".strip()
 98
 99        s = _seed(scene_id, phase, frame_id, f"rec_yes_{cat}")
100        opts, idx = _shuffle("yes", ["no", "similar but different object", "cannot determine"], s)
101
102        pairs.append(
103            RobotVQA(
104                scene_id=scene_id,
105                phase=phase,
106                frame_id=frame_id,
107                task="recognition",
108                image_only_prompt="Is the object shown in the reference image present in this scene?",
109                image_text_prompt=f"Is the {desc} shown in the reference image present in this scene?",
110                text_only_prompt=f"Is there a {desc} in this scene?",
111                reference_image=ref_images[cat],
112                scene_frame=scene_frame,
113                correct_answer="yes",
114                options=opts,
115                correct_index=idx,
116                robot_operation="object_registration",
117                metadata={"category": cat, "present": True},
118            )
119        )
120
121    # Negative examples (balanced 50/50)
122    n_neg = 0
123    for cat in absent_cats:
124        if cat not in ref_images or n_neg >= len([p for p in pairs if p.metadata.get("present")]):
125            continue
126        attrs = questionnaire.get(cat, {})
127        color = attrs.get("color", "")
128        material = attrs.get("material", "")
129        desc = f"{color} {material} {cat}".strip()
130
131        s = _seed(scene_id, phase, frame_id, f"rec_no_{cat}")
132        opts, idx = _shuffle("no", ["yes", "partially visible", "cannot determine"], s)
133
134        pairs.append(
135            RobotVQA(
136                scene_id=scene_id,
137                phase=phase,
138                frame_id=frame_id,
139                task="recognition",
140                image_only_prompt="Is the object shown in the reference image present in this scene?",
141                image_text_prompt=f"Is the {desc} shown in the reference image present in this scene?",
142                text_only_prompt=f"Is there a {desc} in this scene?",
143                reference_image=ref_images[cat],
144                scene_frame=scene_frame,
145                correct_answer="no",
146                options=opts,
147                correct_index=idx,
148                robot_operation="object_registration",
149                metadata={"category": cat, "present": False},
150            )
151        )
152        n_neg += 1
153
154    return pairs
155
156
157# ─── Attribute grounding: "Pick the RED mug" ──────────────────────────────
158
159
160def gen_attribute(
161    visible: List[Dict],
162    ref_images: Dict[str, str],
163    questionnaire: Dict[str, Dict],
164    all_objects: List[Dict],
165    scene_id: str,
166    phase: int,
167    frame_id: str,
168    scene_frame: str,
169) -> List[RobotVQA]:
170    """Attribute grounding — only fires when disambiguation is needed."""
171    pairs = []
172    from collections import Counter
173
174    cat_counts = Counter(o["category"] for o in visible)
175
176    for cat, count in cat_counts.items():
177        if count < 2:
178            continue  # no disambiguation needed
179        instances = [o for o in visible if o["category"] == cat]
180
181        for obj in instances[:2]:
182            color = obj.get("color", "")
183            if not color:
184                continue
185            other_colors = [o.get("color", "?") for o in instances if o.get("color") != color]
186
187            s = _seed(scene_id, phase, frame_id, f"attr_{cat}_{color}")
188            gt = f"the {color} {cat}"
189            distractors = [f"the {c} {cat}" for c in other_colors[:2]] + [f"any {cat}"]
190            while len(distractors) < 3:
191                distractors.append("none of these")
192            opts, idx = _shuffle(gt, distractors[:3], s)
193
194            ref_path = ref_images.get(cat)
195            pairs.append(
196                RobotVQA(
197                    scene_id=scene_id,
198                    phase=phase,
199                    frame_id=frame_id,
200                    task="attribute",
201                    image_only_prompt=f"Which of the visible {cat} objects matches the reference image?",
202                    image_text_prompt=f"Which object is the {color} {cat}?",
203                    text_only_prompt=f"A robot must pick the {color} {cat}. Which object should it grasp?",
204                    reference_image=ref_path,
205                    scene_frame=scene_frame,
206                    correct_answer=gt,
207                    options=opts,
208                    correct_index=idx,
209                    robot_operation="language_grounding",
210                    metadata={"category": cat, "target_color": color},
211                )
212            )
213
214    return pairs
215
216
217# ─── Spatial grounding: "Pick the object LEFT of the bowl" ────────────────
218
219
220def gen_spatial(
221    visible: List[Dict],  # must have bbox_cx, bbox_cy
222    scene_id: str,
223    phase: int,
224    frame_id: str,
225    scene_frame: str,
226) -> List[RobotVQA]:
227    """Spatial grounding — image-plane relations only. Text-only task."""
228    pairs = []
229    if len(visible) < 2:
230        return pairs
231
232    for ref in visible[:3]:
233        cx, cy = ref.get("bbox_cx"), ref.get("bbox_cy")
234        if cx is None:
235            continue
236
237        # Bind cx/cy at lambda definition time (default args) so each loop
238        # iteration captures the current ref's centroid, not the last one.
239        for direction, check in [
240            ("left", lambda o, cx=cx: o.get("bbox_cx", cx) < cx),
241            ("right", lambda o, cx=cx: o.get("bbox_cx", cx) > cx),
242            ("above", lambda o, cy=cy: o.get("bbox_cy", cy) < cy),
243            ("below", lambda o, cy=cy: o.get("bbox_cy", cy) > cy),
244        ]:
245            candidates = [
246                o for o in visible if o is not ref and check(o) and o.get("bbox_cx") is not None
247            ]
248            if not candidates:
249                continue
250            # Nearest
251            target = min(
252                candidates,
253                key=lambda o, cx=cx, cy=cy: abs(o["bbox_cx"] - cx) + abs(o["bbox_cy"] - cy),
254            )
255            gt = target["category"]
256
257            s = _seed(scene_id, phase, frame_id, f"sp_{direction}_{ref['category']}")
258            others = list(
259                {o["category"] for o in visible if o["category"] not in (gt, ref["category"])}
260            )
261            others += ["nothing"]
262            distractors = random.Random(s).sample(others, min(3, len(others)))
263            while len(distractors) < 3:
264                distractors.append("cannot determine")
265            opts, idx = _shuffle(gt, distractors[:3], s)
266
267            pairs.append(
268                RobotVQA(
269                    scene_id=scene_id,
270                    phase=phase,
271                    frame_id=frame_id,
272                    task="spatial",
273                    image_only_prompt=f"What object is {direction} of the {ref['category']}?",
274                    image_text_prompt=f"What object is {direction} of the {ref['category']}?",
275                    text_only_prompt=f"A robot must pick the object {direction} of the {ref['category']}. What should it pick?",
276                    reference_image=None,
277                    scene_frame=scene_frame,
278                    correct_answer=gt,
279                    options=opts,
280                    correct_index=idx,
281                    robot_operation="spatial_command",
282                    metadata={"reference": ref["category"], "direction": direction},
283                )
284            )
285            break  # one per direction per ref
286
287    return pairs
288
289
290# ─── State verification: "Did the pick succeed?" ─────────────────────────
291
292
293def gen_state_verification(
294    clutter_objects: List[Dict],
295    clean_objects: List[Dict],
296    scene_id: str,
297    frame_id: str,
298    clutter_frame: str,
299    clean_frame: str,
300) -> List[RobotVQA]:
301    """State verification — compare two phases. Uses TWO frames as input."""
302    pairs = []
303    clu_cats = {o["category"] for o in clutter_objects}
304    cln_cats = {o["category"] for o in clean_objects}
305    removed = clu_cats - cln_cats
306    remained = clu_cats & cln_cats
307
308    for obj_cat in list(removed)[:2]:
309        s = _seed(scene_id, 0, frame_id, f"state_{obj_cat}")
310        distractors = list(remained)[:3]
311        while len(distractors) < 3:
312            distractors.append("nothing changed")
313        opts, idx = _shuffle(obj_cat, distractors[:3], s)
314
315        pairs.append(
316            RobotVQA(
317                scene_id=scene_id,
318                phase=0,
319                frame_id=frame_id,
320                task="state",
321                image_only_prompt="Compare these two images. Which object was removed?",
322                image_text_prompt="Compare the before and after images. Which object was removed from the scene?",
323                text_only_prompt="After the robot's manipulation, which object is no longer on the table?",
324                reference_image=clutter_frame,  # "before" image
325                scene_frame=clean_frame,  # "after" image
326                correct_answer=obj_cat,
327                options=opts,
328                correct_index=idx,
329                robot_operation="task_verification",
330                metadata={"change_type": "removed", "object": obj_cat},
331            )
332        )
333
334    return pairs
335
336
337# ─── Counting: "How many left to pack?" ──────────────────────────────────
338
339
340def gen_counting(
341    visible: List[Dict],
342    scene_id: str,
343    phase: int,
344    frame_id: str,
345    scene_frame: str,
346) -> List[RobotVQA]:
347    """Counting — text-only task, GT from instance masks."""
348    pairs = []
349    from collections import Counter
350
351    counts = Counter(o["category"] for o in visible)
352
353    for cat, count in list(counts.items())[:3]:
354        s = _seed(scene_id, phase, frame_id, f"cnt_{cat}")
355        gt = str(count)
356        pool = [str(i) for i in range(max(count + 3, 5)) if str(i) != gt]
357        distractors = random.Random(s).sample(pool, min(3, len(pool)))
358        while len(distractors) < 3:
359            distractors.append("0")
360        opts, idx = _shuffle(gt, distractors[:3], s)
361
362        pairs.append(
363            RobotVQA(
364                scene_id=scene_id,
365                phase=phase,
366                frame_id=frame_id,
367                task="counting",
368                image_only_prompt=f"How many {cat} objects are in this scene?",
369                image_text_prompt=f"How many {cat} objects are visible in this scene?",
370                text_only_prompt=f"A robot is packing all {cat} objects. How many does it need to pick?",
371                reference_image=None,
372                scene_frame=scene_frame,
373                correct_answer=gt,
374                options=opts,
375                correct_index=idx,
376                robot_operation="task_progress",
377                metadata={"category": cat, "count": count},
378            )
379        )
380
381    return pairs
382
383
384# ─── Evaluation ──────────────────────────────────────────────────────────
385
386CONTEXT_MODES = ("image_only", "image_text", "text_only")
387
388
389def evaluate(
390    predictions: Dict[str, List[int]],  # {mode: [pred_indices]}
391    ground_truth: List[RobotVQA],
392) -> Dict:
393    """Evaluate predictions across context modes.
394
395    Args:
396        predictions: {mode_name: list of predicted option indices}
397                     e.g. {"image_only": [0,1,3,...], "text_only": [2,0,1,...]}
398        ground_truth: list of RobotVQA
399
400    Returns:
401        Per-mode accuracy + per-task + per-phase + cross-mode comparison
402    """
403    results = {}
404    for mode, preds in predictions.items():
405        assert len(preds) == len(ground_truth), (
406            f"{mode}: {len(preds)} preds vs {len(ground_truth)} GT"
407        )
408
409        by_task, by_phase = {}, {}
410        correct = 0
411        for pred, qa in zip(preds, ground_truth, strict=False):
412            hit = pred == qa.correct_index
413            correct += hit
414            by_task.setdefault(qa.task, []).append(hit)
415            by_phase.setdefault(qa.phase, []).append(hit)
416
417        n = len(preds)
418        pa = {p: sum(v) / len(v) for p, v in by_phase.items()}
419        mode_result = {
420            "accuracy": correct / n if n else 0.0,
421            "per_task": {t: sum(v) / len(v) for t, v in by_task.items()},
422            "per_phase": pa,
423        }
424        if 0 in pa and 1 in pa:
425            mode_result["str_clu_to_int"] = pa[1] - pa[0]
426        if 1 in pa and 2 in pa:
427            mode_result["str_int_to_cln"] = pa[2] - pa[1]
428        results[mode] = mode_result
429
430    # Cross-mode comparison: which mode is most robust?
431    if len(results) > 1:
432        results["mode_comparison"] = {
433            mode: r["accuracy"] for mode, r in results.items() if mode != "mode_comparison"
434        }
435
436    return results
@dataclass
class RobotVQA:
32@dataclass
33class RobotVQA:
34    """One question with all three context modes.
35
36    The model is evaluated in ONE mode at a time. The GT is the same
37    regardless of mode.
38    """
39
40    scene_id: str
41    phase: int
42    frame_id: str
43    task: str  # recognition | attribute | spatial | state | counting
44
45    # The question in each context mode
46    image_only_prompt: str  # what to show/ask with reference image only
47    image_text_prompt: str  # what to show/ask with reference image + text
48    text_only_prompt: str  # what to ask with text only
49
50    reference_image: Optional[str]  # path to single-object reference (None for text-only tasks)
51    scene_frame: str  # path to the scene frame being evaluated
52
53    correct_answer: str
54    options: List[str]  # 4-choice MCQ
55    correct_index: int
56
57    robot_operation: str  # what pipeline stage this tests
58    metadata: Dict = field(default_factory=dict)

One question with all three context modes.

The model is evaluated in ONE mode at a time. The GT is the same regardless of mode.

RobotVQA( scene_id: str, phase: int, frame_id: str, task: str, image_only_prompt: str, image_text_prompt: str, text_only_prompt: str, reference_image: Optional[str], scene_frame: str, correct_answer: str, options: List[str], correct_index: int, robot_operation: str, metadata: Dict = <factory>)
scene_id: str
phase: int
frame_id: str
task: str
image_only_prompt: str
image_text_prompt: str
text_only_prompt: str
reference_image: Optional[str]
scene_frame: str
correct_answer: str
options: List[str]
correct_index: int
robot_operation: str
metadata: Dict
def gen_recognition( visible: List[Dict], ref_images: Dict[str, str], absent_cats: List[str], questionnaire: Dict[str, Dict], scene_id: str, phase: int, frame_id: str, scene_frame: str) -> List[RobotVQA]:
 74def gen_recognition(
 75    visible: List[Dict],  # objects visible in this frame
 76    ref_images: Dict[str, str],  # {category: path} from single-object scenes
 77    absent_cats: List[str],  # categories NOT visible
 78    questionnaire: Dict[str, Dict],  # {category: {color, material, shape}}
 79    scene_id: str,
 80    phase: int,
 81    frame_id: str,
 82    scene_frame: str,
 83) -> List[RobotVQA]:
 84    """Object recognition in three modes.
 85
 86    Same GT (yes/no from mask visibility), different prompts.
 87    """
 88    pairs = []
 89    vis_cats = {o["category"] for o in visible}
 90
 91    # Positive examples
 92    for cat in sorted(vis_cats)[:3]:
 93        if cat not in ref_images:
 94            continue
 95        attrs = questionnaire.get(cat, {})
 96        color = attrs.get("color", "")
 97        material = attrs.get("material", "")
 98        desc = f"{color} {material} {cat}".strip()
 99
100        s = _seed(scene_id, phase, frame_id, f"rec_yes_{cat}")
101        opts, idx = _shuffle("yes", ["no", "similar but different object", "cannot determine"], s)
102
103        pairs.append(
104            RobotVQA(
105                scene_id=scene_id,
106                phase=phase,
107                frame_id=frame_id,
108                task="recognition",
109                image_only_prompt="Is the object shown in the reference image present in this scene?",
110                image_text_prompt=f"Is the {desc} shown in the reference image present in this scene?",
111                text_only_prompt=f"Is there a {desc} in this scene?",
112                reference_image=ref_images[cat],
113                scene_frame=scene_frame,
114                correct_answer="yes",
115                options=opts,
116                correct_index=idx,
117                robot_operation="object_registration",
118                metadata={"category": cat, "present": True},
119            )
120        )
121
122    # Negative examples (balanced 50/50)
123    n_neg = 0
124    for cat in absent_cats:
125        if cat not in ref_images or n_neg >= len([p for p in pairs if p.metadata.get("present")]):
126            continue
127        attrs = questionnaire.get(cat, {})
128        color = attrs.get("color", "")
129        material = attrs.get("material", "")
130        desc = f"{color} {material} {cat}".strip()
131
132        s = _seed(scene_id, phase, frame_id, f"rec_no_{cat}")
133        opts, idx = _shuffle("no", ["yes", "partially visible", "cannot determine"], s)
134
135        pairs.append(
136            RobotVQA(
137                scene_id=scene_id,
138                phase=phase,
139                frame_id=frame_id,
140                task="recognition",
141                image_only_prompt="Is the object shown in the reference image present in this scene?",
142                image_text_prompt=f"Is the {desc} shown in the reference image present in this scene?",
143                text_only_prompt=f"Is there a {desc} in this scene?",
144                reference_image=ref_images[cat],
145                scene_frame=scene_frame,
146                correct_answer="no",
147                options=opts,
148                correct_index=idx,
149                robot_operation="object_registration",
150                metadata={"category": cat, "present": False},
151            )
152        )
153        n_neg += 1
154
155    return pairs

Object recognition in three modes.

Same GT (yes/no from mask visibility), different prompts.

def gen_attribute( visible: List[Dict], ref_images: Dict[str, str], questionnaire: Dict[str, Dict], all_objects: List[Dict], scene_id: str, phase: int, frame_id: str, scene_frame: str) -> List[RobotVQA]:
161def gen_attribute(
162    visible: List[Dict],
163    ref_images: Dict[str, str],
164    questionnaire: Dict[str, Dict],
165    all_objects: List[Dict],
166    scene_id: str,
167    phase: int,
168    frame_id: str,
169    scene_frame: str,
170) -> List[RobotVQA]:
171    """Attribute grounding — only fires when disambiguation is needed."""
172    pairs = []
173    from collections import Counter
174
175    cat_counts = Counter(o["category"] for o in visible)
176
177    for cat, count in cat_counts.items():
178        if count < 2:
179            continue  # no disambiguation needed
180        instances = [o for o in visible if o["category"] == cat]
181
182        for obj in instances[:2]:
183            color = obj.get("color", "")
184            if not color:
185                continue
186            other_colors = [o.get("color", "?") for o in instances if o.get("color") != color]
187
188            s = _seed(scene_id, phase, frame_id, f"attr_{cat}_{color}")
189            gt = f"the {color} {cat}"
190            distractors = [f"the {c} {cat}" for c in other_colors[:2]] + [f"any {cat}"]
191            while len(distractors) < 3:
192                distractors.append("none of these")
193            opts, idx = _shuffle(gt, distractors[:3], s)
194
195            ref_path = ref_images.get(cat)
196            pairs.append(
197                RobotVQA(
198                    scene_id=scene_id,
199                    phase=phase,
200                    frame_id=frame_id,
201                    task="attribute",
202                    image_only_prompt=f"Which of the visible {cat} objects matches the reference image?",
203                    image_text_prompt=f"Which object is the {color} {cat}?",
204                    text_only_prompt=f"A robot must pick the {color} {cat}. Which object should it grasp?",
205                    reference_image=ref_path,
206                    scene_frame=scene_frame,
207                    correct_answer=gt,
208                    options=opts,
209                    correct_index=idx,
210                    robot_operation="language_grounding",
211                    metadata={"category": cat, "target_color": color},
212                )
213            )
214
215    return pairs

Attribute grounding — only fires when disambiguation is needed.

def gen_spatial( visible: List[Dict], scene_id: str, phase: int, frame_id: str, scene_frame: str) -> List[RobotVQA]:
221def gen_spatial(
222    visible: List[Dict],  # must have bbox_cx, bbox_cy
223    scene_id: str,
224    phase: int,
225    frame_id: str,
226    scene_frame: str,
227) -> List[RobotVQA]:
228    """Spatial grounding — image-plane relations only. Text-only task."""
229    pairs = []
230    if len(visible) < 2:
231        return pairs
232
233    for ref in visible[:3]:
234        cx, cy = ref.get("bbox_cx"), ref.get("bbox_cy")
235        if cx is None:
236            continue
237
238        # Bind cx/cy at lambda definition time (default args) so each loop
239        # iteration captures the current ref's centroid, not the last one.
240        for direction, check in [
241            ("left", lambda o, cx=cx: o.get("bbox_cx", cx) < cx),
242            ("right", lambda o, cx=cx: o.get("bbox_cx", cx) > cx),
243            ("above", lambda o, cy=cy: o.get("bbox_cy", cy) < cy),
244            ("below", lambda o, cy=cy: o.get("bbox_cy", cy) > cy),
245        ]:
246            candidates = [
247                o for o in visible if o is not ref and check(o) and o.get("bbox_cx") is not None
248            ]
249            if not candidates:
250                continue
251            # Nearest
252            target = min(
253                candidates,
254                key=lambda o, cx=cx, cy=cy: abs(o["bbox_cx"] - cx) + abs(o["bbox_cy"] - cy),
255            )
256            gt = target["category"]
257
258            s = _seed(scene_id, phase, frame_id, f"sp_{direction}_{ref['category']}")
259            others = list(
260                {o["category"] for o in visible if o["category"] not in (gt, ref["category"])}
261            )
262            others += ["nothing"]
263            distractors = random.Random(s).sample(others, min(3, len(others)))
264            while len(distractors) < 3:
265                distractors.append("cannot determine")
266            opts, idx = _shuffle(gt, distractors[:3], s)
267
268            pairs.append(
269                RobotVQA(
270                    scene_id=scene_id,
271                    phase=phase,
272                    frame_id=frame_id,
273                    task="spatial",
274                    image_only_prompt=f"What object is {direction} of the {ref['category']}?",
275                    image_text_prompt=f"What object is {direction} of the {ref['category']}?",
276                    text_only_prompt=f"A robot must pick the object {direction} of the {ref['category']}. What should it pick?",
277                    reference_image=None,
278                    scene_frame=scene_frame,
279                    correct_answer=gt,
280                    options=opts,
281                    correct_index=idx,
282                    robot_operation="spatial_command",
283                    metadata={"reference": ref["category"], "direction": direction},
284                )
285            )
286            break  # one per direction per ref
287
288    return pairs

Spatial grounding — image-plane relations only. Text-only task.

def gen_state_verification( clutter_objects: List[Dict], clean_objects: List[Dict], scene_id: str, frame_id: str, clutter_frame: str, clean_frame: str) -> List[RobotVQA]:
294def gen_state_verification(
295    clutter_objects: List[Dict],
296    clean_objects: List[Dict],
297    scene_id: str,
298    frame_id: str,
299    clutter_frame: str,
300    clean_frame: str,
301) -> List[RobotVQA]:
302    """State verification — compare two phases. Uses TWO frames as input."""
303    pairs = []
304    clu_cats = {o["category"] for o in clutter_objects}
305    cln_cats = {o["category"] for o in clean_objects}
306    removed = clu_cats - cln_cats
307    remained = clu_cats & cln_cats
308
309    for obj_cat in list(removed)[:2]:
310        s = _seed(scene_id, 0, frame_id, f"state_{obj_cat}")
311        distractors = list(remained)[:3]
312        while len(distractors) < 3:
313            distractors.append("nothing changed")
314        opts, idx = _shuffle(obj_cat, distractors[:3], s)
315
316        pairs.append(
317            RobotVQA(
318                scene_id=scene_id,
319                phase=0,
320                frame_id=frame_id,
321                task="state",
322                image_only_prompt="Compare these two images. Which object was removed?",
323                image_text_prompt="Compare the before and after images. Which object was removed from the scene?",
324                text_only_prompt="After the robot's manipulation, which object is no longer on the table?",
325                reference_image=clutter_frame,  # "before" image
326                scene_frame=clean_frame,  # "after" image
327                correct_answer=obj_cat,
328                options=opts,
329                correct_index=idx,
330                robot_operation="task_verification",
331                metadata={"change_type": "removed", "object": obj_cat},
332            )
333        )
334
335    return pairs

State verification — compare two phases. Uses TWO frames as input.

def gen_counting( visible: List[Dict], scene_id: str, phase: int, frame_id: str, scene_frame: str) -> List[RobotVQA]:
341def gen_counting(
342    visible: List[Dict],
343    scene_id: str,
344    phase: int,
345    frame_id: str,
346    scene_frame: str,
347) -> List[RobotVQA]:
348    """Counting — text-only task, GT from instance masks."""
349    pairs = []
350    from collections import Counter
351
352    counts = Counter(o["category"] for o in visible)
353
354    for cat, count in list(counts.items())[:3]:
355        s = _seed(scene_id, phase, frame_id, f"cnt_{cat}")
356        gt = str(count)
357        pool = [str(i) for i in range(max(count + 3, 5)) if str(i) != gt]
358        distractors = random.Random(s).sample(pool, min(3, len(pool)))
359        while len(distractors) < 3:
360            distractors.append("0")
361        opts, idx = _shuffle(gt, distractors[:3], s)
362
363        pairs.append(
364            RobotVQA(
365                scene_id=scene_id,
366                phase=phase,
367                frame_id=frame_id,
368                task="counting",
369                image_only_prompt=f"How many {cat} objects are in this scene?",
370                image_text_prompt=f"How many {cat} objects are visible in this scene?",
371                text_only_prompt=f"A robot is packing all {cat} objects. How many does it need to pick?",
372                reference_image=None,
373                scene_frame=scene_frame,
374                correct_answer=gt,
375                options=opts,
376                correct_index=idx,
377                robot_operation="task_progress",
378                metadata={"category": cat, "count": count},
379            )
380        )
381
382    return pairs

Counting — text-only task, GT from instance masks.

CONTEXT_MODES = ('image_only', 'image_text', 'text_only')
def evaluate( predictions: Dict[str, List[int]], ground_truth: List[RobotVQA]) -> Dict:
390def evaluate(
391    predictions: Dict[str, List[int]],  # {mode: [pred_indices]}
392    ground_truth: List[RobotVQA],
393) -> Dict:
394    """Evaluate predictions across context modes.
395
396    Args:
397        predictions: {mode_name: list of predicted option indices}
398                     e.g. {"image_only": [0,1,3,...], "text_only": [2,0,1,...]}
399        ground_truth: list of RobotVQA
400
401    Returns:
402        Per-mode accuracy + per-task + per-phase + cross-mode comparison
403    """
404    results = {}
405    for mode, preds in predictions.items():
406        assert len(preds) == len(ground_truth), (
407            f"{mode}: {len(preds)} preds vs {len(ground_truth)} GT"
408        )
409
410        by_task, by_phase = {}, {}
411        correct = 0
412        for pred, qa in zip(preds, ground_truth, strict=False):
413            hit = pred == qa.correct_index
414            correct += hit
415            by_task.setdefault(qa.task, []).append(hit)
416            by_phase.setdefault(qa.phase, []).append(hit)
417
418        n = len(preds)
419        pa = {p: sum(v) / len(v) for p, v in by_phase.items()}
420        mode_result = {
421            "accuracy": correct / n if n else 0.0,
422            "per_task": {t: sum(v) / len(v) for t, v in by_task.items()},
423            "per_phase": pa,
424        }
425        if 0 in pa and 1 in pa:
426            mode_result["str_clu_to_int"] = pa[1] - pa[0]
427        if 1 in pa and 2 in pa:
428            mode_result["str_int_to_cln"] = pa[2] - pa[1]
429        results[mode] = mode_result
430
431    # Cross-mode comparison: which mode is most robust?
432    if len(results) > 1:
433        results["mode_comparison"] = {
434            mode: r["accuracy"] for mode, r in results.items() if mode != "mode_comparison"
435        }
436
437    return results

Evaluate predictions across context modes.

Args: predictions: {mode_name: list of predicted option indices} e.g. {"image_only": [0,1,3,...], "text_only": [2,0,1,...]} ground_truth: list of RobotVQA

Returns: Per-mode accuracy + per-task + per-phase + cross-mode comparison