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 8a0b4ef768 [python] Import LeRobot video datasets (#9494)
8a0b4ef768 is described below
commit 8a0b4ef768bf1b9c3e76ee639ab15f194cc2a077
Author: XiaoHongbo <[email protected]>
AuthorDate: Thu Sep 10 23:33:54 2026 +0800
[python] Import LeRobot video datasets (#9494)
---
docs/docs/pypaimon/lerobot.md | 6 +
docs/docs/pypaimon/video.md | 8 +-
paimon-python/pypaimon/multimodal/lerobot/api.py | 49 +-
.../pypaimon/multimodal/lerobot/loader.py | 363 +++++++++-
.../pypaimon/multimodal/lerobot/metadata.py | 88 ++-
.../pypaimon/multimodal/lerobot/schema.py | 14 +-
.../pypaimon/multimodal/lerobot/source.py | 133 +++-
.../pypaimon/multimodal/lerobot/writer.py | 6 +
.../tests/data_evolution_row_rolling_test.py | 187 ++++++
.../pypaimon/tests/multimodal_lerobot_test.py | 733 ++++++++++++++++++++-
.../tests/multimodal_lerobot_writer_test.py | 16 +
paimon-python/pypaimon/write/file_store_write.py | 7 +
paimon-python/pypaimon/write/table_write.py | 16 +
.../pypaimon/write/writer/blob_file_writer.py | 6 +-
paimon-python/pypaimon/write/writer/blob_writer.py | 15 +-
.../write/writer/dedicated_format_writer.py | 44 +-
.../pypaimon/write/writer/vector_writer.py | 27 +-
17 files changed, 1643 insertions(+), 75 deletions(-)
diff --git a/docs/docs/pypaimon/lerobot.md b/docs/docs/pypaimon/lerobot.md
index 9554f0e234..722d63f3f2 100644
--- a/docs/docs/pypaimon/lerobot.md
+++ b/docs/docs/pypaimon/lerobot.md
@@ -107,6 +107,12 @@ 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.
+Video features map to `BLOB`. Frame rows reference MP4 payloads copied once per
+aligned file group. Video imports use the video grouping policy and check
+rolling before each Episode. They require a bucket-unaware table. Read them
+with a Paimon scan and `VideoFrameCollator`; `PaimonLeRobotDataset` currently
+supports image features only.
+
## Capture LeRobot frames directly into Paimon
`PaimonLeRobotWriter` implements the write-side surface used by LeRobot's
diff --git a/docs/docs/pypaimon/video.md b/docs/docs/pypaimon/video.md
index 987cb54322..881ce62fde 100644
--- a/docs/docs/pypaimon/video.md
+++ b/docs/docs/pypaimon/video.md
@@ -89,11 +89,9 @@ frames.add_video(video, episode_43_rows)
```
The writer deduplicates exact payload descriptor identity inside each `.video`
-file. Payload boundaries may be nested across configured video columns, but
-every change must occur at an episode boundary. When a file target is reached
-within an episode, normal, BLOB, and vector rolling waits for the next payload
-boundary and closes the aligned file group together. A single large episode
-may therefore exceed the target. The normal `.blob` format is unchanged.
+file. Its video grouping policy coordinates normal, BLOB, and vector rolling
+at payload boundaries. A file may exceed its target before the next boundary.
+The normal `.blob` format is unchanged.
### Update frame rows and replace a video
diff --git a/paimon-python/pypaimon/multimodal/lerobot/api.py
b/paimon-python/pypaimon/multimodal/lerobot/api.py
index 84e3d2eed5..20a054f90f 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/api.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/api.py
@@ -37,6 +37,7 @@ from pypaimon.multimodal.lerobot.schema import (
_require_v3,
_schema_from_info,
_validate_v3_required_features,
+ _video_feature_names,
)
from pypaimon.multimodal.lerobot.source import (
_close_quietly,
@@ -52,6 +53,12 @@ from pypaimon.multimodal.source_utils import (
)
+_VIDEO_LAYOUT_ERROR = (
+ "LeRobot video import requires a bucket-unaware target table so each "
+ "Episode is written by one writer."
+)
+
+
def load_from_lerobot(
connection,
table_name: str,
@@ -94,14 +101,20 @@ def load_from_lerobot(
_positive_integer(local_info.get("fps"), "fps")
_validated_counts(local_info, resolved_source.path)
_validate_v3_required_features(local_info)
+ video_fields = _video_feature_names(local_info)
LeRobotDataset = _import_lerobot_dataset()
dataset = _open_resolved_dataset(
- LeRobotDataset, resolved_source, local_info)
+ LeRobotDataset,
+ resolved_source,
+ local_info,
+ download_videos=bool(video_fields),
+ )
try:
info = dict(dataset.meta.info)
_require_v3(info, resolved_source.path)
_validated_counts(info, resolved_source.path)
_validate_v3_required_features(info)
+ video_fields = _video_feature_names(info)
lerobot_schema = _schema_from_info(info)
metadata = _load_dataset_metadata(
@@ -117,6 +130,7 @@ def load_from_lerobot(
options,
metadata,
tag_name,
+ video_fields,
)
finally:
close = getattr(dataset, "close", None)
@@ -134,14 +148,21 @@ def _import_dataset(
batch_size,
options,
metadata,
- tag_name):
+ tag_name,
+ video_fields):
table = _create_target_table(
- connection, table_name, source_schema, options, metadata)
+ connection,
+ table_name,
+ source_schema,
+ options,
+ metadata,
+ video_fields,
+ )
tables = _prepare_metadata_tables(
connection, table.raw_table, metadata)
episodes_snapshot_id = _append_arrow_tables(
tables["episodes"],
- _validated_episode_tables(metadata),
+ _validated_episode_tables(metadata, video_fields),
)
frames_snapshot_id = None
if int(info["total_frames"]) > 0:
@@ -153,6 +174,7 @@ def _import_dataset(
source_schema,
batch_size,
metadata,
+ video_fields,
)
_commit_metadata(
connection,
@@ -195,8 +217,25 @@ def _required_count(info, name, source):
def _create_target_table(
- connection, table_name, source_schema, options, metadata):
+ connection, table_name, source_schema, options, metadata,
+ video_fields=()):
create_options = dict(options or {})
+ configured = create_options.get("video-frame-field")
+ if configured is not None:
+ requested = {
+ name.strip() for name in str(configured).split(",")
+ if name.strip()
+ }
+ if requested != set(video_fields):
+ raise ValueError(
+ "LeRobot video features %s do not match "
+ "'video-frame-field'=%r."
+ % (list(video_fields), configured)
+ )
+ if video_fields:
+ if str(create_options.get("bucket", "-1")).strip() != "-1":
+ raise ValueError(_VIDEO_LAYOUT_ERROR)
+ create_options["video-frame-field"] = ",".join(video_fields)
managed_options = _managed_table_options(
connection._identifier(table_name), metadata)
reserved_options = set(_COMPANION_OPTION_KEYS.values()).intersection(
diff --git a/paimon-python/pypaimon/multimodal/lerobot/loader.py
b/paimon-python/pypaimon/multimodal/lerobot/loader.py
index 71719869c3..6925886b97 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/loader.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/loader.py
@@ -19,7 +19,10 @@
import io
import math
import numbers
+from array import array
+from bisect import bisect_left
from pathlib import Path
+from urllib.parse import unquote, urlparse
import pyarrow as pa
@@ -27,6 +30,7 @@ from pypaimon.multimodal.arrow_utils import strict_arrow_table
from pypaimon.multimodal.hdf5 import _SnapshotRecorder
from pypaimon.multimodal.lerobot.schema import _feature_shape
from pypaimon.multimodal.table import _target_schema
+from pypaimon.table.row.blob import VideoFrameDescriptor
_DECLARED_NUMERIC_RANGES = {
@@ -49,6 +53,7 @@ _NUMERIC_DTYPES = {
"float64",
}
_BOOLEAN_DTYPES = {"bool", "boolean"}
+_VIDEO_TIMESTAMP_TOLERANCE = 1e-4
def _strict_lerobot_table(data, target_schema, source, batch_index):
@@ -68,7 +73,8 @@ def _write_dataset(
source,
source_schema,
batch_size,
- metadata):
+ metadata,
+ video_fields=()):
target_schema = _target_schema(table.raw_table)
write_builder = table.raw_table.new_batch_write_builder()
table_write = None
@@ -81,13 +87,23 @@ def _write_dataset(
expected_tasks = set()
observed_tasks = set()
snapshot_recorder = _SnapshotRecorder()
+ video_sources = {}
try:
table_write = write_builder.new_write()
+ reader_factory = getattr(
+ dataset, "video_uri_reader_factory", None)
+ if video_fields and reader_factory is not None:
+ table_write.with_blob_uri_reader_factory(reader_factory)
table_commit = write_builder.new_commit()
table_commit.add_commit_callback(snapshot_recorder)
- for episode_index, episode_begin, task_indices, begin, end in \
- _episode_batches(dataset, info, batch_size, episodes):
+ for source_episode, episode_index, episode_begin, task_indices, \
+ begin, end in _episode_batches(
+ info,
+ batch_size,
+ episodes,
+ video_fields,
+ ):
if episode_index != current_episode:
if current_episode is not None:
_validate_episode_tasks(
@@ -95,8 +111,18 @@ def _write_dataset(
current_episode = episode_index
expected_tasks = set(task_indices)
observed_tasks = set()
+ if video_fields:
+ table_write.roll_before_group_if_needed(
+ int(source_episode["length"]))
batch = _read_batch(
- dataset, info, begin, end, source_schema)
+ dataset,
+ info,
+ begin,
+ end,
+ source_schema,
+ episode=source_episode,
+ video_sources=video_sources,
+ )
seen_tasks = _validate_frame_controls(
batch,
int(info["fps"]),
@@ -146,9 +172,9 @@ def _write_dataset(
table_commit.close()
-def _episode_batches(dataset, info, batch_size, episodes):
+def _episode_batches(info, batch_size, episodes, video_fields=()):
episode_count = int(info.get("total_episodes", 0))
- total_frames = int(info.get("total_frames", len(dataset)))
+ total_frames = int(info["total_frames"])
expected_begin = 0
for ordinal in range(episode_count):
episode = episodes.iloc[ordinal] if hasattr(episodes, "iloc") \
@@ -164,9 +190,11 @@ def _episode_batches(dataset, info, batch_size, episodes):
% ordinal)
episode_begin = begin
task_indices = episode.get("task_indices", ())
+ source_episode = episode if video_fields else None
while begin < end:
batch_end = min(begin + batch_size, end)
yield (
+ source_episode,
episode_index,
episode_begin,
task_indices,
@@ -280,7 +308,9 @@ def _control_integer(value, name, frame_index):
return int(value)
-def _read_batch(dataset, info, begin, end, schema):
+def _read_batch(
+ dataset, info, begin, end, schema, episode=None,
+ video_sources=None):
read_batch = getattr(dataset, "read_batch", None)
if callable(read_batch):
raw = read_batch(begin, end)
@@ -291,32 +321,331 @@ def _read_batch(dataset, info, begin, end, schema):
elif not isinstance(raw, pa.Table):
raw = pa.Table.from_pydict(raw)
features = info["features"]
+ video_rows = None
+ video_timestamp_type = None
+ if any(feature.get("dtype") == "video"
+ for feature in features.values()):
+ video_rows = _validate_video_rows(
+ raw, info, episode, begin, end)
+ video_timestamp_type = raw.schema.field("timestamp").type
arrays = []
fields = []
for name, feature in features.items():
field = schema.field(name)
dtype = feature["dtype"]
- if name not in raw.column_names:
+ if dtype == "video":
+ values = _video_frame_descriptors(
+ dataset,
+ info,
+ episode,
+ video_rows,
+ name,
+ feature,
+ begin,
+ end,
+ video_sources if video_sources is not None else {},
+ video_timestamp_type,
+ )
+ elif name not in raw.column_names:
raise ValueError(
"LeRobot data is missing metadata feature %s." % name)
- values = raw.column(name).to_pylist()
- if dtype == "image":
- image_reader = getattr(dataset, "image_bytes", None)
- if callable(image_reader):
- values = [image_reader(value) for value in values]
+ else:
+ values = raw.column(name).to_pylist()
+ if dtype == "image":
+ image_reader = getattr(dataset, "image_bytes", None)
+ if callable(image_reader):
+ values = [image_reader(value) for value in values]
+ else:
+ values = [_image_bytes(value, dataset.root)
+ for value in values]
else:
- values = [_image_bytes(value, dataset.root)
+ values = [_normalize_value(value, feature, name)
for value in values]
- else:
- values = [_normalize_value(value, feature, name)
- for value in values]
arrays.append(_safe_array(values, field, name, dtype))
fields.append(field)
return pa.Table.from_arrays(arrays, schema=pa.schema(fields))
+def _video_frame_descriptors(
+ dataset, info, episode, video_rows, name, feature, begin, end, cache,
+ timestamp_type):
+ if episode is None:
+ raise ValueError("LeRobot video import requires Episode metadata.")
+ episode_begin = _nonnegative_integer(
+ episode["dataset_from_index"], "dataset_from_index")
+ episode_end = _nonnegative_integer(
+ episode["dataset_to_index"], "dataset_to_index")
+ if begin < episode_begin or end > episode_end:
+ raise ValueError(
+ "LeRobot video batch [%d, %d) crosses Episode range [%d, %d)."
+ % (begin, end, episode_begin, episode_end)
+ )
+
+ fps = _video_fps(info, feature, name)
+ prefix = "videos/%s/" % name
+ try:
+ chunk_index = _nonnegative_integer(
+ episode[prefix + "chunk_index"], prefix + "chunk_index")
+ file_index = _nonnegative_integer(
+ episode[prefix + "file_index"], prefix + "file_index")
+ from_timestamp = float(_python_scalar(
+ episode[prefix + "from_timestamp"]))
+ to_timestamp = float(_python_scalar(
+ episode[prefix + "to_timestamp"]))
+ except (KeyError, TypeError, ValueError) as error:
+ raise ValueError(
+ "LeRobot Episode metadata is missing video mapping for %s."
+ % name
+ ) from error
+
+ episode_length = episode_end - episode_begin
+ _validate_video_timestamp_range(
+ from_timestamp, to_timestamp, fps, episode_length, name)
+
+ source_key = (name, chunk_index, file_index)
+ source = cache.get(source_key)
+ if source is None:
+ uri, length = _video_source(
+ dataset, info, episode, name, chunk_index, file_index)
+ source = (uri, length, _video_sample_timestamps(dataset, uri))
+ cache[source_key] = source
+ uri, length, sample_timestamps = source
+ return [
+ VideoFrameDescriptor(
+ uri,
+ 0,
+ length,
+ _video_frame_ordinal(
+ sample_timestamps,
+ timestamp,
+ from_timestamp,
+ timestamp_type,
+ name,
+ ),
+ ).serialize()
+ for unused_frame_index, timestamp in video_rows
+ ]
+
+
+def _video_sample_timestamps(dataset, uri):
+ resolver = getattr(dataset, "video_sample_timestamps", None)
+ if callable(resolver):
+ values = resolver(uri)
+ else:
+ try:
+ import av
+ except ImportError as error:
+ raise ImportError(
+ "LeRobot video import requires PyAV. Install it with "
+ "`pip install 'pypaimon[lerobot]'`.") from error
+
+ input_stream = None
+ parsed = urlparse(uri)
+ if parsed.scheme in ("", "file"):
+ source = unquote(parsed.path) if parsed.scheme else uri
+ else:
+ factory = getattr(dataset, "video_uri_reader_factory", None)
+ if factory is None:
+ raise ValueError(
+ "LeRobot video source %s cannot be inspected." % uri)
+ input_stream = factory.create(uri).new_input_stream(uri)
+ source = input_stream
+ try:
+ with av.open(source) as container:
+ stream = container.streams.video[0]
+ values = [
+ float(packet.pts * (packet.time_base or stream.time_base))
+ for packet in container.demux(stream)
+ if packet.pts is not None and not packet.is_discard
+ ]
+ finally:
+ if input_stream is not None:
+ input_stream.close()
+
+ timestamps = array("d", sorted(float(value) for value in values))
+ if not timestamps or any(not math.isfinite(value) for value in timestamps):
+ raise ValueError(
+ "LeRobot video source %s has no valid frame timestamps." % uri)
+ return timestamps
+
+
+def _video_frame_ordinal(
+ timestamps, frame_timestamp, from_timestamp, timestamp_type, name):
+ timestamp = from_timestamp + frame_timestamp
+ position = bisect_left(timestamps, timestamp)
+ candidates = []
+ if position:
+ candidates.append(position - 1)
+ if position < len(timestamps):
+ candidates.append(position)
+ ordinal = min(
+ candidates,
+ key=lambda index: (abs(timestamps[index] - timestamp), index),
+ )
+ expected = pa.scalar(
+ timestamps[ordinal] - from_timestamp, type=timestamp_type).as_py()
+ if not math.isclose(
+ frame_timestamp,
+ float(expected),
+ rel_tol=0.0,
+ abs_tol=_VIDEO_TIMESTAMP_TOLERANCE):
+ raise ValueError(
+ "LeRobot video feature %s has no frame matching timestamp %s."
+ % (name, timestamp))
+ return ordinal
+
+
+def _validate_video_rows(raw, info, episode, begin, end):
+ if episode is None:
+ raise ValueError("LeRobot video import requires Episode metadata.")
+ required = ("episode_index", "frame_index", "timestamp")
+ missing = [name for name in required if name not in raw.column_names]
+ if missing:
+ raise ValueError(
+ "LeRobot video import requires frame columns %s."
+ % ", ".join(missing)
+ )
+ episode_index = _nonnegative_integer(
+ episode["episode_index"], "episode_index")
+ episode_begin = _nonnegative_integer(
+ episode["dataset_from_index"], "dataset_from_index")
+ expected_frames = range(begin - episode_begin, end - episode_begin)
+ actual_episodes = raw.column("episode_index").to_pylist()
+ actual_frames = raw.column("frame_index").to_pylist()
+ timestamps = raw.column("timestamp").to_pylist()
+ timestamp_type = raw.schema.field("timestamp").type
+ fps = _positive_fps(info.get("fps"), "dataset")
+ result = []
+ for offset, expected_frame in enumerate(expected_frames):
+ actual_episode = _nonnegative_integer(
+ actual_episodes[offset], "episode_index")
+ actual_frame = _nonnegative_integer(
+ actual_frames[offset], "frame_index")
+ if actual_episode != episode_index or actual_frame != expected_frame:
+ raise ValueError(
+ "LeRobot frame rows do not match Episode %d range [%d, %d)."
+ % (episode_index, begin, end)
+ )
+ timestamp = timestamps[offset]
+ expected_timestamp = pa.scalar(
+ expected_frame / fps, type=timestamp_type).as_py()
+ if (isinstance(timestamp, bool)
+ or not isinstance(timestamp, numbers.Real)
+ or not math.isclose(
+ float(timestamp), float(expected_timestamp),
+ rel_tol=0.0,
+ abs_tol=_VIDEO_TIMESTAMP_TOLERANCE)):
+ raise ValueError(
+ "LeRobot frame %d has timestamp %r; expected %r."
+ % (actual_frame, timestamp, expected_timestamp)
+ )
+ result.append((actual_frame, float(timestamp)))
+ return result
+
+
+def _validate_video_timestamp_range(
+ from_timestamp, to_timestamp, fps, episode_length, name):
+ if (not math.isfinite(from_timestamp)
+ or not math.isfinite(to_timestamp)
+ or from_timestamp < 0
+ or to_timestamp <= from_timestamp):
+ raise ValueError(
+ "LeRobot video feature %s has invalid timestamp range [%s, %s)."
+ % (name, from_timestamp, to_timestamp)
+ )
+ duration = to_timestamp - from_timestamp
+ expected_duration = episode_length / fps
+ if not math.isclose(
+ duration,
+ expected_duration,
+ rel_tol=0.0,
+ abs_tol=_VIDEO_TIMESTAMP_TOLERANCE):
+ raise ValueError(
+ "LeRobot video feature %s has duration %s, but Episode with %d "
+ "frames at FPS %s requires %s."
+ % (name, duration, episode_length, fps, expected_duration)
+ )
+
+
+def _video_fps(info, feature, name):
+ global_fps = _positive_fps(info.get("fps"), "dataset")
+ values = []
+ for key in ("info", "video_info"):
+ details = feature.get(key)
+ if isinstance(details, dict) and "video.fps" in details:
+ values.append(details["video.fps"])
+ if "fps" in feature:
+ values.append(feature["fps"])
+ value = values[0] if values else global_fps
+ fps = _positive_fps(value, "video feature %s" % name)
+ if any(not math.isclose(
+ fps, _positive_fps(other, "video feature %s" % name),
+ rel_tol=1e-6, abs_tol=1e-6) for other in values[1:]) \
+ or not math.isclose(
+ fps, global_fps, rel_tol=1e-6, abs_tol=1e-6):
+ raise ValueError(
+ "LeRobot video feature %s FPS does not match dataset FPS."
+ % name
+ )
+ return fps
+
+
+def _positive_fps(value, owner):
+ try:
+ fps = float(value)
+ except (TypeError, ValueError) as error:
+ raise ValueError(
+ "LeRobot %s is missing a valid FPS." % owner
+ ) from error
+ if not math.isfinite(fps) or fps <= 0:
+ raise ValueError(
+ "LeRobot %s is missing a valid FPS." % owner)
+ return fps
+
+
+def _nonnegative_integer(value, name):
+ value = _python_scalar(value)
+ if isinstance(value, bool) \
+ or not isinstance(value, numbers.Integral) or value < 0:
+ raise ValueError(
+ "LeRobot %s must be a non-negative integer; found %r."
+ % (name, value))
+ return int(value)
+
+
+def _video_source(
+ dataset, info, episode, name, chunk_index, file_index):
+ resolver = getattr(dataset, "video_source", None)
+ if callable(resolver):
+ return resolver(name, episode)
+
+ template = info.get("video_path")
+ if not isinstance(template, str) or not template:
+ raise ValueError("LeRobot v3 metadata is missing info.video_path.")
+ relative = template.format(
+ video_key=name,
+ chunk_index=chunk_index,
+ file_index=file_index,
+ )
+ root = Path(dataset.root).resolve()
+ path = (root / relative).resolve()
+ try:
+ path.relative_to(root)
+ except ValueError as error:
+ raise ValueError(
+ "LeRobot video path must stay within the source directory: %s"
+ % relative
+ ) from error
+ if not path.is_file():
+ raise FileNotFoundError("LeRobot video file does not exist: %s" % path)
+ length = path.stat().st_size
+ if length <= 0:
+ raise ValueError("LeRobot video file is empty: %s" % path)
+ return path.as_uri(), length
+
+
def _safe_array(values, field, name, dtype):
_validate_declared_range(values, field.type, name, dtype)
try:
diff --git a/paimon-python/pypaimon/multimodal/lerobot/metadata.py
b/paimon-python/pypaimon/multimodal/lerobot/metadata.py
index e9daa02066..df75ce9ab0 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/metadata.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/metadata.py
@@ -73,15 +73,33 @@ _EPISODE_CONTROL_COLUMNS = [
class _EpisodeIndex:
- def __init__(self):
+ def __init__(self, video_fields=()):
self._ranges = array("q")
self._task_offsets = array("q", [0])
self._task_indices = array("q")
+ self._video_fields = tuple(video_fields)
+ self._video_indices = {
+ field: (array("q"), array("q"))
+ for field in self._video_fields
+ }
+ self._video_timestamps = {
+ field: (array("d"), array("d"))
+ for field in self._video_fields
+ }
- def append(self, begin, end, task_indices):
+ def append(self, begin, end, task_indices, video_values=None):
self._ranges.extend((begin, end))
self._task_indices.extend(task_indices)
self._task_offsets.append(len(self._task_indices))
+ video_values = video_values or {}
+ for field in self._video_fields:
+ values = video_values[field]
+ chunk_indices, file_indices = self._video_indices[field]
+ from_timestamps, to_timestamps = self._video_timestamps[field]
+ chunk_indices.append(values[0])
+ file_indices.append(values[1])
+ from_timestamps.append(values[2])
+ to_timestamps.append(values[3])
def __len__(self):
return len(self._ranges) // 2
@@ -95,13 +113,22 @@ class _EpisodeIndex:
task_end = self._task_offsets[index + 1]
begin = self._ranges[index * 2]
end = self._ranges[index * 2 + 1]
- return {
+ result = {
"episode_index": index,
"dataset_from_index": begin,
"dataset_to_index": end,
"length": end - begin,
"task_indices": self._task_indices[task_begin:task_end],
}
+ for field in self._video_fields:
+ chunk_indices, file_indices = self._video_indices[field]
+ from_timestamps, to_timestamps = self._video_timestamps[field]
+ prefix = "videos/%s/" % field
+ result[prefix + "chunk_index"] = chunk_indices[index]
+ result[prefix + "file_index"] = file_indices[index]
+ result[prefix + "from_timestamp"] = from_timestamps[index]
+ result[prefix + "to_timestamp"] = to_timestamps[index]
+ return result
def _load_dataset_metadata(dataset, info, source):
@@ -535,14 +562,29 @@ def _source_episodes(dataset, source):
return {"paths": paths, "schema": schema}
-def _validated_episode_tables(metadata):
- episodes = _EpisodeIndex()
+def _validated_episode_tables(metadata, video_fields=()):
+ episodes = _EpisodeIndex(video_fields)
expected_begin = 0
+ video_columns = [
+ "videos/%s/%s" % (field, suffix)
+ for field in video_fields
+ for suffix in (
+ "chunk_index", "file_index", "from_timestamp", "to_timestamp")
+ ]
+ required_columns = _EPISODE_CONTROL_COLUMNS + video_columns
for table in _source_episode_tables(metadata):
- controls = table.select(_EPISODE_CONTROL_COLUMNS)
+ missing = [
+ name for name in required_columns
+ if name not in table.column_names
+ ]
+ if missing:
+ raise ValueError(
+ "LeRobot Episode metadata is missing columns: %s."
+ % ", ".join(missing))
+ controls = table.select(required_columns)
columns = {
name: controls.column(name)
- for name in _EPISODE_CONTROL_COLUMNS
+ for name in required_columns
}
for offset in range(controls.num_rows):
index = _integer(
@@ -581,7 +623,37 @@ def _validated_episode_tables(metadata):
if len(set(task_indices)) != len(task_indices):
raise ValueError(
"LeRobot Episode %d repeats a task." % index)
- episodes.append(begin, end, task_indices)
+ video_values = {}
+ for field in video_fields:
+ prefix = "videos/%s/" % field
+ chunk_index = _integer(
+ columns[prefix + "chunk_index"][offset].as_py(),
+ prefix + "chunk_index",
+ )
+ file_index = _integer(
+ columns[prefix + "file_index"][offset].as_py(),
+ prefix + "file_index",
+ )
+ if chunk_index < 0 or file_index < 0:
+ raise ValueError(
+ "LeRobot Episode video indices must be non-negative.")
+ from_timestamp = columns[
+ prefix + "from_timestamp"][offset].as_py()
+ to_timestamp = columns[
+ prefix + "to_timestamp"][offset].as_py()
+ if isinstance(from_timestamp, bool) \
+ or not isinstance(from_timestamp, numbers.Real) \
+ or isinstance(to_timestamp, bool) \
+ or not isinstance(to_timestamp, numbers.Real):
+ raise ValueError(
+ "LeRobot Episode video timestamps must be numeric.")
+ video_values[field] = (
+ chunk_index,
+ file_index,
+ float(from_timestamp),
+ float(to_timestamp),
+ )
+ episodes.append(begin, end, task_indices, video_values)
expected_begin = end
yield table
del table
diff --git a/paimon-python/pypaimon/multimodal/lerobot/schema.py
b/paimon-python/pypaimon/multimodal/lerobot/schema.py
index de16c95a64..188127a14d 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/schema.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/schema.py
@@ -83,6 +83,14 @@ def _validate_v3_required_features(info):
"shape=[1]." % (name, dtype))
+def _video_feature_names(info):
+ return [
+ name
+ for name, feature in info.get("features", {}).items()
+ if isinstance(feature, dict) and feature.get("dtype") == "video"
+ ]
+
+
def _validate_lerobot_schema(source_schema, target_schema, source):
"""Require an existing table to preserve the LeRobot feature contract."""
for source_field in source_schema:
@@ -122,11 +130,7 @@ def _feature_field(name, feature):
"LeRobot feature %s metadata must be an object." % name)
dtype = str(feature.get("dtype", ""))
shape = _feature_shape(feature, name)
- if dtype == "video":
- raise ValueError(
- "LeRobot video feature %s is not supported yet; use an "
- "image-based dataset." % name)
- if dtype == "image":
+ if dtype in ("image", "video"):
arrow_type = pa.large_binary()
else:
scalar_type = _SCALAR_DTYPES.get(dtype)
diff --git a/paimon-python/pypaimon/multimodal/lerobot/source.py
b/paimon-python/pypaimon/multimodal/lerobot/source.py
index 04547db804..995c14a438 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/source.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/source.py
@@ -32,7 +32,9 @@ import pyarrow.fs as pafs
import pyarrow.parquet as pq
from pypaimon.common.options import Options
+from pypaimon.common.uri_reader import FileUriReader
from pypaimon.filesystem.pyarrow_file_io import LegacyOssDirectoryListingError
+from pypaimon.multimodal.lerobot.schema import _video_feature_names
from pypaimon.multimodal.source_utils import (
_SourceFileIO,
_normalize_source_path,
@@ -61,17 +63,37 @@ class _RemoteLeRobotMeta:
class _RemoteEpisodeIndex:
- def __init__(self):
+ def __init__(self, video_fields=()):
self.starts = array("q")
self._ends = array("q")
self._chunk_indices = array("q")
self._file_indices = array("q")
+ self._video_fields = tuple(video_fields)
+ self._video_indices = {
+ field: (array("q"), array("q"))
+ for field in self._video_fields
+ }
+ self._video_timestamps = {
+ field: (array("d"), array("d"))
+ for field in self._video_fields
+ }
- def append(self, begin, end, chunk_index, file_index):
+ def append(
+ self, begin, end, chunk_index, file_index,
+ video_values=None):
self.starts.append(begin)
self._ends.append(end)
self._chunk_indices.append(chunk_index)
self._file_indices.append(file_index)
+ video_values = video_values or {}
+ for field in self._video_fields:
+ values = video_values[field]
+ chunk_indices, file_indices = self._video_indices[field]
+ from_timestamps, to_timestamps = self._video_timestamps[field]
+ chunk_indices.append(values[0])
+ file_indices.append(values[1])
+ from_timestamps.append(values[2])
+ to_timestamps.append(values[3])
def __len__(self):
return len(self.starts)
@@ -81,13 +103,23 @@ class _RemoteEpisodeIndex:
index += len(self)
if index < 0 or index >= len(self):
raise IndexError(index)
- return {
+ result = {
"episode_index": index,
"dataset_from_index": self.starts[index],
"dataset_to_index": self._ends[index],
+ "length": self._ends[index] - self.starts[index],
"data/chunk_index": self._chunk_indices[index],
"data/file_index": self._file_indices[index],
}
+ for field in self._video_fields:
+ chunk_indices, file_indices = self._video_indices[field]
+ from_timestamps, to_timestamps = self._video_timestamps[field]
+ prefix = "videos/%s/" % field
+ result[prefix + "chunk_index"] = chunk_indices[index]
+ result[prefix + "file_index"] = file_indices[index]
+ result[prefix + "from_timestamp"] = from_timestamps[index]
+ result[prefix + "to_timestamp"] = to_timestamps[index]
+ return result
@contextmanager
@@ -197,17 +229,17 @@ def _load_hub_info(source):
% (source.path, error)) from error
-def _open_dataset(LeRobotDataset, source):
+def _open_dataset(LeRobotDataset, source, download_videos=False):
try:
if source.root is not None:
return LeRobotDataset(
repo_id=source.repo_id,
root=source.root,
- download_videos=False,
+ download_videos=download_videos,
)
return LeRobotDataset(
repo_id=source.repo_id,
- download_videos=False,
+ download_videos=download_videos,
)
except Exception as error:
raise ValueError(
@@ -215,10 +247,11 @@ def _open_dataset(LeRobotDataset, source):
% (source.path, error)) from error
-def _open_resolved_dataset(LeRobotDataset, source, info):
+def _open_resolved_dataset(
+ LeRobotDataset, source, info, download_videos=False):
if source.file_io is not None:
return _RemoteLeRobotDataset(source, info)
- return _open_dataset(LeRobotDataset, source)
+ return _open_dataset(LeRobotDataset, source, download_videos)
class _RemoteLeRobotDataset:
@@ -291,35 +324,94 @@ class _RemoteLeRobotDataset:
return _read_remote_bytes(self._file_io, source_path)
return _encode_media_frame(value)
+ def video_source(self, video_key, episode):
+ relative_path = self.meta.info["video_path"].format(
+ video_key=video_key,
+ chunk_index=int(episode[
+ "videos/%s/chunk_index" % video_key]),
+ file_index=int(episode[
+ "videos/%s/file_index" % video_key]),
+ )
+ relative_path = _relative_dataset_path(
+ relative_path, "info.video_path")
+ path = _remote_source_path(
+ self.source.path,
+ relative_path,
+ "info.video_path",
+ self._file_io,
+ )
+ status = self._file_io.get_file_status(path)
+ if status.type != pafs.FileType.File \
+ or status.size is None or status.size <= 0:
+ raise ValueError("LeRobot video file is empty: %s" % path)
+ return path, int(status.size)
+
+ @property
+ def video_uri_reader_factory(self):
+ return _SourceUriReaderFactory(self._file_io)
+
def _load_episodes(self, info):
episode_count = int(info.get("total_episodes", 0))
if episode_count == 0:
return []
directory = _remote_path(self.source.path, "meta/episodes")
paths = _remote_parquet_files(self._file_io, directory)
- episodes = _RemoteEpisodeIndex()
+ video_fields = _video_feature_names(info)
+ selected_columns = list(self._EPISODE_COLUMNS)
+ for field in video_fields:
+ selected_columns.extend([
+ "videos/%s/%s" % (field, suffix)
+ for suffix in (
+ "chunk_index",
+ "file_index",
+ "from_timestamp",
+ "to_timestamp",
+ )
+ ])
+ episodes = _RemoteEpisodeIndex(video_fields)
for path in paths:
table = _read_remote_parquet(
self._file_io,
path,
- columns=self._EPISODE_COLUMNS,
+ columns=selected_columns,
)
- columns = {
+ control_columns = {
name: table.column(name)
for name in self._EPISODE_COLUMNS
}
for offset in range(table.num_rows):
episode_index = int(
- columns["episode_index"][offset].as_py())
+ control_columns["episode_index"][offset].as_py())
if episode_index != len(episodes):
raise ValueError(
"LeRobot Episode metadata must be ordered by "
"episode_index.")
episodes.append(
- int(columns["dataset_from_index"][offset].as_py()),
- int(columns["dataset_to_index"][offset].as_py()),
- int(columns["data/chunk_index"][offset].as_py()),
- int(columns["data/file_index"][offset].as_py()),
+ int(control_columns[
+ "dataset_from_index"][offset].as_py()),
+ int(control_columns[
+ "dataset_to_index"][offset].as_py()),
+ int(control_columns[
+ "data/chunk_index"][offset].as_py()),
+ int(control_columns[
+ "data/file_index"][offset].as_py()),
+ {
+ field: (
+ int(table.column(
+ "videos/%s/chunk_index" % field
+ )[offset].as_py()),
+ int(table.column(
+ "videos/%s/file_index" % field
+ )[offset].as_py()),
+ float(table.column(
+ "videos/%s/from_timestamp" % field
+ )[offset].as_py()),
+ float(table.column(
+ "videos/%s/to_timestamp" % field
+ )[offset].as_py()),
+ )
+ for field in video_fields
+ },
)
if len(episodes) != episode_count:
raise ValueError(
@@ -389,6 +481,15 @@ def _remote_path(root, relative_path):
return "%s/%s" % (root.rstrip("/"), relative_path.lstrip("/"))
+class _SourceUriReaderFactory:
+
+ def __init__(self, file_io):
+ self._reader = FileUriReader(file_io)
+
+ def create(self, unused_uri):
+ return self._reader
+
+
def _pandas_index_column(schema, component):
encoded = (schema.metadata or {}).get(b"pandas")
try:
diff --git a/paimon-python/pypaimon/multimodal/lerobot/writer.py
b/paimon-python/pypaimon/multimodal/lerobot/writer.py
index f73fadf110..8fc152e6f1 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/writer.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/writer.py
@@ -52,6 +52,7 @@ from pypaimon.multimodal.lerobot.schema import (
_schema_from_info,
_validate_lerobot_schema,
_validate_v3_required_features,
+ _video_feature_names,
)
from pypaimon.multimodal.table import _target_schema
@@ -288,6 +289,11 @@ class PaimonLeRobotWriter:
raise ValueError("features must be a non-empty mapping.")
if "task" in features:
raise ValueError("task is managed by PaimonLeRobotWriter.")
+ video_fields = _video_feature_names({"features": features})
+ if video_fields:
+ raise ValueError(
+ "PaimonLeRobotWriter does not support video features: %s."
+ % ", ".join(video_fields))
requested_subtasks = _validate_subtasks(
subtasks, "subtask_index" in features)
_lerobot_stats_functions()
diff --git a/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
b/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
index eb5b61786c..bc797398e3 100644
--- a/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
+++ b/paimon-python/pypaimon/tests/data_evolution_row_rolling_test.py
@@ -134,6 +134,20 @@ class DataEvolutionRowRollingTest(unittest.TestCase):
tw.close()
return files
+ def _write_episode_files(self, table, data, episode_lengths):
+ wb = table.new_batch_write_builder()
+ writer = wb.new_write()
+ offset = 0
+ for length in episode_lengths:
+ writer.roll_before_group_if_needed(length)
+ writer.write_arrow(data.slice(offset, length))
+ offset += length
+ messages = writer.prepare_commit()
+ files = [file for message in messages for file in message.new_files]
+ wb.new_commit().commit(messages)
+ writer.close()
+ return files
+
def _read_ids(self, table):
rb = table.new_read_builder().with_projection(['id'])
return sorted(
@@ -419,6 +433,179 @@ class DataEvolutionRowRollingTest(unittest.TestCase):
self.assertEqual([4, 4], sorted(file.row_count for file in
video_files))
self.assertEqual(list(range(4)), self._read_ids(table))
+ def test_video_episodes_roll_before_shared_payload_group(self):
+ path = os.path.join(self.tempdir, 'shared-episodes.mp4')
+ payload = b'shared-video'
+ with open(path, 'wb') as output:
+ output.write(payload)
+ descriptor = BlobDescriptor(path, 0, len(payload))
+ table = self._create_with_schema(
+ self.blob_schema,
+ {
+ **self.de_options,
+ 'target-file-row-num': '3',
+ 'video-frame-field': 'payload',
+ 'blob-as-descriptor': 'true',
+ },
+ )
+ rows = pa.Table.from_pydict(
+ {
+ 'id': list(range(6)),
+ 'payload': [
+ VideoFrameDescriptor(
+ descriptor.uri,
+ descriptor.offset,
+ descriptor.length,
+ frame,
+ ).serialize()
+ for frame in range(6)
+ ],
+ },
+ schema=self.blob_schema,
+ )
+
+ files = self._write_episode_files(table, rows, [2, 4])
+
+ normal_rows = sorted(
+ file.row_count for file in files
+ if not file.file_name.endswith('.video')
+ )
+ video_rows = sorted(
+ file.row_count for file in files
+ if file.file_name.endswith('.video')
+ )
+ self.assertEqual([2, 4], normal_rows)
+ self.assertEqual([2, 4], video_rows)
+ self.assertEqual(list(range(6)), self._read_ids(table))
+
+ def test_video_columns_roll_together_at_episode_boundaries(self):
+ paths = [
+ os.path.join(self.tempdir, name)
+ for name in ('small-camera.mp4', 'large-camera.mp4')
+ ]
+ payloads = [b'a', b'b' * 100]
+ for path, payload in zip(paths, payloads):
+ with open(path, 'wb') as output:
+ output.write(payload)
+ camera_a, camera_b = [
+ BlobDescriptor(path, 0, len(payload))
+ for path, payload in zip(paths, payloads)
+ ]
+ table = self._create_with_schema(
+ self.multi_video_schema,
+ {
+ **self.de_options,
+ 'video-frame-field': 'camera_a,camera_b',
+ 'blob-as-descriptor': 'true',
+ 'blob.target-file-size': '50 b',
+ },
+ )
+ rows = pa.Table.from_pydict(
+ {
+ 'id': list(range(4)),
+ 'camera_a': [
+ VideoFrameDescriptor(
+ camera_a.uri, 0, camera_a.length, frame
+ ).serialize()
+ for frame in range(4)
+ ],
+ 'camera_b': [
+ VideoFrameDescriptor(
+ camera_b.uri, 0, camera_b.length, frame
+ ).serialize()
+ for frame in range(4)
+ ],
+ },
+ schema=self.multi_video_schema,
+ )
+
+ files = self._write_episode_files(table, rows, [2, 2])
+
+ files_by_column = {}
+ for file in files:
+ if file.file_name.endswith('.video'):
+ files_by_column.setdefault(file.write_cols[0], []).append(
+ file.row_count)
+ self.assertEqual([2, 2], files_by_column['camera_a'])
+ self.assertEqual([2, 2], files_by_column['camera_b'])
+ self.assertEqual([2, 2], sorted(
+ file.row_count for file in files
+ if not file.file_name.endswith('.video')
+ ))
+ result = table.new_read_builder().new_read().to_arrow(
+ table.new_read_builder().new_scan().plan().splits()
+ ).sort_by('id')
+ for column in ('camera_a', 'camera_b'):
+ self.assertEqual(
+ list(range(4)),
+ [
+ VideoFrameDescriptor.deserialize(value.as_py()).frame_index
+ for value in result[column]
+ ],
+ )
+ self.assertEqual(list(range(4)), self._read_ids(table))
+
+ def test_vector_rolling_waits_for_video_episode_boundary(self):
+ path = os.path.join(self.tempdir, 'vector-episodes.mp4')
+ payload = b'shared-video'
+ with open(path, 'wb') as output:
+ output.write(payload)
+ descriptor = BlobDescriptor(path, 0, len(payload))
+ rows = pa.Table.from_pydict(
+ {
+ 'id': list(range(4)),
+ 'payload': [
+ VideoFrameDescriptor(
+ descriptor.uri,
+ descriptor.offset,
+ descriptor.length,
+ frame,
+ ).serialize()
+ for frame in range(4)
+ ],
+ 'embedding': [
+ [float(frame), float(frame + 1), float(frame + 2)]
+ for frame in range(4)
+ ],
+ },
+ schema=self.blob_vector_schema,
+ )
+
+ for episode_lengths, expected_rows in (
+ ([2, 2], [2, 2]), ([4], [4])):
+ with self.subTest(episode_lengths=episode_lengths):
+ table = self._create_with_schema(
+ self.blob_vector_schema,
+ {
+ **self.de_options,
+ 'target-file-row-num': '100',
+ 'video-frame-field': 'payload',
+ 'blob-as-descriptor': 'true',
+ 'vector.file.format': 'parquet',
+ 'vector.target-file-size': '1 b',
+ },
+ )
+ files = self._write_episode_files(
+ table, rows, episode_lengths)
+
+ normal_rows = sorted(
+ file.row_count for file in files
+ if not file.file_name.endswith('.video')
+ and '.vector.' not in file.file_name
+ )
+ video_rows = sorted(
+ file.row_count for file in files
+ if file.file_name.endswith('.video')
+ )
+ vector_rows = sorted(
+ file.row_count for file in files
+ if '.vector.' in file.file_name
+ )
+ self.assertEqual(expected_rows, normal_rows)
+ self.assertEqual(expected_rows, video_rows)
+ self.assertEqual(expected_rows, vector_rows)
+ self.assertEqual(list(range(4)), self._read_ids(table))
+
def test_blob_consumer_descriptors_survive_abort_after_rolling(self):
table = self._create_with_schema(
self.blob_schema,
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
index 5be17dc7b5..82e2b7631c 100644
--- a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
@@ -16,6 +16,7 @@
import builtins
from array import array
+from fractions import Fraction
import io
import json
import pickle
@@ -26,6 +27,7 @@ import threading
import unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
+from types import SimpleNamespace
from unittest.mock import Mock, patch
import numpy as np
@@ -48,6 +50,7 @@ from pypaimon.multimodal.lerobot.dataset import (
_selected_episodes,
_torch_row,
)
+from pypaimon.multimodal.lerobot.api import _create_target_table
from pypaimon.multimodal.lerobot.metadata import (
_append_arrow_tables,
_companion_identifier,
@@ -61,6 +64,8 @@ from pypaimon.multimodal.lerobot.metadata import (
from pypaimon.multimodal.lerobot.loader import (
_image_bytes,
_read_batch,
+ _video_frame_ordinal,
+ _video_sample_timestamps,
_validate_frame_controls,
)
from pypaimon.multimodal.lerobot.schema import (
@@ -83,6 +88,11 @@ try:
except ImportError:
LeRobotDataset = None
+try:
+ import av
+except ImportError:
+ av = None
+
def _replaced_contract(field, old, new):
description = field.metadata[b"description"].decode("utf-8")
@@ -882,8 +892,10 @@ class LeRobotValidationTest(unittest.TestCase):
"shape": [8, 10, 3],
}
}
- with self.assertRaisesRegex(ValueError, "video feature camera.*not
supported"):
- _schema_from_info(info)
+ self.assertEqual(
+ pa.large_binary(),
+ _schema_from_info(info).field("camera").type,
+ )
def test_existing_schema_preserves_lerobot_feature_contract(self):
source = _schema_from_info({
@@ -993,6 +1005,7 @@ class LeRobotValidationTest(unittest.TestCase):
"episode_index": [0],
"dataset_from_index": [0],
"dataset_to_index": [1],
+ "length": [1],
"data/chunk_index": [0],
"data/file_index": [0],
})
@@ -1026,6 +1039,16 @@ class LeRobotValidationTest(unittest.TestCase):
"fps": 30,
"features": {
"index": {"dtype": "int64", "shape": [1]},
+ "timestamp": {
+ "dtype": "float32",
+ "shape": [1],
+ "fps": 10.0,
+ },
+ "camera": {
+ "dtype": "video",
+ "shape": [8, 10, 3],
+ "video_info": {"video.fps": 10.0},
+ },
},
}))
connection = pmm.connect(options={
@@ -1042,6 +1065,72 @@ class LeRobotValidationTest(unittest.TestCase):
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
+ def test_video_options_must_match_metadata(self):
+ temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_options_"))
+ try:
+ info = {
+ "features": {
+ "index": {"dtype": "int64", "shape": [1]},
+ "camera_a": {"dtype": "video", "shape": [8, 10, 3]},
+ "camera_b": {"dtype": "video", "shape": [8, 10, 3]},
+ },
+ }
+ connection = pmm.connect(options={
+ "warehouse": str(temp_dir / "warehouse"),
+ })
+ schema = _schema_from_info(info)
+ metadata = {"stats_table": None, "subtasks_table": None}
+
+ with self.assertRaisesRegex(ValueError, "do not match"):
+ _create_target_table(
+ connection,
+ "conflict",
+ schema,
+ options={"video-frame-field": "camera_a"},
+ metadata=metadata,
+ video_fields=("camera_a", "camera_b"),
+ )
+
+ table = _create_target_table(
+ connection,
+ "reordered",
+ schema,
+ options={"video-frame-field": "camera_b,camera_a"},
+ metadata=metadata,
+ video_fields=("camera_a", "camera_b"),
+ )
+ self.assertEqual(
+ {"camera_a", "camera_b"},
+ table.raw_table.options.video_frame_fields(),
+ )
+ finally:
+ shutil.rmtree(temp_dir, ignore_errors=True)
+
+ def test_video_import_requires_single_writer_layout(self):
+ temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_layout_"))
+ try:
+ info = {
+ "features": {
+ "episode_index": {"dtype": "int64", "shape": [1]},
+ "camera": {"dtype": "video", "shape": [8, 10, 3]},
+ },
+ }
+ schema = _schema_from_info(info)
+ connection = pmm.connect(options={
+ "warehouse": str(temp_dir / "warehouse"),
+ })
+ with self.assertRaisesRegex(ValueError, "bucket-unaware"):
+ _create_target_table(
+ connection,
+ "bucketed",
+ schema,
+ options={"bucket": "1"},
+ metadata={"stats_table": None, "subtasks_table": None},
+ video_fields=("camera",),
+ )
+ finally:
+ shutil.rmtree(temp_dir, ignore_errors=True)
+
def test_empty_fast_path_validates_required_counts(self):
temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_counts_"))
try:
@@ -1339,26 +1428,644 @@ class LeRobotValidationTest(unittest.TestCase):
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
- def test_local_video_is_rejected_before_opening(self):
+ def test_episode_aware_multi_video_import(self):
+ import pandas as pd
+
temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_video_"))
try:
info_dir = temp_dir / "meta"
info_dir.mkdir()
- (info_dir / "info.json").write_text(json.dumps({
+ info = {
"codebase_version": "v3.0",
+ "fps": 10,
+ "total_frames": 5,
+ "total_episodes": 2,
+ "total_tasks": 1,
+ "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"
+ ),
"features": {
- "camera": {"dtype": "video", "shape": [8, 10, 3]},
+ "index": {"dtype": "int64", "shape": [1]},
+ "episode_index": {"dtype": "int64", "shape": [1]},
+ "frame_index": {"dtype": "int64", "shape": [1]},
+ "timestamp": {
+ "dtype": "float32",
+ "shape": [1],
+ "fps": 10.0,
+ },
+ "task_index": {"dtype": "int64", "shape": [1]},
+ "observation.state": {
+ "dtype": "float32",
+ "shape": [3],
+ },
+ "camera_a": {
+ "dtype": "video",
+ "shape": [8, 10, 3],
+ "video_info": {"video.fps": 10.0},
+ },
+ "camera_b": {
+ "dtype": "video",
+ "shape": [8, 10, 3],
+ "video_info": {"video.fps": 10.0},
+ },
},
- }))
+ }
+ (info_dir / "info.json").write_text(json.dumps(info))
+ payloads = {
+ "camera_a/chunk-000/file-000.mp4": b"camera-a",
+ "camera_b/chunk-000/file-000.mp4": b"camera-b-0",
+ "camera_b/chunk-000/file-001.mp4": b"camera-b-1",
+ }
+ for relative, payload in payloads.items():
+ path = temp_dir / "videos" / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(payload)
+
+ episodes = [
+ {
+ "episode_index": 0,
+ "dataset_from_index": 0,
+ "dataset_to_index": 2,
+ "length": 2,
+ "data/chunk_index": 0,
+ "data/file_index": 0,
+ "tasks": ["pick"],
+ "videos/camera_a/chunk_index": 0,
+ "videos/camera_a/file_index": 0,
+ "videos/camera_a/from_timestamp": 0.5,
+ "videos/camera_a/to_timestamp": 0.7,
+ "videos/camera_b/chunk_index": 0,
+ "videos/camera_b/file_index": 0,
+ "videos/camera_b/from_timestamp": 0.0,
+ "videos/camera_b/to_timestamp": 0.2,
+ },
+ {
+ "episode_index": 1,
+ "dataset_from_index": 2,
+ "dataset_to_index": 5,
+ "length": 3,
+ "data/chunk_index": 0,
+ "data/file_index": 0,
+ "tasks": ["pick"],
+ "videos/camera_a/chunk_index": 0,
+ "videos/camera_a/file_index": 0,
+ "videos/camera_a/from_timestamp": 0.1,
+ "videos/camera_a/to_timestamp": 0.4,
+ "videos/camera_b/chunk_index": 0,
+ "videos/camera_b/file_index": 1,
+ "videos/camera_b/from_timestamp": 0.0,
+ "videos/camera_b/to_timestamp": 0.3,
+ },
+ ]
+ # The published Episode Parquet, not the dataset object's stale
+ # metadata cache, must determine the imported video descriptors.
+ cached_episodes = [dict(episode) for episode in episodes]
+ cached_episodes[1].update({
+ "videos/camera_b/file_index": 0,
+ "videos/camera_b/from_timestamp": 0.2,
+ "videos/camera_b/to_timestamp": 0.5,
+ })
+
+ class Dataset:
+
+ root = temp_dir
+ meta = SimpleNamespace(
+ info=info, episodes=cached_episodes, tasks=["pick"])
+ rows = pa.table({
+ "index": pa.array(range(5), type=pa.int64()),
+ "episode_index": pa.array(
+ [0, 0, 1, 1, 1], type=pa.int64()),
+ "frame_index": pa.array(
+ [0, 1, 0, 1, 2], type=pa.int64()),
+ "timestamp": pa.array(
+ [0.0, 0.1, 0.0, 0.1, 0.2],
+ type=pa.float32(),
+ ),
+ "task_index": pa.array([0] * 5, type=pa.int64()),
+ "observation.state": pa.array(
+ [[float(index), 0.0, 1.0] for index in range(5)],
+ type=pa.list_(pa.float32(), 3),
+ ),
+ })
+
+ def __len__(self):
+ return 5
+
+ def read_batch(self, begin, end):
+ return self.rows.slice(begin, end - begin)
+
connection = pmm.connect(options={
"warehouse": str(temp_dir / "warehouse"),
})
- with self.assertRaisesRegex(
- ValueError, "video feature camera.*not supported"):
- connection.load_from_lerobot("frames", temp_dir)
+ episodes_path = (
+ temp_dir / "meta/episodes/chunk-000/file-000.parquet")
+ episodes_path.parent.mkdir(parents=True)
+ pq.write_table(pa.Table.from_pylist(episodes), episodes_path)
+ pq.write_table(pa.Table.from_pandas(pd.DataFrame(
+ {"task_index": [0]},
+ index=pd.Index(["pick"], name="task"),
+ )), temp_dir / "meta/tasks.parquet")
+
+ def sample_timestamps(unused_dataset, uri):
+ return (
+ [0.0, 0.1, 0.2, 0.3, 0.5, 0.6]
+ if "camera_a" in uri else
+ [0.0, 0.1, 0.2, 0.3, 0.4]
+ )
+
+ with patch(
+ "pypaimon.multimodal.lerobot.api."
+ "_import_lerobot_dataset",
+ return_value=object,
+ ), patch(
+ "pypaimon.multimodal.lerobot.api."
+ "_open_resolved_dataset",
+ return_value=Dataset(),
+ ), patch(
+ "pypaimon.multimodal.lerobot.loader."
+ "_video_sample_timestamps",
+ side_effect=sample_timestamps,
+ ):
+ result = connection.load_from_lerobot(
+ "frames", temp_dir, batch_size=1)
+
+ self.assertIsNone(result)
+ table = connection.get_table("frames")
+ self.assertEqual(
+ {"camera_a", "camera_b"},
+ table.raw_table.options.video_frame_fields(),
+ )
+ field_types = {
+ field.name: str(field.type)
+ for field in table.raw_table.fields
+ }
+ self.assertEqual(
+ "VECTOR<FLOAT, 3> NOT NULL",
+ field_types["observation.state"],
+ )
+ rows = table.scan().select([
+ "index", "camera_a", "camera_b"
+ ]).to_arrow().sort_by("index").to_pylist()
+ camera_a = [
+ pmm.VideoFrameDescriptor.deserialize(row["camera_a"])
+ for row in rows
+ ]
+ camera_b = [
+ pmm.VideoFrameDescriptor.deserialize(row["camera_b"])
+ for row in rows
+ ]
+ self.assertEqual(
+ [4, 5, 1, 2, 3],
+ [descriptor.frame_index for descriptor in camera_a],
+ )
+ self.assertEqual(
+ [0, 1, 0, 1, 2],
+ [descriptor.frame_index for descriptor in camera_b],
+ )
+ _, bodies = table.scan().select([
+ "index", "camera_a", "camera_b"
+ ]).read_blobs()
+ self.assertEqual(
+ [payloads["camera_a/chunk-000/file-000.mp4"]] * 5,
+ bodies["camera_a"],
+ )
+ self.assertEqual(
+ [payloads["camera_b/chunk-000/file-000.mp4"]] * 2
+ + [payloads["camera_b/chunk-000/file-001.mp4"]] * 3,
+ bodies["camera_b"],
+ )
+
+ data_path = temp_dir / "data/chunk-000/file-000.parquet"
+ data_path.parent.mkdir(parents=True)
+ pq.write_table(Dataset.rows, data_path)
+ remote = "oss://source-bucket/robot-videos"
+ source_file_io = _RemoteLeRobotFileIO(temp_dir, remote)
+ with patch(
+ "pypaimon.multimodal.lerobot.source._SourceFileIO",
+ return_value=source_file_io,
+ ), patch(
+ "pypaimon.multimodal.lerobot.api."
+ "_import_lerobot_dataset",
+ return_value=object,
+ ), patch(
+ "pypaimon.multimodal.lerobot.loader."
+ "_video_sample_timestamps",
+ side_effect=sample_timestamps,
+ ):
+ remote_result = connection.load_from_lerobot(
+ "remote_frames", remote, batch_size=1)
+
+ self.assertIsNone(remote_result)
+ opened_videos = [
+ path for path in source_file_io.opened_paths
+ if path.endswith(".mp4")
+ ]
+ self.assertEqual(3, len(opened_videos))
+ self.assertEqual(1, source_file_io.close_count)
+ _, remote_bodies = connection.get_table(
+ "remote_frames").scan().select([
+ "index", "camera_a", "camera_b"
+ ]).read_blobs()
+ self.assertEqual(bodies, remote_bodies)
+
+ # Both cameras now share one physical MP4 across Episodes. The
+ # logical Episode boundary must still control normal-file rolling.
+ episodes[1].update({
+ "videos/camera_b/file_index": 0,
+ "videos/camera_b/from_timestamp": 0.2,
+ "videos/camera_b/to_timestamp": 0.5,
+ })
+ pq.write_table(pa.Table.from_pylist(episodes), episodes_path)
+ with patch(
+ "pypaimon.multimodal.lerobot.api."
+ "_import_lerobot_dataset",
+ return_value=object,
+ ), patch(
+ "pypaimon.multimodal.lerobot.api."
+ "_open_resolved_dataset",
+ return_value=Dataset(),
+ ), patch(
+ "pypaimon.multimodal.lerobot.loader."
+ "_video_sample_timestamps",
+ side_effect=sample_timestamps,
+ ):
+ connection.load_from_lerobot(
+ "shared_video_frames",
+ temp_dir,
+ batch_size=1,
+ options={"target-file-row-num": "1"},
+ )
+ raw_table = connection.get_table(
+ "shared_video_frames").raw_table
+ files = {
+ file.file_name: file
+ for split in
raw_table.new_read_builder().new_scan().plan().splits()
+ for file in split.files
+ }.values()
+ self.assertEqual(
+ [2, 3],
+ sorted(
+ file.row_count for file in files
+ if not file.file_name.endswith(".video")
+ and ".vector." not in file.file_name
+ ),
+ )
+ self.assertEqual(
+ [2, 3],
+ sorted(
+ file.row_count for file in files
+ if ".vector." in file.file_name
+ ),
+ )
+ finally:
+ shutil.rmtree(temp_dir, ignore_errors=True)
+
+ @unittest.skipUnless(av is not None, "PyAV is required for MP4 decoding")
+ def test_imported_video_payload_can_be_decoded(self):
+ import pandas as pd
+
+ temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_mp4_"))
+ try:
+ info = {
+ "codebase_version": "v3.0",
+ "fps": 10,
+ "total_frames": 5,
+ "total_episodes": 2,
+ "total_tasks": 1,
+ "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"
+ ),
+ "features": {
+ "index": {"dtype": "int64", "shape": [1]},
+ "episode_index": {"dtype": "int64", "shape": [1]},
+ "frame_index": {"dtype": "int64", "shape": [1]},
+ "timestamp": {
+ "dtype": "float32",
+ "shape": [1],
+ "fps": 10.0,
+ },
+ "task_index": {"dtype": "int64", "shape": [1]},
+ "camera": {
+ "dtype": "video",
+ "shape": [16, 16, 3],
+ "video_info": {"video.fps": 10.0},
+ },
+ },
+ }
+ episodes = [
+ {
+ "episode_index": 0,
+ "dataset_from_index": 0,
+ "dataset_to_index": 2,
+ "length": 2,
+ "data/chunk_index": 0,
+ "data/file_index": 0,
+ "tasks": ["pick"],
+ "videos/camera/chunk_index": 0,
+ "videos/camera/file_index": 0,
+ "videos/camera/from_timestamp": 0.5,
+ "videos/camera/to_timestamp": 0.7,
+ },
+ {
+ "episode_index": 1,
+ "dataset_from_index": 2,
+ "dataset_to_index": 5,
+ "length": 3,
+ "data/chunk_index": 0,
+ "data/file_index": 0,
+ "tasks": ["pick"],
+ "videos/camera/chunk_index": 0,
+ "videos/camera/file_index": 0,
+ "videos/camera/from_timestamp": 0.1,
+ "videos/camera/to_timestamp": 0.4,
+ },
+ ]
+ physical_frame_values = [24, 56, 88, 120, 168, 216]
+ expected_frame_values = [168, 216, 56, 88, 120]
+
+ info_dir = temp_dir / "meta"
+ info_dir.mkdir()
+ (info_dir / "info.json").write_text(json.dumps(info))
+ episodes_path = (
+ info_dir / "episodes/chunk-000/file-000.parquet")
+ episodes_path.parent.mkdir(parents=True)
+ pq.write_table(pa.Table.from_pylist(episodes), episodes_path)
+ pq.write_table(pa.Table.from_pandas(pd.DataFrame(
+ {"task_index": [0]},
+ index=pd.Index(["pick"], name="task"),
+ )), info_dir / "tasks.parquet")
+
+ video_path = (
+ temp_dir / "videos/camera/chunk-000/file-000.mp4")
+ video_path.parent.mkdir(parents=True)
+ with av.open(str(video_path), mode="w") as container:
+ stream = container.add_stream("mpeg4", rate=10)
+ stream.width = 16
+ stream.height = 16
+ stream.pix_fmt = "yuv420p"
+ stream.time_base = Fraction(1, 10)
+ for pts, value in zip(
+ [0, 1, 2, 3, 5, 6], physical_frame_values):
+ image = np.full((16, 16, 3), value, dtype=np.uint8)
+ frame = av.VideoFrame.from_ndarray(image, format="rgb24")
+ frame.pts = pts
+ frame.time_base = Fraction(1, 10)
+ for packet in stream.encode(frame):
+ container.mux(packet)
+ for packet in stream.encode():
+ container.mux(packet)
+
+ class Dataset:
+
+ root = temp_dir
+ meta = SimpleNamespace(
+ info=info, episodes=episodes, tasks=["pick"])
+ rows = pa.table({
+ "index": pa.array(range(5), type=pa.int64()),
+ "episode_index": pa.array(
+ [0, 0, 1, 1, 1], type=pa.int64()),
+ "frame_index": pa.array(
+ [0, 1, 0, 1, 2], type=pa.int64()),
+ "timestamp": pa.array(
+ [0.0, 0.1, 0.0, 0.1, 0.2],
+ type=pa.float32(),
+ ),
+ "task_index": pa.array([0] * 5, type=pa.int64()),
+ })
+
+ def __len__(self):
+ return 5
+
+ def read_batch(self, begin, end):
+ return self.rows.slice(begin, end - begin)
+
+ connection = pmm.connect(options={
+ "warehouse": str(temp_dir / "warehouse"),
+ })
+ with patch(
+ "pypaimon.multimodal.lerobot.api."
+ "_import_lerobot_dataset",
+ return_value=object,
+ ), patch(
+ "pypaimon.multimodal.lerobot.api."
+ "_open_resolved_dataset",
+ return_value=Dataset(),
+ ):
+ connection.load_from_lerobot(
+ "frames", temp_dir, batch_size=1)
+
+ table = connection.get_table("frames")
+ rows = table.scan().select([
+ "index", "camera"
+ ]).to_arrow().sort_by("index").to_pylist()
+ descriptors = [
+ pmm.VideoFrameDescriptor.deserialize(row["camera"])
+ for row in rows
+ ]
+ self.assertEqual(
+ [4, 5, 1, 2, 3],
+ [descriptor.frame_index for descriptor in descriptors],
+ )
+
+ class Decoder:
+
+ def __init__(self, source):
+ self.container = av.open(source)
+ self.frames = list(self.container.decode(video=0))
+
+ def value(self, frame_index):
+ frame = self.frames[frame_index]
+ return float(frame.to_ndarray(
+ format="rgb24").mean()), float(frame.time)
+
+ def close(self):
+ self.container.close()
+
+ collator = pmm.VideoFrameCollator(
+ table,
+ video_column="camera",
+ decoder_factory=Decoder,
+ decode_fn=lambda decoder, frame_index, row: decoder.value(
+ frame_index),
+ output_column="decoded",
+ collate_fn=lambda decoded_rows: decoded_rows,
+ )
+ try:
+ decoded_rows = collator(rows)
+ self.assertEqual(1, len(collator._decoders))
+ finally:
+ collator.close()
+ np.testing.assert_allclose(
+ [row["decoded"][0] for row in decoded_rows],
+ expected_frame_values,
+ atol=5,
+ )
+ np.testing.assert_allclose(
+ [row["decoded"][1] for row in decoded_rows],
+ [0.5, 0.6, 0.1, 0.2, 0.3],
+ atol=1e-6,
+ )
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
+ def test_video_ordinals_follow_source_timestamps(self):
+ info = {
+ "fps": 10,
+ "features": {
+ "episode_index": {"dtype": "int64", "shape": [1]},
+ "frame_index": {"dtype": "int64", "shape": [1]},
+ "timestamp": {"dtype": "float32", "shape": [1]},
+ "camera": {
+ "dtype": "video",
+ "shape": [8, 10, 3],
+ "video_info": {"video.fps": 10.0},
+ },
+ },
+ }
+ rows = pa.table({
+ "episode_index": pa.array([0, 0, 1, 1], type=pa.int64()),
+ "frame_index": pa.array([0, 1, 0, 1], type=pa.int64()),
+ "timestamp": pa.array(
+ [0.0, 0.1, 0.0, 0.1], type=pa.float32()),
+ })
+
+ class Dataset:
+
+ root = Path("/")
+
+ def __init__(self, rows):
+ self.rows = rows
+
+ def read_batch(self, begin, end):
+ return self.rows.slice(begin, end - begin)
+
+ def video_sample_timestamps(self, unused_uri):
+ return [0.0, 0.1, 0.2, 0.3, 0.5, 0.6]
+
+ schema = _schema_from_info(info)
+ episodes = [
+ {
+ "episode_index": 0,
+ "length": 2,
+ "dataset_from_index": 0,
+ "dataset_to_index": 2,
+ "videos/camera/chunk_index": 0,
+ "videos/camera/file_index": 0,
+ "videos/camera/from_timestamp": 0.5,
+ "videos/camera/to_timestamp": 0.7,
+ },
+ {
+ "episode_index": 1,
+ "length": 2,
+ "dataset_from_index": 2,
+ "dataset_to_index": 4,
+ "videos/camera/chunk_index": 0,
+ "videos/camera/file_index": 0,
+ "videos/camera/from_timestamp": 0.1,
+ "videos/camera/to_timestamp": 0.3,
+ },
+ ]
+ video_sources = {}
+ with patch(
+ "pypaimon.multimodal.lerobot.loader._video_source",
+ return_value=("file:/video.mp4", 10),
+ ):
+ results = [
+ _read_batch(
+ Dataset(rows), info, begin, begin + 2, schema,
+ episode=episode, video_sources=video_sources,
+ )
+ for begin, episode in zip((0, 2), episodes)
+ ]
+ self.assertEqual(
+ [4, 5, 1, 2],
+ [
+ pmm.VideoFrameDescriptor.deserialize(value.as_py()).frame_index
+ for result in results
+ for value in result["camera"]
+ ],
+ )
+
+ missing = dict(episodes[0])
+ missing.update({
+ "videos/camera/from_timestamp": 0.8,
+ "videos/camera/to_timestamp": 1.0,
+ })
+ with patch(
+ "pypaimon.multimodal.lerobot.loader._video_source",
+ return_value=("file:/video.mp4", 10),
+ ), self.assertRaisesRegex(ValueError, "has no frame"):
+ _read_batch(
+ Dataset(rows), info, 0, 2, schema,
+ episode=missing, video_sources={})
+
+ for timestamp_range in (
+ (-0.1, 0.1), (0.0, float("nan")), (0.0, 0.3)):
+ invalid_episode = dict(episodes[0])
+ invalid_episode.update({
+ "videos/camera/from_timestamp": timestamp_range[0],
+ "videos/camera/to_timestamp": timestamp_range[1],
+ })
+ with self.subTest(timestamp_range=timestamp_range), \
+ self.assertRaisesRegex(ValueError, "timestamp|duration"):
+ _read_batch(
+ Dataset(rows), info, 0, 2, schema,
+ episode=invalid_episode, video_sources={})
+
+ def test_video_sample_timestamps_skip_discard_packets(self):
+ class Container:
+
+ streams = SimpleNamespace(video=[SimpleNamespace(
+ time_base=Fraction(1, 10))])
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, unused_type, unused_value, unused_traceback):
+ pass
+
+ def demux(self, unused_stream):
+ return [
+ SimpleNamespace(
+ pts=-1, time_base=Fraction(1, 10),
+ is_discard=True),
+ SimpleNamespace(
+ pts=0, time_base=Fraction(1, 10),
+ is_discard=False),
+ SimpleNamespace(
+ pts=1, time_base=Fraction(1, 10),
+ is_discard=False),
+ ]
+
+ fake_av = SimpleNamespace(open=lambda unused_source: Container())
+ with patch.dict(sys.modules, {"av": fake_av}):
+ timestamps = _video_sample_timestamps(
+ SimpleNamespace(), "file:/video.mp4")
+ self.assertEqual([0.0, 0.1], list(timestamps))
+
+ def test_video_frame_matching_quantizes_float32_timestamp(self):
+ frame_index = 61441
+ timestamps = array(
+ "d", (index / 30 for index in range(frame_index + 1)))
+ frame_timestamp = pa.scalar(
+ frame_index / 30, type=pa.float32()).as_py()
+
+ self.assertEqual(
+ frame_index,
+ _video_frame_ordinal(
+ timestamps, frame_timestamp, 0.0, pa.float32(), "camera"),
+ )
+
class _RemoteLeRobotFileIO:
@@ -1384,7 +2091,9 @@ class _RemoteLeRobotFileIO:
native_path = remote_path.split("://", 1)[1]
file_type = pafs.FileType.Directory if local_path.is_dir() \
else pafs.FileType.File
- return pafs.FileInfo(native_path, file_type)
+ size = local_path.stat().st_size \
+ if file_type == pafs.FileType.File else None
+ return pafs.FileInfo(native_path, file_type, size=size)
def get_file_status(self, remote_path):
local_path = self._local_path(remote_path)
@@ -2387,7 +3096,8 @@ class LeRobotImportTest(unittest.TestCase):
source,
source_schema,
batch_size,
- metadata):
+ metadata,
+ video_fields=()):
table.add(_read_batch(
dataset,
info,
@@ -2403,6 +3113,7 @@ class LeRobotImportTest(unittest.TestCase):
source_schema,
batch_size,
metadata,
+ video_fields,
)
with patch.object(
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_writer_test.py
b/paimon-python/pypaimon/tests/multimodal_lerobot_writer_test.py
index 26fb37a248..b796e04061 100644
--- a/paimon-python/pypaimon/tests/multimodal_lerobot_writer_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_writer_test.py
@@ -437,6 +437,22 @@ class PaimonLeRobotWriterTest(unittest.TestCase):
},
)
+ def test_video_features_are_rejected(self):
+ with self.assertRaisesRegex(
+ ValueError, "does not support video features: camera"):
+ PaimonLeRobotWriter(
+ self.connection,
+ "video",
+ fps=10,
+ features={
+ "camera": {
+ "dtype": "video",
+ "shape": (3, 4, 5),
+ "names": ["channels", "height", "width"],
+ },
+ },
+ )
+
def
test_missing_lerobot_stats_dependency_fails_before_table_creation(self):
self.connection.catalog.create_database(
"default", ignore_if_exists=True)
diff --git a/paimon-python/pypaimon/write/file_store_write.py
b/paimon-python/pypaimon/write/file_store_write.py
index df3022b37d..a73b9df77d 100644
--- a/paimon-python/pypaimon/write/file_store_write.py
+++ b/paimon-python/pypaimon/write/file_store_write.py
@@ -48,6 +48,7 @@ class FileStoreWrite:
self.max_seq_numbers: dict = {}
self.write_cols = None
self.blob_consumer = None
+ self.blob_uri_reader_factory = None
self.commit_identifier = 0
self.options = CoreOptions.copy(table.options)
self.changelog_producer = self.options.changelog_producer()
@@ -110,6 +111,11 @@ class FileStoreWrite:
)
writer.write(data.to_batches()[0])
+ def roll_before_group_if_needed(self, row_count: int):
+ for writer in self.data_writers.values():
+ if isinstance(writer, DedicatedFormatWriter):
+ writer.roll_before_group_if_needed(row_count)
+
def _check_runtime_bucket(self, partition, bucket, total_buckets):
if total_buckets is None:
return
@@ -168,6 +174,7 @@ class FileStoreWrite:
write_cols=self.write_cols,
blob_consumer=self.blob_consumer,
changelog_producer=self.changelog_producer,
+ blob_uri_reader_factory=self.blob_uri_reader_factory,
)
elif self._has_vector_columns() and options.with_vector_format():
return DataVectorWriter(
diff --git a/paimon-python/pypaimon/write/table_write.py
b/paimon-python/pypaimon/write/table_write.py
index f0a68bcf6f..fd09282c1d 100644
--- a/paimon-python/pypaimon/write/table_write.py
+++ b/paimon-python/pypaimon/write/table_write.py
@@ -85,6 +85,13 @@ class TableWrite:
sub_table = pa.compute.take(data, row_indices)
self._write_partition_bucket_batch(partition, bucket, sub_table)
+ def roll_before_group_if_needed(self, row_count: int):
+ """Roll current files before the next logical write group if needed."""
+ if isinstance(row_count, bool) or not isinstance(row_count, int) \
+ or row_count <= 0:
+ raise ValueError("Group row count must be a positive integer.")
+ self.file_store_write.roll_before_group_if_needed(row_count)
+
def _write_partition_bucket_batch(self, partition, bucket, data):
self.file_store_write.write(partition, bucket, data)
@@ -226,6 +233,15 @@ class TableWrite:
self.file_store_write.blob_consumer = blob_consumer
return self
+ def with_blob_uri_reader_factory(self, uri_reader_factory):
+ if self.file_store_write.data_writers:
+ raise RuntimeError(
+ "with_blob_uri_reader_factory must be called before any "
+ "write operation."
+ )
+ self.file_store_write.blob_uri_reader_factory = uri_reader_factory
+ return self
+
def write_ray(
self,
dataset: "Dataset",
diff --git a/paimon-python/pypaimon/write/writer/blob_file_writer.py
b/paimon-python/pypaimon/write/writer/blob_file_writer.py
index 887d8cd792..77605ac16c 100644
--- a/paimon-python/pypaimon/write/writer/blob_file_writer.py
+++ b/paimon-python/pypaimon/write/writer/blob_file_writer.py
@@ -45,9 +45,10 @@ class BlobFileWriter:
def __init__(self, file_io, file_path: Path, blob_consumer:
Optional[BlobConsumer] = None,
copy_buffer_size: int = BlobFormatWriter.BUFFER_SIZE,
- video: bool = False):
+ video: bool = False, uri_reader_factory=None):
self.file_io = file_io
self.file_path = file_path
+ self._uri_reader_factory = uri_reader_factory
self._blob_consumer = blob_consumer
if video:
if blob_consumer is not None:
@@ -122,7 +123,8 @@ class BlobFileWriter:
if isinstance(col_data, bytes):
if BlobDescriptorSerde.is_descriptor(col_data):
descriptor = BlobDescriptorSerde.deserialize(col_data)
- uri_reader =
self.file_io.uri_reader_factory.create(descriptor.uri)
+ factory = self._uri_reader_factory or
self.file_io.uri_reader_factory
+ uri_reader = factory.create(descriptor.uri)
return Blob.from_descriptor(uri_reader, descriptor)
return BlobData(col_data)
diff --git a/paimon-python/pypaimon/write/writer/blob_writer.py
b/paimon-python/pypaimon/write/writer/blob_writer.py
index c32205b2c9..166144e5c6 100644
--- a/paimon-python/pypaimon/write/writer/blob_writer.py
+++ b/paimon-python/pypaimon/write/writer/blob_writer.py
@@ -37,7 +37,7 @@ class BlobWriter(AppendOnlyDataWriter):
def __init__(self, table, partition: Tuple, bucket: int, max_seq_number:
int, blob_column: str,
options: Dict[str, str] = None, blob_consumer:
Optional[BlobConsumer] = None,
- video: bool = False):
+ video: bool = False, uri_reader_factory=None):
super().__init__(table, partition, bucket, max_seq_number,
options, write_cols=[blob_column])
@@ -61,6 +61,7 @@ class BlobWriter(AppendOnlyDataWriter):
self.blob_copy_buffer_size = self.options.blob_copy_buffer_size()
self._blob_consumer = blob_consumer
+ self._uri_reader_factory = uri_reader_factory
self.current_writer: Optional[BlobFileWriter] = None
self.current_file_path: Optional[str] = None
self.record_count = 0
@@ -140,6 +141,7 @@ class BlobWriter(AppendOnlyDataWriter):
blob_consumer=self._blob_consumer,
copy_buffer_size=self.blob_copy_buffer_size,
video=self.video,
+ uri_reader_factory=self._uri_reader_factory,
)
def rolling_file(self) -> bool:
@@ -151,6 +153,17 @@ class BlobWriter(AppendOnlyDataWriter):
or
self.current_writer.reach_target_size(self.blob_target_file_size)
)
+ def should_roll_before_group(self, row_count: int) -> bool:
+ return (
+ self._video_group_policy is not None
+ and self.current_writer is not None
+ and (
+ self._video_group_policy.pending_roll
+ or self.current_writer.row_count + row_count
+ > self.target_file_row_num
+ )
+ )
+
def close_current_writer(self):
"""Close current writer and create metadata."""
if self.current_writer is None:
diff --git a/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
b/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
index 3df6db795d..c894182fbe 100644
--- a/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
+++ b/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
@@ -68,7 +68,8 @@ class DedicatedFormatWriter(DataWriter):
def __init__(self, table, partition: Tuple, bucket: int, max_seq_number:
int, options: CoreOptions = None,
write_cols: Optional[List[str]] = None, blob_consumer:
Optional[BlobConsumer] = None,
- changelog_producer: ChangelogProducer =
ChangelogProducer.NONE):
+ changelog_producer: ChangelogProducer =
ChangelogProducer.NONE,
+ blob_uri_reader_factory=None):
super().__init__(table, partition, bucket, max_seq_number, options,
write_cols=write_cols,
changelog_producer=changelog_producer)
@@ -181,6 +182,7 @@ class DedicatedFormatWriter(DataWriter):
options=options,
blob_consumer=blob_consumer,
video=blob_column in configured_video_fields,
+ uri_reader_factory=blob_uri_reader_factory,
)
# Initialize vector writer when vector.file.format is configured.
@@ -195,6 +197,7 @@ class DedicatedFormatWriter(DataWriter):
vector_columns=self.vector_write_columns,
vector_file_format=options.vector_file_format(),
options=options,
+ rolling_managed_by_parent=bool(self.video_frame_columns),
)
logger.info(
@@ -270,8 +273,8 @@ class DedicatedFormatWriter(DataWriter):
self.record_count += data.num_rows
- # Check if normal data rolling is needed
- if self._should_roll_normal():
+ # Defer any active video-group roll to its Episode boundary.
+ if self._should_roll_active_group():
self._roll_or_defer_for_video_group()
def write_row(self, row):
@@ -325,7 +328,7 @@ class DedicatedFormatWriter(DataWriter):
self.vector_writer.write(vector_data)
self.record_count += 1
- if self._should_roll_normal():
+ if self._should_roll_active_group():
self._roll_or_defer_for_video_group()
except Exception as e:
@@ -520,6 +523,39 @@ class DedicatedFormatWriter(DataWriter):
# Check if normal data exceeds target size
return self._normal_buffer.nbytes > self.target_file_size
+ def roll_before_group_if_needed(self, row_count: int):
+ """Roll current files before the next logical write group if needed."""
+ self._require_finished_flush()
+ if self._video_group_policy is None:
+ return
+
+ pending_rows = self.pending_row_count
+ should_roll = pending_rows > 0 and (
+ self._video_group_policy.pending_roll
+ or pending_rows + row_count > self.target_file_row_num
+ or (
+ not self._normal_buffer.is_empty
+ and self._normal_buffer.nbytes > self.target_file_size
+ )
+ )
+ should_roll = should_roll or any(
+ self.blob_writers[column].should_roll_before_group(row_count)
+ for column in self.video_frame_columns
+ )
+ should_roll = should_roll or (
+ self.vector_writer is not None
+ and self.vector_writer.should_roll_before_group(row_count)
+ )
+ if should_roll:
+ self._close_current_writers()
+
+ def _should_roll_active_group(self) -> bool:
+ return self._should_roll_normal() or (
+ self._video_group_policy is not None
+ and self.vector_writer is not None
+ and self.vector_writer.rolling_file()
+ )
+
def _roll_or_defer_for_video_group(self):
if self._video_group_policy is not None and
self._video_group_policy.defer_roll():
return
diff --git a/paimon-python/pypaimon/write/writer/vector_writer.py
b/paimon-python/pypaimon/write/writer/vector_writer.py
index b51e2e49ae..a7cd6bfc58 100644
--- a/paimon-python/pypaimon/write/writer/vector_writer.py
+++ b/paimon-python/pypaimon/write/writer/vector_writer.py
@@ -36,16 +36,41 @@ class VectorWriter(AppendOnlyDataWriter):
"""
def __init__(self, table, partition: Tuple, bucket: int, max_seq_number:
int,
- vector_columns: List[str], vector_file_format: str, options:
CoreOptions = None):
+ vector_columns: List[str], vector_file_format: str,
+ options: CoreOptions = None,
+ rolling_managed_by_parent: bool = False):
super().__init__(table, partition, bucket, max_seq_number,
options, write_cols=vector_columns)
self.vector_columns = vector_columns
self.vector_file_format = vector_file_format
self.file_format = vector_file_format
self.target_file_size = options.vector_target_file_size()
+ # The parent coordinates rolling at logical group boundaries.
+ self.rolling_managed_by_parent = rolling_managed_by_parent
self.file_uuid = str(uuid.uuid4())
self.file_count = 0
+ def _check_and_roll_if_needed(self):
+ if not self.rolling_managed_by_parent:
+ super()._check_and_roll_if_needed()
+
+ def rolling_file(self) -> bool:
+ return (
+ self._buffer.num_rows >= self.target_file_row_num
+ or self._buffer.nbytes > self.target_file_size
+ )
+
+ def should_roll_before_group(self, row_count: int) -> bool:
+ return (
+ self.rolling_managed_by_parent
+ and self._buffer.num_rows > 0
+ and (
+ self.rolling_file()
+ or self._buffer.num_rows + row_count
+ > self.target_file_row_num
+ )
+ )
+
def _write_data_to_file(self, data: pa.Table):
if data.num_rows == 0:
return