feat(annotate): support video datasets in VF advantage scoring

This commit is contained in:
Khalil Meftah
2026-07-08 12:16:01 +02:00
parent 3c3f3bdf61
commit 407a8c1d7d
3 changed files with 106 additions and 19 deletions
@@ -33,6 +33,7 @@ import numpy as np
import torch import torch
from ..config import AdvantageConfig from ..config import AdvantageConfig
from ..frames import VideoFrameProvider, null_provider
from ..reader import EpisodeRecord from ..reader import EpisodeRecord
from ..staging import EpisodeStaging from ..staging import EpisodeStaging
@@ -124,6 +125,9 @@ class AdvantageModule:
def _compute_values(self, record: EpisodeRecord, skip_mask: np.ndarray | None = None) -> np.ndarray: def _compute_values(self, record: EpisodeRecord, skip_mask: np.ndarray | None = None) -> np.ndarray:
"""Run frozen VF over all frames to get V(s_t) predictions. """Run frozen VF over all frames to get V(s_t) predictions.
Supports both image datasets (columns in parquet) and video datasets
(frames decoded from .mp4 via the shared VideoFrameProvider).
Args: Args:
record: Episode data. record: Episode data.
skip_mask: Optional boolean mask [num_frames]. Frames where True are skip_mask: Optional boolean mask [num_frames]. Frames where True are
@@ -133,17 +137,23 @@ class AdvantageModule:
num_frames = len(df) num_frames = len(df)
values = np.zeros(num_frames, dtype=np.float32) values = np.zeros(num_frames, dtype=np.float32)
image_key = self._resolve_image_key(df)
if image_key is None:
logger.warning("No image key found for episode %d; returning zero values.", record.episode_index)
return values
# Determine which frame indices actually need inference # Determine which frame indices actually need inference
infer_indices = np.where(~skip_mask)[0] if skip_mask is not None else np.arange(num_frames) infer_indices = np.where(~skip_mask)[0] if skip_mask is not None else np.arange(num_frames)
if len(infer_indices) == 0: if len(infer_indices) == 0:
return values return values
# Try parquet image columns first, fall back to video decoding
image_key = self._resolve_image_key(df)
video_frames = None
if image_key is None:
image_key, video_frames = self._decode_video_frames(record, infer_indices)
if image_key is None:
logger.warning(
"No image/video key found for episode %d; returning zero values.", record.episode_index
)
return values
task_text = record.episode_task task_text = record.episode_task
for batch_start in range(0, len(infer_indices), self.config.batch_size): for batch_start in range(0, len(infer_indices), self.config.batch_size):
@@ -151,14 +161,18 @@ class AdvantageModule:
batch_indices = infer_indices[batch_start:batch_end] batch_indices = infer_indices[batch_start:batch_end]
batch_images = [] batch_images = []
for idx in batch_indices: for local_i in range(len(batch_indices)):
img_val = df.iloc[idx][image_key] if video_frames is not None:
if isinstance(img_val, np.ndarray): img_tensor = video_frames[batch_start + local_i].float()
img_tensor = torch.from_numpy(img_val).float()
elif isinstance(img_val, torch.Tensor):
img_tensor = img_val.float()
else: else:
img_tensor = torch.zeros(3, 224, 224) idx = batch_indices[local_i]
img_val = df.iloc[idx][image_key]
if isinstance(img_val, np.ndarray):
img_tensor = torch.from_numpy(img_val).float()
elif isinstance(img_val, torch.Tensor):
img_tensor = img_val.float()
else:
img_tensor = torch.zeros(3, 224, 224)
batch_images.append(img_tensor) batch_images.append(img_tensor)
batch_images_tensor = torch.stack(batch_images) batch_images_tensor = torch.stack(batch_images)
@@ -178,6 +192,34 @@ class AdvantageModule:
return values return values
def _decode_video_frames(
self, record: EpisodeRecord, infer_indices: np.ndarray
) -> tuple[str | None, torch.Tensor | None]:
"""Decode video frames using the existing VideoFrameProvider infrastructure.
Returns (image_key, decoded_frames_tensor) or (None, None) on failure.
"""
dataset_root = record.data_path.parent.parent.parent
if not hasattr(self, "_frame_provider") or self._frame_provider is None:
try:
self._frame_provider = VideoFrameProvider(root=dataset_root)
except Exception:
self._frame_provider = null_provider()
if not self._frame_provider.camera_keys:
return None, None
camera_key = self._frame_provider.camera_keys[0]
timestamps = [float(record.frame_timestamps[i]) for i in infer_indices]
frames = self._frame_provider.frames_at(record, timestamps, camera_key=camera_key)
if not frames:
return None, None
frames_tensor = torch.stack(frames)
return camera_key, frames_tensor
def _compute_n_step_advantages( def _compute_n_step_advantages(
self, mc_returns: np.ndarray, values: np.ndarray, record: EpisodeRecord, n: int self, mc_returns: np.ndarray, values: np.ndarray, record: EpisodeRecord, n: int
) -> np.ndarray: ) -> np.ndarray:
@@ -31,6 +31,7 @@ rows into memory at once.
from __future__ import annotations from __future__ import annotations
import functools
from collections.abc import Iterator, Sequence from collections.abc import Iterator, Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -42,6 +43,18 @@ from lerobot.datasets.io_utils import load_tasks
from lerobot.datasets.utils import DEFAULT_TASKS_PATH from lerobot.datasets.utils import DEFAULT_TASKS_PATH
@functools.lru_cache(maxsize=8)
def _read_parquet_as_pandas(path: Path): # type: ignore[no-untyped-def]
"""Read a parquet shard once and cache the pandas DataFrame.
Multiple EpisodeRecords from the same shard share this single read.
The LRU cache (keyed by path) avoids re-reading the same file
across 100+ episodes that all live in one chunk.
"""
return pq.read_table(path).to_pandas()
@dataclass @dataclass
class EpisodeRecord: class EpisodeRecord:
"""Per-episode record yielded by the reader.""" """Per-episode record yielded by the reader."""
@@ -61,10 +74,7 @@ class EpisodeRecord:
def frames_df(self): # type: ignore[no-untyped-def] def frames_df(self): # type: ignore[no-untyped-def]
"""Lazy-load the pandas slice for this episode (memoized).""" """Lazy-load the pandas slice for this episode (memoized)."""
if self._frames_df_cache is None: if self._frames_df_cache is None:
import pandas as pd # noqa: PLC0415 - deferred for optional dataset extra df = _read_parquet_as_pandas(self.data_path)
table = pq.read_table(self.data_path)
df: pd.DataFrame = table.to_pandas()
self._frames_df_cache = df.iloc[self.row_offset : self.row_offset + self.row_count].reset_index( self._frames_df_cache = df.iloc[self.row_offset : self.row_offset + self.row_count].reset_index(
drop=True drop=True
) )
+37 -2
View File
@@ -53,6 +53,37 @@ def _resolve_root(cfg: AnnotationPipelineConfig) -> Path:
if cfg.repo_id is not None: if cfg.repo_id is not None:
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
needs_vlm = cfg.plan.enabled or cfg.interjections.enabled or cfg.vqa.enabled
advantage_only = cfg.advantage.enabled and not needs_vlm
if advantage_only:
# Download only metadata + parquet first to resolve the camera key,
# then fetch only the single camera's videos the advantage module needs.
root = Path(
snapshot_download(
repo_id=cfg.repo_id,
repo_type="dataset",
allow_patterns=["meta/**", "data/**"],
)
)
camera_key = cfg.vlm.camera_key
if camera_key is None:
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata # noqa: PLC0415
meta = LeRobotDatasetMetadata(repo_id="local", root=root)
depth_keys = set(meta.depth_keys)
video_keys = [k for k in meta.video_keys if k not in depth_keys]
camera_key = video_keys[0] if video_keys else None
if camera_key:
logger.info("advantage-only mode: downloading only camera %s", camera_key)
snapshot_download(
repo_id=cfg.repo_id,
repo_type="dataset",
allow_patterns=[f"videos/{camera_key}/**"],
)
return root
return Path(snapshot_download(repo_id=cfg.repo_id, repo_type="dataset")) return Path(snapshot_download(repo_id=cfg.repo_id, repo_type="dataset"))
raise ValueError("Either --root or --repo_id must be provided.") raise ValueError("Either --root or --repo_id must be provided.")
@@ -65,10 +96,11 @@ def annotate(cfg: AnnotationPipelineConfig) -> None:
logger.info("annotate: root=%s", root) logger.info("annotate: root=%s", root)
needs_vlm = cfg.plan.enabled or cfg.interjections.enabled or cfg.vqa.enabled needs_vlm = cfg.plan.enabled or cfg.interjections.enabled or cfg.vqa.enabled
needs_video = needs_vlm or cfg.advantage.enabled
vlm = make_vlm_client(cfg.vlm) if needs_vlm else None vlm = make_vlm_client(cfg.vlm) if needs_vlm else None
frame_provider = ( frame_provider = (
make_frame_provider(root, camera_key=cfg.vlm.camera_key, video_backend=cfg.video_backend) make_frame_provider(root, camera_key=cfg.vlm.camera_key, video_backend=cfg.video_backend)
if needs_vlm if needs_video
else None else None
) )
# Surface the resolved cameras up front so a silent vqa-module no-op # Surface the resolved cameras up front so a silent vqa-module no-op
@@ -105,7 +137,10 @@ def annotate(cfg: AnnotationPipelineConfig) -> None:
if needs_vlm if needs_vlm
else None else None
) )
advantage = AdvantageModule(config=cfg.advantage) advantage = AdvantageModule(
config=cfg.advantage,
**({"frame_provider": frame_provider} if frame_provider is not None else {}),
)
writer = LanguageColumnsWriter() writer = LanguageColumnsWriter()
validator = StagingValidator( validator = StagingValidator(
dataset_camera_keys=tuple(cam_keys) or None, dataset_camera_keys=tuple(cam_keys) or None,