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 d77efe0d2e [python] Add Paimon LeRobot map-style dataset (#9498)
d77efe0d2e is described below

commit d77efe0d2e3d15e33df0eb8211f833035e0959b8
Author: XiaoHongbo <[email protected]>
AuthorDate: Wed Sep 9 22:46:38 2026 +0800

    [python] Add Paimon LeRobot map-style dataset (#9498)
---
 .github/workflows/paimon-python-checks.yml         |    2 +-
 docs/docs/pypaimon/multimodal-api.mdx              |   24 +-
 paimon-python/pypaimon/multimodal/__init__.py      |    2 +
 .../pypaimon/multimodal/lerobot/__init__.py        |    4 +-
 .../pypaimon/multimodal/lerobot/dataset.py         | 1183 ++++++++++++++++++++
 .../pypaimon/multimodal/lerobot/metadata.py        |   18 +
 .../pypaimon/tests/multimodal_lerobot_test.py      |  627 ++++++++++-
 paimon-python/setup.py                             |    1 +
 8 files changed, 1853 insertions(+), 8 deletions(-)

diff --git a/.github/workflows/paimon-python-checks.yml 
b/.github/workflows/paimon-python-checks.yml
index 1124e8b83e..ac48f730fc 100755
--- a/.github/workflows/paimon-python-checks.yml
+++ b/.github/workflows/paimon-python-checks.yml
@@ -210,7 +210,7 @@ jobs:
         run: |
             python -m pip install --upgrade pip
             pip install torch --index-url https://download.pytorch.org/whl/cpu
-            python -m pip install pyroaring readerwriterlock==1.0.9 
fsspec==2024.3.1 cachetools==5.3.3 ossfs==2023.12.0 ray==2.54.0 
fastavro==1.11.1 pyarrow==16.0.0 zstandard==0.24.0 polars==1.32.0 duckdb==1.3.2 
numpy==1.24.3 pandas==2.0.3 pylance==0.39.0 flake8==4.0.1 pytest~=7.0 
py4j==0.10.9.9 requests parameterized==0.9.0 datasketches
+            python -m pip install pyroaring readerwriterlock==1.0.9 
fsspec==2024.3.1 cachetools==5.3.3 ossfs==2023.12.0 ray==2.54.0 
fastavro==1.11.1 pyarrow==16.0.0 zstandard==0.24.0 polars==1.32.0 duckdb==1.3.2 
numpy==1.24.3 pandas==2.0.3 pylance==0.39.0 flake8==4.0.1 pytest~=7.0 
py4j==0.10.9.9 requests parameterized==0.9.0 datasketches 'datasets>=4,<4.1'
             python -m pip install 'lumina-data>=${{ env.LUMINA_DATA_VERSION 
}}' -i https://pypi.org/simple/
       - name: Run lint-python.sh
         shell: bash
diff --git a/docs/docs/pypaimon/multimodal-api.mdx 
b/docs/docs/pypaimon/multimodal-api.mdx
index ef0a38e28c..6edbeeb798 100644
--- a/docs/docs/pypaimon/multimodal-api.mdx
+++ b/docs/docs/pypaimon/multimodal-api.mdx
@@ -707,7 +707,7 @@ from pypaimon.multimodal.lerobot import PaimonLeRobotWriter
 
 writer = PaimonLeRobotWriter(
     conn,
-    "robot_data",
+    "recorded_frames",
     fps=30,
     features=dataset_features,
 )
@@ -754,6 +754,28 @@ before these properties existed is scanned once and 
upgraded by its next
 commit. A commit exception has an unknown result and is not automatically
 retried.
 
