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 be77b25d40 [python] Read LeRobot video datasets for training (#9757)
be77b25d40 is described below

commit be77b25d4034ad21bb415e2b9a48d55bf4646fae
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Sep 13 16:28:48 2026 +0800

    [python] Read LeRobot video datasets for training (#9757)
---
 docs/docs/pypaimon/lerobot.md                      |  19 +-
 .../pypaimon/multimodal/lerobot/dataset.py         | 293 +++++++++++++++++++--
 .../pypaimon/tests/multimodal_lerobot_test.py      | 281 ++++++++++++++++++++
 3 files changed, 564 insertions(+), 29 deletions(-)

diff --git a/docs/docs/pypaimon/lerobot.md b/docs/docs/pypaimon/lerobot.md
index 722d63f3f2..b1e487fff1 100644
--- a/docs/docs/pypaimon/lerobot.md
+++ b/docs/docs/pypaimon/lerobot.md
@@ -109,9 +109,8 @@ Scalars map to scalar types, vectors to `VECTOR`, 
higher-rank tensors to nested
 
 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.
+rolling before each Episode. They require a bucket-unaware table. Use
+`VideoFrameCollator` for scans or `PaimonLeRobotDataset` for training.
 
 ## Capture LeRobot frames directly into Paimon
 
@@ -208,10 +207,9 @@ pin one named snapshot on every component.
 
 ## Train with Paimon LeRobot data
 
-For map-style training, read a tagged table group created by
-`load_from_lerobot` directly from Paimon. `PaimonLeRobotDataset` requires the
-complete table group; a frame-only table created by `PaimonLeRobotWriter` is
-not sufficient.
+For map-style training, pass an image- or video-backed table group created by
+`load_from_lerobot` to `PaimonLeRobotDataset`. A frame-only table created by
+`PaimonLeRobotWriter` is not sufficient.
 
 ```python
 from torch.utils.data import DataLoader
@@ -224,6 +222,7 @@ dataset = PaimonLeRobotDataset(
 loader = DataLoader(dataset, batch_size=32, shuffle=True, num_workers=4)
 ```
 
-If `tag_name` is omitted, the latest snapshots are used. Metadata is available
-through `dataset.meta`. Frame lookups use the BTree on `index`; payload columns
-remain lazy.
+Without `tag_name`, the latest snapshots are used. Frame lookups use the BTree
+on `index`; payloads remain lazy. Video decoding prefers TorchCodec, falls back
+to PyAV, and reuses a bounded decoder cache. Set `video_backend` to force
+either decoder.
diff --git a/paimon-python/pypaimon/multimodal/lerobot/dataset.py 
b/paimon-python/pypaimon/multimodal/lerobot/dataset.py
index 49e26097e0..7a13731e5f 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/dataset.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/dataset.py
@@ -24,6 +24,8 @@ import math
 import operator
 import os
 import sys
+from collections import OrderedDict
+from functools import partial
 
 import pyarrow as pa
 
@@ -42,6 +44,7 @@ from pypaimon.multimodal.lerobot.schema import (
     _validate_lerobot_schema,
 )
 from pypaimon.multimodal.table import _target_schema, _time_travel_table
+from pypaimon.multimodal.video import VideoFrameCollator
 from pypaimon.read.query_auth_split import QueryAuthSplit
 
 
@@ -78,7 +81,7 @@ class PaimonLeRobotDataset:
     LeRobot metadata is resolved from the Paimon table group and remains
     available through :attr:`meta`.
 
-    Set ``return_uint8=True`` to keep 8-bit images in their decoded
+    Set ``return_uint8=True`` to keep 8-bit visual frames in their decoded
     ``torch.uint8`` representation instead of normalizing them to float32.
     Higher-bit-depth images retain the existing float32 behavior.
     """
@@ -93,6 +96,7 @@ class PaimonLeRobotDataset:
             delta_timestamps=None,
             tolerance_s=1e-4,
             blob_parallelism=16,
+            video_backend=None,
             return_uint8=False):
         if sys.version_info < (3, 10):
             raise RuntimeError(
@@ -109,6 +113,10 @@ class PaimonLeRobotDataset:
             raise ValueError("tolerance_s must be finite and non-negative.")
         self.blob_parallelism = _positive_int(
             blob_parallelism, "blob_parallelism")
+        if video_backend not in (None, "torchcodec", "pyav"):
+            raise ValueError(
+                "video_backend must be None, 'torchcodec', or 'pyav'.")
+        self.video_backend = video_backend
         if not isinstance(return_uint8, bool):
             raise TypeError("return_uint8 must be a boolean.")
         self.return_uint8 = return_uint8
@@ -130,15 +138,11 @@ class PaimonLeRobotDataset:
             name for name, feature in self._features.items()
             if feature.get("dtype") == "image"
         ]
-        video_keys = [
+        self._video_keys = [
             name for name, feature in self._features.items()
             if feature.get("dtype") == "video"
         ]
-        if video_keys:
-            raise NotImplementedError(
-                "PaimonLeRobotDataset currently supports image-backed "
-                "features only; video features are not yet supported: %s"
-                % video_keys)
+        self._visual_keys = self._image_keys + self._video_keys
 
         self._total_frames = int(
             _metadata_member(
@@ -230,6 +234,18 @@ class PaimonLeRobotDataset:
             self._read_table, snapshot, splits)
         self._validation_context = validation_context
         self._file_io = self._read_table.file_io
+        self._video_collators = [
+            VideoFrameCollator(
+                self._read_table,
+                video_column=key,
+                decoder_factory=partial(
+                    _open_video_decoder, backend=self.video_backend),
+                decode_fn=_decode_video_frame,
+                output_column=key,
+                collate_fn=_identity,
+            )
+            for key in self._video_keys
+        ]
         self._task_names = validation_context["task_names"]
         self._subtask_names = validation_context["subtask_names"]
         self._delta_projection = None
@@ -319,21 +335,30 @@ class PaimonLeRobotDataset:
                     self._image_keys,
                     self.blob_parallelism,
                 )
-                converted = {
-                    position: _torch_row(
-                        row, self._features, self.return_uint8)
-                    for position, row in base_rows.items()
-                }
-                converted.update({
-                    position: _torch_row(
-                        row, self._features, self.return_uint8)
-                    for position, row in delta_rows.items()
-                })
+                _decode_image_rows(
+                    row_groups,
+                    self._image_keys,
+                    self._features,
+                    self.return_uint8,
+                )
                 break
             except OSError:
                 if attempt + 1 == _IMAGE_READ_ATTEMPTS:
                     raise
 
+        _decode_video_rows(
+            row_groups, getattr(self, "_video_collators", ()))
+        converted = {
+            position: _torch_row(
+                row, self._features, self.return_uint8)
+            for position, row in base_rows.items()
+        }
+        converted.update({
+            position: _torch_row(
+                row, self._features, self.return_uint8)
+            for position, row in delta_rows.items()
+        })
+
         import torch
         duplicates = _duplicate_indices(plans)
         result = []
@@ -350,11 +375,34 @@ class PaimonLeRobotDataset:
                 ])
             item.update(plan["padding"])
             if self.image_transforms is not None:
-                for key in self._image_keys:
+                for key in self._visual_keys:
                     item[key] = self.image_transforms(item[key])
             result.append(item)
         return result
 
+    def close(self):
+        first_error = None
+        locator = getattr(self, "_frame_locator", None)
+        if locator is not None:
+            try:
+                locator.close()
+            except Exception as error:
+                first_error = error
+        for collator in getattr(self, "_video_collators", ()):
+            try:
+                collator.close()
+            except Exception as error:
+                if first_error is None:
+                    first_error = error
+        if first_error is not None:
+            raise first_error
+
+    def __del__(self):
+        try:
+            self.close()
+        except Exception:
+            pass
+
     def _read_rows(
             self, indices, projection, splits=None, needs_filter=True):
         if not indices:
@@ -1086,6 +1134,15 @@ def _resolve_image_blobs(
             row[key] = body
 
 
+def _decode_image_rows(row_groups, image_keys, features, return_uint8):
+    for rows in row_groups:
+        for row in rows.values():
+            for key in image_keys:
+                if key in row:
+                    row[key] = _image_tensor(
+                        row[key], features[key], return_uint8=return_uint8)
+
+
 def _image_blob_sources(row_groups, image_keys):
     return [
         (row, key, row[key])
@@ -1118,9 +1175,12 @@ def _torch_row(row, features, return_uint8=False):
         if key not in result:
             continue
         value = result[key]
-        if feature.get("dtype") == "image":
+        if feature.get("dtype") == "image" and not torch.is_tensor(value):
             result[key] = _image_tensor(
                 value, feature, return_uint8=return_uint8)
+        elif feature.get("dtype") == "video":
+            result[key] = _video_tensor(
+                value, feature, return_uint8=return_uint8)
         elif feature.get("dtype") != "string" and not torch.is_tensor(value):
             dtype = getattr(torch, _TORCH_DTYPE_NAMES[feature.get("dtype")])
             result[key] = torch.tensor(value, dtype=dtype)
@@ -1164,6 +1224,201 @@ def _image_tensor(payload, feature, return_uint8=False):
     return tensor.div_(255) if normalize else tensor
 
 
+def _video_tensor(frame, feature, return_uint8=False):
+    import torch
+
+    if not torch.is_tensor(frame):
+        raise ValueError("LeRobot video decoder must return a Torch tensor.")
+    expected_shape = _feature_shape(feature, "video")
+    if len(expected_shape) != 3:
+        raise ValueError("LeRobot video feature must have three dimensions.")
+    names = feature.get("names") or []
+    output_shape = expected_shape if names and names[0] in (
+        "channel", "channels"
+    ) else expected_shape[2:] + expected_shape[:2]
+    if tuple(frame.shape) != output_shape:
+        raise ValueError(
+            "LeRobot video frame has shape %s, expected %s."
+            % (tuple(frame.shape), output_shape)
+        )
+    if frame.dtype == torch.uint8 and not return_uint8:
+        return frame.float().div_(255)
+    return frame
+
+
+def _open_video_decoder(stream, backend=None):
+    if backend in (None, "torchcodec"):
+        try:
+            return _open_torchcodec_decoder(stream)
+        except (ImportError, OSError, RuntimeError):
+            if backend == "torchcodec":
+                raise
+            stream.seek(0)
+    return _PyAVVideoDecoder(stream)
+
+
+def _open_torchcodec_decoder(stream):
+    try:
+        from torchcodec.decoders import VideoDecoder
+    except (ImportError, RuntimeError) as error:
+        raise ImportError(
+            "Video-backed PaimonLeRobotDataset requires TorchCodec from "
+            "'pypaimon[lerobot]'."
+        ) from error
+    try:
+        return VideoDecoder(stream, seek_mode="exact")
+    except TypeError:
+        # TorchCodec 0.2 accepts bytes but not seekable file-like objects.
+        stream.seek(0)
+        return VideoDecoder(stream.read(), seek_mode="exact")
+
+
+class _PyAVVideoDecoder:
+
+    # Reuse common overlapping delta windows without retaining a whole video.
+    _FRAME_CACHE_SIZE = 8
+
+    def __init__(self, stream):
+        try:
+            import av
+        except ImportError as error:
+            raise ImportError(
+                "Video-backed PaimonLeRobotDataset requires PyAV from "
+                "'pypaimon[lerobot]'."
+            ) from error
+        self._container = av.open(stream)
+        self._stream = self._container.streams.video[0]
+        self._next_index = 0
+        self._timestamps = []
+        self._keyframes = []
+        self._cache = OrderedDict()
+        self._frames = iter(self._container.decode(self._stream))
+
+    def __getitem__(self, index):
+        index = operator.index(index)
+        if index < 0:
+            raise IndexError("Video frame index %d is out of range." % index)
+        frame = self._cache.pop(index, None)
+        if frame is not None:
+            self._cache[index] = frame
+            return self._tensor(frame)
+
+        indexed = (
+            index > 0 and not self._timestamps
+            and self._index_packets()
+        )
+        at_frontier = self._next_index == len(self._timestamps)
+        if indexed:
+            self._seek(index)
+        elif index != self._next_index and not (
+                at_frontier and index >= self._next_index):
+            self._seek(index)
+        try:
+            while True:
+                frame = next(self._frames)
+                if frame.pts is None:
+                    continue
+                timestamp = frame.pts * (
+                    frame.time_base or self._stream.time_base)
+                position = bisect.bisect_left(self._timestamps, timestamp)
+                if (
+                    position < len(self._timestamps)
+                    and self._timestamps[position] == timestamp
+                ):
+                    frame_index = position
+                elif self._next_index == len(self._timestamps):
+                    frame_index = self._next_index
+                    self._timestamps.append(timestamp)
+                    if frame.key_frame:
+                        self._keyframes.append(frame_index)
+                else:
+                    continue
+                self._next_index = frame_index + 1
+                self._remember(frame_index, frame)
+                if frame_index == index:
+                    return self._tensor(frame)
+                if frame_index > index:
+                    break
+        except StopIteration as error:
+            raise IndexError(
+                "Video frame index %d is out of range." % index
+            ) from error
+        raise IndexError("Video frame index %d is out of range." % index)
+
+    def _index_packets(self):
+        entries = []
+        for packet in self._container.demux(self._stream):
+            if (packet.pts is None
+                    or getattr(packet, "is_discard", False)):
+                continue
+            timestamp = packet.pts * (
+                packet.time_base or self._stream.time_base)
+            entries.append((timestamp, packet.is_keyframe))
+        if not entries:
+            self._container.seek(
+                0, backward=True, any_frame=False, stream=self._stream)
+            self._frames = iter(self._container.decode(self._stream))
+            return False
+        entries.sort(key=lambda entry: entry[0])
+        self._timestamps = [timestamp for timestamp, unused in entries]
+        self._keyframes = [
+            index for index, (unused, keyframe) in enumerate(entries)
+            if keyframe
+        ]
+        return True
+
+    def _seek(self, index):
+        position = bisect.bisect_right(self._keyframes, index)
+        anchor = self._keyframes[position - 1] if position else 0
+        timestamp = self._timestamps[anchor]
+        self._container.seek(
+            round(timestamp / self._stream.time_base),
+            backward=True,
+            any_frame=False,
+            stream=self._stream,
+        )
+        self._next_index = None
+        self._frames = iter(self._container.decode(self._stream))
+
+    def _remember(self, index, frame):
+        self._cache.pop(index, None)
+        self._cache[index] = frame
+        if len(self._cache) > self._FRAME_CACHE_SIZE:
+            self._cache.popitem(last=False)
+
+    @staticmethod
+    def _tensor(frame):
+        import numpy as np
+        import torch
+        array = np.array(frame.to_ndarray(format="rgb24"), copy=True)
+        return torch.from_numpy(array).permute(2, 0, 1)
+
+    def close(self):
+        self._container.close()
+
+
+def _decode_video_frame(decoder, frame_index, unused_row):
+    return decoder[frame_index]
+
+
+def _identity(values):
+    return values
+
+
+def _decode_video_rows(row_groups, collators):
+    for collator in collators:
+        for rows in row_groups:
+            indices = [
+                index for index, row in rows.items()
+                if collator.video_column in row
+            ]
+            if not indices:
+                continue
+            decoded = collator([rows[index] for index in indices])
+            for index, row in zip(indices, decoded):
+                rows[index] = row
+
+
 def _normalize_index(index, size):
     index = operator.index(index)
     if index < 0:
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py 
b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
index 82e2b7631c..97b180d39e 100644
--- a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
@@ -17,6 +17,7 @@
 import builtins
 from array import array
 from fractions import Fraction
+import importlib.util
 import io
 import json
 import pickle
@@ -44,9 +45,11 @@ from pypaimon.multimodal.source_utils import _SourceFileIO
 from pypaimon.multimodal.connection import MultimodalConnection
 from pypaimon.multimodal.lerobot import load_from_lerobot
 from pypaimon.multimodal.lerobot.dataset import (
+    _PyAVVideoDecoder,
     _arrow_rows,
     _image_tensor,
     _index_names,
+    _open_video_decoder,
     _selected_episodes,
     _torch_row,
 )
@@ -126,6 +129,193 @@ def _catalog_metadata(connection, name):
 
 class LeRobotValidationTest(unittest.TestCase):
 
+    @unittest.skipUnless(
+        av is not None and importlib.util.find_spec("torch") is not None,
+        "PyAV and Torch are required for video decoding",
+    )
+    def test_pyav_decoder_reuses_windows_and_seeks_known_frames(self):
+        class Frame:
+
+            time_base = Fraction(1, 10)
+
+            def __init__(self, pts):
+                self.pts = pts
+                self.key_frame = pts % 10 == 0
+
+            def to_ndarray(self, format):
+                assert format == "rgb24"
+                return np.full((2, 2, 3), self.pts, dtype=np.uint8)
+
+        class Container:
+
+            def __init__(self):
+                self.stream = SimpleNamespace(time_base=Fraction(1, 10))
+                self.streams = SimpleNamespace(video=[self.stream])
+                self.position = 0
+                self.decoded = 0
+                self.seeks = []
+
+            def decode(self, stream):
+                assert stream is self.stream
+                while self.position < 120:
+                    index = self.position
+                    self.position += 1
+                    self.decoded += 1
+                    yield Frame(index)
+
+            def seek(self, offset, *, backward, any_frame, stream):
+                assert backward
+                assert not any_frame
+                assert stream is self.stream
+                self.seeks.append(offset)
+                self.position = offset
+
+            def close(self):
+                pass
+
+        container = Container()
+        with patch("av.open", return_value=container):
+            decoder = _PyAVVideoDecoder(io.BytesIO())
+        try:
+            for index in range(119):
+                decoder[index]
+                decoder[index + 1]
+            self.assertEqual(120, container.decoded)
+            self.assertEqual([], container.seeks)
+
+            decoder[5]
+            decoder[90]
+            decoder[8]
+            self.assertEqual(136, container.decoded)
+            self.assertEqual([0, 90, 0], container.seeks)
+        finally:
+            decoder.close()
+
+    def test_pyav_decoder_indexes_cold_random_reads(self):
+        class Frame:
+
+            time_base = Fraction(1, 10)
+
+            def __init__(self, pts):
+                self.pts = pts
+                self.key_frame = pts % 10 == 0
+
+            def to_ndarray(self, format):
+                assert format == "rgb24"
+                return np.full((2, 2, 3), self.pts, dtype=np.uint8)
+
+        class Container:
+
+            def __init__(self):
+                self.stream = SimpleNamespace(time_base=Fraction(1, 10))
+                self.streams = SimpleNamespace(video=[self.stream])
+                self.position = 0
+                self.decoded = 0
+                self.demuxed = 0
+                self.seeks = []
+
+            def demux(self, stream):
+                assert stream is self.stream
+                for index in range(120):
+                    self.demuxed += 1
+                    yield SimpleNamespace(
+                        pts=index, time_base=Fraction(1, 10),
+                        is_discard=False, is_keyframe=index % 10 == 0,
+                    )
+
+            def decode(self, stream):
+                assert stream is self.stream
+                while self.position < 120:
+                    index = self.position
+                    self.position += 1
+                    self.decoded += 1
+                    yield Frame(index)
+
+            def seek(self, offset, *, backward, any_frame, stream):
+                assert backward
+                assert not any_frame
+                assert stream is self.stream
+                self.seeks.append(offset)
+                self.position = offset
+
+            def close(self):
+                pass
+
+        container = Container()
+        fake_av = SimpleNamespace(open=lambda unused_stream: container)
+        tensor = staticmethod(
+            lambda frame: frame.to_ndarray(format="rgb24"))
+        with patch.dict(sys.modules, {"av": fake_av}), patch.object(
+                _PyAVVideoDecoder, "_tensor", tensor):
+            decoder = _PyAVVideoDecoder(io.BytesIO())
+            try:
+                frame = decoder[95]
+                self.assertEqual(120, container.demuxed)
+                self.assertEqual([90], container.seeks)
+                self.assertEqual(6, container.decoded)
+                self.assertTrue((frame == 95).all())
+            finally:
+                decoder.close()
+
+    @unittest.skipUnless(
+        av is not None and importlib.util.find_spec("torch") is not None,
+        "PyAV and Torch are required for video decoding",
+    )
+    def test_pyav_decoder_seeks_before_b_frames(self):
+        output = io.BytesIO()
+        with av.open(output, mode="w", format="mp4") as container:
+            stream = container.add_stream("mpeg4", rate=30)
+            stream.width = 16
+            stream.height = 16
+            stream.pix_fmt = "yuv420p"
+            stream.gop_size = 12
+            stream.codec_context.max_b_frames = 2
+            for index in range(70):
+                image = np.full(
+                    (16, 16, 3), index + 24, dtype=np.uint8)
+                frame = av.VideoFrame.from_ndarray(image, format="rgb24")
+                frame.pts = index
+                frame.time_base = Fraction(1, 30)
+                for packet in stream.encode(frame):
+                    container.mux(packet)
+            for packet in stream.encode():
+                container.mux(packet)
+
+        payload = output.getvalue()
+        with av.open(io.BytesIO(payload)) as container:
+            expected = [
+                np.array(frame.to_ndarray(format="rgb24"), copy=True)
+                for frame in container.decode(video=0)
+            ]
+
+        decoder = _PyAVVideoDecoder(io.BytesIO(payload))
+        try:
+            for index in (69, 20, 35, 1, 68):
+                actual = decoder[index].permute(1, 2, 0).numpy()
+                np.testing.assert_array_equal(expected[index], actual)
+        finally:
+            decoder.close()
+
+    def test_default_video_backend_falls_back_on_os_error(self):
+        stream = Mock()
+        decoder = object()
+        with patch(
+                "pypaimon.multimodal.lerobot.dataset."
+                "_open_torchcodec_decoder",
+                side_effect=OSError("unavailable")), patch(
+                "pypaimon.multimodal.lerobot.dataset._PyAVVideoDecoder",
+                return_value=decoder) as pyav:
+            self.assertIs(decoder, _open_video_decoder(stream))
+            stream.seek.assert_called_once_with(0)
+            pyav.assert_called_once_with(stream)
+
+        with patch(
+                "pypaimon.multimodal.lerobot.dataset."
+                "_open_torchcodec_decoder",
+                side_effect=OSError("unavailable")):
+            with self.assertRaises(OSError):
+                _open_video_decoder(stream, backend="torchcodec")
+
     def test_dataset_requires_supported_python(self):
         with patch(
                 "pypaimon.multimodal.lerobot.dataset.sys.version_info",
@@ -1727,6 +1917,19 @@ class LeRobotValidationTest(unittest.TestCase):
 
     @unittest.skipUnless(av is not None, "PyAV is required for MP4 decoding")
     def test_imported_video_payload_can_be_decoded(self):
+        self._assert_imported_video_payload_can_be_decoded(False)
+
+    @unittest.skipUnless(
+        av is not None
+        and sys.version_info >= (3, 10)
+        and importlib.util.find_spec("datasets") is not None
+        and importlib.util.find_spec("torch") is not None,
+        "Video training reads require Python 3.10+, PyAV, datasets, and Torch",
+    )
+    def test_imported_video_payload_supports_training_reads(self):
+        self._assert_imported_video_payload_can_be_decoded(True)
+
+    def _assert_imported_video_payload_can_be_decoded(self, training_reads):
         import pandas as pd
 
         temp_dir = Path(tempfile.mkdtemp(prefix="pypaimon_lerobot_mp4_"))
@@ -1755,11 +1958,17 @@ class LeRobotValidationTest(unittest.TestCase):
                         "fps": 10.0,
                     },
                     "task_index": {"dtype": "int64", "shape": [1]},
+                    "action": {"dtype": "float32", "shape": [1]},
                     "camera": {
                         "dtype": "video",
                         "shape": [16, 16, 3],
                         "video_info": {"video.fps": 10.0},
                     },
+                    "camera_b": {
+                        "dtype": "video",
+                        "shape": [16, 16, 3],
+                        "video_info": {"video.fps": 10.0},
+                    },
                 },
             }
             episodes = [
@@ -1775,6 +1984,10 @@ class LeRobotValidationTest(unittest.TestCase):
                     "videos/camera/file_index": 0,
                     "videos/camera/from_timestamp": 0.5,
                     "videos/camera/to_timestamp": 0.7,
+                    "videos/camera_b/chunk_index": 0,
+                    "videos/camera_b/file_index": 0,
+                    "videos/camera_b/from_timestamp": 0.5,
+                    "videos/camera_b/to_timestamp": 0.7,
                 },
                 {
                     "episode_index": 1,
@@ -1788,6 +2001,10 @@ class LeRobotValidationTest(unittest.TestCase):
                     "videos/camera/file_index": 0,
                     "videos/camera/from_timestamp": 0.1,
                     "videos/camera/to_timestamp": 0.4,
+                    "videos/camera_b/chunk_index": 0,
+                    "videos/camera_b/file_index": 0,
+                    "videos/camera_b/from_timestamp": 0.1,
+                    "videos/camera_b/to_timestamp": 0.4,
                 },
             ]
             physical_frame_values = [24, 56, 88, 120, 168, 216]
@@ -1824,6 +2041,10 @@ class LeRobotValidationTest(unittest.TestCase):
                         container.mux(packet)
                 for packet in stream.encode():
                     container.mux(packet)
+            camera_b_path = (
+                temp_dir / "videos/camera_b/chunk-000/file-000.mp4")
+            camera_b_path.parent.mkdir(parents=True)
+            shutil.copy2(video_path, camera_b_path)
 
             class Dataset:
 
@@ -1841,6 +2062,8 @@ class LeRobotValidationTest(unittest.TestCase):
                         type=pa.float32(),
                     ),
                     "task_index": pa.array([0] * 5, type=pa.int64()),
+                    "action": pa.array(
+                        [0.0, 1.0, 2.0, 3.0, 4.0], type=pa.float32()),
                 })
 
                 def __len__(self):
@@ -1915,6 +2138,64 @@ class LeRobotValidationTest(unittest.TestCase):
                 [0.5, 0.6, 0.1, 0.2, 0.3],
                 atol=1e-6,
             )
+            if not training_reads:
+                return
+
+            dataset = pmm.PaimonLeRobotDataset(
+                table,
+                delta_timestamps={"camera": [0.0, 0.1]},
+            )
+            try:
+                last, first = dataset.__getitems__([4, 0])
+                self.assertEqual(
+                    [2, 3, 16, 16], list(last["camera"].shape))
+                self.assertEqual(
+                    [2, 3, 16, 16], list(first["camera"].shape))
+                self.assertEqual(
+                    [3, 16, 16], list(first["camera_b"].shape))
+                self.assertEqual("torch.float32", str(last["camera"].dtype))
+                np.testing.assert_allclose(
+                    [
+                        float(last["camera"][0].mean()) * 255,
+                        float(first["camera"][0].mean()) * 255,
+                        float(first["camera"][1].mean()) * 255,
+                    ],
+                    [120, 168, 216],
+                    atol=5,
+                )
+                self.assertEqual(
+                    [False, True], last["camera_is_pad"].tolist())
+                self.assertEqual(
+                    1, len(dataset._video_collators[0]._decoders))
+
+                from torch.utils.data import DataLoader
+                worker_indices = []
+                for batch in DataLoader(
+                        dataset,
+                        batch_size=2,
+                        shuffle=False,
+                        num_workers=2,
+                        multiprocessing_context="spawn"):
+                    worker_indices.extend(batch["index"].tolist())
+                self.assertEqual(list(range(5)), worker_indices)
+            finally:
+                dataset.close()
+
+            action_dataset = pmm.PaimonLeRobotDataset(
+                table,
+                delta_timestamps={"action": [0.0, 0.1]},
+            )
+            try:
+                item = action_dataset[0]
+                self.assertEqual([2], list(item["action"].shape))
+                np.testing.assert_allclose(
+                    [0.0, 1.0], item["action"].tolist())
+                self.assertEqual(
+                    [3, 16, 16], list(item["camera"].shape))
+                self.assertEqual(
+                    [3, 16, 16], list(item["camera_b"].shape))
+            finally:
+                action_dataset.close()
         finally:
             shutil.rmtree(temp_dir, ignore_errors=True)
 

Reply via email to