File size: 10,717 Bytes
bb59211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""采集层的两条静默失效,各上一把锁。

它们都不报错、报告照常生成,只是内容是错的:

  1. **非首集读出空帧** —— canonical 是一集一文件,曾经取 ``parquets[0]`` 再按
     ``episode_index`` 列筛行,于是除第 0 集外全筛成空。报告退化成
     ``empty_report``(``episode.status = ERROR``),在"数据不对就拦回人工审核"
     的链路里表现为**每一集都被卡住**。
  2. **触觉阵列被截断** —— ``_CHANNEL_WIDTH`` 没有 ``tactile`` 项,落到 3 的
     兜底值,16×16=256 个感应点被静默截成 3 个,检查项只看得到 1%。

外加两套命名的去重(``observation.tactile.*`` 与 ``observation.*_glove``)。

造的是最小 fixture,不依赖真实数据、不需要 cv2。

    pytest tests/test_cleaning_collect.py
    python3 tests/test_cleaning_collect.py
"""
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))  # noqa: E402

import numpy as np  # noqa: E402
import pandas as pd  # noqa: E402

from app.processing.cleaning import collect  # noqa: E402


# ── fixture ─────────────────────────────────────────────────
def _canonical_batch(root: Path, episodes) -> Path:
    """canonical 布局:一集一文件。``episodes`` 是 {集号: 行数}。"""
    (root / "data" / "chunk-000").mkdir(parents=True, exist_ok=True)
    (root / "meta").mkdir(exist_ok=True)
    for index, rows in episodes.items():
        pd.DataFrame({
            "episode_index": np.full(rows, index, dtype="int64"),
            "frame_index": np.arange(rows, dtype="int64"),
            "timestamp": np.arange(rows, dtype=float) / 30.0,
        }).to_parquet(root / "data" / "chunk-000" / f"episode_{index:06d}.parquet")
    return root


def _tactile_batch(root: Path, *, columns, frames=8, width=256) -> Path:
    """带触觉阵列的批次。``columns`` 决定用哪套命名。"""
    (root / "data" / "chunk-000").mkdir(parents=True, exist_ok=True)
    (root / "meta").mkdir(exist_ok=True)
    data = {"timestamp": np.arange(frames, dtype=float) / 30.0}
    for name in columns:
        data[name] = [list(np.full(width, float(i))) for i in range(frames)]
    pd.DataFrame(data).to_parquet(root / "data" / "chunk-000" / "episode_000000.parquet")
    return root


# ── 定位这一集的数据文件 ─────────────────────────────────────
def test_picks_the_file_for_the_requested_episode(tmp_path):
    """★ 核心回归:非首集必须读到**自己**那个文件,而不是空帧。"""
    batch = _canonical_batch(tmp_path / "b", {0: 10, 1: 20, 7: 30})

    for index, rows in ((0, 10), (1, 20), (7, 30)):
        path, filter_rows = collect.resolve_episode_data(batch, index)
        assert path is not None and path.name == f"episode_{index:06d}.parquet"
        assert filter_rows is False, "canonical 一集一文件,不该再按列筛行"
        assert len(collect.load_rows(path)) == rows


def test_the_original_bug_is_actually_a_bug(tmp_path):
    """把当初的写法钉在这里:它**不报错**,只是静默返回空帧。

    当初是「取 ``parquets[0]`` 再按 ``episode_index`` 列筛行」。canonical 每一集
    一个文件,第 0 个文件里只有第 0 集的行 —— 拿它去筛第 3 集得到 0 行。
    """
    batch = _canonical_batch(tmp_path / "b", {0: 10, 3: 25})
    first = collect.resolve_episode_data(batch, 0)[0]

    assert len(collect.load_rows(first, 3)) == 0        # ← 当初的 bug
    assert len(collect.load_rows(first, 0)) == 10       # 只有筛自己那集才对

    # 现在的做法:选对文件,且**不再按列筛行**
    third, filter_rows = collect.resolve_episode_data(batch, 3)
    assert filter_rows is False
    assert len(collect.load_rows(third, None)) == 25


def test_gather_evidence_reads_the_requested_episode(tmp_path):
    """★ 端到端那把锁。

    上面两条直接测 ``resolve_episode_data``,**测不到接线**:把
    ``gather_evidence`` 改回"取第一个文件 + 按列筛行",它们照样全绿。而当初
    出 bug 的正是接线那一处。这里断言的是采集**结果**。
    """
    batch = _canonical_batch(tmp_path / "b", {0: 10, 3: 25, 9: 40})

    for index, rows in ((0, 10), (3, 25), (9, 40)):
        evidence = collect.gather_evidence(batch, episode_index=index)
        assert evidence.rows == rows, (
            f"episode {index} 读到 {evidence.rows} 行、应为 {rows} 行 —— "
            f"多半是又退回了「取第一个文件再按列筛行」")
        assert evidence.notes.get("reason") is None, "退化成空报告了"


def test_missing_episode_returns_none(tmp_path):
    batch = _canonical_batch(tmp_path / "b", {0: 5})
    assert collect.resolve_episode_data(batch, 99)[0] is None


def test_multi_episode_layout_still_filters_rows(tmp_path):
    """导出产物是一文件多集,那条路必须继续按行筛。"""
    root = tmp_path / "b" / "data" / "chunk-000"
    root.mkdir(parents=True)
    pd.DataFrame({
        "episode_index": np.repeat([4, 5], 6),
        "timestamp": np.arange(12, dtype=float),
    }).to_parquet(root / "file-000.parquet")

    path, filter_rows = collect.resolve_episode_data(tmp_path / "b", 5)
    assert path is not None and filter_rows is True
    frame = collect.load_rows(path, 5)
    assert len(frame) == 6 and set(frame["episode_index"]) == {5}


# ── 触觉阵列 ────────────────────────────────────────────────
def test_tactile_array_is_not_truncated(tmp_path):
    """★ 256 个感应点必须原样保留 —— 截成 3 个检查项就废了。"""
    batch = _tactile_batch(tmp_path / "b", columns=["observation.left_glove"])
    evidence = collect.gather_evidence(batch)

    stream = next(s for s in evidence.streams if s.channel == "tactile")
    series = stream.series["tactile"]
    assert len(series[0]) == 256, f"触觉阵列被截断成 {len(series[0])}"


def test_unknown_channel_is_not_truncated():
    """兜底宽度不能是某个具体数字 —— 换设备时同样会静默丢数据。"""
    assert collect.vector_series([[1.0] * 40])[0] == tuple([1.0] * 40)
    assert len(collect.vector_series([[1.0] * 40], 7)[0]) == 7   # 显式声明才截断


def test_tactile_alias_is_recognized(tmp_path):
    """★ 只跑过导出的树上只有 observation.tactile.* —— 以前认不出,手套检查
    整条静默跳过。"""
    batch = _tactile_batch(tmp_path / "b", columns=["observation.tactile.left"])
    evidence = collect.gather_evidence(batch)

    assert "tactile" in evidence.channels
    keys = [s.key for s in evidence.streams if s.channel == "tactile"]
    assert keys == ["left_glove"], f"别名列没归一成协议流名: {keys}"


def test_both_naming_schemes_do_not_duplicate(tmp_path):
    """两套命名同时存在时是同一份数据,登记成两条流会让缺陷被报两次。"""
    batch = _tactile_batch(
        tmp_path / "b",
        columns=["observation.left_glove", "observation.tactile.left"])
    evidence = collect.gather_evidence(batch)

    keys = [s.key for s in evidence.streams if s.channel == "tactile"]
    assert keys == ["left_glove"], f"同一份数据被登记了多条流: {keys}"


def test_one_handed_episode_yields_one_stream(tmp_path):
    """同项目内 schema 不一致是真实存在的(D435 第 3 集只有右手)。

    采集层只要"有什么收什么"即可;检查项不能假设双手都在。
    """
    batch = _tactile_batch(tmp_path / "b", columns=["observation.right_glove"])
    evidence = collect.gather_evidence(batch)

    keys = [s.key for s in evidence.streams if s.channel == "tactile"]
    assert keys == ["right_glove"]
    assert evidence.modalities == ("glove_sensor",)


# ── SLAM 列的多相机命名 ─────────────────────────────────────
#
# 双目/多相机把第二路存成 observation.<相机名>_slam_*,而采集只认不带前缀的
# 两个名字 —— 那种集**有 SLAM 数据、却一个 SLAM 检查都不跑**,且不报任何错
# (实测 Test94/episode_000003 只有 gripper_2 前缀,整集 SLAM 检查全空)。
def test_slam_single_camera_prefers_trajectory():
    """一个相机两列都在时只取一条 —— trajectory 是高频样本缓冲。"""
    assert collect._slam_columns(
        {"observation.slam_trajectory", "observation.slam_pose"}
    ) == ["observation.slam_trajectory"]


def test_slam_prefixed_camera_is_recognized():
    """★ 回归:只有 observation.<相机名>_slam_* 时也要认出来。"""
    picked = collect._slam_columns(
        {"observation.gripper_2_slam_trajectory", "observation.gripper_2_slam_pose"})
    assert picked == ["observation.gripper_2_slam_trajectory"]


def test_slam_multiple_cameras_yield_one_stream_each():
    """★ 双目:每个相机各一条流 —— 之前是全局 break,只取第一路。"""
    picked = collect._slam_columns({
        "observation.slam_pose", "observation.gripper_2_slam_pose"})
    assert sorted(picked) == ["observation.gripper_2_slam_pose",
                              "observation.slam_pose"]


def test_slam_ignores_lookalike_columns():
    """名字里含 slam 但不是位姿的列不能被当成 SLAM。"""
    assert collect._slam_columns({"observation.slam_trajectory_ns"}) == []
    assert collect._slam_columns(
        {"processing.umi_slam_action.data_4.action"}) == []
    # 伴生列在场也不影响正常列
    assert collect._slam_columns(
        {"observation.slam_pose", "observation.slam_trajectory_ns"}
    ) == ["observation.slam_pose"]


def test_gather_evidence_reads_prefixed_slam(tmp_path):
    """端到端:只有前缀命名的集也要产出 slam 流与 slam 信道。"""
    root = tmp_path / "b" / "data" / "chunk-000"
    root.mkdir(parents=True)
    (tmp_path / "b" / "meta").mkdir()
    rows = 6
    pd.DataFrame({
        "observation.gripper_2_slam_pose": [
            [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]] * rows,
    }).to_parquet(root / "episode_000000.parquet")

    evidence = collect.gather_evidence(tmp_path / "b")
    slam = [s for s in evidence.streams if s.channel == "slam"]
    assert [s.key for s in slam] == ["gripper_2_slam_pose"]
    assert "slam" in evidence.channels


if __name__ == "__main__":
    import pytest
    raise SystemExit(pytest.main([__file__, "-q"]))