rpx_benchmark.tasks.robot_vqa

RPX Robot-Grounded VQA — tasks a robot actually needs.

Every question maps to a real robot operation. Every answer feeds an action. No generic VQA. No "what is the weather like?"

Six task types, each grounded in a specific robot pipeline stage:

T1. IN-CONTEXT RECOGNITION (the killer feature) Robot has a reference photo of target object (from single-object scene). VLM must identify whether that object appears in the cluttered scene. Uses: paired single-object / multi-object scenes. This is exactly what DE-ViT, NIDS-Net, and real robot "object registration" do.

T2. ATTRIBUTE GROUNDING Robot receives "pick the red mug" — must distinguish red mug from blue mug. VLM must ground natural-language attributes to specific objects. Uses: FewSOL questionnaire attributes.

T3. SPATIAL GROUNDING Robot receives "pick the object left of the bowl." VLM must resolve spatial relations in the image plane. Uses: 360° walkaround (relations change per viewpoint).

T4. STATE VERIFICATION Robot asks "did I succeed? Is the target still on the table?" VLM compares two frames: before and after manipulation. Uses: clutter → clean phase transition (unique to RPX).

T5. COUNTING Robot asks "how many objects remain to pack?" VLM counts visible instances of a category. Uses: instance masks for GT.

T6. INTERACTION UNDERSTANDING Learning-from-human pipeline asks "what object is the human touching?" VLM must identify the object being manipulated during interaction phase. Uses: interaction phase with human hands in frame.

Format: 4-choice MCQ for T1-T5. Open-ended for T6 (scored by exact category match).

  1"""RPX Robot-Grounded VQA — tasks a robot actually needs.
  2
  3Every question maps to a real robot operation. Every answer feeds an action.
  4No generic VQA. No "what is the weather like?"
  5
  6Six task types, each grounded in a specific robot pipeline stage:
  7
  8T1. IN-CONTEXT RECOGNITION (the killer feature)
  9    Robot has a reference photo of target object (from single-object scene).
 10    VLM must identify whether that object appears in the cluttered scene.
 11    Uses: paired single-object / multi-object scenes.
 12    This is exactly what DE-ViT, NIDS-Net, and real robot "object registration" do.
 13
 14T2. ATTRIBUTE GROUNDING
 15    Robot receives "pick the red mug" — must distinguish red mug from blue mug.
 16    VLM must ground natural-language attributes to specific objects.
 17    Uses: FewSOL questionnaire attributes.
 18
 19T3. SPATIAL GROUNDING
 20    Robot receives "pick the object left of the bowl."
 21    VLM must resolve spatial relations in the image plane.
 22    Uses: 360° walkaround (relations change per viewpoint).
 23
 24T4. STATE VERIFICATION
 25    Robot asks "did I succeed? Is the target still on the table?"
 26    VLM compares two frames: before and after manipulation.
 27    Uses: clutter → clean phase transition (unique to RPX).
 28
 29T5. COUNTING
 30    Robot asks "how many objects remain to pack?"
 31    VLM counts visible instances of a category.
 32    Uses: instance masks for GT.
 33
 34T6. INTERACTION UNDERSTANDING
 35    Learning-from-human pipeline asks "what object is the human touching?"
 36    VLM must identify the object being manipulated during interaction phase.
 37    Uses: interaction phase with human hands in frame.
 38
 39Format: 4-choice MCQ for T1-T5. Open-ended for T6 (scored by exact category match).
 40"""
 41
 42from __future__ import annotations
 43
 44import hashlib
 45import random
 46from dataclasses import dataclass, field
 47from typing import Dict, List, Optional, Tuple
 48
 49
 50@dataclass
 51class RobotQA:
 52    """One robot-grounded QA pair."""
 53
 54    scene_id: str
 55    phase: int
 56    frame_id: str
 57    task_type: str  # T1-T6
 58    robot_operation: str  # what robot pipeline stage this tests
 59    question: str
 60    correct_answer: str
 61    options: List[str]  # 4 options for MCQ; empty for open-ended
 62    correct_index: int  # -1 for open-ended
 63    reference_image: Optional[str] = None  # path to single-object reference (T1)
 64    comparison_frame: Optional[str] = None  # path to before/after frame (T4)
 65    metadata: Dict = field(default_factory=dict)
 66
 67
 68def _seed(scene_id: str, phase: int, frame_id: str, tag: str) -> int:
 69    h = hashlib.md5(f"{scene_id}/{phase}/{frame_id}/{tag}".encode()).hexdigest()
 70    return int(h[:8], 16)
 71
 72
 73def _shuffle(correct: str, distractors: List[str], seed: int) -> Tuple[List[str], int]:
 74    opts = [correct] + distractors[:3]
 75    random.Random(seed).shuffle(opts)
 76    return opts, opts.index(correct)
 77
 78
 79# ── T1: In-Context Recognition ─────────────────────────────────────────────
 80
 81
 82def generate_incontext_recognition(
 83    visible_objects: List[Dict],
 84    single_object_refs: Dict[str, str],  # {category: path_to_reference_image}
 85    absent_categories: List[str],  # categories NOT in this frame
 86    scene_id: str,
 87    phase: int,
 88    frame_id: str,
 89) -> List[RobotQA]:
 90    """T1: 'Here is a photo of the target. Is it in this scene?'
 91
 92    This is the object registration task every real robot does.
 93    The single-object scene image IS the reference photo.
 94    """
 95    pairs = []
 96    visible_cats = {o["category"] for o in visible_objects}
 97
 98    # Positive: objects that ARE visible and have a reference image
 99    for cat in list(visible_cats)[:3]:
100        if cat not in single_object_refs:
101            continue
102        s = _seed(scene_id, phase, frame_id, f"t1_yes_{cat}")
103        q = "The reference image shows an object. Is this exact object present in the current scene?"
104        opts, idx = _shuffle(
105            "yes, it is visible",
106            [
107                "no, it is not in this scene",
108                "a similar but different object is present",
109                "cannot determine from this viewpoint",
110            ],
111            s,
112        )
113        pairs.append(
114            RobotQA(
115                scene_id=scene_id,
116                phase=phase,
117                frame_id=frame_id,
118                task_type="T1_incontext",
119                robot_operation="object_registration",
120                question=q,
121                correct_answer="yes, it is visible",
122                options=opts,
123                correct_index=idx,
124                reference_image=single_object_refs[cat],
125                metadata={"category": cat, "present": True},
126            )
127        )
128
129    # Negative: objects NOT in this frame but that have reference images
130    neg_count = 0
131    for cat in absent_categories:
132        if cat not in single_object_refs or neg_count >= len(pairs):
133            continue
134        s = _seed(scene_id, phase, frame_id, f"t1_no_{cat}")
135        q = "The reference image shows an object. Is this exact object present in the current scene?"
136        opts, idx = _shuffle(
137            "no, it is not in this scene",
138            [
139                "yes, it is visible",
140                "a similar but different object is present",
141                "partially occluded but present",
142            ],
143            s,
144        )
145        pairs.append(
146            RobotQA(
147                scene_id=scene_id,
148                phase=phase,
149                frame_id=frame_id,
150                task_type="T1_incontext",
151                robot_operation="object_registration",
152                question=q,
153                correct_answer="no, it is not in this scene",
154                options=opts,
155                correct_index=idx,
156                reference_image=single_object_refs[cat],
157                metadata={"category": cat, "present": False},
158            )
159        )
160        neg_count += 1
161
162    return pairs
163
164
165# ── T2: Attribute Grounding ────────────────────────────────────────────────
166
167
168def generate_attribute_grounding(
169    visible_objects: List[Dict],
170    all_objects: List[Dict],
171    scene_id: str,
172    phase: int,
173    frame_id: str,
174) -> List[RobotQA]:
175    """T2: 'Pick the red mug' — which object matches this description?
176
177    Tests whether the VLM can distinguish objects by attributes.
178    Only generates questions when multiple objects of the same category
179    are visible (otherwise there's no disambiguation needed).
180    """
181    pairs = []
182    # Find categories with multiple visible instances
183    from collections import Counter
184
185    cat_counts = Counter(o["category"] for o in visible_objects)
186    ambiguous_cats = [c for c, n in cat_counts.items() if n >= 2]
187
188    for cat in ambiguous_cats:
189        instances = [o for o in visible_objects if o["category"] == cat]
190        for obj in instances[:2]:
191            color = obj.get("color", "")
192            if not color:
193                continue
194            s = _seed(scene_id, phase, frame_id, f"t2_{cat}_{color}")
195            q = f"A robot is instructed to pick the {color} {cat}. Which object should it grasp?"
196            gt = f"the {color} {cat}"
197            other_colors = [o.get("color", "?") for o in instances if o.get("color") != color]
198            distractors = [f"the {c} {cat}" for c in other_colors[:2]]
199            distractors.append(f"any {cat}")
200            while len(distractors) < 3:
201                distractors.append("none of the visible objects")
202            opts, idx = _shuffle(gt, distractors[:3], s)
203            pairs.append(
204                RobotQA(
205                    scene_id=scene_id,
206                    phase=phase,
207                    frame_id=frame_id,
208                    task_type="T2_attribute",
209                    robot_operation="language_grounding",
210                    question=q,
211                    correct_answer=gt,
212                    options=opts,
213                    correct_index=idx,
214                    metadata={"category": cat, "target_color": color},
215                )
216            )
217
218    return pairs
219
220
221# ── T3: Spatial Grounding ──────────────────────────────────────────────────
222
223
224def generate_spatial_grounding(
225    visible_objects: List[Dict],  # must include bbox_cx, bbox_cy
226    scene_id: str,
227    phase: int,
228    frame_id: str,
229) -> List[RobotQA]:
230    """T3: 'Pick the object to the left of the bowl.'
231
232    Relations in image plane. Left = smaller x. Above = smaller y.
233    """
234    pairs = []
235    if len(visible_objects) < 2:
236        return pairs
237
238    directions = {"left": (-1, 0), "right": (1, 0), "above": (0, -1), "below": (0, 1)}
239
240    for ref in visible_objects[:3]:
241        cx, cy = ref.get("bbox_cx"), ref.get("bbox_cy")
242        if cx is None:
243            continue
244
245        for direction, (dx_sign, dy_sign) in directions.items():
246            # Find nearest object in that direction
247            best, best_dist = None, float("inf")
248            for other in visible_objects:
249                if other is ref:
250                    continue
251                ox, oy = other.get("bbox_cx"), other.get("bbox_cy")
252                if ox is None:
253                    continue
254                # Check direction
255                if dx_sign < 0 and ox >= cx:
256                    continue
257                if dx_sign > 0 and ox <= cx:
258                    continue
259                if dy_sign < 0 and oy >= cy:
260                    continue
261                if dy_sign > 0 and oy <= cy:
262                    continue
263                d = abs(ox - cx) + abs(oy - cy)
264                if d < best_dist:
265                    best, best_dist = other, d
266
267            if best is None:
268                continue
269
270            s = _seed(scene_id, phase, frame_id, f"t3_{direction}_{ref['category']}")
271            q = f"A robot must pick the object {direction} of the {ref['category']}. What should it pick?"
272            gt = best["category"]
273            others = list(
274                {
275                    o["category"]
276                    for o in visible_objects
277                    if o["category"] != gt and o["category"] != ref["category"]
278                }
279            )
280            others += ["nothing — no object in that direction"]
281            rng = random.Random(s)
282            distractors = rng.sample(others, min(3, len(others)))
283            while len(distractors) < 3:
284                distractors.append("cannot determine")
285            opts, idx = _shuffle(gt, distractors[:3], s)
286            pairs.append(
287                RobotQA(
288                    scene_id=scene_id,
289                    phase=phase,
290                    frame_id=frame_id,
291                    task_type="T3_spatial",
292                    robot_operation="spatial_command",
293                    question=q,
294                    correct_answer=gt,
295                    options=opts,
296                    correct_index=idx,
297                    metadata={"reference": ref["category"], "direction": direction},
298                )
299            )
300            break  # one per ref per direction
301
302    return pairs
303
304
305# ── T4: State Verification ─────────────────────────────────────────────────
306
307
308def generate_state_verification(
309    clutter_objects: List[Dict],  # objects visible in clutter phase
310    clean_objects: List[Dict],  # objects visible in clean phase
311    scene_id: str,
312    frame_id: str,
313) -> List[RobotQA]:
314    """T4: 'Did the manipulation succeed? What changed?'
315
316    Compares clutter and clean phases of the same scene.
317    This is what a robot does after every pick-and-place: verify.
318    UNIQUE TO RPX — no other benchmark can generate this.
319    """
320    pairs = []
321    clutter_cats = {o["category"] for o in clutter_objects}
322    clean_cats = {o["category"] for o in clean_objects}
323
324    removed = clutter_cats - clean_cats
325    added = clean_cats - clutter_cats
326    remained = clutter_cats & clean_cats
327
328    # Question: what was removed?
329    if removed:
330        removed_obj = list(removed)[0]
331        s = _seed(scene_id, 0, frame_id, f"t4_removed_{removed_obj}")
332        q = "Comparing the before and after images: which object was removed from the scene?"
333        distractors = list(remained)[:3]
334        while len(distractors) < 3:
335            distractors.append("nothing was removed")
336        opts, idx = _shuffle(removed_obj, distractors[:3], s)
337        pairs.append(
338            RobotQA(
339                scene_id=scene_id,
340                phase=0,
341                frame_id=frame_id,
342                task_type="T4_state",
343                robot_operation="task_verification",
344                question=q,
345                correct_answer=removed_obj,
346                options=opts,
347                correct_index=idx,
348                metadata={"change_type": "removed", "object": removed_obj},
349            )
350        )
351
352    # Question: what was added?
353    if added:
354        added_obj = list(added)[0]
355        s = _seed(scene_id, 2, frame_id, f"t4_added_{added_obj}")
356        q = "Comparing the before and after images: which object was added to the scene?"
357        distractors = list(remained)[:3]
358        while len(distractors) < 3:
359            distractors.append("nothing was added")
360        opts, idx = _shuffle(added_obj, distractors[:3], s)
361        pairs.append(
362            RobotQA(
363                scene_id=scene_id,
364                phase=2,
365                frame_id=frame_id,
366                task_type="T4_state",
367                robot_operation="task_verification",
368                question=q,
369                correct_answer=added_obj,
370                options=opts,
371                correct_index=idx,
372                metadata={"change_type": "added", "object": added_obj},
373            )
374        )
375
376    # Question: did the count change?
377    if len(clutter_cats) != len(clean_cats):
378        s = _seed(scene_id, 0, frame_id, "t4_count")
379        delta = len(clean_cats) - len(clutter_cats)
380        q = "After manipulation, did the number of visible objects change?"
381        if delta < 0:
382            gt = f"decreased by {abs(delta)}"
383        elif delta > 0:
384            gt = f"increased by {delta}"
385        else:
386            gt = "stayed the same"
387        opts, idx = _shuffle(
388            gt,
389            [
390                "stayed the same" if delta != 0 else "decreased by 1",
391                f"increased by {abs(delta) + 1}" if delta <= 0 else f"decreased by {delta + 1}",
392                "cannot determine",
393            ],
394            s,
395        )
396        pairs.append(
397            RobotQA(
398                scene_id=scene_id,
399                phase=0,
400                frame_id=frame_id,
401                task_type="T4_state",
402                robot_operation="task_verification",
403                question=q,
404                correct_answer=gt,
405                options=opts,
406                correct_index=idx,
407                metadata={"change_type": "count", "delta": delta},
408            )
409        )
410
411    return pairs
412
413
414# ── T5: Counting ───────────────────────────────────────────────────────────
415
416
417def generate_counting(
418    visible_objects: List[Dict],
419    scene_id: str,
420    phase: int,
421    frame_id: str,
422) -> List[RobotQA]:
423    """T5: 'How many objects remain to pack?'"""
424    pairs = []
425    from collections import Counter
426
427    counts = Counter(o["category"] for o in visible_objects)
428
429    for cat, count in list(counts.items())[:3]:
430        s = _seed(scene_id, phase, frame_id, f"t5_{cat}")
431        q = f"A robot is packing all {cat} objects. How many does it need to pick?"
432        gt = str(count)
433        pool = [str(i) for i in range(max(count + 3, 5)) if str(i) != gt]
434        distractors = random.Random(s).sample(pool, min(3, len(pool)))
435        while len(distractors) < 3:
436            distractors.append("0")
437        opts, idx = _shuffle(gt, distractors[:3], s)
438        pairs.append(
439            RobotQA(
440                scene_id=scene_id,
441                phase=phase,
442                frame_id=frame_id,
443                task_type="T5_counting",
444                robot_operation="task_progress",
445                question=q,
446                correct_answer=gt,
447                options=opts,
448                correct_index=idx,
449                metadata={"category": cat, "count": count},
450            )
451        )
452
453    return pairs
454
455
456# ── Evaluation ──────────────────────────────────────────────────────────────
457
458
459def evaluate_robot_vqa(
460    predictions: List[int],
461    ground_truth: List[RobotQA],
462) -> Dict:
463    """MCQ accuracy with breakdowns by task type, phase, and robot operation."""
464    assert len(predictions) == len(ground_truth)
465
466    by_type: Dict[str, List[bool]] = {}
467    by_phase: Dict[int, List[bool]] = {}
468    by_operation: Dict[str, List[bool]] = {}
469
470    for pred, qa in zip(predictions, ground_truth, strict=False):
471        hit = pred == qa.correct_index
472        by_type.setdefault(qa.task_type, []).append(hit)
473        by_phase.setdefault(qa.phase, []).append(hit)
474        by_operation.setdefault(qa.robot_operation, []).append(hit)
475
476    n = len(predictions)
477    correct = sum(
478        1 for p, q in zip(predictions, ground_truth, strict=False) if p == q.correct_index
479    )
480
481    result = {
482        "accuracy": correct / n if n else 0.0,
483        "n_questions": n,
484        "per_task_type": {t: sum(v) / len(v) for t, v in by_type.items()},
485        "per_phase": {p: sum(v) / len(v) for p, v in by_phase.items()},
486        "per_robot_operation": {o: sum(v) / len(v) for o, v in by_operation.items()},
487    }
488
489    # STR
490    pa = {p: sum(v) / len(v) for p, v in by_phase.items()}
491    if 0 in pa and 1 in pa:
492        result["str_clu_to_int"] = pa[1] - pa[0]
493    if 1 in pa and 2 in pa:
494        result["str_int_to_cln"] = pa[2] - pa[1]
495
496    return result
@dataclass
class RobotQA:
51@dataclass
52class RobotQA:
53    """One robot-grounded QA pair."""
54
55    scene_id: str
56    phase: int
57    frame_id: str
58    task_type: str  # T1-T6
59    robot_operation: str  # what robot pipeline stage this tests
60    question: str
61    correct_answer: str
62    options: List[str]  # 4 options for MCQ; empty for open-ended
63    correct_index: int  # -1 for open-ended
64    reference_image: Optional[str] = None  # path to single-object reference (T1)
65    comparison_frame: Optional[str] = None  # path to before/after frame (T4)
66    metadata: Dict = field(default_factory=dict)

