rpx_benchmark.tasks.vqa_generation

RPX VQA ground-truth generation.

Generates deterministic Q/A pairs from annotations. No human labeling needed. No LLM. No fuzzy matching. MCQ with 4 options, random baseline = 25%.

Design principles (Karpathy/Girshick/Fox/He):

  • Every question has ONE correct answer derivable from masks + questionnaire.
  • Every question maps to a robot operation.
  • Phase variation is the anti-bias mechanism — same question, different answer.
  • MCQ distractors come from real attributes of real objects in the dataset.
  • Option order is shuffled per question with a fixed seed for reproducibility.

Question types: Q1. Existence — "Is there a red mug?" → from visibility mask Q2. Attribute — "What color is the mug?" → from questionnaire Q3. Count — "How many cups are visible?" → from mask instance count Q4. Spatial — "What is left of the bowl?" → from bbox centroids (image plane)

Evaluation: MCQ accuracy. Per-phase breakdown → feeds STR.

  1"""RPX VQA ground-truth generation.
  2
  3Generates deterministic Q/A pairs from annotations. No human labeling needed.
  4No LLM. No fuzzy matching. MCQ with 4 options, random baseline = 25%.
  5
  6Design principles (Karpathy/Girshick/Fox/He):
  7  - Every question has ONE correct answer derivable from masks + questionnaire.
  8  - Every question maps to a robot operation.
  9  - Phase variation is the anti-bias mechanism — same question, different answer.
 10  - MCQ distractors come from real attributes of real objects in the dataset.
 11  - Option order is shuffled per question with a fixed seed for reproducibility.
 12
 13Question types:
 14  Q1. Existence   — "Is there a red mug?"        → from visibility mask
 15  Q2. Attribute   — "What color is the mug?"      → from questionnaire
 16  Q3. Count       — "How many cups are visible?"   → from mask instance count
 17  Q4. Spatial     — "What is left of the bowl?"    → from bbox centroids (image plane)
 18
 19Evaluation: MCQ accuracy. Per-phase breakdown → feeds STR.
 20"""
 21
 22from __future__ import annotations
 23
 24import hashlib
 25import random
 26from dataclasses import dataclass, field
 27from typing import Dict, List, Tuple
 28
 29# ── Question templates ──────────────────────────────────────────────────────
 30# Each template maps to a robot operation. Multiple phrasings per type
 31# to reduce template memorization, but all are simple imperative/interrogative.
 32
 33EXISTENCE_TEMPLATES = [
 34    "Is there a {attr} {category} in this scene?",
 35    "Can you see a {attr} {category}?",
 36    "Is a {attr} {category} present?",
 37]
 38
 39ATTRIBUTE_TEMPLATES = {
 40    "color": [
 41        "What color is the {category}?",
 42        "What is the color of the {category}?",
 43    ],
 44    "material": [
 45        "What material is the {category} made of?",
 46        "What is the material of the {category}?",
 47    ],
 48    "shape": [
 49        "What shape is the {category}?",
 50        "What is the shape of the {category}?",
 51    ],
 52}
 53
 54COUNT_TEMPLATES = [
 55    "How many {category} objects are visible?",
 56    "How many {category} can you see?",
 57    "Count the {category} in this scene.",
 58]
 59
 60SPATIAL_TEMPLATES = [
 61    "What object is to the {direction} of the {object}?",
 62    "What is {direction} of the {object}?",
 63]
 64
 65SPATIAL_DIRECTIONS = ["left", "right", "above", "below"]
 66
 67
 68@dataclass
 69class QAPair:
 70    """One question-answer pair with MCQ options."""
 71
 72    scene_id: str
 73    phase: int
 74    frame_id: str
 75    question_type: str  # existence | attribute | count | spatial
 76    question: str
 77    correct_answer: str
 78    options: List[str]  # 4 options, correct answer included, shuffled
 79    correct_index: int  # index of correct answer in options
 80    metadata: Dict = field(default_factory=dict)
 81
 82    def to_dict(self) -> dict:
 83        return {
 84            "scene_id": self.scene_id,
 85            "phase": self.phase,
 86            "frame_id": self.frame_id,
 87            "question_type": self.question_type,
 88            "question": self.question,
 89            "correct_answer": self.correct_answer,
 90            "options": self.options,
 91            "correct_index": self.correct_index,
 92            "metadata": self.metadata,
 93        }
 94
 95
 96def _deterministic_seed(scene_id: str, phase: int, frame_id: str, q: str) -> int:
 97    """Reproducible seed per question for option shuffling."""
 98    h = hashlib.md5(f"{scene_id}/{phase}/{frame_id}/{q}".encode()).hexdigest()
 99    return int(h[:8], 16)
