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"])

Reply via email to