File size: 10,762 Bytes
849e0ae
 
edae745
849e0ae
edae745
 
 
849e0ae
 
edae745
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
849e0ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
edae745
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
import json
import tempfile
import unittest
from pathlib import Path

import numpy as np

from app.lerobot_export import (_ensure_slam_pose, _ensure_observation_state,
                                _force_matrix_semantics, _write_stats)
from app.umi_slam_action import poses_from_trajectory


def _sample(t: float, x: float, z: float) -> list[float]:
    """一组 slam_trajectory 样本:[t, x, y, z, qx, qy, qz, qw]。"""
    return [float(t), float(x), 0.0, float(z), 0.0, 0.0, 0.0, 1.0]


def _rows(episode: int, count: int, *, t0: float,
          slam_offset: float = 0.0) -> list[dict]:
    """造一集 rows:每帧两个 slam 样本,位姿随帧号线性推进。"""
    rows = []
    for index in range(count):
        stamp = t0 + index / 30.0
        rows.append({
            "episode_index": episode,
            "timestamp": stamp,
            "observation.slam_trajectory": [
                _sample(stamp - slam_offset, index * 0.01, index * 0.002),
                _sample(stamp - slam_offset + 0.01,
                        index * 0.01 + 0.001, index * 0.002),
            ],
        })
    return rows


class EnsureSlamPoseTests(unittest.TestCase):
    def test_derives_column_and_matches_direct_reconstruction(self):
        """整列缺失时补出 slam_pose,且值与单独调用重建函数逐值一致。"""
        rows = _rows(0, 40, t0=100.0)
        self.assertEqual(_ensure_slam_pose(rows), "observation.slam_pose")

        self.assertTrue(all(len(row["observation.slam_pose"]) == 7
                            for row in rows))
        expected = poses_from_trajectory(
            [row["observation.slam_trajectory"] for row in rows],
            np.array([row["timestamp"] for row in rows]), len(rows))
        actual = np.array([row["observation.slam_pose"] for row in rows])
        self.assertTrue(np.allclose(actual, expected, atol=1e-12))

    def test_episodes_are_fitted_independently(self):
        """时间基映射必须每集单独拟合,否则跨集求差会把原点差异当运动。

        两集的 slam 时钟相差一个很大的偏移量;若混在一起拟合,时间轴被拉
        成两段,重建值会偏离该集单独重建的结果。
        """
        first = _rows(0, 30, t0=100.0, slam_offset=0.0)
        second = _rows(1, 30, t0=500.0, slam_offset=400.0)
        rows = first + second
        self.assertEqual(_ensure_slam_pose(rows), "observation.slam_pose")

        for group, offset in ((first, 0.0), (second, 400.0)):
            expected = poses_from_trajectory(
                [row["observation.slam_trajectory"] for row in group],
                np.array([row["timestamp"] for row in group]), len(group),
            )
            actual = np.array([row["observation.slam_pose"] for row in group])
            self.assertTrue(np.allclose(actual, expected, atol=1e-12))

    def test_existing_column_is_left_untouched(self):
        """旧批两列都有 —— 重导时不得覆盖采集端原始 slam_pose。"""
        rows = _rows(0, 10, t0=100.0)
        original = [9.0] * 7
        for row in rows:
            row["observation.slam_pose"] = list(original)
        self.assertIsNone(_ensure_slam_pose(rows))
        self.assertTrue(all(row["observation.slam_pose"] == original
                            for row in rows))

    def test_skips_batches_without_trajectory(self):
        """手套 / D435 批次没有 slam_trajectory —— 不得凭空写入该列。"""
        rows = [{"episode_index": 0, "timestamp": 0.0,
                 "observation.tactile.left": [0.0] * 256}]
        self.assertIsNone(_ensure_slam_pose(rows))
        self.assertNotIn("observation.slam_pose", rows[0])

    def test_missing_timestamp_does_not_break_reconstruction(self):
        """时间戳缺帧(None)不能把 np.asarray 推断成 object 而抛异常。"""
        rows = _rows(0, 20, t0=200.0)
        rows[5]["timestamp"] = None
        self.assertEqual(_ensure_slam_pose(rows), "observation.slam_pose")
        self.assertTrue(all(np.all(np.isfinite(row["observation.slam_pose"]))
                            for row in rows))

    def test_derived_pose_feeds_observation_state(self):
        """串联顺序:补出的 slam_pose 要能继续供 state 派生使用。

        新批里 ep72/ep73 两列都缺,只补 slam_pose 而不让 state 跟上,导出
        产物仍然缺 observation.state,ACT 依旧 KeyError。
        """
        rows = _rows(0, 25, t0=300.0)
        _ensure_slam_pose(rows)
        self.assertEqual(_ensure_observation_state(rows), "observation.state")
        self.assertTrue(all(len(row["observation.state"]) == 4 for row in rows))
        # 位置分量是本帧相对本集第一帧的偏移 —— 首帧必须为原点
        self.assertTrue(np.allclose(rows[0]["observation.state"][:3], 0.0))