One robot-grounded QA pair.

RobotQA( scene_id: str, phase: int, frame_id: str, task_type: str, robot_operation: str, question: str, correct_answer: str, options: List[str], correct_index: int, reference_image: Optional[str] = None, comparison_frame: Optional[str] = None, metadata: Dict = <factory>)
scene_id: str
phase: int
frame_id: str
task_type: str
robot_operation: str
question: str
correct_answer: str
options: List[str]
correct_index: int
reference_image: Optional[str] = None
comparison_frame: Optional[str] = None
metadata: Dict
def generate_incontext_recognition( visible_objects: List[Dict], single_object_refs: Dict[str, str], absent_categories: List[str], scene_id: str, phase: int, frame_id: str) -> List[RobotQA]:
 83def generate_incontext_recognition(
 84    visible_objects: List[Dict],
 85    single_object_refs: Dict[str, str],  # {category: path_to_reference_image}
 86    absent_categories: List[str],  # categories NOT in this frame
 87    scene_id: str,
 88    phase: int,
 89    frame_id: str,
 90) -> List[RobotQA]:
 91    """T1: 'Here is a photo of the target. Is it in this scene?'
 92
 93    This is the object registration task every real robot does.
 94    The single-object scene image IS the reference photo.
 95    """
 96    pairs = []
 97    visible_cats = {o["category"] for o in visible_objects}
 98
 99    # Positive: objects that ARE visible and have a reference image