100
101
102def _shuffle_options(correct: str, distractors: List[str], seed: int) -> Tuple[List[str], int]:
103    """Shuffle 4 options with deterministic seed. Return (options, correct_index)."""
104    opts = [correct] + distractors[:3]
105    rng = random.Random(seed)
106    rng.shuffle(opts)
107    return opts, opts.index(correct)
108
109
110def _pick_template(templates: list, seed: int) -> str:
111    """Pick a template deterministically."""
112    return templates[seed % len(templates)]
113
114
115# ── Generators ──────────────────────────────────────────────────────────────
116
117
118def generate_existence_questions(
119    visible_objects: List[Dict],  # [{"category": "mug", "color": "red", ...}, ...]
120    all_objects_in_dataset: List[Dict],  # for generating "no" distractors
121    scene_id: str,
122    phase: int,
123    frame_id: str,
124) -> List[QAPair]:
125    """Generate balanced existence questions: 50% yes, 50% no.
126
127    'Yes' questions ask about objects visible in this frame.
128    'No' questions ask about objects NOT visible (from other scenes in the dataset).
129    This prevents a model from scoring well by always answering 'yes'.
130    """
131    pairs = []
132    visible_set = {(o["category"], o.get("color", "")) for o in visible_objects}
133
134    # Yes questions — pick up to 3 visible objects
135    for obj in visible_objects[:3]:
136        attr = obj.get("color", obj.get("material", ""))
137        if not attr:
138            continue
139        seed = _deterministic_seed(scene_id, phase, frame_id, f"exist_yes_{obj['category']}")
140        q = _pick_template(EXISTENCE_TEMPLATES, seed).format(attr=attr, category=obj["category"])
141        opts, idx = _shuffle_options("yes", ["no", "not sure", "partially visible"], seed)
142        pairs.append(
143            QAPair(
144                scene_id=scene_id,
145                phase=phase,
146                frame_id=frame_id,
147                question_type="existence",
148                question=q,
149                correct_answer="yes",
150                options=opts,
151                correct_index=idx,
152                metadata={"object": obj["category"], "expected": True},
153            )
154        )
155
156    # No questions — ask about objects absent from this frame
157    absent = [
158        o for o in all_objects_in_dataset if (o["category"], o.get("color", "")) not in visible_set
159    ]
160    for obj in absent[: len(pairs)]:  # match count for 50/50 balance
161        attr = obj.get("color", obj.get("material", ""))
162        if not attr:
163            continue
164        seed = _deterministic_seed(scene_id, phase, frame_id, f"exist_no_{obj['category']}")
165        q = _pick_template(EXISTENCE_TEMPLATES, seed).format(attr=attr, category=obj["category"])
166        opts, idx = _shuffle_options("no", ["yes", "not sure", "partially visible"], seed)
167        pairs.append(
168            QAPair(
169                scene_id=scene_id,
170                phase=phase,
171                frame_id=frame_id,
172                question_type="existence",
173                question=q,
174                correct_answer="no",
175                options=opts,
176                correct_index=idx,
177                metadata={"object": obj["category"], "expected": False},
178            )
179        )
180
181    return pairs
182
183
184def generate_attribute_questions(
185    visible_objects: List[Dict],
186    all_objects_in_dataset: List[Dict],  # for distractors
187    scene_id: str,
188    phase: int,
189    frame_id: str,
190) -> List[QAPair]:
191    """Generate attribute questions with distractors from real objects."""
192    pairs = []
193    # Collect all attribute values by type for distractor pool
194    attr_pool: Dict[str, List[str]] = {"color": [], "material": [], "shape": []}
195    for o in all_objects_in_dataset:
196        for attr_type in attr_pool:
197            v = o.get(attr_type, "")
198            if v and v not in attr_pool[attr_type]:
199                attr_pool[attr_type].append(v)
200
201    for obj in visible_objects[:5]:
202        for attr_type, templates in ATTRIBUTE_TEMPLATES.items():
203            gt_val = obj.get(attr_type, "")
204            if not gt_val:
205                continue
206            # Get 3 distractors from real attribute values
207            pool = [v for v in attr_pool.get(attr_type, []) if v != gt_val]
208            if len(pool) < 3:
209                continue
210
211            seed = _deterministic_seed(
212                scene_id, phase, frame_id, f"attr_{attr_type}_{obj['category']}"
213            )
214            rng = random.Random(seed)
215            distractors = rng.sample(pool, 3)
216            q = _pick_template(templates, seed).format(category=obj["category"])
217            opts, idx = _shuffle_options(gt_val, distractors, seed)
218            pairs.append(
219                QAPair(
220                    scene_id=scene_id,
221                    phase=phase,
222                    frame_id=frame_id,
223                    question_type="attribute",
224                    question=q,
225                    correct_answer=gt_val,
226                    options=opts,
227                    correct_index=idx,
228                    metadata={"object": obj["category"], "attribute": attr_type},
229                )
230            )
231
232    return pairs
233
234
235def generate_count_questions(
236    visible_objects: List[Dict],
237    scene_id: str,
238    phase: int,
239    frame_id: str,
240) -> List[QAPair]:
241    """Generate counting questions. GT from mask instance count."""
242    pairs = []
243    # Count by category
244    counts: Dict[str, int] = {}
245    for obj in visible_objects:
246        counts[obj["category"]] = counts.get(obj["category"], 0) + 1
247
248    # Ask about categories with ≥1 instance
249    for cat, count in counts.items():
250        seed = _deterministic_seed(scene_id, phase, frame_id, f"count_{cat}")
251        q = _pick_template(COUNT_TEMPLATES, seed).format(category=cat)
252        gt = str(count)
253        # Distractors: nearby integers, always including 0
254        distractor_pool = [str(i) for i in range(0, max(count + 4, 6)) if str(i) != gt]
255        rng = random.Random(seed)
256        distractors = rng.sample(distractor_pool, min(3, len(distractor_pool)))
257        if len(distractors) < 3:
258            continue
259        opts, idx = _shuffle_options(gt, distractors, seed)
260        pairs.append(
261            QAPair(
262                scene_id=scene_id,
263                phase=phase,
264                frame_id=frame_id,
265                question_type="count",
266                question=q,
267                correct_answer=gt,
268                options=opts,
269                correct_index=idx,
270                metadata={"category": cat, "count": count},
271            )
272        )
273
274    return pairs
275
276
277def generate_spatial_questions(
278    visible_objects: List[Dict],  # must include "bbox_cx", "bbox_cy"
279    scene_id: str,
280    phase: int,
281    frame_id: str,
282) -> List[QAPair]:
283    """Generate spatial questions. Relations defined in image plane.
284
285    "Left" = smaller x-coordinate. "Above" = smaller y-coordinate.
286    This matches how VLMs process images and avoids viewpoint ambiguity.
287    """
288    pairs = []
289    if len(visible_objects) < 2:
290        return pairs
291
292    for ref_obj in visible_objects[:3]:
293        ref_cx = ref_obj.get("bbox_cx")
294        ref_cy = ref_obj.get("bbox_cy")
295        if ref_cx is None or ref_cy is None:
296            continue
297
298        for direction in SPATIAL_DIRECTIONS:
299            # Find the nearest object in that direction
300            best = None
301            best_dist = float("inf")
302            for other in visible_objects:
303                if other["category"] == ref_obj["category"] and other.get(
304                    "instance_id"
305                ) == ref_obj.get("instance_id"):
306                    continue
307                ox, oy = other.get("bbox_cx"), other.get("bbox_cy")
308                if ox is None or oy is None:
309                    continue
310
311                # Check direction
312                if direction == "left" and ox >= ref_cx:
313                    continue
314                if direction == "right" and ox <= ref_cx:
315                    continue
316                if direction == "above" and oy >= ref_cy:
317                    continue
318                if direction == "below" and oy <= ref_cy:
319                    continue
320
321                dist = abs(ox - ref_cx) + abs(oy - ref_cy)
322                if dist < best_dist:
323                    best_dist = dist
324                    best = other
325
326            if best is None:
327                continue
328
329            seed = _deterministic_seed(
330                scene_id, phase, frame_id, f"spatial_{direction}_{ref_obj['category']}"
331            )
332            q = _pick_template(SPATIAL_TEMPLATES, seed).format(
333                direction=direction, object=ref_obj["category"]
334            )
335            gt = best["category"]
336
337            # Distractors: other visible objects
338            others = [
339                o["category"]
340                for o in visible_objects
341                if o["category"] != gt and o["category"] != ref_obj["category"]
342            ]
343            others = list(set(others))
344            if len(others) < 2:
345                others += ["nothing", "unknown"]
346            rng = random.Random(seed)
347            distractors = rng.sample(others, min(3, len(others)))
348            while len(distractors) < 3:
349                distractors.append("nothing")
350            opts, idx = _shuffle_options(gt, distractors[:3], seed)
351
352            pairs.append(
353                QAPair(
354                    scene_id=scene_id,
355                    phase=phase,
356                    frame_id=frame_id,
357                    question_type="spatial",
358                    question=q,
359                    correct_answer=gt,
360                    options=opts,
361                    correct_index=idx,
362                    metadata={
363                        "reference": ref_obj["category"],
364                        "direction": direction,
365                        "target": best["category"],
366                    },
367                )
368            )
369            break  # one spatial question per reference object per direction
370
371    return pairs
372
373
374# ── Evaluation ──────────────────────────────────────────────────────────────
375
376
377def evaluate_mcq(predictions: List[int], ground_truth: List[QAPair]) -> Dict:
378    """Evaluate MCQ predictions. Returns accuracy overall and per type/phase.
379
380    Args:
381        predictions: list of predicted option indices (0-3)
382        ground_truth: list of QAPair objects
383
384    Returns:
385        dict with overall accuracy + per-type + per-phase breakdowns
386    """
387    assert len(predictions) == len(ground_truth)
388
389    correct = 0
390    by_type: Dict[str, List[bool]] = {}
391    by_phase: Dict[int, List[bool]] = {}
392
393    for pred, qa in zip(predictions, ground_truth, strict=False):
394        hit = pred == qa.correct_index
395        correct += hit
396
397        by_type.setdefault(qa.question_type, []).append(hit)
398        by_phase.setdefault(qa.phase, []).append(hit)
399
400    n = len(predictions)
401    result = {
402        "accuracy": correct / n if n > 0 else 0.0,
403        "n_questions": n,
404        "per_type": {t: sum(v) / len(v) for t, v in by_type.items()},
405        "per_phase": {p: sum(v) / len(v) for p, v in by_phase.items()},
406    }
407
408    # STR for VQA = phase accuracy difference
409    phases = sorted(by_phase.keys())
410    if len(phases) >= 2:
411        phase_acc = {p: sum(v) / len(v) for p, v in by_phase.items()}
412        if 0 in phase_acc and 1 in phase_acc:
413            result["str_clu_to_int"] = phase_acc[1] - phase_acc[0]
414        if 1 in phase_acc and 2 in phase_acc:
415            result["str_int_to_cln"] = phase_acc[2] - phase_acc[1]
416
417    return result
EXISTENCE_TEMPLATES = ['Is there a {attr} {category} in this scene?', 'Can you see a {attr} {category}?', 'Is a {attr} {category} present?']
ATTRIBUTE_TEMPLATES = {'color': ['What color is the {category}?', 'What is the color of the {category}?'], 'material': ['What material is the {category} made of?', 'What is the material of the {category}?'], 'shape': ['What shape is the {category}?', 'What is the shape of the {category}?']}
COUNT_TEMPLATES = ['How many {category} objects are visible?', 'How many {category} can you see?', 'Count the {category} in this scene.']
SPATIAL_TEMPLATES = ['What object is to the {direction} of the {object}?', 'What is {direction} of the {object}?']
SPATIAL_DIRECTIONS = ['left', 'right', 'above', 'below']
@dataclass
class QAPair:
69@dataclass
70class QAPair:
71    """One question-answer pair with MCQ options."""
72
73    scene_id: str
74    phase: int
75    frame_id: str
76    question_type: str  # existence | attribute | count | spatial
77    question: str
78    correct_answer: str
79    options: List[str]  # 4 options, correct answer included, shuffled
80    correct_index: int  # index of correct answer in options
81    metadata: Dict = field(default_factory=dict)
82
83    def to_dict(self) -> dict:
84        return {
85            "scene_id": self.scene_id,
86            "phase": self.phase,
87            "frame_id": self.frame_id,
88            "question_type": self.question_type,
89            "question": self.question,
90            "correct_answer": self.correct_answer,
91            "options": self.options,
92            "correct_index": self.correct_index,
93            "metadata": self.metadata,
94        }

