Rate this Page

Source code for torchrl.data.datasets.lerobot

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
"""LeRobot dataset adapter mapping to the canonical VLA TensorDict schema."""
from __future__ import annotations

import importlib.util
import json
import os
from collections.abc import Callable
from pathlib import Path
from typing import Any, TYPE_CHECKING

import torch
from tensordict import NonTensorStack, TensorDict, TensorDictBase
from tensordict.utils import NestedKey

from torchrl._utils import logger as torchrl_logger
from torchrl.data.datasets.common import BaseDatasetExperienceReplay

if TYPE_CHECKING:
    from torchrl.envs import Transform
from torchrl.data.datasets.utils import _get_root_dir
from torchrl.data.replay_buffers.samplers import Sampler, SliceSampler
from torchrl.data.replay_buffers.storages import _collate_id, TensorStorage
from torchrl.data.replay_buffers.writers import ImmutableDatasetWriter, Writer
from torchrl.data.video import _has_torchcodec, VideoClipRef

_has_datasets = importlib.util.find_spec("datasets") is not None
_has_hf_hub = importlib.util.find_spec("huggingface_hub") is not None

__all__ = ["LeRobotExperienceReplay", "lerobot_columns_to_tensordict"]

#: Default mapping from LeRobot's dotted column names to canonical VLA keys.
#: ``observation.images.<camera>`` columns are mapped automatically to
#: ``("observation", "image", <camera>)``; any other dotted name is split into a
#: nested key.
_DEFAULT_KEY_MAP: dict[str, NestedKey] = {
    "action": "action",
    "observation.state": ("observation", "state"),
    "episode_index": "episode",
    "frame_index": "frame",
    "task": "language_instruction",
    "next.reward": ("next", "reward"),
    "next.done": ("next", "done"),
}

_IMAGE_PREFIX = "observation.images."


def _map_lerobot_key(name: str, key_map: dict[str, NestedKey]) -> NestedKey:
    if name in key_map:
        return key_map[name]
    if name.startswith(_IMAGE_PREFIX):
        return ("observation", "image", name[len(_IMAGE_PREFIX) :])
    return tuple(name.split(".")) if "." in name else name


