| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| """``vqa`` module: general VQA at a timed cadence. |
| |
| Every ``1/hz`` seconds an emission tick fires; each tick anchors ``K`` |
| consecutive frames, and every anchored frame gets its own VQA pair. Each |
| pair is grounded on that single anchor frame — there is no per-pair frame |
| window. For datasets with multiple cameras, every anchored frame produces |
| one ``(vqa, user)`` + ``(vqa, assistant)`` pair *per camera*: each pair is |
| generated against that camera's frame and stamped with the matching |
| ``camera`` field on the emitted rows. The resolver disambiguates via |
| ``camera=...``; recipes that consume VQA do so through one sub-recipe |
| per camera (see ``recipes/pi05_hirobot.yaml``). |
| |
| Within a single (frame, camera) we still emit at most one ``(vqa, user)`` |
| and one ``(vqa, assistant)`` row, so the resolver contract stays scalar. |
| |
| Question types covered (per the plan's ``vqa`` table): bbox, keypoint, |
| count, attribute, spatial. The assistant's ``content`` is a JSON string |
| whose schema depends on the question type. Malformed JSON triggers one |
| retry inside :meth:`VlmClient.generate_json`. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import logging |
| import random |
| from collections.abc import Sequence |
| from dataclasses import dataclass, field |
| from typing import Any |
|
|
| from ..config import VqaConfig |
| from ..frames import FrameProvider, null_provider, to_image_blocks |
| from ..prompts import load as load_prompt |
| from ..reader import EpisodeRecord |
| from ..staging import EpisodeStaging |
| from ..validator import classify_vqa_answer |
| from ..vlm_client import VlmClient |
|
|
|
|
| def _emission_anchor_indices(frame_timestamps: Sequence[float], hz: float, k: int) -> list[int]: |
| """Return the relative frame indices to anchor VQA emissions to. |
| |
| For each emission tick (every ``1/hz`` seconds), we anchor ``k`` |
| consecutive frames starting at the tick. Ticks fall on the nearest |
| available source frame timestamp. |
| """ |
| if hz <= 0 or k <= 0 or not frame_timestamps: |
| return [] |
| t0 = frame_timestamps[0] |
| t_last = frame_timestamps[-1] |
| period = 1.0 / hz |
| indices: list[int] = [] |
| t = t0 |
| while t <= t_last + 1e-9: |
| |
| nearest_i = min(range(len(frame_timestamps)), key=lambda i: abs(frame_timestamps[i] - t)) |
| for offset in range(k): |
| j = nearest_i + offset |
| if j >= len(frame_timestamps): |
| break |
| if not indices or indices[-1] != j: |
| indices.append(j) |
| t += period |
| |
| seen: set[int] = set() |
| deduped: list[int] = [] |
| for i in indices: |
| if i in seen: |
| continue |
| seen.add(i) |
| deduped.append(i) |
| return deduped |
|
|
|
|
| @dataclass |
| class GeneralVqaModule: |
| """Emit grounded VQA pairs at a timed cadence.""" |
|
|
| vlm: VlmClient |
| config: VqaConfig |
| seed: int = 1729 |
| frame_provider: FrameProvider = field(default_factory=null_provider) |
| _warned_no_camera: bool = field(default=False, init=False, repr=False) |
|
|
| @property |
| def enabled(self) -> bool: |
| return self.config.enabled |
|
|
| def run_episode(self, record: EpisodeRecord, staging: EpisodeStaging) -> None: |
| if not record.frame_timestamps: |
| staging.write("vqa", []) |
| return |
| rng = random.Random(f"{self.seed}:{record.episode_index}:vqa") |
| anchor_idx = _emission_anchor_indices( |
| record.frame_timestamps, self.config.vqa_emission_hz, self.config.K |
| ) |
| cameras = self._target_cameras() |
| if not cameras: |
| |
| |
| |
| if not self._warned_no_camera: |
| logging.getLogger(__name__).warning( |
| "vqa module found no cameras on the frame provider — " |
| "every episode will emit zero VQA rows. Check that the " |
| "dataset declares observation.images.* features in " |
| "meta/info.json; passing --vlm.camera_key=<key> at the " |
| "CLI now also seeds the cameras list as a fallback." |
| ) |
| self._warned_no_camera = True |
| staging.write("vqa", []) |
| return |
|
|
| |
| |
| |
| per_call: list[tuple[float, str, str, list[dict[str, Any]]]] = [] |
| for idx in anchor_idx: |
| ts = float(record.frame_timestamps[idx]) |
| qtype = rng.choice(self.config.question_types) |
| for camera in cameras: |
| messages = self._build_messages(record, qtype, ts, camera) |
| |
| |
| if not _has_image_block(messages): |
| continue |
| per_call.append((ts, camera, qtype, messages)) |
|
|
| if not per_call: |
| staging.write("vqa", []) |
| return |
|
|
| results = self.vlm.generate_json([m for _, _, _, m in per_call]) |
|
|
| rows: list[dict[str, Any]] = [] |
| for (ts, camera, _qtype, _messages), result in zip(per_call, results, strict=True): |
| qa = self._postprocess(result) |
| if qa is None: |
| continue |
| question, answer = qa |
| rows.append( |
| { |
| "role": "user", |
| "content": question, |
| "style": "vqa", |
| "timestamp": ts, |
| "camera": camera, |
| "tool_calls": None, |
| } |
| ) |
| rows.append( |
| { |
| "role": "assistant", |
| "content": json.dumps(answer, sort_keys=True), |
| "style": "vqa", |
| "timestamp": ts, |
| "camera": camera, |
| "tool_calls": None, |
| } |
| ) |
| staging.write("vqa", rows) |
|
|
| def _target_cameras(self) -> list[str]: |
| """Return the cameras the ``vqa`` module should iterate per anchored frame. |
| |
| Defaults to every camera the provider exposes. Datasets with no |
| cameras (or test/null providers) yield an empty list, which makes |
| ``run_episode`` a no-op. |
| |
| When ``config.restrict_to_default_camera`` is set, VQA grounds on |
| only the provider's default camera (the single ``--vlm.camera_key`` |
| stream), matching the plan / interjection modules so the whole |
| pipeline focuses on one view. |
| """ |
| all_cameras = list(getattr(self.frame_provider, "camera_keys", []) or []) |
| if getattr(self.config, "restrict_to_default_camera", False): |
| default = getattr(self.frame_provider, "camera_key", None) |
| if default and default in all_cameras: |
| return [default] |
| |
| |
| |
| |
| if default: |
| logging.getLogger(__name__).warning( |
| "restrict_to_default_camera is set but camera_key=%r is not in the " |
| "provider's cameras %s; grounding VQA on all available cameras instead.", |
| default, |
| all_cameras, |
| ) |
| return all_cameras |
|
|
| def _build_messages( |
| self, |
| record: EpisodeRecord, |
| question_type: str, |
| frame_timestamp: float, |
| camera_key: str, |
| ) -> list[dict[str, Any]]: |
| prompt = load_prompt("vqa").format( |
| episode_task=record.episode_task, |
| question_type=question_type, |
| ) |
| images = self.frame_provider.frames_at(record, [frame_timestamp], camera_key=camera_key) |
| content = [*to_image_blocks(images), {"type": "text", "text": prompt}] |
| return [{"role": "user", "content": content}] |
|
|
| def _postprocess(self, result: Any) -> tuple[str, dict[str, Any]] | None: |
| if not isinstance(result, dict): |
| return None |
| question = result.get("question") |
| answer = result.get("answer") |
| if not isinstance(question, str) or not question.strip(): |
| return None |
| if not isinstance(answer, dict): |
| return None |
| |
| |
| if classify_vqa_answer(answer) is None: |
| return None |
| return question.strip(), answer |
|
|
|
|
| def _has_image_block(messages: list[dict[str, Any]]) -> bool: |
| """Return True if any user content block is a populated image block.""" |
| for msg in messages: |
| content = msg.get("content") |
| if not isinstance(content, list): |
| continue |
| for block in content: |
| if isinstance(block, dict) and block.get("type") == "image": |
| return True |
| return False |
|
|