100    for cat in list(visible_cats)[:3]:
101        if cat not in single_object_refs:
102            continue
103        s = _seed(scene_id, phase, frame_id, f"t1_yes_{cat}")
104        q = "The reference image shows an object. Is this exact object present in the current scene?"
105        opts, idx = _shuffle(
106            "yes, it is visible",
107            [
108                "no, it is not in this scene",
109                "a similar but different object is present",
110                "cannot determine from this viewpoint",
111            ],
112            s,
113        )
114        pairs.append(
115            RobotQA(
116                scene_id=scene_id,
117                phase=phase,
118                frame_id=frame_id,
119                task_type="T1_incontext",
120                robot_operation="object_registration",
121                question=q,
122                correct_answer="yes, it is visible",
123                options=opts,
124                correct_index=idx,
125                reference_image=single_object_refs[cat],
126                metadata={"category": cat, "present": True},
127            )
128        )
129
130    # Negative: objects NOT in this frame but that have reference images
131    neg_count = 0
132    for cat in absent_categories:
133        if cat not in single_object_refs or neg_count >= len(pairs):
134            continue
135        s = _seed(scene_id, phase, frame_id, f"t1_no_{cat}")
136        q = "The reference image shows an object. Is this exact object present in the current scene?"
137        opts, idx = _shuffle(
138            "no, it is not in this scene",
139            [
140                "yes, it is visible",
141                "a similar but different object is present",
142                "partially occluded but present",
143            ],
144            s,
145        )
146        pairs.append(
147            RobotQA(
148                scene_id=scene_id,
149                phase=phase,
150                frame_id=frame_id,
151                task_type="T1_incontext",
152                robot_operation="object_registration",
153                question=q,
154                correct_answer="no, it is not in this scene",
155                options=opts,
156                correct_index=idx,
157                reference_image=single_object_refs[cat],
158                metadata={"category": cat, "present": False},
159            )
160        )
161        neg_count += 1
162
163    return pairs