One question-answer pair with MCQ options.

QAPair( scene_id: str, phase: int, frame_id: str, question_type: str, question: str, correct_answer: str, options: List[str], correct_index: int, metadata: Dict = <factory>)
scene_id: str
phase: int
frame_id: str
question_type: str
question: str
correct_answer: str
options: List[str]
correct_index: int
metadata: Dict
def to_dict(self) -> dict:
83    def to_dict(self) -> dict:
84        return {
85            "scene_id": self.scene_id,
86            "phase": self.phase,
87            "frame_id": self.frame_id,
88            "question_type": self.question_type,
89            "question": self.question,
90            "correct_answer": self.correct_answer,
91            "options": self.options,
92            "correct_index": self.correct_index,
93            "metadata": self.metadata,
94        }
def generate_existence_questions( visible_objects: List[Dict], all_objects_in_dataset: List[Dict], scene_id: str, phase: int, frame_id: str) -> List[QAPair]:
119def generate_existence_questions(
120    visible_objects: List[Dict],  # [{"category": "mug", "color": "red", ...}, ...]
121    all_objects_in_dataset: List[Dict],  # for generating "no" distractors
122    scene_id: str,
123    phase: int,
124    frame_id: str,
125) -> List[QAPair]:
126    """Generate balanced existence questions: 50% yes, 50% no.
127
128    'Yes' questions ask about objects visible in this frame.
129    'No' questions ask about objects NOT visible (from other scenes in the dataset).
130    This prevents a model from scoring well by always answering 'yes'.
131    """
132    pairs = []
133    visible_set = {(o["category"], o.get("color", "")) for o in visible_objects}
134
135    # Yes questions — pick up to 3 visible objects
136    for obj in visible_objects[:3]:
137        attr = obj.get("color", obj.get("material", ""))
138        if not attr:
139            continue
140        seed = _deterministic_seed(scene_id, phase, frame_id, f"exist_yes_{obj['category']}")
141        q = _pick_template(EXISTENCE_TEMPLATES, seed).format(attr=attr, category=obj["category"])
142        opts, idx = _shuffle_options("yes", ["no", "not sure", "partially visible"], seed)
143        pairs.append(
144            QAPair(
145                scene_id=scene_id,
146                phase=phase,
147                frame_id=frame_id,
148                question_type="existence",
149                question=q,
150                correct_answer="yes",
151                options=opts,
152                correct_index=idx,
153                metadata={"object": obj["category"], "expected": True},
154            )
155        )
156
157    # No questions — ask about objects absent from this frame
158    absent = [
159        o for o in all_objects_in_dataset if (o["category"], o.get("color", "")) not in visible_set
160    ]
161    for obj in absent[: len(pairs)]:  # match count for 50/50 balance
162        attr = obj.get("color", obj.get("material", ""))
163        if not attr:
164            continue
165        seed = _deterministic_seed(scene_id, phase, frame_id, f"exist_no_{obj['category']}")
166        q = _pick_template(EXISTENCE_TEMPLATES, seed).format(attr=attr, category=obj["category"])
167        opts, idx = _shuffle_options("no", ["yes", "not sure", "partially visible"], seed)
168        pairs.append(
169            QAPair(
170                scene_id=scene_id,
171                phase=phase,
172                frame_id=frame_id,
173                question_type="existence",
174                question=q,
175                correct_answer="no",
176                options=opts,
177                correct_index=idx,
178                metadata={"object": obj["category"], "expected": False},
179            )
180        )
181
182    return pairs

