stouchinglhh commited on
Commit
dd446f1
·
1 Parent(s): bb59211

collector: 更新采集工程至 v1.3.15(姿态语义文档/注释校正 + 夹爪配对报错分型 + 设备检测栏可收起)

Browse files

同步 master 自 177ef0ef(v1.3.13)以来的 6 个提交:

c62e21c0 feat: 删除即真删除 —— 删完校验三处落盘 + 真删录制行(含上传后删除与任务页删除)
00612cb1 fix: UVC 发现结果不再丢弃 —— rc=0 也留 stderr,配对失败带上身份字段
0ab64d7c fix: 坐标帧语义校准 —— 原点相对化改为右乘 R0ᵀ(v1.3.13 表观旋转轴转错的 bug)+ 注释/文档同步
7b668fa4 feat: v1.3.14 —— 发布位姿死区 3mm(静止毫米级游走不进轨迹/落盘)+ 跳变门限值改为不随抽帧膨胀
3e381717 fix: v1.3.15 —— 姿态语义文档/注释校正 + 夹爪配对报错分型 + lite 取号跨 chunk
e77c5cc2 feat: 设备检测栏可拖窄 + 可收起(标题栏 × / 视图▸设备检测栏 Ctrl+D)

变更 36 个文件(29 修改 / 7 新增 / 0 删除),全部为文本文件。__version__ = 1.3.15。

Files changed (36) hide show
  1. collector/README.md +98 -10
  2. collector/config/__init__.py +61 -4
  3. collector/config/i18n.py +6 -0
  4. collector/config/settings.py +44 -4
  5. collector/core/egodata_writer.py +6 -2
  6. collector/core/gripper/devices/uvc_camera_service.py +37 -6
  7. collector/core/gripper/orb_slam_src/ORB-SLAM/Examples/fays/fayssense_orb_slam.cc +16 -3
  8. collector/core/gripper/slam/process_controller.py +41 -9
  9. collector/core/gripper/slam/protocol.py +69 -14
  10. collector/core/helpers.py +70 -3
  11. collector/core/recording_repository.py +32 -0
  12. collector/docs/config.md +3 -1
  13. collector/docs/core.md +4 -0
  14. collector/docs/data.md +51 -2
  15. collector/docs/file_format.md +56 -9
  16. collector/docs/tools.md +8 -1
  17. collector/docs/ui.md +15 -8
  18. collector/tools/tests/device_dock_collapse_test.py +217 -0
  19. collector/tools/tests/lite_delete_test.py +172 -0
  20. collector/tools/tests/test_device_access_selflock.py +217 -6
  21. collector/tools/tests/test_gripper_latest_pose_frame.py +5 -2
  22. collector/tools/tests/test_gripper_pose_axes.py +85 -8
  23. collector/tools/tests/test_gripper_pose_deadband_publish.py +299 -0
  24. collector/tools/tests/test_local_delete.py +157 -0
  25. collector/tools/tests/test_pose_guard_limits.py +289 -0
  26. collector/tools/tests/test_pose_publish_deadband.py +272 -0
  27. collector/tools/tests/test_task_delete.py +208 -0
  28. collector/ui/device_panel.py +7 -0
  29. collector/ui/lite_window.py +40 -15
  30. collector/ui/main_window.py +122 -23
  31. collector/ui/playback_dialog.py +27 -27
  32. collector/ui/pose_view_qt.py +9 -2
  33. collector/ui/task_page.py +59 -15
  34. collector/ui/upload_dialog.py +14 -16
  35. collector/使用手册.md +17 -7
  36. collector/使用说明_lite.md +4 -2
collector/README.md CHANGED
@@ -1,6 +1,6 @@
1
  # collector — Multimodal Data Acquisition SDK · 多模态数据采集 SDK
2
 
3
- ![Version](https://img.shields.io/badge/version-1.3.13-blue)
4
  ![Python](https://img.shields.io/badge/python-3.10-blue)
5
  ![License](https://img.shields.io/badge/license-TBD-lightgrey)
6
 
@@ -339,13 +339,17 @@ venv/bin/python tools/tests/test_sdk_python_version.py
339
  # gripper pairing / ESP32 port diagnostics in the main GUI (v1.3.12)
340
  venv/bin/python tools/tests/test_gripper_fays_pairing.py
341
  venv/bin/python tools/tests/test_gripper_esp_binding.py
342
- # published pose frame + pose view (v1.3.13) — run all five after touching
343
- # either side of the frame contract (protocol/ vs ui/pose_view_qt.py)
 
344
  venv/bin/python tools/tests/test_gripper_pose_axes.py
345
  venv/bin/python tools/tests/test_gripper_pose_host_stamp.py
346
  venv/bin/python tools/tests/test_gripper_slam_columns.py
347
  venv/bin/python tools/tests/test_gripper_latest_pose_frame.py
348
  venv/bin/python tools/tests/test_pose_view_qt.py
 
 
 
349
  # hardware tests require the corresponding device attached: d405_worker_test,
350
  # d435_e2e_test, d435_gui_smoke_test, mono_regression, d435_playback_test, etc.
351
  ```
@@ -407,6 +411,49 @@ are documented in [docs/index.md](docs/index.md#开发约定).
407
 
408
  ### Changelog
409
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  - **v1.3.13** — the published gripper SLAM frame changes to **+X=down /
411
  +Y=left / +Z=forward** (was +X=right / +Y=forward / +Z=up). The same
412
  numbers feed the pose view, `observation.*slam_trajectory` and
@@ -421,7 +468,7 @@ are documented in [docs/index.md](docs/index.md#开发约定).
421
  multiply alone would leave it at the constant `M` — two triads ~120°
422
  apart, which is exactly what the user rejected; the earlier "order is
423
  a contract" hazard is gone too, since the conjugating `M` now cancels:
424
- `(M·R0·Mᵀ)ᵀ(M·R·Mᵀ) == M·R0ᵀ·R·Mᵀ`.) The gripper's *physical*
425
  orientation is untouched: the view moves the model's local vertices by
426
  the same `M` before rotating them, so the drawn model is pixel-identical
427
  to before — the two halves must change together, or the gripper renders
@@ -446,9 +493,11 @@ are documented in [docs/index.md](docs/index.md#开发约定).
446
  Caveat: that tag is **task-level** — a task directory spanning the
447
  switch can only attest to the last frame written. The cutover is the
448
  **release**, not a calendar day: episodes recorded by v1.3.12 or
449
- earlier (which includes everything currently on disk — the newest
450
- episode is from 2026-09-21 21:01) are the old convention and carry no
451
- `frame` key at all, so "key absent" means old frame.
 
 
452
  - **v1.3.12** — pairing a new gripper no longer needs the vendor toolkit GUI.
453
  When the app refused to connect a fresh gripper
454
  (`串口连接失败,无法查询绑定的 Fays 序列号: serial open/handshake failed: Write
@@ -1255,6 +1304,10 @@ venv/bin/python tools/tests/test_sdk_python_version.py
1255
  # 主程序内的夹爪配对与 ESP32 串口诊断(v1.3.12)
1256
  venv/bin/python tools/tests/test_gripper_fays_pairing.py
1257
  venv/bin/python tools/tests/test_gripper_esp_binding.py
 
 
 
 
1258
  # 真机相关测试需连接对应设备:d405_worker_test、d435_e2e_test、
1259
  # d435_gui_smoke_test、mono_regression、d435_playback_test 等
1260
  ```
@@ -1306,6 +1359,40 @@ i18n 文案经 `tr()` 翻译、PyQt5 信号参数用 `object` 封送大整数、
1306
 
1307
  ### 更新记录
1308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1309
  - **v1.3.13** — 夹爪 SLAM 的发布坐标帧改为 **+X=下 / +Y=左 / +Z=前**
1310
  (原 +X=右 / +Y=正对 / +Z=上)。位姿视图、`observation.*slam_trajectory`、
1311
  `orb_pose.json` 是同一份数值,帧也就只有一处 —— 发布入口,形态是
@@ -1315,7 +1402,7 @@ i18n 文案经 `tr()` 翻译、PyQt5 信号参数用 `object` 封送大整数、
1315
  发布姿态 = `M·Mᵀ` = **单位矩阵**,跟随夹爪的三支箭头与世界三箭头在原点
1316
  那一瞬**重合**。只左乘不右乘时它是常数 `M`,两套箭头差约 120° —— 那正是
1317
  被否掉的一版;而且左右都乘之后 `M` 在两边相遇处整个约掉,
1318
- `(M·R0·Mᵀ)ᵀ(M·R·Mᵀ) == M·R0ᵀ·R·Mᵀ`,原先"顺序是契约"的隐患也一并消失。
1319
  夹爪的**物理朝向一点没变**:视图按同一张 `M` 把模型顶点从旧体轴坐标搬到
1320
  新体轴坐标再旋转,画出来逐像素与改前相同 —— ★这两处必须同进同退,
1321
  只改一处夹爪会被画歪 120°,且两种改法各自都"看起来自洽"。
@@ -1332,8 +1419,9 @@ i18n 文案经 `tr()` 翻译、PyQt5 信号参数用 `object` 封送大整数、
1332
  `frame_axes` 与顶层 `gripper_slam_frame`),读端据此分辨新旧。
1333
  注意该标注是**任务级**的:一个任务目录若跨了这次改帧,只能记住最后写入
1334
  的帧。分界是**发布版本**不是日历日 —— v1.3.12 及更早录的一律旧约定
1335
- (现存数据最新一段录于 2026-09-21 21:01,全在旧帧那侧),且那些 episode
1336
- **没有** `frame` 键:键不在就按旧帧读。
 
1337
  - **v1.3.12** — 接新夹爪不再需要开厂商工具包 GUI。新板子连不上时报的那句
1338
  `串口连接失败,无法查询绑定的 Fays 序列号: serial open/handshake failed: Write
1339
  timeout` 里,最要紧的问题是「板子到底在不在说话」,而主程序原本答不了。夹爪
 
1
  # collector — Multimodal Data Acquisition SDK · 多模态数据采集 SDK
2
 
3
+ ![Version](https://img.shields.io/badge/version-1.3.15-blue)
4
  ![Python](https://img.shields.io/badge/python-3.10-blue)
5
  ![License](https://img.shields.io/badge/license-TBD-lightgrey)
6
 
 
339
  # gripper pairing / ESP32 port diagnostics in the main GUI (v1.3.12)
340
  venv/bin/python tools/tests/test_gripper_fays_pairing.py
341
  venv/bin/python tools/tests/test_gripper_esp_binding.py
342
+ # published pose frame + pose view + publish-exit deadband / jump gate
343
+ # (v1.3.13-v1.3.14) — run all eight after touching either side of the
344
+ # frame or publish contract (protocol/ vs ui/pose_view_qt.py)
345
  venv/bin/python tools/tests/test_gripper_pose_axes.py
346
  venv/bin/python tools/tests/test_gripper_pose_host_stamp.py
347
  venv/bin/python tools/tests/test_gripper_slam_columns.py
348
  venv/bin/python tools/tests/test_gripper_latest_pose_frame.py
349
  venv/bin/python tools/tests/test_pose_view_qt.py
350
+ venv/bin/python tools/tests/test_pose_publish_deadband.py
351
+ venv/bin/python tools/tests/test_gripper_pose_deadband_publish.py
352
+ venv/bin/python tools/tests/test_pose_guard_limits.py
353
  # hardware tests require the corresponding device attached: d405_worker_test,
354
  # d435_e2e_test, d435_gui_smoke_test, mono_regression, d435_playback_test, etc.
355
  ```
 
411
 
412
  ### Changelog
413
 
414
+ - **v1.3.15** — pose-semantics documentation/comment corrections, a gripper
415
+ pairing error-message split, and a chunk-aware episode-number fix in lite.
416
+ **Fixed a false equation**: `finish_publish_pose`'s docstring asserted
417
+ `M·(R0ᵀ·R)·Mᵀ = (M·R0ᵀ·Mᵀ)(M·R·Mᵀ)(M·R0ᵀ·Mᵀ)ᵀ`, which is wrong by O(1); the
418
+ correct two-factor form is `(M·R0ᵀ·Mᵀ)(M·R·Mᵀ)`, and the left/right branches
419
+ relate by **conjugation** (`left = P·right·Pᵀ`, `P = M·R0·Mᵀ`). The "order
420
+ isn't a contract" argument in `process_controller`/README now cites the
421
+ **right**-multiply identity (`(M·R·Mᵀ)(M·R0·Mᵀ)ᵀ = M·R·R0ᵀ·Mᵀ`), matching
422
+ protocol.py and test section 5. **Removed unsourced numbers**: the "5000
423
+ random poses / max deviation 2.2e-16 m" in `docs/ui.md` and `pose_view_qt.py`
424
+ had no artifact behind it (one hand-written line each); the real regression is
425
+ `check_gripper_model_frame_invariant` — a **per-pixel** assertion (2 fixed
426
+ poses, `mismatch == 0`) — and the identity is algebraically exact for any R, v
427
+ (`MᵀM = I`). `test_pose_publish_deadband.py` no longer quotes the 7.2 mm
428
+ *false value* as the measured limit (back to 1.39–4.98 mm); `process_controller`'s
429
+ sourceless "≈1.4°" now cites protocol.py's p50 0.25°/max 1.80°; the
430
+ guard-interval comment reads "capped at 1.5× the nominal frame period (= 30 ms)".
431
+ **Gripper pairing**: the single "identity not unique" error conflated "this
432
+ camera reported no side (empty Flash)" with "both cameras reported the same
433
+ side"; split into two headlines, the former carrying
434
+ `sightac[i]/side/flash_ok/device_type/serial/error`. **lite**: `_episode_max`
435
+ took "largest filename `f` + 1", but `f` wraps every 1000, so once chunk-001
436
+ exists it returned a *real but wrong* episode in chunk-000 (N=1001 → 1); now
437
+ uses `list_task_episodes` (`N = c*1000 + f + 1`).
438
+ - **v1.3.14** — a publish-exit **deadband (3 mm)** keeps the estimator's
439
+ millimetre rest wander out of the trail, the end point, the pose view
440
+ and `orb_pose.json`. Below the deadband the **previously published
441
+ value** is published — a substitution, not a suppression, so the sample
442
+ cadence, timestamps, `lost_count` and the `slam_trajectory` point
443
+ contract are untouched; only real movement updates it. Rotation is
444
+ deliberately *not* deadbanded. Measured on strict rest windows (10 s,
445
+ net displacement <2 mm **and** every point <5 mm from the window
446
+ median): position wander radius p50 1.06–1.85 mm / max 1.39–4.98 mm per
447
+ session. The same commit drops the jump gate's constants
448
+ (`POSE_STEP_ALLOWANCE_M` / `POSE_MAX_LINEAR_SPEED_MPS` /
449
+ `POSE_MAX_GUARD_INTERVAL_S`) from 0.05 / 3.0 / 0.1 to 0.03 / 2.0 / 0.03
450
+ — not "stricter" but **no longer inflated by decimation**: stereo frames
451
+ are decimated 3-of-5, so every third frame arrives after 60 ms, and
452
+ charging the speed term for that 60 ms tripled the limit exactly on the
453
+ frames a re-placed pose lands on, letting 0.107–0.224 m of estimator
454
+ excursion through verbatim (genuine single-sample steps peak at
455
+ 0.0601 m). Mirrored verbatim in the native guard
456
+ (`fayssense_orb_slam.cc`, `kPose*`).
457
  - **v1.3.13** — the published gripper SLAM frame changes to **+X=down /
458
  +Y=left / +Z=forward** (was +X=right / +Y=forward / +Z=up). The same
459
  numbers feed the pose view, `observation.*slam_trajectory` and
 
468
  multiply alone would leave it at the constant `M` — two triads ~120°
469
  apart, which is exactly what the user rejected; the earlier "order is
470
  a contract" hazard is gone too, since the conjugating `M` now cancels:
471
+ `(M·R·Mᵀ)(M·R0·Mᵀ)ᵀ == M·R·R0ᵀ·Mᵀ`.) The gripper's *physical*
472
  orientation is untouched: the view moves the model's local vertices by
473
  the same `M` before rotating them, so the drawn model is pixel-identical
474
  to before — the two halves must change together, or the gripper renders
 
493
  Caveat: that tag is **task-level** — a task directory spanning the
494
  switch can only attest to the last frame written. The cutover is the
495
  **release**, not a calendar day: episodes recorded by v1.3.12 or
496
+ earlier carry no `frame` key at all, so "key absent" means old frame.
497
+ The oldest data on disk is all on that side (newest legacy episode
498
+ 2026-09-21 21:01), but the line has since been crossed —
499
+ `UMI_9_29` (recorded 2026-09-29, 204 episodes) is v1.3.13 and does
500
+ carry the key, so both conventions now coexist under `data/recordings`.
501
  - **v1.3.12** — pairing a new gripper no longer needs the vendor toolkit GUI.
502
  When the app refused to connect a fresh gripper
503
  (`串口连接失败,无法查询绑定的 Fays 序列号: serial open/handshake failed: Write
 
1304
  # 主程序内的夹爪配对与 ESP32 串口诊断(v1.3.12)
1305
  venv/bin/python tools/tests/test_gripper_fays_pairing.py
1306
  venv/bin/python tools/tests/test_gripper_esp_binding.py
1307
+ # 删除即真删除(文件 + 录制行;含 lite 上传后删除的编号回归、任务页删除)
1308
+ venv/bin/python tools/tests/test_local_delete.py
1309
+ venv/bin/python tools/tests/test_task_delete.py
1310
+ venv/bin/python tools/tests/lite_delete_test.py
1311
  # 真机相关测试需连接对应设备:d405_worker_test、d435_e2e_test、
1312
  # d435_gui_smoke_test、mono_regression、d435_playback_test 等
1313
  ```
 
1359
 
1360
  ### 更新记录
1361
 
1362
+ - **v1.3.15** —— 姿态语义的文档/注释校正 + 夹爪配对报错分型 + lite 取号跨 chunk。
1363
+ **修一处数学错误**:`finish_publish_pose` 的 docstring 把
1364
+ `M·(R0ᵀ·R)·Mᵀ = (M·R0ᵀ·Mᵀ)·(M·R·Mᵀ)·(M·R0ᵀ·Mᵀ)ᵀ` 写成恒等式,代数上不成立
1365
+ (差 O(1));正确的两因子式是 `(M·R0ᵀ·Mᵀ)(M·R·Mᵀ)`,左乘/右乘两支的关系是
1366
+ **共轭**(左乘版 = `P·右乘版·Pᵀ`,`P = M·R0·Mᵀ`)。`process_controller`/README
1367
+ 论证「顺序不敏感」的左乘支恒等式换成**右乘支**
1368
+ (`(M·R·Mᵀ)(M·R0·Mᵀ)ᵀ = M·R·R0ᵀ·Mᵀ`),与 protocol.py、测试第 5 节一致。
1369
+ **删无出处数字**:`docs/ui.md` 与 `pose_view_qt.py` 的「实测 5000 组随机
1370
+ 姿态/最大偏差 2.2e-16 m」背后没有任何产出物(全仓各一行手写),真回归是
1371
+ `check_gripper_model_frame_invariant` 的**逐像素**断言(2 个固定姿态、
1372
+ `mismatch == 0`),而该恒等式对任意 R、v 代数上精确成立(`MᵀM = I`);
1373
+ `test_pose_publish_deadband.py` 不再把 7.2mm 那个**假值**当实测上限(改回
1374
+ 1.39–4.98mm);`process_controller` 无出处的「≈1.4°」改用 protocol.py 的
1375
+ p50 0.25°/max 1.80°;guard-interval 注释改写成「封顶在 1.5× 名义帧周期
1376
+ (= 30ms)」。**夹爪配对**:原来那句「身份不唯一」把「这台没报出 side
1377
+ (Flash 空/无效)」与「两台报了同一个左右」压在一起(前者会被指成拓扑重复,
1378
+ 真因是厂商产线没写 side),拆成两条抬头,前者带
1379
+ `sightac[i]/side/flash_ok/device_type/serial/error`。**lite**:`_episode_max`
1380
+ 原来取「最大文件名 `f` + 1」,而 `f` 每 1000 回绕 ⇒ chunk-001 出现后返回
1381
+ chunk-000 里**真实存在**的错段(N=1001 → 1),上传/删除静默操作错段;改走
1382
+ `list_task_episodes`(`N = c*1000 + f + 1`)。
1383
+ - **v1.3.14** —— 发布出口加 **3mm 死区**:估计器静止时的毫米级游走不再进
1384
+ 轨迹线/末端点/位姿视图/`orb_pose.json`。不足死区时**发布上次的值**(替换,
1385
+ 不是抑制 ⇒ 采样节拍、时间戳、`lost_count`、`slam_trajectory` 的逐点契约
1386
+ 都不变),只有真实移动才更新它;旋转**故意不做**死区(姿态游走峰峰
1387
+ p50 0.25°/max 1.80°,视觉上不可见,而死区会给真实转动引入手感延迟)。
1388
+ 严格静止窗(10s、净位移<2mm **且**窗内每点距窗中���<5mm)实测位置游走半径
1389
+ 逐会话 p50 1.06-1.85mm / max 1.39-4.98mm。同一笔把跳变门常量
1390
+ (`POSE_STEP_ALLOWANCE_M` / `POSE_MAX_LINEAR_SPEED_MPS` /
1391
+ `POSE_MAX_GUARD_INTERVAL_S`)从 0.05 / 3.0 / 0.1 收到 0.03 / 2.0 / 0.03
1392
+ —— 不是"更严",是**不再随抽帧膨胀**:立体帧 3-of-5 抽帧,每三帧有一帧是
1393
+ 60ms,把速度项按 60ms 计会把限值在那批帧上抬到 3×,而重定位后的位姿恰好
1394
+ 落在那种帧上,于是 0.107-0.224m 的估计器跳变被原样放过去(真实单步峰值
1395
+ 0.0601m)。native 侧 `kPose*` 逐字对齐。
1396
  - **v1.3.13** — 夹爪 SLAM 的发布坐标帧改为 **+X=下 / +Y=左 / +Z=前**
1397
  (原 +X=右 / +Y=正对 / +Z=上)。位姿视图、`observation.*slam_trajectory`、
1398
  `orb_pose.json` 是同一份数值,帧也就只有一处 —— 发布入口,形态是
 
1402
  发布姿态 = `M·Mᵀ` = **单位矩阵**,跟随夹爪的三支箭头与世界三箭头在原点
1403
  那一瞬**重合**。只左乘不右乘时它是常数 `M`,两套箭头差约 120° —— 那正是
1404
  被否掉的一版;而且左右都乘之后 `M` 在两边相遇处整个约掉,
1405
+ `(M·R·Mᵀ)(M·R0·Mᵀ)ᵀ == M·R·R0ᵀ·Mᵀ`,原先"顺序是契约"的隐患也一并消失。
1406
  夹爪的**物理朝向一点没变**:视图按同一张 `M` 把模型顶点从旧体轴坐标搬到
1407
  新体轴坐标再旋转,画出来逐像素与改前相同 —— ★这两处必须同进同退,
1408
  只改一处夹爪会被画歪 120°,且两种改法各自都"看起来自洽"。
 
1419
  `frame_axes` 与顶层 `gripper_slam_frame`),读端据此分辨新旧。
1420
  注意该标注是**任务级**的:一个任务目录若跨了这次改帧,只能记住最后写入
1421
  的帧。分界是**发布版本**不是日历日 —— v1.3.12 及更早录的一律旧约定
1422
+ (老数据最新一段录于 2026-09-21 21:01),且那些 episode **没有** `frame`
1423
+ 键:键不在就按旧帧读。这条线**已经跨过去了**:`UMI_9_29`(2026-09-29 录、
1424
+ 204 段)是 v1.3.13、带键,两种帧现在并存于 `data/recordings`。
1425
  - **v1.3.12** — 接新夹爪不再需要开厂商工具包 GUI。新板子连不上时报的那句
1426
  `串口连接失败,无法查询绑定的 Fays 序列号: serial open/handshake failed: Write
1427
  timeout` 里,最要紧的问题是「板子到底在不在说话」,而主程序原本答不了。夹爪
collector/config/__init__.py CHANGED
@@ -3,8 +3,49 @@
3
  版本号集中定义于此:settings.APP_VERSION 及其他模块均从这里取值。
4
  """
5
 
6
- __version__ = "1.3.13"
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  # v1.3.13 —— 夹爪 SLAM 的**发布坐标帧**改为 +X=下 / +Y=左 / +Z=前
9
  # (原 +X=右 / +Y=正对 / +Z=上)。位姿视图、`observation.*slam_trajectory`、
10
  # `orb_pose.json` 是同一份数值 ⇒ 帧只有一处:发布入口,形态是在"姿态相对化
@@ -21,7 +62,17 @@ __version__ = "1.3.13"
21
  # 各自都"看起来自洽",从画面上看不出是谁漏了。
22
  # 左右都乘之后**顺序不再是契约**:M 在两边相遇处整个约掉,
23
  # (M·R0·Mᵀ)ᵀ(M·R·Mᵀ) == M·R0ᵀ·R·Mᵀ,"先相对化再重标"与"先重标再相对化"
24
- # 逐元素相同(有回归钉住)。①位姿视图**不做任何换帧补偿**,发布帧就是它的
 
 
 
 
 
 
 
 
 
 
25
  # 数据帧 —— gui 坐标系与轨迹系是同一个,换帧后画面随之整体改变
26
  # (场景相对画布转约 120°,世界轴三箭头屏幕位置不动、含义改成下/左/前)。
27
  # ⚠v1.3.13 第一版曾在视图入口左乘 V = Mᵀ 求"画面逐像素不变",那等于把换帧
@@ -47,8 +98,14 @@ __version__ = "1.3.13"
47
  # coordinate_frame.json,不是本仓库产的,别混为一谈)。
48
  # ⚠它是**任务级**的(features 按并集/后写覆盖合并),一个任务目录跨了改帧时
49
  # 早期 episode 会被标错。★分界是**发布版本**不是日历日:v1.3.12 及更早录的
50
- # (现存 153 个 episode,一律是旧约定、本次原样留着没动;最新一段录于
51
- # 2026-09-21 21:01)**不写这个键** —— 键不在就等于旧帧,别当默认值读。
 
 
 
 
 
 
52
  # 按 episode 精确区分需要往 meta/episodes 加列(本次未加)。
53
  # C++ 侧一字未动(native 帧没变,变的只是 Python 侧 native→发布帧这一映射),
54
  # 所以无需重编、`libORB_SLAM3.so` 的 md5 也不变。
 
3
  版本号集中定义于此:settings.APP_VERSION 及其他模块均从这里取值。
4
  """
5
 
6
+ __version__ = "1.3.15"
7
 
8
+ # v1.3.15 —— 姿态语义的文档/注释校正 + 夹爪配对报错分型 + lite 取号跨 chunk
9
+ # ①**修一处数学错误**:protocol.finish_publish_pose 的 docstring 把
10
+ # 「M·(R0ᵀ·R)·Mᵀ = (M·R0ᵀ·Mᵀ)·(M·R·Mᵀ)·(M·R0ᵀ·Mᵀ)ᵀ」写成恒等式,代数上不成立
11
+ # (数值差 O(1))。正确的两因子式是 M·(R0ᵀ·R)·Mᵀ = (M·R0ᵀ·Mᵀ)(M·R·Mᵀ);左乘
12
+ # 与右乘两支之间是**共轭**(左乘版 = P·右乘版·Pᵀ,P = M·R0·Mᵀ,R0/M 皆旋转
13
+ # ⇒ P 正交)。同一主题:README 两处 changelog、process_controller 第 329/857
14
+ # 行论证「顺序不敏感」用的**左乘支**恒等式换成**右乘支**(与 protocol 第 537
15
+ # 行、测试第 5 节一致);process_controller 第 1018 行记号 R0ᵀ·R 改 R·R0ᵀ。
16
+ # 姿态相对化「新旧数据分开看」的整套说明落在 docs/data.md(新帧已按右乘回填;
17
+ # 旧帧〔无 frame 键〕同样是左乘、本次未动,要不要重解释仍**待判定**)。
18
+ # ②**无出处数字清理**:docs/ui.md 与 ui/pose_view_qt.py 里「实测 5000 组随机
19
+ # 姿态/顶点,最大偏差 2.2e-16 m」没有任何产出物(全仓只这两行,`git log -S`
20
+ # 指向 0ab64d7c 手写加入),真回归是 test_pose_view_qt.py 的
21
+ # check_gripper_model_frame_invariant —— **逐像素**断言(2 个固定姿态、
22
+ # mismatch==0),不是 5000 组、不是米制。该恒等式对任意 R、v 代数上精确成立
23
+ # (MᵀM = I),只改一半的实偏差是 0.3–0.4 m:删掉那个计数,改成代数事实 +
24
+ # 测试引用。test_pose_publish_deadband.py 把 protocol.py 定性为「假值」的
25
+ # 7.2mm 当成实测上限(同文件 docstring 自相矛盾),改回 1.39–4.98mm;
26
+ # process_controller 第 898 行的「≈1.4°」无出处,改用 protocol.py 的
27
+ # p50 0.25°/max 1.80°。protocol.py 的 guard-interval 注释按作者口径写成
28
+ # 「封顶在 1.5× 名义帧周期(= 30ms)」,不再把 cap 本身称作名义周期。
29
+ # ③**夹爪配对报错分型**:_associate_group 原来把「这台没报出 side(Flash
30
+ # 空/无效)」与「两台报了同一个左右」挤在同一句「身份不唯一」下 —— 前者会被
31
+ # 指成拓扑/端口重复,真因却是厂商产线没写 side(仓库无写路径)。拆成两条
32
+ # 抬头,前者带 sightac[i]/side/flash_ok/device_type/serial/error。
33
+ # ④**lite 取号跨 chunk**:_episode_max 原来取「最大文件名 f + 1」,而 f
34
+ # 每 1000 回绕 ⇒ chunk-001 出现后返回 chunk-000 里**真实存在**的错段
35
+ # (N=1001 → 得 1),上传/删除静默操作错段。改走 list_task_episodes
36
+ # (N = c*1000 + f + 1),顺带删掉随之未用的 import re。
37
+ # 测试:test_device_access_selflock.py 第 8/9/10 节、lite_delete_test.py 第 6 节。
38
+ #
39
+ # v1.3.14 —— 发布位姿死区(最小可报告位移 3mm):静止时估计器的毫米级游走
40
+ # (严格静止窗实测:游走半径逐会话 p50 1.06-1.85mm / max 1.39-4.98mm,单帧
41
+ # 步长 p99 只有 0.5mm;"只卡首末"的宽口径会给到 7.2mm 的假值,见
42
+ # protocol.py 的定义)不再进
43
+ # 轨迹线/末端点/落盘。判据是"距上次**发布**值的累积位移",与跳变门(比相邻
44
+ # 样本)是两件事;不足死区时**发布上次的值**,所以采样节拍、时间戳、
45
+ # slam_trajectory 的逐点契约都不变,只是值冻结。详见
46
+ # core/gripper/slam/protocol.py 的 POSE_PUBLISH_DEADBAND_M 注释与
47
+ # tools/tests/test_pose_publish_deadband.py / test_gripper_pose_deadband_publish.py。
48
+ #
49
  # v1.3.13 —— 夹爪 SLAM 的**发布坐标帧**改为 +X=下 / +Y=左 / +Z=前
50
  # (原 +X=右 / +Y=正对 / +Z=上)。位姿视图、`observation.*slam_trajectory`、
51
  # `orb_pose.json` 是同一份数值 ⇒ 帧只有一处:发布入口,形态是在"姿态相对化
 
62
  # 各自都"看起来自洽",从画面上看不出是谁漏了。
63
  # 左右都乘之后**顺序不再是契约**:M 在两边相遇处整个约掉,
64
  # (M·R0·Mᵀ)ᵀ(M·R·Mᵀ) == M·R0ᵀ·R·Mᵀ,"先相对化再重标"与"先重标再相对化"
65
+ # 逐元素相同(有回归钉住)。⚠这条恒等式写的是**左乘**那一支(`R0ᵀ·R`,锚点体
66
+ # 轴系里的 body delta)—— 相对化从 ≤1.3.12 起一直是左乘(先 process_controller、
67
+ # v1.3.13 起搬到 protocol),它后来被判定**错**(位置/幅度看着正常,但读端按它
68
+ # 解会让表观旋转轴整体转错),正确口径是**右乘 `R·R0ᵀ`**(世界系相对姿态),修在
69
+ # v1.3.14。两种乘法只差一个常数共轭,而 `frame` 与 `app_version` **���分不出**
70
+ # (两个构建都是 `down_left_forward`、`__version__` 都写 "1.3.13")⇒ 别按版本猜。
71
+ # **新帧**数据已按右乘回填(UMI_9_29,逐文件 sha256 记在 MIGRATION.json);
72
+ # **旧帧**数据(无 frame 键)也是左乘、本次没动,要不要重解释待判定 —— 详见
73
+ # docs/data.md 的「⚠ 姿态相对化的语义改过一次」。右乘同样与重标可交换
74
+ # ((M·R·Mᵀ)(M·R0·Mᵀ)ᵀ == M·R·R0ᵀ·Mᵀ),所以「顺序不是契约」的结论不变。
75
+ # ①位姿视图**不做任何换帧补偿**,发布帧就是它的
76
  # 数据帧 —— gui 坐标系与轨迹系是同一个,换帧后画面随之整体改变
77
  # (场景相对画布转约 120°,世界轴三箭头屏幕位置不动、含义改成下/左/前)。
78
  # ⚠v1.3.13 第一版曾在视图入口左乘 V = Mᵀ 求"画面逐像素不变",那等于把换帧
 
98
  # coordinate_frame.json,不是本仓库产的,别混为一谈)。
99
  # ⚠它是**任务级**的(features 按并集/后写覆盖合并),一个任务目录跨了改帧时
100
  # 早期 episode 会被标错。★分界是**发布版本**不是日历日:v1.3.12 及更早录的
101
+ # **不写这个键** —— 键不在就等于旧帧,别当默认值读。
102
+ # (老数据一律是旧约定、本次原样留着没动:`data/recordings/UMIGripper_Action_AI`
103
+ # 一个任务就 153 个 episode(另有 `9_21_Test` 15 / `D435-裸手…` 6 /
104
+ # `S80C-裸手…` 1,都不是 UMI 夹爪的、也不带这个键),最新一段录于
105
+ # 2026-09-21 21:01,**都没有这个键**。新帧那边已有
106
+ # `data/recordings/UMI_9_29`(2026-09-29 录,204 段,app_version 1.3.13、
107
+ # gripper_slam_frame=down_left_forward;其姿态已在 2026-10-12 按右乘口径回填)
108
+ # —— 这条线**已经跨过去了**。)
109
  # 按 episode 精确区分需要往 meta/episodes 加列(本次未加)。
110
  # C++ 侧一字未动(native 帧没变,变的只是 Python 侧 native→发布帧这一映射),
111
  # 所以无需重编、`libORB_SLAM3.so` 的 md5 也不变。
collector/config/i18n.py CHANGED
@@ -32,6 +32,12 @@ _TRANSLATIONS = {
32
  "清空日志": {"en": "Clear Log", "zh": "清空日志"},
33
  "刷新历史记录": {"en": "Refresh History", "zh": "刷新历史记录"},
34
  "重置画面大小": {"en": "Reset Camera Sizes", "zh": "重置画面大小"},
 
 
 
 
 
 
35
  "帮助(&H)": {"en": "&Help", "zh": "帮助(&H)"},
36
  "关于": {"en": "About", "zh": "关于"},
37
  "语言(&L)": {"en": "&Language", "zh": "语言(&L)"},
 
32
  "清空日志": {"en": "Clear Log", "zh": "清空日志"},
33
  "刷新历史记录": {"en": "Refresh History", "zh": "刷新历史记录"},
34
  "重置画面大小": {"en": "Reset Camera Sizes", "zh": "重置画面大小"},
35
+ "设备检测栏": {"en": "Device Panel", "zh": "设备检测栏"},
36
+ "显示/收起左侧设备检测栏(也可点它标题栏的 × 收起)": {
37
+ "en": "Show/collapse the left device panel "
38
+ "(or click the × on its title bar)",
39
+ "zh": "显示/收起左侧设备检测栏(也可点它标题栏的 × 收起)",
40
+ },
41
  "帮助(&H)": {"en": "&Help", "zh": "帮助(&H)"},
42
  "关于": {"en": "About", "zh": "关于"},
43
  "语言(&L)": {"en": "&Language", "zh": "语言(&L)"},
collector/config/settings.py CHANGED
@@ -176,6 +176,14 @@ DEVICE_POLL_INTERVAL_MS = 2000 # 设备列表轮询间隔(毫秒,sysfs
176
  REALSENSE_VID = "8086" # Intel vendor ID(排除 RealSense UVC 节点用)
177
  REALSENSE_PID = "0b07" # D435 的 product ID;其它型号(如 D435i=0b3a)靠驱动 name 含 "RealSense" 兜底
178
 
 
 
 
 
 
 
 
 
179
  # 传感器命名(写入 parquet observation.<name> 列)
180
  SENSOR_NAMES = [
181
  "right_glove", # 右手手套传感器(16×16 压力矩阵)
@@ -293,6 +301,27 @@ def save_upload_delete_after(on: bool):
293
  """持久化"上传成功后自动删除本地文件"开关。"""
294
  _save_server_config({"upload_delete_after": bool(on)})
295
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  def load_gripper_force_matrix_spec() -> str:
297
  """读取力矩阵落盘规格(server_config.json,默认 "int16x100")。
298
 
@@ -562,12 +591,20 @@ GRIPPER_SLAM_READY_TIMEOUT_S = 45.0 # wait_sdk_ready 上限(SDK
562
  # ★ 分界是**发布版本**,不是日历日:v1.3.13(2026-09-28)起录的才是本值,
563
  # v1.3.12 及更早录的一律旧约定、而且那时**根本不写**这个键 —— 所以
564
  # 旧 episode 里 frame 键**不存在**,"键不在"就是"旧帧",别去 lookup 默认。
565
- # (2026-09-22..09-27 用 v1.3.12 录的也一样:没有键、旧帧。现存
566
- # data/recordings 最新一段录于 2026-09-21 21:01,尚未跨到这条线。)
 
567
  # ★ 标注还是**任务级**的:features 按并集/后写覆盖合并,一个任务目录若跨了
568
  # 这次改帧,info.json 只能记住最后写入的帧,早期 episode 会被标错。要按
569
  # episode 精确区分得往 meta/episodes 加列(本次未加);跨线的任务目录请按
570
  # 录制日期切开,切点用"哪一天升到 v1.3.13"而不是写死的日期。
 
 
 
 
 
 
 
571
  GRIPPER_SLAM_FRAME_ENV = "KSQ_GRIPPER_SLAM_FRAME"
572
  GRIPPER_SLAM_FRAME_NEW = "down_left_forward"
573
  GRIPPER_SLAM_FRAME_LEGACY = "right_forward_up"
@@ -580,8 +617,11 @@ GRIPPER_SLAM_FRAME_AXES = {
580
  # 同上,给界面用的中文。**只此一份** —— ui/pose_view_qt.py 直接读这张表,
581
  # 不自己抄:屏幕上写的字和上面落进 info.json 的 frame_axes 必须同源,否则
582
  # 改词只剩一边动,界面和落盘各说各话。
583
- # 旧约定的 +Y 写"正对"不是"前":用户自己的叫法就是「X=右 / Y=正对 / Z=上」;
584
- # 两个帧的 forward 混用同一个中文字,会让人以为它们的 Y 是同一个物理方向。
 
 
 
585
  GRIPPER_SLAM_FRAME_AXES_CN = {
586
  GRIPPER_SLAM_FRAME_NEW: {"+X": "下", "+Y": "左", "+Z": "前"},
587
  GRIPPER_SLAM_FRAME_LEGACY: {"+X": "右", "+Y": "正对", "+Z": "上"},
 
176
  REALSENSE_VID = "8086" # Intel vendor ID(排除 RealSense UVC 节点用)
177
  REALSENSE_PID = "0b07" # D435 的 product ID;其它型号(如 D435i=0b3a)靠驱动 name 含 "RealSense" 兜底
178
 
179
+ # 左侧设备检测栏(QDockWidget)的尺寸口径:默认宽度 = 首次启动时的栏宽;
180
+ # 下限 = 允许拖到的最窄(一行「图标 + 勾选框 + 省略号」仍认得出设备)。
181
+ # 栏宽与「是否收起」由用户操作决定并持久化(见 load_device_dock_prefs)。
182
+ # 注:面板内的长提示文案必须自动换行,否则单行 label 的 minimumSizeHint
183
+ # 会成为栏宽的硬地板,把栏顶宽到拖不小(2026-10-13 修的就是这个)。
184
+ DEVICE_DOCK_DEFAULT_WIDTH = 240
185
+ DEVICE_DOCK_MIN_WIDTH = 96
186
+
187
  # 传感器命名(写入 parquet observation.<name> 列)
188
  SENSOR_NAMES = [
189
  "right_glove", # 右手手套传感器(16×16 压力矩阵)
 
301
  """持久化"上传成功后自动删除本地文件"开关。"""
302
  _save_server_config({"upload_delete_after": bool(on)})
303
 
304
+ def load_device_dock_prefs() -> dict:
305
+ """读取左侧设备检测栏的显示偏好 {"visible": bool, "width": int}。
306
+
307
+ 缺字段 / 类型不对 / 越界一律回默认(可见、DEVICE_DOCK_DEFAULT_WIDTH);
308
+ 宽度夹在 [DEVICE_DOCK_MIN_WIDTH, 1200](上限防手改配置把画面挤没)。
309
+ """
310
+ raw = _load_server_config().get("device_dock")
311
+ if not isinstance(raw, dict):
312
+ raw = {}
313
+ try:
314
+ width = int(raw.get("width", DEVICE_DOCK_DEFAULT_WIDTH))
315
+ except (TypeError, ValueError):
316
+ width = DEVICE_DOCK_DEFAULT_WIDTH
317
+ width = min(max(width, DEVICE_DOCK_MIN_WIDTH), 1200)
318
+ return {"visible": bool(raw.get("visible", True)), "width": width}
319
+
320
+ def save_device_dock_prefs(visible: bool, width: int):
321
+ """持久化设备检测栏的可见性与宽度(收起或拖窄后重启保持)。"""
322
+ _save_server_config({"device_dock": {"visible": bool(visible),
323
+ "width": int(width)}})
324
+
325
  def load_gripper_force_matrix_spec() -> str:
326
  """读取力矩阵落盘规格(server_config.json,默认 "int16x100")。
327
 
 
591
  # ★ 分界是**发布版本**,不是日历日:v1.3.13(2026-09-28)起录的才是本值,
592
  # v1.3.12 及更早录的一律旧约定、而且那时**根本不写**这个键 —— 所以
593
  # 旧 episode 里 frame 键**不存在**,"键不在"就是"旧帧",别去 lookup 默认。
594
+ # (2026-09-22..09-27 用 v1.3.12 录的也一样:没有键、旧帧。老数据最新一段
595
+ # 录于 2026-09-21 21:01;新帧那边 `data/recordings/UMI_9_29`(2026-09-29
596
+ # 录、204 段)已经带着这个键 —— 这条线**已经跨过去了**,别再说"尚未跨到"。)
597
  # ★ 标注还是**任务级**的:features 按并集/后写覆盖合并,一个任务目录若跨了
598
  # 这次改帧,info.json 只能记住最后写入的帧,早期 episode 会被标错。要按
599
  # episode 精确区分得往 meta/episodes 加列(本次未加);跨线的任务目录请按
600
  # 录制日期切开,切点用"哪一天升到 v1.3.13"而不是写死的日期。
601
+ # ⚠ 帧是**一套**含义、姿态**相对化**是另一套:相对化从 ≤1.3.12 起一直是**左乘**
602
+ # (`R0ᵀ·R`,先在 process_controller、v1.3.13 起搬到 protocol),v1.3.14 才改成
603
+ # 正确的**右乘**(`R·R0ᵀ`)。两者只差一个常数共轭,而 frame 与 app_version
604
+ # **都分不出**(都写 "1.3.13")⇒ 别按版本猜。**新帧**数据已按右乘回填
605
+ # (UMI_9_29,逐文件 sha256 见 MIGRATION.json);**旧帧**数据(无 frame 键)
606
+ # 同样是左乘、本次**没动**,要不要重解释待判定 —— 详见 docs/data.md 的
607
+ # 「⚠ 姿态相对化的语义改过一次」。
608
  GRIPPER_SLAM_FRAME_ENV = "KSQ_GRIPPER_SLAM_FRAME"
609
  GRIPPER_SLAM_FRAME_NEW = "down_left_forward"
610
  GRIPPER_SLAM_FRAME_LEGACY = "right_forward_up"
 
617
  # 同上,给界面用的中文。**只此一份** —— ui/pose_view_qt.py 直接读这张表,
618
  # 不自己抄:屏幕上写的字和上面落进 info.json 的 frame_axes 必须同源,否则
619
  # 改词只剩一边动,界面和落盘各说各话。
620
+ # 旧约定的 +Y 写"正对"而不是"前",理由只有一条:用户自己的叫法就是
621
+ # 「X=右 / Y=正对 / Z=上」,照抄。(**不是**因为两�� forward 指向不同的物理
622
+ # 方向 —— 它们其实是同一个方向:M 把中间帧 +Y 映到发布帧 +Z,所以旧 +Y
623
+ # 「正对」和新 +Z「前」指的是同一处。真正会骗人的是**轴字母**:旧 +Y 与新
624
+ # +Y 差 90°(正对 vs 左),跨帧对齐要看方向词,不能看字母。)
625
  GRIPPER_SLAM_FRAME_AXES_CN = {
626
  GRIPPER_SLAM_FRAME_NEW: {"+X": "下", "+Y": "左", "+Z": "前"},
627
  GRIPPER_SLAM_FRAME_LEGACY: {"+X": "右", "+Y": "正对", "+Z": "上"},
collector/core/egodata_writer.py CHANGED
@@ -1155,9 +1155,13 @@ class EgoDataWriter(QObject):
1155
  f"observation.{key}"]
1156
  elif key.endswith("slam_trajectory"):
1157
  # 帧标注(v1.3.13):没有它,读端无从知道 xyz 在现实中
1158
- # 指向哪 —— 2026-09-21 及更早录的 episode 是旧约定
1159
- # (+X=右/+Y=正对/+Z=上),之后默认 +X=下/+Y=左/+Z=前,
1160
  # 两者的数值都能正常解出来,只是含义不同。
 
 
 
 
1161
  # ⚠ features 是**任务级**、按并集/后写覆盖合并的:一个
1162
  # 任务目录若跨了这次改帧,这里只能记住最后写入的帧,
1163
  # 该任务早期的 episode 会被标错。按 episode 精确区分需要
 
1155
  f"observation.{key}"]
1156
  elif key.endswith("slam_trajectory"):
1157
  # 帧标注(v1.3.13):没有它,读端无从知道 xyz 在现实中
1158
+ # 指向哪 —— v1.3.12 及更早录的 episode 是旧约定
1159
+ # (+X=右/+Y=正对/+Z=上),v1.3.13 起默认 +X=下/+Y=左/+Z=前,
1160
  # 两者的数值都能正常解出来,只是含义不同。
1161
+ # ★ 分界是**发布版本**(v1.3.13,2026-09-28),不是日历日:
1162
+ # 早先这里写过"2026-09-21 及更早",那会让 2026-09-22..09-27
1163
+ # 用 v1.3.12 录的数据被当成新帧 —— 它们其实是旧帧且**没有**
1164
+ # 这个键("键不在"= 旧帧,见 settings.resolve_gripper_slam_frame)。
1165
  # ⚠ features 是**任务级**、按并集/后写覆盖合并的:一个
1166
  # 任务目录若跨了这次改帧,这里只能记住最后写入的帧,
1167
  # 该任务早期的 episode 会被标错。按 episode 精确区分需要
collector/core/gripper/devices/uvc_camera_service.py CHANGED
@@ -585,6 +585,18 @@ class UvcCameraServiceManager:
585
  "libusb/libuvc 动态发现失败 "
586
  f"rc={completed.returncode}: {details or '<no detail>'}"
587
  )
 
 
 
 
 
 
 
 
 
 
 
 
588
  try:
589
  report = json.loads(completed.stdout)
590
  except json.JSONDecodeError as exc:
@@ -650,13 +662,32 @@ class UvcCameraServiceManager:
650
  raise UvcCameraServiceError(
651
  "UVC 组 Sightac 数量不是 2"
652
  )
653
- for candidate in sightac:
654
  side = str(candidate.get("side") or "").strip().lower()
655
- if side not in {"left", "right"} or side in normalized[
656
- "sightac"
657
- ]:
 
 
 
 
658
  raise UvcCameraServiceError(
659
- f"UVC 组 left/right Flash 身份不唯一: side={side!r}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660
  )
661
  normalized["sightac"][side] = candidate
662
  if set(normalized["sightac"]) != {"left", "right"}:
@@ -708,7 +739,7 @@ class UvcCameraServiceManager:
708
  # 逐组记下拒绝原因:这里吞掉的往往才是真因(触觉相机
709
  # mode 探测失败、Flash 身份缺失…),只报 matches/groups
710
  # 计数会让人去猜拓扑,等于把现场丢掉一次。
711
- label = group.get("physical_usb_path") or "group#{}".format(index)
712
  rejections.append("{}: {}".format(label, exc))
713
  continue
714
  if len(matches) != 1:
 
585
  "libusb/libuvc 动态发现失败 "
586
  f"rc={completed.returncode}: {details or '<no detail>'}"
587
  )
588
+ # 发现程序那行逐台相机的现场(stderr 的 `[DISCOVERY] …`)在 rc==0
589
+ # 时同样是唯一线索。注意它**不是**唯一的载体:JSON 里也有 `error`
590
+ # 字段("XU unit: …" / "XU6 prepare: …" 就是写进去再回显到 stderr
591
+ # 的)。JSON 丢掉的是**三态**——`mode` 的 ok/unknown/failed 被压成
592
+ # `mode_ok` 布尔,flash/calibration 的 ok|failed|skipped 只剩两个布尔
593
+ # ——而认不认得出「模式协商失败」还是「Flash 读出来是空串」就靠它。
594
+ # 过去这里只在 rc!=0 时读 stderr,rc==0 那次的现场就整份不进日志,
595
+ # 只剩一句 side=''。
596
+ stderr_text = (completed.stderr or "").strip()
597
+ if stderr_text:
598
+ for line in stderr_text.splitlines():
599
+ self._logger(f"[UVC-DISCOVER] {line}")
600
  try:
601
  report = json.loads(completed.stdout)
602
  except json.JSONDecodeError as exc:
 
662
  raise UvcCameraServiceError(
663
  "UVC 组 Sightac 数量不是 2"
664
  )
665
+ for index, candidate in enumerate(sightac):
666
  side = str(candidate.get("side") or "").strip().lower()
667
+ # 两种故障分开报:①这台相机**没报出** left/right(Flash 的 side
668
+ # 字段读不出来 / 是空串 / 值不认识);②两台报了同一个左右。
669
+ # 挤在一句「身份不唯一」下会把①指成「拓扑/端口重复」——那台相机
670
+ # 的 side 得由厂商产线补写(仓库无写路径),照"重复"去查只会在
671
+ # 拓扑上白转一圈。现场原文见 docs/data.md / memory
672
+ # `uvc-pairing-side-empty`。
673
+ if side not in {"left", "right"}:
674
  raise UvcCameraServiceError(
675
+ "UVC 组 Sightac 未报出有效左右(Flash 的 side 为空/"
676
+ "无效,非左右重复): "
677
+ f"sightac[{index}] side={side!r} "
678
+ f"flash_ok={candidate.get('flash_ok')!r} "
679
+ f"device_type={candidate.get('device_type')!r} "
680
+ f"serial={candidate.get('flash_serial')!r} "
681
+ f"error={candidate.get('error')!r}"
682
+ )
683
+ if side in normalized["sightac"]:
684
+ raise UvcCameraServiceError(
685
+ f"UVC 组 left/right Flash 身份不唯一: "
686
+ f"sightac[{index}] side={side!r} "
687
+ f"flash_ok={candidate.get('flash_ok')!r} "
688
+ f"device_type={candidate.get('device_type')!r} "
689
+ f"serial={candidate.get('flash_serial')!r} "
690
+ f"error={candidate.get('error')!r}"
691
  )
692
  normalized["sightac"][side] = candidate
693
  if set(normalized["sightac"]) != {"left", "right"}:
 
739
  # 逐组记下拒绝原因:这里吞掉的往往才是真因(触觉相机
740
  # mode 探测失败、Flash 身份缺失…),只报 matches/groups
741
  # 计数会让人去猜拓扑,等于把现场丢掉一次。
742
+ label = group.get("outer_path") or "group#{}".format(index)
743
  rejections.append("{}: {}".format(label, exc))
744
  continue
745
  if len(matches) != 1:
collector/core/gripper/orb_slam_src/ORB-SLAM/Examples/fays/fayssense_orb_slam.cc CHANGED
@@ -103,9 +103,22 @@ static constexpr double kInactiveCleanupRetryS = 1.0;
103
  // may nevertheless change its world frame after VIBA/loop correction; that
104
  // coordinate-frame jump is rebased below instead of being published as
105
  // motion. The same limits are independently enforced at the Python boundary.
106
- static constexpr float kPoseStepAllowanceM = 0.05f;
107
- static constexpr float kPoseMaxLinearSpeedMps = 3.0f;
108
- static constexpr double kPoseMaxGuardIntervalS = 0.1;
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  static constexpr float kPoseAngleAllowanceRad =
110
  20.0f * static_cast<float>(M_PI) / 180.0f;
111
  static constexpr float kPoseMaxAngularSpeedRadS =
 
103
  // may nevertheless change its world frame after VIBA/loop correction; that
104
  // coordinate-frame jump is rebased below instead of being published as
105
  // motion. The same limits are independently enforced at the Python boundary.
106
+ //
107
+ // The interval is capped at the *nominal* frame period, not the largest one
108
+ // seen: stereo frames are decimated 3-of-5, so one frame in every three
109
+ // arrives after 60 ms instead of 20 ms. Charging the speed term for that
110
+ // 60 ms triples the limit exactly on the frames a re-placed pose lands on
111
+ // (0.05 + 3.0*0.06 = 0.23 m), which let 0.107-0.224 m of estimator excursion
112
+ // through verbatim instead of being rebased -- the visible 抖/飘.
113
+ // Measured over 69061 published samples in 20 sessions (2026-10-10..13):
114
+ // genuine single-sample steps peak at 0.0601 m (p99 0.043) and the smallest
115
+ // excursion is 0.1075 m, so 0.03 + 2.0*0.03 = 0.09 m at dt>=30 ms separates
116
+ // them with margin on both sides and is a no-op on every clean session.
117
+ // (Rotation keeps 20 deg + 1080 deg/s; with the same cap the 30 ms limit is
118
+ // 52.4 deg against a largest genuine step of 15.75 deg.)
119
+ static constexpr float kPoseStepAllowanceM = 0.03f;
120
+ static constexpr float kPoseMaxLinearSpeedMps = 2.0f;
121
+ static constexpr double kPoseMaxGuardIntervalS = 0.03;
122
  static constexpr float kPoseAngleAllowanceRad =
123
  20.0f * static_cast<float>(M_PI) / 180.0f;
124
  static constexpr float kPoseMaxAngularSpeedRadS =
collector/core/gripper/slam/process_controller.py CHANGED
@@ -22,8 +22,9 @@ from core.gripper.runtime.device_access import device_access_guard, FAYS_SDK_INI
22
  from config import settings as app_settings
23
 
24
  from .protocol import (
25
- FAYS_STAGE_KEYS, PoseSample, finish_publish_pose, parse_slam_line,
26
- pose_rejection_reason, rotate_pose_z90,
 
27
  )
28
 
29
 
@@ -314,13 +315,18 @@ class SlamProcessController:
314
  self._pose_buffer_lock = threading.Lock()
315
  self._last_accepted_pose = None
316
  self._pose_rejecting = False
 
 
 
317
  # 原点后姿态相对化:设原点事件后捕获首帧**中间帧**姿态作为
318
- # 基准,此后所有姿态左乘基准的逆 → 中间帧第一帧恒为单位矩阵,
 
 
319
  # 帧轴与轨迹同基准(相对原点),见 _publish_pose。
320
  # ★ 基准活在中间帧(rotate_pose_z90 的输出)里,**不是**发布帧。
321
  # 字段名里的 _mid 就是提醒这件事,改名前先读 _publish_pose。
322
  # 顺序本身自 v1.3.13 起不再敏感(姿态左右各乘一次 M 之后,
323
- # M 在两步相遇处整个约掉,(M·R0·Mᵀ)ᵀ(M·R·Mᵀ) 与先相对化再重标
324
  # 逐元素相同,有回归钉着);但基准必须与**进来的样本**同帧才好
325
  # 推理,而样本在这一步还是中间帧 —— 所以基准就留在中间帧。
326
  self._pose_rotation_zero_mid = None
@@ -436,6 +442,7 @@ class SlamProcessController:
436
  self._initial_origin_generation = None
437
  self._last_accepted_pose = None
438
  self._pose_rejecting = False
 
439
  self._last_pose_reject_log = float("-inf")
440
  self._fays_rates_alarm.reset()
441
  self._pose_raw_seq = 0
@@ -847,7 +854,7 @@ class SlamProcessController:
847
  # 发布帧自 v1.3.13 起是中间帧再叠一次世界重标,环境变量可回落。
848
  # ★ 相对化与重标的先后顺序自 v1.3.13 起**不再敏感**:姿态是左右各
849
  # 乘一次 M·R·Mᵀ,两步一倒 M 会在相遇处整个约掉,
850
- # (M·R0·Mᵀ)ᵀ(M·R·Mᵀ) == M·R0ᵀ·R·Mᵀ(回归钉在
851
  # tools/tests/test_gripper_pose_axes.py 第 5 节)。改动之前那个
852
  # "反过来就少换一次帧、夹爪跟轨迹差 90°" 的坑已经不在了。
853
  # finish_publish_pose 仍是唯一入口,因为它同时管着锚定与要不要重标。
@@ -862,11 +869,11 @@ class SlamProcessController:
862
  # 原点后姿态相对化:C++ 设原点只把位置归零,静止首帧的姿态
863
  # 仍带部署校正/启动朝向的常数旋转(旋转跳变的根源,两轮
864
  # 变换形式修正都没能抵消它)。改为捕获首帧**中间帧**姿态为基准,
865
- # 全部姿态左乘基准的逆 → 中间帧首帧恒为单位矩阵(帧轴与世界
866
- # 轴对齐,不跳变),此后姿态 = 相对原点的真实转动,与轨迹
867
  # (位置相对原点)同基准。位置不动,保持已验证的轨迹映射。
868
  #
869
- # 基准捕获与左乘都必须留在中间帧里 —— 基准是拿来跟**进来的样本**
870
  # 直接相乘的,样本走到这一步还是中间帧,基准就得同帧(字段名里的
871
  # _mid 就是这个约束)。
872
  # ⚠ 别再照抄老注释说"提到前面会让发布帧退回中间帧":自 v1.3.13
@@ -882,6 +889,20 @@ class SlamProcessController:
882
  self._pose_rotation_zero_mid,
883
  relabel=self._publish_relabel,
884
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
885
  self._on_pose(pose)
886
  timestamp = pose.timestamp
887
  x, y, z = pose.position
@@ -974,6 +995,13 @@ class SlamProcessController:
974
  return
975
  if event.kind == "ready":
976
  self._set_state(status="READY - Press ENTER")
 
 
 
 
 
 
 
977
  if self._auto_origin:
978
  self.send_origin("initial origin")
979
  else:
@@ -982,10 +1010,14 @@ class SlamProcessController:
982
  if event.kind == "origin":
983
  self._last_accepted_pose = None
984
  self._pose_rejecting = False
 
 
 
 
985
  # 位置在原点帧被 C++ 归零,姿态却带着部署校正/启动朝向的
986
  # 常数残留(上次跳变的根源);每个 origin 事件(含 NEW
987
  # ORIGIN)都重新捕获首帧姿态基准,与位置重归零保持一致。
988
- # 重新锚定后发布帧的首帧姿态是**单位矩阵**(相对化 R0ᵀ·R 再
989
  # 左乘 M、右乘 Mᵀ 给体轴改名,M·Mᵀ = I)——夹爪体轴与世界轴
990
  # 在原点那一瞬重合,跟位置的"首帧就是原点"一致。
991
  # ⚠只左乘不右乘(v1.3.13 之前)时它是常数 M,那才是要留心的
 
22
  from config import settings as app_settings
23
 
24
  from .protocol import (
25
+ FAYS_STAGE_KEYS, POSE_PUBLISH_DEADBAND_M, PoseSample, finish_publish_pose,
26
+ parse_slam_line, pose_below_deadband, pose_rejection_reason,
27
+ rotate_pose_z90,
28
  )
29
 
30
 
 
315
  self._pose_buffer_lock = threading.Lock()
316
  self._last_accepted_pose = None
317
  self._pose_rejecting = False
318
+ # 死区参照:**上次真正发布出去的位置**(发布帧)。保持期间**不更新**
319
+ # 它 —— 累计位移要一直算到超过死区才换值(见 _publish_pose)。
320
+ self._last_published_position = None
321
  # 原点后姿态相对化:设原点事件后捕获首帧**中间帧**姿态作为
322
+ # 基准,此后所有姿态**右乘**基准的逆(R·R0ᵀ = 世界系相对姿态;左乘
323
+ # 算出来的是锚点体轴系里的增量,差一个常数共轭、表观旋转轴整体转错)
324
+ # → 中间帧第一帧恒为单位矩阵,
325
  # 帧轴与轨迹同基准(相对原点),见 _publish_pose。
326
  # ★ 基准活在中间帧(rotate_pose_z90 的输出)里,**不是**发布帧。
327
  # 字段名里的 _mid 就是提醒这件事,改名前先读 _publish_pose。
328
  # 顺序本身自 v1.3.13 起不再敏感(姿态左右各乘一次 M 之后,
329
+ # M 在两步相遇处整个约掉,(M·R·Mᵀ)(M·R0·Mᵀ)ᵀ 与先相对化再重标
330
  # 逐元素相同,有回归钉着);但基准必须与**进来的样本**同帧才好
331
  # 推理,而样本在这一步还是中间帧 —— 所以基准就留在中间帧。
332
  self._pose_rotation_zero_mid = None
 
442
  self._initial_origin_generation = None
443
  self._last_accepted_pose = None
444
  self._pose_rejecting = False
445
+ self._last_published_position = None
446
  self._last_pose_reject_log = float("-inf")
447
  self._fays_rates_alarm.reset()
448
  self._pose_raw_seq = 0
 
854
  # 发布帧自 v1.3.13 起是中间帧再叠一次世界重标,环境变量可回落。
855
  # ★ 相对化与重标的先后顺序自 v1.3.13 起**不再敏感**:姿态是左右各
856
  # 乘一次 M·R·Mᵀ,两步一倒 M 会在相遇处整个约掉,
857
+ # (M·R·Mᵀ)(M·R0·Mᵀ)ᵀ == M·R·R0ᵀ·Mᵀ(回归钉在
858
  # tools/tests/test_gripper_pose_axes.py 第 5 节)。改动之前那个
859
  # "反过来就少换一次帧、夹爪跟轨迹差 90°" 的坑已经不在了。
860
  # finish_publish_pose 仍是唯一入口,因为它同时管着锚定与要不要重标。
 
869
  # 原点后姿态相对化:C++ 设原点只把位置归零,静止首帧的姿态
870
  # 仍带部署校正/启动朝向的常数旋转(旋转跳变的根源,两轮
871
  # 变换形式修正都没能抵消它)。改为捕获首帧**中间帧**姿态为基准,
872
+ # 全部姿态**右乘**基准的逆(R·R0ᵀ)→ 中间帧首帧恒为单位矩阵(帧轴与
873
+ # 世界轴对齐,不跳变),此后姿态 = 相对原点的真实转动,与轨迹
874
  # (位置相对原点)同基准。位置不动,保持已验证的轨迹映射。
875
  #
876
+ # 基准捕获与右乘都必须留在中间帧里 —— 基准是拿来跟**进来的样本**
877
  # 直接相乘的,样本走到这一步还是中间帧,基准就得同帧(字段名里的
878
  # _mid 就是这个约束)。
879
  # ⚠ 别再照抄老注释说"提到前面会让发布帧退回中间帧":自 v1.3.13
 
889
  self._pose_rotation_zero_mid,
890
  relabel=self._publish_relabel,
891
  )
892
+ # 极小幅度噪声死区(最小可报告位移,见 protocol.POSE_PUBLISH_DEADBAND_M):
893
+ # 静止时估计器的毫米级游走单帧远在跳变限值之下,跳变门管不着,于是
894
+ # 轨迹末端在放大视图里持续微抖。位移不足死区时**发布上次的值**——
895
+ # 不是像跳变门那样不发布:采样节拍、时间戳、落盘的 slam_trajectory
896
+ # 逐点时刻全部保持不变,只是值冻结,下游按时间对齐/回放那套契约
897
+ # 一个字都不动(轨迹是按发布位置追加的,冻结值 = 末端点不再加噪声)。
898
+ # 姿态这一路不动:静止姿态游走峰峰 p50 0.25°/max 1.80°(另一会话
899
+ # 3.46°,出处见 protocol.py 的 POSE_PUBLISH_DEADBAND_M 注释),比
900
+ # 位置更该镇,但按死区量化会把真实慢转也切成一档一档,得不偿失
901
+ # (要动先测清楚再动)。
902
+ if pose_below_deadband(pose.position, self._last_published_position):
903
+ pose = replace(pose, position=self._last_published_position)
904
+ else:
905
+ self._last_published_position = pose.position
906
  self._on_pose(pose)
907
  timestamp = pose.timestamp
908
  x, y, z = pose.position
 
995
  return
996
  if event.kind == "ready":
997
  self._set_state(status="READY - Press ENTER")
998
+ # 死区生效与否必须**在画面上能确认**:开机时把当次生效值打进日志。
999
+ # 用户是拿 GUI 验收的(静止时轨迹线/末端点还抖不抖),没有这一行
1000
+ # 就只能靠"感觉没变"来判断,而感觉分辨不出 3mm。
1001
+ self._log(
1002
+ f"[SLAM] 发布位姿死区 {POSE_PUBLISH_DEADBAND_M * 1000:.1f}mm:"
1003
+ "静止时的毫米级游走不再进轨迹/落盘(真实移动不量化)"
1004
+ )
1005
  if self._auto_origin:
1006
  self.send_origin("initial origin")
1007
  else:
 
1010
  if event.kind == "origin":
1011
  self._last_accepted_pose = None
1012
  self._pose_rejecting = False
1013
+ # 重设原点 = 发布位置整体换锚(当前位姿归零),死区参照必须一起
1014
+ # 丢:否则会拿旧锚下的位置去比新锚下的位置,差出来的那段锚位移
1015
+ # 会把首帧顶掉或反过来冻住首帧。
1016
+ self._last_published_position = None
1017
  # 位置在原点帧被 C++ 归零,姿态却带着部署校正/启动朝向的
1018
  # 常数残留(上次跳变的根源);每个 origin 事件(含 NEW
1019
  # ORIGIN)都重新捕获首帧姿态基准,与位置重归零保持一致。
1020
+ # 重新锚定后发布帧的首帧姿态是**单位矩阵**(相对化 R·R0ᵀ 再
1021
  # 左乘 M、右乘 Mᵀ 给体轴改名,M·Mᵀ = I)——夹爪体轴与世界轴
1022
  # 在原点那一瞬重合,跟位置的"首帧就是原点"一致。
1023
  # ⚠只左乘不右乘(v1.3.13 之前)时它是常数 M,那才是要留心的
collector/core/gripper/slam/protocol.py CHANGED
@@ -66,15 +66,40 @@ MP_CLEANUP_SPECS = (
66
  FAYS_STAGE_KEYS = ("wait_ms", "preprocess_ms", "middle_ms", "post_ms")
67
 
68
  # The gripper cannot physically cover these distances between consecutive
69
- # 25 Hz SLAM samples. The small fixed allowance absorbs normal estimator
70
  # corrections; the speed term keeps the gate independent of the exact FPS.
 
 
 
 
 
 
 
 
 
 
71
  POSE_MAX_ABS_POSITION_M = 10.0
72
- POSE_STEP_ALLOWANCE_M = 0.05
73
- POSE_MAX_LINEAR_SPEED_MPS = 3.0
74
- POSE_MAX_GUARD_INTERVAL_S = 0.1
75
  POSE_ANGLE_ALLOWANCE_RAD = math.radians(20.0)
76
  POSE_MAX_ANGULAR_SPEED_RAD_S = math.radians(1080.0)
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  _POSE_RE = re.compile(
79
  r"\[([\d.]+)\]\s*XYZ:\(([-\d.e+]+),([-\d.e+]+),([-\d.e+]+)\)\s*"
80
  r"Quat:\(w=([-\d.e+]+),x=([-\d.e+]+),y=([-\d.e+]+),z=([-\d.e+]+)\)"
@@ -209,6 +234,21 @@ def pose_rejection_reason(current, previous=None):
209
  return None
210
 
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  @dataclass(frozen=True)
213
  class FaysRateSample:
214
  image_input: float
@@ -481,15 +521,28 @@ def relabel_published_frame(pose):
481
  def finish_publish_pose(pose, rotation_zero=None, relabel=True):
482
  """发布入口最后一步:原点后姿态相对化 →(可选)世界重标(含体轴改名)。
483
 
484
- 顺序:**现在两种顺序等价**,不再有雷。旧版(重标只左乘 M)时反过来做
485
- 会让 M 整体约掉、发出去的还是中间帧,而位置确实换了帧,于是 GUI 里
486
- 夹爪体轴模型会画成转了 90° 的姿势;v1.3.13 的重标改成左右各乘一次
487
- (M·R·Mᵀ,右乘 = 体轴改名,见 relabel_published_frame)之后,
488
- (M·R0·Mᵀ)ᵀ·(M·R·Mᵀ) = M·R0ᵀ·R·Mᵀ
489
- 与"先相对化再重标"逐元素相同 —— 共轭让 M 在两步里都自消。
490
- tools/tests/test_gripper_pose_axes.py 第 5 节把这条等价性钉住了。
491
- 保留这个函数当唯一入口,是因为它同时管着"锚点相对化"和"要不要重标"
492
- 两件事,拆开调用迟早有人只做一半。
 
 
 
 
 
 
 
 
 
 
 
 
 
493
 
494
  pose 必须已处于中间帧(rotate_pose_z90 的输出)。
495
  rotation_zero = 本锚段的**中间帧**首帧姿态;None → 不做相对化
@@ -500,8 +553,10 @@ def finish_publish_pose(pose, rotation_zero=None, relabel=True):
500
  if rotation_zero is not None:
501
  pose = replace(
502
  pose,
 
 
503
  rotation=quat_product(
504
- quat_conjugate(rotation_zero), pose.rotation),
505
  )
506
  if not relabel:
507
  return pose
 
66
  FAYS_STAGE_KEYS = ("wait_ms", "preprocess_ms", "middle_ms", "post_ms")
67
 
68
  # The gripper cannot physically cover these distances between consecutive
69
+ # SLAM samples. The small fixed allowance absorbs normal estimator
70
  # corrections; the speed term keeps the gate independent of the exact FPS.
71
+ #
72
+ # POSE_MAX_GUARD_INTERVAL_S caps the interval at 1.5x the *nominal* frame
73
+ # period (= 30 ms; the nominal is the 20 ms 50 fps input period), not the
74
+ # largest one seen: stereo frames are decimated 3-of-5, so one frame in
75
+ # every three arrives after 60 ms instead of 20 ms. Charging the speed term
76
+ # for that 60 ms triples the limit exactly on the frames a re-placed pose lands
77
+ # on, which let 0.107-0.224 m of estimator excursion through verbatim instead
78
+ # of being held. Measured over 69061 published samples in 20 sessions: genuine
79
+ # single-sample steps peak at 0.0601 m (p99 0.043), smallest excursion 0.1075 m.
80
+ # Mirrored verbatim in the native guard (fayssense_orb_slam.cc, kPose*).
81
  POSE_MAX_ABS_POSITION_M = 10.0
82
+ POSE_STEP_ALLOWANCE_M = 0.03
83
+ POSE_MAX_LINEAR_SPEED_MPS = 2.0
84
+ POSE_MAX_GUARD_INTERVAL_S = 0.03
85
  POSE_ANGLE_ALLOWANCE_RAD = math.radians(20.0)
86
  POSE_MAX_ANGULAR_SPEED_RAD_S = math.radians(1080.0)
87
 
88
+ # 极小幅度噪声死区:**最小可报告位移**。静止时估计器仍在游走——实测 20 个
89
+ # 会话、69061 条发布位姿。静止段的判据是(10s 窗、净位移<2mm)**且**(窗内
90
+ # 每点距窗中位<5mm)——后半条不能省:只卡首末时,那些"出去又回来"的窗会把
91
+ # 统计量整体抬高(单看首末得到的 max 是 7.2mm 的假值)。按这个严格口径,位置
92
+ # 游走半径(距窗中位)逐会话 p50 1.06-1.85mm、逐会话 max 1.39-4.98mm;姿态
93
+ # 峰峰 p50 0.25°、max 1.80°(另有一会话 3.46°)。默认视野
94
+ # 327px/m(span 0.5m 画在 420px 上)下 1mm≈0.33px 看不出来,但用户放大到
95
+ # 4-10× 检查轨迹时,那就是 3-10px 的连续抖动——「静止时轨迹线/末端点还在
96
+ # 小抖」报的就是它。3mm 死区实测:四个会话的静止窗 99.8-100% 帧被冻结,
97
+ # 全段误差 p50≤1.4mm、max=3.0mm(10× 下 10px,且只出现在真实移动时的量化,
98
+ # 不出现在静止时)。
99
+ # ★ 与上面那组"跳变门"是两件事:跳变门比的是**相邻样本**(毫米级游走远在
100
+ # 限值之下,它管不着),死区比的是**距上次发布值**的累积位移。
101
+ POSE_PUBLISH_DEADBAND_M = 0.003
102
+
103
  _POSE_RE = re.compile(
104
  r"\[([\d.]+)\]\s*XYZ:\(([-\d.e+]+),([-\d.e+]+),([-\d.e+]+)\)\s*"
105
  r"Quat:\(w=([-\d.e+]+),x=([-\d.e+]+),y=([-\d.e+]+),z=([-\d.e+]+)\)"
 
234
  return None
235
 
236
 
237
+ def pose_below_deadband(position, reference, epsilon=POSE_PUBLISH_DEADBAND_M):
238
+ """当前位置距**上次发布值**是否小于死区(是 → 保持上次发布的值)。
239
+
240
+ 比的是**累积位移**而不是相邻样本:静止时的毫米级游走单帧远小于任何跳变
241
+ 限值,只有对着"上次真正发出去的那个位置"累计,才分得出游走与真实移动。
242
+ reference 为 None(会话首帧 / 刚重设原点)时恒为 False —— 首帧必须发。
243
+ """
244
+
245
+ if reference is None:
246
+ return False
247
+ return math.sqrt(sum(
248
+ (position[index] - reference[index]) ** 2 for index in range(3)
249
+ )) < epsilon
250
+
251
+
252
  @dataclass(frozen=True)
253
  class FaysRateSample:
254
  image_input: float
 
521
  def finish_publish_pose(pose, rotation_zero=None, relabel=True):
522
  """发布入口最后一步:原点后姿态相对化 →(可选)世界重标(含体轴改名)。
523
 
524
+ ★ 相对化是**右乘** R0ᵀ(世界系相对姿态 R·R0ᵀ),不是左乘。v1.3.13 里
525
+ 写成了左乘 R0ᵀ·R,那算出来的是"在锚点体轴系里表达的相对转动"(body
526
+ delta)——数值上只差一个常数共轭,却让人看见的旋转轴整体转错:
527
+ 发布姿态(右乘) = M·(R·R0ᵀ)·Mᵀ = (M·R·Mᵀ)·(M·R0·Mᵀ)ᵀ
528
+ 发布姿态(左乘) = M·(R0ᵀ·R)·Mᵀ = P·[发布姿态(右乘)]·Pᵀ,
529
+ 其中 P = M·R0·Mᵀ(R0、M 都是旋转 ⇒ P 正交,P⁻¹ 就是 Pᵀ)
530
+ ⚠别把这两支写成"同一个式子多乘一个因子":左乘支不是右乘支右边再挂一个
531
+ (M·R0·Mᵀ) 的转置(代数上不成立,数值差 O(1)),而是**共轭**关系。
532
+ 早期注释里那个三因子式是错的,已删。
533
+ 锚点 R0 = R_z(90°)·correction = R_x(−90°) 时,那个常数恰是"绕发布帧
534
+ Y 轴 ±90°":Y 正好是不动轴(所以只有绕 Y 的手感是对的),X 与 Z 被
535
+ 对调并翻一个号。用户实测三条(绕 X → 表观绕 Z 同向、绕 Z → 表观绕 X
536
+ 反向、绕 Y → 正常)逐条由此而来。右乘后表观旋转轴 = M·a 与物理一致,
537
+ 锚点那一瞬仍是单位矩阵(画面不变),位置一个字不动。
538
+ tools/tests/test_gripper_pose_axes.py 第 10 节把"世界系相对姿态"钉住了,
539
+ 第 4 节钉"锚点 = 单位矩阵"。
540
+
541
+ 顺序:**先相对化再重标 与 先重标再相对化等价**(共轭让 M 在两步里都
542
+ 自消):
543
+ (M·R·Mᵀ)·(M·R0·Mᵀ)ᵀ = M·R·R0ᵀ·Mᵀ
544
+ 第 5 节把这条等价性钉住了。保留这个函数当唯一入口,是因为它同时管着
545
+ "锚点相对化"和"要不要重标"两件事,拆开调用迟早有人只做一半。
546
 
547
  pose 必须已处于中间帧(rotate_pose_z90 的输出)。
548
  rotation_zero = 本锚段的**中间帧**首帧姿态;None → 不做相对化
 
553
  if rotation_zero is not None:
554
  pose = replace(
555
  pose,
556
+ # 右乘 R0ᵀ = 世界系相对姿态;写成左乘会把表观旋转轴整体转错
557
+ # 一个常数(v1.3.13 的 bug,见上面 docstring)
558
  rotation=quat_product(
559
+ pose.rotation, quat_conjugate(rotation_zero)),
560
  )
561
  if not relabel:
562
  return pose
collector/core/helpers.py CHANGED
@@ -884,8 +884,39 @@ def _rmdir_empty(path: str) -> bool:
884
  return False
885
 
886
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
887
  def delete_pooled_episode(task_dir: str, episode_index: int) -> bool:
888
- """彻底删除 episode N 的文件组(用户裁决:GUI 删除不走回收区)。
889
 
890
  - videos/<key>/episode-{f}.{ext} 各流文件
891
  - data/chunk-{c}/episode-{f}.parquet
@@ -893,7 +924,11 @@ def delete_pooled_episode(task_dir: str, episode_index: int) -> bool:
893
  同口径)
894
  统计不回退(stats/total_episodes 保持,与进度语义一致)。
895
 
896
- 幂等:文件组已不存在也返回 True(视为已删)。
 
 
 
 
897
  """
898
  files = episode_video_files(task_dir, episode_index)
899
  for key, src in files.items():
@@ -950,7 +985,39 @@ def delete_pooled_episode(task_dir: str, episode_index: int) -> bool:
950
  if _fcntl is not None:
951
  _fcntl.flock(fh.fileno(), _fcntl.LOCK_UN)
952
  fh.close()
953
- return True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
954
 
955
 
956
  # ═══════════════════════════════════════════════════════════
 
884
  return False
885
 
886
 
887
+ def pooled_episode_leftovers(task_dir: str, episode_index: int) -> list:
888
+ """episode N 删除后仍留在磁盘上的文件(空列表 = 真删干净)。
889
+
890
+ 三处都要查:data/chunk-c/episode-f.parquet、videos/chunk-c/*/episode-f.*、
891
+ meta/episodes 行(每段一文件;旧分片则在分片里查该行还在不在)。
892
+ 读不出分片内容时按「疑似残留」处理——宁可报未删净也不谎报成功。
893
+ """
894
+ left = []
895
+ dp = pooled_data_parquet_path(task_dir, episode_index)
896
+ if os.path.isfile(dp):
897
+ left.append(dp)
898
+ for _key, p in episode_video_files(task_dir, episode_index).items():
899
+ if os.path.isfile(p):
900
+ left.append(p)
901
+ cidx, _f = episode_chunk_file(episode_index)
902
+ ep_candidates = [pooled_episodes_path(task_dir, episode_index),
903
+ _legacy_episodes_shard_path(task_dir, cidx)]
904
+ import pyarrow.parquet as pq
905
+ for ep_file in ep_candidates:
906
+ if not os.path.isfile(ep_file):
907
+ continue
908
+ try:
909
+ rows = pq.read_table(ep_file).to_pylist()
910
+ except Exception:
911
+ left.append(ep_file) # 读不出来 → 无法确认该行还在不在,按残留算
912
+ continue
913
+ if any(r.get("episode_index") == episode_index for r in rows):
914
+ left.append(ep_file)
915
+ return left
916
+
917
+
918
  def delete_pooled_episode(task_dir: str, episode_index: int) -> bool:
919
+ """彻底删除 episode N 的文件组(用户裁决:GUI 删除不走回收区),并校验。
920
 
921
  - videos/<key>/episode-{f}.{ext} 各流文件
922
  - data/chunk-{c}/episode-{f}.parquet
 
924
  同口径)
925
  统计不回退(stats/total_episodes 保持,与进度语义一致)。
926
 
927
+ 幂等:文件组已不存在返回 True(视为已删)。
928
+
929
+ 返回 True **仅当三处都确认没了**(pooled_episode_leftovers 为空);
930
+ os.remove 失败/权限/读不出分片 → 返回 False,由调用方如实报错——
931
+ 不许再出现「界面报已删除、文件还在」。
932
  """
933
  files = episode_video_files(task_dir, episode_index)
934
  for key, src in files.items():
 
985
  if _fcntl is not None:
986
  _fcntl.flock(fh.fileno(), _fcntl.LOCK_UN)
987
  fh.close()
988
+ return not pooled_episode_leftovers(task_dir, episode_index)
989
+
990
+
991
+ def delete_local_session(session_path: str, episode_index: int = 0) -> tuple:
992
+ """本地删除统一入口(真删除)——播放页手动删除与上传后删除共用同一口径。
993
+
994
+ 池化布局(episode_index > 0)删该 episode 文件组(数据 + 视频 + meta 行);
995
+ 旧格式会话目录(episode_index == 0)整目录删除。两处都做删除后校验。
996
+
997
+ 返回 (ok, err):ok=True 时 err 为空串;否则 err 是给界面显示的原因
998
+ (残留文件路径 / OSError 文本 / 目录不存在)。绝不吞错——报成功必须
999
+ 真的是文件已不在。
1000
+ """
1001
+ if not session_path:
1002
+ return False, "会话路径为空"
1003
+ if episode_index > 0:
1004
+ if delete_pooled_episode(session_path, episode_index):
1005
+ return True, ""
1006
+ left = pooled_episode_leftovers(session_path, episode_index)
1007
+ if left:
1008
+ return False, "删除后仍有 {} 个文件残留(如 {})".format(
1009
+ len(left), left[0])
1010
+ return False, "删除未完成"
1011
+ import shutil
1012
+ if not os.path.isdir(session_path):
1013
+ return False, "会话目录不存在"
1014
+ try:
1015
+ shutil.rmtree(session_path)
1016
+ except OSError as e:
1017
+ return False, str(e)
1018
+ if os.path.isdir(session_path):
1019
+ return False, "删除后目录仍存在"
1020
+ return True, ""
1021
 
1022
 
1023
  # ═══════════════════════════════════════════════════════════
collector/core/recording_repository.py CHANGED
@@ -2,6 +2,7 @@
2
  数据仓库层 —— 录制记录的增删改查操作。
3
  """
4
 
 
5
  from typing import List, Optional
6
  from core.database import db
7
  from core.recording_record import RecordingRecord
@@ -49,6 +50,37 @@ class RecordingRepo:
49
  db.conn.execute("DELETE FROM recording WHERE id=?", (record_id,))
50
  db.conn.commit()
51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  @staticmethod
53
  def _where(file_path: str, episode_index):
54
  """匹配条件:v1.1.0 起按 (file_path, episode_index) 定位一个 episode;
 
2
  数据仓库层 —— 录制记录的增删改查操作。
3
  """
4
 
5
+ import os
6
  from typing import List, Optional
7
  from core.database import db
8
  from core.recording_record import RecordingRecord
 
50
  db.conn.execute("DELETE FROM recording WHERE id=?", (record_id,))
51
  db.conn.commit()
52
 
53
+ @staticmethod
54
+ def delete_local_episode(file_path: str, episode_index=None) -> int:
55
+ """真删除:本地文件已删净后,把该 episode 的录制行从库里删掉。
56
+
57
+ 与 mark_deleted / mark_uploaded_deleted 的区别就是不受「行保留」约束——
58
+ 删除 = 真删除(文件 + 记录一起消失),返回删除行数。
59
+ """
60
+ cond, args = RecordingRepo._where(file_path, episode_index)
61
+ cur = db.conn.execute(f"DELETE FROM recording WHERE {cond}", args)
62
+ db.conn.commit()
63
+ return cur.rowcount
64
+
65
+ @staticmethod
66
+ def delete_local_dir(dir_path: str) -> int:
67
+ """真删除:某个录制目录(整个任务/旧格式会话目录)下的全部录制行。
68
+
69
+ 路径按 abspath+normpath 归一化后比较——库里历史行可能带尾斜杠或用
70
+ 了不同的相对写法,不能只靠字符串等值。
71
+ """
72
+ if not dir_path:
73
+ return 0
74
+ target = os.path.normpath(os.path.abspath(dir_path))
75
+ rows = db.conn.execute("SELECT id, file_path FROM recording").fetchall()
76
+ ids = [r[0] for r in rows
77
+ if r[1] and os.path.normpath(os.path.abspath(r[1])) == target]
78
+ if not ids:
79
+ return 0
80
+ db.conn.executemany("DELETE FROM recording WHERE id=?", [(i,) for i in ids])
81
+ db.conn.commit()
82
+ return len(ids)
83
+
84
  @staticmethod
85
  def _where(file_path: str, episode_index):
86
  """匹配条件:v1.1.0 起按 (file_path, episode_index) 定位一个 episode;
collector/docs/config.md CHANGED
@@ -47,6 +47,7 @@ config/ 是应用全局配置、国际化与版本号所在的配置包。版本
47
  | `load_server_url` / `save_server_url` | `() -> str` / `(url: str)` | 读 / 存用户服务器地址 | 无保存值或空串时返回出厂默认 `SERVER_URL` |
48
  | `load_credentials` / `save_credentials` | `() -> tuple[str, str]` / `(username, password)` | 读 / 存登录凭据 | 返回 `(username, password)` |
49
  | `load_upload_auto_sync` / `save_upload_auto_sync` | `() -> bool` / `(on: bool)` | 读 / 存"录制完成后自动上传"开关 | 缺省默认 `True`(沿用历史行为) |
 
50
  | `load_upload_delete_after` / `save_upload_delete_after` | `() -> bool` / `(on: bool)` | 读 / 存"上传成功后自动删除本地文件"开关 | 缺省默认 `False` |
51
  | `load_device_names` / `save_device_name` / `remove_device_name` | `() -> dict` / `(key, name, sensor="")` / `(key)` | 设备命名表读 / merge-write / 删除 | `sensor` 仅 `data_ble` 用,绑定 parquet 列名;`""` 表示不动原值 |
52
  | `device_name` / `device_sensor_role` | `(key: str) -> str` | 读单个设备名 / 读绑定列名 | 兼容 `{"name": …}` 结构与旧版纯字符串;无则空串 |
@@ -85,6 +86,7 @@ config/ 是应用全局配置、国际化与版本号所在的配置包。版本
85
  | RealSense | `REALSENSE_PROFILES["D405"]` | dict | `depth_resolution`/`rgb_resolution` `(1280,720)`、`fps` 30、`depth_near_mm` 100、`depth_far_mm` 1000、`heatmap_smooth_k` 3、`heatmap_temporal_alpha` 0.5 | D405 短距近景配置 |
86
  | 设备检测 | `DEVICE_SCAN_MAX_INDEX` / `DEVICE_POLL_INTERVAL_MS` | int | 16 / 2000 | V4L2 枚举上限 / 轮询间隔(毫秒) |
87
  | 设备检测 | `REALSENSE_VID` / `REALSENSE_PID` | str | `"8086"` / `"0b07"` | Intel vendor ID / D435 product ID(其他型号靠驱动 name 兜底) |
 
88
  | 传感器 | `SENSOR_NAMES` | list | `["right_glove", "left_glove"]` | 写入 parquet observation 列名 |
89
  | 手部 | `HAND_POSE_LEFT` / `HAND_POSE_RIGHT` / `HAND_POSE_DIM` / `SENSOR_DIM` | str / str / int / int | `"left_hand_pose"` / `"right_hand_pose"` / 63(21×3)/ 256 | 手部 63 维、触觉 16×16 展平 |
90
  | 显示 | `CAMERA_MIRROR_HORIZONTAL` / `DISPLAY_FPS_LIMIT` / `FEED_MIN_WIDTH` / `FEED_MIN_HEIGHT` | bool / int / int / int | `True` / 30 / 320 / 240 | 镜像翻转(显示+录制)、GUI 帧率上限、最小画面 |
@@ -119,7 +121,7 @@ config/ 是应用全局配置、国际化与版本号所在的配置包。版本
119
 
120
  **JSON 字段约定**(由本模块的读写函数定义):
121
 
122
- - `data/server_config.json`:`server_url`(str)、`username`(str)、`password`(str)、`upload_auto_sync`(bool,缺省 `True`)、`upload_delete_after`(bool,缺省 `False`)。
123
  - `data/device_names.json`:key = `DeviceInfo.stable_key`(形如 `"uvc:{by-id前缀}"` / `"uvc:usb-1-5"`(无 by-id 链接、或该前缀被同型号多台共用而归属会翻转时,退到 USB 拓扑路径)/ `"d435:{serial}"` / `"ble:{MAC}"` 等),value = `{"name": str, "sensor"?: str}`(`sensor` 仅 `data_ble` 设备用,绑定 parquet 列名 `right_glove`/`left_glove`,按 MAC 持久化);旧版纯字符串自动升级为 `{"name": …}`。
124
  - `data/device_params.json`:key 同 `stable_key`,value = `{"exposure": {"auto": bool, "value": float}, "original": {同结构}}`;`auto=True` 时 `value` 忽略;`original` 首次看到才写入、之后永不覆盖,供"恢复默认"回到开机原厂曝光。
125
 
 
47
  | `load_server_url` / `save_server_url` | `() -> str` / `(url: str)` | 读 / 存用户服务器地址 | 无保存值或空串时返回出厂默认 `SERVER_URL` |
48
  | `load_credentials` / `save_credentials` | `() -> tuple[str, str]` / `(username, password)` | 读 / 存登录凭据 | 返回 `(username, password)` |
49
  | `load_upload_auto_sync` / `save_upload_auto_sync` | `() -> bool` / `(on: bool)` | 读 / 存"录制完成后自动上传"开关 | 缺省默认 `True`(沿用历史行为) |
50
+ | `load_device_dock_prefs` / `save_device_dock_prefs` | `() -> dict` / `(visible: bool, width: int)` | 读 / 存左侧设备检测栏的显示偏好 `{"visible": bool, "width": int}` | 缺字段/类型不对回默认(可见、`DEVICE_DOCK_DEFAULT_WIDTH`);宽度夹在 `[DEVICE_DOCK_MIN_WIDTH, 1200]` |
51
  | `load_upload_delete_after` / `save_upload_delete_after` | `() -> bool` / `(on: bool)` | 读 / 存"上传成功后自动删除本地文件"开关 | 缺省默认 `False` |
52
  | `load_device_names` / `save_device_name` / `remove_device_name` | `() -> dict` / `(key, name, sensor="")` / `(key)` | 设备命名表读 / merge-write / 删除 | `sensor` 仅 `data_ble` 用,绑定 parquet 列名;`""` 表示不动原值 |
53
  | `device_name` / `device_sensor_role` | `(key: str) -> str` | 读单个设备名 / 读绑定列名 | 兼容 `{"name": …}` 结构与旧版纯字符串;无则空串 |
 
86
  | RealSense | `REALSENSE_PROFILES["D405"]` | dict | `depth_resolution`/`rgb_resolution` `(1280,720)`、`fps` 30、`depth_near_mm` 100、`depth_far_mm` 1000、`heatmap_smooth_k` 3、`heatmap_temporal_alpha` 0.5 | D405 短距近景配置 |
87
  | 设备检测 | `DEVICE_SCAN_MAX_INDEX` / `DEVICE_POLL_INTERVAL_MS` | int | 16 / 2000 | V4L2 枚举上限 / 轮询间隔(毫秒) |
88
  | 设备检测 | `REALSENSE_VID` / `REALSENSE_PID` | str | `"8086"` / `"0b07"` | Intel vendor ID / D435 product ID(其他型号靠驱动 name 兜底) |
89
+ | 设备检测 | `DEVICE_DOCK_DEFAULT_WIDTH` / `DEVICE_DOCK_MIN_WIDTH` | int | 240 / 96 | 左侧设备检测栏首启宽度 / 允许拖到的最窄(面板内提示文案必须自动换行,否则单行 label 会把栏宽地板顶宽 —— 见 `docs/ui.md` DevicePanel 段) |
90
  | 传感器 | `SENSOR_NAMES` | list | `["right_glove", "left_glove"]` | 写入 parquet observation 列名 |
91
  | 手部 | `HAND_POSE_LEFT` / `HAND_POSE_RIGHT` / `HAND_POSE_DIM` / `SENSOR_DIM` | str / str / int / int | `"left_hand_pose"` / `"right_hand_pose"` / 63(21×3)/ 256 | 手部 63 维、触觉 16×16 展平 |
92
  | 显示 | `CAMERA_MIRROR_HORIZONTAL` / `DISPLAY_FPS_LIMIT` / `FEED_MIN_WIDTH` / `FEED_MIN_HEIGHT` | bool / int / int / int | `True` / 30 / 320 / 240 | 镜像翻转(显示+录制)、GUI 帧率上限、最小画面 |
 
121
 
122
  **JSON 字段约定**(由本模块的读写函数定义):
123
 
124
+ - `data/server_config.json`:`server_url`(str)、`username`(str)、`password`(str)、`upload_auto_sync`(bool,缺省 `True`)、`upload_delete_after`(bool,缺省 `False`)、`device_dock`(dict,设备检测栏的 `{"visible": bool, "width": int}`,缺省 `{"visible": True, "width": 240}`)。
125
  - `data/device_names.json`:key = `DeviceInfo.stable_key`(形如 `"uvc:{by-id前缀}"` / `"uvc:usb-1-5"`(无 by-id 链接、或该前缀被同型号多台共用而归属会翻转时,退到 USB 拓扑路径)/ `"d435:{serial}"` / `"ble:{MAC}"` 等),value = `{"name": str, "sensor"?: str}`(`sensor` 仅 `data_ble` 设备用,绑定 parquet 列名 `right_glove`/`left_glove`,按 MAC 持久化);旧版纯字符串自动升级为 `{"name": …}`。
126
  - `data/device_params.json`:key 同 `stable_key`,value = `{"exposure": {"auto": bool, "value": float}, "original": {同结构}}`;`auto=True` 时 `value` 忽略;`original` 首次看到才写入、之后永不覆盖,供"恢复默认"回到开机原厂曝光。
127
 
collector/docs/core.md CHANGED
@@ -450,6 +450,8 @@ JSON 读写。docstring 写明其 JSON 布局即为 `calibration/head_stereo.jso
450
  `core.database` 的单例 `db`。其中 `mark_uploaded` 支持"上传成功、本地保留"
451
  的语义(行保留、状态改为 `uploaded`),`mark_uploaded_deleted` 支持"上传
452
  成功、本地已删"(状态 `uploaded_deleted`),两者都保证历史可查。
 
 
453
 
454
  **类/函数**:
455
 
@@ -459,6 +461,8 @@ JSON 读写。docstring 写明其 JSON 布局即为 `calibration/head_stereo.jso
459
  | `RecordingRepo.list_all` | `(limit=100)` | 按 `started_at` 倒序取最近 N 条 | `List[RecordingRecord]` |
460
  | `RecordingRepo.list_by_camera` | `(camera_index, limit=50)` | 按摄像机索引筛选 | `List[RecordingRecord]` |
461
  | `RecordingRepo.delete` | `(record_id)` | 按 id 删除 | 删除并 commit |
 
 
462
  | `RecordingRepo.mark_uploaded` | `(file_path)` | 按路径把状态改为 `uploaded`(已上传、本地保留) | 受影响行数 `int` |
463
  | `RecordingRepo.mark_uploaded_deleted` | `(file_path)` | 按路径把状态改为 `uploaded_deleted` | 受影响行数 `int` |
464
 
 
450
  `core.database` 的单例 `db`。其中 `mark_uploaded` 支持"上传成功、本地保留"
451
  的语义(行保留、状态改为 `uploaded`),`mark_uploaded_deleted` 支持"上传
452
  成功、本地已删"(状态 `uploaded_deleted`),两者都保证历史可查。
453
+ **本地文件被真删除时用 `delete_local_episode` / `delete_local_dir` 把录制行
454
+ 一并真删**(2026-10-12:删除 = 真删除,文件与记录一起消失,不再留痕迹行)。
455
 
456
  **类/函数**:
457
 
 
461
  | `RecordingRepo.list_all` | `(limit=100)` | 按 `started_at` 倒序取最近 N 条 | `List[RecordingRecord]` |
462
  | `RecordingRepo.list_by_camera` | `(camera_index, limit=50)` | 按摄像机索引筛选 | `List[RecordingRecord]` |
463
  | `RecordingRepo.delete` | `(record_id)` | 按 id 删除 | 删除并 commit |
464
+ | `RecordingRepo.delete_local_episode` | `(file_path, episode_index=None)` | 本地文件已删净后真删该 episode 的录制行(DELETE,不标状态) | 删除行数 `int` |
465
+ | `RecordingRepo.delete_local_dir` | `(dir_path)` | 真删某录制目录下全部录制行(路径 abspath+normpath 归一化比较) | 删除行数 `int` |
466
  | `RecordingRepo.mark_uploaded` | `(file_path)` | 按路径把状态改为 `uploaded`(已上传、本地保留) | 受影响行数 `int` |
467
  | `RecordingRepo.mark_uploaded_deleted` | `(file_path)` | 按路径把状态改为 `uploaded_deleted` | 受影响行数 `int` |
468
 
collector/docs/data.md CHANGED
@@ -78,7 +78,7 @@
78
  | `params` | object | 任务自定义参数(如对象、用手等) |
79
  | `completed_count` | int | 录制完成次数(持久化权威值,每次录制完成 +1,与本地文件是否被删除无关) |
80
  | `status` | string | `pending` / `in_progress` / `completed`,每次加载时由 `completed_count` 与 `total_required` 重新推算 |
81
- | `hidden` | bool | 删除墓碑:用户删除过的任务标 `true`,防止后端再次推送时“复活” |
82
 
83
  行为要点(均来自代码):
84
 
@@ -89,6 +89,10 @@
89
  其余标 `hidden`。
90
  - 旧数据缺少 `completed_count` 时,一次性按 `data/recordings/<name>/` 下含
91
  `meta/info.json` 的会话目录数回填初值并写回,此后以持久化值为准。
 
 
 
 
92
 
93
  ### 2.4 `device_params.json`
94
 
@@ -326,7 +330,9 @@ data/recordings/ # 录制根目录(settings.RECORDING
326
  - ⚠ 分界是**发布版本**,不是日历日:v1.3.13(2026-09-28)起才是新帧;
327
  此前的版本**不写** `frame` 键 —— 所以旧 episode 里这两个键**不存在**,
328
  「键不在」就等于旧帧,不要当成"缺省 = 新帧"。(2026-09-22..09-27 用
329
- v1.3.12 录的也在旧帧那侧:没有键。现存数据最新一段录于 2026-09-21 21:01。)
 
 
330
  - ⚠ 帧标注是**任务级**的(`meta/info.json` 的 `features` 按并集/后写覆盖合并):
331
  一个任务目录若跨了这次改帧,它只能记住最后写入的帧,该任务早期 episode
332
  会被标错。按 episode 精确区分需要 `meta/episodes` 加列(v1.3.13 未加)——
@@ -365,6 +371,49 @@ mapping, residual_ns = align_episode("episode-000.parquet", side="left")
365
  的 `hardware_ns` 还被 `pyqtSignal(int)` 截成 32 位(每 2.147 s 翻符号)。
366
  脚本对这类 episode 明确报「无法对齐」而不是拿行号或增长率去猜。
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  ### 4.5.2 SLAM 点与视频帧的时间对齐
369
 
370
  `slam_trajectory` 每点的第 0 个字段 `t` 是**相机传感器钟**上的时间(native
 
78
  | `params` | object | 任务自定义参数(如对象、用手等) |
79
  | `completed_count` | int | 录制完成次数(持久化权威值,每次录制完成 +1,与本地文件是否被删除无关) |
80
  | `status` | string | `pending` / `in_progress` / `completed`,每次加载时由 `completed_count` 与 `total_required` 重新推算 |
81
+ | `hidden` | bool | 删除墓碑:用户删除过的任务标 `true`,防止后端再次推送时“复活”(任务页删除同时会真删本地录制目录与录制记录,这里只留墓碑;进度 `local_count` 不回退) |
82
 
83
  行为要点(均来自代码):
84
 
 
89
  其余标 `hidden`。
90
  - 旧数据缺少 `completed_count` 时,一次性按 `data/recordings/<name>/` 下含
91
  `meta/info.json` 的会话目录数回填初值并写回,此后以持久化值为准。
92
+ - 任务页「🗑 删除」= 真删除:先按 `<RECORDING_DIR>/<task_tag>/` 校验删净
93
+ (删不掉则弹窗报错且任务不隐藏),再删除该目录下的录制记录行,最后只在本
94
+ 文件写 `hidden` 墓碑。`completed_count` 等进度计数按「持久化权威值」处理,
95
+ 不随删除回退。
96
 
97
  ### 2.4 `device_params.json`
98
 
 
330
  - ⚠ 分界是**发布版本**,不是日历日:v1.3.13(2026-09-28)起才是新帧;
331
  此前的版本**不写** `frame` 键 —— 所以旧 episode 里这两个键**不存在**,
332
  「键不在」就等于旧帧,不要当成"缺省 = 新帧"。(2026-09-22..09-27 用
333
+ v1.3.12 录的也在旧帧那侧:没有键。老数据最新一段录于 2026-09-21 21:01;
334
+ 新帧那边已有 `UMI_9_29`(2026-09-29 录、204 段、`app_version` 1.3.13)——
335
+ 这条线**已经跨过去了**,同一份 `data/recordings` 里两种帧并存。)
336
  - ⚠ 帧标注是**任务级**的(`meta/info.json` 的 `features` 按并集/后写覆盖合并):
337
  一个任务目录若跨了这次改帧,它只能记住最后写入的帧,该任务早期 episode
338
  会被标错。按 episode 精确区分需要 `meta/episodes` 加列(v1.3.13 未加)——
 
371
  的 `hardware_ns` 还被 `pyqtSignal(int)` 截成 32 位(每 2.147 s 翻符号)。
372
  脚本对这类 episode 明确报「无法对齐」而不是拿行号或增长率去猜。
373
 
374
+ ##### ⚠ 姿态相对化的语义改过一次 —— 新旧数据要分开看
375
+
376
+ 上面说的「姿态相对该帧首帧」有个**乘在哪一侧**的区别,v1.3.14 之前一直是
377
+ 左边那一侧:
378
+
379
+ - **正确(v1.3.14 起)=右乘 `R · R0ᵀ`**——在世界系里表达的相对姿态
380
+ (「相对锚点转了多大」)。
381
+ - **改前=左乘 `R0ᵀ · R`**——在锚点体轴系里表达的相对转动(body delta)。
382
+ 两者数值上只差一个**常数共轭**,所以位置/幅度都「看着正常」,但读端按它解
383
+ 会让**表观旋转轴整体转错**(本机锚点下约等于绕发布帧 −Y 轴转了 90°:绕 X
384
+ 的表观变成绕 Z、绕 Z 的变成反向绕 X,只有绕 Y 正常)。修在 `0ab64d7c`
385
+ (v1.3.14 那一笔),同一笔把 `process_controller` 里描述该语义的注释同步了。
386
+
387
+ **`frame` 与 `app_version` 都分不出这两者**:两个构建写的都是
388
+ `frame=down_left_forward`,`__version__` 从 `177ef0ef`(左乘)到 `0ab64d7c`
389
+ (右乘)**一直是 `"1.3.13"`**(版本号在下一笔才抬到 1.3.14)。⇒ **不能按版本
390
+ 猜 1.3.13 的数据是哪一种**。
391
+
392
+ **三类数据要分开**(左乘那段代码换过位置,但乘的侧一直没变 —— 别以为
393
+ 「≤1.3.12 是另一条路径所以不受影响」):
394
+
395
+ | 数据 | 相对化 | 现况 |
396
+ |---|---|---|
397
+ | **新帧 + v1.3.14 起**(`frame=down_left_forward` 且 `__version__` ≥1.3.14) | 右乘 | 按右乘读,就是现状 |
398
+ | **新帧 + v1.3.13**(`frame=down_left_forward`,`app_version` 1.3.13) | **左乘** | 仓库内只有 `UMI_9_29`,**已回填成右乘**(见下) |
399
+ | **旧帧**(没有 `frame` 键,≤1.3.12) | **也是左乘** | 在 `process_controller._pose_rotation_zero` 里做的(注释原文「全部姿态左乘基准的逆」),v1.3.13 只是把它搬进 `protocol.finish_publish_pose` 而**没有改乘的侧**;**本次没动**,仓库里的旧帧库(`UMIGripper_Action_AI` 153 段等)原样留在左乘 |
400
+
401
+ ⇒ 所以**「仓库内一律按右乘解释」只对新帧那一支成立**。**旧帧数据要不要也按
402
+ 右乘重解释,需要单独判定**(左乘的差异是「绕锚点 R0 的常数共轭」,旧帧的 R0
403
+ 与轴定义与旧帧文档是配套写的,不能想当然地照新帧的结论搬)——这一条**待办**,
404
+ 不要按「旧数据没受影响」理解。
405
+
406
+ 本机新帧那一支是**已经回填好了**:`data/recordings/UMI_9_29`(204 段,
407
+ `app_version` 1.3.13)已于 2026-10-12 按右乘语义整体回填(逐点
408
+ `q_new = A · q_old · A⁻¹`,A = 绕发布帧 −Y 轴 90°,即把左乘值换成右乘值)。
409
+ 逐点核验(全部 53272 点)与**逐文件 sha256 前后值**记在
410
+ `data/recordings_rotfix_backup/UMI_9_29_rotfix_20261012/MIGRATION.json`,
411
+ 回填前的原值就存在**同一个目录**下(`data/`、`meta/` 两棵子树,204 段都在);
412
+ 逐文件核对:204/204 现场文件 = 记录的 `sha256_after`、204/204 备份 = `sha256_before`。
413
+
414
+ 拿到**仓库之外**的 1.3.13 数据时先按这条确认(`app_version` 不足以判定)。
415
+ `config/settings.py` 与 `config/__init__.py` 的帧注释块有同一提醒。
416
+
417
  ### 4.5.2 SLAM 点与视频帧的时间对齐
418
 
419
  `slam_trajectory` 每点的第 0 个字段 `t` 是**相机传感器钟**上的时间(native
collector/docs/file_format.md CHANGED
@@ -63,7 +63,9 @@ fnum = (N - 1) % 1000 # 三位零起:episode-000 .. episode-999
63
  写下 `meta/recycled_episode.json` 标记(§7.5),下一次录制优先复用该号
64
  (即使进度水位已跑在前面也不跳号);正常完成即清标记。**删除(含上传后
65
  自动删除)不回退**:已完成的 episode 序号永不复用(服务器已持有该号)。
66
- 删除即彻底删除(v1.1.2 起无 `_trash/` 回收区)。
 
 
67
  - **旧分片命名残留(读侧 MUST 容忍)**:v1.1.0 早期曾用「每 chunk 一个
68
  分片」,分片文件名 = chunk 自身编号(chunk-000 → file-000.parquet,
69
  一个文件装该 chunk 内最多 1000 个 episode 的行)。该布局已废除,新写入
@@ -107,7 +109,7 @@ fnum = (N - 1) % 1000 # 三位零起:episode-000 .. episode-999
107
  - **`features["observation.<sn>_slam_trajectory"]` 里的 `frame` / `frame_axes`**:
108
 
109
  ```json
110
- "observation.gripper_1_slam_trajectory": {
111
  "dtype": "float64", "shape": [8],
112
  "encoding": "flat_points_8_txyz_qxyzw",
113
  "frame": "down_left_forward",
@@ -115,6 +117,11 @@ fnum = (N - 1) % 1000 # 三位零起:episode-000 .. episode-999
115
  }
116
  ```
117
 
 
 
 
 
 
118
  `frame` 是帧 id,`frame_axes` 是各轴在现实中指向哪(中文名只在 GUI 上,不落盘)。
119
  两个已知 id:
120
 
@@ -123,8 +130,12 @@ fnum = (N - 1) % 1000 # 三位零起:episode-000 .. episode-999
123
  | `down_left_forward` | `{"+X":"down","+Y":"left","+Z":"forward"}` | 新帧,**v1.3.13 起的默认** |
124
  | `right_forward_up` | `{"+X":"right","+Y":"forward","+Z":"up"}` | 旧帧(`X=右/Y=正对/Z=上`) |
125
 
126
- ★注意旧帧的 `+Y` 是 `forward` 而中文叫「正对」不是「前」:两个帧的 `forward`
127
- 指的是**不同物理方向**,读端别把两个 id 的 `forward` 当成同一个。
 
 
 
 
128
 
129
  **读侧两条硬规则:**
130
 
@@ -135,9 +146,20 @@ fnum = (N - 1) % 1000 # 三位零起:episode-000 .. episode-999
135
  一个任务目录若跨了这次改帧,`info.json` 只能记住**最后写入**的那个帧,
136
  该任务早期的 episode 会被标错。要逐 episode 精确区分得往 `meta/episodes`
137
  加列(v1.3.13 未加)。跨线的任务目录请按录制日期切开。
138
-
139
- 坐标约定本身(帧的物理含义、`M` 变换、与相机相对化的关系)见本仓库
140
- `docs/data.md` 的坐标帧一节。
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  ## 4. meta/stats.json(任务级全局统计,v1.1.1 契约)
143
 
@@ -357,8 +379,33 @@ meta/tasks.jsonl # 任务级
357
  - 导入器以 `info.json.format == "pooled_episodes_v1"` 识别新格式。
358
  - **HEVC 预压缩**:zip 内 mp4 可能已被再编码为 HEVC(hvc1, CRF30)以缩小体积,
359
  与 episodes 行 `video_codec`(描述本机原始编码)**不必一致**;mkv 深度永不再编码。
360
- - 上传删除 = 删该 episode 文件组(videos/data 各 key 本 episode 文件),
361
- episodes 行、stats、total_episodes **不回退**。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
362
 
363
  ## 10. 关键点镜像布局(keypoints_output/)
364
 
 
63
  写下 `meta/recycled_episode.json` 标记(§7.5),下一次录制优先复用该号
64
  (即使进度水位已跑在前面也不跳号);正常完成即清标记。**删除(含上传后
65
  自动删除)不回退**:已完成的 episode 序号永不复用(服务器已持有该号)。
66
+ 删除即彻底删除(v1.1.2 起无 `_trash/` 回收区);2026-10-12 起删完**校验**
67
+ 三处(videos/data/meta 行)确实不在,并真删本地录制记录(`pipeline.db`
68
+ 的 recording 行),删不净如实报错。
69
  - **旧分片命名残留(读侧 MUST 容忍)**:v1.1.0 早期曾用「每 chunk 一个
70
  分片」,分片文件名 = chunk 自身编号(chunk-000 → file-000.parquet,
71
  一个文件装该 chunk 内最多 1000 个 episode 的行)。该布局已废除,新写入
 
109
  - **`features["observation.<sn>_slam_trajectory"]` 里的 `frame` / `frame_axes`**:
110
 
111
  ```json
112
+ "observation.slam_trajectory": {
113
  "dtype": "float64", "shape": [8],
114
  "encoding": "flat_points_8_txyz_qxyzw",
115
  "frame": "down_left_forward",
 
117
  }
118
  ```
119
 
120
+ ★键名带**夹爪前缀**:第 1 台夹爪前缀是空串(列就叫 `observation.slam_trajectory`,
121
+ 上面的例子就是它),第 2 台起才是 `observation.gripper_2_slam_trajectory`。
122
+ 读端请按 `observation.*slam_trajectory` 配,别写死带 `gripper_1_` 的名字——
123
+ 那个键在磁盘上**不存在**。
124
+
125
  `frame` 是帧 id,`frame_axes` 是各轴在现实中指向哪(中文名只在 GUI 上,不落盘)。
126
  两个已知 id:
127
 
 
130
  | `down_left_forward` | `{"+X":"down","+Y":"left","+Z":"forward"}` | 新帧,**v1.3.13 起的默认** |
131
  | `right_forward_up` | `{"+X":"right","+Y":"forward","+Z":"up"}` | 旧帧(`X=右/Y=正对/Z=上`) |
132
 
133
+ ★旧帧的 `+Y` 也叫 `forward`(中文写「正对」不是「前」),而它和新帧 `+Z` 的
134
+ `forward` **是同一个物理方向** —— M 恰好把中间帧 +Y 映到发布帧 +Z(实测映射:
135
+ 旧+X 右→新−Y **右**、旧+Y 正对→新+Z 前、旧+Z 上→新−X **上**;后两项的方向词
136
+ 读的是**落点那个轴是什么方向**,不是新帧同名字母的含义)。所以:
137
+ **`forward` 可以跨帧等同,轴字母不行**。旧 `+Y` 与新 `+Y` 差 90°(前者正对、
138
+ 后者左),读端别按轴字母把两个帧对齐,要按 `frame_axes` 里的方向词。
139
 
140
  **读侧两条硬规则:**
141
 
 
146
  一个任务目录若跨了这次改帧,`info.json` 只能记住**最后写入**的那个帧,
147
  该任务早期的 episode 会被标错。要逐 episode 精确区分得往 `meta/episodes`
148
  加列(v1.3.13 未加)。跨线的任务目录请按录制日期切开。
149
+ 3. **帧是一回事、姿态相对化是另一回事**:相对化从 ≤1.3.12 起一直是**左乘**
150
+ `R0ᵀ·R`(锚点体轴系里的 body delta;先在 `process_controller`,v1.3.13 起搬到
151
+ `protocol.finish_publish_pose`,乘的侧没变),v1.3.14 才改成正确的**右乘**
152
+ `R·R0ᵀ`。两者只差一个常数共轭(位置/幅度看着都正常,但读端按错的那支解会让
153
+ **表观旋转轴整体转错**),而 `frame` 与 `app_version` **都分不出**(两个构建
154
+ 都是 `down_left_forward`、`__version__` 都写 `"1.3.13"`)。
155
+ **新帧**数据已按右乘回填(`data/recordings/UMI_9_29`,逐文件 sha256 记在其
156
+ rotfix 备份目录的 `MIGRATION.json`);**旧帧**数据(无 `frame` 键)同样是左乘、
157
+ 本次**没动**,要不要重解释**待判定**。详见 `docs/data.md`。
158
+
159
+ 坐标约定本身(各轴在现实中指向哪、原点与首帧相对化、两套帧 id、回落开关)
160
+ 见本仓库 `docs/data.md` 的「SLAM 轨迹的坐标帧」一节。发布侧那张换基矩阵 `M`
161
+ (`core/gripper/slam/protocol.py` 的 `PUBLISH_RELABEL_QUAT` / `PUBLISH_RELABEL_MATRIX`)
162
+ 是**实现细节**,`data.md` 里没有、读端也不需要:按 `frame_axes` 解释数值即可。
163
 
164
  ## 4. meta/stats.json(任务级全局统计,v1.1.1 契约)
165
 
 
379
  - 导入器以 `info.json.format == "pooled_episodes_v1"` 识别新格式。
380
  - **HEVC 预压缩**:zip 内 mp4 可能已被再编码为 HEVC(hvc1, CRF30)以缩小体积,
381
  与 episodes 行 `video_codec`(描述本机原始编码)**不必一致**;mkv 深度永不再编码。
382
+ - 上传删除 = 删该 episode 文件组(videos/data 各 key 本 episode 文件 + 本段
383
+ episodes 行文件),删完**校验三处都真的没了**(`pooled_episode_leftovers`),
384
+ 没删净如实报错;stats、total_episodes **不回退**(total_episodes 由下次录制
385
+ 按 `list_task_episodes` 重算),episode 序号永不复用。
386
+ - **删除入口的编号口径**:`delete_pooled_episode`/`delete_local_session` 收的是
387
+ 全局号 **N(1 基)**,和上传器(`episode_chunk_file(N)`)、`UploadManager` 的
388
+ `(path, episode_index)` 防重键同一口径。文件名里的 `f` 是 **chunk 内 0 基**
389
+ 文件号,**跨 chunk 后 `f + 1` 不再是 N**:chunk-000 满 1000 段就开 chunk-001
390
+ 并从 `episode-000` 重新计数,所以
391
+
392
+ ```
393
+ N = c * 1000 + f + 1 (c = chunk 号,1000 = POOLED_CHUNK_SIZE)
394
+ ```
395
+
396
+ **统一由 `core.helpers.list_task_episodes(task_dir)` 还原**(它按
397
+ `chunk*POOLED_CHUNK_SIZE + int(f) + 1` 产出全局号,是权威枚举器),不要自己
398
+ 拿文件名拼 N。少 +1 删错段;多 +1 会指向不存在的 N+1(此时「没找到任何文件」
399
+ 被幂等判为已删净而返回 True,界面报成功、文件一个没动)。
400
+ **f 回绕尤其危险**:chunk-001 出现后再取「最大文件名 f + 1」,得到的是
401
+ chunk-000 里**真实存在**的前一段(f=0 → N=1),于是静默上传/删除到错的段。
402
+ lite 的取号口径历史上错过两次:先是**少 +1**(返回 0 基文件号 f 当 N 用),
403
+ 改成 `max(f) + 1` 后又在跨 chunk 时**回绕**;两处都已修成走
404
+ `list_task_episodes`(`tools/tests/lite_delete_test.py` 第 1/4/6 节分别钉
405
+ 「ep 是全局 N」「单段目录 ep=1 不是 0」「跨 chunk 的 ep=1001 不是 1」)。
406
+ `_delete_after_upload` 本身一直是原样透传(没有 +1 可去),变的一直是它上游
407
+ 的取号口径。旧格式(`data/` 下没有任何 `chunk-*/episode-*.parquet`)按整目录
408
+ 处理,取号返回 0。
409
 
410
  ## 10. 关键点镜像布局(keypoints_output/)
411
 
collector/docs/tools.md CHANGED
@@ -422,7 +422,7 @@ venv/bin/python tools/audit_frame_gaps.py [任务目录] [选项] # 默认 dat
422
 
423
  ## tests/ 测试
424
 
425
- 测试脚本见 `tools/tests/`(当前 73 份;离线测试加 `QT_QPA_PLATFORM=offscreen` 无需任何硬件,真机测试需对应设备在线)。完整运行命令见 `README.md` 测试节。**下表只列了一部分**(早期 16 份 + 帧空洞相关的 5 份 + 夹爪配对/离线入口 + 位姿坐标帧的 5 份),其余按需现查文件名。
426
 
427
  | 文件 | 类型 | 一句话 |
428
  | --- | --- | --- |
@@ -436,6 +436,7 @@ venv/bin/python tools/audit_frame_gaps.py [任务目录] [选项] # 默认 dat
436
  | `glove_widget_test.py` | 离线 | 手套控件配置路由 |
437
  | `grid_drag_fps_test.py` | 离线 | 网格拖拽/分割条 |
438
  | `device_panel_gui_smoke_test.py` | 离线 | 设备面板 GUI 冒烟 |
 
439
  | `test_device_detector.py` | 离线 | 设备枚举 |
440
  | `d405_worker_test.py` | 真机 | D405 采集 worker |
441
  | `d435_e2e_test.py` | 真机 | D435 端到端 |
@@ -454,3 +455,9 @@ venv/bin/python tools/audit_frame_gaps.py [任务目录] [选项] # 默认 dat
454
  | `test_gripper_slam_columns.py` | 离线 | SLAM 落盘列契约:**只落 `slam_trajectory`、不再落 `slam_pose`**(后者是被 26.5% 填零的定长 `[0]*7`,下游会把零当真实位姿) |
455
  | `test_gripper_latest_pose_frame.py` | 离线 | 原生帧 → 发布帧的实际差值就是那张 M(位置 `M·Rz(+90°)`、姿态还多一层体轴改名 `Mᵀ`),拿真数据比对而不是复述公式 |
456
  | `test_pose_view_qt.py` | 离线 | 位姿视图:轨迹常驻、世界轴与**跟随夹爪的三箭头**重合且标签带方向词、三箭头取**列**(体轴)不取行、夹爪模型换基后逐像素不变、主窗口那次四元数→矩阵转换把帧带上 |
 
 
 
 
 
 
 
422
 
423
  ## tests/ 测试
424
 
425
+ 测试脚本见 `tools/tests/`(当前 79 份;离线测试加 `QT_QPA_PLATFORM=offscreen` 无需任何硬件,真机测试需对应设备在线)。完整运行命令见 `README.md` 测试节。**下表只列了一部分**(早期 16 份 + 帧空洞相关的 5 份 + 夹爪配对/离线入口 + 位姿坐标帧的 5 份 + 删除真删除的 3 份 + 发布死区/跳变门的 3 份),其余按需现查文件名。
426
 
427
  | 文件 | 类型 | 一句话 |
428
  | --- | --- | --- |
 
436
  | `glove_widget_test.py` | 离线 | 手套控件配置路由 |
437
  | `grid_drag_fps_test.py` | 离线 | 网格拖拽/分割条 |
438
  | `device_panel_gui_smoke_test.py` | 离线 | 设备面板 GUI 冒烟 |
439
+ | `device_dock_collapse_test.py` | 离线 | 设备检测栏 缩小/隐藏(栏宽地板回归哨兵 + ×/菜单收起 + 偏好持久化) |
440
  | `test_device_detector.py` | 离线 | 设备枚举 |
441
  | `d405_worker_test.py` | 真机 | D405 采集 worker |
442
  | `d435_e2e_test.py` | 真机 | D435 端到端 |
 
455
  | `test_gripper_slam_columns.py` | 离线 | SLAM 落盘列契约:**只落 `slam_trajectory`、不再落 `slam_pose`**(后者是被 26.5% 填零的定长 `[0]*7`,下游会把零当真实位姿) |
456
  | `test_gripper_latest_pose_frame.py` | 离线 | 原生帧 → 发布帧的实际差值就是那张 M(位置 `M·Rz(+90°)`、姿态还多一层体轴改名 `Mᵀ`),拿真数据比对而不是复述公式 |
457
  | `test_pose_view_qt.py` | 离线 | 位姿视图:轨迹常驻、世界轴与**跟随夹爪的三箭头**重合且标签带方向词、三箭头取**列**(体轴)不取行、夹爪模型换基后逐像素不变、主窗口那次四元数→矩阵转换把帧带上 |
458
+ | `test_pose_publish_deadband.py` | 离线 | 发布死区语义:AR(1) 静止游走被冻结(>95% 帧保持、且保持的是上次的值)、慢速移动照常推进且误差不超死区、真实会话回放(`logs/` 不入库时会 SKIP,别把 SKIP 读成 PASS) |
459
+ | `test_gripper_pose_deadband_publish.py` | 离线 | 死区真接在**发布出口**(`_publish_pose`):静止发旧值、节拍/序号/丢失计数不变、每个发布值都是某帧原始值的发布帧像(替换而非插值/平均)、重锚丢参照、开机日志有那行可验收 |
460
+ | `test_pose_guard_limits.py` | 离线 | 跳变门夹在实测包络中间(真实单步 ≤0.0601m 放行 / 异常 ≥0.1075m 拦下)、限值不再随 20/20/60ms 抽帧膨胀、native `kPose*` 与 Python 常量逐字一致 |
461
+ | `test_local_delete.py` | 离线 | 删除即真删除:池化一段的三处文件(data/视频/meta)都清掉且不牵连邻居、删不掉时 `False` + 残留清单 + 中文报错而不是假装删掉、旧格式会话目录 rmtree 复检、录制行真删(含尾斜杠归一化、空路径 0 行) |
462
+ | `test_task_delete.py` | 离线 | 任务页「🗑 删除」= 真删目录 + 真删录制行 + 只留 `hidden` 墓碑(确认框写明目录与片段数);目录删不掉时**不隐藏任务**并弹窗报错 |
463
+ | `lite_delete_test.py` | 离线 | lite 上传后删除的编号回归:列表 ep 与删除入参都必须是**全局号 N**(`_episode_max` 要把文件号 +1;`_delete_after_upload` 不许再加工——多 +1 会指向不存在的 N+1,残留复检空集 → 报成功却一个文件没删)、单段目录只删该段不 rmtree、无 parquet 的旧目录整目录删 |
collector/docs/ui.md CHANGED
@@ -34,7 +34,7 @@
34
 
35
  ### ui/main_window.py
36
 
37
- **作用**:应用程序主窗口,界面层中枢。组合页面栈(任务选择页 + 数据采集页)、`CameraGrid` 主网格、左侧 `DevicePanel` 设备面板、底部日志 dock、右侧录制历史 dock、工具栏与菜单;启动时经 `_show_login_flow` 弹 `LoginDialog` 确定账号/游客身份,首次启动经 `_show_guide` 弹 `GuideDialog` 使用说明;持有 `CameraPipeline`、`DeviceScanner`、`TaskService`、`UploadManager` 与 `core.device_manager.DeviceManager` 注册表(`_workers` 为其 `entries` 别名)等核心对象并负责信号接线。所有设备的开启/关闭都经左侧面板开关按 `core.device_manager.dispatch_toggle` 分派到 `_open_*`/`_close_*` 系列方法:S80M 双目子进程 + 管道解析在 `core.s80m_manager.S80MDeviceManager`,D435 worker 生命周期与帧口径在 `core.d435_manager.D435DeviceManager`,手套以 BLE 引擎 + 渲染定时器运行。
38
 
39
  **模块级辅助与常量**(v1.0.3 后多为 core 模块的兼容 re-export,离线测试直接 patch 这些名字):
40
 
@@ -129,8 +129,8 @@
129
  | `_open_playback` | `()` | 「📂 回放」:`PlaybackDialog(self, settings.RECORDING_DIR)` 模态打开 | — |
130
  | `_open_upload` | `()` | 「☁ 上传」:`UploadDialog(self, settings.RECORDING_DIR, session=TaskService._session)` 模态打开 | — |
131
  | `_on_upload_task_done` | `(task_id)` | 自动上传完成:记日志;`UPLOAD_DELETE_AFTER` 开启时后台线程删本地目录,否则 `RecordingRepo.mark_uploaded` 标「已上传(本地保留)」并刷新历史 | — |
132
- | `_delete_session_after_upload` | `(session_path)` | 后台线程 `shutil.rmtree`(防大目录阻塞 UI),结果经 `_upload_session_deleted` 回主线程 | — |
133
- | `_on_upload_session_deleted` | `(session_path, err)` | 删除收尾:失败记日志;成功 `RecordingRepo.mark_uploaded_deleted` 保留历史行(状态「已上传,本地已删」)并刷新 | — |
134
  | `_on_upload_task_failed` | `(task_id, error)` | 自动上传失败(重试 3 次后仍失败才触发)→ 日志 | — |
135
  | `_on_upload_auto_toggled` / `_on_upload_delete_toggled` | `(on)` | 两个工具栏开关:`settings.save_upload_auto_sync` / `save_upload_delete_after` 持久化 + 运行时立即生效 + 按钮文字/样式刷新 | — |
136
  | `_style_toolbar_toggle` | `(action)` | 开关类 QAction 按钮高亮样式(开启绿底白字加粗) | — |
@@ -144,7 +144,7 @@
144
  | `_on_switch_account` | `()` | 任务页「切换账号」按钮 → 重开登录对话框 | — |
145
  | `_on_identity_expired` | `()` | 登录态轮询遇 401/403 → 提示重登;关闭对话框(Esc)自动降级游客 | — |
146
  | `_on_task_confirmed` | `(task)` | 选中任务进入采集页(index 1),更新窗口标题;不自动扫描,提示先选模式再点扫描 | — |
147
- | `_on_page_changed` | `(index)` | 页面切换控制工具栏/dock 可见性;任务页停设备轮询,采集页立即扫描 + 启动 `_device_timer`(2s 轮询) | — |
148
  | `_go_back_to_task_selection` | `()` | 返回任务选择:录制中先确认(同意则 abort);取消手部后处理/标注;`_teardown_all_workers` + 清网格 + 清面板状态;切回 index 0 并 `poll_now()` 刷新任务列表 | — |
149
 
150
  **其余(语言/视图/手部后处理/日志/退出)**:
@@ -153,6 +153,12 @@
153
  | --- | --- | --- | --- |
154
  | `_on_language_changed` | `(lang)` | 中英文切换:菜单/工具栏/dock 标题/历史表头/手部模式下拉全部 `tr()` 重刷,并调 `_task_page._on_language_changed` | — |
155
  | `_reset_camera_sizes` | `()` | 视图菜单「重置画面大小」:`grid._rebuild_layout()` 等分 | — |
 
 
 
 
 
 
156
  | `_update_status` | `()` | 状态栏:摄像机数(排除 `sensor:`/`ble:` 占位槽)+ 录制中标记与颜色 | — |
157
  | `_process_hand_keypoints` | `(session_path="", silent=False)` | 后台提取手部关键点(`SessionHandProcessor.process_session`,mode 由手套/裸手下拉决定);已有结果时弹确认(silent 模式跳过);无路径用最近一条录制 | — |
158
  | `_on_hand_proc_progress` / `_on_hand_proc_finished` | `(current, total)` / `(session_path, error)` | 进度 → 状态栏+按钮;完成后 300ms 自动触发 `_auto_label`(silent) | — |
@@ -270,6 +276,7 @@
270
 
271
  - `_ICON = {"uvc": "📹", "d435": "🔭", "s80m": "👁", "data_ble": "🧤", "usb_glove": "🧤", "ble": "🎧", "gripper": "🤖"}`;`_GROUP_ORDER = ["camera", "glove", "gripper", "other_ble"]`;`_GROUP_TITLE` 对应中文组标题。
272
  - `_items`:device key → `QTreeWidgetItem`;`_last_check`:key → 上次勾选状态(防 `itemChanged` 误触发);`_group_expanded`:组展开状态(初始 camera/glove 展开、other_ble 折叠);`_has_gripper`:列表里是否有夹爪(决定底部提示是否提右键入口,两个分支都要更新,空设备分支需重置)。
 
273
  - 命名持久化:`config.settings.save_device_name`(device_names.json)。
274
 
275
  **调用关系**:由 `ui/main_window.py` 构造并连接 `device_toggled`/`device_renamed`/`gripper_recalibration_requested`/`gripper_binding_requested`/`gripper_diagnostics_requested`(信号名按字符串 connect,写错不会报错、只是永远不触发 —— `tools/tests/test_gripper_esp_binding.py` §9 静态比对两处名字);`tools/tests/device_panel_gui_smoke_test.py` 直接构造。依赖 `config.settings.save_device_name`、`config.i18n.tr`。夹爪右键三条链路的完整链路(关夹爪 → 后台线程 → 完成后按原样开回)见 `docs/core.md`「夹爪」节与 `ui/main_window.py` 的 `_on_gripper_recalibration` / `_on_gripper_binding` / `_on_gripper_diagnostics`;三条共用 `_gripper_offline_guard`(序列号+录制检查)、`_gripper_offline_take_over`(让出串口)、`_reopen_after_offline`(善后)三件骨架。
@@ -423,7 +430,7 @@
423
  **★ 坐标帧契约(v1.3.13)**:发布侧 `core/gripper/slam/protocol.py` 给位姿做了两次左乘 + 一次右乘(右乘 = **体轴改名**),本控件是那个契约的另一半:
424
 
425
  - `_draw_frame_axes` 画的是 `rotation` 的**三列**(= 夹爪体轴),不是三行。同一张 `M` 也在发布侧给体轴改了名,所以重新锚定那一瞬发布姿态 = `M·Mᵀ` = 单位矩阵,**跟随夹爪的三箭头与世界三箭头重合**,标签同写 下/左/前。
426
- - `_draw_gripper` 用 `_BODY_RELABEL`(与发布侧同一张 `M`)把模型顶点从旧体轴坐标搬到新体轴坐标,于是 `position + R·(M��v) == position + R_旧·v`,**夹爪的物理朝向与画面逐像素与改前相同**。
427
  - 后果:**只改发布侧不改这里(或反过来)夹爪会被画歪 120°**,而两种改法各自都"看起来自洽"——别只动一处。测试见 `tools/tests/test_pose_view_qt.py`。
428
  - 三箭头的轴名来自 `config.settings.GRIPPER_SLAM_FRAME_AXES_CN`,**不自己抄**(屏幕上的字与落进 `info.json` 的 `frame_axes` 必须同源)。
429
 
@@ -464,7 +471,7 @@
464
  | `current_task` | `() -> dict \| None` | 当前选中行任务 | dict 或 None |
465
  | `_on_cell_changed` | `(row, _col, _pr, _pc)` | 选行联动:completed 任务禁用「进入采集」(tooltip 提示换任务) | — |
466
  | `_on_double_clicked` / `_on_enter_clicked` | `()` | 双击行=快捷进入;进入前 completed 任务弹警告 | 发射 `task_selected` |
467
- | `_do_delete_task` | `()` | 删除任务(按钮与右键菜单共用):确认后 `mark_hidden`(可在 tasks.json 中恢复)→ 重载列表 | — |
468
  | `_on_context_menu` | `(pos)` | 右键菜单「🗑 删除任务」 | — |
469
  | `_on_refresh_clicked` | `()` | 发射 `refresh_requested`,按钮 1.5s 内显示「刷新中…」禁用 | — |
470
  | `_on_language_changed` | `(lang)` | 表头/占位符/按钮文字刷新并重建表 | — |
@@ -501,8 +508,8 @@
501
  | `_on_status` | `(task_id, msg)` | 串行处理时状态栏显示「第 x/y 条 [会话名] 当前动作」 | — |
502
  | `_on_progress` | `(task_id, ratio)` | 总进度 =(已完成数 + 当前比例)/ 总数 | 进度条 |
503
  | `_on_task_done` | `(task_id)` | 单条完成:刷新列表;`UPLOAD_DELETE_AFTER` 开启时 `_delete_after_upload`(与主窗口自动上传同规则),否则 `RecordingRepo.mark_uploaded` + 主窗口日志「☁ 上传完成(本地保留)」 | — |
504
- | `_delete_after_upload` | `(session_path)` | 后台线程 `shutil.rmtree`,结果经 `_session_deleted` 回主线程 | — |
505
- | `_on_session_deleted` | `(session_path, err)` | 删除收尾:失败仅提示;成功 `RecordingRepo.mark_uploaded_deleted` + 刷新列表 + 主窗口日志「☁ 上传完成,本地文件已删除」 | — |
506
  | `_on_task_failed` / `_on_all_done` | `(task_id, error)` / `()` | 失败:状态栏「❌ 上传失败」+ 主窗口日志 + 刷新列表 / 全部完成:恢复按钮、进度条 100、状态「全部完成」 | — |
507
  | `closeEvent` | `(event)` | 停止 `_manager` | — |
508
 
 
34
 
35
  ### ui/main_window.py
36
 
37
+ **作用**:应用程序主窗口,界面层中枢。组合页面栈(任务选择页 + 数据采集页)、`CameraGrid` 主网格、左侧 `DevicePanel` 设备面板(`_device_dock`:可拖宽/拖窄,带 Closable 故标题栏有 × 可收起;宽度与收起状态经 `server_config.json` 的 `device_dock` 段跨启动保持,恢复入口为「视图 ▸ 设备检测栏」/ Ctrl+D)、底部日志 dock、右侧录制历史 dock、工具栏与菜单;启动时经 `_show_login_flow` 弹 `LoginDialog` 确定账号/游客身份,首次启动经 `_show_guide` 弹 `GuideDialog` 使用说明;持有 `CameraPipeline`、`DeviceScanner`、`TaskService`、`UploadManager` 与 `core.device_manager.DeviceManager` 注册表(`_workers` 为其 `entries` 别名)等核心对象并负责信号接线。所有设备的开启/关闭都经左侧面板开关按 `core.device_manager.dispatch_toggle` 分派到 `_open_*`/`_close_*` 系列方法:S80M 双目子进程 + 管道解析在 `core.s80m_manager.S80MDeviceManager`,D435 worker 生命周期与帧口径在 `core.d435_manager.D435DeviceManager`,手套以 BLE 引擎 + 渲染定时器运行。
38
 
39
  **模块级辅助与常量**(v1.0.3 后多为 core 模块的兼容 re-export,离线测试直接 patch 这些名字):
40
 
 
129
  | `_open_playback` | `()` | 「📂 回放」:`PlaybackDialog(self, settings.RECORDING_DIR)` 模态打开 | — |
130
  | `_open_upload` | `()` | 「☁ 上传」:`UploadDialog(self, settings.RECORDING_DIR, session=TaskService._session)` 模态打开 | — |
131
  | `_on_upload_task_done` | `(task_id)` | 自动上传完成:记日志;`UPLOAD_DELETE_AFTER` 开启时后台线程删本地目录,否则 `RecordingRepo.mark_uploaded` 标「已上传(本地保留)」并刷新历史 | — |
132
+ | `_delete_session_after_upload` | `(session_path, episode_index=0)` | 后台线程 `delete_local_session`(真删除 + 删除后校验,防大目录阻塞 UI),结果经 `_upload_session_deleted` 回主线程 | — |
133
+ | `_on_upload_session_deleted` | `(session_path, err)` | 删除收尾:失败(含校验残留)记日志;成功 `RecordingRepo.delete_local_episode` 真删录制行并刷新 | — |
134
  | `_on_upload_task_failed` | `(task_id, error)` | 自动上传失败(重试 3 次后仍失败才触发)→ 日志 | — |
135
  | `_on_upload_auto_toggled` / `_on_upload_delete_toggled` | `(on)` | 两个工具栏开关:`settings.save_upload_auto_sync` / `save_upload_delete_after` 持久化 + 运行时立即生效 + 按钮文字/样式刷新 | — |
136
  | `_style_toolbar_toggle` | `(action)` | 开关类 QAction 按钮高亮样式(开启绿底白字加粗) | — |
 
144
  | `_on_switch_account` | `()` | 任务页「切换账号」按钮 → 重开登录对话框 | — |
145
  | `_on_identity_expired` | `()` | 登录态轮询遇 401/403 → 提示重登;关闭对话框(Esc)自动降级游客 | — |
146
  | `_on_task_confirmed` | `(task)` | 选中任务进入采集页(index 1),更新窗口标题;不自动扫描,提示先选模式再点扫描 | — |
147
+ | `_on_page_changed` | `(index)` | 页面切换控制工具栏/dock 可见性;任务页停设备轮询,采集页立即扫描 + 启动 `_device_timer`(2s 轮询);末尾统一调 `_apply_device_dock_visibility`(设备栏:任务页隐藏、采集页显示,但用户收起过就保持收起) | — |
148
  | `_go_back_to_task_selection` | `()` | 返回任务选择:录制中先确认(同意则 abort);取消手部后处理/标注;`_teardown_all_workers` + 清网格 + 清面板状态;切回 index 0 并 `poll_now()` 刷新任务列表 | — |
149
 
150
  **其余(语言/视图/手部后处理/日志/退出)**:
 
153
  | --- | --- | --- | --- |
154
  | `_on_language_changed` | `(lang)` | 中英文切换:菜单/工具栏/dock 标题/历史表头/手部模式下拉全部 `tr()` 重刷,并调 `_task_page._on_language_changed` | — |
155
  | `_reset_camera_sizes` | `()` | 视图菜单「重置画面大小」:`grid._rebuild_layout()` 等分 | — |
156
+ | `_apply_device_dock_visibility` | `()` | 设备栏可见性的唯一口径:`当前页==1 且 用户未收起` → `setVisible` + `resizeDocks` 复原宽度;期间置 `_device_dock_syncing` 挡「程序化改动被当成用户收起」 | 末尾同步菜单勾选 |
157
+ | `_toggle_device_dock` | `(checked)` | 视图菜单「设备检测栏」/ Ctrl+D:置 `_device_dock_user_hidden` → 应用可见性 → 落盘 | 任务页只记偏好不显示 |
158
+ | `_on_device_dock_visibility` | `(visible)` | 设备栏可见性变化(标题栏 × 收起):`_device_dock_syncing` 置位或不在采集页时直接返回;否则记用户收起 + 同步菜单 + 落盘 | — |
159
+ | `_sync_device_dock_action` | `()` | 菜单勾选 = 用户是否想看设备栏(与当前页是否显示无关;`blockSignals` 防递归) | — |
160
+ | `_save_device_dock_prefs` | `()` | `settings.save_device_dock_prefs(未收起, 栏宽)`;宽度由 `eventFilter`(Resize) 持续跟随 | closeEvent 也调一次 |
161
+ | `eventFilter` | `(obj, event)` | 仅拦 `_device_dock` 的 `QEvent.Resize` → 记 `_device_dock_width`(拖窄/拖宽后收起或退出时才能落盘正确宽度) | 其余事件走 `super()` |
162
  | `_update_status` | `()` | 状态栏:摄像机数(排除 `sensor:`/`ble:` 占位槽)+ 录制中标记与颜色 | — |
163
  | `_process_hand_keypoints` | `(session_path="", silent=False)` | 后台提取手部关键点(`SessionHandProcessor.process_session`,mode 由手套/裸手下拉决定);已有结果时弹确认(silent 模式跳过);无路径用最近一条录制 | — |
164
  | `_on_hand_proc_progress` / `_on_hand_proc_finished` | `(current, total)` / `(session_path, error)` | 进度 → 状态栏+按钮;完成后 300ms 自动触发 `_auto_label`(silent) | — |
 
276
 
277
  - `_ICON = {"uvc": "📹", "d435": "🔭", "s80m": "👁", "data_ble": "🧤", "usb_glove": "🧤", "ble": "🎧", "gripper": "🤖"}`;`_GROUP_ORDER = ["camera", "glove", "gripper", "other_ble"]`;`_GROUP_TITLE` 对应中文组标题。
278
  - `_items`:device key → `QTreeWidgetItem`;`_last_check`:key → 上次勾选状态(防 `itemChanged` 误触发);`_group_expanded`:组展开状态(初始 camera/glove 展开、other_ble 折叠);`_has_gripper`:列表里是否有夹爪(决定底部提示是否提右键入口,两个分支都要更新,空设备分支需重置)。
279
+ - ⚠️ 面板内**所有 QLabel 提示都必须 `setWordWrap(True)`**:不换行的 QLabel,其 `minimumSizeHint().width()` 就是整行文字宽,会成为面板与停靠栏的硬地板。底部提示那句中文(「双击设备可重命名;右键夹爪可重读标定 / 配对序列号 / 串口诊断」)曾在夹爪在场时把栏宽地板顶到 289px,导致设备栏**只能拖宽、拖不窄**(2026-10-13 修);面板另设 `setMinimumWidth(settings.DEVICE_DOCK_MIN_WIDTH)` 作为可拖到的最窄。回归哨兵见 `tools/tests/device_dock_collapse_test.py`。
280
  - 命名持久化:`config.settings.save_device_name`(device_names.json)。
281
 
282
  **调用关系**:由 `ui/main_window.py` 构造并连接 `device_toggled`/`device_renamed`/`gripper_recalibration_requested`/`gripper_binding_requested`/`gripper_diagnostics_requested`(信号名按字符串 connect,写错不会报错、只是永远不触发 —— `tools/tests/test_gripper_esp_binding.py` §9 静态比对两处名字);`tools/tests/device_panel_gui_smoke_test.py` 直接构造。依赖 `config.settings.save_device_name`、`config.i18n.tr`。夹爪右键三条链路的完整链路(关夹爪 → 后台线程 → 完成后按原样开回)见 `docs/core.md`「夹爪」节与 `ui/main_window.py` 的 `_on_gripper_recalibration` / `_on_gripper_binding` / `_on_gripper_diagnostics`;三条共用 `_gripper_offline_guard`(序列号+录制检查)、`_gripper_offline_take_over`(让出串口)、`_reopen_after_offline`(善后)三件骨架。
 
430
  **★ 坐标帧契约(v1.3.13)**:发布侧 `core/gripper/slam/protocol.py` 给位姿做了两次左乘 + 一次右乘(右乘 = **体轴改名**),本控件是那个契约的另一半:
431
 
432
  - `_draw_frame_axes` 画的是 `rotation` 的**三列**(= 夹爪体轴),不是三行。同一张 `M` 也在发布侧给体轴改了名,所以重新锚定那一瞬发布姿态 = `M·Mᵀ` = 单位矩阵,**跟随夹爪的三箭头与世界三箭头重合**,标签同写 下/左/前。
433
+ - `_draw_gripper` 用 `_BODY_RELABEL`(与发布侧同一张 `M`)把模型顶点从旧体轴坐标搬到新体轴坐标,于是 `position + R·(M·v) == position + R_旧·v`,**夹爪的物理朝向与画面逐像素与改前相同**(该恒等式对任意 `R`、`v` 代数上精确成立 —— `M·R·Mᵀ·M·v = M·R·v`,因为 `MᵀM = I`;渲染侧由 `test_pose_view_qt.py::check_gripper_model_frame_invariant` 逐像素钉住)。★式中的 `R_旧` 是**改前那次发布的姿态**(已含发布侧左乘的 `M`),不是夹爪自己的相对姿态 `R_rel` —— 拿 `R_rel` 代进去会差一个 `M`,算出"夹爪歪了 0.3–0.4 m"的假警报;对照:两处都改时残差只在浮点噪声量级,只改一半就是 0.3–0.4 m 的实偏差。
434
  - 后果:**只改发布侧不改这里(或反过来)夹爪会被画歪 120°**,而两种改法各自都"看起来自洽"——别只动一处。测试见 `tools/tests/test_pose_view_qt.py`。
435
  - 三箭头的轴名来自 `config.settings.GRIPPER_SLAM_FRAME_AXES_CN`,**不自己抄**(屏幕上的字与落进 `info.json` 的 `frame_axes` 必须同源)。
436
 
 
471
  | `current_task` | `() -> dict \| None` | 当前选中行任务 | dict 或 None |
472
  | `_on_cell_changed` | `(row, _col, _pr, _pc)` | 选行联动:completed 任务禁用「进入采集」(tooltip 提示换任务) | — |
473
  | `_on_double_clicked` / `_on_enter_clicked` | `()` | 双击行=快捷进入;进入前 completed 任务弹警告 | 发射 `task_selected` |
474
+ | `_do_delete_task` | `()` | 删除任务(按钮与右键菜单共用):二次确认后 `delete_local_session` 真删该任务录制目录 + `RecordingRepo.delete_local_dir` 删录制行,再 `mark_hidden` 留墓碑 → 重载列表;目录没删净则弹错并**不隐藏**任务。删除目标先做兜底校验(必须是 `RECORDING_DIR` 的**直接子目录**)——`_sanitize_tag` 把纯空白任务名清洗成空串后 `task_dir_of` 会退化成 `RECORDING_DIR` 本身,不拦就会把**所有任务**的录制整根 rmtree | — |
475
  | `_on_context_menu` | `(pos)` | 右键菜单「🗑 删除任务」 | — |
476
  | `_on_refresh_clicked` | `()` | 发射 `refresh_requested`,按钮 1.5s 内显示「刷新中…」禁用 | — |
477
  | `_on_language_changed` | `(lang)` | 表头/占位符/按钮文字刷新并重建表 | — |
 
508
  | `_on_status` | `(task_id, msg)` | 串行处理时状态栏显示「第 x/y 条 [会话名] 当前动作」 | — |
509
  | `_on_progress` | `(task_id, ratio)` | 总进度 =(已完成数 + 当前比例)/ 总数 | 进度条 |
510
  | `_on_task_done` | `(task_id)` | 单条完成:刷新列表;`UPLOAD_DELETE_AFTER` 开启时 `_delete_after_upload`(与主窗口自动上传同规则),否则 `RecordingRepo.mark_uploaded` + 主窗口日志「☁ 上传完成(本地保留)」 | — |
511
+ | `_delete_after_upload` | `(session_path, episode_index)` | 后台线程 `delete_local_session`(真删除 + 删除后校验),结果经 `_session_deleted` 回主线程 | — |
512
+ | `_on_session_deleted` | `(session_path, err)` | 删除收尾:失败仅提示(含校验残留);成功 `RecordingRepo.delete_local_episode` 真删录制行 + 刷新列表 + 主窗口日志「☁ 上传完成,本地文件已删除」 | — |
513
  | `_on_task_failed` / `_on_all_done` | `(task_id, error)` / `()` | 失败:状态栏「❌ 上传失败」+ 主窗口日志 + 刷新列表 / 全部完成:恢复按钮、进度条 100、状态「全部完成」 | — |
514
  | `closeEvent` | `(event)` | 停止 `_manager` | — |
515
 
collector/tools/tests/device_dock_collapse_test.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """设备检测栏(左侧停靠面板)缩小 / 隐藏回归测试(offscreen)。
2
+
3
+ 用法:
4
+ QT_QPA_PLATFORM=offscreen venv/bin/python tools/tests/device_dock_collapse_test.py
5
+
6
+ 背景(2026-10-13 的 bug):
7
+ 面板底部提示「双击设备可重命名;右键夹爪可重读标定 / 配对序列号 / 串口诊断」
8
+ 是不换行的 QLabel,minimumSizeHint 就是整行文字宽(中文 + 夹爪在场时 289px),
9
+ 成了停靠栏的硬地板 —— 栏只能越拖越宽、拖不小;且 dock 无 Closable,没有任何
10
+ 隐藏入口。本测试是这两条的回归哨兵。
11
+
12
+ 覆盖:
13
+ 1. 中文 + 夹爪:面板最小宽度远小于旧地板(<=150),且栏能真的拖到 DEVICE_DOCK_MIN_WIDTH
14
+ 2. dock 带 Closable(标题栏 × = 收起)
15
+ 3. 标题栏 × 收起 → 菜单勾选同步 + 偏好落盘
16
+ 4. 收起后切页(任务页 ↔ 采集页)不弹回
17
+ 5. 视图▸设备检测栏 / Ctrl+D 能收起也能恢复
18
+ 6. 宽度与收起状态持久化 → 新窗口按偏好恢复
19
+ 7. 中英文切换刷新菜单项文字
20
+ """
21
+ import os
22
+ import sys
23
+ import tempfile
24
+ import time
25
+ from unittest.mock import patch
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
28
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
29
+
30
+ from PyQt5.QtCore import Qt
31
+ from PyQt5.QtWidgets import QApplication, QDockWidget
32
+
33
+ from config import settings
34
+ from config.i18n import lang_manager, tr
35
+ from core.device_detector import DeviceInfo
36
+ from ui.main_window import MainWindow
37
+
38
+ FAILS = []
39
+
40
+
41
+ def check(cond, msg):
42
+ if cond:
43
+ print(f" PASS: {msg}")
44
+ else:
45
+ print(f" FAIL: {msg}")
46
+ FAILS.append(msg)
47
+
48
+
49
+ def wait(app, ms):
50
+ t_end = time.time() + ms / 1000.0
51
+ while time.time() < t_end:
52
+ app.processEvents()
53
+ time.sleep(0.01)
54
+
55
+
56
+ FAKE_GRIPPER = DeviceInfo(key="gripper:/dev/ttyACM9", kind="gripper",
57
+ display_name="UMI 夹爪 (fake)", serial="Fays-0001")
58
+ FAKE_UVC = DeviceInfo(key="uvc:usb-FakeCam_12345678", kind="uvc",
59
+ display_name="FakeCam", serial="12345678",
60
+ video_index=40)
61
+ FAKE_GLOVE = DeviceInfo(key="ble:AA:11:22:33:44:55", kind="data_ble",
62
+ display_name="Matrix Glove R", serial="AA:11:22:33:44:55",
63
+ address="AA:11:22:33:44:55")
64
+ FAKES = [FAKE_GRIPPER, FAKE_UVC, FAKE_GLOVE]
65
+
66
+
67
+ def make_window(app):
68
+ """建窗口并停在采集页(设备栏可见)。调用方负责 close()。"""
69
+ with patch("core.device_detector.detect_devices", return_value=FAKES):
70
+ win = MainWindow()
71
+ win.show()
72
+ win._page_stack.setCurrentIndex(1)
73
+ wait(app, 200)
74
+ win._on_devices_scanned(FAKES)
75
+ win._device_timer.stop()
76
+ return win
77
+
78
+
79
+ def main():
80
+ app = QApplication(sys.argv)
81
+ lang_manager.set_language("zh")
82
+
83
+ # 偏好全程指向临时文件(隔离真实 server_config.json)
84
+ tmp = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
85
+ tmp_path = tmp.name
86
+ tmp.close()
87
+ _orig = settings.SERVER_CONFIG_FILE
88
+ settings.SERVER_CONFIG_FILE = tmp_path
89
+ try:
90
+ return _main(app)
91
+ finally:
92
+ settings.SERVER_CONFIG_FILE = _orig
93
+ for p in (tmp_path,):
94
+ try:
95
+ os.unlink(p)
96
+ except OSError:
97
+ pass
98
+
99
+
100
+ def _main(app):
101
+ print("── 1. 中文 + 夹爪:面板不再有宽地板,栏能拖到最窄 ──")
102
+ win = make_window(app)
103
+ min_w = settings.DEVICE_DOCK_MIN_WIDTH
104
+ panel_min = win._device_panel.minimumSizeHint().width()
105
+ check(panel_min <= 150,
106
+ f"面板最小宽度 {panel_min}px(旧 bug 是 289px,单行提示顶宽)")
107
+ check(win._device_panel._refresh_hint.wordWrap()
108
+ and tr("双击设备可重命名") in win._device_panel._refresh_hint.text(),
109
+ "底部提示是换行的那句(不再吃整行宽度当最小宽度)")
110
+ win.resizeDocks([win._device_dock], [min_w], Qt.Horizontal)
111
+ app.processEvents()
112
+ actual = win._device_dock.width()
113
+ check(abs(actual - min_w) <= 8,
114
+ f"拖到最窄 {min_w}px → 实际 {actual}px(能缩小了)")
115
+ win.resizeDocks([win._device_dock], [320], Qt.Horizontal)
116
+ app.processEvents()
117
+ check(win._device_dock.width() >= 300,
118
+ f"拖宽到 320px → 实际 {win._device_dock.width()}px(能放大)")
119
+
120
+ print("── 2. dock 带 Closable(标题栏有 × = 隐藏入口) ──")
121
+ feats = win._device_dock.features()
122
+ check(bool(feats & QDockWidget.DockWidgetClosable), "features 含 Closable")
123
+ check(bool(feats & QDockWidget.DockWidgetMovable)
124
+ and bool(feats & QDockWidget.DockWidgetFloatable),
125
+ "Movable/Floatable 未被改动")
126
+
127
+ print("── 3. 标题栏 × 收起 → 菜单勾选同步 + 偏好落盘 ──")
128
+ win._device_dock.close() # × 按钮走的就是 close()
129
+ wait(app, 100)
130
+ check(not win._device_dock.isVisible(), "× 收起后设备栏不可见")
131
+ check(win._device_dock_user_hidden, "收起被记为用户意图")
132
+ check(not win._device_dock_action.isChecked(), "菜单勾选同步为未选中")
133
+ check(settings.load_device_dock_prefs()["visible"] is False,
134
+ "收起状态已落盘")
135
+
136
+ print("── 4. 收起后切页不弹回 ──")
137
+ win._page_stack.setCurrentIndex(0)
138
+ wait(app, 100)
139
+ win._page_stack.setCurrentIndex(1)
140
+ wait(app, 100)
141
+ check(not win._device_dock.isVisible(),
142
+ "任务页→采集页往返后仍是收起(不被页面逻辑弹回)")
143
+ win._page_stack.setCurrentIndex(0)
144
+ wait(app, 100)
145
+ check(not win._device_dock.isVisible(), "任务选择页本来就不显示")
146
+
147
+ print("── 5. 视图▸设备检测栏 / Ctrl+D:能收起也能恢复 ──")
148
+ win._device_dock_action.trigger() # 恢复显示(任务页上点)
149
+ wait(app, 100)
150
+ check(not win._device_dock.isVisible(),
151
+ "任务页点显示:只记偏好,不把栏拉到任务页上")
152
+ check(win._device_dock_action.isChecked()
153
+ and not win._device_dock_user_hidden, "偏好已置为「显示」")
154
+ win._page_stack.setCurrentIndex(1)
155
+ wait(app, 150)
156
+ check(win._device_dock.isVisible(), "回采集页后按偏好显示出来")
157
+ win._device_dock_action.trigger() # 再收起
158
+ wait(app, 100)
159
+ check(not win._device_dock.isVisible(), "菜单收起生效")
160
+ check(win._device_dock_action.shortcut().toString() == "Ctrl+D",
161
+ f"快捷键: {win._device_dock_action.shortcut().toString()}")
162
+ win._device_dock_action.trigger() # 恢复
163
+ wait(app, 100)
164
+ check(win._device_dock.isVisible(), "菜单恢复生效")
165
+
166
+ print("── 6. 宽度/收起状态持久化 → 新窗口按偏好恢复 ──")
167
+ win.resizeDocks([win._device_dock], [min_w], Qt.Horizontal)
168
+ app.processEvents()
169
+ saved_w = win._device_dock.width()
170
+ win._save_device_dock_prefs()
171
+ prefs = settings.load_device_dock_prefs()
172
+ check(prefs["width"] == saved_w,
173
+ f"宽度落盘: 存 {prefs['width']}px(实际 {saved_w}px)")
174
+ check(prefs["visible"] is True, "可见偏好落盘")
175
+ win.close()
176
+ wait(app, 200)
177
+
178
+ # 新窗口:应恢复刚存的窄宽度(首启即窄,不用再拖一次)
179
+ win2 = make_window(app)
180
+ restored = win2._device_dock.width()
181
+ check(abs(restored - saved_w) <= 8,
182
+ f"重启恢复栏宽: {restored}px(存 {saved_w}px)")
183
+ # 存成「收起」→ 新窗口首启就是收起
184
+ win2._device_dock.close()
185
+ wait(app, 100)
186
+ win2.close()
187
+ wait(app, 200)
188
+ win3 = make_window(app)
189
+ check(not win3._device_dock.isVisible(),
190
+ "上次收起 → 新窗口首启保持收起(采集页也不弹回来)")
191
+ check(not win3._device_dock_action.isChecked(), "菜单勾选与之一致")
192
+ win3._device_dock_action.trigger() # 恢复,别把状态留给下个测试
193
+ wait(app, 100)
194
+ check(win3._device_dock.isVisible(), "恢复显示")
195
+
196
+ print("── 7. 中英文切换刷新菜单项 ──")
197
+ lang_manager.set_language("en")
198
+ wait(app, 100)
199
+ check(win3._device_dock_action.text() == "Device Panel",
200
+ f"英文菜单项: {win3._device_dock_action.text()}")
201
+ lang_manager.set_language("zh")
202
+ wait(app, 100)
203
+ check(win3._device_dock_action.text() == "设备检测栏",
204
+ f"中文菜单项: {win3._device_dock_action.text()}")
205
+ win3.close()
206
+ wait(app, 200)
207
+
208
+ print()
209
+ if FAILS:
210
+ print(f"FAIL: {len(FAILS)} 项未通过")
211
+ return 1
212
+ print("PASS: 设备检测栏 缩小/隐藏 回归测试全部通过")
213
+ return 0
214
+
215
+
216
+ if __name__ == "__main__":
217
+ sys.exit(main())
collector/tools/tests/lite_delete_test.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """极简版「上传后删除」编号回归测试(无硬件,离屏 Qt)。
2
+
3
+ 回归点(2026-10-12 改真删除时发现,对抗性审查复现):
4
+ lite 的 `_delete_after_upload` 收的必须是**全局 episode 号 N(1 基)**——
5
+ 上传器按 N 取文件(`episode_chunk_file(N)` → 文件号 N-1)、`UploadManager`
6
+ 按 (path, N) 记账,自动上传路径传进来的 `pipeline.last_episode_index` 也是 N。
7
+ 这里多 +1(或 `_episode_max` 少 +1)都会指向另一段:轻则「上传的那段没删、
8
+ 删了一段没上传过的」,重则目标段本就不存在 → `delete_pooled_episode` 的残留
9
+ 复检空集 → 返回 True → 界面报「已删除」而文件一个没动。
10
+
11
+ 本测试造 3 段池化数据(文件号 0/1/2 = N 1/2/3):
12
+ - `_episode_max` 返回 N=3(不是文件号 2);
13
+ - `_on_upload_done` 原样把 N 透传给 `_delete_after_upload`(不 +1);
14
+ - 按 N=3 删掉的是文件号 002 那一组(数据 + 视频 + meta 行),N=1/2 原样在;
15
+ - 单段目录删的是那一段本身,不整目录 rmtree;
16
+ - 无 parquet 的旧格式目录按整目录删;
17
+ - 跨 chunk(chunk-000 满 1000 段后开 chunk-001 并从 episode-000 重新计数):
18
+ `episode_max` 必须按 `chunk*1000 + f + 1` 还原成真值 N=1001,**不能**取
19
+ 「最大文件名 f + 1」—— f 会回绕,那个值指向 chunk-000 里**真实存在**的前
20
+ 一段,于是上传/删除静默操作到错的段。
21
+
22
+ 用法:
23
+ QT_QPA_PLATFORM=offscreen venv/bin/python tools/tests/lite_delete_test.py
24
+ """
25
+ import os
26
+ import shutil
27
+ import sys
28
+
29
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
30
+ os.path.abspath(__file__)))))
31
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
32
+
33
+ from PyQt5.QtWidgets import QApplication
34
+
35
+ import main_lite # noqa: F401 — 生产入口,先 import 再构造窗口
36
+ from config import settings
37
+ from core.egodata_writer import _atomic_write_parquet, _episode_rows_table
38
+ from core.helpers import (POOLED_CHUNK_SIZE, list_task_episodes,
39
+ pooled_data_parquet_path, pooled_episodes_path)
40
+ from core.pipeline import CameraPipeline
41
+ from ui.lite_window import LiteWindow
42
+
43
+ ROOT = "/tmp/lite_delete_test"
44
+ FAILS = []
45
+
46
+
47
+ def check(cond, msg):
48
+ print(f" {'PASS' if cond else 'FAIL'}: {msg}")
49
+ if not cond:
50
+ FAILS.append(msg)
51
+
52
+
53
+ def make_episode(task_dir: str, file_no: int, chunk: int = 0):
54
+ """造一段池化数据:chunk 内文件号 file_no。
55
+
56
+ 全局号 N = chunk * 1000 + file_no + 1 —— 每 chunk 1000 段,chunk-000 满
57
+ 1000 段后开 chunk-001 并从 episode-000 重新计数。
58
+ """
59
+ n = chunk * POOLED_CHUNK_SIZE + file_no + 1
60
+ cdir = f"chunk-{chunk:03d}"
61
+ d = os.path.join(task_dir, "data", cdir)
62
+ os.makedirs(d, exist_ok=True)
63
+ with open(os.path.join(d, f"episode-{file_no:03d}.parquet"), "wb") as f:
64
+ f.write(b"data")
65
+ v = os.path.join(task_dir, "videos", cdir, "uvc_rgb")
66
+ os.makedirs(v, exist_ok=True)
67
+ with open(os.path.join(v, f"episode-{file_no:03d}.mp4"), "wb") as f:
68
+ f.write(b"video")
69
+ ep_path = pooled_episodes_path(task_dir, n)
70
+ os.makedirs(os.path.dirname(ep_path), exist_ok=True)
71
+ _atomic_write_parquet(
72
+ _episode_rows_table([{"episode_index": n, "task_name": "t"}]),
73
+ ep_path)
74
+
75
+
76
+ def group_exists_n(task_dir: str, n: int) -> bool:
77
+ """全局号 N 的三件套是否齐全(数据 / 视频 / meta 行)。"""
78
+ from core.helpers import episode_chunk_file
79
+ c, f = episode_chunk_file(n)
80
+ return (os.path.isfile(pooled_data_parquet_path(task_dir, n))
81
+ and os.path.isfile(os.path.join(
82
+ task_dir, "videos", f"chunk-{c:03d}", "uvc_rgb",
83
+ f"episode-{f:03d}.mp4"))
84
+ and os.path.isfile(pooled_episodes_path(task_dir, n)))
85
+
86
+
87
+ def group_exists(task_dir: str, file_no: int) -> bool:
88
+ """chunk-000 里该文件号的三件套(旧调用点的简写)。"""
89
+ return group_exists_n(task_dir, file_no + 1)
90
+
91
+
92
+ def main():
93
+ shutil.rmtree(ROOT, ignore_errors=True)
94
+ app = QApplication(sys.argv) # noqa: F841 — 持有引用才有 QApplication
95
+ settings.UPLOAD_AUTO_SYNC = False
96
+ settings.ENCODER_PROBE_ENABLED = False
97
+ win = LiteWindow(pipeline=CameraPipeline(output_dir=ROOT))
98
+ try:
99
+ t1 = os.path.join(ROOT, "multi")
100
+ for f in (0, 1, 2):
101
+ make_episode(t1, f)
102
+ check(len(list_task_episodes(t1)) == 3, "造 3 段池化数据")
103
+
104
+ # ── 1. 列表口径:ep 必须是全局号 N,不是文件号 ──
105
+ check(win._episode_max(t1) == 3,
106
+ "上传列表的 ep = 全局 N 3(不是文件号 2;列表值会被上传器当 N 用)")
107
+
108
+ # ── 2. 上传完成后删除:原样透传 N,不许 +1 ──
109
+ real_delete = win._delete_after_upload
110
+ seen = []
111
+ win._delete_after_upload = lambda p, e: seen.append((p, e))
112
+ settings.UPLOAD_DELETE_AFTER = True
113
+ win._upload_task_map["tid"] = (t1, 3) # 自动上传路径存的就是 N
114
+ win._on_upload_done("tid")
115
+ check(seen == [(t1, 3)],
116
+ f"_on_upload_done 原样透传 N=3(实测 {seen};+1 会删到不存在的 N=4)")
117
+ win._delete_after_upload = real_delete
118
+
119
+ # ── 3. 真删:N=3 删的是文件号 002 那一组 ──
120
+ win._delete_after_upload(t1, 3)
121
+ check(not group_exists(t1, 2), "删掉的是文件号 002 那一段(上传的那段)")
122
+ check(group_exists(t1, 0) and group_exists(t1, 1),
123
+ "文件号 000/001 两组原样保留(没误删邻居)")
124
+ check(list_task_episodes(t1) == [1, 2], "剩余 episode 号 = [1, 2]")
125
+ check(os.path.isdir(t1), "任务目录本身保留(只删该段)")
126
+
127
+ # ── 4. 单段目录:删的是那一段,不是整目录 ──
128
+ t2 = os.path.join(ROOT, "single")
129
+ make_episode(t2, 0)
130
+ check(win._episode_max(t2) == 1, "单段目录的 ep = N 1(不是 0 = 整目录)")
131
+ win._delete_after_upload(t2, 1)
132
+ check(list_task_episodes(t2) == [], "单段目录删掉该段")
133
+ check(os.path.isdir(t2), "单段目录仍存在(不整目录 rmtree)")
134
+
135
+ # ── 5. 池化 parquet 全无(旧格式/空目录)→ 按整目录删 ──
136
+ t3 = os.path.join(ROOT, "legacy")
137
+ os.makedirs(os.path.join(t3, "meta"), exist_ok=True)
138
+ with open(os.path.join(t3, "meta", "info.json"), "w") as f:
139
+ f.write("{}")
140
+ check(win._episode_max(t3) == 0, "旧格式目录 ep = 0(整目录)")
141
+ win._delete_after_upload(t3, 0)
142
+ check(not os.path.isdir(t3), "无 parquet 的目录整目录删除")
143
+
144
+ # ── 6. 跨 chunk:f 回绕后必须按 chunk 号还原全局 N ──
145
+ t4 = os.path.join(ROOT, "chunked")
146
+ for f in (0, 1, POOLED_CHUNK_SIZE - 1):
147
+ make_episode(t4, f) # chunk-000 → N 1 / 2 / 1000
148
+ make_episode(t4, 0, chunk=1) # chunk-001 → N 1001(f 从 0 重开)
149
+ check(len(list_task_episodes(t4)) == 4, "造跨 chunk 数据:N = [1, 2, 1000, 1001]")
150
+ check(win._episode_max(t4) == POOLED_CHUNK_SIZE + 1,
151
+ f"跨 chunk 的任务 ep = 真值 N {POOLED_CHUNK_SIZE + 1}"
152
+ f"(不是「最大文件名 f + 1」= 1 —— 那指向 chunk-000 里真实存在的 N=1)")
153
+ win._delete_after_upload(t4, POOLED_CHUNK_SIZE + 1)
154
+ check(not group_exists_n(t4, POOLED_CHUNK_SIZE + 1),
155
+ "删掉的是 chunk-001/episode-000(N 1001)")
156
+ check(group_exists_n(t4, 1) and group_exists_n(t4, 2)
157
+ and group_exists_n(t4, POOLED_CHUNK_SIZE),
158
+ "chunk-000 的 N=1/2/1000 原样保留(没回绕删到真值 N=1)")
159
+ finally:
160
+ win.close()
161
+ shutil.rmtree(ROOT, ignore_errors=True)
162
+
163
+ print()
164
+ if FAILS:
165
+ print(f"FAIL: {len(FAILS)} 项未通过")
166
+ return 1
167
+ print("PASS: lite 上传后删除编号回归全部通过")
168
+ return 0
169
+
170
+
171
+ if __name__ == "__main__":
172
+ sys.exit(main())
collector/tools/tests/test_device_access_selflock.py CHANGED
@@ -29,6 +29,17 @@
29
  8. fays_device_guard_path 拒绝非 /dev/videoN
30
  9. UVC 配对失败文案带上逐组拒绝原因(原先只报 matches/groups 计数)
31
 
 
 
 
 
 
 
 
 
 
 
 
32
  退出码 0 = 全部通过。
33
  """
34
 
@@ -203,15 +214,33 @@ def test_invalid_node():
203
  check(raised, "fays_device_guard_path 拒绝非 /dev/videoN")
204
 
205
 
206
- def test_pairing_message_carries_reason():
207
- """配对失败必须把逐组拒绝原因带出来,不能只剩 matches/groups 计数。"""
208
- from core.gripper.devices.uvc_camera_service import (
209
- UvcCameraServiceError, UvcCameraServiceManager,
210
- )
 
 
211
 
212
  service = object.__new__(UvcCameraServiceManager)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  reason = "sightac mode=failed error='MJPEG 640x480@30: Invalid mode'"
214
- group = {"physical_usb_path": "3-1", "valid": False}
215
  with mock.patch.object(service, "_discovery_scope", return_value=(3, "1")), \
216
  mock.patch.object(service, "_run_discovery", return_value=[group]), \
217
  mock.patch.object(service, "_associate_group",
@@ -226,6 +255,182 @@ def test_pairing_message_carries_reason():
226
  check("matches=0" in text and "groups=1" in text,
227
  "文案仍保留 matches/groups 计数")
228
  check(reason in text, "文案带上了逐组拒绝原因(真因不再被吞)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
 
231
  def main():
@@ -243,6 +448,12 @@ def main():
243
  test_invalid_node()
244
  print("\n[7. 配对失败文案带原因]")
245
  test_pairing_message_carries_reason()
 
 
 
 
 
 
246
 
247
  print()
248
  if FAILS:
 
29
  8. fays_device_guard_path 拒绝非 /dev/videoN
30
  9. UVC 配对失败文案带上逐组拒绝原因(原先只报 matches/groups 计数)
31
 
32
+ 第 9 条以外,以下三条钉的是提交 00612cb1「UVC 发现结果不再丢弃」的三处改动
33
+ (改前它们**零测试覆盖**:唯一触边的第 9 条用它自己的 fixture 键 `physical_usb_path`
34
+ —— 那键在 UVC 报告里根本不存在 —— 且不打标签,所以标签改错了它照样 PASS):
35
+ 10. 逐组拒绝原因的标签取报告真键 `outer_path`(键名写错就退化成 group#N;
36
+ 报告里真缺这个键时才回退 group#N)
37
+ 11. Flash 身份失败报错带 `sightac[i]` + side/flash_ok/device_type/serial/error
38
+ 五项(否则三种不同故障报同一句话),且**「side 空/无效」与「左右重复」两条
39
+ 分支的抬头不同**(前者会被误指成「拓扑重复」,真因却是厂商没写 side)
40
+ 12. 发现程序 rc==0 时 stderr 也逐行进 `[UVC-DISCOVER]` 日志(rc!=0 的既有行为
41
+ 不变:stderr 尾部进异常文案、不重复转发)
42
+
43
  退出码 0 = 全部通过。
44
  """
45
 
 
214
  check(raised, "fays_device_guard_path 拒绝非 /dev/videoN")
215
 
216
 
217
+ def fake_manager(**attrs):
218
+ """object.__new__ 造的假 manager:只装本次要用的属性(不起设备/子进程)。
219
+
220
+ `_resolve_current_group` / `_associate_group` / `_run_discovery` 都是拿
221
+ self 当命名空间用的纯逻辑,构造真 manager 会去碰设备,所以照旧绕过。
222
+ """
223
+ from core.gripper.devices.uvc_camera_service import UvcCameraServiceManager
224
 
225
  service = object.__new__(UvcCameraServiceManager)
226
+ for key, value in attrs.items():
227
+ setattr(service, key, value)
228
+ return service
229
+
230
+
231
+ def test_pairing_message_carries_reason():
232
+ """配对失败必须把逐组拒绝原因带出来,不能只剩 matches/groups 计数。
233
+
234
+ 组里用的是报告里**真有的** `outer_path`(C 侧 `discover_uvc_config.c:1239`
235
+ 写出 `"bus":N,"outer_path":...`)—— 这个 fixture 原先写的是
236
+ `physical_usb_path`,那个键在报告里根本不存在,于是标签一直退化成
237
+ `group#0`,把「标签对不对」这件事测成了假绿(见 test_rejection_label_...)。
238
+ """
239
+ from core.gripper.devices.uvc_camera_service import UvcCameraServiceError
240
+
241
+ service = fake_manager()
242
  reason = "sightac mode=failed error='MJPEG 640x480@30: Invalid mode'"
243
+ group = {"outer_path": "1.2", "valid": False}
244
  with mock.patch.object(service, "_discovery_scope", return_value=(3, "1")), \
245
  mock.patch.object(service, "_run_discovery", return_value=[group]), \
246
  mock.patch.object(service, "_associate_group",
 
255
  check("matches=0" in text and "groups=1" in text,
256
  "文案仍保留 matches/groups 计数")
257
  check(reason in text, "文案带上了逐组拒绝原因(真因不再被吞)")
258
+ check("1.2: " in text,
259
+ "拒绝原因的标签是报告里真有的 outer_path(不是 group#N)")
260
+ check("group#0" not in text,
261
+ "标签没有退化成 group#0(键名写错就会退化,见下一条测试)")
262
+
263
+
264
+ def test_rejection_label_uses_outer_path():
265
+ """改动③:逐组拒绝原因的标签必须是报告里真有的键。
266
+
267
+ 真键由 C 侧定:`discover_uvc_config.c:1239` 写的是
268
+ `{"bus":N,"outer_path":...}`;`physical_usb_path` 在整个 C 源码里
269
+ 一次都没出现(那是 ESP32 清单 side 的键,不是 UVC 报告的)。
270
+ 标签写错键名 → 真实失败下每一组都退化成 `group#0`/`group#1`,
271
+ 用户看不出是哪条 USB 路径出的问题。
272
+ """
273
+ from core.gripper.devices.uvc_camera_service import UvcCameraServiceError
274
+
275
+ def rejections_for(groups):
276
+ service = fake_manager()
277
+ with mock.patch.object(service, "_discovery_scope",
278
+ return_value=(3, "1")), \
279
+ mock.patch.object(service, "_run_discovery",
280
+ return_value=groups), \
281
+ mock.patch.object(service, "_associate_group",
282
+ side_effect=UvcCameraServiceError("拒绝原因")), \
283
+ mock.patch.object(service, "_assignment_identity",
284
+ return_value=("ESP-SN", "FAYS-SN")):
285
+ try:
286
+ service._resolve_current_group({"id": "umi"})
287
+ except UvcCameraServiceError as exc:
288
+ return str(exc)
289
+ return ""
290
+
291
+ # 两把键都在、���值不同:标签必须取 outer_path(拿错键就会取到 3-1)——
292
+ # 这是本条的判据,只断言「有标签」会两种实现都通过。
293
+ text = rejections_for([{"outer_path": "1.2", "physical_usb_path": "3-1",
294
+ "valid": False}])
295
+ check("1.2: 拒绝原因" in text,
296
+ "标签取 outer_path(报告真键)")
297
+ check("3-1: 拒绝原因" not in text,
298
+ "标签没有误取 physical_usb_path(那个键在 UVC 报告里不存在)")
299
+
300
+ # 多组时逐组一条,下标与标签一一对应(别把两条压成同一条)。
301
+ text = rejections_for([{"outer_path": "1.2"}, {"outer_path": "2.3"}])
302
+ check("1.2: 拒绝原因" in text and "2.3: 拒绝原因" in text,
303
+ "逐组各记一条拒绝原因,标签各不相同")
304
+
305
+ # 报告里真的没有 outer_path 时按 group#N 回退(不是静默变空)——
306
+ # 这是有意的兜底:C 侧若某天不再写这个键,日志里仍能定位到第几组。
307
+ text = rejections_for([{"bus": 3}])
308
+ check("group#0: 拒绝原因" in text,
309
+ "报告里没有 outer_path 时回退到 group#N(不静默变空)")
310
+
311
+
312
+ def test_pairing_uniqueness_message_carries_identity():
313
+ """改动②:「Flash 身份」失败必须带上 sightac[i] 的元素身份,且**两种故障分开报**。
314
+
315
+ 不加身份字段时,「Flash 读不出 / 读出来是空串 / device_type 不对」
316
+ 三种完全不同的故障报的是**同一句话**(`side=''`),现场又要从头猜。
317
+ 字段名都是 C 侧 `write_json_candidate` 真写出来的键
318
+ (discover_uvc_config.c:1092 起:side / flash_ok / device_type /
319
+ flash_serial / error)。
320
+
321
+ 另一半是**抬头本身**:同一个抬头 `身份不唯一` 同时盖住①这台没报出左右
322
+ (side 空/无效)与②两台报了同一个左右 —— ①的真因是 Flash 没写 side
323
+ (只能厂商补写,仓库无写路径),照"左右重复"去查拓扑是白转一圈。所以两条
324
+ 分支的抬头必须不同。
325
+ """
326
+ from core.gripper.devices.uvc_camera_service import UvcCameraServiceError
327
+
328
+ def message_for(sightac):
329
+ service = fake_manager()
330
+ group = {"bus": 3, "outer_path": "1", "valid": True,
331
+ "sightac": sightac,
332
+ "decxin": {"mode_ok": True, "port": "1.3"}}
333
+ live_esp = [{"serial": "ESP-SN", "physical_usb_path": "3-1.2"}]
334
+ with mock.patch.object(service, "_assignment_identity",
335
+ return_value=("ESP-SN", "FAYS-SN")):
336
+ try:
337
+ service._associate_group(group, {"id": "umi"}, live_esp)
338
+ except UvcCameraServiceError as exc:
339
+ return str(exc)
340
+ return ""
341
+
342
+ # 真实的 field failure:side 读出来是空串,JSON 里有 error 说明为什么。
343
+ bad = {"side": "", "flash_ok": False, "device_type": "",
344
+ "flash_serial": "",
345
+ "error": "LIBUSB_ERROR_TIMEOUT reading Flash side"}
346
+ text = message_for([{"side": "left", "flash_ok": True,
347
+ "device_type": "sightac", "flash_serial": "L-SN"},
348
+ bad])
349
+ check("sightac[1]" in text, "带上出问题的那个下标(sightac[1],不是笼统的组)")
350
+ check("side=''" in text, "带上 side 原文")
351
+ check("flash_ok=False" in text, "带上 flash_ok(区分「Flash 读失败」)")
352
+ check("device_type=''" in text, "带上 device_type(区分「类型不对」)")
353
+ check("serial=''" in text, "带上 flash_serial(区分「读出来是空串」)")
354
+ check("LIBUSB_ERROR_TIMEOUT" in text,
355
+ "带上 error 原文(真因不再被压成同一句话)")
356
+ check("身份不唯一" not in text,
357
+ "side 空/无效报的是「没报出有效左右」,**不是**「身份不唯一」"
358
+ "(照「重复」去查拓扑会把方向指错)")
359
+
360
+ # 左右重了:报的是**第二个**(i=1)那个,不是第一个;抬头是「不唯一」。
361
+ text = message_for([{"side": "left", "flash_ok": True},
362
+ {"side": "left", "flash_ok": True}])
363
+ check("sightac[1]" in text and "side='left'" in text,
364
+ "左右重复时报出重复的那个下标与值(sightac[1] side='left')")
365
+ check("身份不唯一" in text,
366
+ "真的左右重复时仍然报「身份不唯一」(两分支抬头不同)")
367
+
368
+
369
+ def test_discover_stderr_kept_on_success():
370
+ """改动①:rc==0 时 stderr 也要进 [UVC-DISCOVER] 日志。
371
+
372
+ 发现程序(core/gripper/camera_service_src/tools/discover_uvc_config.c)
373
+ 把每台相机的现场打到 stderr 的 [DISCOVERY] 行。JSON 里虽然有 error
374
+ 字段,但并非全都在:`mode=unknown` 与 `mode=failed` 在 JSON 里都被压成
375
+ `mode_ok:false`,flash/calibration 的 ok|failed|skipped 三态也只剩布尔
376
+ —— 而 rc==0 的「配对失败」现场恰恰就是这些差别。过去只在 rc!=0 时读
377
+ stderr,rc=0 的那次等于整份丢���,只剩一句 side=''。
378
+ """
379
+ import json as _json
380
+ from pathlib import Path
381
+
382
+ from core.gripper.devices.uvc_camera_service import (
383
+ UvcCameraServiceError, UvcCameraServiceManager,
384
+ )
385
+
386
+ reports = _json.dumps({"groups": [{"bus": 3, "outer_path": "1",
387
+ "sightac": [{}, {}], "decxin": {},
388
+ "valid": True}]})
389
+ lines = [
390
+ "[DISCOVERY] Sightac vidpid=2207:0019 bus=3 port=1.2 "
391
+ "usb_serial=<none> control_if=0 stream_if=1 alt=7 "
392
+ "descriptor_payload=327680 negotiated_payload=241664 mode=unknown "
393
+ "flash=failed calibration=failed side=<none> serial=<none> "
394
+ "type=<none> error=XU6 prepare failed",
395
+ "[DISCOVERY] complete group(s)=1",
396
+ ]
397
+
398
+ def run_with(returncode, stdout, stderr):
399
+ logs = []
400
+ service = fake_manager(_logger=logs.append,
401
+ _discovery_binary=Path("/bin/true"))
402
+ completed = subprocess.CompletedProcess(
403
+ ["/bin/true"], returncode, stdout=stdout, stderr=stderr)
404
+ with mock.patch.object(subprocess, "run", return_value=completed):
405
+ try:
406
+ result, error = service._run_discovery(), None
407
+ except UvcCameraServiceError as exc:
408
+ result, error = None, str(exc)
409
+ return logs, result, error
410
+
411
+ logs, result, error = run_with(0, reports, "\n".join(lines) + "\n")
412
+ check(error is None and result is not None,
413
+ "rc==0 仍正常解析出 groups(改动没干扰成功路径)")
414
+ check(logs == ["[UVC-DISCOVER] " + line for line in lines],
415
+ f"rc==0 的 stderr 逐行进日志({len(logs)} 行;改动前这里一行都没有)")
416
+
417
+ logs, _, _ = run_with(0, reports, "")
418
+ check(logs == [], "stderr 为空时不打空行(不制造噪声)")
419
+
420
+ # rc!=0 的既有行为不变:stderr 尾部进异常文案(不是进日志),
421
+ # 且异常发生在转发之前 —— 别把失败路径的 stderr 打两遍。
422
+ many = [f"[DISCOVERY] line {i}" for i in range(30)]
423
+ logs, _, error = run_with(1, "", "\n".join(many))
424
+ check(error is not None and "rc=1" in error,
425
+ "rc!=0 仍抛错并带上 rc")
426
+ check("line 29" in error and "line 0" not in error,
427
+ "rc!=0 只带 stderr 尾部 20 行(既有行为不变)")
428
+ check(logs == [], "rc!=0 时不重复转发(stderr 已进异常文案)")
429
+
430
+ # 防御:_logger 是实例属性(构造时注入),不是全局 print。
431
+ check(UvcCameraServiceManager.__init__.__code__.co_varnames[:6]
432
+ and "logger" in UvcCameraServiceManager.__init__.__code__
433
+ .co_varnames[:6], "_logger 由构造参数注入(真机上打给 SLAM/设备面板)")
434
 
435
 
436
  def main():
 
448
  test_invalid_node()
449
  print("\n[7. 配对失败文案带原因]")
450
  test_pairing_message_carries_reason()
451
+ print("\n[8. 逐组拒绝原因的标签用真键 outer_path(改动③)]")
452
+ test_rejection_label_uses_outer_path()
453
+ print("\n[9. 「Flash 身份不唯一」带 sightac[i] 身份字段(改动②)]")
454
+ test_pairing_uniqueness_message_carries_identity()
455
+ print("\n[10. rc==0 也留发现程序 stderr(改动①)]")
456
+ test_discover_stderr_kept_on_success()
457
 
458
  print()
459
  if FAILS:
collector/tools/tests/test_gripper_latest_pose_frame.py CHANGED
@@ -177,9 +177,12 @@ def main() -> int:
177
  f"{raw_seq}/{lost} vs {controller._pose_raw_seq}/"
178
  f"{controller._pose_lost_count}")
179
 
180
- # 连续几帧(位姿判据会拒跳变:步长要小)
 
 
 
181
  for index in range(1, 6):
182
- spot = (0.1 + 0.002 * index, 0.2, 0.3 + 0.001 * index)
183
  feed(controller, counters, 1.0 + index, spot)
184
  expect = expected_published(spot)
185
  now = controller.read_latest_pose()[0]
 
177
  f"{raw_seq}/{lost} vs {controller._pose_raw_seq}/"
178
  f"{controller._pose_lost_count}")
179
 
180
+ # 连续几帧。步长 20mm:要**同时**满足两个门 —— 大于发布死区
181
+ # POSE_PUBLISH_DEADBAND_M(3mm,否则发布的是冻结值,这里比的是
182
+ # 发布出口,不是换算本身)且远小于跳变门限值(dt=1s ⇒ 上限受
183
+ # POSE_MAX_LINEAR_SPEED_MPS 约束),别再用 1-2mm 的小步。
184
  for index in range(1, 6):
185
+ spot = (0.1 + 0.02 * index, 0.2, 0.3 + 0.01 * index)
186
  feed(controller, counters, 1.0 + index, spot)
187
  expect = expected_published(spot)
188
  now = controller.read_latest_pose()[0]
collector/tools/tests/test_gripper_pose_axes.py CHANGED
@@ -23,8 +23,14 @@
23
  一个常数 M(约 120°)——用户判"跟随夹爪的坐标轴还没改"。
24
  - **原点那一瞬发布姿态 = M·Mᵀ = 单位矩阵**:夹爪三箭头与世界三箭头
25
  重合,这是"一眼看出用的是哪一帧"的判据(第 4 节钉住)。
 
 
 
 
 
 
26
  - 相对化与重标的**顺序不再影响结果**:共轭让 M 在两步里都自消,
27
- (M·R0·Mᵀ)ᵀ(M·R·Mᵀ) = M·R0ᵀ·R·Mᵀ 与"先相对化再重标"逐元素相同。
28
  第 5 节把这条等价性钉住(旧版顺序是雷,v1.3.13 第二版起不是了)。
29
 
30
  物理验收判据(第 1、2 节):正对→+Z、左→+Y、下→+X、右→−Y、上→−X。
@@ -230,13 +236,13 @@ def main():
230
  # 而位置已换帧,GUI 的夹爪模型会画成转了 90° 的姿势。)
231
  mid_t0b = relabel_published_frame(mid_t0)
232
  mid_t1b = relabel_published_frame(mid_t1)
233
- swapped = quat_product(quat_conjugate(mid_t0b.rotation), mid_t1b.rotation)
234
  if not np.allclose(quat_to_matrix(*swapped), R1, atol=1e-12):
235
  failures.append("顺序等价性不成立:先重标再相对化给出了不同姿态")
236
  # 反向钉死两条:①去掉右乘(旧实现只左乘 M)必须给出**不同**的答案,
237
  # 否则"体轴也改了名"这件事没有回归保护;②旧实现在原点那一瞬是常数 M
238
  # —— 正是用户看到"跟随夹爪的坐标轴还没改"的原因。
239
- rel_q = quat_product(quat_conjugate(mid_t0.rotation), mid_t1.rotation)
240
  left_only = quat_to_matrix(*quat_product(q_M, rel_q))
241
  if np.allclose(left_only, R1, atol=1e-12):
242
  failures.append("体轴改名没生效:只左乘 M 与左右各乘一次结果相同")
@@ -337,13 +343,15 @@ def main():
337
  break
338
 
339
  # 10. 原点相对化:首帧姿态 q0(含部署校正/启动朝向的任意常数
340
- # 旋转),此后 q0⁻¹⊗q 应还原为相对原点的真实转动;再重标
341
- # 之后发布姿态 = M·(相对转动)
 
 
342
  for i in range(200):
343
  q0r = random_unit_quaternion(rng)
344
  q_rel = random_unit_quaternion(rng)
345
- q = quat_product(q0r, q_rel)
346
- recovered = quat_product(quat_conjugate(q0r), q)
347
  if not np.allclose(recovered, q_rel, atol=1e-12):
348
  failures.append(f"#{i} 相对化未还原相对转动")
349
  break
@@ -355,6 +363,74 @@ def main():
355
  failures.append(f"#{i} 发布姿态应为 M·(相对转动)·Mᵀ")
356
  break
357
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  if failures:
359
  print("FAIL:")
360
  for f in failures[:5]:
@@ -363,7 +439,8 @@ def main():
363
  print("PASS: 发布帧(+X下/+Y左/+Z前) 数学性质全部通过"
364
  "(轴映射/物理轴复现/200 随机位姿 M·R_z90·R·Mᵀ/首帧=单位阵即"
365
  "体轴与世界轴在原点重合/★体轴改名+顺序等价★/双应用与M³/"
366
- "时基透传/轨迹等距/四元数积/原点相对化)")
 
367
  return 0
368
 
369
 
 
23
  一个常数 M(约 120°)——用户判"跟随夹爪的坐标轴还没改"。
24
  - **原点那一瞬发布姿态 = M·Mᵀ = 单位矩阵**:夹爪三箭头与世界三箭头
25
  重合,这是"一眼看出用的是哪一帧"的判据(第 4 节钉住)。
26
+ - 相对化是**右乘** R0⁻¹(世界系相对姿态 R·R0⁻¹),**不是左乘**:左乘
27
+ R0⁻¹·R 得到的是"锚点体轴系里的相对转动",两者只差一个常数共轭,却会让
28
+ 视图里看得见的**旋转轴整体转错**(v1.3.13 的 bug,锚点 R0 = R_x(−90°) 时
29
+ 恰好是绕发布帧 Y 轴 90°:Y 是不动轴所以只有它是对的,X 与 Z 被对调并翻号
30
+ —— 用户实测"绕 X → 表观绕 Z 同向、绕 Z → 表观绕 X 反向、绕 Y 正常")。
31
+ 第 10 节钉相对化的语义,第 11 节钉用户的验收判据「表观旋转轴 == 显示轴」。
32
  - 相对化与重标的**顺序不再影响结果**:共轭让 M 在两步里都自消,
33
+ (M·R·Mᵀ)(M·R0·Mᵀ)ᵀ = M·R·R0⁻¹·Mᵀ 与"先相对化再重标"逐元素相同。
34
  第 5 节把这条等价性钉住(旧版顺序是雷,v1.3.13 第二版起不是了)。
35
 
36
  物理验收判据(第 1、2 节):正对→+Z、左→+Y、下→+X、右→−Y、上→−X。
 
236
  # 而位置已换帧,GUI 的夹爪模型会画成转了 90° 的姿势。)
237
  mid_t0b = relabel_published_frame(mid_t0)
238
  mid_t1b = relabel_published_frame(mid_t1)
239
+ swapped = quat_product(mid_t1b.rotation, quat_conjugate(mid_t0b.rotation))
240
  if not np.allclose(quat_to_matrix(*swapped), R1, atol=1e-12):
241
  failures.append("顺序等价性不成立:先重标再相对化给出了不同姿态")
242
  # 反向钉死两条:①去掉右乘(旧实现只左乘 M)必须给出**不同**的答案,
243
  # 否则"体轴也改了名"这件事没有回归保护;②旧实现在原点那一瞬是常数 M
244
  # —— 正是用户看到"跟随夹爪的坐标轴还没改"的原因。
245
+ rel_q = quat_product(mid_t1.rotation, quat_conjugate(mid_t0.rotation))
246
  left_only = quat_to_matrix(*quat_product(q_M, rel_q))
247
  if np.allclose(left_only, R1, atol=1e-12):
248
  failures.append("体轴改名没生效:只左乘 M 与左右各乘一次结果相同")
 
343
  break
344
 
345
  # 10. 原点相对化:首帧姿态 q0(含部署校正/启动朝向的任意常数
346
+ # 旋转),此后**右乘** q0⁻¹ 应还原为相对原点的真实转动(世界系
347
+ # 相对姿态 q⊗q0⁻¹);再重标之后发布姿态 = M·(相对转动)·Mᵀ。
348
+ # ⚠左乘 q0⁻¹⊗q 不是"另一种写法",它是"锚点体轴系里的增量",
349
+ # 差一个常数共轭 ⇒ 视图里看得见的旋转轴整体转错(第 11 节)。
350
  for i in range(200):
351
  q0r = random_unit_quaternion(rng)
352
  q_rel = random_unit_quaternion(rng)
353
+ q = quat_product(q_rel, q0r) # 世界系:R = R_rel·R0
354
+ recovered = quat_product(q, quat_conjugate(q0r))
355
  if not np.allclose(recovered, q_rel, atol=1e-12):
356
  failures.append(f"#{i} 相对化未还原相对转动")
357
  break
 
363
  failures.append(f"#{i} 发布姿态应为 M·(相对转动)·Mᵀ")
364
  break
365
 
366
+ # 11. ★验收判据:位姿视图里"看得见的"旋转必须与物理转动同轴同向★
367
+ # 视图画的是 R_pub·(M·v)(体轴箭头 = R_pub 的三列),眼睛看到的表观
368
+ # 旋转 = R_pub(t)·R_pub(0)ᵀ;锚点那一瞬发布姿态是单位矩阵,所以它就
369
+ # 等于 R_pub(t) 本身。物理上绕"显示出来的第 k 根轴"转 deg,在中间帧里
370
+ # 就是绕 a = Mᵀ·e_k 左乘 ΔR(世界系转动,物理动作决定的就是它)。
371
+ # 修好后表观旋转必须恰好 = 绕 e_k 转 deg,**与锚点 R0 无关**。
372
+ # (v1.3.13 的左乘相对化在这里给出 +Z / +Y / −X,R0 = R_x(−90°) 时
373
+ # 正是用户实测的三条,见下面那条反向钉。)
374
+ def world_rot_quat(axis, deg):
375
+ a = np.asarray(axis, dtype=float)
376
+ a = a / np.linalg.norm(a)
377
+ half = math.radians(deg) / 2.0
378
+ return (math.sin(half) * a[0], math.sin(half) * a[1],
379
+ math.sin(half) * a[2], math.cos(half))
380
+
381
+ # q0 = R_x(−90°)(真机锚点,= 第 4 节的 mid0)在前,随机锚点在后 ——
382
+ # 两条都要过:修好后表观轴与 R0 无关。
383
+ anchor_qs = [(-c, 0.0, 0.0, c)] + [random_unit_quaternion(rng)
384
+ for _ in range(3)]
385
+ for j, q0r in enumerate(anchor_qs):
386
+ for k in range(3):
387
+ for deg in (30.0, -30.0):
388
+ a = M.T @ np.eye(3)[k] # 显示轴 e_k 对应的物理轴
389
+ q1 = quat_product(world_rot_quat(a, deg), q0r)
390
+ pub1 = finish_publish_pose(
391
+ PoseSample(position=(0.0, 0.0, 0.0), rotation=q1,
392
+ timestamp=2.0), q0r)
393
+ apparent = quat_to_matrix(*pub1.rotation)
394
+ want = quat_to_matrix(*world_rot_quat(np.eye(3)[k], deg))
395
+ if not np.allclose(apparent, want, atol=1e-9):
396
+ failures.append(
397
+ f"锚点#{j} 绕显示 {'XYZ'[k]} 轴 {deg:+.0f}° 的表观旋转"
398
+ f"不是同轴同向:\n实际\n{np.round(apparent, 6)}\n"
399
+ f"应为\n{np.round(want, 6)}")
400
+ break
401
+ # 反向钉:左乘 R0⁻¹(v1.3.13 的 bug)在真机锚点 R_x(−90°) 下把表观转轴
402
+ # 按 `R_y(−90°)·(显示轴)` 搬走、角度符号不变 —— 逐条就是用户实测的
403
+ # 「绕 X → 绕 Z 同向、绕 Z → 绕 X 逆向、**绕 Y 不动**」。
404
+ # 第 0、2 根轴必须**对不上**(否则上面的验收判据是"两种写法都能过"的空
405
+ # 断言),第 1 根轴必须**恰好重合**(Y 正是那个搬运旋转的不动轴 —— 用户
406
+ # 看到的"只有绕 Y 是正常的"就是这一行)。
407
+ Rbug = np.array([[0.0, 0.0, -1.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]])
408
+ q0r = anchor_qs[0]
409
+ for k in range(3):
410
+ a = M.T @ np.eye(3)[k]
411
+ q1 = quat_product(world_rot_quat(a, 30.0), q0r)
412
+ bug = quat_product(quat_conjugate(q0r), q1) # 左乘相对化
413
+ bug_pub = quat_to_matrix(*quat_product(
414
+ quat_product(q_M, bug), quat_conjugate(q_M)))
415
+ if not np.allclose(
416
+ bug_pub,
417
+ quat_to_matrix(*world_rot_quat(Rbug @ np.eye(3)[k], 30.0)),
418
+ atol=1e-9):
419
+ failures.append(
420
+ f"左乘相对化的错法不是预期的 R_y(−90°) 搬运(显示轴 k={k})")
421
+ continue
422
+ same = np.allclose(
423
+ bug_pub,
424
+ quat_to_matrix(*world_rot_quat(np.eye(3)[k], 30.0)), atol=1e-9)
425
+ if k == 1 and not same:
426
+ failures.append(
427
+ "绕 Y 的转动本该不受左乘 bug 影响(Y 是搬运旋转的不动轴)"
428
+ "—— 用户实测「只有绕 Y 正常」就靠这条")
429
+ if k != 1 and same:
430
+ failures.append(
431
+ f"绕 {'XYZ'[k]} 的 bug 竟然给出正确表观旋转 —— "
432
+ "上面的验收判据是空断言")
433
+
434
  if failures:
435
  print("FAIL:")
436
  for f in failures[:5]:
 
439
  print("PASS: 发布帧(+X下/+Y左/+Z前) 数学性质全部通过"
440
  "(轴映射/物理轴复现/200 随机位姿 M·R_z90·R·Mᵀ/首帧=单位阵即"
441
  "体轴与世界轴在原点重合/★体轴改名+顺序等价★/双应用与M³/"
442
+ "时基透传/轨迹等距/四元数积/原点相对化=右乘R0⁻¹/"
443
+ "★表观旋转轴==显示轴(用户验收判据)★)")
444
  return 0
445
 
446
 
collector/tools/tests/test_gripper_pose_deadband_publish.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """发布出口的死区集成测试:走真 SlamProcessController(v1.3.14)。
4
+
5
+ venv/bin/python tools/tests/test_gripper_pose_deadband_publish.py
6
+
7
+ 背景:用户报「大幅漂移没了,静止时轨迹线/末端点还有小抖」。实测那是估计器的
8
+ 毫米级有界游走(严格静止窗:位置游走半径逐会话 p50 1.06-1.85mm/max 1.39-4.98mm;
9
+ 姿态峰峰 p50 0.25°/max 1.80°),单帧步长远在跳变门
10
+ 限值之下,所以跳变门(比相邻样本)管不着。修法是在**发布出口**对"上次发出的
11
+ 位置"设死区:不足死区就发上次的值。
12
+
13
+ 单元测试(test_pose_publish_deadband.py)钉的是函数语义;这里钉的是**接线**:
14
+ 死区必须真的作用在 _publish_pose 出口那条路上(UI/_on_pose/轨迹/orb_pose.json
15
+ 全从这一条路出去),而且不能把下面这些既有契约弄坏:
16
+
17
+ 1. 静止游走:发出去的值冻结(_on_pose 收到的位置恒定),末端点不再抖
18
+ 2. 采样节拍不变:每帧仍算 valid、raw_seq 仍逐帧递增、lost_count 不得虚增
19
+ (死区是"发旧值",不是"丢帧"——丢帧会让 GUI 的位姿丢失计数虚报)
20
+ 3. orb_pose.json 里也是冻结值(GUI/控制通道读的就是它)
21
+ 4. 真实移动照常跟踪(误差 ≤ 死区)
22
+ 5. 重设原点后死区参照被丢掉(否则会拿旧锚下的位置比新锚下的位置)
23
+ 6. 落盘未启用的分支(pose_paths 为空)同样生效
24
+
25
+ 退出码 0 = 全部通过。
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import math
32
+ import os
33
+ import random
34
+ import sys
35
+ import tempfile
36
+
37
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
38
+ os.path.abspath(__file__))))
39
+ sys.path.insert(0, REPO_ROOT)
40
+
41
+ from core.gripper.slam.process_controller import ( # noqa: E402
42
+ SlamProcessController,
43
+ SlamState,
44
+ )
45
+ from core.gripper.slam.protocol import ( # noqa: E402
46
+ POSE_PUBLISH_DEADBAND_M,
47
+ PoseSample,
48
+ ProtocolEvent,
49
+ parse_slam_line,
50
+ relabel_published_frame,
51
+ rotate_pose_z90,
52
+ )
53
+
54
+ FAILS: list[str] = []
55
+ EPS = POSE_PUBLISH_DEADBAND_M
56
+
57
+
58
+ def check(name: str, cond: bool, detail: str = ""):
59
+ tag = "PASS" if cond else "FAIL"
60
+ print(f" [{tag}] {name}" + (f" — {detail}" if detail else ""))
61
+ if not cond:
62
+ FAILS.append(name)
63
+
64
+
65
+ def make_controller(pose_paths=(None, None), log=None) -> SlamProcessController:
66
+ return SlamProcessController(
67
+ SlamState(),
68
+ executable="/bin/true",
69
+ vocabulary="/dev/null",
70
+ settings="/dev/null",
71
+ device_config="/dev/null",
72
+ trajectory="/dev/null",
73
+ work_dir="/tmp",
74
+ pose_paths=pose_paths,
75
+ log=log,
76
+ runtime_env_factory=lambda: {},
77
+ cpu_roles={
78
+ "fays_left_orb": (0,),
79
+ "fays_right_orb": (1,),
80
+ "fays_background": (2,),
81
+ },
82
+ )
83
+
84
+
85
+ def feed(controller, counters, timestamp, position,
86
+ quat=(1.0, 0.0, 0.0, 0.0)):
87
+ """喂一行**真格式**的 native 位姿(走真解析器,别手工造 PoseSample)。"""
88
+ x, y, z = position
89
+ qw, qx, qy, qz = quat
90
+ line = (f"[{timestamp:.6f}] XYZ:({x:.6f},{y:.6f},{z:.6f}) "
91
+ f"Quat:(w={qw:.6f},x={qx:.6f},y={qy:.6f},z={qz:.6f})")
92
+ event = parse_slam_line(line)
93
+ assert event is not None and event.kind == "pose", f"{line!r} -> {event!r}"
94
+ controller._handle_event(event, counters, arrival_monotonic=timestamp,
95
+ arrival_wall_time=timestamp)
96
+ pose, _, _ = controller.read_latest_pose()
97
+ return pose
98
+
99
+
100
+ def rest_wander(frames, seed=20261013, sigma=0.00010, a=0.96):
101
+ """实测形状的静止游走:有界小球(AR(1)),不是越走越远的随机游走。"""
102
+ rng = random.Random(seed)
103
+ pos = [0.0, 0.0, 0.0]
104
+ out = []
105
+ for _ in range(frames):
106
+ pos = [a * pos[0] + rng.gauss(0.0, sigma),
107
+ a * pos[1] + rng.gauss(0.0, sigma),
108
+ a * pos[2] + rng.gauss(0.0, sigma)]
109
+ out.append(tuple(pos))
110
+ return out
111
+
112
+
113
+ def published_frame_images(raw_positions):
114
+ """把每个原始(中间帧)位置独立过一遍发布变换,得到"合法发布值"集合。
115
+
116
+ 发布值若都落在其中,说明死区是**替换**(发的是某一帧真值),而不是插值/
117
+ 平均/自造的值 —— 这是"发上次的值"这句话唯一能被证伪的形式(第 1 节那句
118
+ 「连续帧逐分量相同 or distinct<=3」是恒真的,钉不住东西)。
119
+
120
+ ⚠ 先按喂入时的**线上量化**(feed 用 %.6f 写行)还原,否则算出来的像与
121
+ 控制器真正收到的值差在 6 位小数之外,集合对不上。
122
+ """
123
+ images = set()
124
+ for p in raw_positions:
125
+ q = tuple(float(f"{float(v):.6f}") for v in p)
126
+ s = relabel_published_frame(rotate_pose_z90(
127
+ PoseSample(position=q, rotation=(0.0, 0.0, 0.0, 1.0),
128
+ timestamp=0.0)))
129
+ images.add(tuple(round(float(v), 12) for v in s.position))
130
+ return images
131
+
132
+
133
+ def main() -> int:
134
+ print("发布出口死区集成测试(v1.3.14)")
135
+
136
+ # ---------------------------------------------------------- 静止游走
137
+ for label, pose_paths in (("落盘未启用(pose_paths 空)", (None, None)),
138
+ ("落盘启用(写 orb_pose.json)", None)):
139
+ if pose_paths is None:
140
+ tmpdir = tempfile.mkdtemp(prefix="pose_deadband_")
141
+ pose_paths = (os.path.join(tmpdir, "orb_pose.json.tmp"),
142
+ os.path.join(tmpdir, "orb_pose.json"))
143
+ print(f"[1] 静止游走冻结 · {label}")
144
+ controller = make_controller(pose_paths)
145
+ counters = {"raw": 0, "valid": 0}
146
+
147
+ raw = rest_wander(400)
148
+ published = []
149
+ for i, p in enumerate(raw):
150
+ pose = feed(controller, counters, 1.0 + i * 0.02, p)
151
+ published.append(tuple(pose.position))
152
+ raw_radius = max(math.dist(p, (0.0, 0.0, 0.0)) for p in raw)
153
+ distinct = len(set(published))
154
+
155
+ check("合成游走本身有实测量级的幅度(不是空断言)",
156
+ 0.0004 < raw_radius < 0.004,
157
+ f"半径 {raw_radius*1000:.2f}mm")
158
+ check("发出去的位置被冻结",
159
+ distinct <= 3,
160
+ f"400 帧只发出 {distinct} 个不同位置"
161
+ f"(死区 {EPS*1000:.0f}mm,游走半径"
162
+ f" {raw_radius*1000:.2f}mm)")
163
+ transitions = sum(1 for i in range(1, len(published))
164
+ if published[i] != published[i - 1])
165
+ check("末端点保持:相邻帧发布值相同,只有越过死区的那几帧才变",
166
+ transitions <= 3,
167
+ f"{transitions} 次变化 / {len(published) - 1} 帧"
168
+ f"(游走半径 {raw_radius*1000:.2f}mm 全在死区 "
169
+ f"{EPS*1000:.0f}mm 内 ⇒ 本该 0 次)")
170
+ images = published_frame_images(raw)
171
+ rounded = [tuple(round(float(v), 12) for v in p) for p in published]
172
+ check("替换而非平滑:每个发布值都是某一帧原始值的发布帧像",
173
+ all(p in images for p in rounded),
174
+ f"{sum(1 for p in rounded if p not in images)} 个发布值不在"
175
+ "原始值像集合里(插值/平均会落到集合外)")
176
+ check("采样节拍不变:每帧都算 valid",
177
+ counters["valid"] == len(raw),
178
+ f"valid={counters['valid']} / 喂了 {len(raw)} 帧")
179
+ check("采样节拍不变:raw_seq 逐帧递增",
180
+ controller._pose_raw_seq == len(raw))
181
+ check("位姿丢失计数不得虚增(死区是发旧值,不是丢帧)",
182
+ controller.pose_lost_count() == 0,
183
+ f"lost_count={controller.pose_lost_count()}")
184
+ if pose_paths[1]:
185
+ with open(pose_paths[1], encoding="utf-8") as fh:
186
+ on_disk = json.load(fh)
187
+ check("orb_pose.json 里也是冻结值",
188
+ (on_disk["x"], on_disk["y"], on_disk["z"])
189
+ == published[-1])
190
+ else:
191
+ check("落盘未启用分支同样生效(有 pose 返回、有冻结)",
192
+ published[-1] is not None and distinct <= 3)
193
+
194
+ # ---------------------------------------------------------- 真实移动
195
+ print("[2] 真实移动照常跟踪")
196
+ controller = make_controller()
197
+ counters = {"raw": 0, "valid": 0}
198
+ raw = [(0.01 * i, 0.0, 0.0) for i in range(200)] # 10mm/帧 ≈ 0.3m/s
199
+ err = 0.0
200
+ for i, p in enumerate(raw):
201
+ pose = feed(controller, counters, 2.0 + i * 0.02, p)
202
+ expected = _published_position(p)
203
+ err = max(err, math.dist(pose.position, expected))
204
+ check("快速移动逐帧跟上",
205
+ err < 1e-6,
206
+ f"与独立复算的发布帧位置最大差 {err:.2e}m")
207
+ check("移动中丢失计数仍为 0", controller.pose_lost_count() == 0)
208
+
209
+ # 慢速移动(每帧 0.5mm < 死区):量化但不丢,误差不累积
210
+ controller = make_controller()
211
+ counters = {"raw": 0, "valid": 0}
212
+ raw = [(0.0005 * i, 0.0, 0.0) for i in range(200)]
213
+ err = 0.0
214
+ for i, p in enumerate(raw):
215
+ pose = feed(controller, counters, 3.0 + i * 0.02, p)
216
+ err = max(err, math.dist(pose.position, _published_position(p)))
217
+ check("慢速移动(0.5mm/帧)误差不超死区",
218
+ err <= EPS + 1e-9,
219
+ f"最大误差 {err*1000:.2f}mm ≤ {EPS*1000:.0f}mm")
220
+ final, _, _ = controller.read_latest_pose()
221
+ check("慢速移动的末端仍跟到真值附近",
222
+ math.dist(final.position, _published_position(raw[-1])) <= EPS + 1e-9)
223
+
224
+ # ---------------------------------------------------------- 重设原点
225
+ # 注意:位置归零是 **native** 做的(原点是 C++ 帧的事),Python 这一层
226
+ # 只把姿态按新锚相对化。所以这里能钉的是"死区参照跟着丢":重锚后即使
227
+ # 新���置离旧锚下的发布值不到一个死区,也必须照发,而不是被旧参照顶掉。
228
+ print("[3] 重设原点后参照要丢掉")
229
+ controller = make_controller()
230
+ counters = {"raw": 0, "valid": 0}
231
+ anchor_native = (0.3, 0.2, 0.1)
232
+ pose = feed(controller, counters, 4.0, anchor_native)
233
+ before = controller._last_published_position
234
+ before_published = tuple(pose.position)
235
+ check("重设原点前参照已建立", before is not None)
236
+ check("重设原点前参照 = 当前发布位置",
237
+ before is not None and math.dist(before, before_published) < 1e-9)
238
+ controller._handle_event(
239
+ ProtocolEvent("origin", "[ORIGIN SET]", "[ORIGIN SET]"),
240
+ counters, arrival_monotonic=4.1, arrival_wall_time=4.1)
241
+ check("重设原点后参照被丢弃", controller._last_published_position is None,
242
+ f"参照={controller._last_published_position}")
243
+ # 新锚下第一帧:相对旧锚下的发布值只挪 0.5mm(< 死区 3mm)。
244
+ # 参照没丢的话这一帧会被旧值顶掉 —— 那正是"重锚后位置跳变被吞"的病。
245
+ nudged_native = (0.3, 0.2, 0.1005)
246
+ pose = feed(controller, counters, 4.2, nudged_native)
247
+ check("重锚后参照重新建立",
248
+ controller._last_published_position is not None)
249
+ check("重锚后首帧照发(不足死区也放行,没被旧锚的参照顶掉)",
250
+ pose is not None
251
+ and math.dist(pose.position,
252
+ _published_position(nudged_native)) < 1e-9,
253
+ f"发布位置={tuple(pose.position) if pose else None}"
254
+ f"(期望 {_published_position(nudged_native)})")
255
+ check("重锚后首帧参照取的是新帧的值",
256
+ math.dist(controller._last_published_position,
257
+ _published_position(nudged_native)) < 1e-9)
258
+
259
+ # ---------------------------------------------------------- 开机可见
260
+ # 用户是拿 GUI 验收的:「改了但界面上看不出来」等于没改。死区值必须在
261
+ # 开机日志里有一行,用户才能在 SLAM 日志面板里确认当次生效的是 3.0mm。
262
+ print("[4] 开机日志里能看见当次生效的死区(界面可验收)")
263
+ lines: list[str] = []
264
+ controller = make_controller(log=lines.append)
265
+ # READY 是 native 报「可以按回车」的那一行,每次起流只来一次
266
+ ready = parse_slam_line("[SLAM] READY")
267
+ assert ready is None or ready.kind != "pose"
268
+ controller._handle_event(ProtocolEvent("ready", "READY", "READY"),
269
+ {"raw": 0, "valid": 0},
270
+ arrival_monotonic=0.0, arrival_wall_time=0.0)
271
+ hit = [ln for ln in lines if "死区" in ln]
272
+ check("开机打出一行死区说明(GUI 日志面板能看见)",
273
+ len(hit) == 1,
274
+ f"{hit[0] if hit else '一行都没有'}")
275
+ check("行里的数值就是当次生效的死区",
276
+ bool(hit) and f"{EPS * 1000:.1f}mm" in hit[0],
277
+ f"期望含 {EPS * 1000:.1f}mm")
278
+
279
+ if FAILS:
280
+ print(f"\nFAIL: {len(FAILS)} 条")
281
+ for f in FAILS:
282
+ print(" -", f)
283
+ return 1
284
+ print(f"\nPASS: 死区 {EPS*1000:.0f}mm 在发布出口生效——静止发旧值(节拍/序号/"
285
+ "丢失计数不变)、移动照常、重锚丢参照")
286
+ return 0
287
+
288
+
289
+ def _published_position(native_position):
290
+ """独立复算发布帧位置(native → R_z(+90°) → 世界重标 M)。"""
291
+ from core.gripper.slam.protocol import (
292
+ PoseSample, relabel_published_frame, rotate_pose_z90)
293
+ return relabel_published_frame(rotate_pose_z90(PoseSample(
294
+ position=native_position, rotation=(0.0, 0.0, 0.0, 1.0),
295
+ timestamp=0.0))).position
296
+
297
+
298
+ if __name__ == "__main__":
299
+ sys.exit(main())
collector/tools/tests/test_local_delete.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """本地删除统一口径(真删除)测试 —— 文件 + 录制行,全程校验。
2
+
3
+ 2026-10-12:删除从「标一行痕迹 / 界面照报成功」改成**真删除 + 删除后校验**。
4
+ 本测试钉死四件事:
5
+ 1. 池化删除删对组(数据 + 视频 + meta 行),且返回 True 只代表真删净;
6
+ 2. 删不掉(目录不可写)时返回 False 并给出残留文件——不许谎报成功;
7
+ 3. 旧格式会话目录整目录删 + 校验;目录不存在如实报错;
8
+ 4. RecordingRepo.delete_local_episode / delete_local_dir 真删库行(含尾斜杠
9
+ 等路径写法归一化)。
10
+
11
+ 用法:
12
+ venv/bin/python tools/tests/test_local_delete.py
13
+ """
14
+ import os
15
+ import shutil
16
+ import sys
17
+ import tempfile
18
+
19
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
20
+ os.path.abspath(__file__)))))
21
+
22
+ from core.database import Database
23
+ from core.egodata_writer import _atomic_write_parquet, _episode_rows_table
24
+ from core.helpers import (delete_local_session, delete_pooled_episode,
25
+ list_task_episodes, pooled_data_parquet_path,
26
+ pooled_episode_leftovers, pooled_episodes_path)
27
+ from core.recording_record import RecordingRecord
28
+
29
+ FAILS = []
30
+
31
+
32
+ def check(cond, msg):
33
+ print(f" {'PASS' if cond else 'FAIL'}: {msg}")
34
+ if not cond:
35
+ FAILS.append(msg)
36
+
37
+
38
+ def make_episode(task_dir: str, file_no: int, key: str = "uvc_rgb"):
39
+ """造一段池化数据:文件号 file_no(全局 N = file_no + 1)。"""
40
+ n = file_no + 1
41
+ d = os.path.join(task_dir, "data", "chunk-000")
42
+ os.makedirs(d, exist_ok=True)
43
+ with open(os.path.join(d, f"episode-{file_no:03d}.parquet"), "wb") as f:
44
+ f.write(b"data")
45
+ v = os.path.join(task_dir, "videos", "chunk-000", key)
46
+ os.makedirs(v, exist_ok=True)
47
+ with open(os.path.join(v, f"episode-{file_no:03d}.mp4"), "wb") as f:
48
+ f.write(b"video")
49
+ ep_path = pooled_episodes_path(task_dir, n)
50
+ os.makedirs(os.path.dirname(ep_path), exist_ok=True)
51
+ _atomic_write_parquet(
52
+ _episode_rows_table([{"episode_index": n, "task_name": "t"}]),
53
+ ep_path)
54
+
55
+
56
+ def test_pooled_true_delete(root: str):
57
+ print("── 1. 池化真删除(删对组 + 校验)──")
58
+ t = os.path.join(root, "pooled")
59
+ for f in (0, 1, 2):
60
+ make_episode(t, f)
61
+ check(list_task_episodes(t) == [1, 2, 3], "造 3 段(全局 N=1..3)")
62
+ check(delete_pooled_episode(t, 2), "delete N=2 返回 True")
63
+ check(pooled_episode_leftovers(t, 2) == [], "N=2 残留为空(真删净)")
64
+ check(list_task_episodes(t) == [1, 3], "删的是 N=2(文件号 001)")
65
+ check(not os.path.isfile(pooled_data_parquet_path(t, 2))
66
+ and not os.path.isfile(pooled_episodes_path(t, 2)),
67
+ "N=2 的 parquet 与 meta 行文件都没了")
68
+ check(os.path.isfile(pooled_data_parquet_path(t, 1))
69
+ and os.path.isfile(pooled_data_parquet_path(t, 3)),
70
+ "邻居段(N=1/N=3)原样在")
71
+ check(delete_pooled_episode(t, 2), "重复删除幂等(返回 True)")
72
+
73
+
74
+ def test_honest_failure(root: str):
75
+ print("── 2. 删不掉必须如实报错(不许报成功)──")
76
+ t = os.path.join(root, "locked")
77
+ make_episode(t, 0)
78
+ vdir = os.path.join(t, "videos", "chunk-000", "uvc_rgb")
79
+ os.chmod(vdir, 0o500) # 目录不可写 → unlink 失败
80
+ try:
81
+ check(delete_pooled_episode(t, 1) is False,
82
+ "视频删不掉时 delete_pooled_episode 返回 False")
83
+ left = pooled_episode_leftovers(t, 1)
84
+ check(any(p.endswith(".mp4") for p in left),
85
+ f"残留清单含那段视频({len(left)} 个残留)")
86
+ ok, err = delete_local_session(t, 1)
87
+ check(ok is False and err, f"delete_local_session 报错: {err}")
88
+ finally:
89
+ os.chmod(vdir, 0o755)
90
+ ok, err = delete_local_session(t, 1)
91
+ check(ok is True and err == "", "恢复可写后重试成功")
92
+ check(pooled_episode_leftovers(t, 1) == [], "重试后残留为空")
93
+
94
+
95
+ def test_legacy_dir(root: str):
96
+ print("── 3. 旧格式会话目录 ──")
97
+ d = os.path.join(root, "legacy_sess")
98
+ os.makedirs(os.path.join(d, "meta"))
99
+ with open(os.path.join(d, "meta", "info.json"), "w") as f:
100
+ f.write("{}")
101
+ check(delete_local_session(d, 0) == (True, ""), "整目录删除返回 (True, '')")
102
+ check(not os.path.isdir(d), "目录真的没了")
103
+ ok, err = delete_local_session(d, 0)
104
+ check(ok is False and err, f"目录不存在如实报错: {err}")
105
+ ok, err = delete_local_session("", 3)
106
+ check(ok is False and err, f"空路径如实报错: {err}")
107
+
108
+
109
+ def test_repo_true_delete(root: str):
110
+ print("── 4. 录制行真删除(不再留痕迹行)──")
111
+ import core.recording_repository as rr
112
+ db = Database(db_path=os.path.join(root, "test.db"))
113
+ db.init_schema()
114
+ rr.db = db # 注入临时库,别碰真实 pipeline.db
115
+ try:
116
+ rows = [("/x/UMI_9_29", 7), ("/x/UMI_9_29", 8), ("/x/UMI_9_29/", 9),
117
+ ("/x/other", 1)]
118
+ for i, (path, ep) in enumerate(rows):
119
+ rr.RecordingRepo.save(RecordingRecord(
120
+ id=f"r{i}", camera_index=0, camera_name="cam",
121
+ file_path=path, episode_index=ep, file_size_mb=1.0,
122
+ duration_sec=1.0, resolution_w=1, resolution_h=1,
123
+ status="completed", started_at="t0", finished_at="t1"))
124
+ check(len(rr.RecordingRepo.list_all()) == 4, "造 4 行(两组同目录)")
125
+ check(rr.RecordingRepo.delete_local_episode("/x/UMI_9_29",
126
+ episode_index=7) == 1,
127
+ "delete_local_episode 精确删 1 行")
128
+ check(len(rr.RecordingRepo.list_all()) == 3, "只剩 3 行")
129
+ check(rr.RecordingRepo.delete_local_dir("/x/UMI_9_29/") == 2,
130
+ "delete_local_dir 归一化尾斜杠后删 2 行")
131
+ left = [r.file_path for r in rr.RecordingRepo.list_all()]
132
+ check(left == ["/x/other"], f"别的任务的行没被牵连: {left}")
133
+ check(rr.RecordingRepo.delete_local_dir("") == 0, "空路径删 0 行")
134
+ finally:
135
+ db.close()
136
+ rr.db = None
137
+
138
+
139
+ def main():
140
+ root = tempfile.mkdtemp(prefix="local_delete_")
141
+ try:
142
+ test_pooled_true_delete(root)
143
+ test_honest_failure(root)
144
+ test_legacy_dir(root)
145
+ test_repo_true_delete(root)
146
+ finally:
147
+ shutil.rmtree(root, ignore_errors=True)
148
+ print()
149
+ if FAILS:
150
+ print(f"FAIL: {len(FAILS)} 项未通过")
151
+ return 1
152
+ print("PASS: 真删除(文件 + 录制行)测试全部通过")
153
+ return 0
154
+
155
+
156
+ if __name__ == "__main__":
157
+ sys.exit(main())
collector/tools/tests/test_pose_guard_limits.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """位姿连续性门的门槛单测(v1.3.14)。
2
+
3
+ `process_controller._publish_pose` 前的那道门(`protocol.pose_rejection_reason`)
4
+ 和 native 桥里的同门(`fayssense_orb_slam.cc` 的 `kPoseStepAllowanceM` 一族)
5
+ 是**同一个判据的两份实现**,谁动谁都得同步。历史上没测试钉过它们,于是
6
+ 「60ms 帧把限值抬到 0.23m」这个洞一直是靠肉眼发现——直到 2026-10-12 它在
7
+ 真机上放行了一次 0.224m 的平移跳变,GUI 里就是那个「抖/飘」。
8
+
9
+ 这里钉三件事:
10
+ ① 两处常量必须逐字相等(防镜像漂移);
11
+ ② 实测包络:69061 条发布位姿里,**真实**单帧步长最大 0.0601m(p99 0.043)、
12
+ 最小异常步长 0.1075m —— 限值必须夹在中间,且在降采样 60ms 帧上不变;
13
+ ③ 门给出的 reason 文本仍然区分 平移跳/旋转跳/时间戳回退/非法分量。
14
+
15
+ 实测来源:logging 里的 `[SLAM] Pose held:` 与 native `[POSE_REBASE]`,
16
+ 以及 logs/slam_native/20261012_*_3500000262300100_slam_stdout.log 的
17
+ A/B 分支交替段(同一四元数、仅平移在两组之间来回)。
18
+
19
+ 用法:
20
+ venv/bin/python tools/tests/test_pose_guard_limits.py
21
+ """
22
+ import math
23
+ import os
24
+ import re
25
+ import sys
26
+
27
+ ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
28
+ sys.path.insert(0, ROOT)
29
+
30
+ from core.gripper.slam import protocol as proto # noqa: E402
31
+
32
+ NATIVE_CC = os.path.join(
33
+ ROOT,
34
+ "core/gripper/orb_slam_src/ORB-SLAM/Examples/fays/fayssense_orb_slam.cc",
35
+ )
36
+
37
+ # 实测包络(见模块 docstring)。
38
+ GENUINE_MAX_STEP_M = 0.0601 # 真实单帧平移步长上限
39
+ GENUINE_P99_STEP_M = 0.0430
40
+ ARTIFACT_MIN_STEP_M = 0.1075 # 最小的一次估计器平移跳变
41
+ ARTIFACT_MAX_STEP_M = 0.2238 # 旧的 60ms 限值下被原样放行的那一档
42
+ GENUINE_MAX_ROT_DEG = 15.75
43
+ ARTIFACT_ROT_DEG = 83.405502 # main.log 2026-10-12 14:25:51 rotation_jump
44
+
45
+ FAILS = []
46
+
47
+
48
+ def check(cond, msg):
49
+ if cond:
50
+ print(f" PASS: {msg}")
51
+ else:
52
+ print(f" FAIL: {msg}")
53
+ FAILS.append(msg)
54
+
55
+
56
+ def quat_about_x(deg):
57
+ half = math.radians(deg) / 2.0
58
+ return (math.cos(half), math.sin(half), 0.0, 0.0)
59
+
60
+
61
+ def pose(position, timestamp, rotation=(1.0, 0.0, 0.0, 0.0)):
62
+ return proto.PoseSample(
63
+ position=tuple(position), rotation=tuple(rotation),
64
+ timestamp=timestamp, sequence=1,
65
+ )
66
+
67
+
68
+ def step(distance_m, dt_s, base=(0.0, 0.0, 0.0), rot=(1.0, 0.0, 0.0, 0.0)):
69
+ """沿 +x 走 distance_m,间隔 dt_s,返回 (reason, previous, current)。"""
70
+ prev = pose(base, 1000.0)
71
+ cur = pose(
72
+ (base[0] + distance_m, base[1], base[2]), 1000.0 + dt_s, rotation=rot)
73
+ return proto.pose_rejection_reason(cur, prev), prev, cur
74
+
75
+
76
+ def test_constants_mirror():
77
+ """① native 与 Python 的门槛必须逐字相等。"""
78
+ if not os.path.exists(NATIVE_CC):
79
+ check(False, f"找不到 native 源文件 {NATIVE_CC}(镜像检查无法进行)")
80
+ return
81
+ with open(NATIVE_CC, encoding="utf-8") as fh:
82
+ src = fh.read()
83
+
84
+ def native(name):
85
+ m = re.search(rf"{name}\s*=\s*(-?[\d.]+)f?\s*;", src)
86
+ return float(m.group(1)) if m else None
87
+
88
+ def native_degrees(name):
89
+ """角度常量在 native 里写成 `20.0f * M_PI / 180.0f`,取度数那一项。"""
90
+ m = re.search(rf"{name}\s*=\s*(-?[\d.]+)f?\s*\*\s*static_cast", src)
91
+ if m is None:
92
+ m = re.search(rf"{name}\s*=\s*(-?[\d.]+)f?\s*\*\s*M_PI", src)
93
+ return float(m.group(1)) if m else None
94
+
95
+ pairs = (
96
+ ("kPoseStepAllowanceM", "POSE_STEP_ALLOWANCE_M"),
97
+ ("kPoseMaxLinearSpeedMps", "POSE_MAX_LINEAR_SPEED_MPS"),
98
+ ("kPoseMaxGuardIntervalS", "POSE_MAX_GUARD_INTERVAL_S"),
99
+ )
100
+ for native_name, py_name in pairs:
101
+ nv = native(native_name)
102
+ pv = getattr(proto, py_name)
103
+ check(
104
+ nv is not None and abs(nv - pv) < 1e-12,
105
+ f"{native_name}={nv} 与 {py_name}={pv} 一致",
106
+ )
107
+
108
+ deg_native = native_degrees("kPoseAngleAllowanceRad")
109
+ check(
110
+ deg_native is not None
111
+ and abs(math.radians(deg_native) - proto.POSE_ANGLE_ALLOWANCE_RAD) < 1e-12,
112
+ f"kPoseAngleAllowanceRad({deg_native}°)与 POSE_ANGLE_ALLOWANCE_RAD 一致",
113
+ )
114
+ speed_native = native_degrees("kPoseMaxAngularSpeedRadS")
115
+ check(
116
+ speed_native is not None
117
+ and abs(math.radians(speed_native) - proto.POSE_MAX_ANGULAR_SPEED_RAD_S) < 1e-12,
118
+ f"kPoseMaxAngularSpeedRadS({speed_native}°/s)"
119
+ "与 POSE_MAX_ANGULAR_SPEED_RAD_S 一致",
120
+ )
121
+
122
+
123
+ def test_genuine_steps_accepted():
124
+ """② 真实步长(含 p99)在任何帧间隔下都必须放行。"""
125
+ for dt in (0.02, 0.06):
126
+ for dist in (GENUINE_P99_STEP_M, GENUINE_MAX_STEP_M):
127
+ reason, _, _ = step(dist, dt)
128
+ check(
129
+ reason is None,
130
+ f"真实步长 {dist:.4f}m @ dt={dt:.2f}s 被放行(reason={reason})",
131
+ )
132
+ # 静止时的估计器微调也是真实数据的一部分,不能被误伤。
133
+ reason, _, _ = step(0.0, 0.06)
134
+ check(reason is None, "零位移 @ dt=0.06s 被放行")
135
+
136
+
137
+ def test_artifacts_rejected_at_every_interval():
138
+ """② 估计器平移跳变在 20ms 和 60ms 帧上都必须被拦下。
139
+
140
+ 修的就是这条:旧门槛 charge 了 60ms,限值 0.23m,把 0.107-0.224m
141
+ 原样放了出去。降采样帧不该享有 3 倍宽限——跳变恰好落在那一帧上。
142
+ """
143
+ for dt in (0.02, 0.04, 0.06):
144
+ for dist in (ARTIFACT_MIN_STEP_M, ARTIFACT_MAX_STEP_M):
145
+ reason, prev, cur = step(dist, dt)
146
+ check(
147
+ reason is not None and "translation step" in reason,
148
+ f"跳变 {dist:.4f}m @ dt={dt:.2f}s 被拦下(reason={reason})",
149
+ )
150
+ limit = proto.POSE_STEP_ALLOWANCE_M + (
151
+ proto.POSE_MAX_LINEAR_SPEED_MPS * min(
152
+ dt, proto.POSE_MAX_GUARD_INTERVAL_S)
153
+ )
154
+ check(
155
+ limit < ARTIFACT_MIN_STEP_M,
156
+ f"dt={dt:.2f}s 的限值 {limit:.4f}m 低于最小跳变 "
157
+ f"{ARTIFACT_MIN_STEP_M:.4f}m",
158
+ )
159
+ check(
160
+ limit > GENUINE_MAX_STEP_M,
161
+ f"dt={dt:.2f}s 的限值 {limit:.4f}m 高于最大真实步长 "
162
+ f"{GENUINE_MAX_STEP_M:.4f}m",
163
+ )
164
+
165
+
166
+ def test_interval_cap_removes_decimation_inflation():
167
+ """② 限值不再随 60ms 降采样帧膨胀——这是修复的本质。
168
+
169
+ 限值对 dt 仍是线性的(正常:帧隔得久,允许走得远),但只线性到
170
+ 名义帧周期为止。修复前它一直线性到 0.1s,于是 60ms 帧拿到 0.23m——
171
+ 而跳变恰好落在那一帧上。现在 60ms 帧与 30ms 帧同额。
172
+ """
173
+ def limit(dt):
174
+ return (
175
+ proto.POSE_STEP_ALLOWANCE_M
176
+ + proto.POSE_MAX_LINEAR_SPEED_MPS
177
+ * min(dt, proto.POSE_MAX_GUARD_INTERVAL_S)
178
+ )
179
+
180
+ cap = proto.POSE_MAX_GUARD_INTERVAL_S
181
+ check(
182
+ cap <= 0.04,
183
+ f"间隔上限 {cap}s 不超过名义帧周期量级(20/20/60ms 的 60ms)",
184
+ )
185
+ check(
186
+ limit(0.06) == limit(cap) == limit(0.5),
187
+ f"60ms 及更长的帧与 {cap * 1000:.0f}ms 帧同额"
188
+ f"(限值 {limit(0.06):.4f}m,旧门槛这里是 0.2300m)",
189
+ )
190
+ check(
191
+ limit(0.02) < limit(0.06),
192
+ f"20ms 帧仍比 60ms 帧严({limit(0.02):.4f}m < {limit(0.06):.4f}m),"
193
+ "即限值随真实帧间隔单调、不是常数",
194
+ )
195
+ check(
196
+ limit(0.06) < ARTIFACT_MIN_STEP_M,
197
+ f"最宽的 60ms 档限值 {limit(0.06):.4f}m 仍低于最小跳变 "
198
+ f"{ARTIFACT_MIN_STEP_M:.4f}m",
199
+ )
200
+
201
+
202
+ def test_rotation_steps():
203
+ """② 真实旋转步长放行,83° 的旋转跳变拦下。"""
204
+ limit_60 = proto.POSE_ANGLE_ALLOWANCE_RAD + (
205
+ proto.POSE_MAX_ANGULAR_SPEED_RAD_S
206
+ * min(0.06, proto.POSE_MAX_GUARD_INTERVAL_S)
207
+ )
208
+ check(
209
+ math.degrees(limit_60) > GENUINE_MAX_ROT_DEG * 2.0,
210
+ f"dt=0.06s 的旋转限值 {math.degrees(limit_60):.1f}° 仍是最大真实旋转步长"
211
+ f" {GENUINE_MAX_ROT_DEG}° 的 2 倍以上余量",
212
+ )
213
+ for dt in (0.02, 0.06):
214
+ reason, _, _ = step(0.0, dt, rot=quat_about_x(GENUINE_MAX_ROT_DEG))
215
+ check(
216
+ reason is None,
217
+ f"真实旋转步长 {GENUINE_MAX_ROT_DEG}° @ dt={dt:.2f}s 被放行",
218
+ )
219
+ reason, _, _ = step(0.0, dt, rot=quat_about_x(ARTIFACT_ROT_DEG))
220
+ check(
221
+ reason is not None and "rotation step" in reason,
222
+ f"旋转跳变 {ARTIFACT_ROT_DEG}° @ dt={dt:.2f}s 被拦下"
223
+ f"(reason={reason})",
224
+ )
225
+
226
+
227
+ def test_reason_taxonomy():
228
+ """③ reason 文本仍然区分四类,且优先级不变(时间戳先于跳变)。"""
229
+ prev = pose((0.0, 0.0, 0.0), 1000.0)
230
+
231
+ cur = pose((1.0, 0.0, 0.0), 1000.0 + 0.02)
232
+ check(
233
+ proto.pose_rejection_reason(cur, prev) ==
234
+ "translation step 1.000m exceeds 0.070m",
235
+ "平移跳的 reason 文本与数值格式未变",
236
+ )
237
+
238
+ cur = pose((0.0, 0.0, 0.0), 1000.0)
239
+ check(
240
+ proto.pose_rejection_reason(cur, prev) ==
241
+ "non-monotonic pose timestamp",
242
+ "时间戳回退的 reason 未变",
243
+ )
244
+
245
+ cur = pose((0.0, 0.0, float("nan")), 1000.0 + 0.02)
246
+ check(
247
+ proto.pose_rejection_reason(cur, prev) == "invalid pose components",
248
+ "非法分量的 reason 未变",
249
+ )
250
+
251
+ check(
252
+ proto.pose_rejection_reason(prev, None) is None,
253
+ "无前帧时不设限(会话首帧必须放行)",
254
+ )
255
+
256
+ # 时间戳回退优先于平移跳:先判时间基,再判步长。
257
+ cur = pose((1.0, 0.0, 0.0), 1000.0)
258
+ check(
259
+ proto.pose_rejection_reason(cur, prev) ==
260
+ "non-monotonic pose timestamp",
261
+ "时间戳回退优先于平移跳报出",
262
+ )
263
+
264
+
265
+ def main():
266
+ print("位姿门门槛单测(v1.3.14)")
267
+ print("[1] 常量镜像(native ⇄ Python)")
268
+ test_constants_mirror()
269
+ print("[2] 实测包络")
270
+ test_genuine_steps_accepted()
271
+ test_artifacts_rejected_at_every_interval()
272
+ test_interval_cap_removes_decimation_inflation()
273
+ test_rotation_steps()
274
+ print("[3] reason 分类")
275
+ test_reason_taxonomy()
276
+
277
+ if FAILS:
278
+ print(f"\nFAIL: {len(FAILS)} 条")
279
+ for f in FAILS:
280
+ print(" -", f)
281
+ return 1
282
+ print(
283
+ "\nPASS: 门槛夹在实测包络中间(真实 ≤0.0601m 放行 / 异常 ≥0.1075m 拦下),"
284
+ "限值不再随 20/20/60ms 降采样帧膨胀,native 与 Python 逐字一致")
285
+ return 0
286
+
287
+
288
+ if __name__ == "__main__":
289
+ sys.exit(main())
collector/tools/tests/test_pose_publish_deadband.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """发布位姿死区(最小可报告位移)单测(v1.3.14)。
2
+
3
+ 用户报的「大幅漂移没了,但静止时轨迹线/末端点还有小抖」= 估计器的毫米级游走。
4
+ 实测(69061 条发布位姿 / 20 个会话,见 protocol.POSE_PUBLISH_DEADBAND_M 注释):
5
+ 静止段位置游走半径逐会话 p50 1.06-1.85mm、max 1.39-4.98mm(严格口径:
6
+ 10s 窗净位移<2mm **且**窗内每点距窗中位<5mm)。默认视野 327px/m 下看不出来,放大
7
+ 4-10× 就是 3-10px 的连续抖动。
8
+
9
+ 这里钉住四件事:
10
+ ① 死区是**对上次发布值的累积位移**,不是相邻样本差 —— 静止游走必须被冻住,
11
+ 而真实慢速移动(每帧都不到死区)必须照常推进;这是它与跳变门的分别。
12
+ ② 保持时**发布上次的值**(不是不发布):采样节拍/时间戳不变,轨迹不再加噪。
13
+ ③ 死区不得吃掉真实运动:单帧 ≥3mm 的位移一律照发(最快的手动速度也不受影响)。
14
+ ④ 会话首帧 / 重设原点后(参照为 None)必须放行。
15
+
16
+ 用法:
17
+ venv/bin/python tools/tests/test_pose_publish_deadband.py
18
+ """
19
+ import glob
20
+ import math
21
+ import os
22
+ import random
23
+ import sys
24
+
25
+ ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
26
+ sys.path.insert(0, ROOT)
27
+
28
+ from core.gripper.slam import protocol as proto # noqa: E402
29
+
30
+ EPS = proto.POSE_PUBLISH_DEADBAND_M
31
+ FAILS = []
32
+
33
+
34
+ def check(cond, msg):
35
+ if cond:
36
+ print(f" PASS: {msg}")
37
+ else:
38
+ print(f" FAIL: {msg}")
39
+ FAILS.append(msg)
40
+
41
+
42
+ def publish_stream(positions, epsilon=EPS):
43
+ """按发布点语义跑一遍:返回 (发布值序列, 是否保持的掩码)。"""
44
+ out, held, ref = [], [], None
45
+ for p in positions:
46
+ if proto.pose_below_deadband(p, ref, epsilon):
47
+ out.append(ref)
48
+ held.append(True)
49
+ else:
50
+ ref = p
51
+ out.append(p)
52
+ held.append(False)
53
+ return out, held
54
+
55
+
56
+ # ---------------------------------------------------------------- ① 语义
57
+
58
+ def test_helper_semantics():
59
+ ref = (0.0, 0.0, 0.0)
60
+ check(
61
+ proto.pose_below_deadband((1.0, 2.0, 3.0), None) is False,
62
+ "参照为 None(首帧/重设原点)→ 放行",
63
+ )
64
+ check(
65
+ proto.pose_below_deadband((EPS * 0.5, 0.0, 0.0), ref) is True,
66
+ f"位移 {EPS/2*1000:.1f}mm < 死区 {EPS*1000:.0f}mm → 保持",
67
+ )
68
+ check(
69
+ proto.pose_below_deadband((EPS * 1.001, 0.0, 0.0), ref) is False,
70
+ f"位移稍超死区 → 放行(判据是 <,等于死区的真实位移不被吞)",
71
+ )
72
+ check(
73
+ proto.pose_below_deadband((0.0, 0.0, EPS * 2), ref) is False,
74
+ "沿单轴 2×死区 → 放行(三轴等权)",
75
+ )
76
+ check(
77
+ proto.pose_below_deadband(
78
+ (EPS * 0.6, EPS * 0.6, EPS * 0.6), ref) is False,
79
+ "三轴各 0.6×死区 → 合成 1.04×死区 → 放行(按欧氏距离,不逐轴判)",
80
+ )
81
+ check(
82
+ math.isclose(
83
+ proto.POSE_PUBLISH_DEADBAND_M, 0.003, abs_tol=1e-12),
84
+ f"死区常量 = {proto.POSE_PUBLISH_DEADBAND_M*1000:.1f}mm(实测值 3mm)",
85
+ )
86
+
87
+
88
+ # ---------------------------------------------------------------- ② 静止游走
89
+
90
+ def test_rest_wander_frozen():
91
+ """按实测统计量合成静止游走。
92
+
93
+ 实测静止段是**有界的小球**而不是无界随机游走:每帧步长 p50≈0.14mm、
94
+ p99≈0.45mm,但 10s 窗内的游走半径只有 p50 1.06-1.85mm、max 1.39-4.98mm —— 即
95
+ 高频来回(均值回复),不是越走越远。用 AR(1)(系数 0.96)复现这个形状:
96
+ 步长与半径都对得上,纯随机游走会走到 10mm+,那就不是用户看到的抖了。
97
+ """
98
+ rng = random.Random(20261013)
99
+ a, sigma = 0.96, 0.00010
100
+ pos = [0.0, 0.0, 0.0]
101
+ positions = []
102
+ for _ in range(600): # 20s @30fps
103
+ pos = [
104
+ a * pos[0] + rng.gauss(0.0, sigma),
105
+ a * pos[1] + rng.gauss(0.0, sigma),
106
+ a * pos[2] + rng.gauss(0.0, sigma),
107
+ ]
108
+ positions.append(tuple(pos))
109
+ steps = [math.dist(positions[i], positions[i - 1])
110
+ for i in range(1, len(positions))]
111
+ raw_radius = max(math.dist(p, (0.0, 0.0, 0.0)) for p in positions)
112
+ out, held = publish_stream(positions)
113
+ frozen = sum(1 for i, h in enumerate(held) if h and out[i] == out[max(0, i - 1)])
114
+ distinct = len({o for o in out})
115
+
116
+ check(
117
+ 0.0005 < raw_radius < 0.004,
118
+ f"合成游走半径 {raw_radius*1000:.2f}mm 落在实测量级"
119
+ f"(逐会话 p50 1.06-1.85mm / max 1.39-4.98mm)",
120
+ )
121
+ check(
122
+ sorted(steps)[len(steps) // 2] < EPS,
123
+ f"合成每帧步长 p50 {sorted(steps)[len(steps)//2]*1000:.3f}mm < 死区"
124
+ "(单帧全部在死区之下 —— 这正是跳变门管不着的那类噪声)",
125
+ )
126
+ check(
127
+ sum(held) / len(held) > 0.95,
128
+ f"死区冻住�� {100*sum(held)/len(held):.1f}% 的帧(实测会话 99.8-100%)",
129
+ )
130
+ check(
131
+ distinct < 0.05 * len(positions),
132
+ f"600 帧静止只发出 {distinct} 个不同位置(<5%,原来 600 个各不相同)",
133
+ )
134
+ check(
135
+ frozen == sum(held),
136
+ "保持帧发布的确实是上一次的值(末端点不动,而不是发了别的值)",
137
+ )
138
+
139
+
140
+ def test_slow_motion_still_advances():
141
+ """真实慢速移动:1mm/帧(≈3cm/s @30fps)必须照常前进,只是量化到死区。"""
142
+ positions = [(i * 0.001, 0.0, 0.0) for i in range(300)]
143
+ out, held = publish_stream(positions)
144
+ err = max(abs(out[i][0] - positions[i][0]) for i in range(len(positions)))
145
+ check(
146
+ abs(out[-1][0] - positions[-1][0]) < EPS,
147
+ f"300 帧后末端仍在真值 {EPS*1000:.0f}mm 内"
148
+ f"(走了 {out[-1][0]*1000:.1f}mm,真值 {positions[-1][0]*1000:.0f}mm)",
149
+ )
150
+ check(
151
+ err < EPS,
152
+ f"全程误差 {err*1000:.2f}mm < 死区(量化不累积)",
153
+ )
154
+ check(
155
+ sum(held) < 0.9 * len(held),
156
+ f"移动时确实在发新值(保持率 {100*sum(held)/len(held):.0f}%,"
157
+ "不是把运动也冻住)",
158
+ )
159
+ # 单调前进且在推:每 3 帧至少推进一次
160
+ advances = sum(
161
+ 1 for i in range(1, len(out)) if out[i][0] > out[i - 1][0])
162
+ check(
163
+ advances >= 90,
164
+ f"单调推进 {advances} 次(300 帧里按死区每 3 帧推一次是下限)",
165
+ )
166
+
167
+
168
+ # ---------------------------------------------------------------- ③ 不吃运动
169
+
170
+ def test_real_motion_never_blocked():
171
+ """单帧位移 ≥ 死区一律照发 —— 最大实测真实单帧步长 0.0601m 也不例外。"""
172
+ for step in (EPS, 0.01, 0.0601, 0.1):
173
+ held = proto.pose_below_deadband((step, 0.0, 0.0), (0.0, 0.0, 0.0))
174
+ check(
175
+ held is False,
176
+ f"单帧 {step*1000:.1f}mm 位移照发(死区只压 <{EPS*1000:.0f}mm 的噪声)",
177
+ )
178
+ # 静止 10s 后突然快移:第一帧就该发出去,不因"冻久了"而迟滞
179
+ positions = [(0.0, 0.0, 0.0)] * 300 + [(0.05, 0.0, 0.0)]
180
+ out, held = publish_stream(positions)
181
+ check(
182
+ held[-1] is False and out[-1][0] == 0.05,
183
+ "静止后突然移动 50mm:第一帧立即发布(无迟滞)",
184
+ )
185
+
186
+
187
+ # ---------------------------------------------------------------- ④ 真日志回放
188
+
189
+ def test_replay_real_session():
190
+ """拿真会话回放:静止窗冻结率、全段误差、运动不丢。日志不在就跳过。"""
191
+ cands = sorted(glob.glob(os.path.join(
192
+ ROOT, "logs/slam_native/*_slam_stdout.log")))
193
+ picked = [p for p in cands
194
+ if "20261013_114841" in p] or cands[-1:]
195
+ if not picked:
196
+ print(" SKIP: 没有 logs/slam_native/*.log(本机数据不入库)")
197
+ return
198
+ path = picked[0]
199
+ positions = []
200
+ with open(path, encoding="utf-8", errors="replace") as fh:
201
+ for line in fh:
202
+ event = proto.parse_slam_line(line)
203
+ if event is not None and event.kind == "pose":
204
+ positions.append(tuple(event.payload.position))
205
+ if len(positions) < 300:
206
+ print(f" SKIP: {os.path.basename(path)} 只有 {len(positions)} 条位姿")
207
+ return
208
+
209
+ # 静止窗用**净位移**独立判定(与死区的判据无关,避免自证)
210
+ W = 300
211
+ rest = [False] * len(positions)
212
+ for i in range(0, len(positions) - W, 30):
213
+ if math.dist(positions[i + W - 1], positions[i]) < 0.002:
214
+ for k in range(i, i + W):
215
+ rest[k] = True
216
+ out, held = publish_stream(positions)
217
+ held = list(held)
218
+ rest_hold = (sum(1 for i in range(len(held)) if rest[i] and held[i])
219
+ / max(1, sum(rest)))
220
+ err = max(math.dist(out[i], positions[i]) for i in range(len(positions)))
221
+ raw_path = sum(math.dist(positions[i], positions[i - 1])
222
+ for i in range(1, len(positions)))
223
+ pub_path = sum(math.dist(out[i], out[i - 1])
224
+ for i in range(1, len(out)))
225
+
226
+ print(f" (回放 {os.path.basename(path)}:{len(positions)} 条位姿,"
227
+ f"静止帧占 {100*sum(rest)/len(rest):.0f}%)")
228
+ check(
229
+ rest_hold > 0.95,
230
+ f"真会话静止窗冻结率 {100*rest_hold:.1f}% > 95%(下游末端点不再抖)",
231
+ )
232
+ check(
233
+ err < EPS + 1e-9,
234
+ f"全段发布误差 max {err*1000:.2f}mm < {EPS*1000:.0f}mm(量化不超死区)",
235
+ )
236
+ check(
237
+ pub_path < raw_path,
238
+ f"发布路径长 {pub_path:.3f}m < 原始 {raw_path:.3f}m"
239
+ f"(掉掉的 {100*(1-pub_path/raw_path):.0f}% 全是静止游走)",
240
+ )
241
+ check(
242
+ pub_path > 0.5 * raw_path,
243
+ f"发布路径长仍有原始的 {100*pub_path/raw_path:.0f}%(没把运动也削掉)",
244
+ )
245
+
246
+
247
+ def main():
248
+ print("发布位姿死区单测(v1.3.14)")
249
+ print("[1] 语义(对上次发布值的累积位移 / None 放行 / 边界)")
250
+ test_helper_semantics()
251
+ print("[2] 静止游走必须冻住")
252
+ test_rest_wander_frozen()
253
+ print("[3] 真实慢速移动必须照常推进(量化不累积)")
254
+ test_slow_motion_still_advances()
255
+ print("[4] 不吃真实运动(无迟滞)")
256
+ test_real_motion_never_blocked()
257
+ print("[5] 真会话回放")
258
+ test_replay_real_session()
259
+
260
+ if FAILS:
261
+ print(f"\nFAIL: {len(FAILS)} 条")
262
+ for f in FAILS:
263
+ print(" -", f)
264
+ return 1
265
+ print(
266
+ f"\nPASS: 死区 {EPS*1000:.0f}mm 冻住静止游走(>95% 帧保持且保持的是上次的值),"
267
+ "真实移动照常推进且误差不超死区、无迟滞")
268
+ return 0
269
+
270
+
271
+ if __name__ == "__main__":
272
+ sys.exit(main())
collector/tools/tests/test_task_delete.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """任务页「🗑 删除」= 真删除 测试(离屏 Qt,全部落在临时目录)。
2
+
3
+ 2026-10-12:任务删除从「只往 tasks.json 写 hidden(目录原样留着)」改成
4
+ 真删除——删掉该任务在本机的录制目录(含全部 episode 文件组)与库里的录制行,
5
+ tasks.json 只留 hidden 墓碑(防后端刷新复活)。删不干净就如实报错且**不隐藏**
6
+ 任务(用户能重试)。
7
+
8
+ 用法:
9
+ QT_QPA_PLATFORM=offscreen venv/bin/python tools/tests/test_task_delete.py
10
+ """
11
+ import json
12
+ import os
13
+ import shutil
14
+ import sys
15
+ import tempfile
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
18
+ os.path.abspath(__file__)))))
19
+ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
20
+
21
+ from PyQt5.QtWidgets import QApplication, QMessageBox
22
+
23
+ from config import settings
24
+ from core.database import Database
25
+ from core.egodata_writer import _atomic_write_parquet, _episode_rows_table
26
+ from core.helpers import pooled_data_parquet_path, pooled_episodes_path
27
+ from core.recording_record import RecordingRecord
28
+ import core.task_record as task_record
29
+ import ui.task_page as task_page_mod
30
+ from ui.task_page import TaskSelectionPage as TaskPage
31
+
32
+ FAILS = []
33
+ TASK_NAME = "Del_Test/1"
34
+ TASK_ID = "tid-del-1"
35
+
36
+
37
+ def check(cond, msg):
38
+ print(f" {'PASS' if cond else 'FAIL'}: {msg}")
39
+ if not cond:
40
+ FAILS.append(msg)
41
+
42
+
43
+ class FakeMB:
44
+ """QMessageBox 替身:记录弹窗文本,question 恒答 Yes。"""
45
+
46
+ Yes, No = QMessageBox.Yes, QMessageBox.No
47
+ calls = []
48
+
49
+ @staticmethod
50
+ def question(*a, **k):
51
+ FakeMB.calls.append(("question", a[2] if len(a) > 2 else ""))
52
+ return QMessageBox.Yes
53
+
54
+ @staticmethod
55
+ def warning(*a, **k):
56
+ FakeMB.calls.append(("warning", a[2] if len(a) > 2 else ""))
57
+
58
+ @staticmethod
59
+ def information(*a, **k):
60
+ FakeMB.calls.append(("information", a[2] if len(a) > 2 else ""))
61
+
62
+
63
+ def make_episode(task_dir: str, file_no: int):
64
+ n = file_no + 1
65
+ d = os.path.join(task_dir, "data", "chunk-000")
66
+ os.makedirs(d, exist_ok=True)
67
+ with open(os.path.join(d, f"episode-{file_no:03d}.parquet"), "wb") as f:
68
+ f.write(b"data")
69
+ v = os.path.join(task_dir, "videos", "chunk-000", "d435_rgb")
70
+ os.makedirs(v, exist_ok=True)
71
+ with open(os.path.join(v, f"episode-{file_no:03d}.mp4"), "wb") as f:
72
+ f.write(b"video")
73
+ ep_path = pooled_episodes_path(task_dir, n)
74
+ os.makedirs(os.path.dirname(ep_path), exist_ok=True)
75
+ _atomic_write_parquet(
76
+ _episode_rows_table([{"episode_index": n, "task_name": TASK_NAME}]),
77
+ ep_path)
78
+
79
+
80
+ def seed_db(db, task_dir):
81
+ for i, ep in enumerate((1, 2)):
82
+ db.conn.execute(
83
+ """INSERT OR REPLACE INTO recording
84
+ (id, camera_index, camera_name, file_path, episode_index,
85
+ file_size_mb, duration_sec, resolution_w, resolution_h,
86
+ status, started_at, finished_at)
87
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""",
88
+ (f"r{i}", 0, "cam", task_dir, ep, 1.0, 1.0, 1, 1,
89
+ "completed", "t0", "t1"))
90
+ db.conn.commit()
91
+
92
+
93
+ def write_tasks(tasks_file: str, hidden: bool = False, name: str = TASK_NAME):
94
+ """写单任务 tasks.json(用例 2 要先把用例 1 的墓碑复原)。"""
95
+ task = {"id": TASK_ID, "name": name, "status": "in_progress",
96
+ "total_required": 10, "local_count": 2, "synced_count": 2,
97
+ "backend_count": 0}
98
+ if hidden:
99
+ task["hidden"] = True
100
+ with open(tasks_file, "w", encoding="utf-8") as f:
101
+ json.dump({"tasks": [task], "updated_at": "2026-10-12T00:00:00"}, f)
102
+
103
+
104
+ def setup(root: str):
105
+ """临时 tasks.json / RECORDING_DIR / DB 全部指到 root 下。"""
106
+ data_dir = os.path.join(root, "data")
107
+ rec_dir = os.path.join(root, "recordings")
108
+ os.makedirs(data_dir, exist_ok=True)
109
+ os.makedirs(rec_dir, exist_ok=True)
110
+ tasks_file = os.path.join(data_dir, "tasks.json")
111
+ write_tasks(tasks_file)
112
+ task_record._TASKS_FILE = tasks_file
113
+ settings.RECORDING_DIR = rec_dir
114
+ db = Database(db_path=os.path.join(root, "test.db"))
115
+ db.init_schema()
116
+ import core.recording_repository as rr
117
+ rr.db = db
118
+ return rec_dir, tasks_file, db
119
+
120
+
121
+ def hidden_in_file(tasks_file: str) -> bool:
122
+ with open(tasks_file, encoding="utf-8") as f:
123
+ tasks = json.load(f)["tasks"]
124
+ return any(t.get("id") == TASK_ID and t.get("hidden") for t in tasks)
125
+
126
+
127
+ def main():
128
+ root = tempfile.mkdtemp(prefix="task_delete_")
129
+ app = QApplication(sys.argv)
130
+ real_mb = task_page_mod.QMessageBox
131
+ task_page_mod.QMessageBox = FakeMB
132
+ db = None
133
+ try:
134
+ rec_dir, tasks_file, db = setup(root)
135
+ task_dir = os.path.join(rec_dir, "Del_Test_1")
136
+ make_episode(task_dir, 0)
137
+ make_episode(task_dir, 1)
138
+ seed_db(db, task_dir)
139
+
140
+ print("── 1. 真删除任务:目录 + 录制行 + 墓碑 ──")
141
+ page = TaskPage()
142
+ page._table.setCurrentCell(0, 0)
143
+ page._do_delete_task()
144
+ check(not os.path.isdir(task_dir), "录制目录真的被删除")
145
+ check(not os.path.exists(pooled_data_parquet_path(task_dir, 1)),
146
+ "episode 文件组一并删除")
147
+ check(len(db.conn.execute("SELECT 1 FROM recording").fetchall()) == 0,
148
+ "该任务的录制行真删(不留痕迹行)")
149
+ check(hidden_in_file(tasks_file), "tasks.json 保留 hidden 墓碑")
150
+ check(all(t.get("id") != TASK_ID for t in page._tasks),
151
+ "任务从列表消失")
152
+ check(FakeMB.calls and FakeMB.calls[0][0] == "question",
153
+ "删除前有二次确认")
154
+ qtext = FakeMB.calls[0][1]
155
+ check("Del_Test_1" in qtext and "2 个片段" in qtext,
156
+ f"确认框写明要删的目录与片段数: {qtext!r}")
157
+
158
+ print("── 2. 删不干净:如实报错且不隐藏任务 ──")
159
+ FakeMB.calls.clear()
160
+ write_tasks(tasks_file) # 复原墓碑,让任务重新出现在列表
161
+ task_dir2 = os.path.join(rec_dir, "Del_Test_1")
162
+ make_episode(task_dir2, 0)
163
+ os.chmod(rec_dir, 0o500) # 根目录不可写 → rmtree 失败
164
+ try:
165
+ page2 = TaskPage()
166
+ check(len(page2._tasks) == 1, "任务已复原到列表(用例前提)")
167
+ page2._table.setCurrentCell(0, 0)
168
+ page2._do_delete_task()
169
+ finally:
170
+ os.chmod(rec_dir, 0o755)
171
+ check(os.path.isdir(task_dir2), "删除失败时目录还在(没装作删掉了)")
172
+ check(any(k == "warning" for k, _ in FakeMB.calls),
173
+ "失败有弹窗提示")
174
+ check(not hidden_in_file(tasks_file),
175
+ "失败时任务不隐藏(仍可重试)")
176
+
177
+ print("── 3. 纯空白任务名:拒绝删除,绝不 rmtree 到 RECORDING_DIR ──")
178
+ # _sanitize_tag(" ") 返回空串 → task_dir_of 退化成 RECORDING_DIR 本身,
179
+ # 不拦就会把**所有任务**的录制一起删掉。
180
+ FakeMB.calls.clear()
181
+ write_tasks(tasks_file, name=" ")
182
+ decoy = os.path.join(rec_dir, "Other_Task")
183
+ make_episode(decoy, 0)
184
+ page3 = TaskPage()
185
+ check(len(page3._tasks) == 1, "空白名任务在列表里(用例前提)")
186
+ page3._table.setCurrentCell(0, 0)
187
+ page3._do_delete_task()
188
+ check(os.path.isdir(decoy) and os.path.isdir(rec_dir),
189
+ "录制根目录与别的任务原样都在(没被整根 rmtree)")
190
+ check(any(k == "warning" and "拒绝删除" in str(t)
191
+ for k, t in FakeMB.calls), "拒绝删除有弹窗提示")
192
+ check(not hidden_in_file(tasks_file), "拒绝时任务不隐藏")
193
+ finally:
194
+ task_page_mod.QMessageBox = real_mb
195
+ if db is not None:
196
+ db.close()
197
+ shutil.rmtree(root, ignore_errors=True)
198
+
199
+ print()
200
+ if FAILS:
201
+ print(f"FAIL: {len(FAILS)} 项未通过")
202
+ return 1
203
+ print("PASS: 任务删除真删除测试全部通过")
204
+ return 0
205
+
206
+
207
+ if __name__ == "__main__":
208
+ sys.exit(main())
collector/ui/device_panel.py CHANGED
@@ -71,8 +71,15 @@ class DevicePanel(QWidget):
71
  self._refresh_hint = QLabel(tr("双击设备可重命名"))
72
  self._refresh_hint.setStyleSheet(
73
  f"color:{settings.COLOR_TEXT_HINT}; font-size:10px;")
 
 
 
 
74
  layout.addWidget(self._refresh_hint)
75
 
 
 
 
76
  self._items: dict = {} # device key → QTreeWidgetItem
77
  self._last_check: dict = {} # device key → 上次勾选状态(挡文字类变化)
78
  self._group_expanded = {"camera": True, "glove": True,
 
71
  self._refresh_hint = QLabel(tr("双击设备可重命名"))
72
  self._refresh_hint.setStyleSheet(
73
  f"color:{settings.COLOR_TEXT_HINT}; font-size:10px;")
74
+ # 必须换行:不换行的 QLabel 其 minimumSizeHint 就是整行文字宽
75
+ # (中文 + 夹爪在场时这句长达 289px),会成为面板/停靠栏的硬地板,
76
+ # 栏宽只能越拖越宽、拖不小(2026-10-13)。
77
+ self._refresh_hint.setWordWrap(True)
78
  layout.addWidget(self._refresh_hint)
79
 
80
+ # 允许被拖到很窄(一行图标 + 勾选框);实际下限口径见 settings
81
+ self.setMinimumWidth(settings.DEVICE_DOCK_MIN_WIDTH)
82
+
83
  self._items: dict = {} # device key → QTreeWidgetItem
84
  self._last_check: dict = {} # device key → 上次勾选状态(挡文字类变化)
85
  self._group_expanded = {"camera": True, "glove": True,
collector/ui/lite_window.py CHANGED
@@ -22,8 +22,6 @@ tools/tests/lite_import_guard_test.py 断言守护。
22
  from __future__ import annotations
23
  import glob
24
  import os
25
- import re
26
- import shutil
27
  import threading
28
  import time
29
  from typing import List, Optional
@@ -56,7 +54,8 @@ from core.encoder_probe import list_working_ffmpegs
56
  from core.gripper_codec import (describe_gripper_matrix_spec,
57
  encode_gripper_force_matrix,
58
  encode_gripper_force_matrix_array)
59
- from core.helpers import delete_pooled_episode, format_duration
 
60
  from core.pipeline import CameraPipeline, frame_drop_total
61
  from core.uploader import UploadManager
62
 
@@ -1485,14 +1484,25 @@ class LiteWindow(QMainWindow):
1485
  settings.UPLOAD_DELETE_AFTER = bool(on)
1486
 
1487
  def _episode_max(self, task_dir: str) -> int:
1488
- """任务目录内已录制的最大 episode 号(0 = 无 parquet,按整目录上传)。"""
1489
- best = 0
1490
- for p in glob.glob(os.path.join(task_dir, "data", "chunk-*",
1491
- "episode-*.parquet")):
1492
- m = re.search(r"episode-(\d+)\.parquet$", os.path.basename(p))
1493
- if m:
1494
- best = max(best, int(m.group(1)))
1495
- return best
 
 
 
 
 
 
 
 
 
 
 
1496
 
1497
  def _refresh_upload_list(self):
1498
  """待上传列表 = data/recordings 下的任务目录 + 上传状态。"""
@@ -1559,11 +1569,26 @@ class LiteWindow(QMainWindow):
1559
  self._upload_progress.setValue(0)
1560
 
1561
  def _delete_after_upload(self, session_path: str, episode_index: int):
1562
- """后台删除本地会话(ep>0 只删该 episode 文件组,否则整目录)。"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1563
  if episode_index > 0:
1564
- delete_pooled_episode(session_path, episode_index)
1565
- elif os.path.isdir(session_path):
1566
- shutil.rmtree(session_path, ignore_errors=True)
1567
 
1568
  def _on_upload_failed(self, task_id: str, error: str):
1569
  pair = self._upload_task_map.pop(task_id, ("", 0))
 
22
  from __future__ import annotations
23
  import glob
24
  import os
 
 
25
  import threading
26
  import time
27
  from typing import List, Optional
 
54
  from core.gripper_codec import (describe_gripper_matrix_spec,
55
  encode_gripper_force_matrix,
56
  encode_gripper_force_matrix_array)
57
+ from core.helpers import (delete_local_session, episode_file_suffix,
58
+ format_duration, list_task_episodes)
59
  from core.pipeline import CameraPipeline, frame_drop_total
60
  from core.uploader import UploadManager
61
 
 
1484
  settings.UPLOAD_DELETE_AFTER = bool(on)
1485
 
1486
  def _episode_max(self, task_dir: str) -> int:
1487
+ """任务目录内最新一段的**全局 episode 号 N(1 基)**(0 = 无 parquet,
1488
+ 按整目录上传)。
1489
+
1490
+ 文件名是 `episode-<f>.parquet`,f 是 **chunk 内**的 0 基文件号:每
1491
+ chunk 1000 段(`data/chunk-{c:03d}/`),chunk-000 装满 1000 段后开
1492
+ chunk-001 并从 `episode-000` 重新计数(`episode_chunk_file(N)` 给出
1493
+ (c, f))。所以「最大的文件名 f」既不是 N、也不能靠 +1 换算:**f 每过
1494
+ 1000 就回绕**,`int(f)+1` 在 chunk-001 出现后会返回一个**真实存在**的
1495
+ 错段(例:N=1001 时 f=000 ⇒ 得 1,指向 chunk-000/episode-000)。
1496
+ 全局号统一由 `list_task_episodes` 还原(`chunk*1000 + f + 1`)——它与
1497
+ 上传器、`UploadManager` 的 (path, episode_index) 防重键、
1498
+ `delete_local_session` 同一口径,也与本文件自动上传路径传的
1499
+ `pipeline.last_episode_index` 一致。
1500
+ """
1501
+ if not glob.glob(os.path.join(task_dir, "data", "chunk-*",
1502
+ "episode-*.parquet")):
1503
+ return 0 # 旧格式/空目录:按整目录上传
1504
+ episodes = list_task_episodes(task_dir)
1505
+ return episodes[-1] if episodes else 0
1506
 
1507
  def _refresh_upload_list(self):
1508
  """待上传列表 = data/recordings 下的任务目录 + 上传状态。"""
 
1569
  self._upload_progress.setValue(0)
1570
 
1571
  def _delete_after_upload(self, session_path: str, episode_index: int):
1572
+ """后台真删除本地上传件(含删除校验);失败写日志,不谎报成功。
1573
+
1574
+ episode_index 就是上传时用的那个号(手动列表是 `_episode_max` 的
1575
+ 返回值、自动上传是 `pipeline.last_episode_index`,两者都是**全局
1576
+ episode 号 N(1 基)**),原样透传给 delete_local_session —— 删的必须
1577
+ 恰好是刚上传的那一段,所以这里**不能再加工**(上传器按 N 取文件,
1578
+ 多 +1 会指向 N+1:既删错段,又因为那一段本就不存在而「顺利返回
1579
+ True」,界面报成功、文件还在)。目录里没有池化 parquet 时(旧格式/
1580
+ 空目录)按整目录删。
1581
+ """
1582
+ has_parquet = bool(glob.glob(os.path.join(
1583
+ session_path, "data", "chunk-*", "episode-*.parquet")))
1584
+ n = episode_index if has_parquet else 0
1585
+ ok, err = delete_local_session(session_path, n)
1586
+ if ok:
1587
+ return
1588
+ name = os.path.basename(session_path)
1589
  if episode_index > 0:
1590
+ name = f"{name}_ep{episode_file_suffix(episode_index):06d}"
1591
+ self._log(tr("[错误] 上传后自动删除失败: {}", f"{name} ({err})"))
 
1592
 
1593
  def _on_upload_failed(self, task_id: str, error: str):
1594
  pair = self._upload_task_map.pop(task_id, ("", 0))
collector/ui/main_window.py CHANGED
@@ -16,7 +16,7 @@ from datetime import datetime
16
  import cv2
17
  import numpy as np
18
 
19
- from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, QSettings
20
  from PyQt5.QtGui import QFont, QColor, QPixmap
21
  from PyQt5.QtWidgets import (
22
  QMainWindow, QWidget, QVBoxLayout, QDockWidget, QTextEdit,
@@ -58,7 +58,6 @@ _RESUME_SKIP_TEXT = {
58
 
59
  # 双目相机 —— 子进程/管道/曝光/抽帧口径已入 core/s80m_manager;
60
  # 路径常量 re-export 供本窗口可用性检查与离线测试 patch
61
- import shutil
62
  from core.s80m_manager import (
63
  S80MDeviceManager, frame_record_decision, s80m_depth_available,
64
  s80m_drop_watch, STEREO_DROP_ALERT_RATE,
@@ -201,6 +200,14 @@ class MainWindow(QMainWindow):
201
  self._last_device_keys: dict = {} # key → 标签(插拔 diff 日志)
202
  self._last_device_kinds: dict = {} # key → kind(蓝牙刷屏过滤)
203
 
 
 
 
 
 
 
 
 
204
  # ── 实时 FPS 显示(D435/S80M 无 CameraWorker 测量,主窗口统一计数)──
205
  self._fps_ring: dict = {} # slot_id → deque(帧到达时刻, monotonic 秒)
206
  self._fps_timer = QTimer(self)
@@ -379,11 +386,20 @@ class MainWindow(QMainWindow):
379
  self._on_gripper_diagnostics_done)
380
  self._device_dock = QDockWidget(tr("📷 设备检测"), self)
381
  self._device_dock.setWidget(self._device_panel)
 
 
382
  self._device_dock.setFeatures(
383
  QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
 
384
  )
385
  self.addDockWidget(Qt.LeftDockWidgetArea, self._device_dock)
386
- self.resizeDocks([self._device_dock], [240], Qt.Horizontal)
 
 
 
 
 
 
387
 
388
  # ── 状态栏 ────────────────────────────────────
389
  self._status_label = QLabel(tr("就绪"))
@@ -416,6 +432,19 @@ class MainWindow(QMainWindow):
416
  self._view_menu.addSeparator()
417
  self._reset_size_action = self._view_menu.addAction(
418
  tr("重置画面大小"), self._reset_camera_sizes)
 
 
 
 
 
 
 
 
 
 
 
 
 
419
 
420
  # ── 语言菜单 ───────────────────────────────────
421
  self._lang_menu = mb.addMenu(tr("语言(&L)"))
@@ -767,7 +796,6 @@ class MainWindow(QMainWindow):
767
  self._log_dock.setVisible(False)
768
  self._history_dock.setVisible(False)
769
  # 设备检测面板:任务选择页隐藏 + 停轮询
770
- self._device_dock.setVisible(False)
771
  self._device_timer.stop()
772
  else:
773
  # 数据采集页面 — 显示全部 UI
@@ -777,9 +805,11 @@ class MainWindow(QMainWindow):
777
  self._log_dock.setVisible(True)
778
  self._history_dock.setVisible(True)
779
  # 设备检测面板:采集页显示 + 立即扫描 + 周期轮询
780
- self._device_dock.setVisible(True)
781
  self._device_scanner.request_scan()
782
  self._device_timer.start()
 
 
 
783
 
784
  def _go_back_to_task_selection(self):
785
  """从数据采集页面返回任务选择页面。"""
@@ -2973,6 +3003,9 @@ class MainWindow(QMainWindow):
2973
  self._clear_log_action.setText(tr("清空日志"))
2974
  self._refresh_history_action.setText(tr("刷新历史记录"))
2975
  self._reset_size_action.setText(tr("重置画面大小"))
 
 
 
2976
 
2977
  # ── 返回任务选择 ──────────────────────────
2978
  self._back_to_task_action.setText(tr("返回任务选择"))
@@ -3048,6 +3081,74 @@ class MainWindow(QMainWindow):
3048
  """重置所有分割条到等分状态。"""
3049
  self.grid._rebuild_layout()
3050
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3051
  # ═══════════════════════════════════════════════════
3052
  # 状态栏刷新
3053
  # ═══════════════════════════════════════════════════
@@ -3341,40 +3442,36 @@ class MainWindow(QMainWindow):
3341
  """上传成功后删除本地会话目录(后台线程,防大目录阻塞 UI���。
3342
 
3343
  池化布局(episode_index > 0)只删该 episode 文件组
3344
- (delete_pooled_episode → 彻底删除,不走 _trash)。
3345
- 结果经 _upload_session_deleted 信号回主线程:标记 recording 行、
3346
  刷新历史面板并记日志。手部关键点后处理持有的已打开视频句柄在
3347
  Linux 下不受 unlink 影响,输出镜像在 keypoints_output/ 不受牵连。
3348
  """
3349
  def _worker():
3350
- err = ""
3351
- if episode_index > 0:
3352
- from core.helpers import delete_pooled_episode
3353
- delete_pooled_episode(session_path, episode_index)
3354
- elif not os.path.isdir(session_path):
3355
- err = "会话目录不存在"
3356
- else:
3357
- try:
3358
- shutil.rmtree(session_path)
3359
- except OSError as e:
3360
- err = str(e)
3361
- self._upload_session_deleted.emit(session_path, err,
3362
  episode_index)
3363
  threading.Thread(target=_worker, daemon=True,
3364
  name="upload-auto-delete").start()
3365
 
3366
  def _on_upload_session_deleted(self, session_path: str, err: str,
3367
  episode_index: int = 0):
3368
- """后台删除完成(主线程):recording 行标记已删并刷新历史。"""
3369
  name = os.path.basename(session_path)
3370
  if episode_index > 0:
3371
  name = f"{name}_ep{episode_file_suffix(episode_index):06d}"
3372
  if err:
3373
  self._log(tr("[错误] 上传后自动删除失败: {}", f"{name} ({err})"))
3374
  return
3375
- # 历史行保留(用户要求),状态标记"已上传,本地已删"
3376
- RecordingRepo.mark_uploaded_deleted(
3377
- session_path, episode_index=episode_index)
 
 
 
 
3378
  self._refresh_history()
3379
  self._log(tr("☁ 上传完成,本地文件已删除: {}", name))
3380
 
@@ -3512,6 +3609,8 @@ class MainWindow(QMainWindow):
3512
  return
3513
  self._shutting_down = True
3514
  self._s80m_manager.shutting_down = True
 
 
3515
  self._device_timer.stop()
3516
  self._device_scanner.stop()
3517
  if self._pipeline.is_recording:
 
16
  import cv2
17
  import numpy as np
18
 
19
+ from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, QSettings, QEvent
20
  from PyQt5.QtGui import QFont, QColor, QPixmap
21
  from PyQt5.QtWidgets import (
22
  QMainWindow, QWidget, QVBoxLayout, QDockWidget, QTextEdit,
 
58
 
59
  # 双目相机 —— 子进程/管道/曝光/抽帧口径已入 core/s80m_manager;
60
  # 路径常量 re-export 供本窗口可用性检查与离线测试 patch
 
61
  from core.s80m_manager import (
62
  S80MDeviceManager, frame_record_decision, s80m_depth_available,
63
  s80m_drop_watch, STEREO_DROP_ALERT_RATE,
 
200
  self._last_device_keys: dict = {} # key → 标签(插拔 diff 日志)
201
  self._last_device_kinds: dict = {} # key → kind(蓝牙刷屏过滤)
202
 
203
+ # ── 设备检测栏(左侧停靠面板)显示偏好 ────────
204
+ # user_hidden:用户主动收起(标题栏 × / 视图▸设备检测栏);任务选择页
205
+ # 隐藏是页面行为,不算收起。width:栏宽,由 eventFilter 跟随实际 resize。
206
+ # 二者随 closeEvent / 收起动作持久化到 server_config.json。
207
+ self._device_dock_user_hidden = False
208
+ self._device_dock_width = settings.DEVICE_DOCK_DEFAULT_WIDTH
209
+ self._device_dock_syncing = False # 程序化改可见性时置位(挡误判为用户收起)
210
+
211
  # ── 实时 FPS 显示(D435/S80M 无 CameraWorker 测量,主窗口统一计数)──
212
  self._fps_ring: dict = {} # slot_id → deque(帧到达时刻, monotonic 秒)
213
  self._fps_timer = QTimer(self)
 
386
  self._on_gripper_diagnostics_done)
387
  self._device_dock = QDockWidget(tr("📷 设备检测"), self)
388
  self._device_dock.setWidget(self._device_panel)
389
+ # Closable = 标题栏出 × 按钮,点它就是「隐藏设备栏」;恢复入口是
390
+ # 视图▸设备检测栏 / Ctrl+D(见 _on_device_dock_visibility)。
391
  self._device_dock.setFeatures(
392
  QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable
393
+ | QDockWidget.DockWidgetClosable
394
  )
395
  self.addDockWidget(Qt.LeftDockWidgetArea, self._device_dock)
396
+ # 栏宽与收起状态沿用上次(server_config.json),首次启动用默认宽度
397
+ prefs = settings.load_device_dock_prefs()
398
+ self._device_dock_width = prefs["width"]
399
+ self._device_dock_user_hidden = not prefs["visible"]
400
+ self._device_dock.installEventFilter(self) # 跟随实际宽度(拖窄后落盘)
401
+ self.resizeDocks([self._device_dock], [self._device_dock_width],
402
+ Qt.Horizontal)
403
 
404
  # ── 状态栏 ────────────────────────────────────
405
  self._status_label = QLabel(tr("就绪"))
 
432
  self._view_menu.addSeparator()
433
  self._reset_size_action = self._view_menu.addAction(
434
  tr("重置画面大小"), self._reset_camera_sizes)
435
+ # 设备检测栏(左侧停靠面板)显示开关:标题栏的 × 也能收起,收起后
436
+ # 只有这里 / Ctrl+D 能恢复,所以勾选状态要与实际可见性同步。
437
+ self._device_dock_action = self._view_menu.addAction(
438
+ tr("设备检测栏"))
439
+ self._device_dock_action.setCheckable(True)
440
+ self._device_dock_action.setChecked(not self._device_dock_user_hidden)
441
+ self._device_dock_action.setShortcut("Ctrl+D")
442
+ self._device_dock_action.setToolTip(tr(
443
+ "显示/收起左侧设备检测栏(也可点它标题栏的 × 收起)"))
444
+ self._device_dock_action.triggered.connect(self._toggle_device_dock)
445
+ # 接线放在动作建好之后:× 收起时要同步勾选状态
446
+ self._device_dock.visibilityChanged.connect(
447
+ self._on_device_dock_visibility)
448
 
449
  # ── 语言菜单 ───────────────────────────────────
450
  self._lang_menu = mb.addMenu(tr("语言(&L)"))
 
796
  self._log_dock.setVisible(False)
797
  self._history_dock.setVisible(False)
798
  # 设备检测面板:任务选择页隐藏 + 停轮询
 
799
  self._device_timer.stop()
800
  else:
801
  # 数据采集页面 — 显示全部 UI
 
805
  self._log_dock.setVisible(True)
806
  self._history_dock.setVisible(True)
807
  # 设备检测面板:采集页显示 + 立即扫描 + 周期轮询
 
808
  self._device_scanner.request_scan()
809
  self._device_timer.start()
810
+ # 设备检测面板可见性:采集页显示、任务页隐藏,但用户主动收起过就保持
811
+ # 收起(否则每次切页都把用户收起来的栏弹回来)
812
+ self._apply_device_dock_visibility()
813
 
814
  def _go_back_to_task_selection(self):
815
  """从数据采集页面返回任务选择页面。"""
 
3003
  self._clear_log_action.setText(tr("清空日志"))
3004
  self._refresh_history_action.setText(tr("刷新历史记录"))
3005
  self._reset_size_action.setText(tr("重置画面大小"))
3006
+ self._device_dock_action.setText(tr("设备检测栏"))
3007
+ self._device_dock_action.setToolTip(tr(
3008
+ "显示/收起左侧设备检测栏(也可点它标题栏的 × 收起)"))
3009
 
3010
  # ── 返回任务选择 ──────────────────────────
3011
  self._back_to_task_action.setText(tr("返回任务选择"))
 
3081
  """重置所有分割条到等分状态。"""
3082
  self.grid._rebuild_layout()
3083
 
3084
+ # ── 设备检测栏:显示 / 收起 / 宽度落盘 ───────────────
3085
+
3086
+ def eventFilter(self, obj, event):
3087
+ """跟随设备栏的实际宽度(拖动分割条 → 记住,收起/退出时落盘)。"""
3088
+ if obj is self._device_dock and event.type() == QEvent.Resize:
3089
+ w = self._device_dock.width()
3090
+ if w > 0:
3091
+ self._device_dock_width = w
3092
+ return super().eventFilter(obj, event)
3093
+
3094
+ def _apply_device_dock_visibility(self):
3095
+ """按「当前页 + 用户是否收起」刷新设备栏可见性。
3096
+
3097
+ 页面切换、菜单与 × 收起都走这一个入口,避免两处口径打架。
3098
+ """
3099
+ show = (self._page_stack.currentIndex() == 1
3100
+ and not self._device_dock_user_hidden)
3101
+ self._device_dock_syncing = True # 程序化改动 → 不当作用户收起
3102
+ try:
3103
+ self._device_dock.setVisible(show)
3104
+ if show:
3105
+ # 隐藏期间改过窗口大小 / 首次显示时,Qt 给的宽度未必是保存值
3106
+ self.resizeDocks([self._device_dock], [self._device_dock_width],
3107
+ Qt.Horizontal)
3108
+ finally:
3109
+ self._device_dock_syncing = False
3110
+ self._sync_device_dock_action()
3111
+
3112
+ def _toggle_device_dock(self, checked: bool):
3113
+ """视图▸设备检测栏 / Ctrl+D:显示或收起设备栏(收起状态持久化)。
3114
+
3115
+ 任务选择页收起只记偏好,回采集页才真正生效;反之在任务页点「显示」
3116
+ 也不会把设备栏拉到任务页上(那页本来就不显示采集专属 UI)。
3117
+ """
3118
+ self._device_dock_user_hidden = not checked
3119
+ self._apply_device_dock_visibility()
3120
+ self._save_device_dock_prefs()
3121
+
3122
+ def _on_device_dock_visibility(self, visible: bool):
3123
+ """设备栏可见性变化(点标题栏 × 收起)→ 同步菜单勾选 + 记偏好。
3124
+
3125
+ 页面切换与程序化改动期间 _device_dock_syncing=True,任务选择页的
3126
+ 隐藏也只是页面行为 —— 两者都不代表用户收起栏,直接返回。
3127
+ """
3128
+ if self._device_dock_syncing or self._page_stack.currentIndex() != 1:
3129
+ return
3130
+ self._device_dock_user_hidden = not visible
3131
+ self._sync_device_dock_action()
3132
+ self._save_device_dock_prefs()
3133
+
3134
+ def _sync_device_dock_action(self):
3135
+ """菜单勾选 = 用户是否想看到设备栏(与当前页是否显示无关)。"""
3136
+ action = getattr(self, "_device_dock_action", None)
3137
+ if action is None: # _setup_menu 之前的调用(构建期)
3138
+ return
3139
+ action.blockSignals(True)
3140
+ action.setChecked(not self._device_dock_user_hidden)
3141
+ action.blockSignals(False)
3142
+
3143
+ def _save_device_dock_prefs(self):
3144
+ """把设备栏的收起状态与宽度写回 server_config.json。
3145
+
3146
+ 宽度取 `_device_dock_width`(由 eventFilter 跟随每次 Resize),不用
3147
+ `self._device_dock.width()` —— 收起瞬间读到的是收起前的旧几何。
3148
+ """
3149
+ settings.save_device_dock_prefs(not self._device_dock_user_hidden,
3150
+ self._device_dock_width)
3151
+
3152
  # ═══════════════════════════════════════════════════
3153
  # 状态栏刷新
3154
  # ═══════════════════════════════════════════════════
 
3442
  """上传成功后删除本地会话目录(后台线程,防大目录阻塞 UI���。
3443
 
3444
  池化布局(episode_index > 0)只删该 episode 文件组
3445
+ (delete_local_session → 彻底删除 + 删除校验,不走 _trash)。
3446
+ 结果经 _upload_session_deleted 信号回主线程:真删 recording 行、
3447
  刷新历史面板并记日志。手部关键点后处理持有的已打开视频句柄在
3448
  Linux 下不受 unlink 影响,输出镜像在 keypoints_output/ 不受牵连。
3449
  """
3450
  def _worker():
3451
+ from core.helpers import delete_local_session
3452
+ ok, err = delete_local_session(session_path, episode_index)
3453
+ self._upload_session_deleted.emit(session_path,
3454
+ "" if ok else err,
 
 
 
 
 
 
 
 
3455
  episode_index)
3456
  threading.Thread(target=_worker, daemon=True,
3457
  name="upload-auto-delete").start()
3458
 
3459
  def _on_upload_session_deleted(self, session_path: str, err: str,
3460
  episode_index: int = 0):
3461
+ """后台删除完成(主线程):真删 recording 行并刷新历史。"""
3462
  name = os.path.basename(session_path)
3463
  if episode_index > 0:
3464
  name = f"{name}_ep{episode_file_suffix(episode_index):06d}"
3465
  if err:
3466
  self._log(tr("[错误] 上传后自动删除失败: {}", f"{name} ({err})"))
3467
  return
3468
+ # 真删除:本地文件已删净 → 录制行一并删除(不留"已删除"痕迹行)
3469
+ try:
3470
+ RecordingRepo.delete_local_episode(
3471
+ session_path, episode_index=episode_index)
3472
+ except Exception as e: # noqa: BLE001 — 文件已删净,记录删失败只提示
3473
+ self._log(tr("[错误] 本地文件已删除,录制记录删除失败: {}",
3474
+ f"{name} ({e})"))
3475
  self._refresh_history()
3476
  self._log(tr("☁ 上传完成,本地文件已删除: {}", name))
3477
 
 
3609
  return
3610
  self._shutting_down = True
3611
  self._s80m_manager.shutting_down = True
3612
+ # 记住设备栏的宽度与收起状态(下次启动保持)
3613
+ self._save_device_dock_prefs()
3614
  self._device_timer.stop()
3615
  self._device_scanner.stop()
3616
  if self._pipeline.is_recording:
collector/ui/playback_dialog.py CHANGED
@@ -12,7 +12,6 @@
12
  import os
13
  import json
14
  import time
15
- import shutil
16
  import numpy as np
17
  import cv2
18
  from PyQt5.QtCore import Qt, QTimer, QPointF, QRectF
@@ -28,7 +27,7 @@ from config import settings
28
  from config.i18n import tr
29
  from core.helpers import (video_mp4_path, hand_kpts_parquet_path,
30
  egodata_video_path, episode_video_files,
31
- episode_file_suffix, delete_pooled_episode)
32
  from ui.camera_widget import ZoomableVideoWidget
33
  from ui.camera_grid import CameraGrid, SPLITTER_HANDLE_WIDTH, SPLITTER_HANDLE_QSS
34
  from core.render_engine import (
@@ -607,36 +606,37 @@ class PlaybackDialog(QDialog):
607
  return
608
 
609
  from core.recording_repository import RecordingRepo
610
- from core.uploader import UploadManager
611
  deleted = 0
 
612
  for s in selected:
613
  episode_index = s.get("episode_index", 0)
614
- if episode_index > 0:
615
- # 池化布局:彻底删除该 episode 文件组
616
- # (不触碰其他 episode 与任务级 meta)
617
- ok = delete_pooled_episode(s["path"], episode_index)
618
- else:
619
- try:
620
- shutil.rmtree(s["path"])
621
- ok = True
622
- except OSError:
623
- ok = False
624
- if ok:
625
- deleted += 1
626
- # 成功上传过的会话 → 历史标「已上传,本地已删」(与自动上传
627
- # 后删除口径一致);未上传/上传失败 → 标「已删除,未上传」
628
- try:
629
- if UploadManager.get_upload_status(
630
- s["path"], episode_index) == "completed":
631
- RecordingRepo.mark_uploaded_deleted(
632
- s["path"], episode_index=episode_index)
633
- else:
634
- RecordingRepo.mark_deleted(
635
- s["path"], episode_index=episode_index)
636
- except Exception:
637
- pass
638
 
639
  self._refresh_list()
 
 
 
 
 
 
 
640
  QMessageBox.information(
641
  self, tr("完成"),
642
  tr("已永久删除 {}/{} 个会话。", deleted, len(selected)),
 
12
  import os
13
  import json
14
  import time
 
15
  import numpy as np
16
  import cv2
17
  from PyQt5.QtCore import Qt, QTimer, QPointF, QRectF
 
27
  from config.i18n import tr
28
  from core.helpers import (video_mp4_path, hand_kpts_parquet_path,
29
  egodata_video_path, episode_video_files,
30
+ episode_file_suffix, delete_local_session)
31
  from ui.camera_widget import ZoomableVideoWidget
32
  from ui.camera_grid import CameraGrid, SPLITTER_HANDLE_WIDTH, SPLITTER_HANDLE_QSS
33
  from core.render_engine import (
 
606
  return
607
 
608
  from core.recording_repository import RecordingRepo
 
609
  deleted = 0
610
+ failures = []
611
  for s in selected:
612
  episode_index = s.get("episode_index", 0)
613
+ # 真删除:删净(含校验)后再删录制行;失败则原样列出,不谎报成功
614
+ ok, err = delete_local_session(s["path"], episode_index)
615
+ if not ok:
616
+ # episode_index == 0 是旧格式会话目录(整目录删除),别写成
617
+ # episode-000 那段
618
+ target = (f'episode-{episode_file_suffix(episode_index):03d}'
619
+ if episode_index > 0 else tr("整个会话目录"))
620
+ failures.append(
621
+ f' {os.path.basename(s["path"])}/{target}: {err}')
622
+ continue
623
+ deleted += 1
624
+ try:
625
+ RecordingRepo.delete_local_episode(
626
+ s["path"], episode_index=episode_index)
627
+ except Exception as e: # noqa: BLE001 — 文件已删净,记录删失败只提示
628
+ failures.append(
629
+ f' {os.path.basename(s["path"])}: '
630
+ f'{tr("文件已删除,录制记录删除失败")} ({e})')
 
 
 
 
 
 
631
 
632
  self._refresh_list()
633
+ if failures:
634
+ QMessageBox.warning(
635
+ self, tr("部分失败"),
636
+ tr("已删除 {}/{} 个会话,以下未能删净:\n\n{}",
637
+ deleted, len(selected), "\n".join(failures)),
638
+ )
639
+ return
640
  QMessageBox.information(
641
  self, tr("完成"),
642
  tr("已永久删除 {}/{} 个会话。", deleted, len(selected)),
collector/ui/pose_view_qt.py CHANGED
@@ -22,7 +22,8 @@ KSQ_GRIPPER_SLAM_FRAME 开关走),换帧或回落旧约定时逐字变化—
22
  published_frame 姿态那个右乘 Mᵀ),所以原点那一瞬发布姿态 = M·Mᵀ = 单位
23
  矩阵,这三支箭头与世界三箭头**重合**,标签写同样的 下/左/前。夹爪的**物理
24
  朝向**一点没变:_draw_gripper 用同一张 M(_BODY_RELABEL)把模型顶点从旧体轴
25
- 坐标搬到新体轴坐标,position + R·(M·v) == position + R_旧·v,逐像素与改前
 
26
  相同。★只改发布侧不改这里(或反过来)夹爪会被画歪 120°,两种改法各自都
27
  "看起来自洽"——别只动一处。
28
  """
@@ -85,6 +86,10 @@ CAM_AXES = (
85
  #
86
  # ★ 只换名字不动物理朝向:rotation 已经被发布侧右乘了 Mᵀ,这里再左乘一次 M,
87
  # 画出来 position + R·(M·v) == position + R_旧·v —— 逐点与换帧前相同。
 
 
 
 
88
  # 两处必须同进同退:只改发布侧不改这里、或只改这里不改发布侧,夹爪都会歪
89
  # 120°,而且两种改法各自都"看起来自洽",很难从画面上看出是谁漏了。
90
  _BODY_RELABEL = {
@@ -102,7 +107,9 @@ _BODY_RELABEL = {
102
  # 箭头自己变成"右/正对/上",否则界面在说谎。
103
  #
104
  # 旧约定的 +Y 写"正对"而不是"前"——用户自己的叫法就是「X=右 / Y=正对 / Z=上」,
105
- # 照抄。两种 forward 混用同一个字,会让人以为两个帧的 Y 是同一个物理方向。
 
 
106
 
107
  # 标题那一行的顶。画布上两行字(标题、帧)都从这个顶开始往下排,行高取
108
  # **字体实测高度**,不写死像素 —— 写死 16px 时 21px 的中文行会被 drawText
 
22
  published_frame 姿态那个右乘 Mᵀ),所以原点那一瞬发布姿态 = M·Mᵀ = 单位
23
  矩阵,这三支箭头与世界三箭头**重合**,标签写同样的 下/左/前。夹爪的**物理
24
  朝向**一点没变:_draw_gripper 用同一张 M(_BODY_RELABEL)把模型顶点从旧体轴
25
+ 坐标搬到新体轴坐标,position + R·(M·v) == position + R_旧·v(R_旧 = 改前那次
26
+ **发布**的姿态,已含发布侧左乘的 M,不是夹爪的相对姿态 R_rel),逐像素与改前
27
  相同。★只改发布侧不改这里(或反过来)夹爪会被画歪 120°,两种改法各自都
28
  "看起来自洽"——别只动一处。
29
  """
 
86
  #
87
  # ★ 只换名字不动物理朝向:rotation 已经被发布侧右乘了 Mᵀ,这里再左乘一次 M,
88
  # 画出来 position + R·(M·v) == position + R_旧·v —— 逐点与换帧前相同。
89
+ # ⚠ R_旧 指的是**改前那次发布的姿态**(= M·R_rel,里头已经含了发布侧那道左乘
90
+ # M),不是夹爪自己的相对姿态 R_rel。拿 R_rel 去代会差一个 M、算出"夹爪歪了
91
+ # 0.3–0.4m"的假警报 —— 这个坑我自己踩过一次(两处都改时残差只在浮点噪声量级,
92
+ # 只改一半就是 0.3–0.4m 的实偏差)。
93
  # 两处必须同进同退:只改发布侧不改这里、或只改这里不改发布侧,夹爪都会歪
94
  # 120°,而且两种改法各自都"看起来自洽",很难从画面上看出是谁漏了。
95
  _BODY_RELABEL = {
 
107
  # 箭头自己变成"右/正对/上",否则界面在说谎。
108
  #
109
  # 旧约定的 +Y 写"正对"而不是"前"——用户自己的叫法就是「X=右 / Y=正对 / Z=上」,
110
+ # 照抄。**不是**因为两种 forward 指向不同物理方向:M 把中间帧 +Y 映到发布帧 +Z,
111
+ # 旧 +Y「正对」与新 +Z「前」就是同一处。会骗人的是**轴字母**(旧 +Y 与新 +Y 差
112
+ # 90°),跨帧对齐看方向词、别看字母。
113
 
114
  # 标题那一行的顶。画布上两行字(标题、帧)都从这个顶开始往下排,行高取
115
  # **字体实测高度**,不写死像素 —— 写死 16px 时 21px 的中文行会被 drawText
collector/ui/task_page.py CHANGED
@@ -464,27 +464,71 @@ class TaskSelectionPage(QWidget):
464
  self.task_selected.emit(task)
465
 
466
  def _do_delete_task(self):
467
- """Execute task deletion (called by both button and right-click)."""
 
 
 
 
 
 
468
  row = self._table.currentRow()
469
  if row < 0 or row >= len(self._tasks):
470
  return
471
  task = self._tasks[row]
472
  name = task.get("name", task.get("task_name", ""))
473
  tid = _tid(task)
474
- reply = QMessageBox.question(
475
- self, tr("确认"),
476
- tr("确定要删除任务 \"{}\" 吗?\n删除后可在 tasks.json 中恢复。", name),
477
- )
478
- if reply == QMessageBox.Yes:
479
- mark_hidden(tid)
480
- self._tasks = filter_by_identity(load_tasks(), self._identity_scope())
481
- for t in self._tasks:
482
- t["status"] = _compute_status(t)
483
- self._rebuild_table()
484
- self._current_row = -1
485
- self._enter_btn.setEnabled(False)
486
- self._delete_btn.setEnabled(False)
487
- self._selection_summary.setText("")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
488
 
489
  def _on_delete_clicked(self):
490
  self._do_delete_task()
 
464
  self.task_selected.emit(task)
465
 
466
  def _do_delete_task(self):
467
+ """Execute task deletion (called by both button and right-click).
468
+
469
+ 真删除:删掉该任务在本机的全部录制数据(<RECORDING_DIR>/<task_tag>/
470
+ 整个目录)连同库里的录制行;tasks.json 只保留 hidden 墓碑(后端再次
471
+ 推送时据此识别为已删除,否则任务会死而复生)。目录没删净就如实报错,
472
+ 并且不隐藏任务(留给用户重试)。
473
+ """
474
  row = self._table.currentRow()
475
  if row < 0 or row >= len(self._tasks):
476
  return
477
  task = self._tasks[row]
478
  name = task.get("name", task.get("task_name", ""))
479
  tid = _tid(task)
480
+ from core.helpers import (delete_local_session, list_task_episodes,
481
+ task_dir_of)
482
+ task_dir = task_dir_of(settings.RECORDING_DIR, name)
483
+ # 兜底:task_dir_of 走 _sanitize_tag 清洗,纯空白任务名会清洗成空串、
484
+ # 于是 task_dir 退化成 RECORDING_DIR 本身 —— 那样下面的 rmtree 会把
485
+ # **所有任务**的录制一起删掉。要求它确实是 RECORDING_DIR 的直接子目录。
486
+ ground = _os.path.normpath(_os.path.abspath(settings.RECORDING_DIR))
487
+ target = _os.path.normpath(_os.path.abspath(task_dir))
488
+ if _os.path.dirname(target) != ground:
489
+ QMessageBox.warning(
490
+ self, tr("删除失败"),
491
+ tr("任务名 \"{}\" 无法映射到本机录制目录下的子目录,已拒绝删除:\n{}",
492
+ name, task_dir),
493
+ )
494
+ return
495
+ has_dir = _os.path.isdir(task_dir)
496
+ n_eps = len(list_task_episodes(task_dir)) if has_dir else 0
497
+ if has_dir:
498
+ ask = tr("确定要删除任务 \"{}\" 吗?\n\n将永久删除本地目录:\n{}\n"
499
+ "(含 {} 个片段的全部文件与录制记录,不可恢复)",
500
+ name, task_dir, n_eps)
501
+ else:
502
+ ask = tr("确定要删除任务 \"{}\" 吗?\n\n本机没有该任务的录制数据。",
503
+ name)
504
+ reply = QMessageBox.question(self, tr("确认"), ask)
505
+ if reply != QMessageBox.Yes:
506
+ return
507
+ if has_dir:
508
+ ok, err = delete_local_session(task_dir, 0)
509
+ if not ok: # 目录没删净:不隐藏任务,让用户能重试
510
+ QMessageBox.warning(
511
+ self, tr("删除失败"),
512
+ tr("本地数据删除失败,任务未删除:\n{}", f"{task_dir} ({err})"),
513
+ )
514
+ return
515
+ try:
516
+ from core.recording_repository import RecordingRepo
517
+ RecordingRepo.delete_local_dir(task_dir)
518
+ except Exception as e: # noqa: BLE001 — 数据已删净,记录删失败只提示
519
+ QMessageBox.warning(
520
+ self, tr("提示"),
521
+ tr("录制记录删除失败:\n{}", str(e)),
522
+ )
523
+ mark_hidden(tid)
524
+ self._tasks = filter_by_identity(load_tasks(), self._identity_scope())
525
+ for t in self._tasks:
526
+ t["status"] = _compute_status(t)
527
+ self._rebuild_table()
528
+ self._current_row = -1
529
+ self._enter_btn.setEnabled(False)
530
+ self._delete_btn.setEnabled(False)
531
+ self._selection_summary.setText("")
532
 
533
  def _on_delete_clicked(self):
534
  self._do_delete_task()
collector/ui/upload_dialog.py CHANGED
@@ -4,7 +4,6 @@
4
 
5
  from __future__ import annotations
6
  import os
7
- import shutil
8
  import threading
9
  from typing import Optional
10
 
@@ -511,30 +510,29 @@ class UploadDialog(QDialog):
511
 
512
  def _delete_after_upload(self, session_path: str, episode_index: int):
513
  """后台线程删除本地上传件:池化 = 彻底删除该 episode 文件组
514
- (不走 _trash),旧格式会话目录 = 整目录删除。完成后经信号回
515
- 主线程收尾。"""
516
  def _run():
517
- err = ""
518
- try:
519
- if episode_index > 0:
520
- from core.helpers import delete_pooled_episode
521
- delete_pooled_episode(session_path, episode_index)
522
- else:
523
- shutil.rmtree(session_path)
524
- except Exception as e: # noqa: BLE001 — 收尾线程兜底上报
525
- err = str(e)
526
- self._session_deleted.emit(session_path, err, episode_index)
527
  threading.Thread(target=_run, daemon=True).start()
528
 
529
  def _on_session_deleted(self, session_path: str, err: str, episode_index: int):
530
- """删除收尾:失败只提示;成功标记录制记录并刷新列表。"""
531
  name = self._pair_name((session_path, episode_index))
532
  if err:
533
  self._status_label.setText(
534
  tr("[错误] 上传后自动删除失败: {}", f"{name} ({err})"))
535
  return
536
- RecordingRepo.mark_uploaded_deleted(
537
- session_path, episode_index=episode_index)
 
 
 
 
 
538
  self._refresh_list()
539
  self._status_label.setText(
540
  tr("☁ 上传完成,本地文件已删除: {}", name))
 
4
 
5
  from __future__ import annotations
6
  import os
 
7
  import threading
8
  from typing import Optional
9
 
 
510
 
511
  def _delete_after_upload(self, session_path: str, episode_index: int):
512
  """后台线程删除本地上传件:池化 = 彻底删除该 episode 文件组
513
+ (不走 _trash),旧格式会话目录 = 整目录删除;两者都做删除后校验。
514
+ 完成后经信号回主线程收尾。"""
515
  def _run():
516
+ from core.helpers import delete_local_session
517
+ ok, err = delete_local_session(session_path, episode_index)
518
+ self._session_deleted.emit(session_path, "" if ok else err,
519
+ episode_index)
 
 
 
 
 
 
520
  threading.Thread(target=_run, daemon=True).start()
521
 
522
  def _on_session_deleted(self, session_path: str, err: str, episode_index: int):
523
+ """删除收尾:失败只提示;成功真删录制记录并刷新列表。"""
524
  name = self._pair_name((session_path, episode_index))
525
  if err:
526
  self._status_label.setText(
527
  tr("[错误] 上传后自动删除失败: {}", f"{name} ({err})"))
528
  return
529
+ try:
530
+ RecordingRepo.delete_local_episode(
531
+ session_path, episode_index=episode_index)
532
+ except Exception as e: # noqa: BLE001 — 文件已删净,记录删失败只提示
533
+ self._parent_log(
534
+ tr("[错误] 本地文件已删除,录制记录删除失败: {}",
535
+ f"{name} ({e})"))
536
  self._refresh_list()
537
  self._status_label.setText(
538
  tr("☁ 上传完成,本地文件已删除: {}", name))
collector/使用手册.md CHANGED
@@ -291,11 +291,11 @@ Top: app name, server address & current identity, connection dot (● green = co
291
 
292
  | Panel | Position | Content |
293
  |---|---|---|
294
- | 📷 Devices | Left | Detected devices grouped (📷 Cameras / 🧤 Gloves / 🎧 Other Bluetooth), each with a check-toggle — **checking it shows the feed**. The star of Chapter 6 |
295
  | Log | Bottom | Timestamped program log (recording, upload, hot-plug events), keeps up to 2000 lines |
296
  | Recording History | Right | Latest 100 recordings: Camera | File | Duration | Size | Codec | Status |
297
 
298
- All three panels can be **dragged, docked, or floated** (drag the title bar); close and reopen the program to restore the default layout.
299
 
300
  **Status bar** (very bottom):
301
 
@@ -542,6 +542,9 @@ A wireless (Bluetooth) tactile glove.
542
  | Manual scan | Toolbar **🔍 Scan** | Panel auto-scans every 2 s; this forces an immediate rescan |
543
  | Remove all | Toolbar **✕ Remove All** | Confirms "Remove all cameras?" |
544
  | While recording | — | **Whole panel locked**: toggles and rename all disabled — data streams must not change mid-recording |
 
 
 
545
 
546
  <a id="en-ch6-8"></a>
547
 
@@ -1420,11 +1423,11 @@ python scripts/pack_wheels.py --lite # 极简版依赖
1420
 
1421
  | 面板 | 位置 | 内容 |
1422
  |---|---|---|
1423
- | 📷 设备检测 | 左侧 | 已接入设备分组列出(📷 相机 / 🧤 手套 / 🎧 其他蓝牙),每台设备带勾选开关——**勾选即显示画面**。第 6 章的主角 |
1424
  | 日志 | 底部 | 程序运行日志(时间戳 + 事件),录制、上传、设备插拔都有记录,最多保留 2000 行 |
1425
  | 录制历史 | 右侧 | 最近 100 条录制记录:摄像机 | 文件 | 时长 | 大小 | 编码 | 状态,启动自动加载 |
1426
 
1427
- 三个面板都可以**拖动停靠或拉出来悬浮**(拖面板标题栏即可),被拖乱之后关闭重开程序恢复默认。
1428
 
1429
  **状态栏**(窗口最底部):
1430
 
@@ -1561,7 +1564,7 @@ python scripts/pack_wheels.py --lite # 极简版依赖
1561
  | 操作 | 说明 |
1562
  |---|---|
1563
  | 🔄 刷新 | 立即从服务器拉取一次任务(带 1.5 秒防抖,连点只发一次请求;不点也会每 30 秒自动刷新) |
1564
- | 右键 → 删除任务 | 从本地列表移除(打隐藏标记,`data/tasks.json` 里仍可恢复);服务器任务以服务器为准 |
1565
  | 任务来源 | 服务器下发 + 本地 `data/tasks.json` 缓存合并;没有后端时内置 3 个示例任务(cylinder_grasping / cube_placement / valve_rotation)供练手 |
1566
 
1567
  每完成一段录制,任务进度自动 +1 并上报服务器(多台电脑协同采集同一任务时进度共享)。
@@ -1675,6 +1678,9 @@ Intel 的深度相机,**能同时拍彩色画面和深度(距离)画面**
1675
  | 手动扫描 | 工具栏「🔍 扫描」 | 面板平时每 2 秒自动扫一次,此按钮立即重扫 |
1676
  | 全部移除 | 工具栏「✕ 全部移除」 | 弹「移除所有摄像机?」确认后关闭所有画面 |
1677
  | 录制中 | — | **整个面板锁定**:开关、重命名全部禁用(tooltip「录制中不可更改设备」)——防止录制中途数据流变化 |
 
 
 
1678
 
1679
  <a id="cn-ch6-8"></a>
1680
 
@@ -1891,14 +1897,18 @@ RECORD_VIDEO_ENCODER = "auto" # 自动(默认);也可填 "nvenc" | "x265
1891
 
1892
  两个开关的状态与服务器地址一起保存在 `data/server_config.json`,下次启动保持。
1893
 
 
 
 
1894
  <a id="cn-ch8-6"></a>
1895
 
1896
  ## 8.6 删除录制数据
1897
 
1898
  - **入口**:回放窗口的「🗑 删除选中」按钮(先勾选要删的段落);
1899
- - **范围**:删除该段的**全部文件**(视频 + parquet + 元数据行),**只删本地,不动服务器**;
1900
  - **确认**:弹二次确认「确定要永久删除以下会话?…此操作不可逆。」——**没有回收站**,确认后无法恢复;
1901
- - 删除已上传的段会标记「已上传,本地已删」;未上传的段删除前请确认服务器上有备份。
 
1902
 
1903
  ![图 8-5 删除确认弹窗截图](docs/images/fig-8-5.png)
1904
 
 
291
 
292
  | Panel | Position | Content |
293
  |---|---|---|
294
+ | 📷 Devices | Left | Detected devices grouped (📷 Cameras / 🧤 Gloves / 🎧 Other Bluetooth), each with a check-toggle — **checking it shows the feed**. The star of Chapter 6; **resizable and collapsible** (see 6.7) |
295
  | Log | Bottom | Timestamped program log (recording, upload, hot-plug events), keeps up to 2000 lines |
296
  | Recording History | Right | Latest 100 recordings: Camera | File | Duration | Size | Codec | Status |
297
 
298
+ All three panels can be **dragged, docked, or floated** (drag the title bar); close and reopen the program to restore the default layout. The device panel **remembers its width and collapsed state** across restarts; the Log and Recording History panels have no close button (drag or float them instead).
299
 
300
  **Status bar** (very bottom):
301
 
 
542
  | Manual scan | Toolbar **🔍 Scan** | Panel auto-scans every 2 s; this forces an immediate rescan |
543
  | Remove all | Toolbar **✕ Remove All** | Confirms "Remove all cameras?" |
544
  | While recording | — | **Whole panel locked**: toggles and rename all disabled — data streams must not change mid-recording |
545
+ | Resize | Drag the **vertical splitter** between the panel and the feeds | Shrinks down to ~96 px (icons and check-boxes only), any width upwards; width is remembered |
546
+ | Collapse | Click the **×** on the panel title bar, or menu **View ▸ Device Panel** (**Ctrl+D**) | The feed area widens immediately; the state is remembered and survives restarts |
547
+ | Restore | Menu **View ▸ Device Panel** (**Ctrl+D**) | After collapsing this is the **only** way back (the panel is hidden, no other entry point); ticking it on the task-selection page only records the preference — it appears once you are on the collection page |
548
 
549
  <a id="en-ch6-8"></a>
550
 
 
1423
 
1424
  | 面板 | 位置 | 内容 |
1425
  |---|---|---|
1426
+ | 📷 设备检测 | 左侧 | 已接入设备分组列出(📷 相机 / 🧤 手套 / 🎧 其他蓝牙),每台设备带勾选开关——**勾选即显示画面**。第 6 章的主角;**可拖窄/拖宽、可收起**(见 6.7) |
1427
  | 日志 | 底部 | 程序运行日志(时间戳 + 事件),录制、上传、设备插拔都有记录,最多保留 2000 行 |
1428
  | 录制历史 | 右侧 | 最近 100 条录制记录:摄像机 | 文件 | 时长 | 大小 | 编码 | 状态,启动自动加载 |
1429
 
1430
+ 三个面板都可以**拖动停靠或拉出来悬浮**(拖面板标题栏即可),被拖乱之后关闭重开程序恢复默认。设备检测面板的**宽度和收起状态会自动记住**,下次启动保持;日志与录制历史面板没有关闭按钮(只能拖动/悬浮)。
1431
 
1432
  **状态栏**(窗口最底部):
1433
 
 
1564
  | 操作 | 说明 |
1565
  |---|---|
1566
  | 🔄 刷新 | 立即从服务器拉取一次任务(带 1.5 秒防抖,连点只发一次请求;不点也会每 30 秒自动刷新) |
1567
+ | 右键 → 删除任务 / 🗑 删除 | **真删除**:删掉该任务在本机的整个录制目录(`<录制目录>/<任务名>/`,含全部片段的视频与 parquet)与录制历史记录。二次确认会写明要删的目录和片段数,**不可恢复**;删不干净会弹错误提示且任务保留,可重试。`data/tasks.json` 里只留一条隐藏墓碑(服务器再次推送任务时据此识别为已删除,避免任务复活) |
1568
  | 任务来源 | 服务器下发 + 本地 `data/tasks.json` 缓存合并;没有后端时内置 3 个示例任务(cylinder_grasping / cube_placement / valve_rotation)供练手 |
1569
 
1570
  每完成一段录制,任务进度自动 +1 并上报服务器(多台电脑协同采集同一任务时进度共享)。
 
1678
  | 手动扫描 | 工具栏「🔍 扫描」 | 面板平时每 2 秒自动扫一次,此按钮立即重扫 |
1679
  | 全部移除 | 工具栏「✕ 全部移除」 | 弹「移除所有摄像机?」确认后关闭所有画面 |
1680
  | 录制中 | — | **整个面板锁定**:开关、重命名全部禁用(tooltip「录制中不可更改设备」)——防止录制中途数据流变化 |
1681
+ | 调整栏宽 | 拖面板与画面之间那条**竖直分割线** | 最小可拖到约 96px(只剩图标与勾选框),拖宽不限;宽度自动记住 |
1682
+ | 收起面板 | 点面板标题栏右侧的 **×**,或菜单 **视图 ▸ 设备检测栏**(快捷键 **Ctrl+D**) | 收起后画面区立刻变宽;状态同样记住,重启保持收起 |
1683
+ | 恢复面板 | 菜单 **视图 ▸ 设备检测栏**(**Ctrl+D**) | 收起后**只有这里能恢复**(面板已隐藏,没有别的入口);任务选择页勾上只记偏好,回采集页才显示 |
1684
 
1685
  <a id="cn-ch6-8"></a>
1686
 
 
1897
 
1898
  两个开关的状态与服务器地址一起保存在 `data/server_config.json`,下次启动保持。
1899
 
1900
+ 「上传后自动删除」与手动删除同一口径(都是**真删除**:删完校验文件确实不在,
1901
+ 删不掉会写日志报错而不是假装删掉)。
1902
+
1903
  <a id="cn-ch8-6"></a>
1904
 
1905
  ## 8.6 删除录制数据
1906
 
1907
  - **入口**:回放窗口的「🗑 删除选中」按钮(先勾选要删的段落);
1908
+ - **范围**:删除该段的**全部文件**(视频 + parquet + 元数据行)与录制历史里该段的记录,**只删本地,不动服务器**;
1909
  - **确认**:弹二次确认「确定要永久删除以下会话?…此操作不可逆。」——**没有回收站**,确认后无法恢复;
1910
+ - **删除即真删除**:删完会校验文件确实不在,历史记录里该段一并消失;若有文件删不掉(权限、只读挂载等),会列出失败条目并给出原因,**不会报「已删除」**。同样地,任务页「🗑 删除」会删掉该任务的整个录制目录与全部录制记录;
1911
+ - 未上传的段删除前请确认服务器上有备份(已上传的段以服务器为准)。
1912
 
1913
  ![图 8-5 删除确认弹窗截图](docs/images/fig-8-5.png)
1914
 
collector/使用说明_lite.md CHANGED
@@ -59,8 +59,10 @@
59
  - 手套左右手按序列号/广播名 L/R 自动绑定传感器列,与主程序共用绑定记忆。
60
  3. **录制**:填任务名(可选)→「▶ 开始」→「■ 停止」保存(「X 丢弃」作废)。
61
  保存后按「自动上传」开关决定是否自动入队上传。
62
- 4. **上传**:顶部填服务器地址 →「测试连接」→ 下方列表「上传全部未上传」;
63
- 勾「上传后删除」则上传成功后自动删除本地文件。
 
 
64
 
65
  录制数据落在 `data/recordings/`(与主程序同目录结构),在主程序的 ☁ 上传对话框同样可见。
66
 
 
59
  - 手套左右手按序列号/广播名 L/R 自动绑定传感器列,与主程序共用绑定记忆。
60
  3. **录制**:填任务名(可选)→「▶ 开始」→「■ 停止」保存(「X 丢弃」作废)。
61
  保存后按「自动上传」开关决定是否自动入队上传。
62
+ 4. **上传**:顶部填服务器地址 →「测试连接」→ 下方列表「上传全部未上传」
63
+ (列表里的 `ep N` 是该任务最新一段的**全局 episode 号**,与主程序同口径);
64
+ 勾「上传后删除」则上传成功后**真删除刚才上传的那一段**(数据 / 视频 / meta
65
+ 三处一起删净);删不干净会在日志里报错,不会假装删掉。
66
 
67
  录制数据落在 `data/recordings/`(与主程序同目录结构),在主程序的 ☁ 上传对话框同样可见。
68