Spaces:
Running
Running
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。
- collector/README.md +98 -10
- collector/config/__init__.py +61 -4
- collector/config/i18n.py +6 -0
- collector/config/settings.py +44 -4
- collector/core/egodata_writer.py +6 -2
- collector/core/gripper/devices/uvc_camera_service.py +37 -6
- collector/core/gripper/orb_slam_src/ORB-SLAM/Examples/fays/fayssense_orb_slam.cc +16 -3
- collector/core/gripper/slam/process_controller.py +41 -9
- collector/core/gripper/slam/protocol.py +69 -14
- collector/core/helpers.py +70 -3
- collector/core/recording_repository.py +32 -0
- collector/docs/config.md +3 -1
- collector/docs/core.md +4 -0
- collector/docs/data.md +51 -2
- collector/docs/file_format.md +56 -9
- collector/docs/tools.md +8 -1
- collector/docs/ui.md +15 -8
- collector/tools/tests/device_dock_collapse_test.py +217 -0
- collector/tools/tests/lite_delete_test.py +172 -0
- collector/tools/tests/test_device_access_selflock.py +217 -6
- collector/tools/tests/test_gripper_latest_pose_frame.py +5 -2
- collector/tools/tests/test_gripper_pose_axes.py +85 -8
- collector/tools/tests/test_gripper_pose_deadband_publish.py +299 -0
- collector/tools/tests/test_local_delete.py +157 -0
- collector/tools/tests/test_pose_guard_limits.py +289 -0
- collector/tools/tests/test_pose_publish_deadband.py +272 -0
- collector/tools/tests/test_task_delete.py +208 -0
- collector/ui/device_panel.py +7 -0
- collector/ui/lite_window.py +40 -15
- collector/ui/main_window.py +122 -23
- collector/ui/playback_dialog.py +27 -27
- collector/ui/pose_view_qt.py +9 -2
- collector/ui/task_page.py +59 -15
- collector/ui/upload_dialog.py +14 -16
- collector/使用手册.md +17 -7
- collector/使用说明_lite.md +4 -2
collector/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# collector — Multimodal Data Acquisition SDK · 多模态数据采集 SDK
|
| 2 |
|
| 3 |
-

|
| 5 |

|
| 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
|
| 343 |
-
#
|
|
|
|
| 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·
|
| 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
|
| 450 |
-
|
| 451 |
-
|
|
|
|
|
|
|
| 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·
|
| 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 |
-
(
|
| 1336 |
-
|
|
|
|
| 1337 |
- **v1.3.12** — 接新夹爪不再需要开厂商工具包 GUI。新板子连不上时报的那句
|
| 1338 |
`串口连接失败,无法查询绑定的 Fays 序列号: serial open/handshake failed: Write
|
| 1339 |
timeout` 里,最要紧的问题是「板子到底在不在说话」,而主程序原本答不了。夹爪
|
|
|
|
| 1 |
# collector — Multimodal Data Acquisition SDK · 多模态数据采集 SDK
|
| 2 |
|
| 3 |
+

|
| 4 |

|
| 5 |

|
| 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.
|
| 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 |
-
#
|
| 51 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
| 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 写"正对"不是"前":用户自己的叫法就是
|
| 584 |
-
#
|
|
|
|
|
|
|
|
|
|
| 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 |
-
# 指向哪 ——
|
| 1159 |
-
# (+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 |
-
|
| 656 |
-
|
| 657 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
raise UvcCameraServiceError(
|
| 659 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("
|
| 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 |
-
|
| 107 |
-
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 26 |
-
|
|
|
|
| 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·
|
| 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·
|
| 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ᵀ
|
| 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 |
-
#
|
| 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.
|
| 73 |
-
POSE_MAX_LINEAR_SPEED_MPS =
|
| 74 |
-
POSE_MAX_GUARD_INTERVAL_S = 0.
|
| 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 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 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)
|
| 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 |
-
幂等:文件组已不存在
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 录的也在旧帧那侧:没有键。
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
★
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 140 |
-
`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 行
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/`(当前
|
| 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)` | 后台线程 `
|
| 133 |
-
| `_on_upload_session_deleted` | `(session_path, err)` | 删除收尾:失败记日志;成功 `RecordingRepo.
|
| 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
|
| 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` | `()` | 删除任务(按钮与右键菜单共用):确认后 `
|
| 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)` | 后台线程 `
|
| 505 |
-
| `_on_session_deleted` | `(session_path, err)` | 删除收尾:失败仅提示;成功 `RecordingRepo.
|
| 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
|
| 207 |
-
"""
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
| 211 |
|
| 212 |
service = object.__new__(UvcCameraServiceManager)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
reason = "sightac mode=failed error='MJPEG 640x480@30: Invalid mode'"
|
| 214 |
-
group = {"
|
| 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.
|
| 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·
|
| 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(
|
| 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(
|
| 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⁻¹
|
| 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(
|
| 346 |
-
recovered = quat_product(quat_conjugate(q0r)
|
| 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
|
|
|
|
| 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 |
-
"""任务目录内
|
| 1489 |
-
|
| 1490 |
-
|
| 1491 |
-
|
| 1492 |
-
|
| 1493 |
-
|
| 1494 |
-
|
| 1495 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""后台删除本地
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1563 |
if episode_index > 0:
|
| 1564 |
-
|
| 1565 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
(
|
| 3345 |
-
结果经 _upload_session_deleted 信号回主线程:
|
| 3346 |
刷新历史面板并记日志。手部关键点后处理持有的已打开视频句柄在
|
| 3347 |
Linux 下不受 unlink 影响,输出镜像在 keypoints_output/ 不受牵连。
|
| 3348 |
"""
|
| 3349 |
def _worker():
|
| 3350 |
-
|
| 3351 |
-
|
| 3352 |
-
|
| 3353 |
-
|
| 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 |
-
|
| 3377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 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 |
-
|
| 537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
| 右键 → 删除任务 |
|
| 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 |

|
| 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 |

|
| 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 |
|