This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new d95ebff28f [python] Add direct LeRobot capture writer (#9665)
d95ebff28f is described below
commit d95ebff28fafd0443b6a2b0801f61a1ec027193e
Author: Yann Byron <[email protected]>
AuthorDate: Wed Sep 9 14:04:57 2026 +0800
[python] Add direct LeRobot capture writer (#9665)
---
docs/docs/pypaimon/multimodal-api.mdx | 60 +++
paimon-python/pypaimon/multimodal/arrow_utils.py | 5 +
.../pypaimon/multimodal/lerobot/__init__.py | 4 +-
.../pypaimon/multimodal/lerobot/loader.py | 7 +-
.../pypaimon/multimodal/lerobot/writer.py | 419 +++++++++++++++++++
.../tests/multimodal_lerobot_writer_test.py | 460 +++++++++++++++++++++
6 files changed, 952 insertions(+), 3 deletions(-)
diff --git a/docs/docs/pypaimon/multimodal-api.mdx
b/docs/docs/pypaimon/multimodal-api.mdx
index ddef5d1475..ef0a38e28c 100644
--- a/docs/docs/pypaimon/multimodal-api.mdx
+++ b/docs/docs/pypaimon/multimodal-api.mdx
@@ -694,6 +694,66 @@ together, and keep writers paused until the call returns.
Scalars map to scalar types, vectors to `VECTOR`, higher-rank tensors to nested
`ARRAY`, and images to `BLOB`. Images keep their compressed bytes.
+## Capture LeRobot frames directly into Paimon
+
+`PaimonLeRobotWriter` implements the write-side surface used by LeRobot's
+recording loop without first creating a LeRobot Parquet/image dataset. Pass the
+same user feature mapping that would be passed to `LeRobotDataset.create`.
+The writer adds the standard `timestamp`, `frame_index`, `episode_index`,
+`index`, and `task_index` features itself.
+
+```python
+from pypaimon.multimodal.lerobot import PaimonLeRobotWriter
+
+writer = PaimonLeRobotWriter(
+ conn,
+ "robot_data",
+ fps=30,
+ features=dataset_features,
+)
+
+# LeRobot's record_loop only needs writer.fps, writer.features, and
+# writer.add_frame(frame), so the writer can be passed as its dataset argument.
+record_loop(..., fps=30, dataset=writer)
+writer.save_episode()
+
+# Optional durability/visibility boundary before finalize.
+writer.flush()
+writer.finalize()
+```
+
+Like native LeRobot, `add_frame` requires every declared user feature plus a
+string `task`, and rejects caller-provided generated fields. Numeric features
+must be NumPy arrays (Torch tensors are converted) with the declared dtype and
+shape. Image metadata uses `(channels, height, width)`; image values may be
CHW,
+HWC, or PIL. Images are encoded as PNG bytes and stored in Paimon `BLOB`
+columns; no LeRobot data directory or MP4 is created. `video` features remain
+unsupported.
+
+`save_episode` accepts the current episode and writes it to a long-lived Paimon
+batch writer. The default `episodes_per_commit=-1` keeps all completed episodes
+in that batch until `finalize()`. Set a positive threshold for periodic
commits,
+or call `flush()` at an operational boundary. `save_episode`, `flush`, and
+`finalize` always return `None`. Calling `save_episode()` without any buffered
+frames raises `ValueError`, matching native LeRobot.
+
+Call `clear_episode_buffer()` before `save_episode()` to discard a re-recorded
+episode without advancing frame or episode indices. Once `save_episode()`
+accepts an episode, `clear_episode_buffer()` no longer affects it, even when
the
+batch has not yet been committed. `finalize()` rejects an unfinished episode
+instead of silently dropping its frames.
+
+The writer creates a missing table and appends to an existing compatible table.
+Before writing, it requires the table columns, order, Arrow types, nullability,
+and LeRobot feature metadata to match. On resume, new `index` and
+`episode_index` values continue after the existing maxima, existing task
+mappings are retained, and the episode-local `frame_index` starts again at
zero.
+Each commit records the next global indices and task mapping in Snapshot
+properties. Resume normally reads only that metadata; a non-empty table created
+before these properties existed is scanned once and upgraded by its next
+commit. A commit exception has an unknown result and is not automatically
+retried.
+
## Overwrite
`overwrite` accepts the same input formats as `add` and replaces existing data
diff --git a/paimon-python/pypaimon/multimodal/arrow_utils.py
b/paimon-python/pypaimon/multimodal/arrow_utils.py
index 76d056ed92..47f9245222 100644
--- a/paimon-python/pypaimon/multimodal/arrow_utils.py
+++ b/paimon-python/pypaimon/multimodal/arrow_utils.py
@@ -26,6 +26,11 @@ def strict_arrow_table(
source_path,
batch_index,
format_name):
+ """Validate one Arrow batch against the exact target table schema.
+
+ Reject missing, extra, or reordered columns, validate nested nullability,
+ and apply only Arrow safe casts before returning a ``pyarrow.Table``.
+ """
if isinstance(data, pa.RecordBatch):
table = pa.Table.from_batches([data])
elif isinstance(data, pa.Table):
diff --git a/paimon-python/pypaimon/multimodal/lerobot/__init__.py
b/paimon-python/pypaimon/multimodal/lerobot/__init__.py
index a40f2a8cce..5f598d2991 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/__init__.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/__init__.py
@@ -14,11 +14,13 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-"""One-time LeRobot Dataset v3 import into a multimodal Paimon table."""
+"""LeRobot Dataset v3 import and direct Paimon capture."""
from pypaimon.multimodal.lerobot.api import load_from_lerobot
+from pypaimon.multimodal.lerobot.writer import PaimonLeRobotWriter
__all__ = [
+ "PaimonLeRobotWriter",
"load_from_lerobot",
]
diff --git a/paimon-python/pypaimon/multimodal/lerobot/loader.py
b/paimon-python/pypaimon/multimodal/lerobot/loader.py
index 295eee99c3..71719869c3 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/loader.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/loader.py
@@ -450,7 +450,7 @@ def _image_bytes(value, root):
return _encode_media_frame(value)
-def _encode_media_frame(value):
+def _encode_media_frame(value, channel_first=None):
try:
import numpy as np
from PIL import Image
@@ -466,7 +466,10 @@ def _encode_media_frame(value):
if callable(detach):
value = detach().cpu().numpy()
array = np.asarray(value)
- if array.ndim == 3 and array.shape[0] in (1, 3, 4):
+ if channel_first is True or (
+ channel_first is None
+ and array.ndim == 3
+ and array.shape[0] in (1, 3, 4)):
array = np.transpose(array, (1, 2, 0))
if np.issubdtype(array.dtype, np.floating):
array = np.rint(np.clip(array, 0.0, 1.0) * 255.0).astype(np.uint8)
diff --git a/paimon-python/pypaimon/multimodal/lerobot/writer.py
b/paimon-python/pypaimon/multimodal/lerobot/writer.py
new file mode 100644
index 0000000000..faa3c6b5bd
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/lerobot/writer.py
@@ -0,0 +1,419 @@
+# 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.
+
+"""LeRobot-compatible capture writer for multimodal Paimon tables."""
+
+import copy
+import json
+from typing import Mapping, Optional
+
+import numpy as np
+import pyarrow as pa
+
+from pypaimon.multimodal.arrow_utils import strict_arrow_table
+from pypaimon.multimodal.hdf5 import _SnapshotRecorder
+from pypaimon.multimodal.lerobot.loader import (
+ _encode_media_frame,
+ _normalize_value,
+ _safe_array,
+ _value_shape,
+)
+from pypaimon.multimodal.lerobot.schema import (
+ _feature_shape,
+ _schema_from_info,
+ _validate_lerobot_schema,
+)
+from pypaimon.multimodal.table import _target_schema
+
+
+_DEFAULT_FEATURES = {
+ "timestamp": {"dtype": "float32", "shape": (1,), "names": None},
+ "frame_index": {"dtype": "int64", "shape": (1,), "names": None},
+ "episode_index": {"dtype": "int64", "shape": (1,), "names": None},
+ "index": {"dtype": "int64", "shape": (1,), "names": None},
+ "task_index": {"dtype": "int64", "shape": (1,), "names": None},
+}
+_TASK_FEATURE = {"dtype": "string", "shape": (1,), "names": None}
+_STATE_PREFIX = "pypaimon.lerobot."
+_STATE_VERSION = _STATE_PREFIX + "state-version"
+_NEXT_INDEX = _STATE_PREFIX + "next-index"
+_NEXT_EPISODE_INDEX = _STATE_PREFIX + "next-episode-index"
+_TASK_INDICES = _STATE_PREFIX + "task-indices"
+
+
+class PaimonLeRobotWriter:
+ """Collect LeRobot frames and commit completed episodes to Paimon."""
+
+ def __init__(
+ self,
+ connection,
+ table_name: str,
+ *,
+ fps: int,
+ features: Mapping[str, Mapping[str, object]],
+ episodes_per_commit: int = -1,
+ options: Optional[Mapping[str, object]] = None):
+ if isinstance(fps, bool) or not isinstance(fps, int) or fps <= 0:
+ raise ValueError("fps must be a positive integer.")
+ if isinstance(episodes_per_commit, bool) \
+ or not isinstance(episodes_per_commit, int) \
+ or episodes_per_commit == 0 \
+ or episodes_per_commit < -1:
+ raise ValueError(
+ "episodes_per_commit must be -1 or a positive integer.")
+ if not isinstance(features, Mapping) or not features:
+ raise ValueError("features must be a non-empty mapping.")
+ if "task" in features:
+ raise ValueError("task is managed by PaimonLeRobotWriter.")
+
+ self.fps = fps
+ self.episodes_per_commit = episodes_per_commit
+ self.features = copy.deepcopy(dict(features))
+ self._user_features = copy.deepcopy(dict(features))
+ self.features.update(copy.deepcopy(_DEFAULT_FEATURES))
+ schema_features = dict(self.features)
+ schema_features["task"] = _TASK_FEATURE
+ self._source_schema = _schema_from_info({"features": schema_features})
+ self._table = connection.create_table(
+ table_name,
+ schema=self._source_schema,
+ options=options,
+ ignore_if_exists=True,
+ )
+ self._target_schema = _target_schema(self._table.raw_table)
+ _validate_lerobot_schema(
+ self._source_schema, self._target_schema, table_name)
+ strict_arrow_table(
+ pa.Table.from_batches([], schema=self._source_schema),
+ self._target_schema,
+ table_name,
+ 0,
+ "LeRobot",
+ )
+
+ self.num_frames, self.num_episodes, self._task_indices = \
+ self._load_existing_state()
+ self._next_task_index = (
+ max(self._task_indices.values()) + 1
+ if self._task_indices else 0
+ )
+ self.pending_episodes = 0
+ self._episode_frames = []
+ self._table_write = None
+ self._table_commit = None
+ self._snapshot_recorder = None
+ self._finalized = False
+ self._failed = False
+
+ def _load_existing_state(self):
+ snapshot = self._table.raw_table.snapshot_manager() \
+ .get_latest_snapshot()
+ if snapshot is None:
+ return 0, 0, {}
+ properties = snapshot.properties or {}
+ if _STATE_VERSION in properties:
+ return self._state_from_snapshot_properties(properties)
+ if any(key.startswith(_STATE_PREFIX) for key in properties):
+ raise ValueError("Existing LeRobot snapshot state is incomplete.")
+
+ # ponytail: one resume scan; persist counters if startup cost matters.
+ rows = self._table.scan().select([
+ "index", "episode_index", "task_index", "task"
+ ]).to_arrow().to_pylist()
+ if not rows:
+ return 0, 0, {}
+
+ task_indices = {}
+ index_tasks = {}
+ for row in rows:
+ task = row["task"]
+ task_index = row["task_index"]
+ if ((task in task_indices
+ and task_indices[task] != task_index)
+ or (task_index in index_tasks
+ and index_tasks[task_index] != task)):
+ raise ValueError(
+ "Existing LeRobot task and task_index values conflict.")
+ task_indices[task] = task_index
+ index_tasks[task_index] = task
+ return (
+ max(row["index"] for row in rows) + 1,
+ max(row["episode_index"] for row in rows) + 1,
+ task_indices,
+ )
+
+ @staticmethod
+ def _state_from_snapshot_properties(properties):
+ if properties[_STATE_VERSION] != "1":
+ raise ValueError(
+ "Unsupported LeRobot snapshot state version %r."
+ % properties[_STATE_VERSION])
+ try:
+ next_index = int(properties[_NEXT_INDEX])
+ next_episode_index = int(properties[_NEXT_EPISODE_INDEX])
+ task_indices = json.loads(properties[_TASK_INDICES])
+ except (KeyError, TypeError, ValueError) as error:
+ raise ValueError(
+ "Existing LeRobot snapshot state is invalid.") from error
+ if next_index < 0 or next_episode_index < 0 \
+ or not isinstance(task_indices, dict):
+ raise ValueError("Existing LeRobot snapshot state is invalid.")
+ indices = list(task_indices.values())
+ if any(not isinstance(task, str)
+ or isinstance(index, bool)
+ or not isinstance(index, int)
+ or index < 0
+ for task, index in task_indices.items()) \
+ or len(set(indices)) != len(indices):
+ raise ValueError("Existing LeRobot snapshot state is invalid.")
+ return next_index, next_episode_index, task_indices
+
+ def _snapshot_properties(self):
+ return {
+ _STATE_VERSION: "1",
+ _NEXT_INDEX: str(self.num_frames),
+ _NEXT_EPISODE_INDEX: str(self.num_episodes),
+ _TASK_INDICES: json.dumps(
+ self._task_indices,
+ ensure_ascii=False,
+ separators=(",", ":"),
+ sort_keys=True,
+ ),
+ }
+
+ def add_frame(self, frame):
+ self._require_open("add_frame")
+ if not isinstance(frame, Mapping):
+ raise ValueError("frame must be a mapping.")
+ expected = set(self._user_features)
+ actual = set(frame) - {"task"}
+ if actual != expected:
+ missing = sorted(expected - actual)
+ extra = sorted(actual - expected)
+ raise ValueError(
+ "LeRobot frame fields do not match features; missing=%s, "
+ "extra=%s." % (missing, extra))
+ task = frame.get("task")
+ if not isinstance(task, str):
+ raise ValueError("LeRobot frame task must be a string.")
+
+ values = {"task": task}
+ for name, feature in self._user_features.items():
+ if feature.get("dtype") == "image":
+ value = self._image_bytes(frame[name], feature, name)
+ else:
+ value = self._normalize_frame_value(
+ frame[name], feature, name)
+ _safe_array(
+ [value],
+ self._source_schema.field(name),
+ name,
+ str(feature.get("dtype", "")),
+ )
+ values[name] = value
+ self._episode_frames.append(values)
+
+ @staticmethod
+ def _normalize_frame_value(value, feature, name):
+ numpy = getattr(value, "numpy", None)
+ if callable(numpy):
+ value = numpy()
+ dtype = str(feature.get("dtype", ""))
+ if dtype == "string":
+ if not isinstance(value, str):
+ raise ValueError(
+ "LeRobot feature %s must be a string." % name)
+ else:
+ if not isinstance(value, np.ndarray):
+ raise ValueError(
+ "LeRobot feature %s must be a NumPy array." % name)
+ if value.dtype != np.dtype(dtype):
+ raise ValueError(
+ "LeRobot feature %s expected dtype %s, got %s."
+ % (name, dtype, value.dtype))
+ expected_shape = _feature_shape(feature, name)
+ if value.shape != expected_shape:
+ raise ValueError(
+ "LeRobot feature %s expected shape %s, got %s."
+ % (name, expected_shape, value.shape))
+ value = _normalize_value(value, feature, name)
+ return value.tolist() if isinstance(value, np.ndarray) else value
+
+ @staticmethod
+ def _image_bytes(value, feature, name):
+ actual_shape = _value_shape(value)
+ getbands = getattr(value, "getbands", None)
+ image_size = getattr(value, "size", None)
+ if not actual_shape and callable(getbands) \
+ and isinstance(image_size, tuple) and len(image_size) == 2:
+ actual_shape = (image_size[1], image_size[0], len(getbands()))
+ expected_shape = _feature_shape(feature, name)
+ names = tuple(feature.get("names") or ())
+ if names == ("height", "width", "channels"):
+ channel_first_shape = (
+ expected_shape[2], expected_shape[0], expected_shape[1])
+ channel_last_shape = expected_shape
+ else:
+ channel_first_shape = expected_shape
+ channel_last_shape = (
+ (expected_shape[1], expected_shape[2], expected_shape[0])
+ if len(expected_shape) == 3 else ()
+ )
+ if actual_shape and actual_shape != channel_first_shape \
+ and actual_shape != channel_last_shape:
+ raise ValueError(
+ "LeRobot feature %s expected shape %s, got %s."
+ % (name, expected_shape, actual_shape))
+ return _encode_media_frame(
+ value,
+ channel_first=actual_shape == channel_first_shape,
+ )
+
+ def save_episode(self):
+ self._require_open("save_episode")
+ if not self._episode_frames:
+ raise ValueError("Cannot save an empty LeRobot episode.")
+
+ episode = self._episode_table()
+ try:
+ self._ensure_batch()
+ self._table_write.write_arrow(episode)
+ except BaseException:
+ self._fail_batch(abort=True)
+ raise
+
+ self.num_frames += episode.num_rows
+ self.num_episodes += 1
+ self.pending_episodes += 1
+ self._episode_frames = []
+ if self.episodes_per_commit != -1 \
+ and self.pending_episodes >= self.episodes_per_commit:
+ self.flush()
+ return None
+
+ def clear_episode_buffer(self, delete_images=True):
+ self._require_open("clear_episode_buffer")
+ self._episode_frames = []
+
+ def has_pending_frames(self):
+ return bool(self._episode_frames)
+
+ def flush(self):
+ self._require_open("flush")
+ if self.pending_episodes == 0:
+ return None
+ commit_started = False
+ try:
+ messages = self._table_write.prepare_commit()
+ commit_started = True
+ self._table_commit.commit(
+ messages,
+ snapshot_properties=self._snapshot_properties())
+ snapshot_id = self._snapshot_recorder.snapshot_id
+ if snapshot_id is None:
+ raise RuntimeError(
+ "LeRobot batch committed without reporting a snapshot id.")
+ except BaseException:
+ self._fail_batch(abort=not commit_started)
+ raise
+ self._close_batch()
+ self.pending_episodes = 0
+ return None
+
+ def finalize(self):
+ if self._finalized:
+ return None
+ self._require_open("finalize")
+ if self._episode_frames:
+ raise RuntimeError(
+ "Cannot finalize with unsaved LeRobot frames; call "
+ "save_episode() or clear_episode_buffer() first.")
+ self.flush()
+ self._finalized = True
+ return None
+
+ def _episode_table(self):
+ """Build one episode as a target-schema ``pyarrow.Table``."""
+ episode_index = self.num_episodes
+ first_index = self.num_frames
+ size = len(self._episode_frames)
+ tasks = [frame["task"] for frame in self._episode_frames]
+ task_indices = []
+ for task in tasks:
+ if task not in self._task_indices:
+ self._task_indices[task] = self._next_task_index
+ self._next_task_index += 1
+ task_indices.append(self._task_indices[task])
+
+ generated = {
+ "timestamp": [index / self.fps for index in range(size)],
+ "frame_index": list(range(size)),
+ "episode_index": [episode_index] * size,
+ "index": list(range(first_index, first_index + size)),
+ "task_index": task_indices,
+ }
+ arrays = []
+ for name, feature in self.features.items():
+ values = generated.get(name)
+ if values is None:
+ values = [frame[name] for frame in self._episode_frames]
+ field = self._source_schema.field(name)
+ arrays.append(_safe_array(
+ values, field, name, str(feature.get("dtype", ""))))
+ arrays.append(pa.array(tasks, type=pa.string()))
+ source = pa.Table.from_arrays(arrays, schema=self._source_schema)
+ return strict_arrow_table(
+ source,
+ self._target_schema,
+ self._table.identifier,
+ self.num_episodes,
+ "LeRobot",
+ )
+
+ def _ensure_batch(self):
+ if self._table_write is not None:
+ return
+ builder = self._table.raw_table.new_batch_write_builder()
+ self._table_write = builder.new_write()
+ self._table_commit = builder.new_commit()
+ self._snapshot_recorder = _SnapshotRecorder()
+ self._table_commit.add_commit_callback(self._snapshot_recorder)
+
+ def _fail_batch(self, abort):
+ self._failed = True
+ if abort and self._table_write is not None:
+ self._table_write.abort()
+ self._close_batch()
+
+ def _close_batch(self):
+ try:
+ if self._table_write is not None:
+ self._table_write.close()
+ finally:
+ if self._table_commit is not None:
+ self._table_commit.close()
+ self._table_write = None
+ self._table_commit = None
+ self._snapshot_recorder = None
+
+ def _require_open(self, method):
+ if self._failed:
+ raise RuntimeError(
+ "Cannot call %s() after a Paimon write failure." % method)
+ if self._finalized:
+ raise RuntimeError(
+ "Cannot call %s() after finalize()." % method)
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_writer_test.py
b/paimon-python/pypaimon/tests/multimodal_lerobot_writer_test.py
new file mode 100644
index 0000000000..f446de7581
--- /dev/null
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_writer_test.py
@@ -0,0 +1,460 @@
+# 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 io
+import shutil
+import tempfile
+import unittest
+from pathlib import Path
+from unittest.mock import patch
+
+import numpy as np
+
+import pypaimon.multimodal as pmm
+from pypaimon.multimodal.lerobot import PaimonLeRobotWriter
+
+try:
+ from PIL import Image
+except ImportError:
+ Image = None
+
+
+class PaimonLeRobotWriterTest(unittest.TestCase):
+
+ def setUp(self):
+ self.temp_dir = Path(tempfile.mkdtemp(
+ prefix="pypaimon_lerobot_writer_"))
+ self.connection = pmm.connect(options={
+ "warehouse": str(self.temp_dir / "warehouse"),
+ })
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
+
+ def test_default_commits_only_on_finalize_and_returns_none(self):
+ writer = PaimonLeRobotWriter(
+ self.connection,
+ "finalize_only",
+ fps=10,
+ features={
+ "action": {
+ "dtype": "float32",
+ "shape": (1,),
+ "names": None,
+ },
+ },
+ )
+
+ self.assertEqual(-1, writer.episodes_per_commit)
+ for value in (1.0, 2.0):
+ writer.add_frame({
+ "action": np.array([value], dtype=np.float32),
+ "task": "pick",
+ })
+ self.assertIsNone(writer.save_episode())
+ table = self.connection.get_table("finalize_only")
+ self.assertIsNone(
+ table.raw_table.snapshot_manager().get_latest_snapshot())
+
+ self.assertIsNone(writer.finalize())
+ self.assertEqual(
+ 1, table.raw_table.snapshot_manager().get_latest_snapshot().id)
+
+ def test_existing_table_resumes_global_and_task_indices(self):
+ features = {
+ "action": {
+ "dtype": "float32",
+ "shape": (1,),
+ "names": None,
+ },
+ }
+ first = PaimonLeRobotWriter(
+ self.connection, "resume", fps=10, features=features)
+ for value in (1.0, 2.0):
+ first.add_frame({
+ "action": np.array([value], dtype=np.float32),
+ "task": "pick",
+ })
+ first.save_episode()
+ first.finalize()
+
+ snapshot = self.connection.get_table(
+ "resume").raw_table.snapshot_manager().get_latest_snapshot()
+ self.assertEqual("1", snapshot.properties[
+ "pypaimon.lerobot.state-version"])
+ with patch(
+ "pypaimon.multimodal.table.MultimodalTable.scan",
+ side_effect=AssertionError("resume must not scan table data")):
+ resumed = PaimonLeRobotWriter(
+ self.connection, "resume", fps=10, features=features)
+ self.assertEqual(2, resumed.num_frames)
+ self.assertEqual(1, resumed.num_episodes)
+ resumed.add_frame({
+ "action": np.array([3.0], dtype=np.float32),
+ "task": "pick",
+ })
+ resumed.add_frame({
+ "action": np.array([4.0], dtype=np.float32),
+ "task": "place",
+ })
+ resumed.save_episode()
+ resumed.finalize()
+
+ rows = self.connection.get_table("resume").scan().select([
+ "episode_index", "frame_index", "index", "task_index", "task"
+ ]).to_arrow().sort_by("index").to_pylist()
+ self.assertEqual([0, 0, 1, 1], [r["episode_index"] for r in rows])
+ self.assertEqual([0, 1, 0, 1], [r["frame_index"] for r in rows])
+ self.assertEqual([0, 1, 2, 3], [r["index"] for r in rows])
+ self.assertEqual([0, 0, 0, 1], [r["task_index"] for r in rows])
+
+ def test_existing_table_requires_matching_feature_schema(self):
+ PaimonLeRobotWriter(
+ self.connection,
+ "schema_mismatch",
+ fps=10,
+ features={
+ "action": {
+ "dtype": "float32",
+ "shape": (1,),
+ "names": None,
+ },
+ },
+ )
+
+ with self.assertRaisesRegex(ValueError, "LeRobot feature action"):
+ PaimonLeRobotWriter(
+ self.connection,
+ "schema_mismatch",
+ fps=10,
+ features={
+ "action": {
+ "dtype": "int64",
+ "shape": (1,),
+ "names": None,
+ },
+ },
+ )
+
+ def test_commits_multiple_completed_episodes_as_one_batch(self):
+ writer = PaimonLeRobotWriter(
+ self.connection,
+ "robot_data",
+ fps=10,
+ episodes_per_commit=2,
+ features={
+ "observation.state": {
+ "dtype": "float32",
+ "shape": (2,),
+ "names": ["x", "y"],
+ },
+ "action": {
+ "dtype": "float32",
+ "shape": (1,),
+ "names": ["gripper"],
+ },
+ },
+ )
+
+ writer.add_frame({
+ "observation.state": np.array([1.0, 2.0], dtype=np.float32),
+ "action": np.array([0.5], dtype=np.float32),
+ "task": "pick",
+ })
+ self.assertIsNone(writer.save_episode())
+ table = self.connection.get_table("robot_data")
+ self.assertIsNone(
+ table.raw_table.snapshot_manager().get_latest_snapshot())
+
+ writer.add_frame({
+ "observation.state": np.array([3.0, 4.0], dtype=np.float32),
+ "action": np.array([0.0], dtype=np.float32),
+ "task": "place",
+ })
+ self.assertIsNone(writer.save_episode())
+ self.assertEqual(
+ 1, table.raw_table.snapshot_manager().get_latest_snapshot().id)
+
+ rows = table.scan().select([
+ "episode_index",
+ "frame_index",
+ "timestamp",
+ "index",
+ "task_index",
+ "task",
+ "observation.state",
+ "action",
+ ]).to_arrow().sort_by("index").to_pylist()
+ self.assertEqual([0, 1], [row["episode_index"] for row in rows])
+ self.assertEqual([0, 0], [row["frame_index"] for row in rows])
+ self.assertEqual([0, 1], [row["index"] for row in rows])
+ self.assertEqual([0, 1], [row["task_index"] for row in rows])
+ self.assertEqual(["pick", "place"], [row["task"] for row in rows])
+ self.assertEqual([0.0, 0.0], [row["timestamp"] for row in rows])
+ self.assertEqual([0.5, 0.0], [row["action"] for row in rows])
+
+ @unittest.skipUnless(Image is not None, "Pillow is required for image
tests")
+ def test_writes_raw_image_frame_as_png_blob(self):
+ writer = PaimonLeRobotWriter(
+ self.connection,
+ "images",
+ fps=30,
+ features={
+ "observation.image": {
+ "dtype": "image",
+ "shape": (3, 4, 5),
+ "names": ["channels", "height", "width"],
+ },
+ },
+ )
+ writer.add_frame({
+ "observation.image": np.full(
+ (4, 5, 3), 73, dtype=np.uint8),
+ "task": "inspect",
+ })
+ writer.save_episode()
+ self.assertIsNone(writer.flush())
+
+ table = self.connection.get_table("images")
+ scalar, blobs = table.scan().select([
+ "index", "observation.image"
+ ]).read_blobs()
+ self.assertEqual([0], scalar.column("index").to_pylist())
+ image = Image.open(io.BytesIO(blobs["observation.image"][0]))
+ self.assertEqual((5, 4), image.size)
+ self.assertEqual((73, 73, 73), image.getpixel((0, 0)))
+
+ @unittest.skipUnless(Image is not None, "Pillow is required for image
tests")
+ def test_writes_native_hwc_image_frame_as_png_blob(self):
+ writer = PaimonLeRobotWriter(
+ self.connection,
+ "native_hwc_images",
+ fps=30,
+ features={
+ "observation.image": {
+ "dtype": "image",
+ "shape": (4, 5, 3),
+ "names": ["height", "width", "channels"],
+ },
+ },
+ )
+ writer.add_frame({
+ "observation.image": np.full(
+ (4, 5, 3), 73, dtype=np.uint8),
+ "task": "inspect",
+ })
+ writer.save_episode()
+ writer.finalize()
+
+ _, blobs = self.connection.get_table(
+ "native_hwc_images").scan().select([
+ "observation.image"
+ ]).read_blobs()
+ image = Image.open(io.BytesIO(blobs["observation.image"][0]))
+ self.assertEqual((5, 4), image.size)
+ self.assertEqual((73, 73, 73), image.getpixel((0, 0)))
+
+ def test_writes_multidimensional_and_boolean_numpy_features(self):
+ writer = PaimonLeRobotWriter(
+ self.connection,
+ "numpy_features",
+ fps=10,
+ features={
+ "observation.matrix": {
+ "dtype": "float32",
+ "shape": (2, 2),
+ "names": None,
+ },
+ "observation.flags": {
+ "dtype": "bool",
+ "shape": (2,),
+ "names": None,
+ },
+ },
+ )
+ writer.add_frame({
+ "observation.matrix": np.array(
+ [[1.0, 2.0], [3.0, 4.0]], dtype=np.float32),
+ "observation.flags": np.array([True, False], dtype=np.bool_),
+ "task": "inspect",
+ })
+ writer.save_episode()
+ writer.finalize()
+
+ rows = self.connection.get_table("numpy_features").scan().select([
+ "observation.matrix", "observation.flags"
+ ]).to_arrow().to_pylist()
+ self.assertEqual([[1.0, 2.0], [3.0, 4.0]],
+ rows[0]["observation.matrix"])
+ self.assertEqual([True, False], rows[0]["observation.flags"])
+
+ def test_discards_rerecorded_episode_and_finalizes_tail_batch(self):
+ writer = PaimonLeRobotWriter(
+ self.connection,
+ "rerecord",
+ fps=10,
+ features={
+ "action": {
+ "dtype": "float32",
+ "shape": (1,),
+ "names": None,
+ },
+ },
+ )
+ writer.add_frame({
+ "action": np.array([9.0], dtype=np.float32),
+ "task": "discard",
+ })
+ self.assertTrue(writer.has_pending_frames())
+ writer.clear_episode_buffer()
+ self.assertFalse(writer.has_pending_frames())
+
+ writer.add_frame({
+ "action": np.array([1.0], dtype=np.float32),
+ "task": "keep",
+ })
+ writer.save_episode()
+ self.assertEqual(1, writer.num_episodes)
+ self.assertEqual(1, writer.pending_episodes)
+ writer.clear_episode_buffer()
+ self.assertEqual(1, writer.pending_episodes)
+
+ writer.add_frame({
+ "action": np.array([2.0], dtype=np.float32),
+ "task": "also keep",
+ })
+ writer.save_episode()
+ writer.finalize()
+ writer.finalize()
+
+ rows = self.connection.get_table("rerecord").scan().select([
+ "episode_index", "index", "task", "action"
+ ]).to_arrow().to_pylist()
+ self.assertEqual([
+ {
+ "episode_index": 0,
+ "index": 0,
+ "task": "keep",
+ "action": 1.0,
+ },
+ {
+ "episode_index": 1,
+ "index": 1,
+ "task": "also keep",
+ "action": 2.0,
+ },
+ ], rows)
+ with self.assertRaisesRegex(RuntimeError, "after finalize"):
+ writer.add_frame({
+ "action": np.array([2.0], dtype=np.float32),
+ "task": "late",
+ })
+
+ def test_finalize_rejects_unsaved_frames(self):
+ writer = PaimonLeRobotWriter(
+ self.connection,
+ "unsaved",
+ fps=10,
+ features={
+ "action": {
+ "dtype": "float32",
+ "shape": (1,),
+ "names": None,
+ },
+ },
+ )
+ writer.add_frame({
+ "action": np.array([1.0], dtype=np.float32),
+ "task": "pick",
+ })
+
+ with self.assertRaisesRegex(RuntimeError, "unsaved"):
+ writer.finalize()
+ self.assertTrue(writer.has_pending_frames())
+ self.assertIsNone(
+ self.connection.get_table("unsaved").raw_table
+ .snapshot_manager().get_latest_snapshot())
+
+ def test_add_frame_rejects_invalid_value_without_buffering_it(self):
+ writer = PaimonLeRobotWriter(
+ self.connection,
+ "invalid_frame",
+ fps=10,
+ features={
+ "sensor": {
+ "dtype": "uint8",
+ "shape": (1,),
+ "names": None,
+ },
+ },
+ )
+
+ with self.assertRaisesRegex(ValueError, "expected dtype"):
+ writer.add_frame({
+ "sensor": np.array([1], dtype=np.int16),
+ "task": "measure",
+ })
+ self.assertFalse(writer.has_pending_frames())
+
+ def test_add_frame_requires_native_numeric_dtype_and_shape(self):
+ writer = PaimonLeRobotWriter(
+ self.connection,
+ "native_frame_contract",
+ fps=10,
+ features={
+ "action": {
+ "dtype": "float32",
+ "shape": (1,),
+ "names": None,
+ },
+ },
+ )
+
+ with self.assertRaisesRegex(ValueError, "NumPy array"):
+ writer.add_frame({"action": [1.0], "task": "pick"})
+ with self.assertRaisesRegex(ValueError, "expected dtype"):
+ writer.add_frame({
+ "action": np.array([1.0], dtype=np.float64),
+ "task": "pick",
+ })
+ self.assertFalse(writer.has_pending_frames())
+
+ @unittest.skipUnless(Image is not None, "Pillow is required for image
tests")
+ def test_add_frame_validates_pil_image_shape(self):
+ writer = PaimonLeRobotWriter(
+ self.connection,
+ "pil_shape",
+ fps=10,
+ features={
+ "observation.image": {
+ "dtype": "image",
+ "shape": (3, 4, 5),
+ "names": ["channels", "height", "width"],
+ },
+ },
+ )
+
+ with self.assertRaisesRegex(ValueError, "expected shape"):
+ writer.add_frame({
+ "observation.image": Image.new("RGB", (6, 4)),
+ "task": "inspect",
+ })
+ self.assertFalse(writer.has_pending_frames())
+
+
+if __name__ == "__main__":
+ unittest.main()