T1: 'Here is a photo of the target. Is it in this scene?'

This is the object registration task every real robot does. The single-object scene image IS the reference photo.

def generate_attribute_grounding( visible_objects: List[Dict], all_objects: List[Dict], scene_id: str, phase: int, frame_id: str) -> List[RobotQA]:
169def generate_attribute_grounding(
170    visible_objects: List[Dict],
171    all_objects: List[Dict],
172    scene_id: str,
173    phase: int,
174    frame_id: str,
175) -> List[RobotQA]:
176    """T2: 'Pick the red mug' — which object matches this description?
177
178    Tests whether the VLM can distinguish objects by attributes.
179    Only generates questions when multiple objects of the same category
180    are visible (otherwise there's no disambiguation needed).
181    """
182    pairs = []
183    # Find categories with multiple visible instances
184    from collections import Counter
185
186    cat_counts = Counter(o["category"] for o in visible_objects)
187    ambiguous_cats = [c for c, n in cat_counts.items() if n >= 2]
188
189    for cat in ambiguous_cats:
190        instances = [o for o in visible_objects if o["category"] == cat]
191        for obj in instances[:2]:
192            color = obj.get("color", "")
193            if not color:
194                continue
195            s = _seed(scene_id, phase, frame_id, f"t2_{cat}_{color}")
196            q = f"A robot is instructed to pick the {color} {cat}. Which object should it grasp?"
197            gt = f"the {color} {cat}"
198            other_colors = [o.get("color", "?") for o in instances if o.get("color") != color]
199            distractors = [f"the {c} {cat}" for c in other_colors[:2]]
200            distractors.append(f"any {cat}")
201            while len(distractors) < 3:
202                distractors.append("none of the visible objects")
203            opts, idx = _shuffle(gt, distractors[:3], s)
204            pairs.append(
205                RobotQA(
206                    scene_id=scene_id,
207                    phase=phase,
208                    frame_id=frame_id,
209                    task_type="T2_attribute",
210                    robot_operation="language_grounding",
211                    question=q,
212                    correct_answer=gt,
213                    options=opts,
214                    correct_index=idx,
215                    metadata={"category": cat, "target_color": color},
216                )
217            )
218
219    return pairs

