mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-24 18:26:11 +00:00
refactor(dataset): split LeRobotDataset into DatasetReader & DatasetWriter (+ API cleanup) (#3180)
* refactor(dataset): split reader and writer * chore(dataset): remove proxys * refactor(dataset): better reader & writer encapsulation * refactor(datasets): clean API + reduce leaky implementations * refactor(dataset): API cleaning for writer, reader and meta * refactor(dataset): expose writer & reader + other minor improvements * refactor(dataset): improve teardown routine * refactor(dataset): add hf_dataset property at the facade level * chore(dataset): add init for datasset module * docs(dataset): add docstrings for public API of the dataset classes * tests(dataset): add tests for new classes * fix(dataset): remove circular dependecy
This commit is contained in:
@@ -424,7 +424,7 @@ robot = SO100Follower(robot_config)
|
|||||||
robot.connect()
|
robot.connect()
|
||||||
|
|
||||||
dataset = LeRobotDataset("<hf_username>/<dataset_repo_id>", episodes=[episode_idx])
|
dataset = LeRobotDataset("<hf_username>/<dataset_repo_id>", episodes=[episode_idx])
|
||||||
actions = dataset.hf_dataset.select_columns("action")
|
actions = dataset.select_columns("action")
|
||||||
|
|
||||||
log_say(f"Replaying episode {episode_idx}")
|
log_say(f"Replaying episode {episode_idx}")
|
||||||
for idx in range(dataset.num_frames):
|
for idx in range(dataset.num_frames):
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def replay(cfg: ReplayConfig):
|
|||||||
|
|
||||||
robot = make_robot_from_config(cfg.robot)
|
robot = make_robot_from_config(cfg.robot)
|
||||||
dataset = LeRobotDataset(cfg.dataset.repo_id, root=cfg.dataset.root, episodes=[cfg.dataset.episode])
|
dataset = LeRobotDataset(cfg.dataset.repo_id, root=cfg.dataset.root, episodes=[cfg.dataset.episode])
|
||||||
actions = dataset.hf_dataset.select_columns(ACTION)
|
actions = dataset.select_columns(ACTION)
|
||||||
robot.connect()
|
robot.connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -88,9 +88,8 @@ def main():
|
|||||||
# The previous metadata class is contained in the 'meta' attribute of the dataset:
|
# The previous metadata class is contained in the 'meta' attribute of the dataset:
|
||||||
print(dataset.meta)
|
print(dataset.meta)
|
||||||
|
|
||||||
# LeRobotDataset actually wraps an underlying Hugging Face dataset
|
# You can inspect the dataset using its repr:
|
||||||
# (see https://huggingface.co/docs/datasets for more information).
|
print(dataset)
|
||||||
print(dataset.hf_dataset)
|
|
||||||
|
|
||||||
# LeRobot datasets also subclasses PyTorch datasets so you can do everything you know and love from working
|
# LeRobot datasets also subclasses PyTorch datasets so you can do everything you know and love from working
|
||||||
# with the latter, like iterating through the dataset.
|
# with the latter, like iterating through the dataset.
|
||||||
|
|||||||
@@ -35,9 +35,7 @@ def main():
|
|||||||
|
|
||||||
# Fetch the dataset to replay
|
# Fetch the dataset to replay
|
||||||
dataset = LeRobotDataset("<hf_username>/<dataset_repo_id>", episodes=[EPISODE_IDX])
|
dataset = LeRobotDataset("<hf_username>/<dataset_repo_id>", episodes=[EPISODE_IDX])
|
||||||
# Filter dataset to only include frames from the specified episode since episodes are chunked in dataset V3.0
|
actions = dataset.select_columns(ACTION)
|
||||||
episode_frames = dataset.hf_dataset.filter(lambda x: x["episode_index"] == EPISODE_IDX)
|
|
||||||
actions = episode_frames.select_columns(ACTION)
|
|
||||||
|
|
||||||
# Connect to the robot
|
# Connect to the robot
|
||||||
robot.connect()
|
robot.connect()
|
||||||
@@ -48,7 +46,7 @@ def main():
|
|||||||
|
|
||||||
print("Starting replay loop...")
|
print("Starting replay loop...")
|
||||||
log_say(f"Replaying episode {EPISODE_IDX}")
|
log_say(f"Replaying episode {EPISODE_IDX}")
|
||||||
for idx in range(len(episode_frames)):
|
for idx in range(dataset.num_frames):
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
|
|
||||||
# Get recorded action from dataset
|
# Get recorded action from dataset
|
||||||
|
|||||||
@@ -67,9 +67,7 @@ def main():
|
|||||||
|
|
||||||
# Fetch the dataset to replay
|
# Fetch the dataset to replay
|
||||||
dataset = LeRobotDataset(HF_REPO_ID, episodes=[EPISODE_IDX])
|
dataset = LeRobotDataset(HF_REPO_ID, episodes=[EPISODE_IDX])
|
||||||
# Filter dataset to only include frames from the specified episode since episodes are chunked in dataset V3.0
|
actions = dataset.select_columns(ACTION)
|
||||||
episode_frames = dataset.hf_dataset.filter(lambda x: x["episode_index"] == EPISODE_IDX)
|
|
||||||
actions = episode_frames.select_columns(ACTION)
|
|
||||||
|
|
||||||
# Connect to the robot
|
# Connect to the robot
|
||||||
robot.connect()
|
robot.connect()
|
||||||
@@ -80,7 +78,7 @@ def main():
|
|||||||
|
|
||||||
print("Starting replay loop...")
|
print("Starting replay loop...")
|
||||||
log_say(f"Replaying episode {EPISODE_IDX}")
|
log_say(f"Replaying episode {EPISODE_IDX}")
|
||||||
for idx in range(len(episode_frames)):
|
for idx in range(dataset.num_frames):
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
|
|
||||||
# Get recorded action from dataset
|
# Get recorded action from dataset
|
||||||
|
|||||||
@@ -68,9 +68,7 @@ def main():
|
|||||||
|
|
||||||
# Fetch the dataset to replay
|
# Fetch the dataset to replay
|
||||||
dataset = LeRobotDataset(HF_REPO_ID, episodes=[EPISODE_IDX])
|
dataset = LeRobotDataset(HF_REPO_ID, episodes=[EPISODE_IDX])
|
||||||
# Filter dataset to only include frames from the specified episode since episodes are chunked in dataset V3.0
|
actions = dataset.select_columns(ACTION)
|
||||||
episode_frames = dataset.hf_dataset.filter(lambda x: x["episode_index"] == EPISODE_IDX)
|
|
||||||
actions = episode_frames.select_columns(ACTION)
|
|
||||||
|
|
||||||
# Connect to the robot
|
# Connect to the robot
|
||||||
robot.connect()
|
robot.connect()
|
||||||
@@ -81,7 +79,7 @@ def main():
|
|||||||
|
|
||||||
print("Starting replay loop...")
|
print("Starting replay loop...")
|
||||||
log_say(f"Replaying episode {EPISODE_IDX}")
|
log_say(f"Replaying episode {EPISODE_IDX}")
|
||||||
for idx in range(len(episode_frames)):
|
for idx in range(dataset.num_frames):
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
|
|
||||||
# Get recorded action from dataset
|
# Get recorded action from dataset
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
# Copyright 2026 The HuggingFace Inc. team.
|
||||||
|
# All rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
|
||||||
|
from lerobot.datasets.lerobot_dataset import LeRobotDataset
|
||||||
|
from lerobot.datasets.multi_dataset import MultiLeRobotDataset
|
||||||
|
from lerobot.datasets.sampler import EpisodeAwareSampler
|
||||||
|
from lerobot.datasets.streaming_dataset import StreamingLeRobotDataset
|
||||||
|
from lerobot.datasets.transforms import ImageTransforms, ImageTransformsConfig
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EpisodeAwareSampler",
|
||||||
|
"ImageTransforms",
|
||||||
|
"ImageTransformsConfig",
|
||||||
|
"LeRobotDataset",
|
||||||
|
"LeRobotDatasetMetadata",
|
||||||
|
"MultiLeRobotDataset",
|
||||||
|
"StreamingLeRobotDataset",
|
||||||
|
]
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
import contextlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -53,6 +54,13 @@ CODEBASE_VERSION = "v3.0"
|
|||||||
|
|
||||||
|
|
||||||
class LeRobotDatasetMetadata:
|
class LeRobotDatasetMetadata:
|
||||||
|
"""Metadata container for a LeRobot dataset.
|
||||||
|
|
||||||
|
Manages the ``info.json``, ``stats.json``, ``tasks.parquet``, and
|
||||||
|
``episodes/`` parquet files that describe a dataset's structure, content,
|
||||||
|
and statistics.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
repo_id: str,
|
repo_id: str,
|
||||||
@@ -61,33 +69,51 @@ class LeRobotDatasetMetadata:
|
|||||||
force_cache_sync: bool = False,
|
force_cache_sync: bool = False,
|
||||||
metadata_buffer_size: int = 10,
|
metadata_buffer_size: int = 10,
|
||||||
):
|
):
|
||||||
|
"""Load or download metadata for an existing LeRobot dataset.
|
||||||
|
|
||||||
|
Attempts to load metadata from local disk. If files are missing or
|
||||||
|
``force_cache_sync`` is ``True``, downloads the ``meta/`` directory from
|
||||||
|
the Hub.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_id: Repository identifier (e.g. ``'lerobot/aloha_sim'``).
|
||||||
|
root: Local directory for the dataset. Defaults to
|
||||||
|
``$HF_LEROBOT_HOME/{repo_id}``.
|
||||||
|
revision: Git revision (branch, tag, or commit hash). Defaults to
|
||||||
|
the current codebase version.
|
||||||
|
force_cache_sync: If ``True``, re-download metadata from the Hub
|
||||||
|
even when local files exist.
|
||||||
|
metadata_buffer_size: Number of episode metadata records to buffer
|
||||||
|
in memory before flushing to parquet.
|
||||||
|
"""
|
||||||
self.repo_id = repo_id
|
self.repo_id = repo_id
|
||||||
self.revision = revision if revision else CODEBASE_VERSION
|
self.revision = revision if revision else CODEBASE_VERSION
|
||||||
self.root = Path(root) if root is not None else HF_LEROBOT_HOME / repo_id
|
self.root = Path(root) if root is not None else HF_LEROBOT_HOME / repo_id
|
||||||
self.writer = None
|
self._pq_writer = None
|
||||||
self.latest_episode = None
|
self.latest_episode = None
|
||||||
self.metadata_buffer: list[dict] = []
|
self._metadata_buffer: list[dict] = []
|
||||||
self.metadata_buffer_size = metadata_buffer_size
|
self._metadata_buffer_size = metadata_buffer_size
|
||||||
|
self._finalized = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if force_cache_sync:
|
if force_cache_sync:
|
||||||
raise FileNotFoundError
|
raise FileNotFoundError
|
||||||
self.load_metadata()
|
self._load_metadata()
|
||||||
except (FileNotFoundError, NotADirectoryError):
|
except (FileNotFoundError, NotADirectoryError):
|
||||||
if is_valid_version(self.revision):
|
if is_valid_version(self.revision):
|
||||||
self.revision = get_safe_version(self.repo_id, self.revision)
|
self.revision = get_safe_version(self.repo_id, self.revision)
|
||||||
|
|
||||||
(self.root / "meta").mkdir(exist_ok=True, parents=True)
|
(self.root / "meta").mkdir(exist_ok=True, parents=True)
|
||||||
self.pull_from_repo(allow_patterns="meta/")
|
self._pull_from_repo(allow_patterns="meta/")
|
||||||
self.load_metadata()
|
self._load_metadata()
|
||||||
|
|
||||||
def _flush_metadata_buffer(self) -> None:
|
def _flush_metadata_buffer(self) -> None:
|
||||||
"""Write all buffered episode metadata to parquet file."""
|
"""Write all buffered episode metadata to parquet file."""
|
||||||
if not hasattr(self, "metadata_buffer") or len(self.metadata_buffer) == 0:
|
if not hasattr(self, "_metadata_buffer") or len(self._metadata_buffer) == 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
combined_dict = {}
|
combined_dict = {}
|
||||||
for episode_dict in self.metadata_buffer:
|
for episode_dict in self._metadata_buffer:
|
||||||
for key, value in episode_dict.items():
|
for key, value in episode_dict.items():
|
||||||
if key not in combined_dict:
|
if key not in combined_dict:
|
||||||
combined_dict[key] = []
|
combined_dict[key] = []
|
||||||
@@ -96,40 +122,50 @@ class LeRobotDatasetMetadata:
|
|||||||
val = value[0] if isinstance(value, list) else value
|
val = value[0] if isinstance(value, list) else value
|
||||||
combined_dict[key].append(val.tolist() if isinstance(val, np.ndarray) else val)
|
combined_dict[key].append(val.tolist() if isinstance(val, np.ndarray) else val)
|
||||||
|
|
||||||
first_ep = self.metadata_buffer[0]
|
first_ep = self._metadata_buffer[0]
|
||||||
chunk_idx = first_ep["meta/episodes/chunk_index"][0]
|
chunk_idx = first_ep["meta/episodes/chunk_index"][0]
|
||||||
file_idx = first_ep["meta/episodes/file_index"][0]
|
file_idx = first_ep["meta/episodes/file_index"][0]
|
||||||
|
|
||||||
table = pa.Table.from_pydict(combined_dict)
|
table = pa.Table.from_pydict(combined_dict)
|
||||||
|
|
||||||
if not self.writer:
|
if not self._pq_writer:
|
||||||
path = Path(self.root / DEFAULT_EPISODES_PATH.format(chunk_index=chunk_idx, file_index=file_idx))
|
path = Path(self.root / DEFAULT_EPISODES_PATH.format(chunk_index=chunk_idx, file_index=file_idx))
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self.writer = pq.ParquetWriter(
|
self._pq_writer = pq.ParquetWriter(
|
||||||
path, schema=table.schema, compression="snappy", use_dictionary=True
|
path, schema=table.schema, compression="snappy", use_dictionary=True
|
||||||
)
|
)
|
||||||
|
|
||||||
self.writer.write_table(table)
|
self._pq_writer.write_table(table)
|
||||||
|
|
||||||
self.latest_episode = self.metadata_buffer[-1]
|
self.latest_episode = self._metadata_buffer[-1]
|
||||||
self.metadata_buffer.clear()
|
self._metadata_buffer.clear()
|
||||||
|
|
||||||
def _close_writer(self) -> None:
|
def _close_writer(self) -> None:
|
||||||
"""Close and cleanup the parquet writer if it exists."""
|
"""Close and cleanup the parquet writer if it exists."""
|
||||||
self._flush_metadata_buffer()
|
self._flush_metadata_buffer()
|
||||||
|
|
||||||
writer = getattr(self, "writer", None)
|
writer = getattr(self, "_pq_writer", None)
|
||||||
if writer is not None:
|
if writer is not None:
|
||||||
writer.close()
|
writer.close()
|
||||||
self.writer = None
|
self._pq_writer = None
|
||||||
|
|
||||||
|
def finalize(self) -> None:
|
||||||
|
"""Flush metadata buffer and close the parquet writer.
|
||||||
|
|
||||||
|
Idempotent — safe to call multiple times.
|
||||||
|
"""
|
||||||
|
if getattr(self, "_finalized", False):
|
||||||
|
return
|
||||||
|
self._close_writer()
|
||||||
|
self._finalized = True
|
||||||
|
|
||||||
def __del__(self):
|
def __del__(self):
|
||||||
"""
|
"""Safety net: flush and close parquet writer on garbage collection."""
|
||||||
Trust the user to call .finalize() but as an added safety check call the parquet writer to stop when calling the destructor
|
# During interpreter shutdown, referenced objects may already be collected.
|
||||||
"""
|
with contextlib.suppress(Exception):
|
||||||
self._close_writer()
|
self.finalize()
|
||||||
|
|
||||||
def load_metadata(self):
|
def _load_metadata(self):
|
||||||
self.info = load_info(self.root)
|
self.info = load_info(self.root)
|
||||||
check_version_compatibility(self.repo_id, self._version, CODEBASE_VERSION)
|
check_version_compatibility(self.repo_id, self._version, CODEBASE_VERSION)
|
||||||
self.tasks = load_tasks(self.root)
|
self.tasks = load_tasks(self.root)
|
||||||
@@ -137,7 +173,7 @@ class LeRobotDatasetMetadata:
|
|||||||
self.episodes = load_episodes(self.root)
|
self.episodes = load_episodes(self.root)
|
||||||
self.stats = load_stats(self.root)
|
self.stats = load_stats(self.root)
|
||||||
|
|
||||||
def pull_from_repo(
|
def _pull_from_repo(
|
||||||
self,
|
self,
|
||||||
allow_patterns: list[str] | str | None = None,
|
allow_patterns: list[str] | str | None = None,
|
||||||
ignore_patterns: list[str] | str | None = None,
|
ignore_patterns: list[str] | str | None = None,
|
||||||
@@ -153,6 +189,7 @@ class LeRobotDatasetMetadata:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def url_root(self) -> str:
|
def url_root(self) -> str:
|
||||||
|
"""Hugging Face Hub URL root for this dataset."""
|
||||||
return f"hf://datasets/{self.repo_id}"
|
return f"hf://datasets/{self.repo_id}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -161,6 +198,17 @@ class LeRobotDatasetMetadata:
|
|||||||
return packaging.version.parse(self.info["codebase_version"])
|
return packaging.version.parse(self.info["codebase_version"])
|
||||||
|
|
||||||
def get_data_file_path(self, ep_index: int) -> Path:
|
def get_data_file_path(self, ep_index: int) -> Path:
|
||||||
|
"""Return the relative parquet file path for the given episode index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ep_index: Zero-based episode index.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the parquet file containing this episode's data.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
IndexError: If ``ep_index`` is out of range.
|
||||||
|
"""
|
||||||
if self.episodes is None:
|
if self.episodes is None:
|
||||||
self.episodes = load_episodes(self.root)
|
self.episodes = load_episodes(self.root)
|
||||||
if ep_index >= len(self.episodes):
|
if ep_index >= len(self.episodes):
|
||||||
@@ -174,6 +222,19 @@ class LeRobotDatasetMetadata:
|
|||||||
return Path(fpath)
|
return Path(fpath)
|
||||||
|
|
||||||
def get_video_file_path(self, ep_index: int, vid_key: str) -> Path:
|
def get_video_file_path(self, ep_index: int, vid_key: str) -> Path:
|
||||||
|
"""Return the relative video file path for the given episode and video key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ep_index: Zero-based episode index.
|
||||||
|
vid_key: Feature key identifying the video stream
|
||||||
|
(e.g. ``'observation.images.laptop'``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the video file containing this episode's frames.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
IndexError: If ``ep_index`` is out of range.
|
||||||
|
"""
|
||||||
if self.episodes is None:
|
if self.episodes is None:
|
||||||
self.episodes = load_episodes(self.root)
|
self.episodes = load_episodes(self.root)
|
||||||
if ep_index >= len(self.episodes):
|
if ep_index >= len(self.episodes):
|
||||||
@@ -277,6 +338,17 @@ class LeRobotDatasetMetadata:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def save_episode_tasks(self, tasks: list[str]):
|
def save_episode_tasks(self, tasks: list[str]):
|
||||||
|
"""Register tasks for the current episode and persist to disk.
|
||||||
|
|
||||||
|
New tasks that do not already exist in the dataset are assigned
|
||||||
|
sequential task indices and appended to the tasks parquet file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tasks: List of unique task descriptions in natural language.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If ``tasks`` contains duplicates.
|
||||||
|
"""
|
||||||
if len(set(tasks)) != len(tasks):
|
if len(set(tasks)) != len(tasks):
|
||||||
raise ValueError(f"Tasks are not unique: {tasks}")
|
raise ValueError(f"Tasks are not unique: {tasks}")
|
||||||
|
|
||||||
@@ -336,8 +408,8 @@ class LeRobotDatasetMetadata:
|
|||||||
|
|
||||||
latest_path = (
|
latest_path = (
|
||||||
self.root / DEFAULT_EPISODES_PATH.format(chunk_index=chunk_idx, file_index=file_idx)
|
self.root / DEFAULT_EPISODES_PATH.format(chunk_index=chunk_idx, file_index=file_idx)
|
||||||
if self.writer is None
|
if self._pq_writer is None
|
||||||
else self.writer.where
|
else self._pq_writer.where
|
||||||
)
|
)
|
||||||
|
|
||||||
if Path(latest_path).exists():
|
if Path(latest_path).exists():
|
||||||
@@ -359,10 +431,10 @@ class LeRobotDatasetMetadata:
|
|||||||
episode_dict["dataset_to_index"] = [self.latest_episode["dataset_to_index"][0] + num_frames]
|
episode_dict["dataset_to_index"] = [self.latest_episode["dataset_to_index"][0] + num_frames]
|
||||||
|
|
||||||
# Add to buffer
|
# Add to buffer
|
||||||
self.metadata_buffer.append(episode_dict)
|
self._metadata_buffer.append(episode_dict)
|
||||||
self.latest_episode = episode_dict
|
self.latest_episode = episode_dict
|
||||||
|
|
||||||
if len(self.metadata_buffer) >= self.metadata_buffer_size:
|
if len(self._metadata_buffer) >= self._metadata_buffer_size:
|
||||||
self._flush_metadata_buffer()
|
self._flush_metadata_buffer()
|
||||||
|
|
||||||
def save_episode(
|
def save_episode(
|
||||||
@@ -373,6 +445,20 @@ class LeRobotDatasetMetadata:
|
|||||||
episode_stats: dict[str, dict],
|
episode_stats: dict[str, dict],
|
||||||
episode_metadata: dict,
|
episode_metadata: dict,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Persist episode metadata, update dataset info, and aggregate stats.
|
||||||
|
|
||||||
|
Writes the episode's metadata to the buffered parquet writer, increments
|
||||||
|
the total episode/frame counters in ``info.json``, and merges the
|
||||||
|
episode's statistics into the running dataset statistics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
episode_index: Zero-based index of the episode being saved.
|
||||||
|
episode_length: Number of frames in this episode.
|
||||||
|
episode_tasks: List of task descriptions for this episode.
|
||||||
|
episode_stats: Per-feature statistics for this episode.
|
||||||
|
episode_metadata: Additional metadata (chunk/file indices, frame
|
||||||
|
ranges, video timestamps, etc.).
|
||||||
|
"""
|
||||||
episode_dict = {
|
episode_dict = {
|
||||||
"episode_index": episode_index,
|
"episode_index": episode_index,
|
||||||
"tasks": episode_tasks,
|
"tasks": episode_tasks,
|
||||||
@@ -479,7 +565,32 @@ class LeRobotDatasetMetadata:
|
|||||||
data_files_size_in_mb: int | None = None,
|
data_files_size_in_mb: int | None = None,
|
||||||
video_files_size_in_mb: int | None = None,
|
video_files_size_in_mb: int | None = None,
|
||||||
) -> "LeRobotDatasetMetadata":
|
) -> "LeRobotDatasetMetadata":
|
||||||
"""Creates metadata for a LeRobotDataset."""
|
"""Create metadata for a new LeRobot dataset from scratch.
|
||||||
|
|
||||||
|
Initializes the ``info.json`` file on disk with the provided feature
|
||||||
|
schema and dataset settings. No episode data is written yet.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
repo_id: Repository identifier (e.g. ``'user/my_dataset'``).
|
||||||
|
fps: Frames per second used during data collection.
|
||||||
|
features: Feature specification dict mapping feature names to their
|
||||||
|
type/shape metadata.
|
||||||
|
robot_type: Optional robot type string stored in metadata.
|
||||||
|
root: Local directory for the dataset. Defaults to
|
||||||
|
``$HF_LEROBOT_HOME/{repo_id}``. Must not already exist.
|
||||||
|
use_videos: If ``True``, visual modalities are encoded as MP4 videos.
|
||||||
|
metadata_buffer_size: Number of episode metadata records to buffer
|
||||||
|
before flushing to parquet.
|
||||||
|
chunks_size: Max number of files per chunk directory. ``None`` uses
|
||||||
|
the default.
|
||||||
|
data_files_size_in_mb: Max parquet file size in MB. ``None`` uses the
|
||||||
|
default.
|
||||||
|
video_files_size_in_mb: Max video file size in MB. ``None`` uses the
|
||||||
|
default.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A new :class:`LeRobotDatasetMetadata` instance.
|
||||||
|
"""
|
||||||
obj = cls.__new__(cls)
|
obj = cls.__new__(cls)
|
||||||
obj.repo_id = repo_id
|
obj.repo_id = repo_id
|
||||||
obj.root = Path(root) if root is not None else HF_LEROBOT_HOME / repo_id
|
obj.root = Path(root) if root is not None else HF_LEROBOT_HOME / repo_id
|
||||||
@@ -510,8 +621,9 @@ class LeRobotDatasetMetadata:
|
|||||||
)
|
)
|
||||||
write_json(obj.info, obj.root / INFO_PATH)
|
write_json(obj.info, obj.root / INFO_PATH)
|
||||||
obj.revision = None
|
obj.revision = None
|
||||||
obj.writer = None
|
obj._pq_writer = None
|
||||||
obj.latest_episode = None
|
obj.latest_episode = None
|
||||||
obj.metadata_buffer = []
|
obj._metadata_buffer = []
|
||||||
obj.metadata_buffer_size = metadata_buffer_size
|
obj._metadata_buffer_size = metadata_buffer_size
|
||||||
|
obj._finalized = False
|
||||||
return obj
|
return obj
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""Private reader component for LeRobotDataset. Handles random-access reading (HF dataset, delta indices, video decoding)."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import datasets
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
|
||||||
|
from lerobot.datasets.feature_utils import (
|
||||||
|
check_delta_timestamps,
|
||||||
|
get_delta_indices,
|
||||||
|
get_hf_features_from_features,
|
||||||
|
)
|
||||||
|
from lerobot.datasets.io_utils import (
|
||||||
|
hf_transform_to_torch,
|
||||||
|
load_nested_dataset,
|
||||||
|
)
|
||||||
|
from lerobot.datasets.video_utils import decode_video_frames
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetReader:
|
||||||
|
"""Encapsulates read-side state and methods for LeRobotDataset.
|
||||||
|
|
||||||
|
Owns: hf_dataset, _absolute_to_relative_idx, delta_indices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
meta: LeRobotDatasetMetadata,
|
||||||
|
root: Path,
|
||||||
|
episodes: list[int] | None,
|
||||||
|
tolerance_s: float,
|
||||||
|
video_backend: str,
|
||||||
|
delta_timestamps: dict[str, list[float]] | None,
|
||||||
|
image_transforms: Callable | None,
|
||||||
|
):
|
||||||
|
"""Initialize the reader with metadata, filtering, and transform config.
|
||||||
|
|
||||||
|
The HF dataset is not loaded here — call :meth:`try_load` or
|
||||||
|
:meth:`load_and_activate` afterward.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
meta: Dataset metadata instance.
|
||||||
|
root: Local dataset root directory.
|
||||||
|
episodes: Optional list of episode indices to select. ``None``
|
||||||
|
means all episodes.
|
||||||
|
tolerance_s: Timestamp synchronization tolerance in seconds.
|
||||||
|
video_backend: Video decoding backend identifier.
|
||||||
|
delta_timestamps: Optional dict mapping feature keys to lists of
|
||||||
|
relative timestamp offsets for temporal context windows.
|
||||||
|
image_transforms: Optional torchvision v2 transform applied to
|
||||||
|
visual features.
|
||||||
|
"""
|
||||||
|
self._meta = meta
|
||||||
|
self._root = root
|
||||||
|
self.episodes = episodes
|
||||||
|
self._tolerance_s = tolerance_s
|
||||||
|
self._video_backend = video_backend
|
||||||
|
self._image_transforms = image_transforms
|
||||||
|
|
||||||
|
self.hf_dataset: datasets.Dataset | None = None
|
||||||
|
self._absolute_to_relative_idx: dict[int, int] | None = None
|
||||||
|
|
||||||
|
# Setup delta_indices (doesn't depend on hf_dataset)
|
||||||
|
self.delta_indices = None
|
||||||
|
if delta_timestamps is not None:
|
||||||
|
check_delta_timestamps(delta_timestamps, meta.fps, tolerance_s)
|
||||||
|
self.delta_indices = get_delta_indices(delta_timestamps, meta.fps)
|
||||||
|
|
||||||
|
def try_load(self) -> bool:
|
||||||
|
"""Attempt to load from local cache. Returns True if data is sufficient."""
|
||||||
|
try:
|
||||||
|
self.hf_dataset = self._load_hf_dataset()
|
||||||
|
except (FileNotFoundError, NotADirectoryError):
|
||||||
|
self.hf_dataset = None
|
||||||
|
return False
|
||||||
|
if not self._check_cached_episodes_sufficient():
|
||||||
|
self.hf_dataset = None
|
||||||
|
return False
|
||||||
|
self._build_index_mapping()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def load_and_activate(self) -> None:
|
||||||
|
"""Load HF dataset from disk and build index mapping. Call after data is on disk."""
|
||||||
|
self.hf_dataset = self._load_hf_dataset()
|
||||||
|
self._build_index_mapping()
|
||||||
|
|
||||||
|
def _build_index_mapping(self) -> None:
|
||||||
|
"""Build absolute-to-relative index mapping from loaded hf_dataset."""
|
||||||
|
self._absolute_to_relative_idx = None
|
||||||
|
if self.episodes is not None and self.hf_dataset is not None:
|
||||||
|
self._absolute_to_relative_idx = {
|
||||||
|
abs_idx.item() if isinstance(abs_idx, torch.Tensor) else abs_idx: rel_idx
|
||||||
|
for rel_idx, abs_idx in enumerate(self.hf_dataset["index"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def num_frames(self) -> int:
|
||||||
|
"""Number of frames in selected episodes."""
|
||||||
|
if self.episodes is not None and self.hf_dataset is not None:
|
||||||
|
return len(self.hf_dataset)
|
||||||
|
return self._meta.total_frames
|
||||||
|
|
||||||
|
@property
|
||||||
|
def num_episodes(self) -> int:
|
||||||
|
"""Number of episodes selected."""
|
||||||
|
return len(self.episodes) if self.episodes is not None else self._meta.total_episodes
|
||||||
|
|
||||||
|
def _load_hf_dataset(self) -> datasets.Dataset:
|
||||||
|
"""hf_dataset contains all the observations, states, actions, rewards, etc."""
|
||||||
|
features = get_hf_features_from_features(self._meta.features)
|
||||||
|
hf_dataset = load_nested_dataset(self._root / "data", features=features, episodes=self.episodes)
|
||||||
|
hf_dataset.set_transform(hf_transform_to_torch)
|
||||||
|
return hf_dataset
|
||||||
|
|
||||||
|
def _check_cached_episodes_sufficient(self) -> bool:
|
||||||
|
"""Check if the cached dataset contains all requested episodes and their video files."""
|
||||||
|
if self.hf_dataset is None or len(self.hf_dataset) == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
available_episodes = {
|
||||||
|
ep_idx.item() if isinstance(ep_idx, torch.Tensor) else ep_idx
|
||||||
|
for ep_idx in self.hf_dataset.unique("episode_index")
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.episodes is None:
|
||||||
|
requested_episodes = set(range(self._meta.total_episodes))
|
||||||
|
else:
|
||||||
|
requested_episodes = set(self.episodes)
|
||||||
|
|
||||||
|
if not requested_episodes.issubset(available_episodes):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if len(self._meta.video_keys) > 0:
|
||||||
|
for ep_idx in requested_episodes:
|
||||||
|
for vid_key in self._meta.video_keys:
|
||||||
|
video_path = self._root / self._meta.get_video_file_path(ep_idx, vid_key)
|
||||||
|
if not video_path.exists():
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_episodes_file_paths(self) -> list[Path]:
|
||||||
|
"""Return deduplicated file paths (data + video) for selected episodes.
|
||||||
|
|
||||||
|
Used to build the ``allow_patterns`` list for ``snapshot_download``.
|
||||||
|
"""
|
||||||
|
episodes = self.episodes if self.episodes is not None else list(range(self._meta.total_episodes))
|
||||||
|
fpaths = [str(self._meta.get_data_file_path(ep_idx)) for ep_idx in episodes]
|
||||||
|
if len(self._meta.video_keys) > 0:
|
||||||
|
video_files = [
|
||||||
|
str(self._meta.get_video_file_path(ep_idx, vid_key))
|
||||||
|
for vid_key in self._meta.video_keys
|
||||||
|
for ep_idx in episodes
|
||||||
|
]
|
||||||
|
fpaths += video_files
|
||||||
|
# episodes are stored in the same files, so we return unique paths only
|
||||||
|
fpaths = list(set(fpaths))
|
||||||
|
return fpaths
|
||||||
|
|
||||||
|
def _get_query_indices(
|
||||||
|
self, abs_idx: int, ep_idx: int
|
||||||
|
) -> tuple[dict[str, list[int]], dict[str, torch.Tensor]]:
|
||||||
|
"""Compute query indices for delta timestamps."""
|
||||||
|
ep = self._meta.episodes[ep_idx]
|
||||||
|
ep_start = ep["dataset_from_index"]
|
||||||
|
ep_end = ep["dataset_to_index"]
|
||||||
|
query_indices = {
|
||||||
|
key: [max(ep_start, min(ep_end - 1, abs_idx + delta)) for delta in delta_idx]
|
||||||
|
for key, delta_idx in self.delta_indices.items()
|
||||||
|
}
|
||||||
|
padding = {
|
||||||
|
f"{key}_is_pad": torch.BoolTensor(
|
||||||
|
[(abs_idx + delta < ep_start) | (abs_idx + delta >= ep_end) for delta in delta_idx]
|
||||||
|
)
|
||||||
|
for key, delta_idx in self.delta_indices.items()
|
||||||
|
}
|
||||||
|
return query_indices, padding
|
||||||
|
|
||||||
|
def _get_query_timestamps(
|
||||||
|
self,
|
||||||
|
current_ts: float,
|
||||||
|
query_indices: dict[str, list[int]] | None = None,
|
||||||
|
) -> dict[str, list[float]]:
|
||||||
|
query_timestamps = {}
|
||||||
|
for key in self._meta.video_keys:
|
||||||
|
if query_indices is not None and key in query_indices:
|
||||||
|
if self._absolute_to_relative_idx is not None:
|
||||||
|
relative_indices = [self._absolute_to_relative_idx[idx] for idx in query_indices[key]]
|
||||||
|
timestamps = self.hf_dataset[relative_indices]["timestamp"]
|
||||||
|
else:
|
||||||
|
timestamps = self.hf_dataset[query_indices[key]]["timestamp"]
|
||||||
|
query_timestamps[key] = torch.stack(timestamps).tolist()
|
||||||
|
else:
|
||||||
|
query_timestamps[key] = [current_ts]
|
||||||
|
|
||||||
|
return query_timestamps
|
||||||
|
|
||||||
|
def _query_hf_dataset(self, query_indices: dict[str, list[int]]) -> dict:
|
||||||
|
"""Query dataset for indices across keys, skipping video keys."""
|
||||||
|
result: dict = {}
|
||||||
|
for key, q_idx in query_indices.items():
|
||||||
|
if key in self._meta.video_keys:
|
||||||
|
continue
|
||||||
|
relative_indices = (
|
||||||
|
q_idx
|
||||||
|
if self._absolute_to_relative_idx is None
|
||||||
|
else [self._absolute_to_relative_idx[idx] for idx in q_idx]
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result[key] = torch.stack(self.hf_dataset[key][relative_indices])
|
||||||
|
except (KeyError, TypeError, IndexError):
|
||||||
|
result[key] = torch.stack(self.hf_dataset[relative_indices][key])
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _query_videos(self, query_timestamps: dict[str, list[float]], ep_idx: int) -> dict[str, torch.Tensor]:
|
||||||
|
"""Note: When using data workers (e.g. DataLoader with num_workers>0), do not call this function
|
||||||
|
in the main process (e.g. by using a second Dataloader with num_workers=0). It will result in a
|
||||||
|
Segmentation Fault.
|
||||||
|
"""
|
||||||
|
ep = self._meta.episodes[ep_idx]
|
||||||
|
item = {}
|
||||||
|
for vid_key, query_ts in query_timestamps.items():
|
||||||
|
from_timestamp = ep[f"videos/{vid_key}/from_timestamp"]
|
||||||
|
shifted_query_ts = [from_timestamp + ts for ts in query_ts]
|
||||||
|
|
||||||
|
video_path = self._root / self._meta.get_video_file_path(ep_idx, vid_key)
|
||||||
|
frames = decode_video_frames(video_path, shifted_query_ts, self._tolerance_s, self._video_backend)
|
||||||
|
item[vid_key] = frames.squeeze(0)
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
def get_item(self, idx) -> dict:
|
||||||
|
"""Core __getitem__ logic. Assumes hf_dataset is loaded.
|
||||||
|
|
||||||
|
``idx`` is a *relative* index into the (possibly episode-filtered)
|
||||||
|
HF dataset, **not** the absolute frame index stored in the ``index``
|
||||||
|
column. The absolute index is retrieved from the row itself.
|
||||||
|
"""
|
||||||
|
item = self.hf_dataset[idx]
|
||||||
|
ep_idx = item["episode_index"].item()
|
||||||
|
abs_idx = item["index"].item()
|
||||||
|
|
||||||
|
query_indices = None
|
||||||
|
if self.delta_indices is not None:
|
||||||
|
query_indices, padding = self._get_query_indices(abs_idx, ep_idx)
|
||||||
|
query_result = self._query_hf_dataset(query_indices)
|
||||||
|
item = {**item, **padding}
|
||||||
|
for key, val in query_result.items():
|
||||||
|
item[key] = val
|
||||||
|
|
||||||
|
if len(self._meta.video_keys) > 0:
|
||||||
|
current_ts = item["timestamp"].item()
|
||||||
|
query_timestamps = self._get_query_timestamps(current_ts, query_indices)
|
||||||
|
video_frames = self._query_videos(query_timestamps, ep_idx)
|
||||||
|
item = {**video_frames, **item}
|
||||||
|
|
||||||
|
if self._image_transforms is not None:
|
||||||
|
image_keys = self._meta.camera_keys
|
||||||
|
for cam in image_keys:
|
||||||
|
item[cam] = self._image_transforms(item[cam])
|
||||||
|
|
||||||
|
# Add task as a string
|
||||||
|
task_idx = item["task_index"].item()
|
||||||
|
item["task"] = self._meta.tasks.iloc[task_idx].name
|
||||||
|
|
||||||
|
# add subtask information if available
|
||||||
|
if "subtask_index" in self._meta.features and self._meta.subtasks is not None:
|
||||||
|
subtask_idx = item["subtask_index"].item()
|
||||||
|
item["subtask"] = self._meta.subtasks.iloc[subtask_idx].name
|
||||||
|
|
||||||
|
return item
|
||||||
@@ -891,7 +891,7 @@ def _copy_and_reindex_episodes_metadata(
|
|||||||
|
|
||||||
total_frames += src_episode["length"]
|
total_frames += src_episode["length"]
|
||||||
|
|
||||||
dst_meta._close_writer()
|
dst_meta.finalize()
|
||||||
|
|
||||||
dst_meta.info.update(
|
dst_meta.info.update(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,625 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""Private writer component for LeRobotDataset. Handles sequential recording (episode buffer, ParquetWriter, image writer, video encoding)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import concurrent.futures
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import datasets
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import PIL.Image
|
||||||
|
import pyarrow.parquet as pq
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from lerobot.datasets.compute_stats import compute_episode_stats
|
||||||
|
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
|
||||||
|
from lerobot.datasets.feature_utils import (
|
||||||
|
get_hf_features_from_features,
|
||||||
|
validate_episode_buffer,
|
||||||
|
validate_frame,
|
||||||
|
)
|
||||||
|
from lerobot.datasets.image_writer import AsyncImageWriter, write_image
|
||||||
|
from lerobot.datasets.io_utils import (
|
||||||
|
embed_images,
|
||||||
|
get_file_size_in_mb,
|
||||||
|
load_episodes,
|
||||||
|
write_info,
|
||||||
|
)
|
||||||
|
from lerobot.datasets.utils import (
|
||||||
|
DEFAULT_EPISODES_PATH,
|
||||||
|
DEFAULT_IMAGE_PATH,
|
||||||
|
update_chunk_file_indices,
|
||||||
|
)
|
||||||
|
from lerobot.datasets.video_utils import (
|
||||||
|
StreamingVideoEncoder,
|
||||||
|
concatenate_video_files,
|
||||||
|
encode_video_frames,
|
||||||
|
get_video_duration_in_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_video_worker(
|
||||||
|
video_key: str,
|
||||||
|
episode_index: int,
|
||||||
|
root: Path,
|
||||||
|
fps: int,
|
||||||
|
vcodec: str = "libsvtav1",
|
||||||
|
encoder_threads: int | None = None,
|
||||||
|
) -> Path:
|
||||||
|
temp_path = Path(tempfile.mkdtemp(dir=root)) / f"{video_key}_{episode_index:03d}.mp4"
|
||||||
|
fpath = DEFAULT_IMAGE_PATH.format(image_key=video_key, episode_index=episode_index, frame_index=0)
|
||||||
|
img_dir = (root / fpath).parent
|
||||||
|
encode_video_frames(
|
||||||
|
img_dir, temp_path, fps, vcodec=vcodec, overwrite=True, encoder_threads=encoder_threads
|
||||||
|
)
|
||||||
|
shutil.rmtree(img_dir)
|
||||||
|
return temp_path
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetWriter:
|
||||||
|
"""Encapsulates write-side state and methods for LeRobotDataset.
|
||||||
|
|
||||||
|
Owns: episode_buffer, image_writer, _pq_writer (ParquetWriter), _latest_episode,
|
||||||
|
_current_file_start_frame, _streaming_encoder, _episodes_since_last_encoding, _recorded_frames.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
meta: LeRobotDatasetMetadata,
|
||||||
|
root: Path,
|
||||||
|
vcodec: str,
|
||||||
|
encoder_threads: int | None,
|
||||||
|
batch_encoding_size: int,
|
||||||
|
streaming_encoder: StreamingVideoEncoder | None = None,
|
||||||
|
initial_frames: int = 0,
|
||||||
|
):
|
||||||
|
"""Initialize the writer with metadata, codec, and encoding config.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
meta: Dataset metadata instance (used for feature schema, chunk
|
||||||
|
settings, and episode persistence).
|
||||||
|
root: Local dataset root directory.
|
||||||
|
vcodec: Video codec for encoding (e.g. ``'libsvtav1'``, ``'h264'``).
|
||||||
|
encoder_threads: Threads per encoder instance. ``None`` for auto.
|
||||||
|
batch_encoding_size: Number of episodes to accumulate before
|
||||||
|
batch-encoding videos.
|
||||||
|
streaming_encoder: Optional pre-built :class:`StreamingVideoEncoder`
|
||||||
|
for real-time encoding. ``None`` disables streaming mode.
|
||||||
|
initial_frames: Starting frame count (non-zero when resuming).
|
||||||
|
"""
|
||||||
|
self._meta = meta
|
||||||
|
self._root = root
|
||||||
|
self._vcodec = vcodec
|
||||||
|
self._encoder_threads = encoder_threads
|
||||||
|
self._batch_encoding_size = batch_encoding_size
|
||||||
|
self._streaming_encoder = streaming_encoder
|
||||||
|
|
||||||
|
# Writer state
|
||||||
|
self.image_writer: AsyncImageWriter | None = None
|
||||||
|
self.episode_buffer: dict = self._create_episode_buffer()
|
||||||
|
self._pq_writer: pq.ParquetWriter | None = None
|
||||||
|
self._latest_episode: dict | None = None
|
||||||
|
self._current_file_start_frame: int | None = None
|
||||||
|
self._episodes_since_last_encoding: int = 0
|
||||||
|
self._recorded_frames: int = initial_frames
|
||||||
|
self._finalized = False
|
||||||
|
|
||||||
|
def _create_episode_buffer(self, episode_index: int | None = None) -> dict:
|
||||||
|
current_ep_idx = self._meta.total_episodes if episode_index is None else episode_index
|
||||||
|
ep_buffer = {}
|
||||||
|
ep_buffer["size"] = 0
|
||||||
|
ep_buffer["task"] = []
|
||||||
|
for key in self._meta.features:
|
||||||
|
ep_buffer[key] = current_ep_idx if key == "episode_index" else []
|
||||||
|
return ep_buffer
|
||||||
|
|
||||||
|
def _get_image_file_path(self, episode_index: int, image_key: str, frame_index: int) -> Path:
|
||||||
|
fpath = DEFAULT_IMAGE_PATH.format(
|
||||||
|
image_key=image_key, episode_index=episode_index, frame_index=frame_index
|
||||||
|
)
|
||||||
|
return self._root / fpath
|
||||||
|
|
||||||
|
def _get_image_file_dir(self, episode_index: int, image_key: str) -> Path:
|
||||||
|
return self._get_image_file_path(episode_index, image_key, frame_index=0).parent
|
||||||
|
|
||||||
|
def _save_image(
|
||||||
|
self, image: torch.Tensor | np.ndarray | PIL.Image.Image, fpath: Path, compress_level: int = 1
|
||||||
|
) -> None:
|
||||||
|
if self.image_writer is None:
|
||||||
|
if isinstance(image, torch.Tensor):
|
||||||
|
image = image.cpu().numpy()
|
||||||
|
write_image(image, fpath, compress_level=compress_level)
|
||||||
|
else:
|
||||||
|
self.image_writer.save_image(image=image, fpath=fpath, compress_level=compress_level)
|
||||||
|
|
||||||
|
def add_frame(self, frame: dict) -> None:
|
||||||
|
"""Add a frame to the episode_buffer. Images are written to a temporary directory."""
|
||||||
|
# Convert torch to numpy if needed
|
||||||
|
for name in frame:
|
||||||
|
if isinstance(frame[name], torch.Tensor):
|
||||||
|
frame[name] = frame[name].numpy()
|
||||||
|
|
||||||
|
validate_frame(frame, self._meta.features)
|
||||||
|
|
||||||
|
if self.episode_buffer is None:
|
||||||
|
self.episode_buffer = self._create_episode_buffer()
|
||||||
|
|
||||||
|
# Automatically add frame_index and timestamp to episode buffer
|
||||||
|
frame_index = self.episode_buffer["size"]
|
||||||
|
timestamp = frame.pop("timestamp") if "timestamp" in frame else frame_index / self._meta.fps
|
||||||
|
self.episode_buffer["frame_index"].append(frame_index)
|
||||||
|
self.episode_buffer["timestamp"].append(timestamp)
|
||||||
|
self.episode_buffer["task"].append(frame.pop("task"))
|
||||||
|
|
||||||
|
# Start streaming encoder on first frame of episode
|
||||||
|
if frame_index == 0 and self._streaming_encoder is not None:
|
||||||
|
self._streaming_encoder.start_episode(
|
||||||
|
video_keys=list(self._meta.video_keys),
|
||||||
|
temp_dir=self._root,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add frame features to episode_buffer
|
||||||
|
for key in frame:
|
||||||
|
if key not in self._meta.features:
|
||||||
|
raise ValueError(
|
||||||
|
f"An element of the frame is not in the features. '{key}' not in '{self._meta.features.keys()}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._meta.features[key]["dtype"] == "video" and self._streaming_encoder is not None:
|
||||||
|
self._streaming_encoder.feed_frame(key, frame[key])
|
||||||
|
self.episode_buffer[key].append(None)
|
||||||
|
elif self._meta.features[key]["dtype"] in ["image", "video"]:
|
||||||
|
img_path = self._get_image_file_path(
|
||||||
|
episode_index=self.episode_buffer["episode_index"], image_key=key, frame_index=frame_index
|
||||||
|
)
|
||||||
|
if frame_index == 0:
|
||||||
|
img_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
compress_level = 1 if self._meta.features[key]["dtype"] == "video" else 6
|
||||||
|
self._save_image(frame[key], img_path, compress_level)
|
||||||
|
self.episode_buffer[key].append(str(img_path))
|
||||||
|
else:
|
||||||
|
self.episode_buffer[key].append(frame[key])
|
||||||
|
|
||||||
|
self.episode_buffer["size"] += 1
|
||||||
|
|
||||||
|
def save_episode(
|
||||||
|
self,
|
||||||
|
episode_data: dict | None = None,
|
||||||
|
parallel_encoding: bool = True,
|
||||||
|
) -> None:
|
||||||
|
"""Save the current episode in self.episode_buffer to disk."""
|
||||||
|
episode_buffer = episode_data if episode_data is not None else self.episode_buffer
|
||||||
|
|
||||||
|
validate_episode_buffer(episode_buffer, self._meta.total_episodes, self._meta.features)
|
||||||
|
|
||||||
|
# size and task are special cases that won't be added to hf_dataset
|
||||||
|
episode_length = episode_buffer.pop("size")
|
||||||
|
tasks = episode_buffer.pop("task")
|
||||||
|
episode_tasks = list(set(tasks))
|
||||||
|
episode_index = episode_buffer["episode_index"]
|
||||||
|
|
||||||
|
episode_buffer["index"] = np.arange(self._meta.total_frames, self._meta.total_frames + episode_length)
|
||||||
|
episode_buffer["episode_index"] = np.full((episode_length,), episode_index)
|
||||||
|
|
||||||
|
# Update tasks and task indices with new tasks if any
|
||||||
|
self._meta.save_episode_tasks(episode_tasks)
|
||||||
|
|
||||||
|
# Given tasks in natural language, find their corresponding task indices
|
||||||
|
episode_buffer["task_index"] = np.array([self._meta.get_task_index(task) for task in tasks])
|
||||||
|
|
||||||
|
for key, ft in self._meta.features.items():
|
||||||
|
if key in ["index", "episode_index", "task_index"] or ft["dtype"] in ["image", "video"]:
|
||||||
|
continue
|
||||||
|
episode_buffer[key] = np.stack(episode_buffer[key])
|
||||||
|
|
||||||
|
# Wait for image writer to end, so that episode stats over images can be computed
|
||||||
|
self._wait_image_writer()
|
||||||
|
|
||||||
|
has_video_keys = len(self._meta.video_keys) > 0
|
||||||
|
use_streaming = self._streaming_encoder is not None and has_video_keys
|
||||||
|
use_batched_encoding = self._batch_encoding_size > 1
|
||||||
|
|
||||||
|
if use_streaming:
|
||||||
|
non_video_buffer = {
|
||||||
|
k: v
|
||||||
|
for k, v in episode_buffer.items()
|
||||||
|
if self._meta.features.get(k, {}).get("dtype") not in ("video",)
|
||||||
|
}
|
||||||
|
non_video_features = {k: v for k, v in self._meta.features.items() if v["dtype"] != "video"}
|
||||||
|
ep_stats = compute_episode_stats(non_video_buffer, non_video_features)
|
||||||
|
else:
|
||||||
|
ep_stats = compute_episode_stats(episode_buffer, self._meta.features)
|
||||||
|
|
||||||
|
ep_metadata = self._save_episode_data(episode_buffer)
|
||||||
|
|
||||||
|
if use_streaming:
|
||||||
|
streaming_results = self._streaming_encoder.finish_episode()
|
||||||
|
for video_key in self._meta.video_keys:
|
||||||
|
temp_path, video_stats = streaming_results[video_key]
|
||||||
|
if video_stats is not None:
|
||||||
|
ep_stats[video_key] = {
|
||||||
|
k: v if k == "count" else np.squeeze(v.reshape(1, -1, 1, 1) / 255.0, axis=0)
|
||||||
|
for k, v in video_stats.items()
|
||||||
|
}
|
||||||
|
ep_metadata.update(self._save_episode_video(video_key, episode_index, temp_path=temp_path))
|
||||||
|
elif has_video_keys and not use_batched_encoding:
|
||||||
|
num_cameras = len(self._meta.video_keys)
|
||||||
|
if parallel_encoding and num_cameras > 1:
|
||||||
|
with concurrent.futures.ProcessPoolExecutor(max_workers=num_cameras) as executor:
|
||||||
|
future_to_key = {
|
||||||
|
executor.submit(
|
||||||
|
_encode_video_worker,
|
||||||
|
video_key,
|
||||||
|
episode_index,
|
||||||
|
self._root,
|
||||||
|
self._meta.fps,
|
||||||
|
self._vcodec,
|
||||||
|
self._encoder_threads,
|
||||||
|
): video_key
|
||||||
|
for video_key in self._meta.video_keys
|
||||||
|
}
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for future in concurrent.futures.as_completed(future_to_key):
|
||||||
|
video_key = future_to_key[future]
|
||||||
|
try:
|
||||||
|
temp_path = future.result()
|
||||||
|
results[video_key] = temp_path
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Video encoding failed for {video_key}: {exc}")
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
for video_key in self._meta.video_keys:
|
||||||
|
temp_path = results[video_key]
|
||||||
|
ep_metadata.update(
|
||||||
|
self._save_episode_video(video_key, episode_index, temp_path=temp_path)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
for video_key in self._meta.video_keys:
|
||||||
|
ep_metadata.update(self._save_episode_video(video_key, episode_index))
|
||||||
|
|
||||||
|
# `meta.save_episode` need to be executed after encoding the videos
|
||||||
|
self._meta.save_episode(episode_index, episode_length, episode_tasks, ep_stats, ep_metadata)
|
||||||
|
|
||||||
|
if has_video_keys and use_batched_encoding:
|
||||||
|
self._episodes_since_last_encoding += 1
|
||||||
|
if self._episodes_since_last_encoding == self._batch_encoding_size:
|
||||||
|
start_ep = self._meta.total_episodes - self._batch_encoding_size
|
||||||
|
end_ep = self._meta.total_episodes
|
||||||
|
self._batch_save_episode_video(start_ep, end_ep)
|
||||||
|
self._episodes_since_last_encoding = 0
|
||||||
|
|
||||||
|
if episode_data is None:
|
||||||
|
self.clear_episode_buffer(delete_images=len(self._meta.image_keys) > 0)
|
||||||
|
|
||||||
|
def _batch_save_episode_video(self, start_episode: int, end_episode: int | None = None) -> None:
|
||||||
|
"""Batch save videos for multiple episodes."""
|
||||||
|
if end_episode is None:
|
||||||
|
end_episode = self._meta.total_episodes
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Batch encoding {self._batch_encoding_size} videos for episodes {start_episode} to {end_episode - 1}"
|
||||||
|
)
|
||||||
|
|
||||||
|
chunk_idx = self._meta.episodes[start_episode]["data/chunk_index"]
|
||||||
|
file_idx = self._meta.episodes[start_episode]["data/file_index"]
|
||||||
|
episode_df_path = self._root / DEFAULT_EPISODES_PATH.format(
|
||||||
|
chunk_index=chunk_idx, file_index=file_idx
|
||||||
|
)
|
||||||
|
episode_df = pd.read_parquet(episode_df_path)
|
||||||
|
|
||||||
|
for ep_idx in range(start_episode, end_episode):
|
||||||
|
logger.info(f"Encoding videos for episode {ep_idx}")
|
||||||
|
|
||||||
|
if (
|
||||||
|
self._meta.episodes[ep_idx]["data/chunk_index"] != chunk_idx
|
||||||
|
or self._meta.episodes[ep_idx]["data/file_index"] != file_idx
|
||||||
|
):
|
||||||
|
episode_df.to_parquet(episode_df_path)
|
||||||
|
self._meta.episodes = load_episodes(self._root)
|
||||||
|
|
||||||
|
chunk_idx = self._meta.episodes[ep_idx]["data/chunk_index"]
|
||||||
|
file_idx = self._meta.episodes[ep_idx]["data/file_index"]
|
||||||
|
episode_df_path = self._root / DEFAULT_EPISODES_PATH.format(
|
||||||
|
chunk_index=chunk_idx, file_index=file_idx
|
||||||
|
)
|
||||||
|
episode_df = pd.read_parquet(episode_df_path)
|
||||||
|
|
||||||
|
video_ep_metadata = {}
|
||||||
|
for video_key in self._meta.video_keys:
|
||||||
|
video_ep_metadata.update(self._save_episode_video(video_key, ep_idx))
|
||||||
|
video_ep_metadata.pop("episode_index")
|
||||||
|
video_ep_df = pd.DataFrame(video_ep_metadata, index=[ep_idx]).convert_dtypes(
|
||||||
|
dtype_backend="pyarrow"
|
||||||
|
)
|
||||||
|
|
||||||
|
episode_df = episode_df.combine_first(video_ep_df)
|
||||||
|
episode_df.to_parquet(episode_df_path)
|
||||||
|
self._meta.episodes = load_episodes(self._root)
|
||||||
|
|
||||||
|
def _save_episode_data(self, episode_buffer: dict) -> dict:
|
||||||
|
"""Save episode data to a parquet file."""
|
||||||
|
# Use metadata features as the authoritative schema
|
||||||
|
hf_features = get_hf_features_from_features(self._meta.features)
|
||||||
|
ep_dict = {key: episode_buffer[key] for key in hf_features}
|
||||||
|
ep_dataset = datasets.Dataset.from_dict(ep_dict, features=hf_features, split="train")
|
||||||
|
ep_dataset = embed_images(ep_dataset)
|
||||||
|
ep_num_frames = len(ep_dataset)
|
||||||
|
|
||||||
|
if self._latest_episode is None:
|
||||||
|
chunk_idx, file_idx = 0, 0
|
||||||
|
global_frame_index = 0
|
||||||
|
self._current_file_start_frame = 0
|
||||||
|
if self._meta.episodes is not None and len(self._meta.episodes) > 0:
|
||||||
|
latest_ep = self._meta.episodes[-1]
|
||||||
|
global_frame_index = latest_ep["dataset_to_index"]
|
||||||
|
chunk_idx = latest_ep["data/chunk_index"]
|
||||||
|
file_idx = latest_ep["data/file_index"]
|
||||||
|
|
||||||
|
chunk_idx, file_idx = update_chunk_file_indices(chunk_idx, file_idx, self._meta.chunks_size)
|
||||||
|
self._current_file_start_frame = global_frame_index
|
||||||
|
else:
|
||||||
|
latest_ep = self._latest_episode
|
||||||
|
chunk_idx = latest_ep["data/chunk_index"]
|
||||||
|
file_idx = latest_ep["data/file_index"]
|
||||||
|
global_frame_index = latest_ep["index"][-1] + 1
|
||||||
|
|
||||||
|
latest_path = self._root / self._meta.data_path.format(chunk_index=chunk_idx, file_index=file_idx)
|
||||||
|
latest_size_in_mb = get_file_size_in_mb(latest_path)
|
||||||
|
|
||||||
|
frames_in_current_file = global_frame_index - self._current_file_start_frame
|
||||||
|
av_size_per_frame = (
|
||||||
|
latest_size_in_mb / frames_in_current_file if frames_in_current_file > 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
if latest_size_in_mb + av_size_per_frame * ep_num_frames >= self._meta.data_files_size_in_mb:
|
||||||
|
chunk_idx, file_idx = update_chunk_file_indices(chunk_idx, file_idx, self._meta.chunks_size)
|
||||||
|
self.close_writer()
|
||||||
|
self._current_file_start_frame = global_frame_index
|
||||||
|
|
||||||
|
ep_dict["data/chunk_index"] = chunk_idx
|
||||||
|
ep_dict["data/file_index"] = file_idx
|
||||||
|
|
||||||
|
path = self._root / self._meta.data_path.format(chunk_index=chunk_idx, file_index=file_idx)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
table = ep_dataset.with_format("arrow")[:]
|
||||||
|
if not self._pq_writer:
|
||||||
|
self._pq_writer = pq.ParquetWriter(
|
||||||
|
path, schema=table.schema, compression="snappy", use_dictionary=True
|
||||||
|
)
|
||||||
|
self._pq_writer.write_table(table)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"data/chunk_index": chunk_idx,
|
||||||
|
"data/file_index": file_idx,
|
||||||
|
"dataset_from_index": global_frame_index,
|
||||||
|
"dataset_to_index": global_frame_index + ep_num_frames,
|
||||||
|
}
|
||||||
|
|
||||||
|
self._latest_episode = {**ep_dict, **metadata}
|
||||||
|
self._recorded_frames += ep_num_frames
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def _save_episode_video(
|
||||||
|
self,
|
||||||
|
video_key: str,
|
||||||
|
episode_index: int,
|
||||||
|
temp_path: Path | None = None,
|
||||||
|
) -> dict:
|
||||||
|
if temp_path is None:
|
||||||
|
ep_path = self._encode_temporary_episode_video(video_key, episode_index)
|
||||||
|
else:
|
||||||
|
ep_path = temp_path
|
||||||
|
|
||||||
|
ep_size_in_mb = get_file_size_in_mb(ep_path)
|
||||||
|
ep_duration_in_s = get_video_duration_in_s(ep_path)
|
||||||
|
|
||||||
|
if (
|
||||||
|
episode_index == 0
|
||||||
|
or self._meta.latest_episode is None
|
||||||
|
or f"videos/{video_key}/chunk_index" not in self._meta.latest_episode
|
||||||
|
):
|
||||||
|
chunk_idx, file_idx = 0, 0
|
||||||
|
if self._meta.episodes is not None and len(self._meta.episodes) > 0:
|
||||||
|
old_chunk_idx = self._meta.episodes[-1][f"videos/{video_key}/chunk_index"]
|
||||||
|
old_file_idx = self._meta.episodes[-1][f"videos/{video_key}/file_index"]
|
||||||
|
chunk_idx, file_idx = update_chunk_file_indices(
|
||||||
|
old_chunk_idx, old_file_idx, self._meta.chunks_size
|
||||||
|
)
|
||||||
|
latest_duration_in_s = 0.0
|
||||||
|
new_path = self._root / self._meta.video_path.format(
|
||||||
|
video_key=video_key, chunk_index=chunk_idx, file_index=file_idx
|
||||||
|
)
|
||||||
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(ep_path), str(new_path))
|
||||||
|
else:
|
||||||
|
latest_ep = self._meta.latest_episode
|
||||||
|
chunk_idx = latest_ep[f"videos/{video_key}/chunk_index"][0]
|
||||||
|
file_idx = latest_ep[f"videos/{video_key}/file_index"][0]
|
||||||
|
|
||||||
|
latest_path = self._root / self._meta.video_path.format(
|
||||||
|
video_key=video_key, chunk_index=chunk_idx, file_index=file_idx
|
||||||
|
)
|
||||||
|
latest_size_in_mb = get_file_size_in_mb(latest_path)
|
||||||
|
latest_duration_in_s = latest_ep[f"videos/{video_key}/to_timestamp"][0]
|
||||||
|
|
||||||
|
if latest_size_in_mb + ep_size_in_mb >= self._meta.video_files_size_in_mb:
|
||||||
|
chunk_idx, file_idx = update_chunk_file_indices(chunk_idx, file_idx, self._meta.chunks_size)
|
||||||
|
new_path = self._root / self._meta.video_path.format(
|
||||||
|
video_key=video_key, chunk_index=chunk_idx, file_index=file_idx
|
||||||
|
)
|
||||||
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(ep_path), str(new_path))
|
||||||
|
latest_duration_in_s = 0.0
|
||||||
|
else:
|
||||||
|
concatenate_video_files(
|
||||||
|
[latest_path, ep_path],
|
||||||
|
latest_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remove temporary directory
|
||||||
|
shutil.rmtree(str(ep_path.parent))
|
||||||
|
|
||||||
|
# Update video info (only needed when first episode is encoded)
|
||||||
|
if episode_index == 0:
|
||||||
|
self._meta.update_video_info(video_key)
|
||||||
|
write_info(self._meta.info, self._meta.root)
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"episode_index": episode_index,
|
||||||
|
f"videos/{video_key}/chunk_index": chunk_idx,
|
||||||
|
f"videos/{video_key}/file_index": file_idx,
|
||||||
|
f"videos/{video_key}/from_timestamp": latest_duration_in_s,
|
||||||
|
f"videos/{video_key}/to_timestamp": latest_duration_in_s + ep_duration_in_s,
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def clear_episode_buffer(self, delete_images: bool = True) -> None:
|
||||||
|
"""Discard the current episode buffer and optionally delete temp images.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
delete_images: If ``True``, remove temporary image directories
|
||||||
|
written for the current episode.
|
||||||
|
"""
|
||||||
|
# Cancel streaming encoder if active
|
||||||
|
if self._streaming_encoder is not None:
|
||||||
|
self._streaming_encoder.cancel_episode()
|
||||||
|
|
||||||
|
if delete_images:
|
||||||
|
if self.image_writer is not None:
|
||||||
|
self._wait_image_writer()
|
||||||
|
episode_index = self.episode_buffer["episode_index"]
|
||||||
|
# episode_index is `int` when freshly created, but becomes `np.ndarray` after
|
||||||
|
# save_episode() mutates the buffer. Handle both types here.
|
||||||
|
if isinstance(episode_index, np.ndarray):
|
||||||
|
episode_index = episode_index.item() if episode_index.size == 1 else episode_index[0]
|
||||||
|
for cam_key in self._meta.image_keys:
|
||||||
|
img_dir = self._get_image_file_dir(episode_index, cam_key)
|
||||||
|
if img_dir.is_dir():
|
||||||
|
shutil.rmtree(img_dir)
|
||||||
|
|
||||||
|
self.episode_buffer = self._create_episode_buffer()
|
||||||
|
|
||||||
|
def start_image_writer(self, num_processes: int = 0, num_threads: int = 4) -> None:
|
||||||
|
"""Start an :class:`AsyncImageWriter` for background image persistence.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
num_processes: Number of subprocesses. ``0`` means threads only.
|
||||||
|
num_threads: Number of threads per process.
|
||||||
|
"""
|
||||||
|
if isinstance(self.image_writer, AsyncImageWriter):
|
||||||
|
logger.warning(
|
||||||
|
"You are starting a new AsyncImageWriter that is replacing an already existing one in the dataset."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.image_writer = AsyncImageWriter(
|
||||||
|
num_processes=num_processes,
|
||||||
|
num_threads=num_threads,
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop_image_writer(self) -> None:
|
||||||
|
"""Stop the image writer (needed before pickling the dataset for DataLoader)."""
|
||||||
|
if self.image_writer is not None:
|
||||||
|
self.image_writer.stop()
|
||||||
|
self.image_writer = None
|
||||||
|
|
||||||
|
def _wait_image_writer(self) -> None:
|
||||||
|
"""Wait for asynchronous image writer to finish."""
|
||||||
|
if self.image_writer is not None:
|
||||||
|
self.image_writer.wait_until_done()
|
||||||
|
|
||||||
|
def _encode_temporary_episode_video(self, video_key: str, episode_index: int) -> Path:
|
||||||
|
"""Use ffmpeg to convert frames stored as png into mp4 videos."""
|
||||||
|
return _encode_video_worker(
|
||||||
|
video_key, episode_index, self._root, self._meta.fps, self._vcodec, self._encoder_threads
|
||||||
|
)
|
||||||
|
|
||||||
|
def close_writer(self) -> None:
|
||||||
|
"""Close and cleanup the parquet writer if it exists."""
|
||||||
|
if self._pq_writer is not None:
|
||||||
|
self._pq_writer.close()
|
||||||
|
self._pq_writer = None
|
||||||
|
|
||||||
|
def flush_pending_videos(self) -> None:
|
||||||
|
"""Flush any pending video encoding (streaming or batch).
|
||||||
|
|
||||||
|
For streaming encoding: closes the encoder.
|
||||||
|
For batch encoding: encodes any remaining episodes that haven't been batch-encoded yet.
|
||||||
|
"""
|
||||||
|
if self._streaming_encoder is not None:
|
||||||
|
self._streaming_encoder.close()
|
||||||
|
elif self._episodes_since_last_encoding > 0:
|
||||||
|
start_ep = self._meta.total_episodes - self._episodes_since_last_encoding
|
||||||
|
end_ep = self._meta.total_episodes
|
||||||
|
logger.info(
|
||||||
|
f"Encoding remaining {self._episodes_since_last_encoding} episodes, "
|
||||||
|
f"from episode {start_ep} to {end_ep - 1}"
|
||||||
|
)
|
||||||
|
self._batch_save_episode_video(start_ep, end_ep)
|
||||||
|
|
||||||
|
def cancel_pending_videos(self) -> None:
|
||||||
|
"""Cancel any in-progress streaming encoding without flushing."""
|
||||||
|
if self._streaming_encoder is not None:
|
||||||
|
self._streaming_encoder.cancel_episode()
|
||||||
|
|
||||||
|
def cleanup_interrupted_episode(self, episode_index: int) -> None:
|
||||||
|
"""Remove temporary image directories for an interrupted episode."""
|
||||||
|
for key in self._meta.video_keys:
|
||||||
|
img_dir = self._get_image_file_path(
|
||||||
|
episode_index=episode_index, image_key=key, frame_index=0
|
||||||
|
).parent
|
||||||
|
if img_dir.exists():
|
||||||
|
logger.debug(
|
||||||
|
f"Cleaning up interrupted episode images for episode {episode_index}, camera {key}"
|
||||||
|
)
|
||||||
|
shutil.rmtree(img_dir)
|
||||||
|
|
||||||
|
def finalize(self) -> None:
|
||||||
|
"""Flush all pending work and release all resources.
|
||||||
|
|
||||||
|
Idempotent — safe to call multiple times.
|
||||||
|
"""
|
||||||
|
if getattr(self, "_finalized", False):
|
||||||
|
return
|
||||||
|
# 1. Wait for async image writes to complete, then stop
|
||||||
|
if self.image_writer is not None:
|
||||||
|
self.image_writer.wait_until_done()
|
||||||
|
self.image_writer.stop()
|
||||||
|
self.image_writer = None
|
||||||
|
# 2. Flush pending video encoding (streaming or batch)
|
||||||
|
self.flush_pending_videos()
|
||||||
|
# 3. Close own parquet writer
|
||||||
|
self.close_writer()
|
||||||
|
# 4. Finalize metadata (idempotent)
|
||||||
|
self._meta.finalize()
|
||||||
|
self._finalized = True
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""Safety net: release resources on garbage collection."""
|
||||||
|
# During interpreter shutdown, referenced objects may already be collected.
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self.finalize()
|
||||||
@@ -32,10 +32,10 @@ def safe_stop_image_writer(func):
|
|||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
dataset = kwargs.get("dataset")
|
dataset = kwargs.get("dataset")
|
||||||
image_writer = getattr(dataset, "image_writer", None) if dataset else None
|
writer = getattr(dataset, "writer", None) if dataset else None
|
||||||
if image_writer is not None:
|
if writer is not None and writer.image_writer is not None:
|
||||||
logger.warning("Waiting for image writer to terminate...")
|
logger.warning("Waiting for image writer to terminate...")
|
||||||
image_writer.stop()
|
writer.image_writer.stop()
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ import torch
|
|||||||
import torch.utils
|
import torch.utils
|
||||||
|
|
||||||
from lerobot.datasets.compute_stats import aggregate_stats
|
from lerobot.datasets.compute_stats import aggregate_stats
|
||||||
|
from lerobot.datasets.feature_utils import get_hf_features_from_features
|
||||||
from lerobot.datasets.lerobot_dataset import LeRobotDataset
|
from lerobot.datasets.lerobot_dataset import LeRobotDataset
|
||||||
from lerobot.datasets.video_utils import VideoFrame
|
from lerobot.datasets.video_utils import VideoFrame
|
||||||
from lerobot.utils.constants import HF_LEROBOT_HOME
|
from lerobot.utils.constants import HF_LEROBOT_HOME
|
||||||
@@ -125,7 +126,13 @@ class MultiLeRobotDataset(torch.utils.data.Dataset):
|
|||||||
def features(self) -> datasets.Features:
|
def features(self) -> datasets.Features:
|
||||||
features = {}
|
features = {}
|
||||||
for dataset in self._datasets:
|
for dataset in self._datasets:
|
||||||
features.update({k: v for k, v in dataset.hf_features.items() if k not in self.disabled_features})
|
features.update(
|
||||||
|
{
|
||||||
|
k: v
|
||||||
|
for k, v in get_hf_features_from_features(dataset.features).items()
|
||||||
|
if k not in self.disabled_features
|
||||||
|
}
|
||||||
|
)
|
||||||
return features
|
return features
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -741,6 +741,7 @@ class StreamingVideoEncoder:
|
|||||||
self._video_paths: dict[str, Path] = {}
|
self._video_paths: dict[str, Path] = {}
|
||||||
self._dropped_frames: dict[str, int] = {}
|
self._dropped_frames: dict[str, int] = {}
|
||||||
self._episode_active = False
|
self._episode_active = False
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
def start_episode(self, video_keys: list[str], temp_dir: Path) -> None:
|
def start_episode(self, video_keys: list[str], temp_dir: Path) -> None:
|
||||||
"""Start encoder threads for a new episode.
|
"""Start encoder threads for a new episode.
|
||||||
@@ -895,8 +896,11 @@ class StreamingVideoEncoder:
|
|||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Close the encoder, canceling any in-progress episode."""
|
"""Close the encoder, canceling any in-progress episode."""
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
if self._episode_active:
|
if self._episode_active:
|
||||||
self.cancel_episode()
|
self.cancel_episode()
|
||||||
|
self._closed = True
|
||||||
|
|
||||||
def _cleanup(self) -> None:
|
def _cleanup(self) -> None:
|
||||||
"""Clean up queues and thread tracking dicts."""
|
"""Clean up queues and thread tracking dicts."""
|
||||||
@@ -1063,43 +1067,19 @@ class VideoEncodingManager:
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
streaming_encoder = getattr(self.dataset, "_streaming_encoder", None)
|
writer = self.dataset.writer
|
||||||
|
if writer is not None:
|
||||||
|
if exc_type is not None and writer._streaming_encoder is not None:
|
||||||
|
writer.cancel_pending_videos()
|
||||||
|
|
||||||
if streaming_encoder is not None:
|
# finalize() handles flush_pending_videos + parquet + metadata
|
||||||
# Handle streaming encoder cleanup
|
self.dataset.finalize()
|
||||||
if exc_type is not None:
|
|
||||||
streaming_encoder.cancel_episode()
|
|
||||||
streaming_encoder.close()
|
|
||||||
elif self.dataset.episodes_since_last_encoding > 0:
|
|
||||||
# Handle any remaining episodes that haven't been batch encoded
|
|
||||||
if exc_type is not None:
|
|
||||||
logger.info("Exception occurred. Encoding remaining episodes before exit...")
|
|
||||||
else:
|
|
||||||
logger.info("Recording stopped. Encoding remaining episodes...")
|
|
||||||
|
|
||||||
start_ep = self.dataset.num_episodes - self.dataset.episodes_since_last_encoding
|
# Clean up episode images if recording was interrupted (only for non-streaming mode)
|
||||||
end_ep = self.dataset.num_episodes
|
if exc_type is not None and writer._streaming_encoder is None:
|
||||||
logger.info(
|
writer.cleanup_interrupted_episode(self.dataset.num_episodes)
|
||||||
f"Encoding remaining {self.dataset.episodes_since_last_encoding} episodes, "
|
else:
|
||||||
f"from episode {start_ep} to {end_ep - 1}"
|
self.dataset.finalize()
|
||||||
)
|
|
||||||
self.dataset._batch_save_episode_video(start_ep, end_ep)
|
|
||||||
|
|
||||||
# Finalize the dataset to properly close all writers
|
|
||||||
self.dataset.finalize()
|
|
||||||
|
|
||||||
# Clean up episode images if recording was interrupted (only for non-streaming mode)
|
|
||||||
if exc_type is not None and streaming_encoder is None:
|
|
||||||
interrupted_episode_index = self.dataset.num_episodes
|
|
||||||
for key in self.dataset.meta.video_keys:
|
|
||||||
img_dir = self.dataset._get_image_file_path(
|
|
||||||
episode_index=interrupted_episode_index, image_key=key, frame_index=0
|
|
||||||
).parent
|
|
||||||
if img_dir.exists():
|
|
||||||
logger.debug(
|
|
||||||
f"Cleaning up interrupted episode images for episode {interrupted_episode_index}, camera {key}"
|
|
||||||
)
|
|
||||||
shutil.rmtree(img_dir)
|
|
||||||
|
|
||||||
# Clean up any remaining images directory if it's empty
|
# Clean up any remaining images directory if it's empty
|
||||||
img_dir = self.dataset.root / "images"
|
img_dir = self.dataset.root / "images"
|
||||||
|
|||||||
@@ -563,7 +563,7 @@ class ReplayBuffer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Start writing images if needed
|
# Start writing images if needed
|
||||||
lerobot_dataset.start_image_writer(num_processes=0, num_threads=3)
|
lerobot_dataset.writer.start_image_writer(num_processes=0, num_threads=3)
|
||||||
|
|
||||||
# Convert transitions into episodes and frames
|
# Convert transitions into episodes and frames
|
||||||
|
|
||||||
@@ -603,10 +603,10 @@ class ReplayBuffer:
|
|||||||
lerobot_dataset.save_episode()
|
lerobot_dataset.save_episode()
|
||||||
|
|
||||||
# Save any remaining frames in the buffer
|
# Save any remaining frames in the buffer
|
||||||
if lerobot_dataset.episode_buffer["size"] > 0:
|
if lerobot_dataset.has_pending_frames():
|
||||||
lerobot_dataset.save_episode()
|
lerobot_dataset.save_episode()
|
||||||
|
|
||||||
lerobot_dataset.stop_image_writer()
|
lerobot_dataset.writer.stop_image_writer()
|
||||||
lerobot_dataset.finalize()
|
lerobot_dataset.finalize()
|
||||||
|
|
||||||
return lerobot_dataset
|
return lerobot_dataset
|
||||||
|
|||||||
@@ -752,8 +752,7 @@ def replay_trajectory(
|
|||||||
episodes=[cfg.dataset.replay_episode],
|
episodes=[cfg.dataset.replay_episode],
|
||||||
download_videos=False,
|
download_videos=False,
|
||||||
)
|
)
|
||||||
episode_frames = dataset.hf_dataset.filter(lambda x: x["episode_index"] == cfg.dataset.replay_episode)
|
actions = dataset.select_columns(ACTION)
|
||||||
actions = episode_frames.select_columns(ACTION)
|
|
||||||
|
|
||||||
_, info = env.reset()
|
_, info = env.reset()
|
||||||
|
|
||||||
|
|||||||
@@ -468,7 +468,8 @@ def record(cfg: RecordConfig) -> LeRobotDataset:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
if cfg.resume:
|
if cfg.resume:
|
||||||
dataset = LeRobotDataset(
|
num_cameras = len(robot.cameras) if hasattr(robot, "cameras") else 0
|
||||||
|
dataset = LeRobotDataset.resume(
|
||||||
cfg.dataset.repo_id,
|
cfg.dataset.repo_id,
|
||||||
root=cfg.dataset.root,
|
root=cfg.dataset.root,
|
||||||
batch_encoding_size=cfg.dataset.video_encoding_batch_size,
|
batch_encoding_size=cfg.dataset.video_encoding_batch_size,
|
||||||
@@ -476,13 +477,11 @@ def record(cfg: RecordConfig) -> LeRobotDataset:
|
|||||||
streaming_encoding=cfg.dataset.streaming_encoding,
|
streaming_encoding=cfg.dataset.streaming_encoding,
|
||||||
encoder_queue_maxsize=cfg.dataset.encoder_queue_maxsize,
|
encoder_queue_maxsize=cfg.dataset.encoder_queue_maxsize,
|
||||||
encoder_threads=cfg.dataset.encoder_threads,
|
encoder_threads=cfg.dataset.encoder_threads,
|
||||||
|
image_writer_processes=cfg.dataset.num_image_writer_processes if num_cameras > 0 else 0,
|
||||||
|
image_writer_threads=cfg.dataset.num_image_writer_threads_per_camera * num_cameras
|
||||||
|
if num_cameras > 0
|
||||||
|
else 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
if hasattr(robot, "cameras") and len(robot.cameras) > 0:
|
|
||||||
dataset.start_image_writer(
|
|
||||||
num_processes=cfg.dataset.num_image_writer_processes,
|
|
||||||
num_threads=cfg.dataset.num_image_writer_threads_per_camera * len(robot.cameras),
|
|
||||||
)
|
|
||||||
sanity_check_dataset_robot_compatibility(dataset, robot, cfg.dataset.fps, dataset_features)
|
sanity_check_dataset_robot_compatibility(dataset, robot, cfg.dataset.fps, dataset_features)
|
||||||
else:
|
else:
|
||||||
# Create empty dataset or load existing saved episodes
|
# Create empty dataset or load existing saved episodes
|
||||||
|
|||||||
@@ -104,15 +104,13 @@ def replay(cfg: ReplayConfig):
|
|||||||
robot = make_robot_from_config(cfg.robot)
|
robot = make_robot_from_config(cfg.robot)
|
||||||
dataset = LeRobotDataset(cfg.dataset.repo_id, root=cfg.dataset.root, episodes=[cfg.dataset.episode])
|
dataset = LeRobotDataset(cfg.dataset.repo_id, root=cfg.dataset.root, episodes=[cfg.dataset.episode])
|
||||||
|
|
||||||
# Filter dataset to only include frames from the specified episode since episodes are chunked in dataset V3.0
|
actions = dataset.select_columns(ACTION)
|
||||||
episode_frames = dataset.hf_dataset.filter(lambda x: x["episode_index"] == cfg.dataset.episode)
|
|
||||||
actions = episode_frames.select_columns(ACTION)
|
|
||||||
|
|
||||||
robot.connect()
|
robot.connect()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
log_say("Replaying episode", cfg.play_sounds, blocking=True)
|
log_say("Replaying episode", cfg.play_sounds, blocking=True)
|
||||||
for idx in range(len(episode_frames)):
|
for idx in range(dataset.num_frames):
|
||||||
start_episode_t = time.perf_counter()
|
start_episode_t = time.perf_counter()
|
||||||
|
|
||||||
action_array = actions[idx][ACTION]
|
action_array = actions[idx][ACTION]
|
||||||
|
|||||||
@@ -204,15 +204,15 @@ def process_episode(args):
|
|||||||
|
|
||||||
for abs_idx in range(from_idx, to_idx):
|
for abs_idx in range(from_idx, to_idx):
|
||||||
# map absolute index to relative index if needed
|
# map absolute index to relative index if needed
|
||||||
if dataset._absolute_to_relative_idx is not None:
|
if dataset.reader._absolute_to_relative_idx is not None:
|
||||||
if abs_idx not in dataset._absolute_to_relative_idx:
|
if abs_idx not in dataset.reader._absolute_to_relative_idx:
|
||||||
# this episode's frames aren't in the filtered dataset
|
# this episode's frames aren't in the filtered dataset
|
||||||
return None
|
return None
|
||||||
rel_idx = dataset._absolute_to_relative_idx[abs_idx]
|
rel_idx = dataset.reader._absolute_to_relative_idx[abs_idx]
|
||||||
else:
|
else:
|
||||||
rel_idx = abs_idx
|
rel_idx = abs_idx
|
||||||
|
|
||||||
frame = dataset.hf_dataset[rel_idx]
|
frame = dataset.get_raw_item(rel_idx)
|
||||||
|
|
||||||
# get state (could be from observation.state or other state key)
|
# get state (could be from observation.state or other state key)
|
||||||
if state_key in frame:
|
if state_key in frame:
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def get_policy_stats(ds_repo_id: str, policy_name: str, policy_kwargs: dict):
|
|||||||
# HACK: We reload a batch with no delta_indices as `select_action` won't expect a timestamps dimension
|
# HACK: We reload a batch with no delta_indices as `select_action` won't expect a timestamps dimension
|
||||||
# We simulate having an environment using a dataset by setting delta_indices to None and dropping tensors
|
# We simulate having an environment using a dataset by setting delta_indices to None and dropping tensors
|
||||||
# indicating padding (those ending with "_is_pad")
|
# indicating padding (those ending with "_is_pad")
|
||||||
dataset.delta_indices = None
|
dataset.reader.delta_indices = None
|
||||||
batch = next(iter(dataloader))
|
batch = next(iter(dataloader))
|
||||||
obs = {}
|
obs = {}
|
||||||
for k in batch:
|
for k in batch:
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""Contract tests for LeRobotDatasetMetadata."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
|
||||||
|
from lerobot.datasets.utils import INFO_PATH
|
||||||
|
from tests.fixtures.constants import DEFAULT_FPS, DUMMY_ROBOT_TYPE
|
||||||
|
|
||||||
|
# ── helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
SIMPLE_FEATURES = {
|
||||||
|
"state": {"dtype": "float32", "shape": (6,), "names": None},
|
||||||
|
"action": {"dtype": "float32", "shape": (6,), "names": None},
|
||||||
|
}
|
||||||
|
|
||||||
|
VIDEO_FEATURES = {
|
||||||
|
**SIMPLE_FEATURES,
|
||||||
|
"observation.images.laptop": {
|
||||||
|
"dtype": "video",
|
||||||
|
"shape": (64, 96, 3),
|
||||||
|
"names": ["height", "width", "channels"],
|
||||||
|
"info": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
IMAGE_FEATURES = {
|
||||||
|
**SIMPLE_FEATURES,
|
||||||
|
"observation.images.laptop": {
|
||||||
|
"dtype": "image",
|
||||||
|
"shape": (64, 96, 3),
|
||||||
|
"names": ["height", "width", "channels"],
|
||||||
|
"info": None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_dummy_stats(features: dict) -> dict:
|
||||||
|
"""Create minimal episode stats matching the given features."""
|
||||||
|
stats = {}
|
||||||
|
for key, ft in features.items():
|
||||||
|
if ft["dtype"] in ("image", "video"):
|
||||||
|
stats[key] = {
|
||||||
|
"max": np.ones((3, 1, 1), dtype=np.float32),
|
||||||
|
"mean": np.full((3, 1, 1), 0.5, dtype=np.float32),
|
||||||
|
"min": np.zeros((3, 1, 1), dtype=np.float32),
|
||||||
|
"std": np.full((3, 1, 1), 0.25, dtype=np.float32),
|
||||||
|
"count": np.array([5]),
|
||||||
|
}
|
||||||
|
elif ft["dtype"] in ("float32", "float64", "int64"):
|
||||||
|
stats[key] = {
|
||||||
|
"max": np.ones(ft["shape"], dtype=np.float32),
|
||||||
|
"mean": np.full(ft["shape"], 0.5, dtype=np.float32),
|
||||||
|
"min": np.zeros(ft["shape"], dtype=np.float32),
|
||||||
|
"std": np.full(ft["shape"], 0.25, dtype=np.float32),
|
||||||
|
"count": np.array([5]),
|
||||||
|
}
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
# ── Construction contracts ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_produces_valid_info_on_disk(tmp_path):
|
||||||
|
"""create() writes info.json and the returned object reflects the provided settings."""
|
||||||
|
root = tmp_path / "new_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/meta",
|
||||||
|
fps=DEFAULT_FPS,
|
||||||
|
features=SIMPLE_FEATURES,
|
||||||
|
robot_type=DUMMY_ROBOT_TYPE,
|
||||||
|
root=root,
|
||||||
|
use_videos=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# info.json was written to disk
|
||||||
|
assert (root / INFO_PATH).exists()
|
||||||
|
with open(root / INFO_PATH) as f:
|
||||||
|
info_on_disk = json.load(f)
|
||||||
|
|
||||||
|
assert meta.fps == DEFAULT_FPS
|
||||||
|
assert meta.robot_type == DUMMY_ROBOT_TYPE
|
||||||
|
assert "state" in meta.features
|
||||||
|
assert "action" in meta.features
|
||||||
|
assert info_on_disk["fps"] == DEFAULT_FPS
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_starts_with_zero_counts(tmp_path):
|
||||||
|
"""A freshly created metadata has zero episode/frame/task counts."""
|
||||||
|
root = tmp_path / "empty_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/empty", fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert meta.total_episodes == 0
|
||||||
|
assert meta.total_frames == 0
|
||||||
|
assert meta.total_tasks == 0
|
||||||
|
assert meta.tasks is None
|
||||||
|
assert meta.episodes is None
|
||||||
|
assert meta.stats is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_with_videos_sets_video_path(tmp_path):
|
||||||
|
"""When features include video-dtype keys, create() produces a non-None video_path."""
|
||||||
|
root = tmp_path / "video_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/video", fps=DEFAULT_FPS, features=VIDEO_FEATURES, root=root, use_videos=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert meta.video_path is not None
|
||||||
|
assert len(meta.video_keys) == 1
|
||||||
|
assert "observation.images.laptop" in meta.video_keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_without_videos_has_no_video_path(tmp_path):
|
||||||
|
"""When use_videos=False and no video features, video_path is None."""
|
||||||
|
root = tmp_path / "no_video"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/novid", fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert meta.video_path is None
|
||||||
|
assert meta.video_keys == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_raises_on_existing_directory(tmp_path):
|
||||||
|
"""create() raises if root directory already exists."""
|
||||||
|
root = tmp_path / "existing"
|
||||||
|
root.mkdir()
|
||||||
|
|
||||||
|
with pytest.raises(FileExistsError):
|
||||||
|
LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/exists", fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_loads_existing_metadata(tmp_path, lerobot_dataset_metadata_factory, info_factory):
|
||||||
|
"""When metadata files exist on disk, __init__ loads them correctly."""
|
||||||
|
root = tmp_path / "load_test"
|
||||||
|
info = info_factory(total_episodes=3, total_frames=150, total_tasks=1, use_videos=False)
|
||||||
|
meta = lerobot_dataset_metadata_factory(root=root, info=info)
|
||||||
|
|
||||||
|
assert meta.total_episodes == 3
|
||||||
|
assert meta.total_frames == 150
|
||||||
|
assert meta.fps == info["fps"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Property accessors ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_property_accessors_reflect_info(tmp_path):
|
||||||
|
"""Properties return values consistent with the info dict."""
|
||||||
|
root = tmp_path / "props_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/props",
|
||||||
|
fps=DEFAULT_FPS,
|
||||||
|
features=IMAGE_FEATURES,
|
||||||
|
robot_type=DUMMY_ROBOT_TYPE,
|
||||||
|
root=root,
|
||||||
|
use_videos=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert meta.fps == DEFAULT_FPS
|
||||||
|
assert meta.robot_type == DUMMY_ROBOT_TYPE
|
||||||
|
# shapes should be tuples
|
||||||
|
for _key, shape in meta.shapes.items():
|
||||||
|
assert isinstance(shape, tuple)
|
||||||
|
# image_keys should contain the image feature
|
||||||
|
assert "observation.images.laptop" in meta.image_keys
|
||||||
|
# camera_keys is a superset of image_keys and video_keys
|
||||||
|
assert set(meta.image_keys + meta.video_keys) == set(meta.camera_keys)
|
||||||
|
|
||||||
|
|
||||||
|
def test_data_path_is_formattable(tmp_path):
|
||||||
|
"""data_path contains format placeholders that can be .format()-ed."""
|
||||||
|
root = tmp_path / "fmt_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/fmt", fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
|
||||||
|
formatted = meta.data_path.format(chunk_index=0, file_index=0)
|
||||||
|
assert "chunk" in formatted.lower() or "0" in formatted
|
||||||
|
|
||||||
|
|
||||||
|
# ── Task management ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_episode_tasks_creates_tasks_dataframe(tmp_path):
|
||||||
|
"""On a fresh metadata, save_episode_tasks() creates the tasks DataFrame."""
|
||||||
|
root = tmp_path / "task_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/task", fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
assert meta.tasks is None
|
||||||
|
|
||||||
|
meta.save_episode_tasks(["Pick up the cube"])
|
||||||
|
|
||||||
|
assert meta.tasks is not None
|
||||||
|
assert len(meta.tasks) == 1
|
||||||
|
assert "Pick up the cube" in meta.tasks.index
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_episode_tasks_is_additive(tmp_path):
|
||||||
|
"""New tasks are added; existing tasks keep their original index."""
|
||||||
|
root = tmp_path / "additive_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/add", fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
|
||||||
|
meta.save_episode_tasks(["Task A"])
|
||||||
|
idx_a = meta.get_task_index("Task A")
|
||||||
|
|
||||||
|
meta.save_episode_tasks(["Task A", "Task B"])
|
||||||
|
assert meta.get_task_index("Task A") == idx_a # unchanged
|
||||||
|
assert meta.get_task_index("Task B") is not None
|
||||||
|
assert len(meta.tasks) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_task_index_returns_none_for_unknown(tmp_path):
|
||||||
|
"""get_task_index() returns None for an unknown task."""
|
||||||
|
root = tmp_path / "unknown_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/unknown", fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
meta.save_episode_tasks(["Known task"])
|
||||||
|
|
||||||
|
assert meta.get_task_index("Known task") == 0
|
||||||
|
assert meta.get_task_index("Unknown task") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_episode_tasks_rejects_duplicates(tmp_path):
|
||||||
|
"""save_episode_tasks() raises ValueError on duplicate task strings."""
|
||||||
|
root = tmp_path / "dup_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/dup", fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
meta.save_episode_tasks(["Same task", "Same task"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Episode saving ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_episode_increments_counters(tmp_path):
|
||||||
|
"""After save_episode(), total_episodes and total_frames increase."""
|
||||||
|
root = tmp_path / "ep_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/ep", fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
meta.save_episode_tasks(["Task 1"])
|
||||||
|
stats = _make_dummy_stats(meta.features)
|
||||||
|
|
||||||
|
meta.save_episode(
|
||||||
|
episode_index=0,
|
||||||
|
episode_length=10,
|
||||||
|
episode_tasks=["Task 1"],
|
||||||
|
episode_stats=stats,
|
||||||
|
episode_metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert meta.total_episodes == 1
|
||||||
|
assert meta.total_frames == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_episode_updates_stats(tmp_path):
|
||||||
|
"""After save_episode(), .stats is non-None and has feature keys."""
|
||||||
|
root = tmp_path / "stats_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/stats", fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
meta.save_episode_tasks(["Task 1"])
|
||||||
|
stats = _make_dummy_stats(meta.features)
|
||||||
|
|
||||||
|
meta.save_episode(
|
||||||
|
episode_index=0,
|
||||||
|
episode_length=5,
|
||||||
|
episode_tasks=["Task 1"],
|
||||||
|
episode_stats=stats,
|
||||||
|
episode_metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert meta.stats is not None
|
||||||
|
# Stats should contain at least the user-defined feature keys
|
||||||
|
for key in SIMPLE_FEATURES:
|
||||||
|
assert key in meta.stats
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chunk settings ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_chunk_settings_persists(tmp_path):
|
||||||
|
"""update_chunk_settings() changes values and writes info.json."""
|
||||||
|
root = tmp_path / "chunk_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/chunk", fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
original = meta.get_chunk_settings()
|
||||||
|
|
||||||
|
meta.update_chunk_settings(chunks_size=500)
|
||||||
|
assert meta.chunks_size == 500
|
||||||
|
assert meta.chunks_size != original["chunks_size"] or original["chunks_size"] == 500
|
||||||
|
|
||||||
|
# Verify persisted
|
||||||
|
with open(root / INFO_PATH) as f:
|
||||||
|
info_on_disk = json.load(f)
|
||||||
|
assert info_on_disk["chunks_size"] == 500
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_chunk_settings_rejects_non_positive(tmp_path):
|
||||||
|
"""update_chunk_settings() raises ValueError for <= 0 values."""
|
||||||
|
root = tmp_path / "bad_chunk"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/bad", fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
meta.update_chunk_settings(chunks_size=0)
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
meta.update_chunk_settings(data_files_size_in_mb=-1)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Finalization ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_is_idempotent(tmp_path):
|
||||||
|
"""Calling finalize() multiple times does not raise."""
|
||||||
|
root = tmp_path / "fin_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/fin", fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
|
||||||
|
meta.finalize()
|
||||||
|
meta.finalize() # second call should not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_flushes_buffered_metadata(tmp_path):
|
||||||
|
"""Episodes saved before finalize() are written to parquet."""
|
||||||
|
root = tmp_path / "flush_ds"
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/flush",
|
||||||
|
fps=DEFAULT_FPS,
|
||||||
|
features=SIMPLE_FEATURES,
|
||||||
|
root=root,
|
||||||
|
use_videos=False,
|
||||||
|
metadata_buffer_size=100, # large buffer so nothing auto-flushes
|
||||||
|
)
|
||||||
|
meta.save_episode_tasks(["Task 1"])
|
||||||
|
stats = _make_dummy_stats(meta.features)
|
||||||
|
|
||||||
|
# Save a few episodes (won't auto-flush since buffer_size=100)
|
||||||
|
for i in range(3):
|
||||||
|
meta.save_episode(
|
||||||
|
episode_index=i,
|
||||||
|
episode_length=5,
|
||||||
|
episode_tasks=["Task 1"],
|
||||||
|
episode_stats=stats,
|
||||||
|
episode_metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Before finalize, the parquet might not exist yet
|
||||||
|
meta.finalize()
|
||||||
|
|
||||||
|
# After finalize, episodes parquet should exist
|
||||||
|
episodes_dir = root / "meta" / "episodes"
|
||||||
|
assert episodes_dir.exists()
|
||||||
|
parquet_files = list(episodes_dir.rglob("*.parquet"))
|
||||||
|
assert len(parquet_files) > 0
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""Contract tests for DatasetReader."""
|
||||||
|
|
||||||
|
from lerobot.datasets.dataset_reader import DatasetReader
|
||||||
|
from lerobot.datasets.video_utils import get_safe_default_codec
|
||||||
|
|
||||||
|
# ── Loading ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_load_returns_true_when_data_exists(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""Given a fully written dataset, try_load() returns True."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=2, total_frames=20, use_videos=False
|
||||||
|
)
|
||||||
|
reader = DatasetReader(
|
||||||
|
meta=dataset.meta,
|
||||||
|
root=dataset.root,
|
||||||
|
episodes=None,
|
||||||
|
tolerance_s=1e-4,
|
||||||
|
video_backend=get_safe_default_codec(),
|
||||||
|
delta_timestamps=None,
|
||||||
|
image_transforms=None,
|
||||||
|
)
|
||||||
|
assert reader.try_load() is True
|
||||||
|
assert reader.hf_dataset is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_load_returns_false_when_no_data(tmp_path):
|
||||||
|
"""When only metadata exists (no data/ parquets), try_load() returns False."""
|
||||||
|
from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata
|
||||||
|
|
||||||
|
root = tmp_path / "meta_only"
|
||||||
|
features = {"state": {"dtype": "float32", "shape": (2,), "names": None}}
|
||||||
|
meta = LeRobotDatasetMetadata.create(
|
||||||
|
repo_id="test/meta_only", fps=30, features=features, root=root, use_videos=False
|
||||||
|
)
|
||||||
|
|
||||||
|
reader = DatasetReader(
|
||||||
|
meta=meta,
|
||||||
|
root=meta.root,
|
||||||
|
episodes=None,
|
||||||
|
tolerance_s=1e-4,
|
||||||
|
video_backend=get_safe_default_codec(),
|
||||||
|
delta_timestamps=None,
|
||||||
|
image_transforms=None,
|
||||||
|
)
|
||||||
|
assert reader.try_load() is False
|
||||||
|
assert reader.hf_dataset is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Counts ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_num_frames_without_filter(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""With episodes=None, num_frames equals total_frames."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=3, total_frames=60, use_videos=False
|
||||||
|
)
|
||||||
|
assert dataset.reader.num_frames == dataset.meta.total_frames
|
||||||
|
|
||||||
|
|
||||||
|
def test_num_episodes_without_filter(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""With episodes=None, num_episodes equals total_episodes."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=3, total_frames=60, use_videos=False
|
||||||
|
)
|
||||||
|
assert dataset.reader.num_episodes == dataset.meta.total_episodes
|
||||||
|
|
||||||
|
|
||||||
|
def test_num_frames_with_episode_filter(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""When filtering to a subset, only those episodes' frames are counted."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=5, total_frames=100, episodes=[0, 2], use_videos=False
|
||||||
|
)
|
||||||
|
# Filtered frames should be less than total
|
||||||
|
assert dataset.reader.num_frames <= dataset.meta.total_frames
|
||||||
|
assert dataset.reader.num_episodes == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── get_item ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_item_returns_expected_keys(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""get_item(0) returns a dict with expected keys."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=1, total_frames=10, use_videos=False
|
||||||
|
)
|
||||||
|
item = dataset.reader.get_item(0)
|
||||||
|
|
||||||
|
# Standard keys that must always be present
|
||||||
|
for key in ["index", "episode_index", "frame_index", "timestamp", "task_index", "task"]:
|
||||||
|
assert key in item, f"Missing key: {key}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_item_values_are_correct(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""get_item() returns correct index and episode_index."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=2, total_frames=20, use_videos=False
|
||||||
|
)
|
||||||
|
item_0 = dataset.reader.get_item(0)
|
||||||
|
|
||||||
|
assert item_0["index"].item() == 0
|
||||||
|
assert item_0["episode_index"].item() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Transforms ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_transforms_are_applied(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""When image_transforms is provided, get_item() applies it to camera keys."""
|
||||||
|
transform_called = {"count": 0}
|
||||||
|
|
||||||
|
def sentinel_transform(img):
|
||||||
|
transform_called["count"] += 1
|
||||||
|
return img
|
||||||
|
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds",
|
||||||
|
total_episodes=1,
|
||||||
|
total_frames=5,
|
||||||
|
use_videos=False,
|
||||||
|
image_transforms=sentinel_transform,
|
||||||
|
)
|
||||||
|
item = dataset[0] # noqa: F841
|
||||||
|
|
||||||
|
# Should have been called once per camera key per frame
|
||||||
|
num_cameras = len(dataset.meta.camera_keys)
|
||||||
|
if num_cameras > 0:
|
||||||
|
assert transform_called["count"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── File paths ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_episodes_file_paths_returns_data_paths(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""get_episodes_file_paths() returns paths including data/ paths."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=2, total_frames=20, use_videos=False
|
||||||
|
)
|
||||||
|
paths = dataset.reader.get_episodes_file_paths()
|
||||||
|
|
||||||
|
assert len(paths) > 0
|
||||||
|
assert any("data/" in str(p) for p in paths)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_episodes_file_paths_includes_video_paths(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""When dataset has video keys, file paths include video/ paths."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=2, total_frames=20, use_videos=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(dataset.meta.video_keys) > 0:
|
||||||
|
paths = dataset.reader.get_episodes_file_paths()
|
||||||
|
assert any("video" in str(p).lower() for p in paths)
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""Contract tests for DatasetWriter."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from lerobot.datasets.dataset_writer import _encode_video_worker
|
||||||
|
from lerobot.datasets.lerobot_dataset import LeRobotDataset
|
||||||
|
from lerobot.datasets.utils import DEFAULT_IMAGE_PATH
|
||||||
|
from tests.fixtures.constants import DEFAULT_FPS, DUMMY_REPO_ID
|
||||||
|
|
||||||
|
SIMPLE_FEATURES = {
|
||||||
|
"state": {"dtype": "float32", "shape": (6,), "names": None},
|
||||||
|
"action": {"dtype": "float32", "shape": (6,), "names": None},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_frame(features: dict, task: str = "Dummy task") -> dict:
|
||||||
|
"""Build a valid frame dict for the given features."""
|
||||||
|
frame = {"task": task}
|
||||||
|
for key, ft in features.items():
|
||||||
|
if ft["dtype"] in ("image", "video"):
|
||||||
|
frame[key] = np.random.randint(0, 256, size=ft["shape"], dtype=np.uint8)
|
||||||
|
elif ft["dtype"] in ("float32", "float64"):
|
||||||
|
frame[key] = torch.randn(ft["shape"])
|
||||||
|
elif ft["dtype"] == "int64":
|
||||||
|
frame[key] = torch.zeros(ft["shape"], dtype=torch.int64)
|
||||||
|
return frame
|
||||||
|
|
||||||
|
|
||||||
|
# ── Existing encode_video_worker tests ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_video_worker_forwards_vcodec(tmp_path):
|
||||||
|
"""_encode_video_worker correctly forwards the vcodec parameter."""
|
||||||
|
video_key = "observation.images.laptop"
|
||||||
|
fpath = DEFAULT_IMAGE_PATH.format(image_key=video_key, episode_index=0, frame_index=0)
|
||||||
|
img_dir = tmp_path / Path(fpath).parent
|
||||||
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
Image.new("RGB", (64, 64), color="red").save(img_dir / "frame-000000.png")
|
||||||
|
|
||||||
|
captured_kwargs = {}
|
||||||
|
|
||||||
|
def mock_encode(imgs_dir, video_path, fps, **kwargs):
|
||||||
|
captured_kwargs.update(kwargs)
|
||||||
|
Path(video_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Path(video_path).touch()
|
||||||
|
|
||||||
|
with patch("lerobot.datasets.dataset_writer.encode_video_frames", side_effect=mock_encode):
|
||||||
|
_encode_video_worker(video_key, 0, tmp_path, fps=30, vcodec="h264")
|
||||||
|
|
||||||
|
assert captured_kwargs["vcodec"] == "h264"
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_video_worker_default_vcodec(tmp_path):
|
||||||
|
"""_encode_video_worker uses libsvtav1 as the default codec."""
|
||||||
|
video_key = "observation.images.laptop"
|
||||||
|
fpath = DEFAULT_IMAGE_PATH.format(image_key=video_key, episode_index=0, frame_index=0)
|
||||||
|
img_dir = tmp_path / Path(fpath).parent
|
||||||
|
img_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
Image.new("RGB", (64, 64), color="red").save(img_dir / "frame-000000.png")
|
||||||
|
|
||||||
|
captured_kwargs = {}
|
||||||
|
|
||||||
|
def mock_encode(imgs_dir, video_path, fps, **kwargs):
|
||||||
|
captured_kwargs.update(kwargs)
|
||||||
|
Path(video_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Path(video_path).touch()
|
||||||
|
|
||||||
|
with patch("lerobot.datasets.dataset_writer.encode_video_frames", side_effect=mock_encode):
|
||||||
|
_encode_video_worker(video_key, 0, tmp_path, fps=30)
|
||||||
|
|
||||||
|
assert captured_kwargs["vcodec"] == "libsvtav1"
|
||||||
|
|
||||||
|
|
||||||
|
# ── add_frame contracts ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_frame_increments_buffer_size(tmp_path):
|
||||||
|
"""Each add_frame() call increases episode_buffer['size'] by 1."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
assert dataset.writer.episode_buffer["size"] == 0
|
||||||
|
|
||||||
|
dataset.add_frame(_make_frame(SIMPLE_FEATURES))
|
||||||
|
assert dataset.writer.episode_buffer["size"] == 1
|
||||||
|
|
||||||
|
dataset.add_frame(_make_frame(SIMPLE_FEATURES))
|
||||||
|
assert dataset.writer.episode_buffer["size"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_frame_rejects_missing_feature(tmp_path):
|
||||||
|
"""add_frame() raises ValueError when a required feature is missing."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
with pytest.raises(ValueError, match="Missing features"):
|
||||||
|
dataset.add_frame({"task": "Dummy task", "state": torch.randn(6)})
|
||||||
|
# missing 'action'
|
||||||
|
|
||||||
|
|
||||||
|
# ── save_episode contracts ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_episode_writes_parquet(tmp_path):
|
||||||
|
"""After save_episode(), at least one .parquet file exists under data/."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
for _ in range(3):
|
||||||
|
dataset.add_frame(_make_frame(SIMPLE_FEATURES))
|
||||||
|
dataset.save_episode()
|
||||||
|
|
||||||
|
parquet_files = list((tmp_path / "ds" / "data").rglob("*.parquet"))
|
||||||
|
assert len(parquet_files) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_episode_updates_counters(tmp_path):
|
||||||
|
"""After save_episode(), metadata counters are updated."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
for _ in range(5):
|
||||||
|
dataset.add_frame(_make_frame(SIMPLE_FEATURES))
|
||||||
|
dataset.save_episode()
|
||||||
|
|
||||||
|
assert dataset.meta.total_episodes == 1
|
||||||
|
assert dataset.meta.total_frames == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_episode_resets_buffer(tmp_path):
|
||||||
|
"""After save_episode(), the episode buffer is reset."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
for _ in range(3):
|
||||||
|
dataset.add_frame(_make_frame(SIMPLE_FEATURES))
|
||||||
|
dataset.save_episode()
|
||||||
|
|
||||||
|
assert dataset.writer.episode_buffer["size"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_multiple_episodes(tmp_path):
|
||||||
|
"""Recording 3 episodes results in correct total counts."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
total_frames = 0
|
||||||
|
for ep in range(3):
|
||||||
|
n_frames = ep + 2 # 2, 3, 4
|
||||||
|
for _ in range(n_frames):
|
||||||
|
dataset.add_frame(_make_frame(SIMPLE_FEATURES))
|
||||||
|
dataset.save_episode()
|
||||||
|
total_frames += n_frames
|
||||||
|
|
||||||
|
assert dataset.meta.total_episodes == 3
|
||||||
|
assert dataset.meta.total_frames == total_frames
|
||||||
|
|
||||||
|
|
||||||
|
# ── clear / lifecycle ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_resets_buffer(tmp_path):
|
||||||
|
"""clear_episode_buffer() resets the buffer size to 0."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
dataset.add_frame(_make_frame(SIMPLE_FEATURES))
|
||||||
|
assert dataset.writer.episode_buffer["size"] == 1
|
||||||
|
|
||||||
|
dataset.clear_episode_buffer()
|
||||||
|
assert dataset.writer.episode_buffer["size"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_is_idempotent(tmp_path):
|
||||||
|
"""Calling finalize() twice does not raise."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
for _ in range(3):
|
||||||
|
dataset.add_frame(_make_frame(SIMPLE_FEATURES))
|
||||||
|
dataset.save_episode()
|
||||||
|
|
||||||
|
dataset.finalize()
|
||||||
|
dataset.finalize() # second call should not raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_then_read_roundtrip(tmp_path):
|
||||||
|
"""Write data, finalize, re-open, and verify data matches."""
|
||||||
|
root = tmp_path / "roundtrip"
|
||||||
|
features = {"state": {"dtype": "float32", "shape": (2,), "names": None}}
|
||||||
|
dataset = LeRobotDataset.create(repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=features, root=root)
|
||||||
|
|
||||||
|
# Record known values
|
||||||
|
known_states = []
|
||||||
|
for i in range(5):
|
||||||
|
state = torch.tensor([float(i), float(i * 10)])
|
||||||
|
known_states.append(state)
|
||||||
|
dataset.add_frame({"task": "Test task", "state": state})
|
||||||
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
|
# Read back
|
||||||
|
for i in range(5):
|
||||||
|
item = dataset[i]
|
||||||
|
assert torch.allclose(item["state"], known_states[i], atol=1e-5)
|
||||||
+52
-118
@@ -32,10 +32,7 @@ from lerobot.datasets.factory import make_dataset
|
|||||||
from lerobot.datasets.feature_utils import get_hf_features_from_features, hw_to_dataset_features
|
from lerobot.datasets.feature_utils import get_hf_features_from_features, hw_to_dataset_features
|
||||||
from lerobot.datasets.image_writer import image_array_to_pil_image
|
from lerobot.datasets.image_writer import image_array_to_pil_image
|
||||||
from lerobot.datasets.io_utils import hf_transform_to_torch
|
from lerobot.datasets.io_utils import hf_transform_to_torch
|
||||||
from lerobot.datasets.lerobot_dataset import (
|
from lerobot.datasets.lerobot_dataset import LeRobotDataset
|
||||||
LeRobotDataset,
|
|
||||||
_encode_video_worker,
|
|
||||||
)
|
|
||||||
from lerobot.datasets.multi_dataset import MultiLeRobotDataset
|
from lerobot.datasets.multi_dataset import MultiLeRobotDataset
|
||||||
from lerobot.datasets.utils import (
|
from lerobot.datasets.utils import (
|
||||||
DEFAULT_CHUNK_SIZE,
|
DEFAULT_CHUNK_SIZE,
|
||||||
@@ -72,7 +69,7 @@ def image_dataset(tmp_path, empty_lerobot_dataset_factory):
|
|||||||
def test_same_attributes_defined(tmp_path, lerobot_dataset_factory):
|
def test_same_attributes_defined(tmp_path, lerobot_dataset_factory):
|
||||||
"""
|
"""
|
||||||
Instantiate a LeRobotDataset both ways with '__init__()' and 'create()' and verify that instantiated
|
Instantiate a LeRobotDataset both ways with '__init__()' and 'create()' and verify that instantiated
|
||||||
objects have the same sets of attributes defined.
|
objects have the same sets of facade-level attributes defined.
|
||||||
"""
|
"""
|
||||||
# Instantiate both ways
|
# Instantiate both ways
|
||||||
robot = make_robot_from_config(MockRobotConfig())
|
robot = make_robot_from_config(MockRobotConfig())
|
||||||
@@ -87,6 +84,7 @@ def test_same_attributes_defined(tmp_path, lerobot_dataset_factory):
|
|||||||
root_init = tmp_path / "init"
|
root_init = tmp_path / "init"
|
||||||
dataset_init = lerobot_dataset_factory(root=root_init, total_episodes=1, total_frames=1)
|
dataset_init = lerobot_dataset_factory(root=root_init, total_episodes=1, total_frames=1)
|
||||||
|
|
||||||
|
# Facade-level attributes should match between __init__ and create()
|
||||||
init_attr = set(vars(dataset_init).keys())
|
init_attr = set(vars(dataset_init).keys())
|
||||||
create_attr = set(vars(dataset_create).keys())
|
create_attr = set(vars(dataset_create).keys())
|
||||||
|
|
||||||
@@ -214,6 +212,7 @@ def test_add_frame(tmp_path, empty_lerobot_dataset_factory):
|
|||||||
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
||||||
dataset.add_frame({"state": torch.randn(1), "task": "Dummy task"})
|
dataset.add_frame({"state": torch.randn(1), "task": "Dummy task"})
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
assert len(dataset) == 1
|
assert len(dataset) == 1
|
||||||
assert dataset[0]["task"] == "Dummy task"
|
assert dataset[0]["task"] == "Dummy task"
|
||||||
@@ -226,6 +225,7 @@ def test_add_frame_state_1d(tmp_path, empty_lerobot_dataset_factory):
|
|||||||
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
||||||
dataset.add_frame({"state": torch.randn(2), "task": "Dummy task"})
|
dataset.add_frame({"state": torch.randn(2), "task": "Dummy task"})
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
assert dataset[0]["state"].shape == torch.Size([2])
|
assert dataset[0]["state"].shape == torch.Size([2])
|
||||||
|
|
||||||
@@ -235,6 +235,7 @@ def test_add_frame_state_2d(tmp_path, empty_lerobot_dataset_factory):
|
|||||||
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
||||||
dataset.add_frame({"state": torch.randn(2, 4), "task": "Dummy task"})
|
dataset.add_frame({"state": torch.randn(2, 4), "task": "Dummy task"})
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
assert dataset[0]["state"].shape == torch.Size([2, 4])
|
assert dataset[0]["state"].shape == torch.Size([2, 4])
|
||||||
|
|
||||||
@@ -244,6 +245,7 @@ def test_add_frame_state_3d(tmp_path, empty_lerobot_dataset_factory):
|
|||||||
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
||||||
dataset.add_frame({"state": torch.randn(2, 4, 3), "task": "Dummy task"})
|
dataset.add_frame({"state": torch.randn(2, 4, 3), "task": "Dummy task"})
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
assert dataset[0]["state"].shape == torch.Size([2, 4, 3])
|
assert dataset[0]["state"].shape == torch.Size([2, 4, 3])
|
||||||
|
|
||||||
@@ -253,6 +255,7 @@ def test_add_frame_state_4d(tmp_path, empty_lerobot_dataset_factory):
|
|||||||
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
||||||
dataset.add_frame({"state": torch.randn(2, 4, 3, 5), "task": "Dummy task"})
|
dataset.add_frame({"state": torch.randn(2, 4, 3, 5), "task": "Dummy task"})
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
assert dataset[0]["state"].shape == torch.Size([2, 4, 3, 5])
|
assert dataset[0]["state"].shape == torch.Size([2, 4, 3, 5])
|
||||||
|
|
||||||
@@ -262,6 +265,7 @@ def test_add_frame_state_5d(tmp_path, empty_lerobot_dataset_factory):
|
|||||||
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
||||||
dataset.add_frame({"state": torch.randn(2, 4, 3, 5, 1), "task": "Dummy task"})
|
dataset.add_frame({"state": torch.randn(2, 4, 3, 5, 1), "task": "Dummy task"})
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
assert dataset[0]["state"].shape == torch.Size([2, 4, 3, 5, 1])
|
assert dataset[0]["state"].shape == torch.Size([2, 4, 3, 5, 1])
|
||||||
|
|
||||||
@@ -271,6 +275,7 @@ def test_add_frame_state_numpy(tmp_path, empty_lerobot_dataset_factory):
|
|||||||
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
||||||
dataset.add_frame({"state": np.array([1], dtype=np.float32), "task": "Dummy task"})
|
dataset.add_frame({"state": np.array([1], dtype=np.float32), "task": "Dummy task"})
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
assert dataset[0]["state"].ndim == 0
|
assert dataset[0]["state"].ndim == 0
|
||||||
|
|
||||||
@@ -280,6 +285,7 @@ def test_add_frame_string(tmp_path, empty_lerobot_dataset_factory):
|
|||||||
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features)
|
||||||
dataset.add_frame({"caption": "Dummy caption", "task": "Dummy task"})
|
dataset.add_frame({"caption": "Dummy caption", "task": "Dummy task"})
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
assert dataset[0]["caption"] == "Dummy caption"
|
assert dataset[0]["caption"] == "Dummy caption"
|
||||||
|
|
||||||
@@ -315,6 +321,7 @@ def test_add_frame_image(image_dataset):
|
|||||||
dataset = image_dataset
|
dataset = image_dataset
|
||||||
dataset.add_frame({"image": np.random.rand(*DUMMY_CHW), "task": "Dummy task"})
|
dataset.add_frame({"image": np.random.rand(*DUMMY_CHW), "task": "Dummy task"})
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
assert dataset[0]["image"].shape == torch.Size(DUMMY_CHW)
|
assert dataset[0]["image"].shape == torch.Size(DUMMY_CHW)
|
||||||
|
|
||||||
@@ -323,6 +330,7 @@ def test_add_frame_image_h_w_c(image_dataset):
|
|||||||
dataset = image_dataset
|
dataset = image_dataset
|
||||||
dataset.add_frame({"image": np.random.rand(*DUMMY_HWC), "task": "Dummy task"})
|
dataset.add_frame({"image": np.random.rand(*DUMMY_HWC), "task": "Dummy task"})
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
assert dataset[0]["image"].shape == torch.Size(DUMMY_CHW)
|
assert dataset[0]["image"].shape == torch.Size(DUMMY_CHW)
|
||||||
|
|
||||||
@@ -332,6 +340,7 @@ def test_add_frame_image_uint8(image_dataset):
|
|||||||
image = np.random.randint(0, 256, DUMMY_HWC, dtype=np.uint8)
|
image = np.random.randint(0, 256, DUMMY_HWC, dtype=np.uint8)
|
||||||
dataset.add_frame({"image": image, "task": "Dummy task"})
|
dataset.add_frame({"image": image, "task": "Dummy task"})
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
assert dataset[0]["image"].shape == torch.Size(DUMMY_CHW)
|
assert dataset[0]["image"].shape == torch.Size(DUMMY_CHW)
|
||||||
|
|
||||||
@@ -341,6 +350,7 @@ def test_add_frame_image_pil(image_dataset):
|
|||||||
image = np.random.randint(0, 256, DUMMY_HWC, dtype=np.uint8)
|
image = np.random.randint(0, 256, DUMMY_HWC, dtype=np.uint8)
|
||||||
dataset.add_frame({"image": Image.fromarray(image), "task": "Dummy task"})
|
dataset.add_frame({"image": Image.fromarray(image), "task": "Dummy task"})
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
assert dataset[0]["image"].shape == torch.Size(DUMMY_CHW)
|
assert dataset[0]["image"].shape == torch.Size(DUMMY_CHW)
|
||||||
|
|
||||||
@@ -361,7 +371,7 @@ def test_tmp_image_deletion(tmp_path, empty_lerobot_dataset_factory):
|
|||||||
ds_img = empty_lerobot_dataset_factory(root=tmp_path / "img", features=features_image)
|
ds_img = empty_lerobot_dataset_factory(root=tmp_path / "img", features=features_image)
|
||||||
ds_img.add_frame({"image": np.random.rand(*DUMMY_CHW), "task": "Dummy task"})
|
ds_img.add_frame({"image": np.random.rand(*DUMMY_CHW), "task": "Dummy task"})
|
||||||
ds_img.save_episode()
|
ds_img.save_episode()
|
||||||
img_dir = ds_img._get_image_file_dir(0, image_key)
|
img_dir = ds_img.writer._get_image_file_dir(0, image_key)
|
||||||
assert not img_dir.exists(), "Temporary image directory should be removed for image features"
|
assert not img_dir.exists(), "Temporary image directory should be removed for image features"
|
||||||
|
|
||||||
|
|
||||||
@@ -374,10 +384,10 @@ def test_tmp_video_deletion(tmp_path, empty_lerobot_dataset_factory):
|
|||||||
}
|
}
|
||||||
|
|
||||||
ds_vid = empty_lerobot_dataset_factory(root=tmp_path / "vid", features=features_video)
|
ds_vid = empty_lerobot_dataset_factory(root=tmp_path / "vid", features=features_video)
|
||||||
ds_vid.batch_encoding_size = 1
|
ds_vid.writer._batch_encoding_size = 1
|
||||||
ds_vid.add_frame({vid_key: np.random.rand(*DUMMY_CHW), "task": "Dummy task"})
|
ds_vid.add_frame({vid_key: np.random.rand(*DUMMY_CHW), "task": "Dummy task"})
|
||||||
ds_vid.save_episode()
|
ds_vid.save_episode()
|
||||||
vid_img_dir = ds_vid._get_image_file_dir(0, vid_key)
|
vid_img_dir = ds_vid.writer._get_image_file_dir(0, vid_key)
|
||||||
assert not vid_img_dir.exists(), (
|
assert not vid_img_dir.exists(), (
|
||||||
"Temporary image directory should be removed when batch_encoding_size == 1"
|
"Temporary image directory should be removed when batch_encoding_size == 1"
|
||||||
)
|
)
|
||||||
@@ -402,8 +412,8 @@ def test_tmp_mixed_deletion(tmp_path, empty_lerobot_dataset_factory):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
ds_mixed.save_episode()
|
ds_mixed.save_episode()
|
||||||
img_dir = ds_mixed._get_image_file_dir(0, image_key)
|
img_dir = ds_mixed.writer._get_image_file_dir(0, image_key)
|
||||||
vid_img_dir = ds_mixed._get_image_file_dir(0, vid_key)
|
vid_img_dir = ds_mixed.writer._get_image_file_dir(0, vid_key)
|
||||||
assert not img_dir.exists(), "Temporary image directory should be removed for image features"
|
assert not img_dir.exists(), "Temporary image directory should be removed for image features"
|
||||||
assert vid_img_dir.exists(), (
|
assert vid_img_dir.exists(), (
|
||||||
"Temporary image directory should not be removed for video features when batch_encoding_size == 2"
|
"Temporary image directory should not be removed for video features when batch_encoding_size == 2"
|
||||||
@@ -631,29 +641,29 @@ def test_check_cached_episodes_sufficient(tmp_path, lerobot_dataset_factory):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Test hf_dataset is None
|
# Test hf_dataset is None
|
||||||
dataset.hf_dataset = None
|
dataset.reader.hf_dataset = None
|
||||||
assert dataset._check_cached_episodes_sufficient() is False
|
assert dataset.reader._check_cached_episodes_sufficient() is False
|
||||||
|
|
||||||
# Test hf_dataset is empty
|
# Test hf_dataset is empty
|
||||||
import datasets
|
import datasets
|
||||||
|
|
||||||
empty_features = get_hf_features_from_features(dataset.features)
|
empty_features = get_hf_features_from_features(dataset.features)
|
||||||
dataset.hf_dataset = datasets.Dataset.from_dict(
|
dataset.reader.hf_dataset = datasets.Dataset.from_dict(
|
||||||
{key: [] for key in empty_features}, features=empty_features
|
{key: [] for key in empty_features}, features=empty_features
|
||||||
)
|
)
|
||||||
dataset.hf_dataset.set_transform(hf_transform_to_torch)
|
dataset.reader.hf_dataset.set_transform(hf_transform_to_torch)
|
||||||
assert dataset._check_cached_episodes_sufficient() is False
|
assert dataset.reader._check_cached_episodes_sufficient() is False
|
||||||
|
|
||||||
# Restore the original dataset for remaining tests
|
# Restore the original dataset for remaining tests
|
||||||
dataset.hf_dataset = dataset.load_hf_dataset()
|
dataset.reader.hf_dataset = dataset.reader._load_hf_dataset()
|
||||||
|
|
||||||
# Test all episodes requested (self.episodes = None) and all are available
|
# Test all episodes requested (self.episodes = None) and all are available
|
||||||
dataset.episodes = None
|
dataset.reader.episodes = None
|
||||||
assert dataset._check_cached_episodes_sufficient() is True
|
assert dataset.reader._check_cached_episodes_sufficient() is True
|
||||||
|
|
||||||
# Test specific episodes requested that are all available
|
# Test specific episodes requested that are all available
|
||||||
dataset.episodes = [0, 2, 4]
|
dataset.reader.episodes = [0, 2, 4]
|
||||||
assert dataset._check_cached_episodes_sufficient() is True
|
assert dataset.reader._check_cached_episodes_sufficient() is True
|
||||||
|
|
||||||
# Test request episodes that don't exist in the cached dataset
|
# Test request episodes that don't exist in the cached dataset
|
||||||
# Create a dataset with only episodes 0, 1, 2
|
# Create a dataset with only episodes 0, 1, 2
|
||||||
@@ -665,8 +675,8 @@ def test_check_cached_episodes_sufficient(tmp_path, lerobot_dataset_factory):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Request episodes that include non-existent ones
|
# Request episodes that include non-existent ones
|
||||||
limited_dataset.episodes = [0, 1, 2, 3, 4]
|
limited_dataset.reader.episodes = [0, 1, 2, 3, 4]
|
||||||
assert limited_dataset._check_cached_episodes_sufficient() is False
|
assert limited_dataset.reader._check_cached_episodes_sufficient() is False
|
||||||
|
|
||||||
# Test create a dataset with sparse episodes (e.g., only episodes 0, 2, 4)
|
# Test create a dataset with sparse episodes (e.g., only episodes 0, 2, 4)
|
||||||
# First create the full dataset structure
|
# First create the full dataset structure
|
||||||
@@ -702,22 +712,22 @@ def test_check_cached_episodes_sufficient(tmp_path, lerobot_dataset_factory):
|
|||||||
|
|
||||||
filtered_data[key] = filtered_values
|
filtered_data[key] = filtered_values
|
||||||
|
|
||||||
sparse_dataset.hf_dataset = datasets.Dataset.from_dict(
|
sparse_dataset.reader.hf_dataset = datasets.Dataset.from_dict(
|
||||||
filtered_data, features=get_hf_features_from_features(sparse_dataset.features)
|
filtered_data, features=get_hf_features_from_features(sparse_dataset.features)
|
||||||
)
|
)
|
||||||
sparse_dataset.hf_dataset.set_transform(hf_transform_to_torch)
|
sparse_dataset.reader.hf_dataset.set_transform(hf_transform_to_torch)
|
||||||
|
|
||||||
# Test requesting all episodes when only some are cached
|
# Test requesting all episodes when only some are cached
|
||||||
sparse_dataset.episodes = None
|
sparse_dataset.reader.episodes = None
|
||||||
assert sparse_dataset._check_cached_episodes_sufficient() is False
|
assert sparse_dataset.reader._check_cached_episodes_sufficient() is False
|
||||||
|
|
||||||
# Test requesting only the available episodes
|
# Test requesting only the available episodes
|
||||||
sparse_dataset.episodes = [0, 2, 4]
|
sparse_dataset.reader.episodes = [0, 2, 4]
|
||||||
assert sparse_dataset._check_cached_episodes_sufficient() is True
|
assert sparse_dataset.reader._check_cached_episodes_sufficient() is True
|
||||||
|
|
||||||
# Test requesting a mix of available and unavailable episodes
|
# Test requesting a mix of available and unavailable episodes
|
||||||
sparse_dataset.episodes = [0, 1, 2]
|
sparse_dataset.reader.episodes = [0, 1, 2]
|
||||||
assert sparse_dataset._check_cached_episodes_sufficient() is False
|
assert sparse_dataset.reader._check_cached_episodes_sufficient() is False
|
||||||
|
|
||||||
|
|
||||||
def test_update_chunk_settings(tmp_path, empty_lerobot_dataset_factory):
|
def test_update_chunk_settings(tmp_path, empty_lerobot_dataset_factory):
|
||||||
@@ -1189,13 +1199,13 @@ def test_dataset_resume_recording(tmp_path, empty_lerobot_dataset_factory):
|
|||||||
del dataset_verify
|
del dataset_verify
|
||||||
|
|
||||||
# Phase 3: Resume recording - add more episodes
|
# Phase 3: Resume recording - add more episodes
|
||||||
dataset_resumed = LeRobotDataset(initial_repo_id, root=initial_root, revision="v3.0")
|
dataset_resumed = LeRobotDataset.resume(initial_repo_id, root=initial_root, revision="v3.0")
|
||||||
|
|
||||||
assert dataset_resumed.meta.total_episodes == initial_episodes
|
assert dataset_resumed.meta.total_episodes == initial_episodes
|
||||||
assert dataset_resumed.meta.total_frames == initial_episodes * frames_per_episode
|
assert dataset_resumed.meta.total_frames == initial_episodes * frames_per_episode
|
||||||
assert dataset_resumed.latest_episode is None # Not recording yet
|
assert dataset_resumed.writer._latest_episode is None # Not recording yet
|
||||||
assert dataset_resumed.writer is None
|
assert dataset_resumed.writer._pq_writer is None
|
||||||
assert dataset_resumed.meta.writer is None
|
assert dataset_resumed.meta._pq_writer is None
|
||||||
|
|
||||||
additional_episodes = 2
|
additional_episodes = 2
|
||||||
for ep_idx in range(initial_episodes, initial_episodes + additional_episodes):
|
for ep_idx in range(initial_episodes, initial_episodes + additional_episodes):
|
||||||
@@ -1271,7 +1281,7 @@ def test_frames_in_current_file_calculation(tmp_path, empty_lerobot_dataset_fact
|
|||||||
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features, use_videos=False)
|
dataset = empty_lerobot_dataset_factory(root=tmp_path / "test", features=features, use_videos=False)
|
||||||
dataset.meta.update_chunk_settings(data_files_size_in_mb=100)
|
dataset.meta.update_chunk_settings(data_files_size_in_mb=100)
|
||||||
|
|
||||||
assert dataset._current_file_start_frame is None
|
assert dataset.writer._current_file_start_frame is None
|
||||||
|
|
||||||
frames_per_episode = 10
|
frames_per_episode = 10
|
||||||
for _ in range(frames_per_episode):
|
for _ in range(frames_per_episode):
|
||||||
@@ -1284,7 +1294,7 @@ def test_frames_in_current_file_calculation(tmp_path, empty_lerobot_dataset_fact
|
|||||||
)
|
)
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
|
||||||
assert dataset._current_file_start_frame == 0
|
assert dataset.writer._current_file_start_frame == 0
|
||||||
assert dataset.meta.total_episodes == 1
|
assert dataset.meta.total_episodes == 1
|
||||||
assert dataset.meta.total_frames == frames_per_episode
|
assert dataset.meta.total_frames == frames_per_episode
|
||||||
|
|
||||||
@@ -1298,12 +1308,12 @@ def test_frames_in_current_file_calculation(tmp_path, empty_lerobot_dataset_fact
|
|||||||
)
|
)
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
|
||||||
assert dataset._current_file_start_frame == 0
|
assert dataset.writer._current_file_start_frame == 0
|
||||||
assert dataset.meta.total_episodes == 2
|
assert dataset.meta.total_episodes == 2
|
||||||
assert dataset.meta.total_frames == 2 * frames_per_episode
|
assert dataset.meta.total_frames == 2 * frames_per_episode
|
||||||
|
|
||||||
ep1_chunk = dataset.latest_episode["data/chunk_index"]
|
ep1_chunk = dataset.writer._latest_episode["data/chunk_index"]
|
||||||
ep1_file = dataset.latest_episode["data/file_index"]
|
ep1_file = dataset.writer._latest_episode["data/file_index"]
|
||||||
assert ep1_chunk == 0
|
assert ep1_chunk == 0
|
||||||
assert ep1_file == 0
|
assert ep1_file == 0
|
||||||
|
|
||||||
@@ -1317,12 +1327,12 @@ def test_frames_in_current_file_calculation(tmp_path, empty_lerobot_dataset_fact
|
|||||||
)
|
)
|
||||||
dataset.save_episode()
|
dataset.save_episode()
|
||||||
|
|
||||||
assert dataset._current_file_start_frame == 0
|
assert dataset.writer._current_file_start_frame == 0
|
||||||
assert dataset.meta.total_episodes == 3
|
assert dataset.meta.total_episodes == 3
|
||||||
assert dataset.meta.total_frames == 3 * frames_per_episode
|
assert dataset.meta.total_frames == 3 * frames_per_episode
|
||||||
|
|
||||||
ep2_chunk = dataset.latest_episode["data/chunk_index"]
|
ep2_chunk = dataset.writer._latest_episode["data/chunk_index"]
|
||||||
ep2_file = dataset.latest_episode["data/file_index"]
|
ep2_file = dataset.writer._latest_episode["data/file_index"]
|
||||||
assert ep2_chunk == 0
|
assert ep2_chunk == 0
|
||||||
assert ep2_file == 0
|
assert ep2_file == 0
|
||||||
|
|
||||||
@@ -1354,82 +1364,6 @@ def test_frames_in_current_file_calculation(tmp_path, empty_lerobot_dataset_fact
|
|||||||
assert frame["episode_index"].item() == expected_ep
|
assert frame["episode_index"].item() == expected_ep
|
||||||
|
|
||||||
|
|
||||||
def test_encode_video_worker_forwards_vcodec(tmp_path):
|
|
||||||
"""Test that _encode_video_worker correctly forwards the vcodec parameter to encode_video_frames."""
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from lerobot.datasets.utils import DEFAULT_IMAGE_PATH
|
|
||||||
|
|
||||||
# Create the expected directory structure
|
|
||||||
video_key = "observation.images.laptop"
|
|
||||||
episode_index = 0
|
|
||||||
frame_index = 0
|
|
||||||
|
|
||||||
fpath = DEFAULT_IMAGE_PATH.format(
|
|
||||||
image_key=video_key, episode_index=episode_index, frame_index=frame_index
|
|
||||||
)
|
|
||||||
img_dir = tmp_path / Path(fpath).parent
|
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create a dummy image file
|
|
||||||
dummy_img = Image.new("RGB", (64, 64), color="red")
|
|
||||||
dummy_img.save(img_dir / "frame-000000.png")
|
|
||||||
|
|
||||||
# Track what vcodec was passed to encode_video_frames
|
|
||||||
captured_kwargs = {}
|
|
||||||
|
|
||||||
def mock_encode_video_frames(imgs_dir, video_path, fps, **kwargs):
|
|
||||||
captured_kwargs.update(kwargs)
|
|
||||||
# Create a dummy output file so the worker doesn't fail
|
|
||||||
Path(video_path).parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
Path(video_path).touch()
|
|
||||||
|
|
||||||
with patch("lerobot.datasets.lerobot_dataset.encode_video_frames", side_effect=mock_encode_video_frames):
|
|
||||||
# Test with h264 codec
|
|
||||||
_encode_video_worker(video_key, episode_index, tmp_path, fps=30, vcodec="h264")
|
|
||||||
|
|
||||||
assert "vcodec" in captured_kwargs
|
|
||||||
assert captured_kwargs["vcodec"] == "h264"
|
|
||||||
|
|
||||||
|
|
||||||
def test_encode_video_worker_default_vcodec(tmp_path):
|
|
||||||
"""Test that _encode_video_worker uses libsvtav1 as the default codec."""
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from lerobot.datasets.utils import DEFAULT_IMAGE_PATH
|
|
||||||
|
|
||||||
# Create the expected directory structure
|
|
||||||
video_key = "observation.images.laptop"
|
|
||||||
episode_index = 0
|
|
||||||
frame_index = 0
|
|
||||||
|
|
||||||
fpath = DEFAULT_IMAGE_PATH.format(
|
|
||||||
image_key=video_key, episode_index=episode_index, frame_index=frame_index
|
|
||||||
)
|
|
||||||
img_dir = tmp_path / Path(fpath).parent
|
|
||||||
img_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create a dummy image file
|
|
||||||
dummy_img = Image.new("RGB", (64, 64), color="red")
|
|
||||||
dummy_img.save(img_dir / "frame-000000.png")
|
|
||||||
|
|
||||||
# Track what vcodec was passed to encode_video_frames
|
|
||||||
captured_kwargs = {}
|
|
||||||
|
|
||||||
def mock_encode_video_frames(imgs_dir, video_path, fps, **kwargs):
|
|
||||||
captured_kwargs.update(kwargs)
|
|
||||||
# Create a dummy output file so the worker doesn't fail
|
|
||||||
Path(video_path).parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
Path(video_path).touch()
|
|
||||||
|
|
||||||
with patch("lerobot.datasets.lerobot_dataset.encode_video_frames", side_effect=mock_encode_video_frames):
|
|
||||||
# Test with default codec (no vcodec specified)
|
|
||||||
_encode_video_worker(video_key, episode_index, tmp_path, fps=30)
|
|
||||||
|
|
||||||
assert "vcodec" in captured_kwargs
|
|
||||||
assert captured_kwargs["vcodec"] == "libsvtav1"
|
|
||||||
|
|
||||||
|
|
||||||
def test_lerobot_dataset_vcodec_validation():
|
def test_lerobot_dataset_vcodec_validation():
|
||||||
"""Test that LeRobotDataset validates the vcodec parameter."""
|
"""Test that LeRobotDataset validates the vcodec parameter."""
|
||||||
# Test that invalid vcodec raises ValueError
|
# Test that invalid vcodec raises ValueError
|
||||||
|
|||||||
@@ -352,10 +352,14 @@ def test_with_different_image_formats(tmp_path, img_array_factory):
|
|||||||
|
|
||||||
|
|
||||||
def test_safe_stop_image_writer_decorator():
|
def test_safe_stop_image_writer_decorator():
|
||||||
class MockDataset:
|
class MockWriter:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.image_writer = MagicMock(spec=AsyncImageWriter)
|
self.image_writer = MagicMock(spec=AsyncImageWriter)
|
||||||
|
|
||||||
|
class MockDataset:
|
||||||
|
def __init__(self):
|
||||||
|
self.writer = MockWriter()
|
||||||
|
|
||||||
@safe_stop_image_writer
|
@safe_stop_image_writer
|
||||||
def function_that_raises_exception(dataset=None):
|
def function_that_raises_exception(dataset=None):
|
||||||
raise Exception("Test exception")
|
raise Exception("Test exception")
|
||||||
@@ -366,7 +370,7 @@ def test_safe_stop_image_writer_decorator():
|
|||||||
function_that_raises_exception(dataset=dataset)
|
function_that_raises_exception(dataset=dataset)
|
||||||
|
|
||||||
assert str(exc_info.value) == "Test exception"
|
assert str(exc_info.value) == "Test exception"
|
||||||
dataset.image_writer.stop.assert_called_once()
|
dataset.writer.image_writer.stop.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_main_process_time(tmp_path, img_tensor_factory):
|
def test_main_process_time(tmp_path, img_tensor_factory):
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
# Copyright 2024 The HuggingFace Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
"""Contract tests for the LeRobotDataset facade.
|
||||||
|
|
||||||
|
Tests focus on mode contracts (read-only, write-only, resume), guards,
|
||||||
|
property delegation, and the full create-record-finalize-read lifecycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import torch
|
||||||
|
|
||||||
|
from lerobot.datasets.dataset_reader import DatasetReader
|
||||||
|
from lerobot.datasets.dataset_writer import DatasetWriter
|
||||||
|
from lerobot.datasets.lerobot_dataset import LeRobotDataset
|
||||||
|
from tests.fixtures.constants import DEFAULT_FPS, DUMMY_REPO_ID
|
||||||
|
|
||||||
|
SIMPLE_FEATURES = {
|
||||||
|
"state": {"dtype": "float32", "shape": (2,), "names": None},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_frame(task: str = "Dummy task") -> dict:
|
||||||
|
return {"task": task, "state": torch.randn(2)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Read-only mode (via __init__) ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_creates_reader_no_writer(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""__init__() sets reader to a DatasetReader and writer to None."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=1, total_frames=10, use_videos=False
|
||||||
|
)
|
||||||
|
assert isinstance(dataset.reader, DatasetReader)
|
||||||
|
assert dataset.writer is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_loads_data(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""After __init__(), the dataset has data and len > 0."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=1, total_frames=10, use_videos=False
|
||||||
|
)
|
||||||
|
assert len(dataset) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_getitem_works_in_read_mode(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""dataset[0] returns a dict with expected keys in read-only mode."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=1, total_frames=10, use_videos=False
|
||||||
|
)
|
||||||
|
item = dataset[0]
|
||||||
|
assert isinstance(item, dict)
|
||||||
|
assert "index" in item
|
||||||
|
assert "task" in item
|
||||||
|
|
||||||
|
|
||||||
|
def test_len_matches_num_frames(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""len(dataset) equals dataset.num_frames."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=2, total_frames=30, use_videos=False
|
||||||
|
)
|
||||||
|
assert len(dataset) == dataset.num_frames
|
||||||
|
|
||||||
|
|
||||||
|
# ── Write-only mode (via create()) ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_sets_writer_no_reader(tmp_path):
|
||||||
|
"""create() sets writer to a DatasetWriter and reader to None."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
assert isinstance(dataset.writer, DatasetWriter)
|
||||||
|
assert dataset.reader is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_initial_counts_zero(tmp_path):
|
||||||
|
"""After create(), num_episodes == 0 and num_frames == 0."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
assert dataset.num_episodes == 0
|
||||||
|
assert dataset.num_frames == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_frame_works_in_write_mode(tmp_path):
|
||||||
|
"""add_frame() succeeds on a dataset created via create()."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
dataset.add_frame(_make_frame()) # should not raise
|
||||||
|
|
||||||
|
|
||||||
|
# ── Resume mode ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_creates_writer(tmp_path):
|
||||||
|
"""After resume(), writer is a DatasetWriter."""
|
||||||
|
root = tmp_path / "resume_ds"
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root
|
||||||
|
)
|
||||||
|
for _ in range(3):
|
||||||
|
dataset.add_frame(_make_frame())
|
||||||
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
|
resumed = LeRobotDataset.resume(repo_id=DUMMY_REPO_ID, root=root)
|
||||||
|
assert isinstance(resumed.writer, DatasetWriter)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_preserves_episode_count(tmp_path):
|
||||||
|
"""After resume(), existing episodes are counted."""
|
||||||
|
root = tmp_path / "resume_ds"
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root
|
||||||
|
)
|
||||||
|
for _ in range(3):
|
||||||
|
dataset.add_frame(_make_frame())
|
||||||
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
|
resumed = LeRobotDataset.resume(repo_id=DUMMY_REPO_ID, root=root)
|
||||||
|
assert resumed.meta.total_episodes == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_resume_can_add_more_episodes(tmp_path):
|
||||||
|
"""After resume(), new episodes can be added."""
|
||||||
|
root = tmp_path / "resume_ds"
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root
|
||||||
|
)
|
||||||
|
for _ in range(3):
|
||||||
|
dataset.add_frame(_make_frame())
|
||||||
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
|
resumed = LeRobotDataset.resume(repo_id=DUMMY_REPO_ID, root=root)
|
||||||
|
for _ in range(2):
|
||||||
|
resumed.add_frame(_make_frame())
|
||||||
|
resumed.save_episode()
|
||||||
|
|
||||||
|
assert resumed.meta.total_episodes == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ── Writer guard ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_frame_raises_without_writer(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""add_frame() raises RuntimeError on a read-only dataset."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=1, total_frames=5, use_videos=False
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="read-only"):
|
||||||
|
dataset.add_frame(_make_frame())
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_episode_raises_without_writer(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""save_episode() raises RuntimeError on a read-only dataset."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=1, total_frames=5, use_videos=False
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="read-only"):
|
||||||
|
dataset.save_episode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_episode_buffer_raises_without_writer(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""clear_episode_buffer() raises RuntimeError on a read-only dataset."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=1, total_frames=5, use_videos=False
|
||||||
|
)
|
||||||
|
with pytest.raises(RuntimeError, match="read-only"):
|
||||||
|
dataset.clear_episode_buffer()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Reader guard ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_getitem_raises_before_finalize(tmp_path):
|
||||||
|
"""dataset[0] raises RuntimeError while recording (before finalize)."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
for _ in range(3):
|
||||||
|
dataset.add_frame(_make_frame())
|
||||||
|
dataset.save_episode()
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="finalize"):
|
||||||
|
dataset[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_getitem_works_after_finalize(tmp_path):
|
||||||
|
"""After finalize(), dataset[0] returns data."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
for _ in range(3):
|
||||||
|
dataset.add_frame(_make_frame())
|
||||||
|
dataset.save_episode()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
|
item = dataset[0]
|
||||||
|
assert "state" in item
|
||||||
|
assert "task" in item
|
||||||
|
|
||||||
|
|
||||||
|
# ── Property delegation ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_fps_delegates_to_meta(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""dataset.fps == dataset.meta.fps."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=1, total_frames=5, use_videos=False
|
||||||
|
)
|
||||||
|
assert dataset.fps == dataset.meta.fps
|
||||||
|
|
||||||
|
|
||||||
|
def test_features_delegates_to_meta(tmp_path, lerobot_dataset_factory):
|
||||||
|
"""dataset.features is dataset.meta.features."""
|
||||||
|
dataset = lerobot_dataset_factory(
|
||||||
|
root=tmp_path / "ds", total_episodes=1, total_frames=5, use_videos=False
|
||||||
|
)
|
||||||
|
assert dataset.features is dataset.meta.features
|
||||||
|
|
||||||
|
|
||||||
|
def test_num_frames_uses_meta_in_write_mode(tmp_path):
|
||||||
|
"""In write-only mode (reader=None), num_frames comes from metadata."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
assert dataset.reader is None
|
||||||
|
assert dataset.num_frames == dataset.meta.total_frames
|
||||||
|
|
||||||
|
|
||||||
|
# ── Lifecycle ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_finalize_is_idempotent(tmp_path):
|
||||||
|
"""Calling finalize() twice does not raise."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
dataset.finalize()
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_pending_frames_lifecycle(tmp_path):
|
||||||
|
"""has_pending_frames: False -> True (add_frame) -> False (save_episode)."""
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=tmp_path / "ds"
|
||||||
|
)
|
||||||
|
assert dataset.has_pending_frames() is False
|
||||||
|
|
||||||
|
dataset.add_frame(_make_frame())
|
||||||
|
assert dataset.has_pending_frames() is True
|
||||||
|
|
||||||
|
dataset.save_episode()
|
||||||
|
assert dataset.has_pending_frames() is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_record_finalize_read_roundtrip(tmp_path):
|
||||||
|
"""End-to-end: create, record 2 episodes, finalize, re-open, verify data."""
|
||||||
|
root = tmp_path / "roundtrip"
|
||||||
|
dataset = LeRobotDataset.create(
|
||||||
|
repo_id=DUMMY_REPO_ID, fps=DEFAULT_FPS, features=SIMPLE_FEATURES, root=root
|
||||||
|
)
|
||||||
|
|
||||||
|
# Episode 0: 3 frames with known values
|
||||||
|
ep0_states = []
|
||||||
|
for i in range(3):
|
||||||
|
state = torch.tensor([float(i), float(i * 2)])
|
||||||
|
ep0_states.append(state)
|
||||||
|
dataset.add_frame({"task": "Task A", "state": state})
|
||||||
|
dataset.save_episode()
|
||||||
|
|
||||||
|
# Episode 1: 2 frames
|
||||||
|
ep1_states = []
|
||||||
|
for i in range(2):
|
||||||
|
state = torch.tensor([float(i + 100), float(i + 200)])
|
||||||
|
ep1_states.append(state)
|
||||||
|
dataset.add_frame({"task": "Task B", "state": state})
|
||||||
|
dataset.save_episode()
|
||||||
|
|
||||||
|
dataset.finalize()
|
||||||
|
|
||||||
|
# Re-open as read-only
|
||||||
|
reopened = LeRobotDataset(repo_id=DUMMY_REPO_ID, root=root)
|
||||||
|
assert len(reopened) == 5
|
||||||
|
assert reopened.num_episodes == 2
|
||||||
|
|
||||||
|
# Verify episode 0
|
||||||
|
for i in range(3):
|
||||||
|
item = reopened[i]
|
||||||
|
assert torch.allclose(item["state"], ep0_states[i], atol=1e-5)
|
||||||
|
assert item["episode_index"].item() == 0
|
||||||
|
|
||||||
|
# Verify episode 1
|
||||||
|
for i in range(2):
|
||||||
|
item = reopened[3 + i]
|
||||||
|
assert torch.allclose(item["state"], ep1_states[i], atol=1e-5)
|
||||||
|
assert item["episode_index"].item() == 1
|
||||||
@@ -534,7 +534,7 @@ class TestStreamingEncoderIntegration:
|
|||||||
streaming_encoding=True,
|
streaming_encoding=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert dataset._streaming_encoder is not None
|
assert dataset.writer._streaming_encoder is not None
|
||||||
|
|
||||||
num_frames = 20
|
num_frames = 20
|
||||||
for _ in range(num_frames):
|
for _ in range(num_frames):
|
||||||
@@ -580,7 +580,7 @@ class TestStreamingEncoderIntegration:
|
|||||||
streaming_encoding=False,
|
streaming_encoding=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert dataset._streaming_encoder is None
|
assert dataset.writer._streaming_encoder is None
|
||||||
|
|
||||||
num_frames = 5
|
num_frames = 5
|
||||||
for _ in range(num_frames):
|
for _ in range(num_frames):
|
||||||
|
|||||||
Reference in New Issue
Block a user