Generate balanced existence questions: 50% yes, 50% no.

'Yes' questions ask about objects visible in this frame. 'No' questions ask about objects NOT visible (from other scenes in the dataset). This prevents a model from scoring well by always answering 'yes'.

def generate_attribute_questions( visible_objects: List[Dict], all_objects_in_dataset: List[Dict], scene_id: str, phase: int, frame_id: str) -> List[QAPair]:
185def generate_attribute_questions(
186    visible_objects: List[Dict],
187    all_objects_in_dataset: List[Dict],  # for distractors
188    scene_id: str,
189    phase: int,
190    frame_id: str,
191) -> List[QAPair]:
192    """Generate attribute questions with distractors from real objects."""
193    pairs = []
194    # Collect all attribute values by type for distractor pool
195    attr_pool: Dict[str, List[str]] = {"color": [], "material": [], "shape": []}
196    for o in all_objects_in_dataset:
197        for attr_type in attr_pool:
198            v = o.get(attr_type, "")
199            if v and v not in attr_pool[attr_type]:
200                attr_pool[attr_type].append(v)
201
202    for obj in visible_objects[:5]:
203        for attr_type, templates in ATTRIBUTE_TEMPLATES.items():
204            gt_val = obj.get(attr_type, "")
205            if not gt_val:
206                continue
207            # Get 3 distractors from real attribute values
208            pool = [v for v in attr_pool.get(attr_type, []) if v != gt_val]
209            if len(pool) < 3:
210                continue
211
212            seed = _deterministic_seed(
213                scene_id, phase, frame_id, f"attr_{attr_type}_{obj['category']}"
214            )
215            rng = random.Random(seed)
216            distractors = rng.sample(pool, 3)
217            q = _pick_template(templates, seed).format(category=obj["category"])
218            opts, idx = _shuffle_options(gt_val, distractors, seed)
219            pairs.append(
220                QAPair(
221                    scene_id=scene_id,
222                    phase=phase,
223                    frame_id=frame_id,
224                    question_type="attribute",
225                    question=q,
226                    correct_answer=gt_val,
227                    options=opts,
228                    correct_index=idx,
229                    metadata={"object": obj["category"], "attribute": attr_type},
230                )
231            )
232
233    return pairs

