blob: 5992b936629def9b8b50b4f4808cb4a03d6fd631 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
import json
import sys
from contextlib import nullcontext
from decimal import Decimal
from pathlib import Path
from unittest.mock import patch
import numpy as np
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
import pytest
from pypaimon.catalog.catalog_exception import TableNotExistException
import pypaimon.multimodal as pm
from pypaimon.multimodal.lerobot import aggregate
from pypaimon.multimodal.lerobot.aggregate import _batch_stats, _transform_frames
from pypaimon.table.row.blob import Blob, VideoFrameDescriptor
pytestmark = pytest.mark.skipif(sys.version_info < (3, 10), reason="LeRobot import requires Python 3.10+")
def source(root, tasks=("pick", "place"), subtasks=("reach", "grasp"), video=False, value=0, fps=10, frames=5):
root.mkdir(parents=True)
meta = root / "meta"
(meta / "episodes/chunk-000").mkdir(parents=True)
(root / "data/chunk-000").mkdir(parents=True)
features = {key: {"dtype": "int64", "shape": [1], "names": None}
for key in ("index", "episode_index", "frame_index", "task_index", "subtask_index")}
features["timestamp"] = {"dtype": "float32", "shape": [1], "names": None}
features["observation.state"] = {"dtype": "float32", "shape": [2], "names": ["x", "y"]}
features["action"] = {"dtype": "float32", "shape": [2], "names": ["x", "y"]}
if video:
features["camera"] = {"dtype": "video", "shape": [16, 16, 3],
"names": ["height", "width", "channels"],
"info": {"video.fps": fps}}
av = pytest.importorskip("av")
path = root / "videos/camera/chunk-000/file-000.mp4"
path.parent.mkdir(parents=True)
with av.open(str(path), "w") as container:
stream = container.add_stream("mpeg4", rate=fps)
stream.width = stream.height = 16
stream.pix_fmt = "yuv420p"
for i in range(frames):
frame = av.VideoFrame.from_ndarray(np.full((16, 16, 3), value + i * 3, np.uint8), format="rgb24")
for packet in stream.encode(frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
info = dict(codebase_version="v3.0", fps=fps, robot_type="test", total_frames=frames,
total_episodes=2, total_tasks=2, features=features, splits={"train": "0:2"},
data_path="data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet",
video_path="videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4")
(meta / "info.json").write_text(json.dumps(info))
rows = []
episodes = []
for ep, (start, end) in enumerate(((0, frames // 2), (frames // 2, frames))):
ep_rows = []
for index in range(start, end):
row = dict(index=index, episode_index=ep, frame_index=index-start,
timestamp=(index-start)/fps, task_index=ep, subtask_index=index % 2)
row["observation.state"] = [float(value + index), float(index)]
row["action"] = [float(index), -float(index)]
ep_rows.append(row)
rows.extend(ep_rows)
episode = dict(episode_index=ep, dataset_from_index=start, dataset_to_index=end,
length=end-start, tasks=[tasks[ep]])
episode.update({"data/chunk_index": 0, "data/file_index": 0})
if video:
episode.update({"videos/camera/chunk_index": 0, "videos/camera/file_index": 0,
"videos/camera/from_timestamp": start/fps,
"videos/camera/to_timestamp": end/fps})
for name in ("index", "episode_index", "task_index", "subtask_index"):
stats = _batch_stats([r[name] for r in ep_rows])
for stat, v in stats.items():
episode["stats/%s/%s" % (name, stat)] = v
episode["stats/%s/q50" % name] = [0.0]
episodes.append(episode)
data = pa.Table.from_pylist(rows)
# Source float32 timestamps must match the training feature contract.
data = data.set_column(data.schema.get_field_index("timestamp"), "timestamp",
data["timestamp"].cast(pa.float32()))
pq.write_table(data, root / "data/chunk-000/file-000.parquet")
pq.write_table(pa.Table.from_pylist(episodes), meta / "episodes/chunk-000/file-000.parquet")
for kind, texts in (("task", tasks), ("subtask", subtasks)):
pq.write_table(pa.Table.from_pandas(pd.DataFrame(
{kind + "_index": [0, 1]}, index=pd.Index(texts, name=kind))), meta / (kind + "s.parquet"))
stats = {name: _batch_stats([r[name] for r in rows])
for name in ("index", "episode_index", "task_index", "subtask_index")}
for name in ("observation.state", "action"):
values = np.array([r[name] for r in rows])
stats[name] = {"min": values.min(0).tolist(), "max": values.max(0).tolist(),
"mean": values.mean(0).tolist(), "std": values.std(0).tolist(), "count": [frames],
"q50": np.median(values, axis=0).tolist()}
(meta / "stats.json").write_text(json.dumps(stats))
return root.as_uri()
@pytest.fixture
def setup(tmp_path, request):
a = source(tmp_path / "a")
b = source(tmp_path / "b", tasks=("place", "stack"), subtasks=("grasp", "release"), value=20,
fps=getattr(request, "param", 10))
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
return conn, [a, b]
def rows(conn, name):
return conn.get_table(name).scan().to_arrow()
def frame_rows(conn, name):
return rows(conn, name).sort_by([
("dataset_index", "ascending"), ("index", "ascending")])
def episode_rows(conn, name):
return rows(conn, name).sort_by([
("dataset_index", "ascending"), ("episode_index", "ascending")])
def metadata(conn, suffix):
from pypaimon.multimodal.lerobot.dataset import (
_merged_source_info, _merged_source_stats, _source_vocabulary,
)
sources = rows(conn, "merged_datasets").sort_by("index").to_pylist()
tasks, _ = _source_vocabulary(sources, "task")
subtasks, _ = _source_vocabulary(sources, "subtask")
if suffix == "info":
return _merged_source_info(sources, tasks, subtasks)
if suffix == "stats":
return _merged_source_stats(sources, conn.get_table("merged_episodes").raw_table)
return {"tasks": tasks, "subtasks": subtasks}[suffix]
def vocabulary(conn, singular):
return metadata(conn, singular + "s")
@pytest.mark.parametrize("multiple", [False, True])
def test_import_uses_group_prefix(setup, multiple):
conn, sources = setup
conn.load_from_lerobot("robot", sources if multiple else sources[0])
expected = {"robot_frames", "robot_episodes", "robot_datasets"}
assert set(conn.catalog.list_tables("default")) == expected
assert rows(conn, "robot_frames").num_rows == (10 if multiple else 5)
assert set(conn.create_lerobot_tag("robot", "training")) == {
"frames", "episodes", "datasets"}
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_append_source_preserves_existing_indices_and_tags(setup, import_parallelism):
conn, sources = setup
conn.load_from_lerobot("robot", [sources[0]], tag_name="before")
conn.append_to_lerobot("robot", sources[1], dataset_name="second",
tag_name="after", batch_size=2,
parallelism=import_parallelism)
assert set(conn.catalog.list_tables("default")) == {
"robot_frames", "robot_episodes", "robot_datasets"}
frames = rows(conn, "robot_frames").sort_by([
("dataset_index", "ascending"), ("index", "ascending")])
assert frames["index"].to_pylist() == list(range(5)) * 2
assert frames["dataset_index"].to_pylist() == [0] * 5 + [1] * 5
episodes = episode_rows(conn, "robot_episodes")
assert episodes["dataset_from_index"].to_pylist() == [0, 2, 0, 2]
assert episodes["dataset_to_index"].to_pylist() == [2, 5, 2, 5]
datasets = rows(conn, "robot_datasets").sort_by("index")
assert datasets["index"].to_pylist() == [0, 1]
assert datasets["name"].to_pylist() == [None, "second"]
with pytest.raises(ValueError, match="already exists"):
conn.append_to_lerobot("robot", sources[1], tag_name="before")
assert rows(conn, "robot_datasets").num_rows == 2
pytest.importorskip("datasets")
pytest.importorskip("torch")
before = pm.PaimonLeRobotDataset(conn.get_table("robot_frames"), tag_name="before")
after = pm.PaimonLeRobotDataset(conn.get_table("robot_frames"), tag_name="after")
try:
assert len(before) == 5
assert len(after) == 10
assert after[5]["task"] == "place"
assert after[7]["task"] == "stack"
assert [row["index"].item() for row in after.reader.get_items([4, 5, 9])] == [4, 5, 9]
finally:
before.close()
after.close()
def test_append_rejects_incompatible_source_without_writing(setup):
conn, sources = setup
conn.load_from_lerobot("robot", [sources[0]])
info_path = Path(sources[1][7:]) / "meta/info.json"
info = json.loads(info_path.read_text())
info["robot_type"] = "incompatible_robot"
info_path.write_text(json.dumps(info))
with pytest.raises(ValueError, match="info.robot_type"):
conn.append_to_lerobot("robot", sources[1])
assert rows(conn, "robot_frames").num_rows == 5
assert rows(conn, "robot_episodes").num_rows == 2
assert rows(conn, "robot_datasets").num_rows == 1
@pytest.mark.parametrize("group_first", [False, True])
def test_append_rejects_episode_group_plain_column_collision(setup, group_first):
conn, sources = setup
for index, uri in enumerate(sources):
path = Path(uri[7:]) / "meta/episodes/chunk-000/file-000.parquet"
grouped = (index == 0) == group_first
name = "camera_intrinsics/head_left" if grouped else "camera_intrinsics"
values = pa.array([[1.], [2.]] if grouped else ["plain one", "plain two"])
pq.write_table(pq.read_table(path).append_column(name, values), path)
conn.load_from_lerobot("robot", [sources[0]])
before = episode_rows(conn, "robot_episodes")["camera_intrinsics"].to_pylist()
with pytest.raises(ValueError, match="JSON groups conflict with plain columns"):
conn.append_to_lerobot("robot", sources[1])
assert episode_rows(conn, "robot_episodes")["camera_intrinsics"].to_pylist() == before
assert rows(conn, "robot_frames").num_rows == 5
assert rows(conn, "robot_datasets").num_rows == 1
def test_append_preserves_episode_group_kind(setup):
conn, sources = setup
for index, uri in enumerate(sources):
path = Path(uri[7:]) / "meta/episodes/chunk-000/file-000.parquet"
values = pa.array([[float(index)], [float(index + 1)]])
pq.write_table(pq.read_table(path).append_column(
"camera_intrinsics/head_left", values), path)
conn.load_from_lerobot("robot", [sources[0]])
conn.append_to_lerobot("robot", sources[1])
actual = episode_rows(conn, "robot_episodes")["camera_intrinsics"].to_pylist()
assert [json.loads(value) for value in actual] == [
{"head_left": [0.]}, {"head_left": [1.]},
{"head_left": [1.]}, {"head_left": [2.]},
]
def test_append_episode_task_stats_use_group_vocabulary(setup):
conn, sources = setup
conn.load_from_lerobot("robot", [sources[0]])
conn.append_to_lerobot("robot", sources[1])
episodes = episode_rows(conn, "robot_episodes").to_pylist()
task_means = [json.loads(row["stats"])["task_index"]["mean"][0]
for row in episodes]
subtask_means = [json.loads(row["stats"])["subtask_index"]["mean"][0]
for row in episodes]
assert task_means == [0, 1, 1, 2]
assert subtask_means == pytest.approx([0.5, 1 / 3, 1.5, 4 / 3])
assert sum(mean * count for mean, count in zip(task_means, (2, 3, 2, 3))) / 10 == 1.1
pytest.importorskip("datasets")
pytest.importorskip("torch")
dataset = pm.PaimonLeRobotDataset(conn.get_table("robot_frames"))
try:
frames = dataset.reader.get_items(range(10))
assert [int(row["task_index"]) for row in frames] == [0] * 2 + [1] * 5 + [2] * 3
finally:
dataset.close()
@pytest.mark.parametrize("setup", [20], indirect=True)
def test_append_mixed_fps_group_remains_readable(setup):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
conn.load_from_lerobot("robot", [sources[0]])
conn.append_to_lerobot("robot", sources[1])
dataset = pm.PaimonLeRobotDataset(conn.get_table("robot_frames"))
try:
assert dataset.fps is None
assert dataset.episode_fps == (10, 10, 20, 20)
assert "fps_mode" not in dataset.reader.meta.info
assert [float(item["timestamp"]) for item in dataset.reader.get_items([5, 6])] == (
pytest.approx([0, 0.05]))
finally:
dataset.close()
@pytest.mark.parametrize("manual_recovery", [False, True])
def test_append_recovers_after_episode_commit_failure(setup, manual_recovery):
conn, sources = setup
conn.load_from_lerobot("robot", [sources[0]])
original_commit = aggregate._commit_messages
calls = 0
def fail_on_episode(commit, messages):
nonlocal calls
calls += 1
if calls == 2:
raise RuntimeError("injected Episode commit failure")
return original_commit(commit, messages)
recovery_patch = (patch.object(aggregate, "_recover_incomplete_append", return_value=[])
if manual_recovery else nullcontext())
with patch.object(aggregate, "_commit_messages", side_effect=fail_on_episode), recovery_patch:
with pytest.raises(RuntimeError, match="injected Episode commit failure"):
conn.append_to_lerobot("robot", sources[1])
if manual_recovery:
assert rows(conn, "robot_frames").num_rows == 10
with pytest.raises(ValueError, match="incomplete frames"):
conn.append_to_lerobot("robot", sources[1])
assert conn.recover_lerobot_append("robot") == ["frames"]
assert rows(conn, "robot_frames").num_rows == 5
assert rows(conn, "robot_episodes").num_rows == 2
assert rows(conn, "robot_datasets").num_rows == 1
conn.append_to_lerobot("robot", sources[1])
assert rows(conn, "robot_frames").num_rows == 10
def test_append_twice_keeps_source_keys_and_training_order(setup, tmp_path):
conn, sources = setup
third = source(tmp_path / "third", value=40)
conn.load_from_lerobot("robot", [sources[0]])
conn.append_to_lerobot("robot", sources[1])
conn.append_to_lerobot("robot", third)
frames = frame_rows(conn, "robot_frames")
assert frames["dataset_index"].to_pylist() == [0] * 5 + [1] * 5 + [2] * 5
assert frames["index"].to_pylist() == list(range(5)) * 3
assert rows(conn, "robot_datasets").sort_by("index")["index"].to_pylist() == [0, 1, 2]
pytest.importorskip("datasets")
pytest.importorskip("torch")
dataset = pm.PaimonLeRobotDataset(conn.get_table("robot_frames"))
try:
assert len(dataset) == 15
indices = [4, 5, 9, 10, 14]
assert [item["index"].item() for item in dataset.reader.get_items(indices)] == indices
finally:
dataset.close()
def test_single_source_feature_named_dataset_index(setup):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
root = Path(sources[0][7:])
info_path = root / "meta/info.json"
info = json.loads(info_path.read_text())
info["features"]["dataset_index"] = {
"dtype": "int64", "shape": [1], "names": None,
}
info_path.write_text(json.dumps(info))
data_path = root / "data/chunk-000/file-000.parquet"
data = pq.read_table(data_path).append_column(
"dataset_index", pa.array([7] * 5, type=pa.int64()))
pq.write_table(data, data_path)
conn.load_from_lerobot("single", sources[0])
reader = pm.PaimonLeRobotDataset(conn.get_table("single_frames"))
try:
assert len(reader) == 5
assert reader[0]["dataset_index"].item() == 7
finally:
reader.close()
def test_transform_frames_preserves_unlabelled_subtasks():
plan = {
"dataset_index": 1,
"frame_offset": 5,
"episode_offset": 2,
"task_index": {0: 1},
"subtask_index": {0: 3},
"control_stats": {},
}
batch = pa.table({
"index": pa.array([0, 1], type=pa.int64()),
"episode_index": pa.array([0, 0], type=pa.int64()),
"task_index": pa.array([0, 0], type=pa.int64()),
"subtask_index": pa.array([0, None], type=pa.int64()),
})
converted = _transform_frames(plan, batch, 0)
assert converted["index"].to_pylist() == [0, 1]
assert converted["episode_index"].to_pylist() == [0, 0]
assert converted["dataset_index"].to_pylist() == [1, 1]
assert "source_index" not in converted.column_names
assert converted["task_index"].to_pylist() == [0, 0]
assert converted["subtask_index"].to_pylist() == [0, None]
assert plan["control_stats"][0]["subtask_index"]["count"] == [1]
@pytest.fixture(scope="module")
def ray_cluster():
ray = pytest.importorskip("ray")
started = not ray.is_initialized()
if started:
ray.init(address="local", num_cpus=2, include_dashboard=False,
object_store_memory=100 * 1024 * 1024)
try:
yield ray
finally:
if started:
ray.shutdown()
@pytest.fixture
def import_parallelism(request):
if request.param > 1:
request.getfixturevalue("ray_cluster")
return request.param
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_merge_numbering_stats_and_training(setup, import_parallelism):
conn, sources = setup
conn.load_from_lerobot("merged", sources, batch_size=2, tag_name="train",
parallelism=import_parallelism)
data = frame_rows(conn, "merged_frames")
assert data["index"].to_pylist() == list(range(5)) * 2
assert data["episode_index"].to_pylist() == [0, 0, 1, 1, 1] * 2
assert data["dataset_index"].to_pylist() == [0] * 5 + [1] * 5
assert "source_index" not in data.column_names
assert data["frame_index"].to_pylist() == [0, 1, 0, 1, 2] * 2
assert data["task_index"].to_pylist() == [0, 0, 1, 1, 1] * 2
assert data["subtask_index"].to_pylist() == [0, 1, 0, 1, 0] * 2
assert set(conn.catalog.list_tables("default")) == {
"merged_frames", "merged_episodes", "merged_datasets"}
episodes = episode_rows(conn, "merged_episodes")
assert "fps" not in episodes.column_names
assert episodes["dataset_index"].to_pylist() == [0, 0, 1, 1]
assert episodes["episode_index"].to_pylist() == [0, 1, 0, 1]
assert "source_frame_from_index" not in episodes.column_names
assert "source_frame_to_index" not in episodes.column_names
datasets = rows(conn, "merged_datasets").sort_by("index")
assert datasets.column_names == ["index", "name", "info", "tasks", "subtasks", "stats"]
assert datasets["name"].to_pylist() == [None, None]
assert "fps" not in datasets.column_names
assert [json.loads(value)["fps"] for value in datasets["info"].to_pylist()] == [10, 10]
assert [json.loads(value)["total_frames"] for value in
datasets["info"].to_pylist()] == [5, 5]
assert [len(value) for value in datasets["tasks"].to_pylist()] == [2, 2]
assert [len(value) for value in datasets["subtasks"].to_pylist()] == [2, 2]
assert [len(json.loads(value)) for value in
datasets["stats"].to_pylist()] == [6, 6]
assert "source_episode_patch_json" not in episodes.column_names
assert conn.catalog.get_tag(
conn._identifier("merged_datasets"), "train").snapshot.id == 1
assert episodes["dataset_from_index"].to_pylist() == [0, 2, 0, 2]
assert episodes["dataset_to_index"].to_pylist() == [2, 5, 2, 5]
assert not any(c.startswith("stats/") for c in episodes.column_names)
episode_stats = [json.loads(value) for value in episodes["stats"].to_pylist()]
assert [item["index"]["mean"] for item in episode_stats] == [
[0.5], [3.0], [5.5], [8.0]]
assert all("q50" not in item["index"] for item in episode_stats)
pytest.importorskip("datasets")
torch = pytest.importorskip("torch")
dataset = pm.PaimonLeRobotDataset(
conn.get_table("merged_frames"), tag_name="train",
delta_timestamps={"action": [-0.1, 0, 0.1]})
info = dataset.reader.meta.info
assert info["fps"] == 10
assert (info["total_frames"], info["total_episodes"], info["total_tasks"]) == (10, 4, 3)
assert info["splits"] == {"train": "0:4"}
assert info["total_subtasks"] == 3
stats = dataset.reader.meta.stats
for name in ("index", "episode_index", "task_index", "subtask_index", "observation.state", "action"):
values = np.array(([0, 0, 1, 1, 1, 1, 1, 2, 2, 2] if name == "task_index"
else [0, 1, 0, 1, 0, 1, 2, 1, 2, 1] if name == "subtask_index"
else list(range(10)) if name == "index"
else data[name].to_pylist()))
np.testing.assert_allclose(stats[name]["mean"], np.atleast_1d(values.mean(0)))
np.testing.assert_allclose(stats[name]["std"], np.atleast_1d(values.std(0)))
assert "q50" not in stats[name]
assert dataset.reader.meta.tasks.index.tolist() == ["pick", "place", "stack"]
assert dataset[5]["task"] == "place"
assert dataset[7]["task"] == "stack"
assert dataset[5]["action_is_pad"].tolist() == [True, False, False]
batch = next(iter(dataset.to_dataloader(batch_size=3, num_workers=0)))
assert batch["action"].shape == (3, 3, 2)
assert batch["action"].dtype == torch.float32
selected = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), tag_name="train", episodes=[2])
assert len(selected) == 2
assert selected[0]["episode_index"].item() == 2
def test_merge_keeps_source_specific_episode_statistics_in_json(setup):
conn, sources = setup
for index, uri in enumerate(sources):
path = Path(uri[7:]) / "meta/episodes/chunk-000/file-000.parquet"
table = pq.read_table(path)
name = "stats/source_%d/mean" % index
table = table.append_column(name, pa.array([[index + 0.5]] * 2))
table = table.append_column("meta/episodes/chunk_index", pa.array([index] * 2))
table = table.append_column("data/vendor_%d" % index, pa.array(["source"] * 2))
pq.write_table(table, path)
conn.load_from_lerobot("merged", sources)
episodes = episode_rows(conn, "merged_episodes")
assert episodes.schema.field("stats").type == pa.string()
assert all(episodes.schema.field(name).type == pa.string()
for name in ("meta", "data", "videos", "stats"))
assert not any(name.startswith(("meta/", "data/", "videos/", "stats/"))
for name in episodes.column_names)
for row in episodes.to_pylist():
stats = json.loads(row["stats"])
assert stats["source_%d" % row["dataset_index"]]["mean"] == [
row["dataset_index"] + 0.5]
assert "q50" not in stats["index"]
assert json.loads(row["meta"]) == {"episodes/chunk_index": row["dataset_index"]}
assert json.loads(row["data"]) == {
"chunk_index": 0, "file_index": 0,
"vendor_%d" % row["dataset_index"]: "source"}
assert json.loads(row["videos"]) == {}
@pytest.mark.parametrize("name,values", [
("data/checksum", pa.array([b"\xff", None], type=pa.binary())),
("meta/captured_at", pa.array(
[1700000000123456789, None], type=pa.timestamp("ns", tz="UTC"))),
("stats/measurement/mean", pa.array(
[Decimal("123456789.123"), None], type=pa.decimal128(12, 3))),
("camera_intrinsics/head_left", pa.array([b"invalid", None])),
])
def test_merge_rejects_non_json_episode_fields_before_create(setup, name, values):
conn, sources = setup
path = Path(sources[0][7:]) / "meta/episodes/chunk-000/file-000.parquet"
pq.write_table(pq.read_table(path).append_column(name, values), path)
with pytest.raises(ValueError, match=(
"source 0 Episode field .* must use a JSON-native Arrow type")):
conn.load_from_lerobot("merged", sources)
for suffix in ("_frames", "_datasets", "_episodes"):
with pytest.raises(TableNotExistException):
conn.get_table("merged" + suffix)
def test_merge_reordered_source_tasks_match_published_stats(setup):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
path = Path(sources[0][7:]) / "meta/tasks.parquet"
tasks = pq.read_table(path)
pq.write_table(tasks.take(pa.array([1, 0])), path)
conn.load_from_lerobot("merged", sources)
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"))
try:
assert reader.meta.tasks.index.tolist() == ["pick", "place", "stack"]
task_ids = [int(row["task_index"]) for row in reader.get_items(range(len(reader)))]
assert task_ids == [0, 0, 1, 1, 1, 1, 1, 2, 2, 2]
np.testing.assert_allclose(reader.meta.stats["task_index"]["mean"],
[np.mean(task_ids)])
assert [reader[index]["task"] for index in (0, 2, 5, 7)] == [
"pick", "place", "place", "stack"]
finally:
reader.close()
@pytest.mark.parametrize("component", ["tasks", "subtasks", "episodes"])
def test_merge_ignores_producer_metadata(setup, component):
conn, sources = setup
root = Path(sources[1][7:]) / "meta"
path = root / ("episodes/chunk-000/file-000.parquet" if component == "episodes"
else component + ".parquet")
table = pq.read_table(path)
schema_metadata = dict(table.schema.metadata or {})
if component == "episodes":
schema_metadata[b"producer"] = b"another-exporter"
else:
pandas_metadata = json.loads(schema_metadata[b"pandas"])
pandas_metadata["pandas_version"] = "1.5.0"
schema_metadata[b"pandas"] = json.dumps(pandas_metadata).encode()
pq.write_table(table.replace_schema_metadata(schema_metadata), path)
conn.load_from_lerobot("merged", sources)
assert rows(conn, "merged_frames").num_rows == 10
assert rows(conn, "merged_episodes").num_rows == 4
for kind, expected in (("task", ["pick", "place", "stack"]),
("subtask", ["reach", "grasp", "release"])):
assert vocabulary(conn, kind).index.tolist() == expected
@pytest.mark.parametrize("custom_source", [0, 1])
def test_merge_normalizes_task_text_columns(setup, custom_source):
conn, sources = setup
root = Path(sources[custom_source][7:]) / "meta"
for kind in ("task", "subtask"):
path = root / (kind + "s.parquet")
frame = pq.read_table(path).to_pandas()
frame.index.name = "instruction"
pq.write_table(pa.Table.from_pandas(frame), path)
conn.load_from_lerobot("merged", sources)
data = frame_rows(conn, "merged_frames")
assert data["task_index"].to_pylist() == [0, 0, 1, 1, 1] * 2
assert data["subtask_index"].to_pylist() == [0, 1, 0, 1, 0] * 2
for kind, expected in (("task", ["pick", "place", "stack"]),
("subtask", ["reach", "grasp", "release"])):
assert vocabulary(conn, kind).index.tolist() == expected
pytest.importorskip("datasets")
pytest.importorskip("torch")
dataset = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"))
assert dataset[5]["task"] == "place"
assert dataset[7]["task"] == "stack"
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_invalid_commit_options_fail_before_preparing_files(setup, import_parallelism):
from pypaimon.multimodal.lerobot import aggregate, ray_loader
conn, sources = setup
with patch.object(aggregate, "_prepare_dataset") as serial, \
patch.object(ray_loader, "_prepare_sources_ray") as parallel:
with pytest.raises(ValueError):
conn.load_from_lerobot(
"merged", sources, parallelism=import_parallelism,
options={"commit.max-retries": "invalid"})
serial.assert_not_called()
parallel.assert_not_called()
table = conn.get_table("merged_frames").raw_table
assert table.snapshot_manager().get_latest_snapshot() is None
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_merge_accepts_reordered_fields_and_control_quantiles(setup, import_parallelism):
conn, sources = setup
root = Path(sources[1][7:])
info_path = root / "meta/info.json"
info = json.loads(info_path.read_text())
info["features"] = dict(reversed(list(info["features"].items())))
info_path.write_text(json.dumps(info))
path = root / "meta/episodes/chunk-000/file-000.parquet"
episodes = pq.read_table(path)
pq.write_table(episodes.select([
name for name in reversed(episodes.column_names) if not name.endswith("/q50")]), path)
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism)
data = frame_rows(conn, "merged_frames")
assert data["index"].to_pylist() == list(range(5)) * 2
assert data["observation.state"].to_pylist() == [
[float(offset + i), float(i)] for offset in (0, 20) for i in range(5)]
episodes = episode_rows(conn, "merged_episodes")
assert episodes["dataset_from_index"].to_pylist() == [0, 2, 0, 2]
assert not any(name.endswith("/q50") for name in episodes.column_names)
@pytest.mark.parametrize("large_source", [0, 1])
def test_merge_accepts_large_string_labels(setup, large_source):
conn, sources = setup
for kind in ("task", "subtask"):
path = Path(sources[large_source][7:]) / ("meta/" + kind + "s.parquet")
data = pq.read_table(path)
fields = [field.with_type(pa.large_string()) if field.name == kind else field
for field in data.schema]
pq.write_table(data.cast(pa.schema(fields, metadata=data.schema.metadata)), path)
conn.load_from_lerobot("merged", sources)
for kind, labels in (("task", ["pick", "place", "stack"]),
("subtask", ["reach", "grasp", "release"])):
assert vocabulary(conn, kind).index.tolist() == labels
assert rows(conn, "merged_frames").num_rows == 10
def test_merge_preserves_integer_statistics(setup):
conn, sources = setup
extremes = [2**53 + 1, 2**53 + 3]
for uri, value in zip(sources, extremes):
root = Path(uri[7:])
path = root / "meta/info.json"
info = json.loads(path.read_text())
info["features"]["observation.state"]["dtype"] = "int64"
path.write_text(json.dumps(info))
path = root / "data/chunk-000/file-000.parquet"
data = pq.read_table(path)
data = data.set_column(data.schema.get_field_index("observation.state"),
"observation.state", pa.array([[value, value]] * 5,
type=pa.list_(pa.int64())))
pq.write_table(data, path)
path = root / "meta/stats.json"
stats = json.loads(path.read_text())
stats["observation.state"] = {
"min": [value, value], "max": [value, value],
"mean": [float(value)] * 2, "std": [0.0, 0.0], "count": [5]}
path.write_text(json.dumps(stats))
conn.load_from_lerobot("merged", sources)
stats = metadata(conn, "stats")["observation.state"]
np.testing.assert_array_equal(stats["min"], [extremes[0]] * 2)
np.testing.assert_array_equal(stats["max"], [extremes[1]] * 2)
np.testing.assert_array_equal(stats["count"], [10])
assert frame_rows(conn, "merged_frames")["observation.state"].to_pylist() == [
[value, value] for value in extremes for unused in range(5)]
assert _batch_stats(extremes)["min"] == [extremes[0]]
from pypaimon.multimodal.lerobot.aggregate import _merge_stats
summary = dict(stats, count=[2**53 + 1])
assert _merge_stats([summary, summary])["count"] == [2 * (2**53 + 1)]
@pytest.mark.parametrize("field", ["robot_type", "features", "stats"])
def test_incompatible_sources_fail_before_creation(setup, field):
conn, sources = setup
root = Path(sources[1][7:])
if field == "stats":
(root / "meta/stats.json").unlink()
else:
path = root / "meta/info.json"
info = json.loads(path.read_text())
if field == "features":
info[field]["action"]["names"] = ["different", "axes"]
else:
info[field] = "different"
path.write_text(json.dumps(info))
with pytest.raises(ValueError):
conn.load_from_lerobot("merged", sources)
with pytest.raises(TableNotExistException):
conn.get_table("merged_frames")
def test_missing_stats_and_existing_target(setup):
conn, sources = setup
for uri in sources:
(Path(uri[7:]) / "meta/stats.json").unlink()
conn.load_from_lerobot("merged", sources)
with pytest.raises(TableNotExistException):
conn.get_table("merged_stats")
with pytest.raises(ValueError, match="already exists"):
conn.load_from_lerobot("merged", sources)
assert rows(conn, "merged_frames").num_rows == 10
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_single_source_metadata_opt_in(setup, import_parallelism):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
conn.load_from_lerobot("single", sources[0], dataset_name="single",
parallelism=import_parallelism)
reader = pm.PaimonLeRobotDataset(conn.get_table("single_frames"))
info = reader.reader.meta.info
assert info["fps"] == 10
assert "fps_mode" not in info
assert [json.loads(value)["fps"] for value in
rows(conn, "single_datasets")["info"].to_pylist()] == [10]
assert rows(conn, "single_episodes")["dataset_index"].to_pylist() == [0, 0]
pytest.importorskip("datasets")
assert reader.fps == 10
assert reader.episode_fps == (10, 10)
def test_source_info_remains_distinct_from_merged_info(setup):
conn, sources = setup
for ordinal, uri in enumerate(sources):
path = Path(uri[7:]) / "meta/info.json"
info = json.loads(path.read_text())
info["vendor_extension"] = {"source": ordinal}
path.write_text(json.dumps(info))
conn.load_from_lerobot("merged", sources,
dataset_name=["move cup", "empty cup"])
assert "vendor_extension" not in metadata(conn, "info")
datasets = rows(conn, "merged_datasets").sort_by("index")
assert datasets["name"].to_pylist() == ["move cup", "empty cup"]
assert [json.loads(value)["vendor_extension"]["source"]
for value in datasets["info"].to_pylist()] == [0, 1]
@pytest.mark.parametrize("dataset_index", [None, 0, 1])
def test_source_selection_derives_unequal_ranges_from_info(setup, dataset_index):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
root = Path(sources[0][7:])
path = root / "data/chunk-000/file-000.parquet"
pq.write_table(pq.read_table(path).slice(0, 2), path)
path = root / "meta/episodes/chunk-000/file-000.parquet"
pq.write_table(pq.read_table(path).slice(0, 1), path)
path = root / "meta/info.json"
info = json.loads(path.read_text())
info.update(total_frames=2, total_episodes=1, splits={"train": "0:1"})
path.write_text(json.dumps(info))
for uri in sources:
(Path(uri[7:]) / "meta/stats.json").unlink()
conn.load_from_lerobot("merged", sources)
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), dataset_index=dataset_index)
try:
indices = list(range(7)) if dataset_index is None else (
[0, 1] if dataset_index == 0 else list(range(2, 7)))
samples = reader.get_items(list(range(len(reader))))
assert [int(sample["index"]) for sample in samples] == indices
expected_episodes = [0, 0, 1, 1, 2, 2, 2]
assert [int(sample["episode_index"]) for sample in samples] == [
expected_episodes[index] for index in indices]
finally:
reader.close()
@pytest.mark.parametrize("field,counts", [
("total_episodes", [1, 3]),
("total_frames", [4, 6]),
("total_frames", [6, 4]),
])
def test_source_info_counts_must_match_episode_layout(setup, field, counts):
from pypaimon.multimodal.lerobot.dataset import _source_dataset_fps
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
conn.load_from_lerobot("merged", sources)
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"))
try:
for dataset, count in zip(reader.meta.datasets, counts):
info = json.loads(dataset["info"])
info[field] = count
dataset["info"] = json.dumps(info)
with pytest.raises(ValueError):
_source_dataset_fps(reader.meta, reader.fps, 4)
finally:
reader.close()
def test_nested_lerobot_source_does_not_infer_business_name(tmp_path):
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
uri = source(tmp_path / "episode-uuid" / "lerobot")
conn.load_from_lerobot("merged", [uri])
assert rows(conn, "merged_datasets")["name"].to_pylist() == [None]
def test_shared_business_label_does_not_merge_source_datasets(setup):
conn, sources = setup
conn.load_from_lerobot("merged", sources,
dataset_name=["same task", "same task"])
datasets = rows(conn, "merged_datasets").sort_by("index")
assert datasets["index"].to_pylist() == [0, 1]
assert datasets["name"].to_pylist() == ["same task", "same task"]
assert episode_rows(conn, "merged_episodes")["episode_index"].to_pylist() == [
0, 1, 0, 1]
def test_dataset_name_validate_before_source_access(tmp_path):
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
with patch("pypaimon.multimodal.lerobot.source._resolved_source",
side_effect=AssertionError("source accessed")):
with pytest.raises(ValueError, match="dataset_name"):
conn.load_from_lerobot("merged", ["a", "b"],
dataset_name=["only one name"])
@pytest.mark.parametrize("sources,parallelism", [([], 1), (["unused", "unused"], 0)])
def test_invalid_arguments_before_io(tmp_path, sources, parallelism):
conn = pm.connect(options={"warehouse": str(tmp_path)})
with patch("pypaimon.multimodal.lerobot.source._resolved_source", side_effect=AssertionError("source accessed")):
with pytest.raises(ValueError):
conn.load_from_lerobot("merged", sources, parallelism=parallelism)
@pytest.mark.parametrize("failure", ["write", "tag"])
def test_failure_does_not_publish_root_tag(setup, failure):
from pypaimon.multimodal.lerobot import aggregate
conn, sources = setup
if failure == "write":
prepare = aggregate._prepare_dataset
calls = []
def fail_second(*args, **kwargs):
calls.append(1)
if len(calls) == 2:
raise RuntimeError("injected")
return prepare(*args, **kwargs)
target = patch.object(aggregate, "_prepare_dataset", side_effect=fail_second)
else:
target = patch("pypaimon.multimodal.lerobot.metadata._create_tag", side_effect=RuntimeError("injected"))
with target, pytest.raises(RuntimeError, match="injected"):
conn.load_from_lerobot("merged", sources, tag_name="train")
table = conn.get_table("merged_frames").raw_table
assert not table.tag_manager().tag_exists("train")
if failure == "write":
assert table.snapshot_manager().get_latest_snapshot() is None
else:
assert rows(conn, "merged_frames").num_rows == 10
assert all(Path(uri[7:]).is_dir() for uri in sources)
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_video_sources_with_identical_paths(tmp_path, import_parallelism):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
sources = [source(tmp_path / "a", video=True, value=0),
source(tmp_path / "b", video=True, value=100)]
conn.load_from_lerobot("merged", sources, batch_size=2, tag_name="train",
parallelism=import_parallelism)
table = conn.get_table("merged_frames").raw_table
imported = frame_rows(conn, "merged_frames")
episodes = episode_rows(conn, "merged_episodes")
assert not any(name.startswith("videos/") for name in episodes.column_names)
for episode in episodes.to_pylist():
video = json.loads(episode["videos"])
assert set(video) == {"camera/" + suffix for suffix in (
"chunk_index", "file_index", "from_timestamp", "to_timestamp")}
for source_index, uri in enumerate(sources):
descriptor = VideoFrameDescriptor.deserialize(
imported["camera"][source_index * 5].as_py()).payload_descriptor
reader = table.file_io.uri_reader_factory.create(descriptor.uri)
stored_mp4 = Blob.from_descriptor(reader, descriptor).to_data()
original_mp4 = (Path(uri[7:]) /
"videos/camera/chunk-000/file-000.mp4").read_bytes()
assert stored_mp4 == original_mp4
dataset = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), tag_name="train", video_backend="pyav")
assert dataset[0]["camera"].mean().item() < 0.05
assert dataset[5]["camera"].mean().item() > 0.3
assert dataset[5]["frame_index"].item() == 0
import av
for source_index, uri in enumerate(sources):
path = Path(uri[7:]) / "videos/camera/chunk-000/file-000.mp4"
with av.open(str(path)) as container:
expected = [frame.to_ndarray(format="rgb24") for frame in container.decode(video=0)]
for frame_index in (0, 4):
actual = dataset[source_index * 5 + frame_index]["camera"].numpy()
np.testing.assert_allclose(actual, expected[frame_index].transpose(2, 0, 1) / 255.0)
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_mixed_fps_group_keeps_episode_rates(tmp_path, import_parallelism):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
sources = [source(tmp_path / "a", video=True, fps=25, frames=16),
source(tmp_path / "b", video=True, fps=30, value=100, frames=16)]
conn.load_from_lerobot("merged", sources, batch_size=2, tag_name="train",
parallelism=import_parallelism)
info = metadata(conn, "info")
assert info["fps"] is None
assert "fps_mode" not in info
datasets = rows(conn, "merged_datasets").sort_by("index")
assert "fps" not in datasets.column_names
assert [json.loads(value)["fps"] for value in
datasets["info"].to_pylist()] == [25, 30]
episodes = episode_rows(conn, "merged_episodes")
assert "fps" not in episodes.column_names
assert episodes["dataset_index"].to_pylist() == [0, 0, 1, 1]
scoped = pm.PaimonLeRobotDataset(
conn.get_table("merged_frames"), tag_name="train", dataset_index=1,
video_backend="pyav")
assert len(scoped) == 16
assert scoped.fps == 30
assert scoped[0]["index"].item() == 16
dataset = pm.PaimonLeRobotDataset(
conn.get_table("merged_frames"), tag_name="train", video_backend="pyav",
delta_timestamps={"action": [-0.2, 0, 0.2]})
assert dataset.fps is None
assert dataset.episode_fps == (25, 25, 30, 30)
assert "video.fps" not in info["features"]["camera"]["info"]
# 0.2 seconds means five frames at 25 FPS and six frames at 30 FPS.
expected = {7: [2, 7, 7], 8: [8, 8, 13], 15: [10, 15, 15],
16: [0, 0, 6], 23: [1, 7, 7]}
for index, positions in expected.items():
assert dataset[index]["action"][:, 0].tolist() == positions
assert dataset[7]["action_is_pad"].tolist() == [False, False, True]
assert dataset[16]["action_is_pad"].tolist() == [True, False, False]
assert dataset[1]["timestamp"].item() == pytest.approx(1 / 25)
assert dataset[17]["timestamp"].item() == pytest.approx(1 / 30)
av = pytest.importorskip("av")
for ordinal, directory in enumerate(("a", "b")):
with av.open(str(tmp_path / directory / "videos/camera/chunk-000/file-000.mp4")) as container:
reference = [frame.to_ndarray(format="rgb24") for frame in container.decode(video=0)]
for index, frame in enumerate(reference):
np.testing.assert_allclose(dataset[ordinal * 16 + index]["camera"].numpy(),
frame.transpose(2, 0, 1) / 255.0)
selected = pm.PaimonLeRobotDataset(
conn.get_table("merged_frames"), episodes=[2], video_backend="pyav",
delta_timestamps={"action": [0, 1 / 30]})
assert selected[0]["action"][:, 0].tolist() == [0, 1]
assert next(iter(dataset.to_dataloader(batch_size=2, num_workers=0)))[
"action"].shape == (2, 3, 2)
with pytest.raises(ValueError, match="multiples of 1/25"):
pm.PaimonLeRobotDataset(
conn.get_table("merged_frames"), tag_name="train",
delta_timestamps={"action": [0.1]})
def test_vocabulary_columns_without_pandas_index_are_preserved(setup):
conn, sources = setup
for uri in sources:
root = Path(uri[7:])
for component in ("tasks", "subtasks"):
path = root / ("meta/%s.parquet" % component)
table = pq.read_table(path).replace_schema_metadata(None)
pq.write_table(table, path)
conn.load_from_lerobot("merged", sources)
dataset = rows(conn, "merged_datasets").sort_by("index").to_pylist()[0]
for component in ("tasks", "subtasks"):
expected = pq.read_table(Path(sources[0][7:]) / ("meta/%s.parquet" % component))
actual = dataset[component]
assert actual == expected.select([component[:-1] + "_index", component[:-1]]).to_pylist()
def test_official_merge_reference_and_local_sources(tmp_path):
pytest.importorskip("datasets")
pytest.importorskip("torch")
module = pytest.importorskip("lerobot.datasets.lerobot_dataset")
official = pytest.importorskip("lerobot.datasets.aggregate")
roots = []
for n in range(2):
root = tmp_path / ("source%d" % n)
dataset = module.LeRobotDataset.create(
repo_id="test/source%d" % n, root=root, fps=10, use_videos=False,
robot_type="test", features={
"observation.state": {"dtype": "float32", "shape": (2,), "names": ["x", "y"]},
"action": {"dtype": "float32", "shape": (2,), "names": ["x", "y"]},
})
for ep in range(2):
for i in range(3):
dataset.add_frame({
"observation.state": np.array([n, i], dtype=np.float32),
"action": np.array([i, -i], dtype=np.float32),
"task": ("pick", "place", "stack")[n + ep],
})
dataset.save_episode()
dataset.finalize()
roots.append(root)
reference = tmp_path / "official"
official.aggregate_datasets(
repo_ids=["test/source0", "test/source1"], aggr_repo_id="test/merged",
roots=roots, aggr_root=reference)
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
conn.load_from_lerobot("merged", roots, tag_name="train", batch_size=2)
expected = pa.concat_tables([pq.read_table(p) for p in sorted((reference / "data").rglob("*.parquet"))])
actual = frame_rows(conn, "merged_frames")
for name in expected.column_names:
if name not in ("index", "episode_index", "task_index", "subtask_index"):
np.testing.assert_allclose(actual[name].to_pylist(), expected[name].to_pylist())
assert actual["episode_index"].to_pylist() == [0] * 3 + [1] * 3 + [0] * 3 + [1] * 3
assert actual["dataset_index"].to_pylist() == [0] * 6 + [1] * 6
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), tag_name="train")
try:
assert [int(sample["index"]) for sample in reader.get_items(range(12))] == (
expected["index"].to_pylist())
assert [int(sample["episode_index"]) for sample in reader.get_items(range(12))] == (
expected["episode_index"].to_pylist())
finally:
reader.close()
info = metadata(conn, "info")
reference_info = json.loads((reference / "meta/info.json").read_text())
for name in ("total_frames", "total_episodes", "total_tasks", "splits"):
assert info[name] == reference_info[name]
reference_stats = json.loads((reference / "meta/stats.json").read_text())
for name in ("observation.state", "action"):
for key in ("min", "max", "mean", "std", "count"):
np.testing.assert_allclose(metadata(conn, "stats")[name][key], reference_stats[name][key])
# A string source continues through the unchanged single-source importer.
conn.load_from_lerobot("single", str(roots[0]))
assert rows(conn, "single_frames").num_rows == 6
def test_weighted_population_statistics():
from pypaimon.multimodal.lerobot.aggregate import _merge_stats
result = _merge_stats([_batch_stats([0, 2]), _batch_stats([10, 10, 10])])
np.testing.assert_allclose(result["mean"], [6.4])
np.testing.assert_allclose(result["std"], [np.std([0, 2, 10, 10, 10])])
assert result["count"] == [5]
def test_invalid_episode_ranges_fail_before_creation(setup):
conn, sources = setup
path = Path(sources[1][7:]) / "meta/episodes/chunk-000/file-000.parquet"
data = pq.read_table(path)
data = data.set_column(data.schema.get_field_index("dataset_from_index"),
"dataset_from_index", pa.array([1, 2], type=pa.int64()))
pq.write_table(data, path)
with pytest.raises(ValueError, match="source 1"):
conn.load_from_lerobot("merged", sources)
with pytest.raises(TableNotExistException):
conn.get_table("merged_frames")
def test_ray_merge_multifile_sources_matches_serial(setup, ray_cluster):
conn, sources = setup
for uri in sources:
root = Path(uri[7:])
path = root / "data/chunk-000/file-000.parquet"
data = pq.read_table(path)
pq.write_table(data.slice(0, 2), path)
pq.write_table(data.slice(2), path.with_name("file-001.parquet"))
episodes_path = root / "meta/episodes/chunk-000/file-000.parquet"
episodes = pq.read_table(episodes_path)
episodes = episodes.set_column(
episodes.schema.get_field_index("data/file_index"),
"data/file_index", pa.array([0, 1], type=pa.int64()))
pq.write_table(episodes, episodes_path)
conn.load_from_lerobot("serial", sources, batch_size=1)
conn.load_from_lerobot("parallel", sources, batch_size=1, parallelism=2)
for suffix, key in (
("_frames", "index"),
("_episodes", [("dataset_index", "ascending"),
("episode_index", "ascending")]),
("_datasets", "index")):
assert rows(conn, "serial" + suffix).sort_by(key).equals(
rows(conn, "parallel" + suffix).sort_by(key))
def test_ray_worker_failure_does_not_commit(setup, ray_cluster):
conn, sources = setup
path = Path(sources[1][7:]) / "data/chunk-000/file-000.parquet"
data = pq.read_table(path)
data = data.set_column(data.schema.get_field_index("index"), "index",
pa.array([9, 1, 2, 3, 4], type=pa.int64()))
pq.write_table(data, path)
with pytest.raises(Exception, match="index"):
conn.load_from_lerobot("merged", sources, parallelism=2, tag_name="train")
table = conn.get_table("merged_frames").raw_table
assert table.snapshot_manager().get_latest_snapshot() is None
assert not table.tag_manager().tag_exists("train")
assert all(Path(uri[7:]).is_dir() for uri in sources)
@pytest.mark.parametrize("setup", [20], indirect=True)
@pytest.mark.parametrize("rate", [None, 0, -1, True, 10.5, 20])
def test_source_fps_conflicts_before_creation(setup, tmp_path, rate):
conn, sources = setup
path = tmp_path / "a/meta/episodes/chunk-000/file-000.parquet"
data = pq.read_table(path).append_column("fps", pa.array([rate, rate]))
pq.write_table(data, path)
with pytest.raises(ValueError, match="fps"):
conn.load_from_lerobot("invalid", sources)
with pytest.raises(TableNotExistException):
conn.get_table("invalid_frames")
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
@pytest.mark.parametrize("multiple", [False, True])
def test_existing_source_episode_fps(setup, tmp_path, import_parallelism, multiple):
conn, sources = setup
path = tmp_path / "a/meta/episodes/chunk-000/file-000.parquet"
pq.write_table(pq.read_table(path).append_column("fps", pa.array([10, 10])), path)
conn.load_from_lerobot("merged", sources if multiple else [sources[0]],
parallelism=import_parallelism)
episodes = episode_rows(conn, "merged_episodes")
assert episodes["fps"].to_pylist() == (
[10, 10, None, None] if multiple else [10, 10])
assert episodes["dataset_index"].to_pylist() == (
[0, 0, 1, 1] if multiple else [0, 0])
assert [json.loads(value)["fps"] for value in
rows(conn, "merged_datasets")["info"].to_pylist()] == (
[10, 10] if multiple else [10])
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
@pytest.mark.parametrize("all_null", [False, True])
def test_merge_nullable_subtask_import(setup, tmp_path, import_parallelism, all_null):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
for directory in ("a", "b"):
path = tmp_path / directory / "data/chunk-000/file-000.parquet"
data = pq.read_table(path)
values = [None] * 5 if all_null else [0, None, 0, 1, None]
data = data.set_column(data.schema.get_field_index("subtask_index"),
"subtask_index", pa.array(values, type=pa.int64()))
pq.write_table(data, path)
if all_null:
path = tmp_path / directory / "meta/subtasks.parquet"
pq.write_table(pq.read_table(path).slice(0, 0), path)
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism, tag_name="train")
expected = [None] * 10 if all_null else [0, None, 0, 1, None] * 2
assert frame_rows(conn, "merged_frames")["subtask_index"].to_pylist() == expected
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), tag_name="train")
assert reader[1]["subtask"] is None
batch = next(iter(reader.to_dataloader(batch_size=5, num_workers=0)))
assert batch["subtask_index"][1].item() == -1
assert not batch["subtask_index_is_valid"][1].item()
if all_null:
assert "subtask_index" not in metadata(conn, "stats")
assert all(all(metric is None for metric in json.loads(value)
["subtask_index"].values()) for value in
rows(conn, "merged_episodes")["stats"].to_pylist())
if not all_null:
windowed = pm.PaimonLeRobotDataset(
conn.get_table("merged_frames"), delta_timestamps={"subtask_index": [-0.1, 0, 0.1]})
first, second = windowed.get_items([0, 0])
first["subtask_index"][0].fill_(-100)
assert first["subtask_index"][1].item() == 0
assert second["subtask_index"][0].item() == 0
@pytest.mark.parametrize("rate", [None, 0, -1, True, 10.5])
@pytest.mark.parametrize("setup", [20], indirect=True)
def test_reader_rejects_invalid_source_fps(setup, rate):
pytest.importorskip("datasets")
pytest.importorskip("torch")
class Reader(pm.PaimonDatasetReader):
def read_indices(self, indices, columns):
raise AssertionError("Invalid FPS must fail before reading frames")
conn, sources = setup
conn.load_from_lerobot("merged", sources)
meta = pm.PaimonLeRobotDataset(conn.get_table("merged_frames")).meta
info = json.loads(meta.datasets[0]["info"])
info["fps"] = rate
meta.datasets[0]["info"] = json.dumps(info)
with pytest.raises(ValueError, match="fps"):
Reader(meta)
@pytest.mark.parametrize("legacy_fps", [False, True])
def test_reader_accepts_legacy_source_info(setup, legacy_fps):
from pypaimon.multimodal.lerobot.dataset import _source_dataset_fps
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
conn.load_from_lerobot("merged", sources)
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"))
try:
for dataset in reader.meta.datasets:
dataset["dataset_index"] = dataset.pop("index")
dataset["info_json"] = dataset.pop("info")
if legacy_fps:
dataset["fps"] = json.loads(dataset["info_json"])["fps"]
assert _source_dataset_fps(reader.meta, reader.fps, 4) == (10, 10, 10, 10)
finally:
reader.close()
def test_reader_requires_fps_without_episode_column(setup):
pytest.importorskip("datasets")
pytest.importorskip("torch")
from pypaimon.multimodal.lerobot.metadata import _overwrite_arrow
conn, sources = setup
conn.load_from_lerobot("merged", sources)
original = rows(conn, "merged_datasets")
info = original.to_pylist()
for row in info:
source_info = json.loads(row["info"])
source_info["fps"] = None
row["info"] = json.dumps(source_info)
from pypaimon.multimodal.table import _target_schema
target = conn.get_table("merged_datasets").raw_table
_overwrite_arrow(target, pa.Table.from_pylist(info, schema=_target_schema(target)))
with pytest.raises(ValueError, match="missing fps"):
pm.PaimonLeRobotDataset(conn.get_table("merged_frames"))
def test_episode_fps_rejects_conflicting_video_feature(tmp_path):
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
uri = source(tmp_path / "source", video=True, fps=25)
path = tmp_path / "source/meta/info.json"
info = json.loads(path.read_text())
info["features"]["camera"]["info"]["video.fps"] = 30
path.write_text(json.dumps(info))
with pytest.raises(ValueError, match="FPS"):
conn.load_from_lerobot("invalid", [uri])
with pytest.raises(TableNotExistException):
conn.get_table("invalid_frames")
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
@pytest.mark.parametrize("empty_episode", [False, True])
def test_reconstructed_subtask_stats_use_output_schema(setup, import_parallelism, empty_episode):
conn, sources = setup
for uri in sources:
root = Path(uri[7:])
path = root / "data/chunk-000/file-000.parquet"
data = pq.read_table(path)
values = [None, None, 0, 1, 0] if empty_episode else [0, None, 0, 1, 0]
data = data.set_column(data.schema.get_field_index("subtask_index"), "subtask_index",
pa.array(values, type=pa.int64()))
pq.write_table(data, path)
path = root / "meta/episodes/chunk-000/file-000.parquet"
episodes = pq.read_table(path)
key = "stats/subtask_index/mean"
if not empty_episode:
episodes = episodes.set_column(episodes.schema.get_field_index(key), key,
pa.array([None, [0.5]], type=pa.list_(pa.float64())))
schema = pa.schema([
field.with_nullable(not empty_episode and field.name == key)
if field.name.startswith("stats/subtask_index/") else field
for field in episodes.schema])
pq.write_table(episodes.cast(schema), path)
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism)
episodes = episode_rows(conn, "merged_episodes")
stats = [json.loads(value) for value in episodes["stats"].to_pylist()]
assert [value.get("subtask_index", {}).get("mean") for value in stats[::2]] == (
[None, None] if empty_episode else [[0.0], [1.0]])
assert not any(name.startswith("stats/") for name in episodes.column_names)
assert rows(conn, "merged_frames").num_rows == 10
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_episode_validation_failure_aborts_before_frames_commit(setup, import_parallelism):
from pypaimon.multimodal.lerobot import aggregate
conn, sources = setup
original = aggregate._merged_episodes
def invalid(plans, schema):
for table in original(plans, schema):
# A malformed replacement must be rejected before any Frames commit.
yield table.drop(["episode_index"])
with patch.object(aggregate, "_merged_episodes", side_effect=invalid):
with pytest.raises(ValueError, match="episode_index"):
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism, tag_name="train")
table = conn.get_table("merged_frames").raw_table
assert table.snapshot_manager().get_latest_snapshot() is None
assert not table.tag_manager().tag_exists("train")
@pytest.mark.parametrize("reverse", [False, True])
def test_float_extrema_propagate_nan(setup, reverse):
conn, sources = setup
path = Path(sources[0][7:]) / "meta/stats.json"
stats = json.loads(path.read_text())
for key in ("min", "max"):
stats["observation.state"][key][0] = float("nan")
path.write_text(json.dumps(stats))
conn.load_from_lerobot("merged", sources[::-1] if reverse else sources)
merged = metadata(conn, "stats")["observation.state"]
assert np.isnan(merged["min"][0])
assert np.isnan(merged["max"][0])
assert merged["min"][1] == 0
assert merged["max"][1] == 4
@pytest.mark.parametrize("multiple", [False, True])
@pytest.mark.parametrize("video_rate", ["10", 10.000005])
def test_default_fps_preserves_importer_contract(tmp_path, multiple, video_rate):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
uri = source(tmp_path / "source", video=True)
path = tmp_path / "source/meta/info.json"
info = json.loads(path.read_text())
info["features"]["camera"]["info"]["video.fps"] = video_rate
path.write_text(json.dumps(info))
path = tmp_path / "source/meta/episodes/chunk-000/file-000.parquet"
pq.write_table(pq.read_table(path).append_column("fps", pa.array([10.0, 10.0])), path)
conn.load_from_lerobot("merged", [uri] if multiple else uri)
episodes = rows(conn, "merged_episodes")
assert episodes["fps"].to_pylist() == [10.0, 10.0]
assert "fps_mode" not in metadata(conn, "info")
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), video_backend="pyav")
try:
assert reader.episode_fps == (10, 10)
assert tuple(reader[0]["camera"].shape) == (3, 16, 16)
finally:
reader.close()
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_nullable_batch_type_stays_stable_after_import(setup, import_parallelism):
pytest.importorskip("datasets")
torch = pytest.importorskip("torch")
conn, sources = setup
path = Path(sources[0][7:]) / "data/chunk-000/file-000.parquet"
data = pq.read_table(path)
data = data.set_column(data.schema.get_field_index("subtask_index"), "subtask_index",
pa.array([0, None, 0, 1, 0], type=pa.int64()))
pq.write_table(data, path)
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism)
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"))
try:
batches = list(reader.to_dataloader(batch_size=2, num_workers=0))
assert batches[0]["subtask_index"].tolist() == [0, -1]
assert batches[0]["subtask_index_is_valid"].tolist() == [True, False]
for batch in batches:
assert batch["subtask_index"].dtype == torch.int64
assert batch["subtask_index_is_valid"].dtype == torch.bool
assert batch["subtask_index"].shape == batch["subtask_index_is_valid"].shape
assert batches[1]["subtask_index_is_valid"].all()
finally:
reader.close()
@pytest.mark.parametrize("mode", ["episode", "source"])
def test_legacy_single_source_extensions(setup, mode):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
root = Path(sources[0][7:])
path = root / "meta/info.json"
info = json.loads(path.read_text())
info["fps_mode"] = mode
path.write_text(json.dumps(info))
path = root / "meta/episodes/chunk-000/file-000.parquet"
data = pq.read_table(path).append_column("vendor_note", pa.array(["a", "b"]))
pq.write_table(data, path)
conn.load_from_lerobot("single", sources[0], tag_name="train")
reader = pm.PaimonLeRobotDataset(conn.get_table("single_frames"), tag_name="train")
try:
assert reader.episode_fps == (10, 10)
assert reader.meta.episodes["vendor_note"] == ["a", "b"]
assert int(reader[2]["episode_index"]) == 1
finally:
reader.close()
@pytest.mark.parametrize("setup", [20], indirect=True)
def test_source_mode_rejects_broken_episode_link(setup):
pytest.importorskip("datasets")
pytest.importorskip("torch")
from datasets import Dataset
class Reader(pm.PaimonDatasetReader):
def read_indices(self, indices, columns):
raise AssertionError("Invalid metadata must fail before reading")
conn, sources = setup
conn.load_from_lerobot("merged", sources)
meta = pm.PaimonLeRobotDataset(conn.get_table("merged_frames")).meta
episodes = meta.episodes.to_dict()
episodes["dataset_index"][0] = 1
meta.episodes = Dataset.from_dict(episodes)
with pytest.raises(ValueError, match="dataset_index"):
Reader(meta)
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_merge_archives_standard_vocabulary_columns(setup, import_parallelism):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
originals = {}
for index, uri in enumerate(sources):
for kind in ("tasks", "subtasks"):
path = Path(uri[7:]) / ("meta/%s.parquet" % kind)
table = pq.read_table(path).append_column(
"native_bytes", pa.array([b"\xff" + bytes([index]), None], type=pa.binary()))
if index == 1:
table = table.append_column("source_note", pa.array(["extra", "column"]))
pq.write_table(table, path)
originals[index, kind] = pq.read_table(path).select([kind[:-1] + "_index", kind[:-1]])
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism, tag_name="train")
datasets = rows(conn, "merged_datasets").sort_by("index").to_pylist()
for index, dataset in enumerate(datasets):
for kind in ("tasks", "subtasks"):
restored = dataset[kind]
assert restored == originals[index, kind].to_pylist()
assert all(set(item) == {kind[:-1] + "_index", kind[:-1]} for item in restored)
assert set(conn.catalog.list_tables("default")) == {
"merged_frames", "merged_episodes", "merged_datasets"}
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), tag_name="train")
try:
samples = reader.get_items([0, 5])
assert [sample["task"] for sample in samples] == ["pick", "place"]
assert [sample["subtask"] for sample in samples] == ["reach", "grasp"]
finally:
reader.close()
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_merge_preserves_arrow_native_episode_extensions(setup, import_parallelism):
conn, sources = setup
for index, uri in enumerate(sources):
path = Path(uri[7:]) / "meta/episodes/chunk-000/file-000.parquet"
table = pq.read_table(path)
table = table.append_column("native_bytes", pa.array([b"\xff" + bytes([index]), None]))
table = table.append_column("captured_at", pa.array(
[1700000000123456789 + index, None], type=pa.timestamp("ns", tz="UTC")))
table = table.append_column("measurement", pa.array(
[Decimal("123456789.123"), None], type=pa.decimal128(12, 3)))
table = table.replace_schema_metadata({b"producer": b"native-source"})
pq.write_table(table, path)
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism, tag_name="train")
episodes = episode_rows(conn, "merged_episodes")
names = ["native_bytes", "captured_at", "measurement"]
for index, uri in enumerate(sources):
expected = pq.read_table(Path(uri[7:]) / "meta/episodes/chunk-000/file-000.parquet")
assert episodes.slice(index * 2, 2).select(names).to_pylist() == (
expected.select(names).to_pylist())
assert rows(conn, "merged_frames").num_rows == 10
def test_training_metadata_ignores_episode_map_extension(setup):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
path = Path(sources[0][7:]) / "meta/episodes/chunk-000/file-000.parquet"
values = pa.array([[("vendor", 1)], [("vendor", 2)]],
type=pa.map_(pa.string(), pa.int64()))
pq.write_table(pq.read_table(path).append_column("vendor_map", values), path)
conn.load_from_lerobot("merged", sources, tag_name="train")
episodes = episode_rows(conn, "merged_episodes")
assert episodes.schema.field("vendor_map").type == values.type
assert episodes["vendor_map"].to_pylist() == values.to_pylist() + [None, None]
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), tag_name="train")
try:
assert reader.num_episodes == 4
assert "vendor_map" not in reader.meta.episodes.column_names
assert int(reader[0]["episode_index"]) == 0
finally:
reader.close()
@pytest.mark.parametrize("dataset_index", [None, 1])
def test_training_projects_out_source_archives(setup, dataset_index):
from pypaimon.multimodal.lerobot import dataset as dataset_module
pytest.importorskip("torch")
pytest.importorskip("datasets")
conn, sources = setup
conn.load_from_lerobot("merged", sources, tag_name="train")
reads = {}
read_arrow = dataset_module._read_arrow
def checked_read(table, projection=None):
name = table.identifier.object
if name.endswith(("_datasets", "_episodes")):
assert projection is not None
assert "source_episode_patch_json" not in projection
if name.endswith("_datasets"):
assert "tasks" in projection
assert "subtasks" in projection
assert "stats" in projection
elif projection != ["stats"]:
assert "stats" not in projection
if projection != ["stats"]:
reads[name.rsplit("_", 1)[1]] = projection
result = read_arrow(table, projection)
if name.endswith(("_datasets", "_episodes")):
assert result.column_names == projection
return result
with patch.object(dataset_module, "_read_arrow", side_effect=checked_read):
dataset = pm.PaimonLeRobotDataset(
conn.get_table("merged_frames"), tag_name="train", dataset_index=dataset_index)
try:
assert dataset[0]["task"] == ("pick" if dataset_index is None else "place")
assert "source_episode_patch_json" not in dataset.meta.episodes.column_names
assert "stats" not in dataset.meta.episodes.column_names
assert all("tasks" not in row and "subtasks" not in row
for row in dataset.meta.datasets)
finally:
dataset.close()
assert set(reads) == {"datasets", "episodes"}
assert "tasks" in rows(conn, "merged_datasets").column_names
assert "subtasks" in rows(conn, "merged_datasets").column_names
assert "source_episode_patch_json" not in episode_rows(conn, "merged_episodes").column_names
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_merge_preserves_episode_timestamp(setup, import_parallelism):
conn, sources = setup
for uri in sources:
path = Path(uri[7:]) / "meta/episodes/chunk-000/file-000.parquet"
data = pq.read_table(path).append_column("capture_time", pa.array(
[1700000000123, None], type=pa.timestamp("ms", tz="America/New_York")))
pq.write_table(data, path)
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism, tag_name="train")
data = episode_rows(conn, "merged_episodes")
expected = pq.read_table(Path(sources[0][7:]) / "meta/episodes/chunk-000/file-000.parquet")
assert data["capture_time"].to_pylist() == expected["capture_time"].to_pylist() * 2
assert conn.catalog.get_tag(conn._identifier("merged_frames"), "train") is not None
@pytest.mark.parametrize("kind", ["task", "subtask"])
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_merge_rejects_ambiguous_source_labels(setup, kind, import_parallelism):
conn, sources = setup
path = Path(sources[0][7:]) / ("meta/%ss.parquet" % kind)
pq.write_table(pa.Table.from_pandas(pd.DataFrame(
{kind + "_index": [0, 1]}, index=pd.Index(["same", "same"], name=kind))), path)
message = "repeats task" if kind == "task" else "source 0 has duplicate subtask text"
with pytest.raises(ValueError, match=message):
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism)
with pytest.raises(TableNotExistException):
conn.get_table("merged_frames")
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_merge_archives_vocabulary_without_arrow_schema(setup, import_parallelism):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
originals = {}
for index, uri in enumerate(sources):
for kind in ("task", "subtask"):
path = Path(uri[7:]) / ("meta/%ss.parquet" % kind)
data = pq.read_table(path).select([kind + "_index", kind])
schema = pa.schema([
data.schema.field(kind + "_index"),
pa.field(kind, pa.string(), nullable=False, metadata={b"label": b"original"}),
], metadata={b"producer": b"arrow"})
pq.write_table(data.cast(schema), path)
originals[index, kind] = pq.read_table(path)
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism)
for row in rows(conn, "merged_datasets").to_pylist():
for kind in ("task", "subtask"):
restored = row[kind + "s"]
assert restored == originals[row["index"], kind].to_pylist()
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"))
try:
assert reader[0]["task"] == "pick"
assert reader[5]["subtask"] == "grasp"
finally:
reader.close()
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_nullable_mask_preserves_source_feature(setup, import_parallelism):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
for uri in sources:
root = Path(uri[7:])
path = root / "meta/info.json"
info = json.loads(path.read_text())
info["features"]["subtask_index_is_valid"] = {
"dtype": "bool", "shape": [1], "names": None}
path.write_text(json.dumps(info))
path = root / "data/chunk-000/file-000.parquet"
data = pq.read_table(path).append_column(
"subtask_index_is_valid", pa.array([False, True, False, True, False]))
data = data.set_column(data.schema.get_field_index("subtask_index"), "subtask_index",
pa.array([0, None, 0, 1, 0], type=pa.int64()))
pq.write_table(data, path)
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism)
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"))
try:
batch = next(iter(reader.to_dataloader(batch_size=2, num_workers=0)))
assert batch["subtask_index_is_valid"].tolist() == [False, True]
assert batch["_subtask_index_is_valid"].tolist() == [True, False]
assert batch["subtask_index"].tolist() == [0, -1]
finally:
reader.close()
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
@pytest.mark.parametrize("video_rate", ["10", 10.000005])
def test_merge_normalizes_video_fps(setup, tmp_path, import_parallelism, video_rate):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, unused = setup
sources = [source(tmp_path / "video_a", video=True),
source(tmp_path / "video_b", video=True)]
path = tmp_path / "video_b/meta/info.json"
info = json.loads(path.read_text())
info["features"]["camera"]["info"]["video.fps"] = video_rate
path.write_text(json.dumps(info))
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism)
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), video_backend="pyav")
try:
assert reader.episode_fps == (10, 10, 10, 10)
assert tuple(reader[5]["camera"].shape) == (3, 16, 16)
finally:
reader.close()
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
@pytest.mark.parametrize("reverse", [False, True])
def test_merge_source_specific_episode_extensions(setup, import_parallelism, reverse):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
original = sources[:]
for index, uri in enumerate(sources):
path = Path(uri[7:]) / "meta/episodes/chunk-000/file-000.parquet"
data = pq.read_table(path)
if index == 1:
data = data.append_column("vendor_note", pa.array(["note", None]))
data = data.append_column("camera_intrinsics/head_left", pa.array(
[[1.] * 8, [2.] * 8], type=pa.list_(pa.float32(), 8)))
data = data.append_column("vendor_value", pa.array(
[1, 2] if index == 0 else [3, 4]))
field = pa.field("shared_note", pa.string(), nullable=index == 1)
data = data.append_column(field, pa.array(["shared", "note"] if index == 0 else [None, "note"]))
pq.write_table(data, path)
if reverse:
sources.reverse()
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism, tag_name="train")
data = episode_rows(conn, "merged_episodes")
assert data.schema.field("vendor_note").nullable
assert data.schema.field("camera_intrinsics").type == pa.string()
assert data.schema.field("camera_intrinsics").nullable
for ordinal, uri in enumerate(sources):
source_index = original.index(uri)
values = data.slice(ordinal * 2, 2)
assert values["vendor_note"].to_pylist() == (
[None, None] if source_index == 0 else ["note", None])
assert values["vendor_value"].to_pylist() == (
[1, 2] if source_index == 0 else [3, 4])
assert values["shared_note"].to_pylist() == (
["shared", "note"] if source_index == 0 else [None, "note"])
assert [None if value is None else json.loads(value)
for value in values["camera_intrinsics"].to_pylist()] == (
[None, None] if source_index == 0 else [
{"head_left": [1.] * 8}, {"head_left": [2.] * 8}])
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), tag_name="train")
try:
assert [int(sample["episode_index"]) for sample in reader.get_items([0, 2, 5, 7])] == [0, 1, 2, 3]
finally:
reader.close()
def test_merge_rejects_conflicting_episode_extension_types(setup):
conn, sources = setup
for index, uri in enumerate(sources):
path = Path(uri[7:]) / "meta/episodes/chunk-000/file-000.parquet"
data = pq.read_table(path).append_column(
"vendor_value", pa.array([1, 2] if index == 0 else [b"a", b"b"]))
pq.write_table(data, path)
with pytest.raises(ValueError, match="incompatible type.*vendor_value"):
conn.load_from_lerobot("merged", sources)
with pytest.raises(TableNotExistException):
conn.get_table("merged_frames")
def test_merge_rejects_episode_json_group_column_collision(setup):
conn, sources = setup
for index, uri in enumerate(sources):
path = Path(uri[7:]) / "meta/episodes/chunk-000/file-000.parquet"
data = pq.read_table(path).append_column(
"camera_intrinsics" if index == 0 else "camera_intrinsics/head_left",
pa.array(["already a column"] * 2 if index == 0 else [[1.] * 8] * 2))
pq.write_table(data, path)
with pytest.raises(ValueError, match="reserved columns:.*camera_intrinsics"):
conn.load_from_lerobot("merged", sources)
with pytest.raises(TableNotExistException):
conn.get_table("merged_frames")
@pytest.mark.parametrize("name,values,error", [
("dataset_index/vendor", pa.array([1, 2]), "reserved column dataset_index"),
("vendor_flag", pa.array([1, 2], type=pa.uint8()), "Unsupported pyarrow type: uint8"),
])
def test_merge_rejects_unwritable_episode_extensions_before_create(setup, name, values, error):
conn, sources = setup
path = Path(sources[0][7:]) / "meta/episodes/chunk-000/file-000.parquet"
pq.write_table(pq.read_table(path).append_column(name, values), path)
with pytest.raises(ValueError, match=error):
conn.load_from_lerobot("merged", sources)
for component in ("frames", "episodes", "datasets"):
with pytest.raises(TableNotExistException):
conn.get_table("merged_" + component)
def test_merge_rejects_incompatible_episode_controls(setup):
conn, sources = setup
path = Path(sources[1][7:]) / "meta/episodes/chunk-000/file-000.parquet"
data = pq.read_table(path)
data = data.set_column(data.schema.get_field_index("length"), "length", data["length"].cast(pa.int32()))
pq.write_table(data, path)
with pytest.raises(ValueError, match="incompatible type.*length"):
conn.load_from_lerobot("merged", sources)
with pytest.raises(TableNotExistException):
conn.get_table("merged_frames")
@pytest.mark.parametrize("key", ["info", "video_info"])
@pytest.mark.parametrize("value", [None, "producer", []])
@pytest.mark.parametrize("reverse", [False, True])
def test_merge_optional_video_metadata(tmp_path, key, value, reverse):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
sources = [source(tmp_path / name, video=True, value=i * 30)
for i, name in enumerate(("a", "b"))]
for i, name in enumerate(("a", "b")):
path = tmp_path / name / "meta/info.json"
info = json.loads(path.read_text())
info["features"]["camera"].pop(key, None)
if i == 0:
info["features"]["camera"][key] = value
path.write_text(json.dumps(info))
if reverse:
sources.reverse()
conn.load_from_lerobot("merged", sources, tag_name="train")
assert metadata(conn, "info")["features"]["camera"][key] == {}
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), tag_name="train", video_backend="pyav")
try:
assert tuple(reader[5]["camera"].shape) == (3, 16, 16)
assert reader.episode_fps == (10, 10, 10, 10)
finally:
reader.close()
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_merge_reserves_fps_mode(setup, import_parallelism):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
for uri in sources:
path = Path(uri[7:]) / "meta/info.json"
info = json.loads(path.read_text())
info["fps_mode"] = "episode"
path.write_text(json.dumps(info))
conn.load_from_lerobot("merged", sources,
parallelism=import_parallelism, tag_name="train")
info = metadata(conn, "info")
assert "fps_mode" not in info
for row in rows(conn, "merged_datasets").to_pylist():
assert json.loads(row["info"])["fps_mode"] == "episode"
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), tag_name="train")
try:
assert int(reader[5]["episode_index"]) == 2
assert reader.episode_fps == (10, 10, 10, 10)
finally:
reader.close()
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_merge_int32_source_episode_ids(setup, import_parallelism):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
for uri in sources:
path = Path(uri[7:]) / "meta/episodes/chunk-000/file-000.parquet"
data = pq.read_table(path)
data = data.set_column(data.schema.get_field_index("episode_index"), "episode_index",
data["episode_index"].cast(pa.int32()))
pq.write_table(data, path)
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism, tag_name="train")
data = episode_rows(conn, "merged_episodes")
assert data.schema.field("episode_index").type == pa.int32()
assert data["episode_index"].to_pylist() == [0, 1, 0, 1]
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), tag_name="train")
try:
assert [int(s["episode_index"]) for s in reader.get_items([0, 2, 5, 7])] == [0, 1, 2, 3]
finally:
reader.close()
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_episode_sources_read_only_before_commit(setup, import_parallelism):
from pypaimon.multimodal.lerobot import aggregate
conn, sources = setup
original = aggregate._source_episode_tables
committed = []
real_commit = aggregate._commit_messages
def read_episodes(metadata):
if committed:
raise OSError("Episode source unavailable after Frames commit")
return original(metadata)
def commit(committer, messages):
snapshot = real_commit(committer, messages)
committed.append(snapshot)
return snapshot
with patch.object(aggregate, "_source_episode_tables", side_effect=read_episodes), \
patch.object(aggregate, "_commit_messages", side_effect=commit):
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism, tag_name="train")
assert rows(conn, "merged_frames").num_rows == 10
assert rows(conn, "merged_episodes").num_rows == 4
assert len(committed) == 2
@pytest.mark.parametrize("multiple", [False, True])
def test_ray_groups_within_each_source(setup, ray_cluster, multiple):
conn, sources = setup
for uri in sources:
root = Path(uri[7:])
path = root / "meta/episodes/chunk-000/file-000.parquet"
episodes = pq.read_table(path)
episodes = episodes.set_column(episodes.schema.get_field_index("data/file_index"),
"data/file_index", pa.array([0, 1]))
pq.write_table(episodes, path)
path = root / "data/chunk-000/file-000.parquet"
data = pq.read_table(path)
pq.write_table(data.slice(0, 2), path)
pq.write_table(data.slice(2), path.with_name("file-001.parquet"))
dispatched = []
remote = ray_cluster.remote
def track_remote(**options):
def decorate(function):
worker = remote(**options)(function)
class TrackedWorker:
def remote(self, *args):
dispatched.append(args[-1])
return worker.remote(*args)
return TrackedWorker()
return decorate
with patch.object(ray_cluster, "remote", side_effect=track_remote):
conn.load_from_lerobot("merged", sources if multiple else sources[0],
dataset_name=["A", "B"] if multiple else ["A"], parallelism=2)
assert dispatched == [[0], [1]] * (2 if multiple else 1)
assert rows(conn, "merged_frames").num_rows == (10 if multiple else 5)
assert rows(conn, "merged_episodes").num_rows == (4 if multiple else 2)
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_episode_prepare_io_failure_publishes_no_snapshots(setup, import_parallelism):
from pypaimon.multimodal.lerobot import aggregate
conn, sources = setup
original = aggregate._merged_episodes
def fail_after_first_batch(plans, schema):
batches = original(plans, schema)
yield next(batches)
raise OSError("Episode source read failed")
with patch.object(aggregate, "_merged_episodes", side_effect=fail_after_first_batch):
with pytest.raises(OSError, match="Episode source read failed"):
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism, tag_name="train")
for name in ("merged_frames", "merged_episodes"):
table = conn.get_table(name).raw_table
assert table.snapshot_manager().get_latest_snapshot() is None
assert not table.tag_manager().tag_exists("train")
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
@pytest.mark.parametrize("fail_commit", [1, 2])
def test_uncertain_commit_preserves_published_files(setup, tmp_path, fail_commit, import_parallelism):
from pypaimon.multimodal.lerobot import aggregate
conn, sources = setup
original = aggregate._commit_messages
calls = []
def lose_response(committer, messages):
result = original(committer, messages)
calls.append(result)
if len(calls) == fail_commit:
raise OSError("Lost commit response")
return result
with patch.object(aggregate, "_commit_messages", side_effect=lose_response):
with pytest.raises(OSError, match="Lost commit response"):
conn.load_from_lerobot("merged", sources, tag_name="train", parallelism=import_parallelism)
assert rows(conn, "merged_frames").num_rows == 10
if fail_commit == 2:
assert rows(conn, "merged_episodes").num_rows == 4
else:
assert conn.get_table("merged_episodes").raw_table.snapshot_manager().get_latest_snapshot() is None
assert not conn.get_table("merged_frames").raw_table.tag_manager().tag_exists("train")
files = list((tmp_path / "warehouse").rglob("*.parquet"))
assert any("merged_frames" in path.parts for path in files)
episode_files = [path for path in files if "merged_episodes" in path.parts]
assert bool(episode_files) == (fail_commit == 2)
@pytest.mark.parametrize("multiple", [False, True])
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_extra_dataset_text_fields(setup, multiple, import_parallelism):
from pypaimon.multimodal.lerobot import dataset as dataset_module
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
expected = []
for ordinal, uri in enumerate(sources):
root = Path(uri[7:])
(root / "config").mkdir()
contents = {"model_a": '{ "value": %d }\r\n' % ordinal,
"model_b": '{ "label": "示例" }\n'}
for name, text in contents.items():
(root / "config" / (name + ".json")).write_bytes(text.encode("utf-8"))
notes = "# Dataset %d\r\nNotes.\n" % ordinal
(root / "notes.md").write_bytes(notes.encode("utf-8"))
expected.append((contents, notes))
conn.load_from_lerobot(
table_name="merged", source=sources if multiple else sources[0],
dataset_name=["A", "B"] if multiple else "A", parallelism=import_parallelism,
extra_dataset_fields={"config": "config/*.json", "notes": "notes.md"}, tag_name="train")
data = rows(conn, "merged_datasets").sort_by("index")
assert pa.types.is_map(data.schema.field("config").type)
assert data.schema.field("notes").type == pa.string()
assert data["name"].to_pylist() == (["A", "B"] if multiple else ["A"])
for actual, (config, notes) in zip(data.to_pylist(), expected):
assert dict(actual["config"]) == config
assert actual["notes"] == notes
read_arrow = dataset_module._read_arrow
def checked_read(table, projection=None):
if table.identifier.object.endswith("_datasets"):
assert "config" not in projection
assert "notes" not in projection
return read_arrow(table, projection)
with patch.object(dataset_module, "_read_arrow", side_effect=checked_read):
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), tag_name="train")
try:
assert len(reader) == (10 if multiple else 5)
assert "config" not in reader.meta.datasets[0]
assert reader[0]["task"] == "pick"
finally:
reader.close()
@pytest.mark.parametrize("suffix", ["json", "txt", "md", "yaml", "yml", "jsonl", "ndjson", "csv", "tsv"])
def test_extra_dataset_supported_text_suffixes(setup, suffix):
conn, sources = setup
root = Path(sources[0][7:])
text = "Unparsed text\r\n示例\n"
filename = "notes." + suffix
(root / filename).write_bytes(text.encode("utf-8"))
conn.load_from_lerobot(table_name="merged", source=sources[0],
extra_dataset_fields={"notes": filename})
assert rows(conn, "merged_datasets")["notes"].to_pylist() == [text]
@pytest.mark.parametrize("fields", [
[], {"info": "notes.txt"}, {"name": "notes.txt"}, {"": "notes.txt"},
{1: "notes.txt"}, {"notes": None}, {"notes": "../notes.txt"},
{"notes": "/notes.txt"}, {"notes": "notes.bin"},
])
def test_extra_dataset_fields_validate_before_source_access(tmp_path, fields):
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
with patch("pypaimon.multimodal.lerobot.aggregate._resolved_source",
side_effect=AssertionError("source accessed")):
with pytest.raises(ValueError):
conn.load_from_lerobot("merged", "unused", extra_dataset_fields=fields)
assert conn.catalog.list_databases() == []
@pytest.mark.parametrize("failure", ["unsupported", "encoding", "duplicate"])
def test_invalid_extra_dataset_text_fails_before_table_creation(setup, failure):
conn, sources = setup
root = Path(sources[0][7:])
(root / "config").mkdir()
(root / "config/model.json").write_text("{}")
fields = {"config": "config/*"}
if failure == "unsupported":
(root / "config/model.bin").write_bytes(b"binary")
elif failure == "encoding":
(root / "config/model.json").write_bytes(b"\xff")
elif failure == "duplicate":
(root / "config/model.txt").write_text("same key")
with pytest.raises(ValueError):
conn.load_from_lerobot("merged", sources[0],
extra_dataset_fields=fields)
with pytest.raises(TableNotExistException):
conn.get_table("merged_frames")
@pytest.mark.parametrize("name", ["A", ["A"]])
def test_single_dataset_name_forms(setup, name):
conn, sources = setup
conn.load_from_lerobot("merged", sources[0], dataset_name=name)
assert rows(conn, "merged_datasets")["name"].to_pylist() == ["A"]
@pytest.mark.parametrize("name", ["A", ["A"], ["A", ""], ["A", 1], 1])
def test_invalid_dataset_name_for_multiple_sources(tmp_path, name):
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
with pytest.raises(ValueError, match="dataset_name"):
conn.load_from_lerobot("merged", ["a", "b"], dataset_name=name)
assert conn.catalog.list_databases() == []
def add_frame_feature(uri, name, dtype, values, shape=(1,)):
root = Path(uri[7:])
path = root / "meta/info.json"
info = json.loads(path.read_text())
info["features"][name] = {"dtype": dtype, "shape": list(shape), "names": None}
path.write_text(json.dumps(info))
path = root / "data/chunk-000/file-000.parquet"
from pypaimon.multimodal.lerobot.schema import _schema_from_info
field = _schema_from_info(info).field(name)
pq.write_table(pq.read_table(path).append_column(field, pa.array(values, type=field.type)), path)
if shape == (1,) and dtype.startswith("float"):
path = root / "meta/stats.json"
stats = json.loads(path.read_text())
stats[name] = _batch_stats(values)
path.write_text(json.dumps(stats))
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
@pytest.mark.parametrize("reverse", [False, True])
def test_schema_evolution_unions_three_tables(setup, import_parallelism, reverse):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
original = sources[:]
add_frame_feature(sources[0], "temperature", "float32", [1., 2., 3., 4., 5.])
add_frame_feature(sources[1], "pressure", "float64", [6., 7., 8., 9., 10.])
for index, uri in enumerate(sources):
root = Path(uri[7:])
path = root / "meta/episodes/chunk-000/file-000.parquet"
data = pq.read_table(path)
data = data.append_column("meta/note", pa.array(["a", "b"])) if index == 0 else (
data.append_column("meta/score", pa.array([0.5, 0.8])))
pq.write_table(data, path)
if index == 0:
(root / "config").mkdir()
(root / "config/model.json").write_text('{"name":"model"}')
else:
(root / "notes.md").write_text("# Notes")
if reverse:
sources.reverse()
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism,
extra_dataset_fields={"config": "config/*.json", "notes": "notes.md"})
data = rows(conn, "merged_frames").sort_by([
("dataset_index", "ascending"), ("index", "ascending")])
episodes = episode_rows(conn, "merged_episodes").to_pylist()
datasets = rows(conn, "merged_datasets").sort_by("index").to_pylist()
for ordinal, uri in enumerate(sources):
first = uri == original[0]
assert data["temperature"].slice(ordinal * 5, 5).to_pylist() == (
[1., 2., 3., 4., 5.] if first else [None] * 5)
assert data["pressure"].slice(ordinal * 5, 5).to_pylist() == (
[None] * 5 if first else [6., 7., 8., 9., 10.])
assert json.loads(episodes[ordinal * 2]["meta"]).get("note") == ("a" if first else None)
assert json.loads(episodes[ordinal * 2]["meta"]).get("score") == (None if first else 0.5)
assert datasets[ordinal]["notes"] == (None if first else "# Notes")
assert datasets[ordinal]["config"] == ([("model", '{"name":"model"}')] if first else None)
assert metadata(conn, "stats")["temperature"]["count"] == [5]
assert metadata(conn, "stats")["pressure"]["mean"] == [8.]
assert data.schema.field("temperature").nullable
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"),
delta_timestamps={"temperature": [-0.1, 0., 0.1]})
try:
samples = reader.get_items([0, 5])
for item, uri in zip(samples, sources):
assert (item["temperature"] is None) == (uri != original[0])
assert (item["pressure"] is None) == (uri == original[0])
batch = next(iter(reader.to_dataloader(batch_size=10, num_workers=0)))
assert isinstance(batch["temperature"], list)
finally:
reader.close()
@pytest.mark.parametrize("add_new", [False, True])
def test_schema_evolution_disabled_keeps_first_source_schema(setup, add_new):
conn, sources = setup
add_frame_feature(sources[1 if add_new else 0], "temperature", "float32", [1.] * 5)
if add_new:
with pytest.raises(ValueError, match="schema_evolution disabled"):
conn.load_from_lerobot("merged", sources, schema_evolution=False)
with pytest.raises(TableNotExistException):
conn.get_table("merged_frames")
else:
conn.load_from_lerobot("merged", sources, schema_evolution=False)
assert rows(conn, "merged_frames")["temperature"].null_count == 5
def test_schema_evolution_disabled_rejects_new_episode_json_group(setup):
conn, sources = setup
path = Path(sources[1][7:]) / "meta/episodes/chunk-000/file-000.parquet"
pq.write_table(pq.read_table(path).append_column(
"camera_intrinsics/head_left", pa.array([[1.] * 8] * 2)), path)
with pytest.raises(ValueError, match="schema_evolution disabled"):
conn.load_from_lerobot("merged", sources, schema_evolution=False)
with pytest.raises(TableNotExistException):
conn.get_table("merged_frames")
@pytest.mark.parametrize("kind", ["frame", "shape"])
def test_schema_evolution_rejects_type_changes(setup, kind):
conn, sources = setup
for index, uri in enumerate(sources):
if kind == "frame":
add_frame_feature(uri, "temperature", "float32" if index == 0 else "float64", [1.] * 5)
elif kind == "shape":
add_frame_feature(uri, "vector", "float32", [[1.] * (index + 2)] * 5, (index + 2,))
with pytest.raises(ValueError, match="incompatible"):
conn.load_from_lerobot("merged", sources)
with pytest.raises(TableNotExistException):
conn.get_table("merged_frames")
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_schema_evolution_allows_missing_video(tmp_path, import_parallelism):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
sources = [source(tmp_path / "with_video", video=True), source(tmp_path / "without_video")]
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism)
assert rows(conn, "merged_frames")["camera"].null_count == 5
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), video_backend="pyav",
delta_timestamps={"camera": [-0.1, 0., 0.1]})
try:
samples = reader.get_items([0, 5])
assert tuple(samples[0]["camera"].shape) == (3, 3, 16, 16)
assert samples[1]["camera"] is None
finally:
reader.close()
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_schema_evolution_allows_missing_images(setup, import_parallelism):
from PIL import Image
import io
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
buffer = io.BytesIO()
Image.new("RGB", (2, 2), color="red").save(buffer, format="PNG")
add_frame_feature(sources[0], "image", "image", [buffer.getvalue()] * 5, (2, 2, 3))
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism)
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"))
try:
samples = reader.get_items([0, 5])
assert tuple(samples[0]["image"].shape) == (3, 2, 2)
assert samples[1]["image"] is None
finally:
reader.close()
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
def test_schema_evolution_allows_optional_subtask_column(setup, import_parallelism):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
root = Path(sources[0][7:])
path = root / "meta/info.json"
info = json.loads(path.read_text())
del info["features"]["subtask_index"]
path.write_text(json.dumps(info))
path = root / "data/chunk-000/file-000.parquet"
pq.write_table(pq.read_table(path).drop(["subtask_index"]), path)
(root / "meta/subtasks.parquet").unlink()
path = root / "meta/stats.json"
stats = json.loads(path.read_text())
del stats["subtask_index"]
path.write_text(json.dumps(stats))
conn.load_from_lerobot("merged", sources, parallelism=import_parallelism)
assert rows(conn, "merged_frames")["subtask_index"].null_count == 5
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"))
try:
samples = reader.get_items([0, 5])
assert samples[0]["subtask"] is None
assert samples[1]["subtask"] == "grasp"
finally:
reader.close()
@pytest.mark.parametrize("value", [None, 1, "true"])
def test_schema_evolution_option_requires_boolean(tmp_path, value):
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
with pytest.raises(ValueError, match="schema_evolution"):
conn.load_from_lerobot("merged", "unused", schema_evolution=value)
assert conn.catalog.list_databases() == []
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
@pytest.mark.parametrize("via_load", [False, True])
def test_append_evolves_frame_and_dataset_columns(setup, tmp_path, import_parallelism, via_load):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn, sources = setup
add_frame_feature(sources[0], "temperature", "float32", [1.] * 5)
add_frame_feature(sources[1], "pressure", "float64", [2.] * 5)
conn.load_from_lerobot("merged", sources, tag_name="before",
extra_dataset_fields={"config": "config/*.json"})
before = frame_rows(conn, "merged_frames")
field_ids = {f.name: f.id for f in conn.get_table("merged_frames").raw_table.fields}
third = source(tmp_path / "third", fps=20)
add_frame_feature(third, "humidity", "float32", [40., 41., 42., 43., 44.])
(tmp_path / "third/notes.md").write_text("# Third dataset")
kwargs = dict(parallelism=import_parallelism, tag_name="after", dataset_name="third",
extra_dataset_fields={"notes": "notes.md"})
if via_load:
from pypaimon.multimodal.lerobot import load_from_lerobot
load_from_lerobot(conn, "merged", third, append=True, **kwargs)
else:
conn.append_to_lerobot("merged", third, **kwargs)
frames = frame_rows(conn, "merged_frames")
assert frames.slice(0, 10).select(before.column_names).equals(before)
assert frames["humidity"].to_pylist() == [None] * 10 + [40., 41., 42., 43., 44.]
assert frames["temperature"].slice(10).to_pylist() == [None] * 5
assert frames["pressure"].slice(10).to_pylist() == [None] * 5
assert frames["dataset_index"].to_pylist() == [0] * 5 + [1] * 5 + [2] * 5
assert frames["index"].to_pylist() == list(range(5)) * 3
current = conn.get_table("merged_frames").raw_table
assert {f.name: f.id for f in current.fields if f.name in field_ids} == field_ids
assert next(f.type.nullable for f in current.fields if f.name == "humidity")
datasets = rows(conn, "merged_datasets").sort_by("index")
assert datasets["notes"].to_pylist() == [None, None, "# Third dataset"]
assert datasets["config"].to_pylist() == [None, None, None]
assert datasets["index"].to_pylist() == [0, 1, 2]
assert metadata(conn, "stats")["humidity"]["count"] == [5]
assert metadata(conn, "stats")["temperature"]["count"] == [5]
assert set(conn.catalog.list_tables("default")) == {
"merged_frames", "merged_episodes", "merged_datasets"}
old = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), tag_name="before")
new = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), tag_name="after")
try:
assert len(old) == 10
assert "humidity" not in old[0]
assert len(new) == 15
samples = new.reader.get_items([0, 5, 10])
assert samples[0]["humidity"] is None and samples[1]["humidity"] is None
assert float(samples[2]["humidity"]) == 40.
assert samples[2]["temperature"] is None
assert new.episode_fps == (10, 10, 10, 10, 20, 20)
finally:
old.close()
new.close()
@pytest.mark.parametrize("schema_evolution", [False, True])
def test_append_missing_optional_columns_uses_existing_schema(setup, tmp_path, schema_evolution):
conn, sources = setup
add_frame_feature(sources[0], "temperature", "float32", [1.] * 5)
third = source(tmp_path / "third")
add_frame_feature(third, "temperature", "float32", [3.] * 5)
conn.load_from_lerobot("merged", [sources[0]])
conn.append_to_lerobot("merged", [sources[1], third], schema_evolution=schema_evolution)
assert frame_rows(conn, "merged_frames")["temperature"].to_pylist() == (
[1.] * 5 + [None] * 5 + [3.] * 5)
@pytest.mark.parametrize("kind", ["frame", "dataset"])
def test_append_disabled_evolution_rejects_new_columns(setup, tmp_path, kind):
conn, sources = setup
conn.load_from_lerobot("merged", [sources[0]])
kwargs = {}
if kind == "frame":
add_frame_feature(sources[1], "humidity", "float32", [1.] * 5)
else:
(Path(sources[1][7:]) / "notes.md").write_text("notes")
kwargs["extra_dataset_fields"] = {"notes": "notes.md"}
with patch.object(conn.catalog, "alter_table", side_effect=AssertionError("must not alter")):
with pytest.raises(ValueError, match="schema_evolution disabled"):
conn.append_to_lerobot("merged", sources[1], schema_evolution=False, **kwargs)
assert rows(conn, "merged_frames").num_rows == 5
assert rows(conn, "merged_datasets").num_rows == 1
@pytest.mark.parametrize("conflict", ["dtype", "shape", "names", "dataset_type", "episode_type"])
def test_append_conflicts_are_rejected_before_any_schema_changes(setup, tmp_path, conflict):
conn, sources = setup
add_frame_feature(sources[1], "temperature", "float32", [[1., 2.]] * 5, (2,))
conn.load_from_lerobot("merged", sources,
extra_dataset_fields={"config": "config/*.json"})
third = source(tmp_path / "third")
add_frame_feature(third, "humidity", "float32", [1.] * 5)
kwargs = {}
if conflict in ("dtype", "shape", "names"):
shape = (3,) if conflict == "shape" else (2,)
add_frame_feature(third, "temperature", "float64" if conflict == "dtype" else "float32",
[[1.] * shape[0]] * 5, shape)
if conflict == "names":
path = tmp_path / "third/meta/info.json"
info = json.loads(path.read_text())
info["features"]["temperature"]["names"] = ["a", "b"]
path.write_text(json.dumps(info))
elif conflict == "dataset_type":
(tmp_path / "third/config.json").write_text("{}")
kwargs["extra_dataset_fields"] = {"config": "config.json"}
else:
path = tmp_path / "third/meta/episodes/chunk-000/file-000.parquet"
data = pq.read_table(path)
data = data.set_column(data.schema.get_field_index("length"), "length", data["length"].cast(pa.int32()))
pq.write_table(data, path)
with patch.object(conn.catalog, "alter_table", side_effect=AssertionError("must not alter")):
with pytest.raises(ValueError, match="incompatible"):
conn.append_to_lerobot("merged", third, **kwargs)
assert "humidity" not in rows(conn, "merged_frames").column_names
assert rows(conn, "merged_frames").num_rows == 10
assert rows(conn, "merged_datasets").num_rows == 2
@pytest.mark.parametrize("import_parallelism", [1, 2], indirect=True)
@pytest.mark.parametrize("media", ["image", "video"])
@pytest.mark.parametrize("add_new", [False, True])
def test_append_nullable_visual_features(tmp_path, import_parallelism, media, add_new):
pytest.importorskip("datasets")
pytest.importorskip("torch")
conn = pm.connect(options={"warehouse": str(tmp_path / "warehouse")})
sources = [source(tmp_path / name, video=media == "video" and index == int(add_new))
for index, name in enumerate(("first", "second"))]
key = "camera" if media == "video" else "image"
if media == "image":
import io
from PIL import Image
content = io.BytesIO()
Image.new("RGB", (2, 2), "red").save(content, format="PNG")
add_frame_feature(sources[int(add_new)], key, "image", [content.getvalue()] * 5, (2, 2, 3))
conn.load_from_lerobot("merged", [sources[0]])
conn.append_to_lerobot("merged", sources[1], parallelism=import_parallelism)
assert rows(conn, "merged_frames")[key].null_count == 5
if media == "video":
assert conn.get_table("merged_frames").raw_table.options.video_frame_fields() == {key}
reader = pm.PaimonLeRobotDataset(conn.get_table("merged_frames"), video_backend="pyav")
try:
samples = reader.reader.get_items([0, 5])
assert samples[1 - int(add_new)][key] is None
assert tuple(samples[int(add_new)][key].shape) == ((3, 16, 16) if media == "video" else (3, 2, 2))
finally:
reader.close()
def test_append_recovery_keeps_new_columns_nullable_and_allows_retry(setup):
conn, sources = setup
conn.load_from_lerobot("merged", [sources[0]])
add_frame_feature(sources[1], "humidity", "float32", [1.] * 5)
original_commit = aggregate._commit_messages
calls = 0
def fail_on_episode(commit, messages):
nonlocal calls
calls += 1
if calls == 2:
raise RuntimeError("injected Episode commit failure")
return original_commit(commit, messages)
with patch.object(aggregate, "_commit_messages", side_effect=fail_on_episode):
with pytest.raises(RuntimeError, match="injected Episode commit failure"):
conn.append_to_lerobot("merged", sources[1])
assert rows(conn, "merged_frames")["humidity"].to_pylist() == [None] * 5
assert rows(conn, "merged_datasets").num_rows == 1
path = Path(sources[1][7:]) / "meta/info.json"
original_info = path.read_text()
changed_info = json.loads(original_info)
changed_info["features"]["humidity"]["dtype"] = "float16"
path.write_text(json.dumps(changed_info))
with pytest.raises(ValueError, match="LeRobot feature humidity"):
conn.append_to_lerobot("merged", sources[1])
path.write_text(original_info)
conn.append_to_lerobot("merged", sources[1])
assert frame_rows(conn, "merged_frames")["humidity"].to_pylist() == [None] * 5 + [1.] * 5