[docs] def lerobot_columns_to_tensordict( columns: dict[str, Any], *, key_map: dict[str, NestedKey] | None = None ) -> TensorDict: """Convert a LeRobot-style columnar dict into a canonical VLA TensorDict. LeRobot stores per-frame data under dotted column names (``observation.state``, ``observation.images.<camera>``, ``action``, ``episode_index``, ``task``, ...). This builds a flat ``[N]`` TensorDict using the canonical VLA key layout: proprioceptive state and images under ``observation``, the per-frame language instruction and the action at the root, and ``episode`` for trajectory boundaries (see :func:`~torchrl.data.vla.validate_vla_tensordict`). Args: columns (dict): mapping from LeRobot column name to a tensor (numeric columns), a list of strings (e.g. the ``task`` instruction), or a :class:`~torchrl.data.VideoClipRef` (lazy video frames, decoded on sampling by :class:`~torchrl.envs.transforms.DecodeVideoTransform`). Keyword Args: key_map (dict, optional): overrides/extends :data:`_DEFAULT_KEY_MAP`, mapping a source column name to a target :class:`~tensordict.utils.NestedKey`. Returns: a flat ``[N]`` :class:`~tensordict.TensorDict`. Examples: >>> import torch >>> from torchrl.data.datasets.lerobot import lerobot_columns_to_tensordict >>> columns = { ... "observation.state": torch.zeros(4, 7), ... "observation.images.top": torch.zeros(4, 3, 8, 8, dtype=torch.uint8), ... "action": torch.zeros(4, 7), ... "episode_index": torch.tensor([0, 0, 1, 1]), ... "task": ["pick", "pick", "place", "place"], ... } >>> td = lerobot_columns_to_tensordict(columns) >>> td["observation", "state"].shape torch.Size([4, 7]) >>> td["observation", "image", "top"].shape torch.Size([4, 3, 8, 8]) >>> td.get("language_instruction").tolist() ['pick', 'pick', 'place', 'place'] """ key_map = {**_DEFAULT_KEY_MAP, **(key_map or {})} n = None for value in columns.values(): if isinstance(value, (torch.Tensor, VideoClipRef)): n = value.shape[0] break if isinstance(value, (list, tuple)): n = len(value) break if n is None: raise ValueError("Could not infer the number of frames from `columns`.") out = TensorDict({}, batch_size=[n]) for name, value in columns.items(): target = _map_lerobot_key(name, key_map) if isinstance(value, torch.Tensor): # TED convention: per-step signals under "next" (reward, done, # success, ...) carry a trailing singleton dim. Without it, # samplers that combine these flags with their own [batch, 1] # entries (e.g. SliceSampler's truncated flag) would broadcast. if ( isinstance(target, tuple) and target and target[0] == "next" and value.ndim == 1 ): value = value.unsqueeze(-1) out.set(target, value) elif isinstance(value, VideoClipRef): out.set(target, value) elif isinstance(value, (list, tuple)) and all( isinstance(v, str) for v in value ): out.set(target, NonTensorStack.from_list(list(value))) elif isinstance(value, (list, tuple)) and any( isinstance(v, str) for v in value ): raise ValueError( f"Column {name!r} mixes strings and non-strings; expected a " "homogeneous column." ) else: out.set(target, torch.as_tensor(value)) return out
def _video_ref_keys(data: TensorDictBase) -> list[NestedKey]: """Return the keys of every :class:`~torchrl.data.VideoClipRef` leaf in ``data``. Walks nested tensordicts but does not descend into the references themselves, so the keys can be handed to :class:`~torchrl.envs.transforms.DecodeVideoTransform`. """ keys: list[NestedKey] = [] def _walk(td: TensorDictBase, prefix: tuple) -> None: for key in td.keys(): value = td.get(key) nested = (*prefix, key) if isinstance(value, VideoClipRef): keys.append(nested[0] if len(nested) == 1 else nested) elif isinstance(value, TensorDictBase): _walk(value, nested) _walk(data, ()) return keys class _LeRobotSnapshot: """Direct reader for the LeRobot on-disk dataset format (v2.x and v3.x). Parses the hub snapshot files (``meta/info.json``, the data parquets and the task/episode metadata) without importing the ``lerobot`` package: only ``huggingface_hub`` and ``datasets`` are needed, and the reader is insulated from the ``lerobot`` package's torch version pins and API changes. Validated against ``lerobot/pusht`` (format ``v3.0``). """ def __init__(self, repo_id: str) -> None: from huggingface_hub import snapshot_download self.repo_id = repo_id self.root = Path(snapshot_download(repo_id, repo_type="dataset")) with open(self.root / "meta" / "info.json") as f: self.info = json.load(f) version = str(self.info.get("codebase_version", "v3.0")).lstrip("v") self.major_version = int(version.split(".")[0]) self.fps = float(self.info["fps"]) self.video_keys = [ key for key, feature in self.info.get("features", {}).items() if feature.get("dtype") == "video" ] self.hf_dataset = self._load_frames() self.episodes = self._load_episodes() self.tasks = self._load_tasks() def _load_frames(self): from datasets import load_dataset files = sorted(str(p) for p in (self.root / "data").rglob("*.parquet")) if not files: raise FileNotFoundError( f"No data parquet files found under {self.root / 'data'}." ) dataset = load_dataset("parquet", data_files=files, split="train") if "index" in dataset.column_names: index = list(dataset["index"]) if index != sorted(index): dataset = dataset.sort("index") return dataset def _load_episodes(self) -> list[dict] | None: # v3 stores per-episode metadata (lengths, video file/timestamp spans) # in meta/episodes/*.parquet; v2.x uses meta/episodes.jsonl. meta_dir = self.root / "meta" episodes_dir = meta_dir / "episodes" rows: list[dict] = [] if episodes_dir.exists(): import pyarrow.parquet as pq # ships with `datasets` for file in sorted(episodes_dir.rglob("*.parquet")): rows.extend(pq.read_table(file).to_pylist()) elif (meta_dir / "episodes.jsonl").exists(): with open(meta_dir / "episodes.jsonl") as f: rows = [json.loads(line) for line in f if line.strip()] else: return None rows.sort(key=lambda row: int(row["episode_index"])) return rows def _load_tasks(self) -> dict[int, str]: # v3: meta/tasks.parquet with the task string as the table index; # v2.x: meta/tasks.jsonl with {"task_index": ..., "task": ...} rows. meta_dir = self.root / "meta" if (meta_dir / "tasks.parquet").exists(): import pyarrow.parquet as pq out: dict[int, str] = {} for row in pq.read_table(meta_dir / "tasks.parquet").to_pylist(): index = row.get("task_index") text = row.get("task", row.get("__index_level_0__")) if index is not None and isinstance(text, str): out[int(index)] = text return out if (meta_dir / "tasks.jsonl").exists(): with open(meta_dir / "tasks.jsonl") as f: rows = [json.loads(line) for line in f if line.strip()] return {int(row["task_index"]): str(row["task"]) for row in rows} return {} def video_segments(self, key: str) -> tuple[list[str], list[int]]: """Ordered video files spanning ``key``, with per-file frame counts. Episodes are stored back-to-back inside each video file (v3) or one per file (v2.x), in episode order -- so the episode-major frame ``j`` of the dataset is frame ``j`` of the concatenated files. """ if self.episodes is None: raise RuntimeError( f"Missing episode metadata under {self.root / 'meta'} " "(expected 'episodes/*.parquet' or 'episodes.jsonl')." ) template = self.info["video_path"] paths: list[str] = [] counts: list[int] = [] drift_warned = False for row in self.episodes: length = int(row["length"]) if self.major_version >= 3: path = str( self.root / template.format( video_key=key, chunk_index=int(row[f"videos/{key}/chunk_index"]), file_index=int(row[f"videos/{key}/file_index"]), ) ) start = row.get(f"videos/{key}/from_timestamp") same_file = bool(paths) and paths[-1] == path expected_start = counts[-1] / self.fps if same_file else 0.0 if ( not drift_warned and start is not None and abs(float(start) - expected_start) > 0.5 / self.fps ): torchrl_logger.warning( f"LeRobotExperienceReplay: episode " f"{row['episode_index']} of video column {key!r} starts " f"at {float(start):.3f}s but {expected_start:.3f}s was " "expected from the cumulative episode lengths; frame " "alignment may be off." ) drift_warned = True if same_file: counts[-1] += length else: paths.append(path) counts.append(length) else: # v2.x: one video file per episode, chunked by episode index. episode = int(row["episode_index"]) chunk = episode // int(self.info.get("chunks_size", 1000)) paths.append( str( self.root / template.format( episode_chunk=chunk, video_key=key, episode_index=episode, ) ) ) counts.append(length) return paths, counts
[docs] class LeRobotExperienceReplay(BaseDatasetExperienceReplay): """Experience replay over a `LeRobot <https://github.com/huggingface/lerobot>`_ dataset. LeRobot is the de-facto open format for robot-learning datasets (Parquet for state/action + MP4 for video), hosting many community datasets and the data used to train SmolVLA / pi0 / ACT. This adapter maps a LeRobot dataset into the canonical VLA TensorDict schema and serves it as a TorchRL replay buffer with trajectory-aware slice sampling. There are three ways to build it: - ``LeRobotExperienceReplay(repo_id, download=True)`` downloads the hub snapshot and reads the on-disk LeRobot format (v2.x and v3.x) directly -- only the ``huggingface_hub`` and ``datasets`` packages are required (installed by the ``vla`` extra), not the ``lerobot`` package itself; - ``LeRobotExperienceReplay(repo_id, root=..., download=False)`` loads a previously-converted memory-mapped copy from disk; - :meth:`from_columns` builds directly from an in-memory LeRobot-style columnar dict (no download), which is also the path used in tests. Args: repo_id (str): the Hugging Face dataset repo id (e.g. ``"lerobot/aloha_sim_insertion_human"``). Keyword Args: root (str or Path, optional): local cache root. Defaults to the TorchRL LeRobot cache directory. download (bool): whether to download+convert the dataset if it is not already cached. Defaults to ``True``. batch_size (int, optional): the batch size for sampling. num_slices (int, optional): number of trajectory slices per batch (exclusive with ``slice_len``). slice_len (int, optional): length of each trajectory slice. sampler (Sampler, optional): a custom sampler. Defaults to a :class:`~torchrl.data.SliceSampler` over the (key-mapped) episode key -- ``episode`` unless ``key_map`` remaps ``episode_index`` -- when ``num_slices``/``slice_len`` is given. writer (Writer, optional): a custom writer. transform (Transform, optional): a transform applied on sampling. key_map (dict, optional): overrides the default LeRobot-to-canonical key mapping (see :func:`lerobot_columns_to_tensordict`). decode_video (bool): if ``True`` (default) and the dataset carries lazy :class:`~torchrl.data.VideoClipRef` video columns, a :class:`~torchrl.envs.transforms.DecodeVideoTransform` is appended so that ``sample()`` returns decoded frames (requires ``torchcodec``). Set to ``False`` to keep the raw references and decode them yourself. rehydrate (bool): if ``True``, sampled batches are made fully TED-compliant by re-hydrating ``("next", "observation", ...)`` entries from the following row of each sampled slice (:class:`~torchrl.envs.transforms.NextStateReconstructor` instances are appended after the video decode and before ``transform``). Boundaries are detected from the episode id (required) plus the per-episode frame counter (``frame``) when present, so positions whose in-batch successor is not the true next step -- slice ends and splices between back-to-back slices -- are filled with ``NaN`` for floating-point leaves and ``0`` for integer leaves (e.g. decoded ``uint8`` frames); mask the filled positions with the slice sampler's ``("next", "truncated")`` flag in the returned batch when consuming ``next``. Video references left undecoded (``decode_video=False`` or ``torchcodec`` not installed) are skipped with a warning. Defaults to ``False``. strict_length (bool): passed to the slice sampler. Defaults to ``True``. collate_fn (Callable, optional): merges samples; defaults to the identity collation used by offline datasets. pin_memory (bool): whether to pin memory on sampling. Defaults to ``False``. prefetch (int, optional): number of batches to prefetch with a background thread. .. note:: Sampled batches are *flat* ``[num_slices * slice_len]`` like any :class:`~torchrl.data.SliceSampler` output; reshape to ``[num_slices, slice_len, ...]`` before applying :class:`~torchrl.envs.transforms.ActionChunkTransform`. .. note:: MP4 video columns are loaded lazily as :class:`~torchrl.data.VideoClipRef` leaves -- no frames are materialized in storage. With ``decode_video=True`` (the default) they are decoded on ``sample()`` via :class:`~torchrl.envs.transforms.DecodeVideoTransform` (requires ``torchcodec``). .. warning:: The ``download=True`` path reads the documented LeRobot on-disk format (validated against ``lerobot/pusht``, format ``v3.0``) but is **not exercised in CI** (``huggingface_hub``/``datasets`` are optional dependencies and CI does not download datasets). For fully reproducible behavior, build offline via :meth:`from_columns`. Examples: >>> import torch >>> from torchrl.data.datasets import LeRobotExperienceReplay >>> columns = { ... "observation.state": torch.zeros(8, 7), ... "action": torch.zeros(8, 7), ... "episode_index": torch.arange(2).repeat_interleave(4), ... "task": ["pick"] * 8, ... } >>> rb = LeRobotExperienceReplay.from_columns( ... columns, slice_len=4, batch_size=8 ... ) >>> sample = rb.sample() >>> sample["action"].shape torch.Size([8, 7]) >>> # rehydrate=True re-hydrates ("next", "observation", ...) from the >>> # following row of each slice (slice ends are filled and flagged >>> # by ("next", "truncated")) >>> rb = LeRobotExperienceReplay.from_columns( ... columns, slice_len=4, batch_size=8, rehydrate=True ... ) >>> sample = rb.sample() >>> sample["next", "observation", "state"].shape torch.Size([8, 7]) .. seealso:: :class:`~torchrl.data.datasets.OpenXExperienceReplay` for the Open X-Embodiment equivalent. """ def __init__( self, repo_id: str, *, root: str | Path | None = None, download: bool = True, batch_size: int | None = None, num_slices: int | None = None, slice_len: int | None = None, sampler: Sampler | None = None, writer: Writer | None = None, collate_fn: Callable | None = None, transform: Transform | None = None, key_map: dict[str, NestedKey] | None = None, decode_video: bool = True, rehydrate: bool = False, strict_length: bool = True, pin_memory: bool = False, prefetch: int | None = None, _data: TensorDictBase | None = None, ) -> None: if (num_slices is not None) and (slice_len is not None): raise ValueError("num_slices or slice_len can be passed, but not both.") self.repo_id = repo_id self.key_map = key_map self.num_slices = num_slices self.slice_len = slice_len self.strict_length = strict_length if root is None: root = _get_root_dir("lerobot") self.root = Path(root) # Only touch disk when a download or memmap-load will actually happen; # the in-memory ``from_columns`` path (``_data`` set) stays disk-free. if _data is None: os.makedirs(self.root, exist_ok=True) if _data is not None: data = _data elif download and not self._is_downloaded(): data = self._download_and_preproc() elif self._is_downloaded(): data = self._attach_video_refs(TensorDict.load_memmap(self.data_path)) else: raise RuntimeError( f"Dataset {repo_id!r} not found at {self.data_path}. Pass " "download=True to fetch it (requires the `huggingface_hub` " "and `datasets` packages)." ) storage = TensorStorage(data) key_map_merged = {**_DEFAULT_KEY_MAP, **(key_map or {})} episode_key = _map_lerobot_key("episode_index", key_map_merged) # Decode lazy VideoClipRef leaves on the sample path: the storage keeps # only the lightweight references (no materialized frames), and frames are # decoded when the buffer is sampled. Disable with ``decode_video=False`` # to keep the raw references. transforms = [] video_keys = _video_ref_keys(data) videos_decoded = False if decode_video and video_keys: if _has_torchcodec: from torchrl.envs.transforms import DecodeVideoTransform transforms.append(DecodeVideoTransform(in_keys=video_keys)) videos_decoded = True else: torchrl_logger.warning( "LeRobotExperienceReplay: video-frame references are present " "but torchcodec is not installed, so frames will not be decoded " "on sampling. Install torchcodec (`pip install " "'torchcodec>=0.10.0'`) or pass decode_video=False to keep the " "raw references and silence this warning." ) internal_slice_sampler = sampler is None and ( num_slices is not None or slice_len is not None ) if rehydrate: transforms.extend( self._make_rehydrate_transforms( data, video_keys=video_keys, videos_decoded=videos_decoded, episode_key=episode_key, key_map=key_map_merged, ) ) if transform is not None: transforms.append(transform) if len(transforms) > 1: from torchrl.envs.transforms import Compose transform = Compose(*transforms) elif transforms: transform = transforms[0] if internal_slice_sampler: sampler = SliceSampler( num_slices=num_slices, slice_len=slice_len, traj_key=episode_key, strict_length=strict_length, ) if writer is None: writer = ImmutableDatasetWriter() if collate_fn is None: collate_fn = _collate_id super().__init__( storage=storage, sampler=sampler, writer=writer, collate_fn=collate_fn, batch_size=batch_size, transform=transform, pin_memory=pin_memory, prefetch=prefetch, ) @staticmethod def _make_rehydrate_transforms( data: TensorDictBase, *, video_keys: list[NestedKey], videos_decoded: bool, episode_key: NestedKey, key_map: dict[str, NestedKey], ) -> list: """Build the :class:`~torchrl.envs.transforms.NextStateReconstructor` instances for ``rehydrate=True``. Observation leaves are grouped by fill value: ``NaN`` for floating-point leaves, ``0`` for integer ones (e.g. decoded ``uint8`` frames, where ``NaN`` cannot be represented). Video references that stay undecoded cannot be shifted and are skipped with a warning. Boundary detection only uses markers that live in **storage** (the buffer transforms run before the sampler's info entries, such as ``("next", "truncated")``, are merged into the batch): the episode id, the per-episode frame counter (``frame_index`` mapped key, used as a ``step_count_key`` -- it deterministically rejects back-to-back slices of the same episode) and the dataset's ``("next", "done")`` flag when present. """ from torchrl.envs.transforms import NextStateReconstructor observation = data.get("observation", None) if observation is None: torchrl_logger.warning( "LeRobotExperienceReplay: rehydrate=True but the dataset has " "no 'observation' entry; nothing to re-hydrate." ) return [] leaves = set(data.keys(include_nested=True, leaves_only=True)) if episode_key not in leaves: raise ValueError( "rehydrate=True requires the episode entry " f"({episode_key!r}) to delimit trajectories, but it is not " "present in the dataset." ) frame_key = _map_lerobot_key("frame_index", key_map) if frame_key not in leaves: torchrl_logger.warning( "LeRobotExperienceReplay: rehydrate=True without a per-episode " f"frame counter ({frame_key!r}): back-to-back slices of the " "same episode cannot be told apart from a contiguous slice, " "so a spliced position may receive the first row of the next " "slice instead of the boundary fill." ) frame_key = None done_key = key_map.get("next.done", ("next", "done")) if done_key not in leaves: done_key = None ref_keys = {key if isinstance(key, tuple) else (key,) for key in video_keys} ref_keys = {key for key in ref_keys if key[0] == "observation"} float_keys: list[NestedKey] = [] int_keys: list[NestedKey] = [] for key in observation.keys(include_nested=True, leaves_only=True): key_t = ("observation", *(key if isinstance(key, tuple) else (key,))) if any(key_t[: len(ref)] == ref for ref in ref_keys): # a field inside a video reference; the reference itself is # handled below continue if observation.get(key).dtype.is_floating_point: float_keys.append(key_t) else: int_keys.append(key_t) for ref in sorted(ref_keys): if videos_decoded: # decoded on the sample path before this transform runs; the # decode dtype follows the reference's out_dtype (uint8 when # unset) out_dtype = getattr(data.get(ref), "out_dtype", None) if isinstance(out_dtype, torch.dtype) and out_dtype.is_floating_point: float_keys.append(ref) else: int_keys.append(ref) else: torchrl_logger.warning( "LeRobotExperienceReplay: rehydrate=True cannot shift the " f"video reference at {ref!r}, which stays undecoded on " "the sample path (decode_video=False or torchcodec not " "installed); skipping it." ) out = [] if float_keys: out.append( NextStateReconstructor( float_keys, traj_key=episode_key, done_key=done_key, step_count_key=frame_key, ) ) if int_keys: out.append( NextStateReconstructor( int_keys, traj_key=episode_key, done_key=done_key, step_count_key=frame_key, fill_value=0, ) ) return out
[docs] @classmethod def from_columns( cls, columns: dict[str, Any], *, repo_id: str = "local", key_map: dict[str, NestedKey] | None = None, **kwargs, ) -> LeRobotExperienceReplay: """Build directly from an in-memory LeRobot-style columnar dict. Converts ``columns`` with :func:`lerobot_columns_to_tensordict` and wraps the result in an in-memory storage -- no download or ``lerobot`` install required. """ data = lerobot_columns_to_tensordict(columns, key_map=key_map) return cls(repo_id, download=False, _data=data, key_map=key_map, **kwargs)
@property def data_path_root(self) -> Path: return self.root / self.repo_id.replace("/", "_") @property def data_path(self) -> Path: return self.data_path_root def _is_downloaded(self) -> bool: return self.data_path.exists() def _download_and_preproc(self) -> TensorDictBase: if not (_has_hf_hub and _has_datasets): raise ImportError( "Downloading LeRobot datasets requires the `huggingface_hub` and " "`datasets` packages (installed by `pip install 'torchrl[vla]'` " "or `pip install lerobot`). Alternatively build the dataset " "offline (e.g. via LeRobotExperienceReplay.from_columns) and load " "it with download=False." ) # The on-disk LeRobot dataset format (v2.x / v3.x) is read directly # from the hub snapshot: the heavy `lerobot` package is not needed (and # its torch pins would often conflict with the installed torch). dataset = _LeRobotSnapshot(self.repo_id) columns = self._extract_columns(dataset) # Lazy video references do not survive a memmap round-trip (the nested # tensorclass identity is lost on load), so they are kept out of the # memmapped storage and rebuilt from a sidecar manifest on every load. manifest: dict[str, dict] = {} for key in dataset.video_keys: if columns.pop(key, None) is None: continue paths, counts = dataset.video_segments(key) manifest[key] = {"paths": paths, "num_frames_per_file": counts} data = lerobot_columns_to_tensordict(columns, key_map=self.key_map) os.makedirs(self.data_path, exist_ok=True) data.memmap_(self.data_path) if manifest: with open(self.data_path / "video_refs.json", "w") as f: json.dump(manifest, f) return self._attach_video_refs(data) def _attach_video_refs(self, data: TensorDictBase) -> TensorDictBase: """Rebuild the lazy video references from the sidecar manifest. ``video_refs.json`` points at the MP4 files of the hub snapshot cache; the references are rebuilt every time the dataset is loaded (they are excluded from the memmapped storage, where the tensorclass identity would not survive the round-trip). """ manifest_path = self.data_path / "video_refs.json" if not manifest_path.exists(): return data with open(manifest_path) as f: manifest = json.load(f) key_map = {**_DEFAULT_KEY_MAP, **(self.key_map or {})} data = data.unlock_() for name, spec in manifest.items(): ref = VideoClipRef.from_files( spec["paths"], num_frames_per_file=spec["num_frames_per_file"] ) data.set(_map_lerobot_key(name, key_map), ref) return data @staticmethod def _extract_columns(dataset: _LeRobotSnapshot) -> dict[str, Any]: """Read a :class:`_LeRobotSnapshot`'s frames into a columnar dict. MP4 video columns are mapped to lazy :class:`~torchrl.data.VideoClipRef` leaves spanning the dataset's video files in episode order; if the video layout cannot be resolved the column is skipped with a warning, as are any other non-tensor columns. The per-frame instruction is taken from a string ``task`` column, or resolved from an integer ``task_index`` joined against the dataset's task table (the common LeRobot layout). """ hf_dataset = dataset.hf_dataset.with_format("torch") columns: dict[str, Any] = {} # Video features are not part of the parquet files (the frames live in # the MP4s only), so they are resolved separately from the metadata. for key in dataset.video_keys: ref = LeRobotExperienceReplay._build_video_ref(dataset, key) if ref is not None: columns[key] = ref for name in hf_dataset.column_names: if name in columns: continue column = hf_dataset[name] if isinstance(column, torch.Tensor): columns[name] = column continue # recent `datasets` versions return a lazy Column: materialize it values = list(column) if values and isinstance(values[0], str): columns[name] = values continue try: if values and isinstance(values[0], torch.Tensor): columns[name] = torch.stack(values) else: columns[name] = torch.as_tensor(values) except (TypeError, ValueError, RuntimeError): torchrl_logger.warning( f"LeRobotExperienceReplay: skipping column {name!r} which " "could not be converted to a tensor or a VideoClipRef." ) if "task" not in columns and "task_index" in columns: tasks = dataset.tasks if tasks: columns["task"] = [ str(tasks[int(i)]) for i in columns["task_index"].tolist() ] return columns @staticmethod def _build_video_ref(dataset: _LeRobotSnapshot, key: str) -> VideoClipRef | None: """Best-effort lazy :class:`~torchrl.data.VideoClipRef` for a video column. Spans the video files of ``key`` in episode order so that frame ``j`` of the reference lines up with row ``j`` of the (episode-major) dataset. Returns ``None`` on any failure so the caller falls back to skipping the column. """ try: paths, counts = dataset.video_segments(key) return VideoClipRef.from_files(paths, num_frames_per_file=counts) except Exception as err: torchrl_logger.warning( f"LeRobotExperienceReplay: could not build a VideoClipRef for video " f"column {key!r} ({type(err).__name__}: {err}); skipping it." ) return None