+## 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.
+
+```python
+from torch.utils.data import DataLoader
+from pypaimon.multimodal import PaimonLeRobotDataset
+
+dataset = PaimonLeRobotDataset(
+    conn.get_table("robot_data"),
+    tag_name=tag,
+)
+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.
+
 ## Overwrite
 
 `overwrite` accepts the same input formats as `add` and replaces existing data
diff --git a/paimon-python/pypaimon/multimodal/__init__.py 
b/paimon-python/pypaimon/multimodal/__init__.py
index 53717d0155..584edc8431 100644
--- a/paimon-python/pypaimon/multimodal/__init__.py
+++ b/paimon-python/pypaimon/multimodal/__init__.py
@@ -29,6 +29,7 @@ from pypaimon.multimodal.hdf5 import (
     Hdf5File,
     Hdf5LoadResult,
 )
+from pypaimon.multimodal.lerobot.dataset import PaimonLeRobotDataset
 from pypaimon.multimodal.rosbag import (
     RosbagLoadResult,
     RosbagSource,
@@ -60,6 +61,7 @@ __all__ = [
     "MultimodalTable",
     "NoSuchKey",
     "ObjectInfo",
+    "PaimonLeRobotDataset",
     "PutObjectResult",
     "RosbagLoadResult",
     "RosbagSource",
diff --git a/paimon-python/pypaimon/multimodal/lerobot/__init__.py 
b/paimon-python/pypaimon/multimodal/lerobot/__init__.py
index 5f598d2991..25e196dbb8 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/__init__.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/__init__.py
@@ -14,13 +14,15 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""LeRobot Dataset v3 import and direct Paimon capture."""
+"""LeRobot Dataset v3 integration for multimodal Paimon tables."""
 
 from pypaimon.multimodal.lerobot.api import load_from_lerobot
+from pypaimon.multimodal.lerobot.dataset import PaimonLeRobotDataset
 from pypaimon.multimodal.lerobot.writer import PaimonLeRobotWriter
 
 
 __all__ = [
+    "PaimonLeRobotDataset",
     "PaimonLeRobotWriter",
     "load_from_lerobot",
 ]
diff --git a/paimon-python/pypaimon/multimodal/lerobot/dataset.py 
b/paimon-python/pypaimon/multimodal/lerobot/dataset.py
new file mode 100644
index 0000000000..49e26097e0
--- /dev/null
+++ b/paimon-python/pypaimon/multimodal/lerobot/dataset.py
@@ -0,0 +1,1183 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""LeRobot-compatible map-style reads from a multimodal Paimon table."""
+
+import bisect
+import io
+import json
+import math
+import operator
+import os
+import sys
+
+import pyarrow as pa
+
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.multimodal.lerobot.metadata import (
+    _companion_table_identifiers,
+    _restore_pandas_metadata,
+    _tag_snapshot_id,
+    _validate_tag_name,
+)
+from pypaimon.multimodal.lerobot.loader import _DECLARED_NUMERIC_RANGES
+from pypaimon.multimodal.lerobot.schema import (
+    _feature_shape,
+    _require_v3,
+    _schema_from_info,
+    _validate_lerobot_schema,
+)
+from pypaimon.multimodal.table import _target_schema, _time_travel_table
+from pypaimon.read.query_auth_split import QueryAuthSplit
+
+
+_TORCH_DTYPE_NAMES = {
+    "bool": "bool",
+    "boolean": "bool",
+    "int8": "int8",
+    "int16": "int16",
+    "int32": "int32",
+    "int64": "int64",
+    "uint8": "uint8",
+    "uint16": "uint16",
+    "uint32": "uint32",
+    "float16": "float16",
+    "float32": "float32",
+    "float64": "float64",
+}
+
+_IMAGE_READ_ATTEMPTS = 3
+
+_CONTROL_FEATURES = frozenset({
+    "index",
+    "episode_index",
+    "frame_index",
+    "timestamp",
+    "task_index",
+    "subtask_index",
+})
+
+
+class PaimonLeRobotDataset:
+    """Map-style LeRobot reader backed by indexed Paimon reads.
+
+    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
+    ``torch.uint8`` representation instead of normalizing them to float32.
+    Higher-bit-depth images retain the existing float32 behavior.
+    """
+
+    def __init__(
+            self,
+            table,
+            *,
+            tag_name=None,
+            episodes=None,
+            image_transforms=None,
+            delta_timestamps=None,
+            tolerance_s=1e-4,
+            blob_parallelism=16,
+            return_uint8=False):
+        if sys.version_info < (3, 10):
+            raise RuntimeError(
+                "PaimonLeRobotDataset requires Python 3.10 or newer; "
+                "install and run 'pypaimon[lerobot]' on a supported Python "
+                "version.")
+        raw_table, self.meta = _load_dataset(table, tag_name)
+        self.tag_name = tag_name
+        self.repo_id = self.meta.repo_id
+        self.image_transforms = image_transforms
+        self.delta_timestamps = delta_timestamps
+        self.tolerance_s = float(tolerance_s)
+        if not math.isfinite(self.tolerance_s) or self.tolerance_s < 0:
+            raise ValueError("tolerance_s must be finite and non-negative.")
+        self.blob_parallelism = _positive_int(
+            blob_parallelism, "blob_parallelism")
+        if not isinstance(return_uint8, bool):
+            raise TypeError("return_uint8 must be a boolean.")
+        self.return_uint8 = return_uint8
+        if image_transforms is not None and not callable(image_transforms):
+            raise TypeError("image_transforms must be callable or None.")
+
+        info = self._init_metadata()
+        self._init_episodes(episodes)
+        self._init_reader(raw_table, info)
+
+    def _init_metadata(self):
+        info = dict(_metadata_member(self.meta, "info", {}))
+        _require_v3(info, self.repo_id)
+        self._features = dict(
+            _metadata_member(self.meta, "features", info.get("features")))
+        if not self._features:
+            raise ValueError("LeRobot metadata must define features.")
+        self._image_keys = [
+            name for name, feature in self._features.items()
+            if feature.get("dtype") == "image"
+        ]
+        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._total_frames = int(
+            _metadata_member(
+                self.meta, "total_frames", info.get("total_frames", -1)))
+        self._total_episodes = int(
+            _metadata_member(
+                self.meta, "total_episodes", info.get("total_episodes", -1)))
+        self._total_tasks = int(
+            _metadata_member(
+                self.meta, "total_tasks", info.get("total_tasks", -1)))
+        if self._total_frames < 0 or self._total_episodes < 0:
+            raise ValueError(
+                "LeRobot metadata must define total_frames and "
+                "total_episodes.")
+        if self._total_tasks < 0:
+            raise ValueError("LeRobot metadata must define total_tasks.")
+
+        self._fps = int(
+            _metadata_member(self.meta, "fps", info.get("fps", 0)))
+        if self._fps <= 0:
+            raise ValueError("LeRobot metadata fps must be positive.")
+        return info
+
+    def _init_episodes(self, episodes):
+        self._episode_ranges = _episode_ranges(
+            self.meta, self._total_frames, self._total_episodes)
+        self._episode_ends = [end for _, end in self._episode_ranges] \
+            if self._episode_ranges is not None else None
+        self.episodes = _selected_episodes(episodes, self._total_episodes)
+        if self.episodes is not None and self._episode_ranges is None:
+            raise ValueError("Episode selection requires episode metadata.")
+        self._selected_ranges = None
+        if self.episodes is not None:
+            # LeRobot exposes the caller's episode order but its Parquet filter
+            # returns frames in their stored dataset order.
+            range_episodes = sorted(self.episodes)
+            self._selected_ranges = [
+                self._episode_ranges[index] for index in range_episodes
+            ]
+            self._selected_ends = []
+            size = 0
+            for begin, end in self._selected_ranges:
+                size += end - begin
+                self._selected_ends.append(size)
+
+        self._delta_indices = _delta_indices(
+            self.delta_timestamps,
+            self._fps,
+            self.tolerance_s,
+            self._features,
+        )
+        if self._delta_indices and self._episode_ranges is None:
+            raise ValueError("delta_timestamps requires episode metadata.")
+
+    def _init_reader(self, raw_table, info):
+        target_schema = _target_schema(raw_table)
+        table_fields = set(target_schema.names)
+        tasks = _metadata_member(self.meta, "tasks")
+        subtasks = _metadata_member(self.meta, "subtasks")
+        _validate_component_metadata(
+            self._features, self._total_tasks, tasks, subtasks)
+        source_schema = _schema_from_info(info)
+        _validate_lerobot_schema(source_schema, target_schema, self.repo_id)
+        validation_context = _build_frame_validation_context(
+            self.meta,
+            self._episode_ranges,
+            self._fps,
+            tasks,
+            subtasks,
+            source_schema.field("timestamp").type,
+        )
+        projection = list(self._features)
+        missing = set(projection) - table_fields
+        if missing:
+            raise ValueError(
+                "Paimon table is missing LeRobot fields: %s"
+                % sorted(missing))
+
+        self._read_table, self._snapshot_id, splits = _indexed_read_table(
+            raw_table, projection)
+        snapshot = self._read_table.snapshot_manager().get_snapshot_by_id(
+            self._snapshot_id)
+        if snapshot.next_row_id != self._total_frames:
+            raise ValueError(
+                "Paimon table has %d rows but metadata declares %d frames."
+                % (snapshot.next_row_id, self._total_frames))
+        self._projection = projection
+        self._frame_locator = _FrameLocator(
+            self._read_table, snapshot, splits)
+        self._validation_context = validation_context
+        self._file_io = self._read_table.file_io
+        self._task_names = validation_context["task_names"]
+        self._subtask_names = validation_context["subtask_names"]
+        self._delta_projection = None
+        if self._delta_indices:
+            self._delta_projection = list(dict.fromkeys(
+                [
+                    "index", "episode_index", "frame_index", "timestamp",
+                    "task_index",
+                ]
+                + (["subtask_index"] if subtasks is not None else [])
+                + list(self._delta_indices)
+            ))
+
+    @property
+    def features(self):
+        return self._features
+
+    @property
+    def fps(self):
+        return self._fps
+
+    @property
+    def num_frames(self):
+        if self.episodes is None:
+            return self._total_frames
+        return self._selected_ends[-1] if self._selected_ends else 0
+
+    @property
+    def num_episodes(self):
+        return self._total_episodes if self.episodes is None \
+            else len(self.episodes)
+
+    def __len__(self):
+        return self.num_frames
+
+    def __getitem__(self, index):
+        if isinstance(index, slice):
+            return self.__getitems__(range(*index.indices(len(self))))
+        return self.__getitems__([index])[0]
+
+    def __getitems__(self, indices):
+        dataset_indices = [
+            _normalize_index(index, len(self)) for index in indices
+        ]
+        if not dataset_indices:
+            return []
+        frame_indices = [
+            self._global_index(index) for index in dataset_indices
+        ]
+        plans = [self._plan(index) for index in frame_indices]
+
+        unique_frame_indices = sorted(set(frame_indices))
+        unique_frame_index_set = set(unique_frame_indices)
+        delta_indices = sorted({
+            position
+            for plan in plans
+            for positions in plan["windows"].values()
+            for position in positions
+            if position not in unique_frame_index_set
+        })
+        lookup_indices = sorted(unique_frame_index_set.union(delta_indices))
+        splits, needs_filter = self._frame_locator.locate(lookup_indices)
+        rows = self._read_rows(
+            lookup_indices, self._projection, splits, needs_filter)
+        base_rows = {
+            index: rows[index] for index in unique_frame_indices
+        }
+        delta_rows = {
+            index: {
+                name: rows[index][name] for name in self._delta_projection
+            }
+            for index in delta_indices
+        } if delta_indices else {}
+
+        _attach_task_labels(
+            base_rows, self._task_names, self._subtask_names)
+        row_groups = [base_rows, delta_rows]
+        image_sources = _image_blob_sources(
+            row_groups, self._image_keys)
+        for attempt in range(_IMAGE_READ_ATTEMPTS):
+            if attempt:
+                _restore_image_blob_sources(image_sources)
+            try:
+                _resolve_image_blobs(
+                    self._file_io,
+                    row_groups,
+                    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()
+                })
+                break
+            except OSError:
+                if attempt + 1 == _IMAGE_READ_ATTEMPTS:
+                    raise
+
+        import torch
+        duplicates = _duplicate_indices(plans)
+        result = []
+        for plan in plans:
+            item = dict(converted[plan["index"]])
+            if plan["index"] in duplicates:
+                item = {
+                    key: value.clone() if torch.is_tensor(value) else value
+                    for key, value in item.items()
+                }
+            for key, positions in plan["windows"].items():
+                item[key] = torch.stack([
+                    converted[position][key] for position in positions
+                ])
+            item.update(plan["padding"])
+            if self.image_transforms is not None:
+                for key in self._image_keys:
+                    item[key] = self.image_transforms(item[key])
+            result.append(item)
+        return result
+
+    def _read_rows(
+            self, indices, projection, splits=None, needs_filter=True):
+        if not indices:
+            return {}
+        return _read_rows_by_index(
+            self._read_table,
+            projection,
+            indices,
+            self._validation_context,
+            self.tolerance_s,
+            self._features,
+            splits,
+            needs_filter,
+        )
+
+    def set_image_transforms(self, image_transforms):
+        if image_transforms is not None and not callable(image_transforms):
+            raise TypeError("image_transforms must be callable or None.")
+        self.image_transforms = image_transforms
+
+    def clear_image_transforms(self):
+        self.image_transforms = None
+
+    def _global_index(self, index):
+        if self._selected_ranges is None:
+            return index
+        range_index = bisect.bisect_right(self._selected_ends, index)
+        previous_end = self._selected_ends[range_index - 1] \
+            if range_index else 0
+        return self._selected_ranges[range_index][0] + index - previous_end
+
+    def _plan(self, index):
+        windows = {}
+        padding = {}
+        if self._delta_indices:
+            episode = bisect.bisect_right(self._episode_ends, index)
+            begin, end = self._episode_ranges[episode]
+            import torch
+            for key, deltas in self._delta_indices.items():
+                windows[key] = [
+                    min(max(index + delta, begin), end - 1)
+                    for delta in deltas
+                ]
+                padding["%s_is_pad" % key] = torch.BoolTensor([
+                    not begin <= index + delta < end for delta in deltas
+                ])
+        return {"index": index, "windows": windows, "padding": padding}
+
+    def __repr__(self):
+        return (
+            "%s(repo_id=%r, episodes=%d, frames=%d, features=%r)"
+            % (self.__class__.__name__, self.repo_id, self.num_episodes,
+               self.num_frames, list(self.features)))
+
+
+class _FrameLocator:
+    """Locate LeRobot frame rows in one fixed Paimon snapshot."""
+
+    def __init__(self, table, snapshot, splits):
+        self._table = table
+        self._snapshot = snapshot
+        self._scanner = None
+        self._scanner_initialized = False
+        self._process_id = os.getpid()
+        self._set_splits(splits)
+
+    def _set_splits(self, splits):
+        from pypaimon.read.datasource.torch_dataset import (
+            SplitRangeIndex,
+            row_ranges_for_split,
+        )
+
+        self._splits = splits
+        self._split_ranges = [
+            row_ranges_for_split(split) for split in splits
+        ]
+        self._split_range_index = SplitRangeIndex(self._split_ranges)
+
+    def locate(self, indices):
+        """Return narrowed splits and whether rows still need filtering."""
+        self._ensure_process()
+        predicate = _index_predicate(self._table, indices)
+        try:
+            scanner = self._index_scanner(predicate)
+        except Exception as error:
+            raise RuntimeError(
+                "Failed to open the Paimon global index for LeRobot frame "
+                "lookups.") from error
+        if scanner is None:
+            raise RuntimeError(
+                "PaimonLeRobotDataset requires a readable global index on "
+                "the frame 'index' column.")
+        try:
+            evaluation = scanner.scan_with_coverage(predicate)
+            if evaluation is None:
+                raise RuntimeError(
+                    "The Paimon global index could not evaluate the LeRobot "
+                    "frame index predicate.")
+            unindexed = scanner.unindexed_ranges(
+                predicate,
+                search_mode=self._table.options.scalar_index_search_mode(),
+                contributing_field_ids=evaluation.contributing_field_ids,
+            )
+            ranges = evaluation.result.results().to_range_list() + unindexed
+            from pypaimon.read.datasource.torch_dataset import (
+                select_indexed_splits,
+            )
+            from pypaimon.utils.range import Range
+            return select_indexed_splits(
+                self._splits,
+                self._split_ranges,
+                self._split_range_index,
+                Range.sort_and_merge_overlap(ranges, True),
+            ), bool(unindexed)
+        except RuntimeError:
+            raise
+        except Exception as error:
+            raise RuntimeError(
+                "Failed to query the Paimon global index for LeRobot "
+                "frames.") from error
+
+    def _ensure_process(self):
+        process_id = os.getpid()
+        if process_id == self._process_id:
+            return
+        self._scanner = None
+        self._scanner_initialized = False
+        self._set_splits(self._splits)
+        self._process_id = process_id
+
+    def _index_scanner(self, predicate):
+        if not self._scanner_initialized:
+            from pypaimon.globalindex import DataEvolutionGlobalIndexScanner
+            self._scanner = DataEvolutionGlobalIndexScanner.create(
+                self._table,
+                predicate=predicate,
+                snapshot=self._snapshot,
+            )
+            self._scanner_initialized = True
+        return self._scanner
+
+    def close(self):
+        scanner = self._scanner
+        self._scanner = None
+        self._scanner_initialized = False
+        if scanner is not None and self._process_id == os.getpid():
+            scanner.close()
+
+    def __getstate__(self):
+        state = self.__dict__.copy()
+        state["_scanner"] = None
+        state["_scanner_initialized"] = False
+        state["_process_id"] = None
+        state["_split_ranges"] = None
+        state["_split_range_index"] = None
+        return state
+
+    def __del__(self):
+        try:
+            self.close()
+        except Exception:
+            pass
+
+
+class _PaimonLeRobotMetadata:
+
+    def __init__(
+            self, repo_id, tag_name, info, stats, episodes, tasks,
+            subtasks):
+        self.repo_id = repo_id
+        self.revision = tag_name
+        self.info = info
+        self.stats = stats
+        self.episodes = episodes
+        self.tasks = tasks
+        self.subtasks = subtasks
+
+    def __getattr__(self, name):
+        info = self.__dict__.get("info", {})
+        try:
+            return info[name]
+        except KeyError as error:
+            raise AttributeError(name) from error
+
+    @property
+    def image_keys(self):
+        return [
+            name for name, feature in self.features.items()
+            if feature["dtype"] == "image"
+        ]
+
+    @property
+    def video_keys(self):
+        return [
+            name for name, feature in self.features.items()
+            if feature["dtype"] == "video"
+        ]
+
+    @property
+    def camera_keys(self):
+        return [
+            name for name, feature in self.features.items()
+            if feature["dtype"] in ("image", "video")
+        ]
+
+    @property
+    def names(self):
+        return {
+            name: feature.get("names")
+            for name, feature in self.features.items()
+        }
+
+    @property
+    def shapes(self):
+        return {
+            name: tuple(feature["shape"])
+            for name, feature in self.features.items()
+        }
+
+    def get_task_index(self, task):
+        if task not in self.tasks.index:
+            return None
+        return int(self.tasks.loc[task].task_index)
+
+
+def _load_dataset(table, tag_name):
+    raw_table = getattr(table, "raw_table", None)
+    if raw_table is None:
+        raise TypeError("table must be a MultimodalTable.")
+    if tag_name is not None:
+        _validate_tag_name(tag_name)
+    identifiers = _companion_table_identifiers(raw_table)
+    catalog = table.catalog
+    frames = _component_table(catalog, raw_table, tag_name)
+    episodes_table = _component_table(
+        catalog, catalog.get_table(identifiers["episodes"]), tag_name)
+    episodes = _episode_dataset(episodes_table)
+    tasks_table = _component_table(
+        catalog, catalog.get_table(identifiers["tasks"]), tag_name)
+    tasks = _component_dataframe(tasks_table, "task_index")
+    subtasks = None
+    if "subtasks" in identifiers:
+        subtasks_table = _component_table(
+            catalog, catalog.get_table(identifiers["subtasks"]), tag_name)
+        subtasks = _component_dataframe(subtasks_table, "subtask_index")
+
+    info = _metadata_object(_component_table(
+        catalog, catalog.get_table(identifiers["info"]), tag_name), "info")
+    for feature in info.get("features", {}).values():
+        feature["shape"] = tuple(feature["shape"])
+    stats = None
+    if "stats" in identifiers:
+        stats = _numpy_stats(_metadata_object(_component_table(
+            catalog, catalog.get_table(identifiers["stats"]), tag_name),
+            "stats"))
+    metadata = _PaimonLeRobotMetadata(
+        str(table.identifier), tag_name, info, stats, episodes, tasks,
+        subtasks)
+    return frames, metadata
+
+
+def _component_table(catalog, table, tag_name):
+    if tag_name is None:
+        return table
+    snapshot_id = _tag_snapshot_id(catalog, table.identifier, tag_name)
+    if snapshot_id is None:
+        raise ValueError(
+            "Paimon LeRobot component %s is missing tag %s."
+            % (table.identifier, tag_name))
+    return _time_travel_table(table, tag_name=tag_name)
+
+
+def _read_arrow(table, projection=None):
+    builder = table.new_read_builder()
+    if projection is not None:
+        builder = builder.with_projection(projection)
+    plan = builder.new_scan().plan()
+    return builder.new_read().to_arrow(plan.splits())
+
+
+def _episode_dataset(table):
+    try:
+        from datasets import Dataset
+    except ImportError as error:
+        raise ImportError(
+            "PaimonLeRobotDataset requires datasets from "
+            "'pypaimon[lerobot]'.") from error
+
+    projection = [
+        name for name in _target_schema(table).names
+        if not name.startswith("stats/")
+    ]
+    data = _read_arrow(table, projection).sort_by("episode_index")
+    return Dataset(data)
+
+
+def _component_dataframe(table, index_field):
+    data = _read_arrow(table).sort_by(index_field)
+    return _restore_pandas_metadata(table, data).to_pandas()
+
+
+def _metadata_object(table, name):
+    result = {}
+    for row in _read_arrow(table).to_pylist():
+        key = row.get("key")
+        if not isinstance(key, str) or key in result:
+            raise ValueError(
+                "Paimon LeRobot %s metadata contains an invalid key."
+                % name)
+        try:
+            result[key] = json.loads(row.get("value"))
+        except (TypeError, ValueError) as error:
+            raise ValueError(
+                "Paimon LeRobot %s metadata value for %r is invalid JSON."
+                % (name, key)) from error
+    return result
+
+
+def _numpy_stats(value):
+    if isinstance(value, dict):
+        return {name: _numpy_stats(item) for name, item in value.items()}
+    import numpy as np
+    return np.array(value)
+
+
+def _metadata_member(metadata, name, default=None):
+    value = getattr(metadata, name, None)
+    return default if value is None else value
+
+
+def _episode_row(episodes, ordinal):
+    return episodes.iloc[ordinal] if hasattr(episodes, "iloc") \
+        else episodes[ordinal]
+
+
+def _episode_ranges(metadata, total_frames, total_episodes):
+    episodes = _metadata_member(metadata, "episodes")
+    if episodes is None:
+        return None
+    if len(episodes) != total_episodes:
+        raise ValueError(
+            "LeRobot episode metadata contains %d rows, expected %d."
+            % (len(episodes), total_episodes))
+    ranges = []
+    expected = 0
+    for ordinal in range(total_episodes):
+        row = _episode_row(episodes, ordinal)
+        try:
+            index = operator.index(row["episode_index"])
+            begin = operator.index(row["dataset_from_index"])
+            end = operator.index(row["dataset_to_index"])
+            length = operator.index(row["length"])
+        except (KeyError, TypeError) as error:
+            raise ValueError(
+                "LeRobot episode %d metadata must contain integer controls."
+                % ordinal) from error
+        if index != ordinal:
+            raise ValueError(
+                "LeRobot episode row %d has episode_index=%d."
+                % (ordinal, index))
+        if begin != expected or end <= begin:
+            raise ValueError(
+                "LeRobot episode %d has invalid frame range [%d, %d)."
+                % (ordinal, begin, end))
+        if length != end - begin:
+            raise ValueError(
+                "LeRobot episode %d has length %d, expected %d."
+                % (ordinal, length, end - begin))
+        ranges.append((begin, end))
+        expected = end
+    if expected != total_frames:
+        raise ValueError(
+            "LeRobot episode ranges cover %d frames, expected %d."
+            % (expected, total_frames))
+    return ranges
+
+
+def _validate_component_metadata(features, total_tasks, tasks, subtasks):
+    task_count = 0 if tasks is None else len(tasks)
+    if task_count != total_tasks:
+        raise ValueError(
+            "LeRobot task metadata contains %d rows, expected %d."
+            % (task_count, total_tasks))
+    has_subtasks = subtasks is not None
+    has_subtask_feature = "subtask_index" in features
+    if has_subtasks != has_subtask_feature:
+        raise ValueError(
+            "Paimon LeRobot subtask metadata does not match the "
+            "subtask_index feature.")
+
+
+def _build_frame_validation_context(
+        metadata, episode_ranges, fps, tasks, subtasks, timestamp_type):
+    task_names = _index_names(tasks, "task_index")
+    subtask_names = _index_names(subtasks, "subtask_index")
+    episode_tasks = _episode_tasks(metadata, len(episode_ranges)) \
+        if episode_ranges is not None else None
+    return {
+        "episode_ranges": episode_ranges,
+        "episode_ends": (
+            [end for _, end in episode_ranges]
+            if episode_ranges is not None else None),
+        "fps": fps,
+        "task_names": task_names,
+        "subtask_names": subtask_names,
+        "episode_tasks": episode_tasks,
+        "timestamp_type": timestamp_type,
+    }
+
+
+def _index_names(values, index_field):
+    if values is None or len(values) == 0:
+        return None
+    if not hasattr(values, "iterrows"):
+        return {
+            index: str(value) for index, value in enumerate(values)
+        }
+    try:
+        indices = values[index_field]
+    except KeyError as error:
+        raise ValueError(
+            "LeRobot %s metadata must contain integer indices."
+            % index_field) from error
+    result = {}
+    for name, value in zip(values.index, indices):
+        try:
+            index = operator.index(value)
+        except TypeError as error:
+            raise ValueError(
+                "LeRobot %s metadata must contain integer indices."
+                % index_field) from error
+        if index in result:
+            raise ValueError(
+                "LeRobot %s metadata contains duplicate index %d."
+                % (index_field, index))
+        result[index] = str(name)
+    if sorted(result) != list(range(len(result))):
+        raise ValueError(
+            "LeRobot %s metadata indices must be contiguous."
+            % index_field)
+    return result
+
+
+def _episode_tasks(metadata, total_episodes):
+    episodes = _metadata_member(metadata, "episodes")
+    if episodes is None:
+        return None
+    result = []
+    for ordinal in range(total_episodes):
+        row = _episode_row(episodes, ordinal)
+        tasks = row.get("tasks") if hasattr(row, "get") else None
+        if tasks is None:
+            result.append(None)
+        elif isinstance(tasks, str):
+            result.append((tasks,))
+        else:
+            result.append(tuple(sorted(str(task) for task in tasks)))
+    return tuple(result)
+
+
+def _selected_episodes(episodes, total_episodes):
+    if episodes is None:
+        return None
+    selected = []
+    seen = set()
+    for value in episodes:
+        try:
+            index = operator.index(value)
+        except TypeError as error:
+            raise ValueError(
+                "episodes must contain integer indices.") from error
+        if index < 0 or index >= total_episodes:
+            raise ValueError(
+                "episodes must contain indices in [0, %d)." % total_episodes)
+        if index in seen:
+            raise ValueError("episodes must not contain duplicate indices.")
+        seen.add(index)
+        selected.append(index)
+    return selected
+
+
+def _delta_indices(delta_timestamps, fps, tolerance_s, features):
+    if delta_timestamps is None:
+        return None
+    if fps <= 0:
+        raise ValueError("LeRobot metadata fps must be positive.")
+    result = {}
+    for key, timestamps in delta_timestamps.items():
+        if key not in features:
+            raise ValueError("Unknown LeRobot delta feature: %s" % key)
+        deltas = []
+        for timestamp in timestamps:
+            index = round(float(timestamp) * fps)
+            if abs(float(timestamp) - index / fps) > tolerance_s:
+                raise ValueError(
+                    "delta_timestamps for %s must be multiples of 1/%d."
+                    % (key, fps))
+            deltas.append(index)
+        result[key] = deltas
+    return result
+
+
+def _indexed_read_table(raw_table, projection):
+    read_table = raw_table.copy({
+        CoreOptions.BLOB_AS_DESCRIPTOR.key(): "true"
+    })
+    plan = read_table.new_read_builder().with_projection(
+        projection).new_scan().plan()
+    splits = plan.splits()
+    if any(
+            isinstance(split, QueryAuthSplit)
+            and (
+                getattr(split.auth_result, "filter", None)
+                or getattr(split.auth_result, "column_masking", None)
+            )
+            for split in splits):
+        raise ValueError(
+            "PaimonLeRobotDataset does not support query authorization "
+            "filters or column masking.")
+    if plan.snapshot_id is None:
+        raise ValueError("Paimon LeRobot frames table has no snapshot.")
+    if read_table.options.scan_tag_name() is None:
+        read_table = _time_travel_table(
+            read_table, snapshot_id=plan.snapshot_id)
+    return read_table, plan.snapshot_id, splits
+
+
+def _index_predicate(table, indices):
+    return table.new_read_builder().new_predicate_builder().is_in(
+        "index", indices)
+
+
+def _read_rows_by_index(
+        table, projection, indices, validation_context, tolerance_s, features,
+        splits=None, needs_filter=True):
+    builder = table.new_read_builder().with_projection(projection)
+    if needs_filter:
+        builder = builder.with_filter(_index_predicate(table, indices))
+    if splits is None:
+        splits = builder.new_scan().plan().splits()
+    rows = _arrow_rows(builder.new_read().to_arrow(splits), features)
+    expected = set(indices)
+    result = {}
+    for row in rows:
+        index = _control_index(row, "index", -1)
+        if index not in expected or index in result:
+            raise ValueError(
+                "Paimon BTree returned an unexpected or duplicate LeRobot "
+                "index: %d." % index)
+        _validate_control_row(index, row, validation_context, tolerance_s)
+        result[index] = row
+    missing = expected - set(result)
+    if missing:
+        raise RuntimeError(
+            "Paimon index lookup did not return LeRobot indices %s."
+            % sorted(missing))
+    return result
+
+
+def _arrow_rows(table, features):
+    """Convert indexed Arrow results without expanding tensors to lists."""
+    rows = [{} for unused in range(table.num_rows)]
+    for name in table.column_names:
+        feature = features.get(name)
+        if (name not in _CONTROL_FEATURES
+                and feature is not None
+                and feature.get("dtype") in _TORCH_DTYPE_NAMES):
+            values = _numeric_tensor_rows(table.column(name), name, feature)
+        else:
+            values = table.column(name).to_pylist()
+        for row, value in zip(rows, values):
+            row[name] = value
+    return rows
+
+
+def _numeric_tensor_rows(column, name, feature):
+    import numpy as np
+    import torch
+
+    values = column.combine_chunks()
+    if values.null_count:
+        raise ValueError(
+            "LeRobot numeric feature %s contains null values." % name)
+    shape = _feature_shape(feature, name)
+    if shape not in ((), (1,)):
+        for size in shape:
+            if pa.types.is_fixed_size_list(values.type):
+                if values.type.list_size != size:
+                    raise ValueError(
+                        "LeRobot feature %s has Arrow type %s, expected "
+                        "shape %s." % (name, column.type, shape))
+                start = values.offset * size
+                values = values.values.slice(start, len(values) * size)
+            elif (pa.types.is_list(values.type)
+                  or pa.types.is_large_list(values.type)):
+                offsets = values.offsets.to_numpy(zero_copy_only=False)
+                if not np.all(np.diff(offsets) == size):
+                    raise ValueError(
+                        "LeRobot feature %s has Arrow type %s, expected "
+                        "shape %s." % (name, column.type, shape))
+                start = int(offsets[0])
+                values = values.values.slice(
+                    start, int(offsets[-1]) - start)
+            else:
+                raise ValueError(
+                    "LeRobot feature %s has Arrow type %s, expected shape "
+                    "%s." % (name, column.type, shape))
+            if values.null_count:
+                raise ValueError(
+                    "LeRobot numeric feature %s contains null values." % name)
+    if not (pa.types.is_integer(values.type)
+            or pa.types.is_floating(values.type)
+            or pa.types.is_boolean(values.type)):
+        raise ValueError(
+            "LeRobot numeric feature %s has unsupported Arrow type %s."
+            % (name, column.type))
+    numpy_values = values.to_numpy(zero_copy_only=False)
+    if shape not in ((), (1,)):
+        numpy_values = numpy_values.reshape((len(column),) + shape)
+    declared_dtype = feature.get("dtype")
+    if declared_dtype in ("uint8", "uint16", "uint32", "float16"):
+        minimum, maximum = _DECLARED_NUMERIC_RANGES[declared_dtype]
+        comparable = numpy_values[np.isfinite(numpy_values)] \
+            if declared_dtype == "float16" else numpy_values
+        if comparable.size and (comparable.min() < minimum
+                                or comparable.max() > maximum):
+            raise ValueError(
+                "LeRobot numeric feature %s contains a value outside the "
+                "%s range [%s, %s]."
+                % (name, declared_dtype, minimum, maximum))
+    dtype = getattr(torch, _TORCH_DTYPE_NAMES[declared_dtype])
+    return torch.tensor(numpy_values, dtype=dtype).unbind(0)
+
+
+def _validate_control_row(index, row, validation_context, tolerance_s):
+    episode = bisect.bisect_right(
+        validation_context["episode_ends"], index)
+    begin, unused_end = validation_context["episode_ranges"][episode]
+    frame = index - begin
+    for name, expected in (
+            ("index", index),
+            ("episode_index", episode),
+            ("frame_index", frame)):
+        try:
+            actual = operator.index(row[name])
+        except (KeyError, TypeError) as error:
+            raise ValueError(
+                "Paimon LeRobot %s at index %d must be an integer."
+                % (name, index)) from error
+        if actual != expected:
+            raise ValueError(
+                "Paimon %s at LeRobot index %d is %r; expected %r."
+                % (name, index, actual, expected))
+
+    timestamp = row.get("timestamp")
+    expected_timestamp = pa.scalar(
+        frame / validation_context["fps"],
+        type=validation_context["timestamp_type"],
+    ).as_py()
+    if isinstance(timestamp, bool) or not isinstance(timestamp, (int, float)) \
+            or not math.isfinite(float(timestamp)) \
+            or not math.isclose(
+                float(timestamp), float(expected_timestamp),
+                rel_tol=0.0, abs_tol=tolerance_s):
+        raise ValueError(
+            "Paimon timestamp at LeRobot index %d is %r; expected %r."
+            % (index, timestamp, expected_timestamp))
+
+    task = _control_index(row, "task_index", index)
+    task_name = (validation_context["task_names"] or {}).get(task)
+    if task_name is None:
+        raise ValueError(
+            "Paimon task_index at LeRobot index %d is absent from metadata: "
+            "%r." % (index, task))
+    episode_tasks = validation_context["episode_tasks"]
+    if episode_tasks is not None and episode_tasks[episode] is not None \
+            and task_name not in episode_tasks[episode]:
+        raise ValueError(
+            "Paimon task at LeRobot index %d is not assigned to Episode %d."
+            % (index, episode))
+    subtasks = validation_context["subtask_names"]
+    if subtasks is not None:
+        subtask = _control_index(row, "subtask_index", index)
+        if subtask not in subtasks:
+            raise ValueError(
+                "Paimon subtask_index at LeRobot index %d is absent from "
+                "metadata: %r." % (index, subtask))
+
+
+def _control_index(row, name, index):
+    try:
+        return operator.index(row[name])
+    except (KeyError, TypeError) as error:
+        raise ValueError(
+            "Paimon LeRobot %s at index %d must be an integer."
+            % (name, index)) from error
+
+
+def _duplicate_indices(plans):
+    seen = set()
+    duplicates = set()
+    for plan in plans:
+        index = plan["index"]
+        if index in seen:
+            duplicates.add(index)
+        seen.add(index)
+    return duplicates
+
+
+def _resolve_image_blobs(
+        file_io, row_groups, image_keys, parallelism):
+    from pypaimon.multimodal.blob_read import fetch_blob_bodies
+
+    values = {key: [] for key in image_keys}
+    targets = {key: [] for key in image_keys}
+    for rows in row_groups:
+        for row in rows.values():
+            for key in image_keys:
+                if key in row:
+                    targets[key].append(row)
+                    values[key].append(row[key])
+    used = [key for key in image_keys if values[key]]
+    if not used:
+        return
+    bodies = fetch_blob_bodies(
+        file_io, values, used, parallelism)
+    for key in used:
+        for row, body in zip(targets[key], bodies[key]):
+            row[key] = body
+
+
+def _image_blob_sources(row_groups, image_keys):
+    return [
+        (row, key, row[key])
+        for rows in row_groups
+        for row in rows.values()
+        for key in image_keys
+        if key in row
+    ]
+
+
+def _restore_image_blob_sources(sources):
+    for row, key, descriptor in sources:
+        row[key] = descriptor
+
+
+def _attach_task_labels(rows, task_names, subtask_names):
+    for row in rows.values():
+        task_index = operator.index(row["task_index"])
+        row["task"] = task_names[task_index]
+        if subtask_names is not None:
+            subtask_index = operator.index(row["subtask_index"])
+            row["subtask"] = subtask_names[subtask_index]
+
+
+def _torch_row(row, features, return_uint8=False):
+    import torch
+
+    result = dict(row)
+    for key, feature in features.items():
+        if key not in result:
+            continue
+        value = result[key]
+        if feature.get("dtype") == "image":
+            result[key] = _image_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)
+    return result
+
+
+def _image_tensor(payload, feature, return_uint8=False):
+    if payload is None:
+        raise ValueError("LeRobot image feature contains a null frame.")
+    import numpy as np
+    import torch
+    try:
+        from PIL import Image, ImageOps
+    except ImportError as error:
+        raise ImportError(
+            "PaimonLeRobotDataset requires Pillow from "
+            "'pypaimon[lerobot]'.") from error
+
+    expected_shape = _feature_shape(feature, "image")
+    if len(expected_shape) != 3:
+        raise ValueError(
+            "LeRobot image feature must have three dimensions.")
+    names = feature.get("names") or []
+    payload_shape = expected_shape[1:] + expected_shape[:1] \
+        if names and names[0] in ("channel", "channels") \
+        else expected_shape
+    with Image.open(io.BytesIO(payload)) as image:
+        array = np.array(ImageOps.exif_transpose(image), copy=True)
+    if array.ndim == 2:
+        array = array[:, :, None]
+    if array.shape != payload_shape:
+        raise ValueError(
+            "LeRobot image payload has shape %s, expected %s."
+            % (array.shape, payload_shape))
+    normalize = array.dtype == np.uint8
+    tensor = torch.from_numpy(array).permute(2, 0, 1)
+    if normalize and return_uint8:
+        return tensor
+    # Preserve high-bit-depth and floating-point images in native units.
+    tensor = tensor.float()
+    return tensor.div_(255) if normalize else tensor
+
+
+def _normalize_index(index, size):
+    index = operator.index(index)
+    if index < 0:
+        index += size
+    if index < 0 or index >= size:
+        raise IndexError("PaimonLeRobotDataset index out of range")
+    return index
+
+
+def _positive_int(value, name):
+    try:
+        value = operator.index(value)
+    except TypeError as error:
+        raise ValueError("%s must be a positive integer." % name) from error
+    if isinstance(value, bool) or value <= 0:
+        raise ValueError("%s must be a positive integer." % name)
+    return value
diff --git a/paimon-python/pypaimon/multimodal/lerobot/metadata.py 
b/paimon-python/pypaimon/multimodal/lerobot/metadata.py
index 0f568f4769..b1130bbbb1 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/metadata.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/metadata.py
@@ -252,6 +252,9 @@ def _commit_metadata(
         snapshot_id = _append_arrow(tables[name], metadata[name + "_table"])
         _require_initial_snapshot(name, snapshot_id)
         component_snapshots.append((tables[name].identifier, snapshot_id))
+    frames_table = connection.catalog.get_table(frames_identifier)
+    frames_snapshot_id = _build_initial_btree(
+        frames_table, "index", frames_snapshot_id)
     # Tag the root last so a failed component tag does not expose a root tag.
     component_snapshots.append((frames_identifier, frames_snapshot_id))
     if tag_name is not None:
@@ -259,6 +262,21 @@ def _commit_metadata(
             _create_tag(connection.catalog, identifier, tag_name, snapshot_id)
 
 
+def _build_initial_btree(table, column, data_snapshot_id):
+    latest = table.snapshot_manager().get_latest_snapshot()
+    if latest is None or latest.id != data_snapshot_id:
+        raise RuntimeError(
+            "LeRobot initial import detected concurrent writes to %s before "
+            "building its %s BTree." % (table.identifier, column))
+    added = table.create_global_index(column, index_type="btree")
+    latest = table.snapshot_manager().get_latest_snapshot()
+    if added <= 0 or latest is None or latest.id != data_snapshot_id + 1:
+        raise RuntimeError(
+            "LeRobot initial import could not publish an isolated "
+            "%s BTree for %s." % (column, table.identifier))
+    return latest.id
+
+
 def create_lerobot_tag(connection, table_name, tag_name):
     """Tag the current snapshots of a LeRobot table group for training.
 
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py 
b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
index 196a795be3..5be17dc7b5 100644
--- a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
@@ -16,7 +16,9 @@
 
 import builtins
 from array import array
+import io
 import json
+import pickle
 import shutil
 import sys
 import tempfile
@@ -32,12 +34,20 @@ import pyarrow.fs as pafs
 import pyarrow.parquet as pq
 
 from pypaimon.catalog.catalog_exception import TableNotExistException
+from pypaimon.catalog.table_query_auth import TableQueryAuthResult
 import pypaimon.multimodal as pmm
 from pypaimon.common.identifier import Identifier
 from pypaimon.common.options import Options
 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 (
+    _arrow_rows,
+    _image_tensor,
+    _index_names,
+    _selected_episodes,
+    _torch_row,
+)
 from pypaimon.multimodal.lerobot.metadata import (
     _append_arrow_tables,
     _companion_identifier,
@@ -106,6 +116,15 @@ def _catalog_metadata(connection, name):
 
 class LeRobotValidationTest(unittest.TestCase):
 
+    def test_dataset_requires_supported_python(self):
+        with patch(
+                "pypaimon.multimodal.lerobot.dataset.sys.version_info",
+                (3, 9)), patch(
+                "pypaimon.multimodal.lerobot.dataset._load_dataset") as load:
+            with self.assertRaisesRegex(RuntimeError, "Python 3.10"):
+                pmm.PaimonLeRobotDataset(Mock())
+            load.assert_not_called()
+
     def test_metadata_json_preserves_nested_values(self):
         values = {
             "name": "机器人",
@@ -201,7 +220,7 @@ class LeRobotValidationTest(unittest.TestCase):
             table.add(frames)
             snapshots = connection.create_lerobot_tag("robot", "training")
             self.assertEqual({
-                "frames": 2, "info": 1, "stats": 1, "episodes": 1, "tasks": 1,
+                "frames": 3, "info": 1, "stats": 1, "episodes": 1, "tasks": 1,
             }, snapshots)
             table.add(frames)
             self.assertEqual(2, 
table.scan(tag_name="training").to_arrow().num_rows)
@@ -230,7 +249,7 @@ class LeRobotValidationTest(unittest.TestCase):
                 with self.assertRaisesRegex(RuntimeError, "tag failed"):
                     connection.create_lerobot_tag("robot", "retry")
             self.assertFalse(table.raw_table.tag_manager().tag_exists("retry"))
-            self.assertEqual(3, connection.create_lerobot_tag(
+            self.assertEqual(4, connection.create_lerobot_tag(
                 "robot", "retry")["frames"])
             self.assertEqual(3, 
table.scan(tag_name="retry").to_arrow().num_rows)
 
@@ -247,7 +266,7 @@ class LeRobotValidationTest(unittest.TestCase):
                 connection.load_from_lerobot("no_stats", remote, 
tag_name="ready")
             with self.assertRaises(TableNotExistException):
                 connection.get_table("no_stats__stats")
-            self.assertEqual({"frames": 1, "info": 1, "episodes": 1, "tasks": 
1},
+            self.assertEqual({"frames": 2, "info": 1, "episodes": 1, "tasks": 
1},
                              connection.create_lerobot_tag("no_stats", 
"training"))
 
     def test_self_contained_import_rejects_table_branches(self):
@@ -262,6 +281,386 @@ class LeRobotValidationTest(unittest.TestCase):
         self.assertEqual("db.name", identifier.get_database_name())
         self.assertEqual("robot.data__tasks", identifier.get_table_name())
 
+    def test_image_tensor_preserves_declared_channels(self):
+        try:
+            from PIL import Image
+            import torch
+        except ImportError as error:
+            self.skipTest(str(error))
+
+        cases = [
+            ("L", np.full((4, 5), 64, dtype=np.uint8),
+             [4, 5, 1], [64]),
+            ("RGB", np.tile(
+                np.array([32, 64, 96], dtype=np.uint8), (4, 5, 1)),
+             [4, 5, 3], [32, 64, 96]),
+            ("RGBA", np.tile(
+                np.array([32, 64, 96, 128], dtype=np.uint8), (4, 5, 1)),
+             [4, 5, 4], [32, 64, 96, 128]),
+        ]
+        for mode, values, shape, expected in cases:
+            with self.subTest(mode=mode):
+                output = io.BytesIO()
+                Image.fromarray(values, mode=mode).save(output, format="PNG")
+                feature = {"dtype": "image", "shape": shape}
+                tensor = _image_tensor(output.getvalue(), feature)
+                uint8_tensor = _image_tensor(
+                    output.getvalue(), feature, return_uint8=True)
+
+                self.assertEqual(torch.float32, tensor.dtype)
+                self.assertEqual(torch.uint8, uint8_tensor.dtype)
+                self.assertEqual(
+                    [shape[2], shape[0], shape[1]], list(tensor.shape))
+                self.assertEqual(list(tensor.shape), list(uint8_tensor.shape))
+                self.assertGreaterEqual(float(tensor.min()), 0.0)
+                self.assertLessEqual(float(tensor.max()), 1.0)
+                self.assertEqual(expected, uint8_tensor[:, 0, 0].tolist())
+                torch.testing.assert_close(
+                    tensor, uint8_tensor.float().div(255))
+
+        output = io.BytesIO()
+        Image.fromarray(cases[1][1], mode="RGB").save(output, format="PNG")
+        tensor = _image_tensor(output.getvalue(), {
+            "dtype": "image",
+            "shape": [3, 4, 5],
+            "names": ["channels", "height", "width"],
+        })
+        self.assertEqual([3, 4, 5], list(tensor.shape))
+
+        uint8_tensor = _image_tensor(
+            output.getvalue(),
+            {
+                "dtype": "image",
+                "shape": [3, 4, 5],
+                "names": ["channels", "height", "width"],
+            },
+            return_uint8=True,
+        )
+        self.assertEqual(torch.uint8, uint8_tensor.dtype)
+        self.assertEqual([32, 64, 96], uint8_tensor[:, 0, 0].tolist())
+        original = uint8_tensor.clone()
+        uint8_tensor.zero_()
+        reread = _image_tensor(
+            output.getvalue(),
+            {
+                "dtype": "image",
+                "shape": [3, 4, 5],
+                "names": ["channels", "height", "width"],
+            },
+            return_uint8=True,
+        )
+        self.assertTrue(torch.equal(original, reread))
+        self.assertNotEqual(uint8_tensor.data_ptr(), reread.data_ptr())
+
+        depth = np.array([
+            [0, 1000, 4095],
+            [8192, 32768, 65535],
+        ], dtype=np.uint16)
+        output = io.BytesIO()
+        Image.fromarray(depth).save(output, format="PNG")
+        feature = {
+            "dtype": "image",
+            "shape": [2, 3, 1],
+            "info": {
+                "is_depth_map": True,
+                "depth_unit": "mm",
+            },
+        }
+        tensor = _image_tensor(output.getvalue(), feature)
+        uint8_requested = _image_tensor(
+            output.getvalue(),
+            feature,
+            return_uint8=True,
+        )
+        self.assertEqual([1, 2, 3], list(tensor.shape))
+        self.assertEqual(torch.float32, tensor.dtype)
+        self.assertEqual(depth.astype(np.float32).tolist(), tensor[0].tolist())
+        self.assertEqual(torch.float32, uint8_requested.dtype)
+        self.assertTrue(torch.equal(tensor, uint8_requested))
+
+    def test_image_tensor_applies_exif_orientation(self):
+        try:
+            from PIL import Image
+            import torch
+        except ImportError as error:
+            self.skipTest(str(error))
+
+        output = io.BytesIO()
+        image = Image.fromarray(
+            np.arange(18, dtype=np.uint8).reshape(2, 3, 3), mode="RGB")
+        exif = image.getexif()
+        exif[274] = 6
+        image.save(output, format="JPEG", exif=exif)
+
+        tensor = _image_tensor(output.getvalue(), {
+            "dtype": "image",
+            "shape": [3, 2, 3],
+        }, return_uint8=True)
+        self.assertEqual(torch.uint8, tensor.dtype)
+        self.assertEqual([3, 3, 2], list(tensor.shape))
+
+    def test_dataset_uint8_getitem_matches_getitems(self):
+        try:
+            from PIL import Image
+            import torch
+        except ImportError as error:
+            self.skipTest(str(error))
+
+        features = {
+            "index": {"dtype": "int64", "shape": [1]},
+            "task_index": {"dtype": "int64", "shape": [1]},
+            "observation.left": {
+                "dtype": "image", "shape": [4, 5, 3]},
+            "observation.wrist": {
+                "dtype": "image", "shape": [4, 5, 1]},
+        }
+
+        def jpeg(mode, values):
+            output = io.BytesIO()
+            Image.fromarray(values, mode=mode).save(
+                output, format="JPEG", quality=100)
+            return output.getvalue()
+
+        rows = []
+        for index in range(2):
+            rows.append({
+                "index": index,
+                "task_index": 0,
+                "observation.left": jpeg(
+                    "RGB", np.full((4, 5, 3), 40 + index, np.uint8)),
+                "observation.wrist": jpeg(
+                    "L", np.full((4, 5), 80 + index, np.uint8)),
+            })
+
+        dataset = object.__new__(pmm.PaimonLeRobotDataset)
+        dataset._total_frames = 2
+        dataset.episodes = None
+        dataset._selected_ranges = None
+        dataset._delta_indices = {}
+        dataset._read_table = Mock()
+        dataset._frame_locator = Mock()
+        dataset._frame_locator.locate.return_value = ([], False)
+
+        def read_rows(indices, projection, splits=None, needs_filter=True):
+            return {
+                index: {
+                    key: value for key, value in rows[index].items()
+                    if key in projection
+                }
+                for index in indices
+            }
+        dataset._read_rows = read_rows
+        dataset._file_io = Mock()
+        dataset._image_keys = [
+            "observation.left", "observation.wrist"]
+        dataset.blob_parallelism = 1
+        dataset._task_names = ["task"]
+        dataset._subtask_names = None
+        dataset._features = features
+        dataset._projection = list(features)
+        dataset._delta_projection = None
+        dataset.return_uint8 = True
+        dataset.image_transforms = None
+
+        with patch(
+                "pypaimon.multimodal.lerobot.dataset._resolve_image_blobs"):
+            single = dataset[1]
+            batched = dataset.__getitems__([1, 0])
+            dataset.return_uint8 = False
+            normalized = dataset[1]
+        for key in dataset._image_keys:
+            self.assertEqual(torch.uint8, single[key].dtype)
+            self.assertTrue(torch.equal(single[key], batched[0][key]))
+            torch.testing.assert_close(
+                normalized[key], single[key].float().div(255))
+
+    def test_dataset_retries_image_fetch_and_decode_together(self):
+        try:
+            from PIL import Image
+            import torch
+        except ImportError as error:
+            self.skipTest(str(error))
+
+        output = io.BytesIO()
+        Image.fromarray(
+            np.full((4, 5, 3), 64, dtype=np.uint8), mode="RGB"
+        ).save(output, format="JPEG")
+        descriptor = b"serialized blob descriptor"
+        rows = [{
+            "index": 0,
+            "task_index": 0,
+            "observation.image": descriptor,
+        }]
+
+        dataset = object.__new__(pmm.PaimonLeRobotDataset)
+        dataset._total_frames = 1
+        dataset.episodes = None
+        dataset._selected_ranges = None
+        dataset._delta_indices = {}
+        dataset._read_table = Mock()
+        dataset._frame_locator = Mock()
+        dataset._frame_locator.locate.return_value = ([], False)
+
+        def read_rows(indices, projection, splits=None, needs_filter=True):
+            return {
+                index: {
+                    key: value for key, value in rows[index].items()
+                    if key in projection
+                }
+                for index in indices
+            }
+        dataset._read_rows = read_rows
+        dataset._file_io = Mock()
+        dataset._image_keys = ["observation.image"]
+        dataset.blob_parallelism = 1
+        dataset._task_names = {0: "task"}
+        dataset._subtask_names = None
+        dataset._features = {
+            "index": {"dtype": "int64", "shape": [1]},
+            "task_index": {"dtype": "int64", "shape": [1]},
+            "observation.image": {
+                "dtype": "image", "shape": [4, 5, 3]},
+        }
+        dataset._projection = list(dataset._features)
+        dataset._delta_projection = None
+        dataset.return_uint8 = True
+        dataset.image_transforms = None
+        sources = []
+
+        def resolve(_file_io, row_groups, image_keys, _parallelism):
+            sources.append(row_groups[0][0][image_keys[0]])
+            row_groups[0][0][image_keys[0]] = \
+                b"not a JPEG" if len(sources) == 1 else output.getvalue()
+
+        with patch(
+                "pypaimon.multimodal.lerobot.dataset._resolve_image_blobs",
+                side_effect=resolve) as fetch:
+            sample = dataset[0]
+
+        self.assertEqual(2, fetch.call_count)
+        self.assertEqual([descriptor, descriptor], sources)
+        self.assertEqual(torch.uint8, sample["observation.image"].dtype)
+        self.assertEqual([3, 4, 5], list(
+            sample["observation.image"].shape))
+
+    def test_dataset_return_uint8_requires_bool(self):
+        loaded = (
+            Mock(),
+            Mock(repo_id="pypaimon/invalid-return-uint8"),
+        )
+        with patch(
+                "pypaimon.multimodal.lerobot.dataset."
+                "_load_dataset",
+                return_value=loaded), patch(
+                "pypaimon.multimodal.lerobot.dataset.sys.version_info",
+                (3, 10)):
+            for invalid in (0, 1, None, "true"):
+                with self.subTest(return_uint8=invalid):
+                    with self.assertRaisesRegex(
+                            TypeError, "return_uint8 must be a boolean"):
+                        pmm.PaimonLeRobotDataset(
+                            Mock(), return_uint8=invalid)
+
+    def test_arrow_rows_converts_numeric_features_by_column(self):
+        try:
+            import torch
+        except ImportError as error:
+            self.skipTest(str(error))
+
+        features = {
+            "index": {"dtype": "int64", "shape": [1]},
+            "state": {"dtype": "float64", "shape": [3]},
+            "matrix": {"dtype": "float32", "shape": [2, 2]},
+            "reward": {"dtype": "float32", "shape": [1]},
+            "label": {"dtype": "string", "shape": [1]},
+            "image": {"dtype": "image", "shape": [1, 1, 3]},
+        }
+        arrow = pa.table({
+            "index": pa.array([0, 1], type=pa.int64()),
+            "state": pa.array(
+                [[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]],
+                type=pa.list_(pa.float64(), 3),
+            ),
+            "matrix": pa.array(
+                [
+                    [[1.0, 2.0], [3.0, 4.0]],
+                    [[5.0, 6.0], [7.0, 8.0]],
+                ],
+                type=pa.list_(pa.list_(pa.float32(), 2)),
+            ),
+            "reward": pa.array([0.0, 1.0], type=pa.float32()),
+            "label": ["pick", "place"],
+            "image": [b"left", b"right"],
+        })
+
+        rows = _arrow_rows(arrow, features)
+        converted = [
+            _torch_row(
+                {key: value for key, value in row.items() if key != "image"},
+                features,
+            )
+            for row in rows
+        ]
+
+        self.assertEqual(0, rows[0]["index"])
+        self.assertEqual(torch.float64, converted[0]["state"].dtype)
+        self.assertEqual([3], list(converted[0]["state"].shape))
+        self.assertEqual(torch.float32, converted[0]["matrix"].dtype)
+        self.assertEqual([2, 2], list(converted[0]["matrix"].shape))
+        self.assertEqual([], list(converted[0]["reward"].shape))
+        self.assertEqual("pick", converted[0]["label"])
+        self.assertEqual(b"right", rows[1]["image"])
+        converted[0]["state"].zero_()
+        self.assertEqual([4.0, 5.0, 6.0], converted[1]["state"].tolist())
+
+    def test_arrow_rows_rejects_lossy_numeric_narrowing(self):
+        try:
+            import torch  # noqa: F401
+        except ImportError as error:
+            self.skipTest(str(error))
+
+        cases = [
+            ("uint8", pa.int16(), [-1, 256]),
+            ("uint16", pa.int32(), [-1, 65536]),
+            ("uint32", pa.int64(), [-1, 4294967296]),
+            ("float16", pa.float32(), [-70000.0, 70000.0]),
+        ]
+        for dtype, arrow_type, values in cases:
+            with self.subTest(dtype=dtype):
+                table = pa.table({
+                    "value": pa.array(values, type=arrow_type),
+                })
+                features = {
+                    "value": {"dtype": dtype, "shape": [1]},
+                }
+                with self.assertRaisesRegex(
+                        ValueError, "outside the %s range" % dtype):
+                    _arrow_rows(table, features)
+
+    def test_selected_episodes_preserves_caller_order(self):
+        self.assertEqual([1, 0], _selected_episodes([1, 0], 2))
+        with self.assertRaisesRegex(ValueError, "duplicate"):
+            _selected_episodes([1, 1], 2)
+        with self.assertRaisesRegex(ValueError, "indices in"):
+            _selected_episodes([2], 2)
+
+    def test_metadata_indices_keep_integer_dtype(self):
+        import pandas as pd
+
+        for field in ("task_index", "subtask_index"):
+            with self.subTest(field=field):
+                values = pd.DataFrame(
+                    {
+                        field: np.array([0, 1], dtype=np.int64),
+                        "quality": [0.9, 0.8],
+                    },
+                    index=pd.Index(["pick", "place"], name="instruction"),
+                )
+                self.assertEqual(
+                    {0: "pick", 1: "place"},
+                    _index_names(values, field),
+                )
+
     def test_dataset_open_never_downloads_videos(self):
         calls = []
 
@@ -1212,7 +1611,7 @@ class LeRobotImportTest(unittest.TestCase):
                 tasks_table, tasks_arrow).to_pandas(),
         )
         self.assertEqual(
-            1,
+            2,
             table.raw_table.snapshot_manager().get_latest_snapshot().id,
         )
 
@@ -1331,6 +1730,10 @@ class LeRobotImportTest(unittest.TestCase):
                 subtasks_table, subtasks_arrow).to_pandas(),
         )
         self.assertIsNone(result)
+        dataset = pmm.PaimonLeRobotDataset(frames, tag_name="training")
+        self.assertEqual(["reach", "grasp"], list(
+            dataset.meta.subtasks.index))
+        self.assertEqual("reach", dataset[0]["subtask"])
         self.assertEqual(
             1,
             self.connection.catalog.get_tag(
@@ -1491,6 +1894,220 @@ class LeRobotImportTest(unittest.TestCase):
         self.assertEqual([], _catalog_rows(
             self.connection, "missing_tasks__info"))
 
+    def test_paimon_dataset_reads_lazy_batches_with_lerobot_metadata(self):
+        import torch
+        from torch.utils.data import DataLoader
+
+        result = self.connection.load_from_lerobot(
+            "training_data", self.image_source, batch_size=2,
+            tag_name="training")
+        self.assertIsNone(result)
+        table = self.connection.get_table("training_data")
+        dataset = pmm.PaimonLeRobotDataset(
+            table,
+            tag_name="training",
+            delta_timestamps={"action": [-0.1, 0.0, 0.1]},
+            blob_parallelism=3,
+        )
+
+        self.assertEqual("training", dataset.tag_name)
+        self.assertEqual("training", dataset.meta.revision)
+        self.assertEqual(5, len(dataset))
+        self.assertEqual(2, dataset.num_episodes)
+        self.assertIsNotNone(dataset.meta.stats)
+        self.assertTrue(all(
+            isinstance(feature["shape"], tuple)
+            for feature in dataset.features.values()
+        ))
+        self.assertEqual(["pick", "place"], list(dataset.meta.tasks.index))
+        episodes = dataset.meta.episodes
+        self.assertEqual(2, len(episodes))
+        self.assertEqual([0, 2], list(episodes["dataset_from_index"]))
+        self.assertEqual([2, 5], list(episodes["dataset_to_index"]))
+        self.assertFalse(any(
+            name.startswith("stats/")
+            for name in episodes.column_names))
+        self.assertFalse(hasattr(dataset, "index_mapping"))
+        frame_indexes = _catalog_rows(
+            self.connection, "training_data$table_indexes")
+        self.assertEqual(
+            {"btree", "index"},
+            {frame_indexes[0]["index_type"],
+             frame_indexes[0]["index_field_name"]},
+        )
+
+        from pypaimon.multimodal.blob_read import fetch_blob_bodies
+        from pypaimon.read.table_scan import TableScan
+        original_plan = TableScan.plan
+        scan_calls = []
+
+        def counted_plan(scan):
+            scan_calls.append(scan)
+            return original_plan(scan)
+
+        with patch.object(TableScan, "plan", new=counted_plan), patch.object(
+                dataset, "_read_rows",
+                wraps=dataset._read_rows) as read, patch(
+                "pypaimon.multimodal.blob_read.fetch_blob_bodies",
+                wraps=fetch_blob_bodies) as fetch:
+            last, first = dataset.__getitems__([4, 0])
+            scanner = dataset._frame_locator._scanner
+            dataset._frame_locator.locate([2])
+        self.assertEqual(0, len(scan_calls))
+        self.assertIs(scanner, dataset._frame_locator._scanner)
+        self.assertEqual(1, read.call_count)
+        self.assertEqual([0, 1, 3, 4], read.call_args.args[0])
+        self.assertFalse(read.call_args.args[3])
+        self.assertEqual(1, fetch.call_count)
+        self.assertEqual(3, fetch.call_args.args[3])
+        self.assertEqual("place", last["task"])
+        self.assertEqual([3, 8, 10], list(last["observation.image"].shape))
+        self.assertAlmostEqual(
+            100.0 / 255.0,
+            float(last["observation.image"].mean()),
+            places=5,
+        )
+        self.assertEqual(
+            [[1.0, -1.0], [2.0, -2.0], [2.0, -2.0]],
+            last["action"].tolist(),
+        )
+        self.assertEqual([False, False, True],
+                         last["action_is_pad"].tolist())
+        self.assertEqual([True, False, False],
+                         first["action_is_pad"].tolist())
+
+        restored_locator = pickle.loads(pickle.dumps(
+            dataset._frame_locator))
+        with patch.object(TableScan, "plan", new=counted_plan):
+            restored_splits, restored_needs_filter = \
+                restored_locator.locate([2])
+        self.assertEqual(0, len(scan_calls))
+        self.assertTrue(restored_splits)
+        self.assertFalse(restored_needs_filter)
+        restored_locator.close()
+
+        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)
+
+        uint8_dataset = pmm.PaimonLeRobotDataset(
+            table,
+            tag_name="training",
+            return_uint8=True,
+        )
+        uint8_sample = uint8_dataset[4]
+        uint8_batch = uint8_dataset.__getitems__([4, 0])
+        uint8_image = uint8_sample["observation.image"]
+        self.assertEqual("torch.uint8", str(uint8_image.dtype))
+        self.assertEqual([3, 8, 10], list(uint8_image.shape))
+        self.assertEqual(100.0, float(uint8_image.float().mean()))
+        self.assertTrue(torch.equal(
+            uint8_image, uint8_batch[0]["observation.image"]))
+        torch.testing.assert_close(
+            last["observation.image"], uint8_image.float().div(255))
+
+        reordered = pmm.PaimonLeRobotDataset(
+            table,
+            tag_name="training",
+            episodes=[1, 0],
+        )
+        self.assertEqual([1, 0], reordered.episodes)
+        self.assertEqual(5, len(reordered))
+        self.assertEqual(0, int(reordered[0]["episode_index"]))
+        self.assertEqual(1, int(reordered[-1]["episode_index"]))
+
+        table.add(pa.Table.from_pylist([{
+            "index": 999,
+            "episode_index": 99,
+            "frame_index": 0,
+            "timestamp": 0.0,
+            "task_index": 0,
+            "observation.state": [0.0, 0.0, 0.0],
+            "observation.matrix": [[0.0, 0.0], [0.0, 0.0]],
+            "action": [0.0, 0.0],
+            "reward": 0.0,
+            "observation.image": _image_bytes(
+                np.zeros((8, 10, 3), dtype=np.uint8), self.temp_dir),
+        }], schema=_target_schema(table.raw_table)))
+        self.assertEqual(6, table.scan().to_arrow().num_rows)
+
+        episode = pmm.PaimonLeRobotDataset(
+            table,
+            tag_name="training",
+            episodes=[1],
+        )
+        self.assertEqual(3, len(episode))
+        self.assertEqual(1, episode.num_episodes)
+        self.assertEqual(2, int(episode[0]["index"]))
+
+    def test_paimon_dataset_rejects_unavailable_frame_index(self):
+        self.connection.load_from_lerobot(
+            "missing_frame_index", self.image_source)
+        dataset = pmm.PaimonLeRobotDataset(
+            self.connection.get_table("missing_frame_index"))
+
+        with patch.object(
+                dataset._frame_locator, "_index_scanner",
+                return_value=None):
+            with self.assertRaisesRegex(
+                    RuntimeError, "requires a readable global index"):
+                dataset[0]
+
+        with patch.object(
+                dataset._frame_locator, "_index_scanner",
+                side_effect=OSError("index unavailable")):
+            with self.assertRaisesRegex(
+                    RuntimeError, "Failed to open the Paimon global index"):
+                dataset[0]
+
+        scanner = Mock()
+        scanner.scan_with_coverage.side_effect = OSError("query failed")
+        with patch.object(
+                dataset._frame_locator, "_index_scanner",
+                return_value=scanner):
+            with self.assertRaisesRegex(
+                    RuntimeError, "Failed to query the Paimon global index"):
+                dataset[0]
+
+    def test_paimon_dataset_rejects_query_authorization(self):
+        self.connection.load_from_lerobot(
+            "authorized_frames", self.image_source)
+        table = self.connection.get_table("authorized_frames")
+        auth_results = [
+            TableQueryAuthResult([json.dumps({
+                "kind": "LEAF",
+                "transform": {
+                    "name": "FIELD_REF",
+                    "fieldRef": {"name": "episode_index"},
+                },
+                "function": "EQUAL",
+                "literals": [0],
+            })], None),
+            TableQueryAuthResult(
+                None, {"observation.state": json.dumps({"name": "NULL"})}),
+        ]
+
+        for auth in auth_results:
+            with self.subTest(auth=auth.__dict__):
+                def query_auth(unused_options, identifier):
+                    if identifier == table.raw_table.identifier:
+                        return lambda unused_projection: auth
+                    return None
+
+                with patch.object(
+                        table.raw_table.catalog_environment,
+                        "table_query_auth",
+                        side_effect=query_auth):
+                    with self.assertRaisesRegex(
+                            ValueError, "query authorization"):
+                        pmm.PaimonLeRobotDataset(table)
+
     def test_oss_source_streams_parquet_and_preserves_episodes(self):
         source = "oss://source-bucket/robot-images"
         source_file_io = _RemoteLeRobotFileIO(self.image_source, source)
@@ -1606,7 +2223,7 @@ class LeRobotImportTest(unittest.TestCase):
 
         self.assertTrue(lost[0])
         self.assertIsNone(result)
-        self.assertEqual(1, self.connection.catalog.get_tag(
+        self.assertEqual(2, self.connection.catalog.get_tag(
             self.connection._identifier("tag_response_loss"),
             "training").snapshot.id)
 
diff --git a/paimon-python/setup.py b/paimon-python/setup.py
index e2cfcc33a8..0a08d7acca 100644
--- a/paimon-python/setup.py
+++ b/paimon-python/setup.py
@@ -222,6 +222,7 @@ LEROBOT_DEPENDENCIES = [
     # by LeRobot's media dependencies.
     'datasets>=4,<4.1; python_version>="3.10"',
     'pandas>=2.2.2,<3; python_version>="3.10"',
+    'torch>=2.3; python_version>="3.10"',
     'lerobot>=0.4.4,<0.5; python_version>="3.10"',
 ]
 

Reply via email to