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
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.
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 }
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'.
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.
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.
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.
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