Generate attribute questions with distractors from real objects.

def generate_count_questions( visible_objects: List[Dict], scene_id: str, phase: int, frame_id: str) -> List[QAPair]:
236def generate_count_questions(
237    visible_objects: List[Dict],
238    scene_id: str,
239    phase: int,
240    frame_id: str,
241) -> List[QAPair]:
242    """Generate counting questions. GT from mask instance count."""
243    pairs = []
244    # Count by category
245    counts: Dict[str, int] = {}
246    for obj in visible_objects:
247        counts[obj["category"]] = counts.get(obj["category"], 0) + 1
248
249    # Ask about categories with ≥1 instance
250    for cat, count in counts.items():
251        seed = _deterministic_seed(scene_id, phase, frame_id, f"count_{cat}")
252        q = _pick_template(COUNT_TEMPLATES, seed).format(category=cat)
253        gt = str(count)
254        # Distractors: nearby integers, always including 0
255        distractor_pool = [str(i) for i in range(0, max(count + 4, 6)) if str(i) != gt]
256        rng = random.Random(seed)
257        distractors = rng.sample(distractor_pool, min(3, len(distractor_pool)))
258        if len(distractors) < 3:
259            continue
260        opts, idx = _shuffle_options(gt, distractors, seed)
261        pairs.append(
262            QAPair(
263                scene_id=scene_id,
264                phase=phase,
265                frame_id=frame_id,
266                question_type="count",
267                question=q,
268                correct_answer=gt,
269                options=opts,
270                correct_index=idx,
271                metadata={"category": cat, "count": count},
272            )
273        )
274
275    return pairs