T2: 'Pick the red mug' — which object matches this description?

Tests whether the VLM can distinguish objects by attributes. Only generates questions when multiple objects of the same category are visible (otherwise there's no disambiguation needed).

def generate_spatial_grounding( visible_objects: List[Dict], scene_id: str, phase: int, frame_id: str) -> List[RobotQA]:
225def generate_spatial_grounding(
226    visible_objects: List[Dict],  # must include bbox_cx, bbox_cy
227    scene_id: str,
228    phase: int,
229    frame_id: str,
230) -> List[RobotQA]:
231    """T3: 'Pick the object to the left of the bowl.'
232
233    Relations in image plane. Left = smaller x. Above = smaller y.
234    """
235    pairs = []
236    if len(visible_objects) < 2:
237        return pairs
238
239    directions = {"left": (-1, 0), "right": (1, 0), "above": (0, -1), "below": (0, 1)}
240
241    for ref in visible_objects[:3]:
242        cx, cy = ref.get("bbox_cx"), ref.get("bbox_cy")
243        if cx is None:
244            continue
245
246        for direction, (dx_sign, dy_sign) in directions.items():
247            # Find nearest object in that direction
248            best, best_dist = None, float("inf")
249            for other in visible_objects:
250                if other is ref:
251                    continue
252                ox, oy = other.get("bbox_cx"), other.get("bbox_cy")
253                if ox is None:
254                    continue
255                # Check direction
256                if dx_sign < 0 and ox >= cx:
257                    continue
258                if dx_sign > 0 and ox <= cx:
259                    continue
260                if dy_sign < 0 and oy >= cy:
261                    continue
262                if dy_sign > 0 and oy <= cy:
263                    continue
264                d = abs(ox - cx) + abs(oy - cy)
265                if d < best_dist:
266                    best, best_dist = other, d
267
268            if best is None:
269                continue
270
271            s = _seed(scene_id, phase, frame_id, f"t3_{direction}_{ref['category']}")
272            q = f"A robot must pick the object {direction} of the {ref['category']}. What should it pick?"
273            gt = best["category"]
274            others = list(
275                {
276                    o["category"]
277                    for o in visible_objects
278                    if o["category"] != gt and o["category"] != ref["category"]
279                }
280            )
281            others += ["nothing — no object in that direction"]
282            rng = random.Random(s)
283            distractors = rng.sample(others, min(3, len(others)))
284            while len(distractors) < 3:
285                distractors.append("cannot determine")
286            opts, idx = _shuffle(gt, distractors[:3], s)
287            pairs.append(
288                RobotQA(
289                    scene_id=scene_id,
290                    phase=phase,
291                    frame_id=frame_id,
292                    task_type="T3_spatial",
293                    robot_operation="spatial_command",
294                    question=q,
295                    correct_answer=gt,
296                    options=opts,
297                    correct_index=idx,
298                    metadata={"reference": ref["category"], "direction": direction},
299                )
300            )
301            break  # one per ref per direction
302
303    return pairs

T3: 'Pick the object to the left of the bowl.'

Relations in image plane. Left = smaller x. Above = smaller y.

def generate_state_verification( clutter_objects: List[Dict], clean_objects: List[Dict], scene_id: str, frame_id: str) -> List[RobotQA]:
309def generate_state_verification(
310    clutter_objects: List[Dict],  # objects visible in clutter phase
311    clean_objects: List[Dict],  # objects visible in clean phase
312    scene_id: str,
313    frame_id: str,
314) -> List[RobotQA]:
315    """T4: 'Did the manipulation succeed? What changed?'
316
317    Compares clutter and clean phases of the same scene.
318    This is what a robot does after every pick-and-place: verify.
319    UNIQUE TO RPX — no other benchmark can generate this.
320    """
321    pairs = []
322    clutter_cats = {o["category"] for o in clutter_objects}
323    clean_cats = {o["category"] for o in clean_objects}
324
325    removed = clutter_cats - clean_cats
326    added = clean_cats - clutter_cats
327    remained = clutter_cats & clean_cats
328
329    # Question: what was removed?
330    if removed:
331        removed_obj = list(removed)[0]
332        s = _seed(scene_id, 0, frame_id, f"t4_removed_{removed_obj}")
333        q = "Comparing the before and after images: which object was removed from the scene?"
334        distractors = list(remained)[:3]
335        while len(distractors) < 3:
336            distractors.append("nothing was removed")
337        opts, idx = _shuffle(removed_obj, distractors[:3], s)
338        pairs.append(
339            RobotQA(
340                scene_id=scene_id,
341                phase=0,
342                frame_id=frame_id,
343                task_type="T4_state",
344                robot_operation="task_verification",
345                question=q,
346                correct_answer=removed_obj,
347                options=opts,
348                correct_index=idx,
349                metadata={"change_type": "removed", "object": removed_obj},
350            )
351        )
352
353    # Question: what was added?
354    if added:
355        added_obj = list(added)[0]
356        s = _seed(scene_id, 2, frame_id, f"t4_added_{added_obj}")
357        q = "Comparing the before and after images: which object was added to the scene?"
358        distractors = list(remained)[:3]
359        while len(distractors) < 3:
360            distractors.append("nothing was added")
361        opts, idx = _shuffle(added_obj, distractors[:3], s)
362        pairs.append(
363            RobotQA(
364                scene_id=scene_id,
365                phase=2,
366                frame_id=frame_id,
367                task_type="T4_state",
368                robot_operation="task_verification",
369                question=q,
370                correct_answer=added_obj,
371                options=opts,
372                correct_index=idx,
373                metadata={"change_type": "added", "object": added_obj},
374            )
375        )
376
377    # Question: did the count change?
378    if len(clutter_cats) != len(clean_cats):
379        s = _seed(scene_id, 0, frame_id, "t4_count")
380        delta = len(clean_cats) - len(clutter_cats)
381        q = "After manipulation, did the number of visible objects change?"
382        if delta < 0:
383            gt = f"decreased by {abs(delta)}"
384        elif delta > 0:
385            gt = f"increased by {delta}"
386        else:
387            gt = "stayed the same"
388        opts, idx = _shuffle(
389            gt,
390            [
391                "stayed the same" if delta != 0 else "decreased by 1",
392                f"increased by {abs(delta) + 1}" if delta <= 0 else f"decreased by {delta + 1}",
393                "cannot determine",
394            ],
395            s,
396        )
397        pairs.append(
398            RobotQA(
399                scene_id=scene_id,
400                phase=0,
401                frame_id=frame_id,
402                task_type="T4_state",
403                robot_operation="task_verification",
404                question=q,
405                correct_answer=gt,
406                options=opts,
407                correct_index=idx,
408                metadata={"change_type": "count", "delta": delta},
409            )
410        )
411
412    return pairs

T4: 'Did the manipulation succeed? What changed?'

Compares clutter and clean phases of the same scene. This is what a robot does after every pick-and-place: verify. UNIQUE TO RPX — no other benchmark can generate this.

def generate_counting( visible_objects: List[Dict], scene_id: str, phase: int, frame_id: str) -> List[RobotQA]:
418def generate_counting(
419    visible_objects: List[Dict],
420    scene_id: str,
421    phase: int,
422    frame_id: str,
423) -> List[RobotQA]:
424    """T5: 'How many objects remain to pack?'"""
425    pairs = []
426    from collections import Counter
427
428    counts = Counter(o["category"] for o in visible_objects)
429
430    for cat, count in list(counts.items())[:3]:
431        s = _seed(scene_id, phase, frame_id, f"t5_{cat}")
432        q = f"A robot is packing all {cat} objects. How many does it need to pick?"
433        gt = str(count)
434        pool = [str(i) for i in range(max(count + 3, 5)) if str(i) != gt]
435        distractors = random.Random(s).sample(pool, min(3, len(pool)))
436        while len(distractors) < 3:
437            distractors.append("0")
438        opts, idx = _shuffle(gt, distractors[:3], s)
439        pairs.append(
440            RobotQA(
441                scene_id=scene_id,
442                phase=phase,
443                frame_id=frame_id,
444                task_type="T5_counting",
445                robot_operation="task_progress",
446                question=q,
447                correct_answer=gt,
448                options=opts,
449                correct_index=idx,
450                metadata={"category": cat, "count": count},
451            )
452        )
453
454    return pairs

T5: 'How many objects remain to pack?'

def evaluate_robot_vqa( predictions: List[int], ground_truth: List[RobotQA]) -> Dict:
460def evaluate_robot_vqa(
461    predictions: List[int],
462    ground_truth: List[RobotQA],
463) -> Dict:
464    """MCQ accuracy with breakdowns by task type, phase, and robot operation."""
465    assert len(predictions) == len(ground_truth)
466
467    by_type: Dict[str, List[bool]] = {}
468    by_phase: Dict[int, List[bool]] = {}
469    by_operation: Dict[str, List[bool]] = {}
470
471    for pred, qa in zip(predictions, ground_truth, strict=False):
472        hit = pred == qa.correct_index
473        by_type.setdefault(qa.task_type, []).append(hit)
474        by_phase.setdefault(qa.phase, []).append(hit)
475        by_operation.setdefault(qa.robot_operation, []).append(hit)
476
477    n = len(predictions)
478    correct = sum(
479        1 for p, q in zip(predictions, ground_truth, strict=False) if p == q.correct_index
480    )
481
482    result = {
483        "accuracy": correct / n if n else 0.0,
484        "n_questions": n,
485        "per_task_type": {t: sum(v) / len(v) for t, v in by_type.items()},
486        "per_phase": {p: sum(v) / len(v) for p, v in by_phase.items()},
487        "per_robot_operation": {o: sum(v) / len(v) for o, v in by_operation.items()},
488    }
489
490    # STR
491    pa = {p: sum(v) / len(v) for p, v in by_phase.items()}
492    if 0 in pa and 1 in pa:
493        result["str_clu_to_int"] = pa[1] - pa[0]
494    if 1 in pa and 2 in pa:
495        result["str_int_to_cln"] = pa[2] - pa[1]
496
497    return result

MCQ accuracy with breakdowns by task type, phase, and robot operation.