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 d32b5c9a25 [python] Map LeRobot tensor features to ARRAY (#10758)
d32b5c9a25 is described below
commit d32b5c9a25d89ffd3668a8192ff6d064e681d019
Author: XiaoHongbo <[email protected]>
AuthorDate: Sun Oct 25 22:08:51 2026 +0800
[python] Map LeRobot tensor features to ARRAY (#10758)
---
docs/docs/pypaimon/lerobot.md | 5 +-
.../pypaimon/multimodal/lerobot/dataset.py | 9 ++++
.../pypaimon/multimodal/lerobot/schema.py | 18 ++++---
.../pypaimon/tests/multimodal_lerobot_ray_test.py | 2 +-
.../pypaimon/tests/multimodal_lerobot_test.py | 56 ++++++++++++++++------
5 files changed, 63 insertions(+), 27 deletions(-)
diff --git a/docs/docs/pypaimon/lerobot.md b/docs/docs/pypaimon/lerobot.md
index caca02fee7..82c81590c7 100644
--- a/docs/docs/pypaimon/lerobot.md
+++ b/docs/docs/pypaimon/lerobot.md
@@ -121,8 +121,9 @@ unchanged snapshots, or choose a new name after repairing
the group. Existing
tags are never moved to different snapshots. Retain or delete component tags
together, and keep writers paused until the call returns.
-Scalars map to scalar types, vectors to `VECTOR`, higher-rank tensors to nested
-`ARRAY`, and images to `BLOB`. Images keep their compressed bytes.
+Scalars map to scalar types, numeric tensors to `ARRAY` (nested for higher
ranks),
+and images to `BLOB`. Tensor shapes remain in the feature metadata; images keep
+their compressed bytes. Previously imported `VECTOR` features remain readable.
Video features map to `BLOB`. Each MP4 is copied once per aligned file group
and indexed for range
reads. Imports require a bucket-unaware table and check rolling before each
Episode. Use
diff --git a/paimon-python/pypaimon/multimodal/lerobot/dataset.py
b/paimon-python/pypaimon/multimodal/lerobot/dataset.py
index 8df8bd50e3..7b0892314a 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/dataset.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/dataset.py
@@ -314,6 +314,15 @@ class PaimonDatasetReader(ABC):
_validate_component_metadata(
self._features, self._total_tasks, tasks, subtasks)
source_schema = _schema_from_info(info)
+ # Previously imported tensors use VECTOR at the innermost dimension.
+ legacy_schema = _schema_from_info(info, legacy_vectors=True)
+ source_schema = pa.schema([
+ legacy_schema.field(field.name)
+ if target_schema.get_field_index(field.name) >= 0
+ and target_schema.field(field.name).type ==
legacy_schema.field(field.name).type
+ else field
+ for field in source_schema
+ ])
if validate_metadata:
_validate_lerobot_schema(
source_schema, target_schema, self.repo_id)
diff --git a/paimon-python/pypaimon/multimodal/lerobot/schema.py
b/paimon-python/pypaimon/multimodal/lerobot/schema.py
index 188127a14d..af47f4c543 100644
--- a/paimon-python/pypaimon/multimodal/lerobot/schema.py
+++ b/paimon-python/pypaimon/multimodal/lerobot/schema.py
@@ -55,13 +55,13 @@ def _require_v3(info, source):
% (source, version or None))
-def _schema_from_info(info):
+def _schema_from_info(info, legacy_vectors=False):
features = info.get("features")
if not isinstance(features, dict) or not features:
raise ValueError("LeRobot metadata features must be a non-empty
object.")
return pa.schema([
- _feature_field(name, feature)
+ _feature_field(name, feature, legacy_vectors)
for name, feature in features.items()
])
@@ -124,7 +124,7 @@ def _description(field):
return field.metadata.get(b"description", b"").decode("utf-8")
-def _feature_field(name, feature):
+def _feature_field(name, feature, legacy_vectors=False):
if not isinstance(feature, dict):
raise ValueError(
"LeRobot feature %s metadata must be an object." % name)
@@ -143,7 +143,7 @@ def _feature_field(name, feature):
if pa.types.is_string(scalar_type) and shape not in ((), (1,)):
raise ValueError(
"LeRobot string feature %s must be scalar." % name)
- arrow_type = _tensor_type(scalar_type, shape)
+ arrow_type = _tensor_type(scalar_type, shape, legacy_vectors)
description = "LeRobot dtype=%s, shape=%s, names=%s" % (
dtype,
json.dumps(list(shape), separators=(",", ":")),
@@ -181,12 +181,10 @@ def _feature_shape(feature, name):
return result
-def _tensor_type(scalar_type, shape):
+def _tensor_type(scalar_type, shape, legacy_vectors=False):
if shape in ((), (1,)):
return scalar_type
- if len(shape) == 1:
- return pa.list_(scalar_type, shape[0])
- result = pa.list_(scalar_type, shape[-1])
- for unused_size in reversed(shape[1:-1]):
+ result = pa.list_(scalar_type, shape[-1]) if legacy_vectors else
scalar_type
+ for unused_size in (shape[:-1] if legacy_vectors else shape):
result = pa.list_(result)
- return pa.list_(result)
+ return result
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_ray_test.py
b/paimon-python/pypaimon/tests/multimodal_lerobot_ray_test.py
index cba6f43a8f..bfd3225014 100644
--- a/paimon-python/pypaimon/tests/multimodal_lerobot_ray_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_ray_test.py
@@ -402,7 +402,7 @@ class LeRobotRayImportTest(unittest.TestCase):
table_name, self.source, parallelism=2)
before = json.loads(trace.read_text())
self.assertTrue(any(name.endswith(".parquet") for name in
before), before)
- self.assertTrue(any(".vector." in name for name in before),
before)
+ self.assertFalse(any(".vector." in name for name in before),
before)
self.assertTrue(any(name.endswith(".blob") for name in
before), before)
table = self.conn.get_table(table_name).raw_table
self.assertIsNone(table.snapshot_manager().get_latest_snapshot())
diff --git a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
index 331a7ef57c..597e683d25 100644
--- a/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
+++ b/paimon-python/pypaimon/tests/multimodal_lerobot_test.py
@@ -2073,9 +2073,9 @@ class LeRobotValidationTest(unittest.TestCase):
schema.names,
)
self.assertEqual(pa.int32(), schema.field("scalar").type)
- self.assertEqual(pa.list_(pa.float32(), 3),
schema.field("vector").type)
+ self.assertEqual(pa.list_(pa.float32()), schema.field("vector").type)
self.assertEqual(
- pa.list_(pa.list_(pa.float64(), 3)),
+ pa.list_(pa.list_(pa.float64())),
schema.field("tensor").type,
)
self.assertEqual(pa.large_binary(), schema.field("image").type)
@@ -2095,6 +2095,20 @@ class LeRobotValidationTest(unittest.TestCase):
_schema_from_info(info).field("camera").type,
)
+ def test_tensor_schema_uses_arrays_and_preserves_shape(self):
+ for shape in ([3], [2, 3], [2, 1, 3]):
+ with self.subTest(shape=shape):
+ field = _schema_from_info({"features": {
+ "state": {"dtype": "float32", "shape": shape}
+ }}).field("state")
+ expected = pa.float32()
+ for unused_size in shape:
+ expected = pa.list_(expected)
+ self.assertEqual(expected, field.type)
+ self.assertIn(
+ "shape=" + json.dumps(shape, separators=(",", ":")),
+ field.metadata[b"description"].decode("utf-8"))
+
def test_existing_schema_preserves_lerobot_feature_contract(self):
source = _schema_from_info({
"features": {
@@ -2139,7 +2153,7 @@ class LeRobotValidationTest(unittest.TestCase):
),
"array": pa.field(
"vector",
- pa.list_(pa.float32()),
+ pa.list_(pa.float64()),
nullable=False,
metadata=source.field("vector").metadata,
),
@@ -2803,7 +2817,7 @@ class LeRobotValidationTest(unittest.TestCase):
for field in table.raw_table.fields
}
self.assertEqual(
- "VECTOR<FLOAT, 3> NOT NULL",
+ "ARRAY<FLOAT> NOT NULL",
field_types["observation.state"],
)
rows = table.scan().select([
@@ -2919,13 +2933,7 @@ class LeRobotValidationTest(unittest.TestCase):
and ".vector." not in file.file_name
),
)
- self.assertEqual(
- [2, 3],
- sorted(
- file.row_count for file in files
- if ".vector." in file.file_name
- ),
- )
+ self.assertFalse(any(".vector." in file.file_name for file in
files))
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
@@ -3682,6 +3690,26 @@ class LeRobotImportTest(unittest.TestCase):
else:
self.assertEqual(original[key], reread[key])
+ def test_training_reads_previously_imported_vector_features(self):
+ import torch
+
+ with patch("pypaimon.multimodal.lerobot.api._schema_from_info",
+ side_effect=lambda info: _schema_from_info(info,
legacy_vectors=True)):
+ self.connection.load_from_lerobot(
+ "legacy_vectors", self.image_source, batch_size=2)
+ table = self.connection.get_table("legacy_vectors")
+ self.assertEqual("VECTOR<FLOAT, 3> NOT NULL", str(next(
+ field.type for field in table.raw_table.fields
+ if field.name == "observation.state")))
+ dataset = pmm.PaimonLeRobotDataset(table)
+ try:
+ row = dataset[0]
+ self.assertEqual((3,), tuple(row["observation.state"].shape))
+ self.assertEqual((2, 2), tuple(row["observation.matrix"].shape))
+ self.assertTrue(torch.equal(row["action"], torch.tensor([0.0,
0.0])))
+ finally:
+ dataset.close()
+
def test_import_infers_schema_and_preserves_episodes(self):
import pandas as pd
@@ -3693,12 +3721,12 @@ class LeRobotImportTest(unittest.TestCase):
table = self.connection.get_table("robot_data")
schema = table.raw_table.fields
types = {field.name: str(field.type) for field in schema}
- self.assertEqual("VECTOR<FLOAT, 3> NOT NULL",
types["observation.state"])
+ self.assertEqual("ARRAY<FLOAT> NOT NULL", types["observation.state"])
self.assertEqual(
- "ARRAY<VECTOR<FLOAT, 2>> NOT NULL",
+ "ARRAY<ARRAY<FLOAT>> NOT NULL",
types["observation.matrix"],
)
- self.assertEqual("VECTOR<FLOAT, 2> NOT NULL", types["action"])
+ self.assertEqual("ARRAY<FLOAT> NOT NULL", types["action"])
self.assertEqual("FLOAT NOT NULL", types["timestamp"])
self.assertEqual("BIGINT NOT NULL", types["episode_index"])
self.assertEqual("BLOB NOT NULL", types["observation.image"])