class ForceMatrixSemanticsTests(unittest.TestCase):
    """源 info.json → 导出 features 的力矩阵语义元数据搬运。

    这些字段(scale/encoding)决定数值怎么还原:力矩阵存的是「放大 scale 倍
    再取行内差分」的整数,少任何一个,产物里的力矩阵都无法解读。它们只写在
    info.json 里,parquet 本身不带,所以导出必须显式搬运。
    """

    def _root(self, features: dict) -> Path:
        root = Path(tempfile.mkdtemp())
        (root / "meta").mkdir(parents=True)
        (root / "meta" / "info.json").write_text(
            json.dumps({"features": features}), encoding="utf-8")
        return root

    def test_carries_scale_and_encoding(self):
        root = self._root({
            "observation.gripper_left_force_matrix": {
                "dtype": "int16", "shape": [250, 250, 3],
                "encoding": "row_diff_quantized", "scale": 100,
                "names": ["fx", "fy", "fz"], "units": ["mN", "mN", "mN"],
            },
        })
        self.assertEqual(_force_matrix_semantics(root), {
            "observation.gripper_left_force_matrix": {
                "encoding": "row_diff_quantized", "scale": 100,
                "names": ["fx", "fy", "fz"], "units": ["mN", "mN", "mN"],
            },
        })

    def test_does_not_carry_shape_or_dtype(self):
        """shape/dtype 由导出自己按实际写入值声明,不能被源覆盖。

        源声明 shape [250,250,3] 是逻辑形状,而 parquet 里存的是展平的
        187500 长列表;照搬声明会让官方加载器按 3 维 reshape 而失败。
        """
        root = self._root({
            "observation.gripper_left_force_matrix": {
                "dtype": "int16", "shape": [250, 250, 3], "scale": 100,
            },
        })
        carried = _force_matrix_semantics(root)["observation.gripper_left_force_matrix"]
        self.assertNotIn("shape", carried)
        self.assertNotIn("dtype", carried)

    def test_ns_columns_keep_their_encoding(self):
        """_ns 采集时刻列同样靠 encoding 声明时基,int64 裸列无法解读。"""
        root = self._root({
            "observation.gripper_left_force_matrix_ns": {
                "dtype": "int64", "encoding": "host_monotonic_ns",
            },
        })
        self.assertEqual(
            _force_matrix_semantics(root)["observation.gripper_left_force_matrix_ns"],
            {"encoding": "host_monotonic_ns"})

    def test_ignores_unrelated_features(self):
        root = self._root({
            "observation.gripper_state": {"dtype": "float32", "scale": 100},
            "action": {"dtype": "float32"},
        })
        self.assertEqual(_force_matrix_semantics(root), {})

    def test_missing_or_broken_source_degrades_to_empty(self):
        """源元数据读不到时返回空字典 —— 导出照常进行,不因缺元数据中断。"""
        self.assertEqual(_force_matrix_semantics(Path("/nonexistent/nope")), {})
        broken = Path(tempfile.mkdtemp())
        (broken / "meta").mkdir()
        (broken / "meta" / "info.json").write_text("{not json", encoding="utf-8")
        self.assertEqual(_force_matrix_semantics(broken), {})


class WriteStatsDataSourceTests(unittest.TestCase):
    """stats.json 的两种取数路径必须产出同一份统计量。

    ``_write_stats`` 原先一律把整个 data/ 目录从磁盘重读一遍;调用方手里
    其实就有刚写完的 arrow table,传进去可省掉这次全量往返。但两条路径的
    DataFrame 必须逐列同 dtype、同值 —— 否则归一化统计会悄悄漂移,而训练
    侧不会报错,只会训出偏差。
    """

    def _build(self, root: Path):
        """造一份含标量列/定长向量列/字符串列的 data/,返回写入的 table。"""
        import pyarrow as pa
        import pyarrow.parquet as pq

        rows = [
            {"frame_index": i,
             "timestamp": i / 30.0,                    # 标量浮点
             "observation.state": float(i % 3),        # 标量浮点
             "action": [float(i), float(i) * 2, -1.0],  # 定长向量(走逐维 stats)
             "gesture": "open" if i % 2 else ""}       # 字符串(应被跳过)
            for i in range(20)
        ]
        table = pa.Table.from_pylist(rows)
        data_dir = root / "data" / "chunk-000"
        data_dir.mkdir(parents=True, exist_ok=True)
        pq.write_table(table, data_dir / "file-000.parquet")
        return table

    def test_table_path_matches_disk_path(self):
        disk_root = Path(tempfile.mkdtemp())
        table_root = Path(tempfile.mkdtemp())
        table = self._build(disk_root)
        self._build(table_root)

        _write_stats(disk_root)                    # 旧路径:从磁盘重读
        _write_stats(table_root, None, table)      # 新路径:直接用写入的表

        from_disk = json.loads((disk_root / "meta" / "stats.json").read_text())
        from_table = json.loads((table_root / "meta" / "stats.json").read_text())
        self.assertEqual(from_disk, from_table)
        # 别退化成"两边都空"的假通过
        self.assertIn("observation.state", from_disk)
        self.assertIn("action", from_disk)

    def test_string_columns_are_not_given_numeric_stats(self):
        """官方加载器会把 stats 每一项转张量,字符串列必须跳过。"""
        root = Path(tempfile.mkdtemp())
        table = self._build(root)
        _write_stats(root, None, table)
        stats = json.loads((root / "meta" / "stats.json").read_text())
        self.assertNotIn("gesture", stats)

    def test_missing_data_dir_degrades_quietly(self):
        """没有 data/ 时直接返回,不抛异常(旧路径原有的容错)。"""
        root = Path(tempfile.mkdtemp())
        _write_stats(root)
        self.assertFalse((root / "meta" / "stats.json").exists())


if __name__ == "__main__":
    unittest.main()