Spaces:
Running
Running
stouchinglhh
Claude
sync processor from GitLab c697b6e: 版本 1.19.4 / 前端 1.12.4 —— 服务端 i18n(首屏不再闪英文)、推理模块并入模型页、前端抽出 shared/、缓存键改用前端号
bb59211 Download processor/tests/test_cleaning_collect.py from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 10.7 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/tests/test_cleaning_collect.py
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/processor/tests/test_cleaning_collect.py
-
curl -L -o test_cleaning_collect.py https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/processor/tests/test_cleaning_collect.py
10.7 kB
| """采集层的两条静默失效,各上一把锁。 | |
| 它们都不报错、报告照常生成,只是内容是错的: | |
| 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"])) | |