Generate counting questions. GT from mask instance count.

def generate_spatial_questions( visible_objects: List[Dict], scene_id: str, phase: int, frame_id: str) -> List[QAPair]:
278def generate_spatial_questions(
279    visible_objects: List[Dict],  # must include "bbox_cx", "bbox_cy"
280    scene_id: str,
281    phase: int,
282    frame_id: str,
283) -> List[QAPair]:
284    """Generate spatial questions. Relations defined in image plane.
285
286    "Left" = smaller x-coordinate. "Above" = smaller y-coordinate.
287    This matches how VLMs process images and avoids viewpoint ambiguity.
288    """
289    pairs = []
290    if len(visible_objects) < 2:
291        return pairs
292
293    for ref_obj in visible_objects[:3]:
294        ref_cx = ref_obj.get("bbox_cx")
295        ref_cy = ref_obj.get("bbox_cy")
296        if ref_cx is None or ref_cy is None:
297            continue
298
299        for direction in SPATIAL_DIRECTIONS:
300            # Find the nearest object in that direction
301            best = None
302            best_dist = float("inf")
303            for other in visible_objects:
304                if other["category"] == ref_obj["category"] and other.get(
305                    "instance_id"
306                ) == ref_obj.get("instance_id"):
307                    continue
308                ox, oy = other.get("bbox_cx"), other.get("bbox_cy")
309                if ox is None or oy is None:
310                    continue
311
312                # Check direction
313                if direction == "left" and ox >= ref_cx:
314                    continue
315                if direction == "right" and ox <= ref_cx:
316                    continue
317                if direction == "above" and oy >= ref_cy:
318                    continue
319                if direction == "below" and oy <= ref_cy:
320                    continue
321
322                dist = abs(ox - ref_cx) + abs(oy - ref_cy)
323                if dist < best_dist:
324                    best_dist = dist
325                    best = other
326
327            if best is None:
328                continue
329
330            seed = _deterministic_seed(
331                scene_id, phase, frame_id, f"spatial_{direction}_{ref_obj['category']}"
332            )
333            q = _pick_template(SPATIAL_TEMPLATES, seed).format(
334                direction=direction, object=ref_obj["category"]
335            )
336            gt = best["category"]
337
338            # Distractors: other visible objects
339            others = [
340                o["category"]
341                for o in visible_objects
342                if o["category"] != gt and o["category"] != ref_obj["category"]
343            ]
344            others = list(set(others))
345            if len(others) < 2:
346                others += ["nothing", "unknown"]
347            rng = random.Random(seed)
348            distractors = rng.sample(others, min(3, len(others)))
349            while len(distractors) < 3:
350                distractors.append("nothing")
351            opts, idx = _shuffle_options(gt, distractors[:3], seed)
352
353            pairs.append(
354                QAPair(
355                    scene_id=scene_id,
356                    phase=phase,
357                    frame_id=frame_id,
358                    question_type="spatial",
359                    question=q,
360                    correct_answer=gt,
361                    options=opts,
362                    correct_index=idx,
363                    metadata={
364                        "reference": ref_obj["category"],
365                        "direction": direction,
366                        "target": best["category"],
367                    },
368                )
369            )
370            break  # one spatial question per reference object per direction
371
372    return pairs

