| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| """Contract tests for LeRobotDatasetMetadata.""" |
|
|
| import json |
|
|
| import numpy as np |
| import pytest |
|
|
| pytest.importorskip("datasets", reason="datasets is required (install lerobot[dataset])") |
|
|
| from lerobot.datasets.dataset_metadata import LeRobotDatasetMetadata |
| from lerobot.datasets.utils import INFO_PATH |
| from tests.fixtures.constants import DEFAULT_FPS, DUMMY_ROBOT_TYPE |
|
|
| |
|
|
| 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"): |
| channels = ft["shape"][-1] |
| stat_shape = (channels, 1, 1) |
| stats[key] = { |
| "max": np.ones(stat_shape, dtype=np.float32), |
| "mean": np.full(stat_shape, 0.5, dtype=np.float32), |
| "min": np.zeros(stat_shape, dtype=np.float32), |
| "std": np.full(stat_shape, 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 |
|
|
|
|
| |
|
|
|
|
| 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, |
| ) |
|
|
| |
| 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 == [] |
|
|
|
|
| @pytest.mark.parametrize( |
| ("marker_field", "marker_key"), |
| [ |
| ("info", "is_depth_map"), |
| ("info", "video.is_depth_map"), |
| ("video_info", "video.is_depth_map"), |
| ], |
| ids=["info.is_depth_map", "info.video.is_depth_map_legacy", "video_info.video.is_depth_map_legacy"], |
| ) |
| def test_depth_keys_property_filters_by_marker(tmp_path, marker_field, marker_key): |
| """``depth_keys`` recognises the canonical and the two legacy marker variants.""" |
| depth_feature = { |
| "dtype": "video", |
| "shape": (64, 96, 1), |
| "names": ["height", "width", "channels"], |
| marker_field: {marker_key: True}, |
| } |
| features = { |
| **VIDEO_FEATURES, |
| "observation.images.laptop_depth": depth_feature, |
| } |
| meta = LeRobotDatasetMetadata.create( |
| repo_id="test/depth_keys", |
| fps=DEFAULT_FPS, |
| features=features, |
| root=tmp_path / f"depth_keys_{marker_field}_{marker_key.replace('.', '_')}", |
| ) |
|
|
| assert set(meta.video_keys) == {"observation.images.laptop", "observation.images.laptop_depth"} |
| assert meta.depth_keys == ["observation.images.laptop_depth"] |
|
|
|
|
| def test_depth_keys_empty_when_no_marker(tmp_path): |
| meta = LeRobotDatasetMetadata.create( |
| repo_id="test/no_depth", fps=DEFAULT_FPS, features=VIDEO_FEATURES, root=tmp_path / "no_depth" |
| ) |
| assert meta.depth_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 |
|
|
|
|
| |
|
|
|
|
| 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 |
| |
| for _key, shape in meta.shapes.items(): |
| assert isinstance(shape, tuple) |
| |
| assert "observation.images.laptop" in meta.image_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 |
|
|
|
|
| |
|
|
|
|
| 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 |
| 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"]) |
|
|
|
|
| |
|
|
|
|
| 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 |
| |
| for key in SIMPLE_FEATURES: |
| assert key in meta.stats |
|
|
|
|
| |
|
|
|
|
| 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 |
|
|
| |
| 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) |
|
|
|
|
| |
|
|
|
|
| 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() |
|
|
|
|
| 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, |
| ) |
| meta.save_episode_tasks(["Task 1"]) |
| stats = _make_dummy_stats(meta.features) |
|
|
| |
| for i in range(3): |
| meta.save_episode( |
| episode_index=i, |
| episode_length=5, |
| episode_tasks=["Task 1"], |
| episode_stats=stats, |
| episode_metadata={}, |
| ) |
|
|
| |
| meta.finalize() |
|
|
| |
| episodes_dir = root / "meta" / "episodes" |
| assert episodes_dir.exists() |
| parquet_files = list(episodes_dir.rglob("*.parquet")) |
| assert len(parquet_files) > 0 |
|
|
|
|
| |
|
|
|
|
| def test_tools_falls_back_to_default_when_info_has_no_tools_field(tmp_path): |
| """meta.tools returns DEFAULT_TOOLS when info.json doesn't declare any.""" |
| from lerobot.datasets.language import DEFAULT_TOOLS |
|
|
| root = tmp_path / "no_tools" |
| meta = LeRobotDatasetMetadata.create( |
| repo_id="test/no_tools", |
| fps=DEFAULT_FPS, |
| features=SIMPLE_FEATURES, |
| root=root, |
| use_videos=False, |
| ) |
|
|
| assert meta.tools == DEFAULT_TOOLS |
| |
| with open(root / INFO_PATH) as f: |
| info_on_disk = json.load(f) |
| assert "tools" not in info_on_disk |
|
|
|
|
| def test_tools_reads_declared_tools_from_info_json(tmp_path): |
| """A `tools` list written into info.json survives load β meta.tools. |
| |
| Regression test for the bug where ``DatasetInfo.from_dict`` silently |
| dropped the ``tools`` key (no matching dataclass field), so |
| ``meta.tools`` always returned ``DEFAULT_TOOLS`` regardless of |
| what was on disk. |
| """ |
| from lerobot.datasets.io_utils import load_info |
|
|
| root = tmp_path / "with_tools" |
| meta = LeRobotDatasetMetadata.create( |
| repo_id="test/with_tools", |
| fps=DEFAULT_FPS, |
| features=SIMPLE_FEATURES, |
| root=root, |
| use_videos=False, |
| ) |
|
|
| custom_tool = { |
| "type": "function", |
| "function": { |
| "name": "record_observation", |
| "description": "Capture a still image.", |
| "parameters": { |
| "type": "object", |
| "properties": {"label": {"type": "string"}}, |
| "required": ["label"], |
| }, |
| }, |
| } |
| info_path = root / INFO_PATH |
| with open(info_path) as f: |
| raw = json.load(f) |
| raw["tools"] = [custom_tool] |
| with open(info_path, "w") as f: |
| json.dump(raw, f) |
|
|
| |
| meta.info = load_info(root) |
| assert meta.tools == [custom_tool] |
|
|
|
|
| def test_tools_round_trip_through_dataset_info(tmp_path): |
| """A `tools` list survives DatasetInfo.from_dict / to_dict.""" |
| from lerobot.datasets.utils import DatasetInfo |
|
|
| raw = { |
| "codebase_version": "v3.1", |
| "fps": 30, |
| "features": SIMPLE_FEATURES, |
| "tools": [{"type": "function", "function": {"name": "say"}}], |
| } |
| info = DatasetInfo.from_dict(raw) |
| assert info.tools == raw["tools"] |
| assert info.to_dict()["tools"] == raw["tools"] |
|
|
|
|
| def test_tools_setter_persists_to_info_json_and_reloads(tmp_path): |
| """Assigning meta.tools writes info.json and reloads meta.info.""" |
| from lerobot.datasets.io_utils import load_info |
|
|
| root = tmp_path / "set_tools" |
| meta = LeRobotDatasetMetadata.create( |
| repo_id="test/set_tools", |
| fps=DEFAULT_FPS, |
| features=SIMPLE_FEATURES, |
| root=root, |
| use_videos=False, |
| ) |
|
|
| custom_tool = { |
| "type": "function", |
| "function": { |
| "name": "record_observation", |
| "description": "Capture a still image.", |
| "parameters": { |
| "type": "object", |
| "properties": {"label": {"type": "string"}}, |
| "required": ["label"], |
| }, |
| }, |
| } |
| meta.tools = [custom_tool] |
|
|
| |
| assert meta.tools == [custom_tool] |
| assert meta.info.tools == [custom_tool] |
| |
| assert load_info(root).tools == [custom_tool] |
|
|
|
|
| def test_tools_setter_clears_key_when_set_to_none(tmp_path): |
| """Setting meta.tools back to None drops the key and restores the default.""" |
| from lerobot.datasets.language import DEFAULT_TOOLS |
|
|
| root = tmp_path / "clear_tools" |
| meta = LeRobotDatasetMetadata.create( |
| repo_id="test/clear_tools", |
| fps=DEFAULT_FPS, |
| features=SIMPLE_FEATURES, |
| root=root, |
| use_videos=False, |
| ) |
|
|
| meta.tools = [{"type": "function", "function": {"name": "say"}}] |
| meta.tools = None |
|
|
| assert meta.tools == DEFAULT_TOOLS |
| with open(root / INFO_PATH) as f: |
| info_on_disk = json.load(f) |
| assert "tools" not in info_on_disk |
|
|