Generate spatial questions. Relations defined in image plane.

"Left" = smaller x-coordinate. "Above" = smaller y-coordinate. This matches how VLMs process images and avoids viewpoint ambiguity.

def evaluate_mcq( predictions: List[int], ground_truth: List[QAPair]) -> Dict:
378def evaluate_mcq(predictions: List[int], ground_truth: List[QAPair]) -> Dict:
379    """Evaluate MCQ predictions. Returns accuracy overall and per type/phase.
380
381    Args:
382        predictions: list of predicted option indices (0-3)
383        ground_truth: list of QAPair objects
384
385    Returns:
386        dict with overall accuracy + per-type + per-phase breakdowns
387    """
388    assert len(predictions) == len(ground_truth)
389
390    correct = 0
391    by_type: Dict[str, List[bool]] = {}
392    by_phase: Dict[int, List[bool]] = {}
393
394    for pred, qa in zip(predictions, ground_truth, strict=False):
395        hit = pred == qa.correct_index
396        correct += hit
397
398        by_type.setdefault(qa.question_type, []).append(hit)
399        by_phase.setdefault(qa.phase, []).append(hit)
400
401    n = len(predictions)
402    result = {
403        "accuracy": correct / n if n > 0 else 0.0,
404        "n_questions": n,
405        "per_type": {t: sum(v) / len(v) for t, v in by_type.items()},
406        "per_phase": {p: sum(v) / len(v) for p, v in by_phase.items()},
407    }
408
409    # STR for VQA = phase accuracy difference
410    phases = sorted(by_phase.keys())
411    if len(phases) >= 2:
412        phase_acc = {p: sum(v) / len(v) for p, v in by_phase.items()}
413        if 0 in phase_acc and 1 in phase_acc:
414            result["str_clu_to_int"] = phase_acc[1] - phase_acc[0]
415        if 1 in phase_acc and 2 in phase_acc:
416            result["str_int_to_cln"] = phase_acc[2] - phase_acc[1]
417
418    return result

Evaluate MCQ predictions. Returns accuracy overall and per type/phase.

Args: predictions: list of predicted option indices (0-3) ground_truth: list of QAPair objects

Returns: dict with overall accuracy + per-type + per-phase breakdowns