Spaces:
Running
collector: 更新采集工程至 v1.3.13(夹爪 SLAM 发布坐标帧改为 +X下/+Y左/+Z前)
Browse files同步 master 的 collector/ 子树至 177ef0ef,含一个提交:
- 177ef0ef feat: v1.3.13 —— 夹爪 SLAM 的**发布坐标帧**由 +X=右/+Y=正对/+Z=上 改为
+X=下/+Y=左/+Z=前。位姿视图、observation.*slam_trajectory、orb_pose.json 是同一份
数值,帧只有一处:发布入口,形态是在「姿态相对化到原点」之后再叠一次世界重标
M = R_y(−90°)·R_z(−90°)。姿态左右各乘一次 M·R·Mᵀ,右乘那次是夹爪体轴改名
(+X=体下/+Y=体左/+Z=体前),于是换锚后第一帧发布姿态 = M·Mᵀ = 单位矩阵,跟随
夹爪的三支箭头与世界三箭头在原点那一瞬重合。夹爪物理朝向一点没变:视图按同一张 M
把模型顶点从旧体轴坐标搬到新体轴坐标再旋转,逐像素与改前相同(两处必须同进同退,
只改一处夹爪歪 120°)。视图**不做任何换帧补偿**,发布帧就是它的数据帧,画面随之
整体改变;帧写在画面上——标题下第二行 `坐标帧 +X下 +Y左 +Z前`,三支世界轴标签各带
方向词(回落时变 X 右 / Y 正对 / Z 上),都从 settings 派生。_latest_pose /
read_latest_pose() 改存发布帧,native 只留在 _last_accepted_pose。落盘在
meta/info.json 加 observation.*slam_trajectory 的 frame / frame_axes 与顶层
gripper_slam_frame(v1.3.12 及更早的版本根本不写这个键 ⇒ 「键不在」即旧帧);
KSQ_GRIPPER_SLAM_FRAME=right_forward_up 可回落旧约定做 A/B。C++ 侧一字未动,
native 帧没变、无需重编,libORB_SLAM3.so 的 md5 也不变。
本次只同步 18 个文本文件(含 1 个新增测试),未触碰 collector/ 下的二进制。
- collector/README.md +78 -1
- collector/config/__init__.py +51 -1
- collector/config/settings.py +59 -0
- collector/core/egodata_writer.py +25 -1
- collector/core/gripper/slam/process_controller.py +90 -24
- collector/core/gripper/slam/protocol.py +100 -3
- collector/docs/data.md +28 -1
- collector/docs/file_format.md +41 -0
- collector/docs/tools.md +6 -1
- collector/docs/ui.md +36 -0
- collector/tools/tests/test_gripper_latest_pose_frame.py +252 -0
- collector/tools/tests/test_gripper_pose_axes.py +248 -96
- collector/tools/tests/test_gripper_pose_host_stamp.py +29 -16
- collector/tools/tests/test_gripper_slam_columns.py +62 -0
- collector/tools/tests/test_pose_view_qt.py +730 -2
- collector/ui/pose_view_qt.py +194 -28
- collector/使用手册.md +2 -2
- collector/使用说明_lite.md +3 -2
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# collector — Multimodal Data Acquisition SDK · 多模态数据采集 SDK
|
| 2 |
|
| 3 |
-

|
| 5 |

|
| 6 |
|
|
@@ -339,6 +339,13 @@ 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 |
# hardware tests require the corresponding device attached: d405_worker_test,
|
| 343 |
# d435_e2e_test, d435_gui_smoke_test, mono_regression, d435_playback_test, etc.
|
| 344 |
```
|
|
@@ -400,6 +407,48 @@ are documented in [docs/index.md](docs/index.md#开发约定).
|
|
| 400 |
|
| 401 |
### Changelog
|
| 402 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
- **v1.3.12** — pairing a new gripper no longer needs the vendor toolkit GUI.
|
| 404 |
When the app refused to connect a fresh gripper
|
| 405 |
(`串口连接失败,无法查询绑定的 Fays 序列号: serial open/handshake failed: Write
|
|
@@ -1257,6 +1306,34 @@ i18n 文案经 `tr()` 翻译、PyQt5 信号参数用 `object` 封送大整数、
|
|
| 1257 |
|
| 1258 |
### 更新记录
|
| 1259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1260 |
- **v1.3.12** — 接新夹爪不再需要开厂商工具包 GUI。新板子连不上时报的那句
|
| 1261 |
`串口连接失败,无法查询绑定的 Fays 序列号: serial open/handshake failed: Write
|
| 1262 |
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 (v1.3.13) — run all five after touching
|
| 343 |
+
# either side of the frame contract (protocol/ vs ui/pose_view_qt.py)
|
| 344 |
+
venv/bin/python tools/tests/test_gripper_pose_axes.py
|
| 345 |
+
venv/bin/python tools/tests/test_gripper_pose_host_stamp.py
|
| 346 |
+
venv/bin/python tools/tests/test_gripper_slam_columns.py
|
| 347 |
+
venv/bin/python tools/tests/test_gripper_latest_pose_frame.py
|
| 348 |
+
venv/bin/python tools/tests/test_pose_view_qt.py
|
| 349 |
# hardware tests require the corresponding device attached: d405_worker_test,
|
| 350 |
# d435_e2e_test, d435_gui_smoke_test, mono_regression, d435_playback_test, etc.
|
| 351 |
```
|
|
|
|
| 407 |
|
| 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
|
| 413 |
+
`orb_pose.json`, so the frame lives in exactly one place — the publish
|
| 414 |
+
entry — as one extra world relabel applied *after* the rotation is
|
| 415 |
+
relativized to the origin. Orientation gets `M` on **both** sides,
|
| 416 |
+
`M·R·Mᵀ`: the right-hand factor renames the gripper's own body axes
|
| 417 |
+
into the published vocabulary (`+X=body-down / +Y=body-left /
|
| 418 |
+
+Z=body-forward`, replacing body-right/forward/up), so at the origin
|
| 419 |
+
anchor the published rotation is `M·Mᵀ` = the **identity** and the
|
| 420 |
+
gripper-following triad **coincides** with the world triad. (Left
|
| 421 |
+
multiply alone would leave it at the constant `M` — two triads ~120°
|
| 422 |
+
apart, which is exactly what the user rejected; the earlier "order is
|
| 423 |
+
a contract" hazard is gone too, since the conjugating `M` now cancels:
|
| 424 |
+
`(M·R0·Mᵀ)ᵀ(M·R·Mᵀ) == M·R0ᵀ·R·Mᵀ`.) The gripper's *physical*
|
| 425 |
+
orientation is untouched: the view moves the model's local vertices by
|
| 426 |
+
the same `M` before rotating them, so the drawn model is pixel-identical
|
| 427 |
+
to before — the two halves must change together, or the gripper renders
|
| 428 |
+
120° askew. One consequence is deliberate and pinned by tests: the
|
| 429 |
+
pose view applies **no**
|
| 430 |
+
frame compensation — the published frame *is* its data frame, so the
|
| 431 |
+
GUI frame and the trajectory frame are one and the same and the
|
| 432 |
+
picture changes along with it (the scene rotates ~120° against the
|
| 433 |
+
canvas while the world triad keeps its screen position and now reads
|
| 434 |
+
down/left/forward). Because that triad's *screen position* is
|
| 435 |
+
pixel-identical before and after (its tips are the constants `e_i`,
|
| 436 |
+
the projection reads no data — measured 0 px), the frame is also
|
| 437 |
+
**written on the canvas**: a `坐标帧 +X下 +Y左 +Z前` line under the
|
| 438 |
+
title and a direction word on each world-axis label (`X 下` / `Y 左` /
|
| 439 |
+
`Z 前`, auto-switching to `X 右` / `Y 正对` / `Z 上` under the
|
| 440 |
+
fallback), both derived from `settings` and never hardcoded; the full
|
| 441 |
+
frame id is in the widget tooltip.
|
| 442 |
+
`KSQ_GRIPPER_SLAM_FRAME=right_forward_up` falls back to the old
|
| 443 |
+
convention for A/B work; the frame is recorded in `meta/info.json`
|
| 444 |
+
(`observation.*slam_trajectory.frame` / `frame_axes` and a top-level
|
| 445 |
+
`gripper_slam_frame`) so external readers can tell the two apart.
|
| 446 |
+
Caveat: that tag is **task-level** — a task directory spanning the
|
| 447 |
+
switch can only attest to the last frame written. The cutover is the
|
| 448 |
+
**release**, not a calendar day: episodes recorded by v1.3.12 or
|
| 449 |
+
earlier (which includes everything currently on disk — the newest
|
| 450 |
+
episode is from 2026-09-21 21:01) are the old convention and carry no
|
| 451 |
+
`frame` key at all, so "key absent" means old frame.
|
| 452 |
- **v1.3.12** — pairing a new gripper no longer needs the vendor toolkit GUI.
|
| 453 |
When the app refused to connect a fresh gripper
|
| 454 |
(`串口连接失败,无法查询绑定的 Fays 序列号: serial open/handshake failed: Write
|
|
|
|
| 1306 |
|
| 1307 |
### 更新记录
|
| 1308 |
|
| 1309 |
+
- **v1.3.13** — 夹爪 SLAM 的发布坐标帧改为 **+X=下 / +Y=左 / +Z=前**
|
| 1310 |
+
(原 +X=右 / +Y=正对 / +Z=上)。位姿视图、`observation.*slam_trajectory`、
|
| 1311 |
+
`orb_pose.json` 是同一份数值,帧也就只有一处 —— 发布入口,形态是
|
| 1312 |
+
在「姿态相对化到原点」**之后**再叠一次世界重标。姿态是左右**各乘一次**
|
| 1313 |
+
`M·R·Mᵀ`:右边那次给夹爪**体轴改名**(体轴从此跟世界轴共用一套词:
|
| 1314 |
+
`+X=体下 / +Y=体左 / +Z=体前`,取��体右/体前/体上),于是换锚后第一帧
|
| 1315 |
+
发布姿态 = `M·Mᵀ` = **单位矩阵**,跟随夹爪的三支箭头与世界三箭头在原点
|
| 1316 |
+
那一瞬**重合**。只左乘不右乘时它是常数 `M`,两套箭头差约 120° —— 那正是
|
| 1317 |
+
被否掉的一版;而且左右都乘之后 `M` 在两边相遇处整个约掉,
|
| 1318 |
+
`(M·R0·Mᵀ)ᵀ(M·R·Mᵀ) == M·R0ᵀ·R·Mᵀ`,原先"顺序是契约"的隐患也一并消失。
|
| 1319 |
+
夹爪的**物理朝向一点没变**:视图按同一张 `M` 把模型顶点从旧体轴坐标搬到
|
| 1320 |
+
新体轴坐标再旋转,画出来逐像素与改前相同 —— ★这两处必须同进同退,
|
| 1321 |
+
只改一处夹爪会被画歪 120°,且两种改法各自都"看起来自洽"。
|
| 1322 |
+
另一个刻意后果:位姿视图**不做任何换帧补偿** ——
|
| 1323 |
+
发布帧就是它的数据帧,gui 坐标系与轨迹系是同一个,换帧后画面随之整体
|
| 1324 |
+
改变(场景相对画布转约 120°,世界轴三箭头屏幕位置不动、含义改成
|
| 1325 |
+
下/左/前)。正因为那三支箭头的**屏幕位置**换帧前后逐像素相同(箭尖是常数
|
| 1326 |
+
`e_i`,投影不吃数据,实测差 0px),帧才必须**写在画面上**:标题下第二行
|
| 1327 |
+
`坐标帧 +X下 +Y左 +Z前`,三支世界轴标签各带方向词(`X 下` / `Y 左` /
|
| 1328 |
+
`Z 前`,回落旧约定时自动变 `X 右` / `Y 正对` / `Z 上`)—— 两处都从
|
| 1329 |
+
`settings` 派生、不硬编码,完整帧名在图上的 tooltip 里。
|
| 1330 |
+
`KSQ_GRIPPER_SLAM_FRAME=right_forward_up` 可回落旧约定做 A/B;
|
| 1331 |
+
帧名写进 `meta/info.json`(`observation.*slam_trajectory.frame` /
|
| 1332 |
+
`frame_axes` 与顶层 `gripper_slam_frame`),读端据此分辨新旧。
|
| 1333 |
+
注意该标注是**任务级**的:一个任务目录若跨了这次改帧,只能记住最后写入
|
| 1334 |
+
的帧。分界是**发布版本**不是日历日 —— v1.3.12 及更早录的一律旧约定
|
| 1335 |
+
(现存数据最新一段录于 2026-09-21 21:01,全在旧帧那侧),且那些 episode
|
| 1336 |
+
**没有** `frame` 键:键不在就按旧帧读。
|
| 1337 |
- **v1.3.12** — 接新夹爪不再需要开厂商工具包 GUI。新板子连不上时报的那句
|
| 1338 |
`串口连接失败,无法查询绑定的 Fays 序列号: serial open/handshake failed: Write
|
| 1339 |
timeout` 里,最要紧的问题是「板子到底在不在说话」,而主程序原本答不了。夹爪
|
|
@@ -3,7 +3,57 @@
|
|
| 3 |
版本号集中定义于此:settings.APP_VERSION 及其他模块均从这里取值。
|
| 4 |
"""
|
| 5 |
|
| 6 |
-
__version__ = "1.3.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
# v1.3.11 —— 手套链路整体换厂商 SDK v2.1.0(`tools/glove_sdk/`,取代 fork 的
|
| 9 |
# `core/glove_usb`),全栈因此锁到 **Python 3.10**:SDK 的 `algorithm/` 是
|
|
|
|
| 3 |
版本号集中定义于此:settings.APP_VERSION 及其他模块均从这里取值。
|
| 4 |
"""
|
| 5 |
|
| 6 |
+
__version__ = "1.3.13"
|
| 7 |
+
|
| 8 |
+
# v1.3.13 —— 夹爪 SLAM 的**发布坐标帧**改为 +X=下 / +Y=左 / +Z=前
|
| 9 |
+
# (原 +X=右 / +Y=正对 / +Z=上)。位姿视图、`observation.*slam_trajectory`、
|
| 10 |
+
# `orb_pose.json` 是同一份数值 ⇒ 帧只有一处:发布入口,形态是在"姿态相对化
|
| 11 |
+
# 到原点"**之后**再叠一次世界重标(换基矩阵 M = R_y(−90°)·R_z(−90°),
|
| 12 |
+
# 位置 (x,y,z)→(−z,−x,y),四元数 q_M=(0.5,−0.5,−0.5,0.5) 左右各乘一次)。
|
| 13 |
+
# ★姿态是左右**各乘一次** M·R·Mᵀ,右边那次是**夹爪体轴改名**:体轴从此跟
|
| 14 |
+
# 世界轴共用一套词(+X=体下 / +Y=体左 / +Z=体前;旧叫法是 体右/体前/体上)。
|
| 15 |
+
# 代价与收益:换锚后第一帧发布姿态 = M·Mᵀ = **单位矩阵**,跟随夹爪的三支箭头
|
| 16 |
+
# 与世界三箭头在原点那一瞬**重合** —— 只左乘不右乘时它是常数 M,两套箭头差
|
| 17 |
+
# 约 120°,用户判"跟随夹爪的坐标轴还没改"。而夹爪**物理朝向一点没变**:
|
| 18 |
+
# ui/pose_view_qt 按同一张 M 把模型顶点从旧体轴坐标搬到新体轴坐标,
|
| 19 |
+
# position + R·(M·v) == position + R_旧·v,逐像素与改前相同。★两处必须同进
|
| 20 |
+
# 同退:只改发布侧不改绘制侧(或反过来)夹爪都会被画歪 120°,而两种改法
|
| 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ᵀ 求"画面逐像素不变",那等于把换帧
|
| 28 |
+
# 在视图里整个抵消 —— 用户在主程序里看到的还是换帧前的画面,判"坐标系还是
|
| 29 |
+
# 没改的"。别再改回去。**帧要写在画面上**:位姿视图标题下第二行是
|
| 30 |
+
# `坐标帧 +X下 +Y左 +Z前`,三支世界轴箭头的标签也各带方向词(X 下 / Y 左 /
|
| 31 |
+
# Z 前,旧约定自动变右/正对/上),方向词从 settings 派生、完整帧名在
|
| 32 |
+
# tooltip —— 因为世界轴三箭头的**屏幕位置**换帧前后逐像素相同(tip 是常数
|
| 33 |
+
# e_i,投影公式不吃数据;实测两帧差 0px),能承载"这一屏画的是哪一帧"的
|
| 34 |
+
# 只有文字。这也是"用户怎么验收"的落点:他读界面,不读测试。
|
| 35 |
+
# ⚠坐标系有两套并存的"latest pose":`_latest_pose` / `read_latest_pose()`
|
| 36 |
+
# 原先把 **native** 帧存了进去(`_publish_pose` 只重绑定自己的局部名,
|
| 37 |
+
# frozen dataclass + replace ⇒ 入参对象不会被换帧),而 docstring 自称
|
| 38 |
+
# "dedicated control/GUI channel"。当时零消费者所以没显形,现已改成存发布帧,
|
| 39 |
+
# native 只留在 `_last_accepted_pose`(连续性判据按契约要它)。
|
| 40 |
+
# 回退开关 `KSQ_GRIPPER_SLAM_FRAME=right_forward_up`(未设/空串
|
| 41 |
+
# ⇒ 新帧;认不出的值出声后回落新帧)。读端(控制器 / 位姿视图)在构造时
|
| 42 |
+
# 解析一次、不逐帧读;写端(egodata_writer)每落一段 episode 解析一次。
|
| 43 |
+
# 两处都只是读 environ,进程内取值固定 ⇒ 必然同值;关键是**别做成可中途
|
| 44 |
+
# 翻转**的:录制中途换帧会让同一个 episode 混进两种帧而磁盘上分不出。
|
| 45 |
+
# 本仓库的帧标注只写 `meta/info.json`:`observation.*slam_trajectory.frame` /
|
| 46 |
+
# `frame_axes` + 顶层 `gripper_slam_frame`(任务根目录另有外部页面写的
|
| 47 |
+
# coordinate_frame.json,不是本仓库产的,别混为一谈)。
|
| 48 |
+
# ⚠它是**任务级**的(features 按并集/后写覆盖合并),一个任务目录跨了改帧时
|
| 49 |
+
# 早期 episode 会被标错。★分界是**发布版本**不是日历日:v1.3.12 及更早录的
|
| 50 |
+
# (现存 153 个 episode,一律是旧约定、本次原样留着没动;最新一段录于
|
| 51 |
+
# 2026-09-21 21:01)**不写这个键** —— 键不在就等于旧帧,别当默认值读。
|
| 52 |
+
# 按 episode 精确区分需要往 meta/episodes 加列(本次未加)。
|
| 53 |
+
# C++ 侧一字未动(native 帧没变,变的只是 Python 侧 native→发布帧这一映射),
|
| 54 |
+
# 所以无需重编、`libORB_SLAM3.so` 的 md5 也不变。
|
| 55 |
+
# `protocol.rotate_pose_z90`(native→中间帧,2026-09-09 真机实测定案)
|
| 56 |
+
# 保持原样,新重标是叠在它上面的一层,不是替换。
|
| 57 |
|
| 58 |
# v1.3.11 —— 手套链路整体换厂商 SDK v2.1.0(`tools/glove_sdk/`,取代 fork 的
|
| 59 |
# `core/glove_usb`),全栈因此锁到 **Python 3.10**:SDK 的 `algorithm/` 是
|
|
@@ -550,6 +550,65 @@ GRIPPER_SLOT_FORCE_L = "gripper_force_left" # Sightac 左(热力图+力曲
|
|
| 550 |
GRIPPER_SLOT_FORCE_R = "gripper_force_right" # Sightac 右(热力图+力曲线)
|
| 551 |
GRIPPER_STEREO_DISPLAY_FPS = 15.0 # 左目显示节流(SLAM 取帧在桥接进程内保持 30fps)
|
| 552 |
GRIPPER_SLAM_READY_TIMEOUT_S = 45.0 # wait_sdk_ready 上限(SDK 运行时重读标定)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
GRIPPER_FORCE_MATRIX_DIM = 250 # 触觉力矩阵边长(250×250×3)
|
| 554 |
# 力矩阵落盘规格(工具栏可切换,持久化到 server_config.json)。
|
| 555 |
# 与实时显示无关:显示一直走 float32 原值,本档位只决定落盘规格。
|
|
|
|
| 550 |
GRIPPER_SLOT_FORCE_R = "gripper_force_right" # Sightac 右(热力图+力曲线)
|
| 551 |
GRIPPER_STEREO_DISPLAY_FPS = 15.0 # 左目显示节流(SLAM 取帧在桥接进程内保持 30fps)
|
| 552 |
GRIPPER_SLAM_READY_TIMEOUT_S = 45.0 # wait_sdk_ready 上限(SDK 运行时重读标定)
|
| 553 |
+
# 夹爪 SLAM 发布坐标帧(v1.3.13)。GUI 位姿视图、episode 里的
|
| 554 |
+
# observation.*slam_trajectory、orb_pose.json 三者是同一份数值,帧也只有一个。
|
| 555 |
+
# down_left_forward(默认) +X=下 / +Y=左 / +Z=前
|
| 556 |
+
# right_forward_up(旧约定) +X=右 / +Y=正对 / +Z=上
|
| 557 |
+
# 环境变量 KSQ_GRIPPER_SLAM_FRAME 可回落旧约定(未设/空串 ⇒ 默认值;
|
| 558 |
+
# 认不出的取值出声后回落默认,不静默)。**每次会话解析一次**,不逐帧读:
|
| 559 |
+
# 录制中途翻转会让同一个 episode 里混进两种帧,而磁盘上分不出(见下)。
|
| 560 |
+
#
|
| 561 |
+
# 磁盘上的帧标注只有 meta/info.json 的 observation.*slam_trajectory.frame。
|
| 562 |
+
# ★ 分界是**发布版本**,不是日历日:v1.3.13(2026-09-28)起录的才是本值,
|
| 563 |
+
# v1.3.12 及更早录的一律旧约定、而且那时**根本不写**这个键 —— 所以
|
| 564 |
+
# 旧 episode 里 frame 键**不存在**,"键不在"就是"旧帧",别去 lookup 默认。
|
| 565 |
+
# (2026-09-22..09-27 用 v1.3.12 录的也一样:没有键、旧帧。现存
|
| 566 |
+
# data/recordings 最新一段录于 2026-09-21 21:01,尚未跨到这条线。)
|
| 567 |
+
# ★ 标注还是**任务级**的:features 按并集/后写覆盖合并,一个任务目录若跨了
|
| 568 |
+
# 这次改帧,info.json 只能记住最后写入的帧,早期 episode 会被标错。要按
|
| 569 |
+
# episode 精确区分得往 meta/episodes 加列(本次未加);跨线的任务目录请按
|
| 570 |
+
# 录制日期切开,切点用"哪一天升到 v1.3.13"而不是写死的日期。
|
| 571 |
+
GRIPPER_SLAM_FRAME_ENV = "KSQ_GRIPPER_SLAM_FRAME"
|
| 572 |
+
GRIPPER_SLAM_FRAME_NEW = "down_left_forward"
|
| 573 |
+
GRIPPER_SLAM_FRAME_LEGACY = "right_forward_up"
|
| 574 |
+
GRIPPER_SLAM_FRAME_DEFAULT = GRIPPER_SLAM_FRAME_NEW
|
| 575 |
+
# 帧 id → 各轴在现实中指向哪(写进 info.json,供外部读端判断)
|
| 576 |
+
GRIPPER_SLAM_FRAME_AXES = {
|
| 577 |
+
GRIPPER_SLAM_FRAME_NEW: {"+X": "down", "+Y": "left", "+Z": "forward"},
|
| 578 |
+
GRIPPER_SLAM_FRAME_LEGACY: {"+X": "right", "+Y": "forward", "+Z": "up"},
|
| 579 |
+
}
|
| 580 |
+
# 同上,给界面用的中文。**只此一份** —— ui/pose_view_qt.py 直接读这张表,
|
| 581 |
+
# 不自己抄:屏幕上写的字和上面落进 info.json 的 frame_axes 必须同源,否则
|
| 582 |
+
# 改词只剩一边动,界面和落盘各说各话。
|
| 583 |
+
# 旧约定的 +Y 写"正对"不是"前":用户自己的叫法就是「X=右 / Y=正对 / Z=上」;
|
| 584 |
+
# 两个帧的 forward 混用同一个中文字,会让人以为它们的 Y 是同一个物理方向。
|
| 585 |
+
GRIPPER_SLAM_FRAME_AXES_CN = {
|
| 586 |
+
GRIPPER_SLAM_FRAME_NEW: {"+X": "下", "+Y": "左", "+Z": "前"},
|
| 587 |
+
GRIPPER_SLAM_FRAME_LEGACY: {"+X": "右", "+Y": "正对", "+Z": "上"},
|
| 588 |
+
}
|
| 589 |
+
_SLAM_FRAME_WARNED = set()
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
def resolve_gripper_slam_frame(raw=None):
|
| 593 |
+
"""解析发布坐标帧 id。缺省/空串 → 默认;认不出的值出声后回落默认。
|
| 594 |
+
|
| 595 |
+
raw 显式给了就用它(便于调用方传入自己的快照),否则读环境变量。
|
| 596 |
+
"""
|
| 597 |
+
if raw is None:
|
| 598 |
+
raw = os.environ.get(GRIPPER_SLAM_FRAME_ENV, "")
|
| 599 |
+
value = str(raw or "").strip().lower()
|
| 600 |
+
if not value:
|
| 601 |
+
return GRIPPER_SLAM_FRAME_DEFAULT
|
| 602 |
+
if value in GRIPPER_SLAM_FRAME_AXES:
|
| 603 |
+
return value
|
| 604 |
+
if value not in _SLAM_FRAME_WARNED:
|
| 605 |
+
_SLAM_FRAME_WARNED.add(value)
|
| 606 |
+
known = "/".join(sorted(GRIPPER_SLAM_FRAME_AXES))
|
| 607 |
+
print(
|
| 608 |
+
"[settings] {}={!r} 不是已知坐标帧,按默认 {} 走(已知:{})".format(
|
| 609 |
+
GRIPPER_SLAM_FRAME_ENV, raw, GRIPPER_SLAM_FRAME_DEFAULT, known),
|
| 610 |
+
file=sys.stderr)
|
| 611 |
+
return GRIPPER_SLAM_FRAME_DEFAULT
|
| 612 |
GRIPPER_FORCE_MATRIX_DIM = 250 # 触觉力矩阵边长(250×250×3)
|
| 613 |
# 力矩阵落盘规格(工具栏可切换,持久化到 server_config.json)。
|
| 614 |
# 与实时显示无关:显示一直走 float32 原值,本档位只决定落盘规格。
|
|
@@ -1091,7 +1091,17 @@ class EgoDataWriter(QObject):
|
|
| 1091 |
total_episodes/app_version。既有键保留合并(其他机器/旧版本写入
|
| 1092 |
的不丢);features/cameras/video_extensions 按并集合并(key 一旦
|
| 1093 |
存在不因后续 episode 缺席而丢失),值以最新 episode 为准。
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1094 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1095 |
# cameras: 必须是 dict 格式 {key: {height, width, fps}},不能用 list
|
| 1096 |
# 深度槽过滤以显式注册为准,不猜槽名
|
| 1097 |
cameras_dict = {}
|
|
@@ -1144,9 +1154,20 @@ class EgoDataWriter(QObject):
|
|
| 1144 |
features[f"observation.{key}"] = matrix_features[
|
| 1145 |
f"observation.{key}"]
|
| 1146 |
elif key.endswith("slam_trajectory"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1147 |
features[f"observation.{key}"] = {
|
| 1148 |
"dtype": "float64", "shape": [8],
|
| 1149 |
-
"encoding": "flat_points_8_txyz_qxyzw"
|
|
|
|
|
|
|
|
|
|
| 1150 |
else: # _state / _force
|
| 1151 |
features[f"observation.{key}"] = {
|
| 1152 |
"dtype": "float32", "shape": [3]}
|
|
@@ -1218,6 +1239,9 @@ class EgoDataWriter(QObject):
|
|
| 1218 |
"total_episodes": len(list_task_episodes(self._task_dir)),
|
| 1219 |
"video_extensions": merged_exts,
|
| 1220 |
})
|
|
|
|
|
|
|
|
|
|
| 1221 |
self._atomic_write_json(path, info)
|
| 1222 |
|
| 1223 |
def _merge_stats(self):
|
|
|
|
| 1091 |
total_episodes/app_version。既有键保留合并(其他机器/旧版本写入
|
| 1092 |
的不丢);features/cameras/video_extensions 按并集合并(key 一旦
|
| 1093 |
存在不因后续 episode 缺席而丢失),值以最新 episode 为准。
|
| 1094 |
+
|
| 1095 |
+
v1.3.13 增:有 SLAM 轨迹时写顶层 gripper_slam_frame(一眼看出
|
| 1096 |
+
该任务的位姿帧)以及 observation.*slam_trajectory 上的
|
| 1097 |
+
frame/frame_axes。都只加不改,服务器依赖的既有键原样不动。
|
| 1098 |
"""
|
| 1099 |
+
# 本任务写入的位姿帧。这里逐次解析(而不是像 process_controller
|
| 1100 |
+
# 那样在构造时定一次):进程外改不了运行中进程的 environ,所以
|
| 1101 |
+
# 两者实际读到的必然是同一个值,不一致需要进程内有人去写
|
| 1102 |
+
# os.environ —— 仓库里没有这样的代码。逐次读的好处是它永远反映
|
| 1103 |
+
# 「刚录完那段数据时发布侧认的帧」。
|
| 1104 |
+
slam_frame = settings.resolve_gripper_slam_frame()
|
| 1105 |
# cameras: 必须是 dict 格式 {key: {height, width, fps}},不能用 list
|
| 1106 |
# 深度槽过滤以显式注册为准,不猜槽名
|
| 1107 |
cameras_dict = {}
|
|
|
|
| 1154 |
features[f"observation.{key}"] = matrix_features[
|
| 1155 |
f"observation.{key}"]
|
| 1156 |
elif key.endswith("slam_trajectory"):
|
| 1157 |
+
# 帧标注(v1.3.13):没有它,读端无从知道 xyz 在现实中
|
| 1158 |
+
# 指向哪 —— 2026-09-21 及更早录的 episode 是旧约定
|
| 1159 |
+
# (+X=右/+Y=正对/+Z=上),之后默认 +X=下/+Y=左/+Z=前,
|
| 1160 |
+
# 两者的数值都能正常解出来,只是含义不同。
|
| 1161 |
+
# ⚠ features 是**任务级**、按并集/后写覆盖合并的:一个
|
| 1162 |
+
# 任务目录若跨了这次改帧,这里只能记住最后写入的帧,
|
| 1163 |
+
# 该任务早期的 episode 会被标错。按 episode 精确区分需要
|
| 1164 |
+
# meta/episodes 加列(本次未加)。
|
| 1165 |
features[f"observation.{key}"] = {
|
| 1166 |
"dtype": "float64", "shape": [8],
|
| 1167 |
+
"encoding": "flat_points_8_txyz_qxyzw",
|
| 1168 |
+
"frame": slam_frame,
|
| 1169 |
+
"frame_axes": dict(
|
| 1170 |
+
settings.GRIPPER_SLAM_FRAME_AXES[slam_frame])}
|
| 1171 |
else: # _state / _force
|
| 1172 |
features[f"observation.{key}"] = {
|
| 1173 |
"dtype": "float32", "shape": [3]}
|
|
|
|
| 1239 |
"total_episodes": len(list_task_episodes(self._task_dir)),
|
| 1240 |
"video_extensions": merged_exts,
|
| 1241 |
})
|
| 1242 |
+
# 只在真有 SLAM 轨迹的任务上标帧,别给无 SLAM 的任务塞无关键
|
| 1243 |
+
if any(k.endswith("slam_trajectory") for k in self._present_gripper):
|
| 1244 |
+
info["gripper_slam_frame"] = slam_frame
|
| 1245 |
self._atomic_write_json(path, info)
|
| 1246 |
|
| 1247 |
def _merge_stats(self):
|
|
@@ -19,9 +19,11 @@ import traceback
|
|
| 19 |
|
| 20 |
from core.gripper.runtime.device_access import device_access_guard, FAYS_SDK_INITIALIZATION_LOCK, fays_config_device_guard
|
| 21 |
|
|
|
|
|
|
|
| 22 |
from .protocol import (
|
| 23 |
-
FAYS_STAGE_KEYS, PoseSample,
|
| 24 |
-
|
| 25 |
)
|
| 26 |
|
| 27 |
|
|
@@ -312,11 +314,25 @@ class SlamProcessController:
|
|
| 312 |
self._pose_buffer_lock = threading.Lock()
|
| 313 |
self._last_accepted_pose = None
|
| 314 |
self._pose_rejecting = False
|
| 315 |
-
# 原点后姿态相对化:设原点事件后捕获首帧
|
| 316 |
-
# 此后所有姿态左乘基准的逆 → 第一帧
|
| 317 |
# 帧轴与轨迹同基准(相对原点),见 _publish_pose。
|
| 318 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
self._pose_rotation_zero_pending = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
self._last_pose_reject_log = float("-inf")
|
| 321 |
self._fays_rates_alarm = FaysRateAlarm()
|
| 322 |
self._unmatched_lines = 0
|
|
@@ -386,6 +402,11 @@ class SlamProcessController:
|
|
| 386 |
no pose has been accepted yet. This is the dedicated control/GUI
|
| 387 |
channel — it always returns the freshest sample without queue
|
| 388 |
contention.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
"""
|
| 390 |
with self._latest_pose_lock:
|
| 391 |
return (
|
|
@@ -816,28 +837,51 @@ class SlamProcessController:
|
|
| 816 |
self._log("[SLAM] 读取原生日志失败: {}".format(exc))
|
| 817 |
|
| 818 |
def _publish_pose(self, pose):
|
| 819 |
-
#
|
| 820 |
-
#
|
| 821 |
-
#
|
| 822 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 823 |
pose = rotate_pose_z90(pose)
|
| 824 |
# 原点后姿态相对化:C++ 设原点只把位置归零,静止首帧的姿态
|
| 825 |
# 仍带部署校正/启动朝向的常数旋转(旋转跳变的根源,两轮
|
| 826 |
-
# 变换形式修正都没能抵消它)。改为捕获首帧
|
| 827 |
-
# 全部姿态左乘基准的逆 → 首帧
|
| 828 |
# 轴对齐,不跳变),此后姿态 = 相对原点的真实转动,与轨迹
|
| 829 |
# (位置相对原点)同基准。位置不动,保持已验证的轨迹映射。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 830 |
if self._pose_rotation_zero_pending:
|
| 831 |
-
self.
|
| 832 |
self._pose_rotation_zero_pending = False
|
| 833 |
-
|
| 834 |
-
pose
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
pose.rotation,
|
| 839 |
-
),
|
| 840 |
-
)
|
| 841 |
self._on_pose(pose)
|
| 842 |
timestamp = pose.timestamp
|
| 843 |
x, y, z = pose.position
|
|
@@ -852,7 +896,7 @@ class SlamProcessController:
|
|
| 852 |
]:
|
| 853 |
del self._pose_buffer[key]
|
| 854 |
if not self._pose_tmp_path or not self._pose_path:
|
| 855 |
-
return
|
| 856 |
try:
|
| 857 |
encoded = json.dumps({
|
| 858 |
"ts": timestamp,
|
|
@@ -869,6 +913,7 @@ class SlamProcessController:
|
|
| 869 |
os.replace(self._pose_tmp_path, self._pose_path)
|
| 870 |
except OSError:
|
| 871 |
pass
|
|
|
|
| 872 |
|
| 873 |
def send_origin(self, reason="origin reset"):
|
| 874 |
"""Write the bridge's newline protocol; never use SIGUSR1."""
|
|
@@ -940,7 +985,12 @@ class SlamProcessController:
|
|
| 940 |
# 位置在原点帧被 C++ 归零,姿态却带着部署校正/启动朝向的
|
| 941 |
# 常数残留(上次跳变的根源);每个 origin 事件(含 NEW
|
| 942 |
# ORIGIN)都重新捕获首帧姿态基准,与位置重归零保持一致。
|
| 943 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 944 |
self._pose_rotation_zero_pending = True
|
| 945 |
self._set_state(status="Tracking")
|
| 946 |
self._log(f"[SLAM] {event.raw}")
|
|
@@ -958,6 +1008,14 @@ class SlamProcessController:
|
|
| 958 |
arrival_monotonic=arrival_monotonic,
|
| 959 |
arrival_wall_time=arrival_wall_time,
|
| 960 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 961 |
rejection = pose_rejection_reason(
|
| 962 |
pose, self._last_accepted_pose,
|
| 963 |
)
|
|
@@ -978,14 +1036,22 @@ class SlamProcessController:
|
|
| 978 |
self._pose_rejecting = False
|
| 979 |
self._last_accepted_pose = pose
|
| 980 |
counters["valid"] += 1
|
| 981 |
-
self._publish_pose(pose)
|
| 982 |
with self._latest_pose_lock:
|
| 983 |
if self._last_accepted_seq:
|
| 984 |
gap = self._pose_raw_seq - self._last_accepted_seq - 1
|
| 985 |
if gap > 0:
|
| 986 |
self._pose_lost_count += gap
|
| 987 |
self._last_accepted_seq = self._pose_raw_seq
|
| 988 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 989 |
lost_count = self._pose_lost_count
|
| 990 |
self._set_state(pose_lost_count=lost_count)
|
| 991 |
self._set_state(status="Tracking")
|
|
|
|
| 19 |
|
| 20 |
from core.gripper.runtime.device_access import device_access_guard, FAYS_SDK_INITIALIZATION_LOCK, fays_config_device_guard
|
| 21 |
|
| 22 |
+
from config import settings as app_settings
|
| 23 |
+
|
| 24 |
from .protocol import (
|
| 25 |
+
FAYS_STAGE_KEYS, PoseSample, finish_publish_pose, parse_slam_line,
|
| 26 |
+
pose_rejection_reason, rotate_pose_z90,
|
| 27 |
)
|
| 28 |
|
| 29 |
|
|
|
|
| 314 |
self._pose_buffer_lock = threading.Lock()
|
| 315 |
self._last_accepted_pose = None
|
| 316 |
self._pose_rejecting = False
|
| 317 |
+
# 原点后姿态相对化:设原点事件后捕获首帧**中间帧**姿态作为
|
| 318 |
+
# 基准,此后所有姿态左乘基准的逆 → 中间帧第一帧恒为单位矩阵,
|
| 319 |
# 帧轴与轨迹同基准(相对原点),见 _publish_pose。
|
| 320 |
+
# ★ 基准活在中间帧(rotate_pose_z90 的输出)里,**不是**发布帧。
|
| 321 |
+
# 字段名里的 _mid 就是提醒这件事,改名前先读 _publish_pose。
|
| 322 |
+
# 顺序本身自 v1.3.13 起不再敏感(姿态左右各乘一次 M 之后,
|
| 323 |
+
# M 在两步相遇处整个约掉,(M·R0·Mᵀ)ᵀ(M·R·Mᵀ) 与先相对化再重标
|
| 324 |
+
# 逐元素相同,有回归钉着);但基准必须与**进来的样本**同帧才好
|
| 325 |
+
# 推理,而样本在这一步还是中间帧 —— 所以基准就留在中间帧。
|
| 326 |
+
self._pose_rotation_zero_mid = None
|
| 327 |
self._pose_rotation_zero_pending = True
|
| 328 |
+
# 发布坐标帧(v1.3.13):默认 +X=下 / +Y=左 / +Z=前;环境变量
|
| 329 |
+
# KSQ_GRIPPER_SLAM_FRAME=right_forward_up 回落旧约定。
|
| 330 |
+
# **每次会话解析一次**,不逐帧读 env —— 录制中途翻转会让同一个
|
| 331 |
+
# episode 里混进两种帧,而磁盘上分不出来(见 config/settings.py)。
|
| 332 |
+
self._publish_relabel = (
|
| 333 |
+
app_settings.resolve_gripper_slam_frame()
|
| 334 |
+
!= app_settings.GRIPPER_SLAM_FRAME_LEGACY
|
| 335 |
+
)
|
| 336 |
self._last_pose_reject_log = float("-inf")
|
| 337 |
self._fays_rates_alarm = FaysRateAlarm()
|
| 338 |
self._unmatched_lines = 0
|
|
|
|
| 402 |
no pose has been accepted yet. This is the dedicated control/GUI
|
| 403 |
channel — it always returns the freshest sample without queue
|
| 404 |
contention.
|
| 405 |
+
|
| 406 |
+
**The pose is in the PUBLISHED frame** (``_publish_pose`` 的返回值,
|
| 407 |
+
默认 +X=下 / +Y=左 / +Z=前,与 ``_pose_buffer`` / UI / 落盘同帧)——
|
| 408 |
+
不是 native、也不是中间帧。直接画它/记它与轨迹同坐标系;要 native
|
| 409 |
+
(例如做连续性判据)请用内部 ``_last_accepted_pose``,别在这里绕。
|
| 410 |
"""
|
| 411 |
with self._latest_pose_lock:
|
| 412 |
return (
|
|
|
|
| 837 |
self._log("[SLAM] 读取原生日志失败: {}".format(exc))
|
| 838 |
|
| 839 |
def _publish_pose(self, pose):
|
| 840 |
+
# 位姿唯一发布入口 —— UI / 落盘(episode 的 slam_trajectory)/
|
| 841 |
+
# orb_pose.json / 回填缓冲全部走这一条路,帧也只有这一个。
|
| 842 |
+
#
|
| 843 |
+
# native --rotate_pose_z90--> 中间帧(X=右 Y=正对 Z=上)
|
| 844 |
+
# --相对化--> --relabel--> 发布帧(X=下 Y=左 Z=前)
|
| 845 |
+
#
|
| 846 |
+
# rotate_pose_z90 是真机实测定案(2026-09-09,见 protocol.py);
|
| 847 |
+
# 发布帧自 v1.3.13 起是中间帧再叠一次世界重标,环境变量可回落。
|
| 848 |
+
# ★ 相对化与重标的先后顺序自 v1.3.13 起**不再敏感**:姿态是左右各
|
| 849 |
+
# 乘一次 M·R·Mᵀ,两步一倒 M 会在相遇处整个约掉,
|
| 850 |
+
# (M·R0·Mᵀ)ᵀ(M·R·Mᵀ) == M·R0ᵀ·R·Mᵀ(回归钉在
|
| 851 |
+
# tools/tests/test_gripper_pose_axes.py 第 5 节)。改动之前那个
|
| 852 |
+
# "反过来就少换一次帧、夹爪跟轨迹差 90°" 的坑已经不在了。
|
| 853 |
+
# finish_publish_pose 仍是唯一入口,因为它同时管着锚定与要不要重标。
|
| 854 |
+
#
|
| 855 |
+
# **返回发布帧位姿**。入参 pose 是 native 帧的对象,本函数只重绑定
|
| 856 |
+
# 自己的局部名(rotate_pose_z90 / finish_publish_pose 都返回新对象,
|
| 857 |
+
# PoseSample 是 frozen dataclass),调用方的那个对象**不会**被换帧。
|
| 858 |
+
# 所以谁要发布帧的位姿,就得拿这里的返回值 —— 别在调用���拿原来的
|
| 859 |
+
# 变量(_latest_pose 曾经就是这么写错的,症状是将来接上 GUI 就会
|
| 860 |
+
# 画成"坐标系没改")。
|
| 861 |
pose = rotate_pose_z90(pose)
|
| 862 |
# 原点后姿态相对化:C++ 设原点只把位置归零,静止首帧的姿态
|
| 863 |
# 仍带部署校正/启动朝向的常数旋转(旋转跳变的根源,两轮
|
| 864 |
+
# 变换形式修正都没能抵消它)。改为捕获首帧**中间帧**姿态为基准,
|
| 865 |
+
# 全部姿态左乘基准的逆 → 中间帧首帧恒为单位矩阵(帧轴与世界
|
| 866 |
# 轴对齐,不跳变),此后姿态 = 相对原点的真实转动,与轨迹
|
| 867 |
# (位置相对原点)同基准。位置不动,保持已验证的轨迹映射。
|
| 868 |
+
#
|
| 869 |
+
# 基准捕获与左乘都必须留在中间帧里 —— 基准是拿来跟**进来的样本**
|
| 870 |
+
# 直接相乘的,样本走到这一步还是中间帧,基准就得同帧(字段名里的
|
| 871 |
+
# _mid 就是这个约束)。
|
| 872 |
+
# ⚠ 别再照抄老注释说"提到前面会让发布帧退回中间帧":自 v1.3.13
|
| 873 |
+
# 姿态左右各乘一次 M 之后,整体提前是等价的(M 在两步相遇处自消,
|
| 874 |
+
# 实测与现状差 1.8e-15);而只把**姿态**那一道重标提前、基准留在
|
| 875 |
+
# 中间帧,得到的是 R0ᵀ·M·R·Mᵀ —— 既不是中间帧也不是发布帧,是
|
| 876 |
+
# 混帧,那才是真正要防的写错法。
|
| 877 |
if self._pose_rotation_zero_pending:
|
| 878 |
+
self._pose_rotation_zero_mid = pose.rotation
|
| 879 |
self._pose_rotation_zero_pending = False
|
| 880 |
+
pose = finish_publish_pose(
|
| 881 |
+
pose,
|
| 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
|
|
|
|
| 896 |
]:
|
| 897 |
del self._pose_buffer[key]
|
| 898 |
if not self._pose_tmp_path or not self._pose_path:
|
| 899 |
+
return pose
|
| 900 |
try:
|
| 901 |
encoded = json.dumps({
|
| 902 |
"ts": timestamp,
|
|
|
|
| 913 |
os.replace(self._pose_tmp_path, self._pose_path)
|
| 914 |
except OSError:
|
| 915 |
pass
|
| 916 |
+
return pose
|
| 917 |
|
| 918 |
def send_origin(self, reason="origin reset"):
|
| 919 |
"""Write the bridge's newline protocol; never use SIGUSR1."""
|
|
|
|
| 985 |
# 位置在原点帧被 C++ 归零,姿态却带着部署校正/启动朝向的
|
| 986 |
# 常数残留(上次跳变的根源);每个 origin 事件(含 NEW
|
| 987 |
# ORIGIN)都重新捕获首帧姿态基准,与位置重归零保持一致。
|
| 988 |
+
# 重新锚定后发布帧的首帧姿态是**单位矩阵**(相对化 R0ᵀ·R 再
|
| 989 |
+
# 左乘 M、右乘 Mᵀ 给体轴改名,M·Mᵀ = I)——夹爪体轴与世界轴
|
| 990 |
+
# 在原点那一瞬重合,跟位置的"首帧就是原点"一致。
|
| 991 |
+
# ⚠只左乘不右乘(v1.3.13 之前)时它是常数 M,那才是要留心的
|
| 992 |
+
# 版本:首帧姿态是个常数,语义上"夹爪体轴指向右/正对/上"。
|
| 993 |
+
self._pose_rotation_zero_mid = None
|
| 994 |
self._pose_rotation_zero_pending = True
|
| 995 |
self._set_state(status="Tracking")
|
| 996 |
self._log(f"[SLAM] {event.raw}")
|
|
|
|
| 1008 |
arrival_monotonic=arrival_monotonic,
|
| 1009 |
arrival_wall_time=arrival_wall_time,
|
| 1010 |
)
|
| 1011 |
+
# 连续性判据跑在 **native 帧**的位姿上(_publish_pose 还没被
|
| 1012 |
+
# 调用)。理由是参照与被比对象必须同帧:进来的样本是 native,
|
| 1013 |
+
# 而 _last_accepted_pose 就是上一次的同一个 native 样本,两者
|
| 1014 |
+
# 直接可比。把发布变换提到这之前会**同时**换掉被比对象与参照
|
| 1015 |
+
# (发布帧 = 中间帧再共轭一次 M,相对转角在共轭下不变,判据本身
|
| 1016 |
+
# 结果一样),但那样判据读到的是"已经发布出去"的位姿、而
|
| 1017 |
+
# _last_accepted_pose 的其余契约(见下方赋值处)也跟着变帧 ——
|
| 1018 |
+
# 没有收益,别动。
|
| 1019 |
rejection = pose_rejection_reason(
|
| 1020 |
pose, self._last_accepted_pose,
|
| 1021 |
)
|
|
|
|
| 1036 |
self._pose_rejecting = False
|
| 1037 |
self._last_accepted_pose = pose
|
| 1038 |
counters["valid"] += 1
|
| 1039 |
+
published = self._publish_pose(pose)
|
| 1040 |
with self._latest_pose_lock:
|
| 1041 |
if self._last_accepted_seq:
|
| 1042 |
gap = self._pose_raw_seq - self._last_accepted_seq - 1
|
| 1043 |
if gap > 0:
|
| 1044 |
self._pose_lost_count += gap
|
| 1045 |
self._last_accepted_seq = self._pose_raw_seq
|
| 1046 |
+
# 存**发布帧**(= _pose_buffer / _on_pose 的那一份),与
|
| 1047 |
+
# read_latest_pose 的契约一致:它自称 GUI/控制通道,给它
|
| 1048 |
+
# native 帧的话,将来接上位姿显示就是「坐标系还是没改」的
|
| 1049 |
+
# 同款症状(v1.3.13 踩过一次)。
|
| 1050 |
+
# 上面那两行别跟着改:连续性判据与 _last_accepted_pose 必须
|
| 1051 |
+
# 留在 native 帧 —— 它们比的是**进来的样本**,那个是 native,
|
| 1052 |
+
# 参照与被比对象得同帧(理由详见 pose 分支开头那段)。
|
| 1053 |
+
# tools/tests/test_gripper_latest_pose_frame.py 钉着这条。
|
| 1054 |
+
self._latest_pose = published
|
| 1055 |
lost_count = self._pose_lost_count
|
| 1056 |
self._set_state(pose_lost_count=lost_count)
|
| 1057 |
self._set_state(status="Tracking")
|
|
@@ -4,7 +4,7 @@ This module is deliberately side-effect free. It does not know about Tk,
|
|
| 4 |
process handles, files, or the application composition root.
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
from dataclasses import dataclass
|
| 8 |
import math
|
| 9 |
import re
|
| 10 |
from types import MappingProxyType
|
|
@@ -379,7 +379,12 @@ def _parse_pose(line):
|
|
| 379 |
|
| 380 |
|
| 381 |
def rotate_pose_z90(pose):
|
| 382 |
-
"""S80M
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
|
| 384 |
真机实测定案(2026-09-09,新 ORB 核心上线后):native 输出
|
| 385 |
相对物理系为 R_z(−90°)——native X=正对、Y=左、Z=上(实测:
|
|
@@ -394,7 +399,7 @@ def rotate_pose_z90(pose):
|
|
| 394 |
结果:X=右、Y=夹爪正对、Z=上(右手系,等价 ENU)。
|
| 395 |
静止首帧姿态 = R_z(+90°)·correction(部署校正的常数残留,
|
| 396 |
约 R_x(−90°),非单位矩阵)——由 process_controller 的原点后
|
| 397 |
-
姿态相对化(捕获首帧
|
| 398 |
单位矩阵,帧轴与世界轴对齐、无起始跳变。
|
| 399 |
"""
|
| 400 |
x, y, z = pose.position
|
|
@@ -411,6 +416,98 @@ def rotate_pose_z90(pose):
|
|
| 411 |
)
|
| 412 |
|
| 413 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
def quat_conjugate(q):
|
| 415 |
"""四元数共轭(单位四元数时即逆)。"""
|
| 416 |
x, y, z, w = q
|
|
|
|
| 4 |
process handles, files, or the application composition root.
|
| 5 |
"""
|
| 6 |
|
| 7 |
+
from dataclasses import dataclass, replace
|
| 8 |
import math
|
| 9 |
import re
|
| 10 |
from types import MappingProxyType
|
|
|
|
| 379 |
|
| 380 |
|
| 381 |
def rotate_pose_z90(pose):
|
| 382 |
+
"""S80M native → **中间帧**(X=右 Y=正对 Z=上,等价 ENU)。
|
| 383 |
+
|
| 384 |
+
★ 这不是发布帧。自 v1.3.13 起发布帧是本函数的输出再叠一次
|
| 385 |
+
世界重标(见 finish_publish_pose / relabel_published_frame),
|
| 386 |
+
默认 +X=下 / +Y=左 / +Z=前。单独调本函数得到的是中间帧:
|
| 387 |
+
既不是 native,也不是发布出去的那个帧 —— 别拿它当结果。
|
| 388 |
|
| 389 |
真机实测定案(2026-09-09,新 ORB 核心上线后):native 输出
|
| 390 |
相对物理系为 R_z(−90°)——native X=正对、Y=左、Z=上(实测:
|
|
|
|
| 399 |
结果:X=右、Y=夹爪正对、Z=上(右手系,等价 ENU)。
|
| 400 |
静止首帧姿态 = R_z(+90°)·correction(部署校正的常数残留,
|
| 401 |
约 R_x(−90°),非单位矩阵)——由 process_controller 的原点后
|
| 402 |
+
姿态相对化(捕获首帧中间帧姿态为基准)吸收,中间帧首帧恒为
|
| 403 |
单位矩阵,帧轴与世界轴对齐、无起始跳变。
|
| 404 |
"""
|
| 405 |
x, y, z = pose.position
|
|
|
|
| 416 |
)
|
| 417 |
|
| 418 |
|
| 419 |
+
# 发布帧重标 M:中间帧 {X=右, Y=正对, Z=上} → 发布帧 {X=下, Y=左, Z=前}。
|
| 420 |
+
# 换基矩阵(行 = 新基在旧坐标下的分量)
|
| 421 |
+
# M = [[0,0,-1],[-1,0,0],[0,1,0]],det=+1、trace=0
|
| 422 |
+
# = 绕 (1,-1,-1)/√3 转 120° = R_y(-90°)·R_z(-90°)
|
| 423 |
+
# 四元数 q_M=(0.5,-0.5,-0.5,0.5),位置 (x,y,z)→(-z,-x,y)。
|
| 424 |
+
# 物理验收判据(换帧的全部意义就在这里):
|
| 425 |
+
# 正对(旧 +Y) → +Z ;左(旧 −X) → +Y ;下(旧 −Z) → +X
|
| 426 |
+
# 右(旧 +X) → −Y ;上(旧 +Z) → −X ;后(旧 −Y) → −Z
|
| 427 |
+
# M³ = 单位(M 是 120° 旋转)—— 测试里用它当不变量。
|
| 428 |
+
# 同一张 M 在发布侧要用三处:位置左乘、姿态左乘、姿态右乘 Mᵀ(体轴改名)。
|
| 429 |
+
# 前两处的四元数从下面这个常量来;**位置那次是手写的字面量**(position=
|
| 430 |
+
# (-z,-x,y),M 的行恰好一行一个 ±1),没有从常量派生 —— 改 M 的话这一处
|
| 431 |
+
# 要跟着手改,别以为引了常量就自动同步。矩阵形式那个常量是给绘制侧
|
| 432 |
+
# (ui/pose_view_qt._BODY_RELABEL)用的,两边共用同一张 M,不要另抄一份。
|
| 433 |
+
_PUBLISH_RELABEL_QUAT = (0.5, -0.5, -0.5, 0.5)
|
| 434 |
+
|
| 435 |
+
# 同一个 M 的矩阵形式,给**体轴改名**那一侧用:画夹爪模型时把顶点从旧体轴
|
| 436 |
+
# 坐标搬到新体轴坐标(v_新 = M·v_旧)。跟姿态那个右乘 Mᵀ 是一体两面:
|
| 437 |
+
# 姿态少乘一次夹爪就画歪 120°,模型少搬一次同样画歪 120° —— 两处必须同时
|
| 438 |
+
# 用同一个 M(ui/pose_view_qt._BODY_RELABEL 直接引这里,不自己抄矩阵)。
|
| 439 |
+
PUBLISH_RELABEL_MATRIX = (
|
| 440 |
+
(0.0, 0.0, -1.0),
|
| 441 |
+
(-1.0, 0.0, 0.0),
|
| 442 |
+
(0.0, 1.0, 0.0),
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def relabel_published_frame(pose):
|
| 447 |
+
"""世界重标 + 体轴改名:中间帧 → 发布帧(默认 +X=下 / +Y=左 / +Z=前)。
|
| 448 |
+
|
| 449 |
+
位置**左乘** M(世界换基);姿态**左右各乘一次** M·R·Mᵀ。
|
| 450 |
+
|
| 451 |
+
右乘那一次是**夹爪体轴改名**,别删:体轴从此跟世界轴共用一套词,
|
| 452 |
+
夹爪的"右/前/上"改叫"−Y/+Z/−X"(等价说法:+X=体下、+Y=体左、
|
| 453 |
+
+Z=体前)。只左乘不右乘的话,发出去的姿态就成了"世界换了名、体轴
|
| 454 |
+
没换名"——GUI 里跟随夹爪的那三支箭头跟世界三箭头差一个常数 M
|
| 455 |
+
(约 120°),用户看见的就是"跟随夹爪的坐标轴还没改"。
|
| 456 |
+
|
| 457 |
+
补上右乘后,原点那一瞬发布姿态 = M·Mᵀ = **单位矩阵**:夹爪三箭头
|
| 458 |
+
与世界三箭头重合。这正是画面上"一眼看出用的是哪一帧"的判据。
|
| 459 |
+
|
| 460 |
+
★ 右乘是**常量换基**,不动夹爪的物理朝向:绘制侧把模型顶点按同一个
|
| 461 |
+
M 从旧体轴搬到新体轴(ui/pose_view_qt._BODY_RELABEL),画出来逐点
|
| 462 |
+
与改之前相同 —— 见 tools/tests/test_pose_view_qt.py 的像素回归。
|
| 463 |
+
物理朝向不变、只有叫法变,所以"夹爪体前 = 局部 +Y"这套建模约定不用
|
| 464 |
+
推翻,只是画之前换一次坐标。
|
| 465 |
+
|
| 466 |
+
不动时基:host_mono_ns / timestamp 原样透传。用 dataclasses.replace
|
| 467 |
+
而不是重建 PoseSample —— PoseSample 上还有 device_name/sequence/
|
| 468 |
+
arrival_* 等字段,重建会把它们悄悄丢掉。
|
| 469 |
+
"""
|
| 470 |
+
x, y, z = pose.position
|
| 471 |
+
return replace(
|
| 472 |
+
pose,
|
| 473 |
+
position=(-z, -x, y),
|
| 474 |
+
rotation=quat_product(
|
| 475 |
+
quat_product(_PUBLISH_RELABEL_QUAT, pose.rotation),
|
| 476 |
+
quat_conjugate(_PUBLISH_RELABEL_QUAT),
|
| 477 |
+
),
|
| 478 |
+
)
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def finish_publish_pose(pose, rotation_zero=None, relabel=True):
|
| 482 |
+
"""发布入口最后一步:原点后姿态相对化 →(可选)世界重标(含体轴改名)。
|
| 483 |
+
|
| 484 |
+
顺序:**现在两种顺序等价**,不再有雷。旧版(重标只左乘 M)时反过来做
|
| 485 |
+
会让 M 整体约掉、发出去的还是中间帧,而位置确实换了帧,于是 GUI 里
|
| 486 |
+
夹爪体轴模型会画成转了 90° 的姿势;v1.3.13 的重标改成左右各乘一次
|
| 487 |
+
(M·R·Mᵀ,右乘 = 体轴改名,见 relabel_published_frame)之后,
|
| 488 |
+
(M·R0·Mᵀ)ᵀ·(M·R·Mᵀ) = M·R0ᵀ·R·Mᵀ
|
| 489 |
+
与"先相对化再重标"逐元素相同 —— 共轭让 M 在两步里都自消。
|
| 490 |
+
tools/tests/test_gripper_pose_axes.py 第 5 节把这条等价性钉住了。
|
| 491 |
+
保留这个函数当唯一入口,是因为它同时管着"锚点相对化"和"要不要重标"
|
| 492 |
+
两件事,拆开调用迟早有人只做一半。
|
| 493 |
+
|
| 494 |
+
pose 必须已处于中间帧(rotate_pose_z90 的输出)。
|
| 495 |
+
rotation_zero = 本锚段的**中间帧**首帧姿态;None → 不做相对化
|
| 496 |
+
(设原点后的第一帧、以及还没锚定的阶段走这条)。
|
| 497 |
+
relabel=False → 旧约定(settings.GRIPPER_SLAM_FRAME_LEGACY),
|
| 498 |
+
发布中间帧本身(体轴也不改名),供过渡期回退。
|
| 499 |
+
"""
|
| 500 |
+
if rotation_zero is not None:
|
| 501 |
+
pose = replace(
|
| 502 |
+
pose,
|
| 503 |
+
rotation=quat_product(
|
| 504 |
+
quat_conjugate(rotation_zero), pose.rotation),
|
| 505 |
+
)
|
| 506 |
+
if not relabel:
|
| 507 |
+
return pose
|
| 508 |
+
return relabel_published_frame(pose)
|
| 509 |
+
|
| 510 |
+
|
| 511 |
def quat_conjugate(q):
|
| 512 |
"""四元数共轭(单位四元数时即逆)。"""
|
| 513 |
x, y, z, w = q
|
|
@@ -301,13 +301,40 @@ data/recordings/ # 录制根目录(settings.RECORDING
|
|
| 301 |
| `observation.gripper_{left,right}_force_ns` | int64 | 该力样本的采集时刻 |
|
| 302 |
| `observation.gripper_{left,right}_force_matrix` | list<int16> / list<float32> | 250×250×3 力矩阵(档位见下) |
|
| 303 |
| `observation.gripper_{left,right}_force_matrix_ns` | int64 | 该力矩阵样本的采集时刻 |
|
| 304 |
-
| `observation.slam_trajectory` | list<double> | 轨迹点 `[t,x,y,z,qx,qy,qz,qw]`×N,变长 |
|
| 305 |
| `observation.slam_trajectory_ns` | list<int64> | 每个轨迹点对应取样帧的**宿主时刻**,与 `slam_trajectory` 同序等长 |
|
| 306 |
| `observation.gripper_state` | list<float32, 3> | `[open_pct, gripped, fz_mn]` |
|
| 307 |
|
| 308 |
力矩阵档位记在**每段一份**的 `meta/episodes` 行(`force_matrix_specs` 列),
|
| 309 |
不按面值猜——同一任务换档时前一段的倍率不会被后一段顶掉。
|
| 310 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
**为什么需要 `_ns` 列。** 同一行里的 RGB 帧和力样本**不是同时刻采集的**,
|
| 312 |
两条支路各走各的路:RGB 走外部帧源队列,写线程每 tick 取**队头最旧帧**;
|
| 313 |
力/矩阵走 latest-wins 单槽,写线程取到的是**当下最新**样本。于是「行号相同」
|
|
|
|
| 301 |
| `observation.gripper_{left,right}_force_ns` | int64 | 该力样本的采集时刻 |
|
| 302 |
| `observation.gripper_{left,right}_force_matrix` | list<int16> / list<float32> | 250×250×3 力矩阵(档位见下) |
|
| 303 |
| `observation.gripper_{left,right}_force_matrix_ns` | int64 | 该力矩阵样本的采集时刻 |
|
| 304 |
+
| `observation.slam_trajectory` | list<double> | 轨迹点 `[t,x,y,z,qx,qy,qz,qw]`×N,变长(坐标帧见下) |
|
| 305 |
| `observation.slam_trajectory_ns` | list<int64> | 每个轨迹点对应取样帧的**宿主时刻**,与 `slam_trajectory` 同序等长 |
|
| 306 |
| `observation.gripper_state` | list<float32, 3> | `[open_pct, gripped, fz_mn]` |
|
| 307 |
|
| 308 |
力矩阵档位记在**每段一份**的 `meta/episodes` 行(`force_matrix_specs` 列),
|
| 309 |
不按面值猜——同一任务换档时前一段的倍率不会被后一段顶掉。
|
| 310 |
|
| 311 |
+
#### SLAM 轨迹的坐标帧
|
| 312 |
+
|
| 313 |
+
`x,y,z,qx,qy,qz,qw` 在现实中指向哪,由 `meta/info.json` 里的帧标注给出:
|
| 314 |
+
|
| 315 |
+
| 键 | 值 |
|
| 316 |
+
|---|---|
|
| 317 |
+
| `observation.{prefix}slam_trajectory.frame` | 帧 id,如 `down_left_forward` |
|
| 318 |
+
| `observation.{prefix}slam_trajectory.frame_axes` | `{"+X":"down","+Y":"left","+Z":"forward"}` |
|
| 319 |
+
| `gripper_slam_frame`(顶层,仅真有 SLAM 轨迹的任务) | 同上帧 id,一眼可查 |
|
| 320 |
+
|
| 321 |
+
- **v1.3.13 起**默认 `down_left_forward`:**+X=下 / +Y=左 / +Z=前**,右手系,
|
| 322 |
+
单位米,原点为按下 ENTER 那一刻的夹爪位置,姿态相对该帧首帧。
|
| 323 |
+
- **v1.3.12 及更早**录的 episode(`data/recordings/UMIGripper_Action_AI` 里
|
| 324 |
+
那 153 段)是 `right_forward_up`:+X=右 / +Y=正对 / +Z=上。数值本身总是能
|
| 325 |
+
正常解算,**只是含义不同**,读端必须按帧标注解释,不能假设一种。
|
| 326 |
+
- ⚠ 分界是**发布版本**,不是日历日:v1.3.13(2026-09-28)起才是新帧;
|
| 327 |
+
此前的版本**不写** `frame` 键 —— 所以旧 episode 里这两个键**不存在**,
|
| 328 |
+
「键不在」就等于旧帧,不要当成"缺省 = 新帧"。(2026-09-22..09-27 用
|
| 329 |
+
v1.3.12 录的也在旧帧那侧:没有键。现存数据最新一段录于 2026-09-21 21:01。)
|
| 330 |
+
- ⚠ 帧标注是**任务级**的(`meta/info.json` 的 `features` 按并集/后写覆盖合并):
|
| 331 |
+
一个任务目录若跨了这次改帧,它只能记住最后写入的帧,该任务早期 episode
|
| 332 |
+
会被标错。按 episode 精确区分需要 `meta/episodes` 加列(v1.3.13 未加)——
|
| 333 |
+
跨改帧的任务目录请按录制日期切分,切点取"那天升到了 v1.3.13"。
|
| 334 |
+
- 环境变量 `KSQ_GRIPPER_SLAM_FRAME=right_forward_up` 可让新录制回落到旧帧
|
| 335 |
+
(做 A/B 对比用);未设/空串走新帧,认不出的取值会向 stderr 出声后回落
|
| 336 |
+
新帧,不静默。该变量**每次会话解析一次**,录制中途改它不生效。
|
| 337 |
+
|
| 338 |
**为什么需要 `_ns` 列。** 同一行里的 RGB 帧和力样本**不是同时刻采集的**,
|
| 339 |
两条支路各走各的路:RGB 走外部帧源队列,写线程每 tick 取**队头最旧帧**;
|
| 340 |
力/矩阵走 latest-wins 单槽,写线程取到的是**当下最新**样本。于是「行号相同」
|
|
@@ -97,6 +97,47 @@ fnum = (N - 1) % 1000 # 三位零起:episode-000 .. episode-999
|
|
| 97 |
| `calibration` | obj | **任务级最新标定**(每设备一键:如 `head_stereo`,内容为 `StereoCalibration.to_dict()` 规范化结构:`type/name/resolution/fps/baseline/left_camera/right_camera/depth_scale/cam_imu_timeshift`,相机内参为 `{intrinsic: [fx,fy,cx,cy], distortion: [...]}`)。多设备时以设备名/前缀消歧编号(`d435_rgb_calibration` 等) |
|
| 98 |
| `total_episodes` | int | 当前 episode 数(上传删除不回退,见 §6) |
|
| 99 |
| `video_extensions` | obj | **每个 image_key** → 扩展名(RGB=`mp4`;深度=`mp4`=12-bit 灰度 HEVC、`mkv`=FFV1 回落);读侧必须按此判文件,勿按扩展名硬猜格式 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
## 4. meta/stats.json(任务级全局统计,v1.1.1 契约)
|
| 102 |
|
|
|
|
| 97 |
| `calibration` | obj | **任务级最新标定**(每设备一键:如 `head_stereo`,内容为 `StereoCalibration.to_dict()` 规范化结构:`type/name/resolution/fps/baseline/left_camera/right_camera/depth_scale/cam_imu_timeshift`,相机内参为 `{intrinsic: [fx,fy,cx,cy], distortion: [...]}`)。多设备时以设备名/前缀消歧编号(`d435_rgb_calibration` 等) |
|
| 98 |
| `total_episodes` | int | 当前 episode 数(上传删除不回退,见 §6) |
|
| 99 |
| `video_extensions` | obj | **每个 image_key** → 扩展名(RGB=`mp4`;深度=`mp4`=12-bit 灰度 HEVC、`mkv`=FFV1 回落);读侧必须按此判文件,勿按扩展名硬猜格式 |
|
| 100 |
+
| `gripper_slam_frame` | str | **条件字段(v1.3.13 增)**:本任务 SLAM 位姿所用的坐标帧 id。**只在任务真有 SLAM 轨迹列时写**——没有 `observation.*slam_trajectory` 的任务不会出现这个键。取值见 §3.1 |
|
| 101 |
+
|
| 102 |
+
### 3.1 SLAM 位姿坐标帧(v1.3.13 增)
|
| 103 |
+
|
| 104 |
+
两处标注,作用不同:
|
| 105 |
+
|
| 106 |
+
- **顶层 `gripper_slam_frame`**(上表):整个任务的位姿帧,一眼可见。
|
| 107 |
+
- **`features["observation.<sn>_slam_trajectory"]` 里的 `frame` / `frame_axes`**:
|
| 108 |
+
|
| 109 |
+
```json
|
| 110 |
+
"observation.gripper_1_slam_trajectory": {
|
| 111 |
+
"dtype": "float64", "shape": [8],
|
| 112 |
+
"encoding": "flat_points_8_txyz_qxyzw",
|
| 113 |
+
"frame": "down_left_forward",
|
| 114 |
+
"frame_axes": {"+X": "down", "+Y": "left", "+Z": "forward"}
|
| 115 |
+
}
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
`frame` 是帧 id,`frame_axes` 是各轴在现实中指向哪(中文名只在 GUI 上,不落盘)。
|
| 119 |
+
两个已知 id:
|
| 120 |
+
|
| 121 |
+
| `frame` | `frame_axes` | 叫法 |
|
| 122 |
+
|---|---|---|
|
| 123 |
+
| `down_left_forward` | `{"+X":"down","+Y":"left","+Z":"forward"}` | 新帧,**v1.3.13 起的默认** |
|
| 124 |
+
| `right_forward_up` | `{"+X":"right","+Y":"forward","+Z":"up"}` | 旧帧(`X=右/Y=正对/Z=上`) |
|
| 125 |
+
|
| 126 |
+
★注意旧帧的 `+Y` 是 `forward` 而中文叫「正对」不是「前」:两个帧的 `forward`
|
| 127 |
+
指的是**不同物理方向**,读端别把两个 id 的 `forward` 当成同一个。
|
| 128 |
+
|
| 129 |
+
**读侧两条硬规则:**
|
| 130 |
+
|
| 131 |
+
1. **`frame` 键不在 = 旧帧**,不是"取默认值"。分界是**发布版本**不是日历日:
|
| 132 |
+
v1.3.13(2026-09-28)起录的才写这个键,v1.3.12 及更早录的**根本不写**。
|
| 133 |
+
所以对老 episode 忽略这个键、按 `right_forward_up` 解读。
|
| 134 |
+
2. **标注是任务级的**,不是每 episode 一条:`features` 按并集 / 后写覆盖合并,
|
| 135 |
+
一个任务目录若跨了这次改帧,`info.json` 只能记住**最后写入**的那个帧,
|
| 136 |
+
该任务早期的 episode 会被标错。要逐 episode 精确区分得往 `meta/episodes`
|
| 137 |
+
加列(v1.3.13 未加)。跨线的任务目录请按录制日期切开。
|
| 138 |
+
|
| 139 |
+
坐标约定本身(帧的物理含义、`M` 变换、与相机相对化的关系)见本仓库
|
| 140 |
+
`docs/data.md` 的坐标帧一节。
|
| 141 |
|
| 142 |
## 4. meta/stats.json(任务级全局统计,v1.1.1 契约)
|
| 143 |
|
|
@@ -422,7 +422,7 @@ venv/bin/python tools/audit_frame_gaps.py [任务目录] [选项] # 默认 dat
|
|
| 422 |
|
| 423 |
## tests/ 测试
|
| 424 |
|
| 425 |
-
测试脚本见 `tools/tests/`(当前
|
| 426 |
|
| 427 |
| 文件 | 类型 | 一句话 |
|
| 428 |
| --- | --- | --- |
|
|
@@ -449,3 +449,8 @@ venv/bin/python tools/audit_frame_gaps.py [任务目录] [选项] # 默认 dat
|
|
| 449 |
| `test_audit_frame_gaps.py` | 离线 | 审计脚本三层判据/四桶/退出码(全合成 fixture) |
|
| 450 |
| `test_gripper_fays_pairing.py` | 离线 | 夹爪配对链失败关闭(QF/WF、按 USB serial 唯一匹配、不猜端口) |
|
| 451 |
| `test_gripper_esp_binding.py` | 离线 | 夹爪离线入口:串口诊断只读收集(含写阻塞时仍走通的只读复检、以及卡在第几笔写的留痕日志)、绑定读写与回读、界面三条链路的善后口径、「板子没在服务串口」提示的挂载范围 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
|
| 423 |
## tests/ 测试
|
| 424 |
|
| 425 |
+
测试脚本见 `tools/tests/`(当前 73 份;离线测试加 `QT_QPA_PLATFORM=offscreen` 无需任何硬件,真机测试需对应设备在线)。完整运行命令见 `README.md` 测试节。**下表只列了一部分**(早期 16 份 + 帧空洞相关的 5 份 + 夹爪配对/离线入口 + 位姿坐标帧的 5 份),其余按需现查文件名。
|
| 426 |
|
| 427 |
| 文件 | 类型 | 一句话 |
|
| 428 |
| --- | --- | --- |
|
|
|
|
| 449 |
| `test_audit_frame_gaps.py` | 离线 | 审计脚本三层判据/四桶/退出码(全合成 fixture) |
|
| 450 |
| `test_gripper_fays_pairing.py` | 离线 | 夹爪配对链失败关闭(QF/WF、按 USB serial 唯一匹配、不猜端口) |
|
| 451 |
| `test_gripper_esp_binding.py` | 离线 | 夹爪离线入口:串口诊断只读收集(含写阻塞时仍走通的只读复检、以及卡在第几笔写的留痕日志)、绑定读写与回读、界面三条链路的善后口径、「板子没在服务串口」提示的挂载范围 |
|
| 452 |
+
| `test_gripper_pose_axes.py` | 离线 | 发布帧的行/列符号约定、`PUBLISH_RELABEL_MATRIX` 与 `_PUBLISH_RELABEL_QUAT` **必须同一张 M**(正交/det=+1/非镜像)、相对化与重标的次序无关性 |
|
| 453 |
+
| `test_gripper_pose_host_stamp.py` | 离线 | 两种时刻的解析契约:位姿点的 `t` 是**相机钟**、native 的 `Host:` 是**宿主单调钟**(两者只差一个只能拟合的偏移,实测散布 ~104ms 已超一帧) |
|
| 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` | 离线 | 位姿视图:轨迹常驻、世界轴与**跟随夹爪的三箭头**重合且标签带方向词、三箭头取**列**(体轴)不取行、夹爪模型换基后逐像素不变、主窗口那次四元数→矩阵转换把帧带上 |
|
|
@@ -25,6 +25,7 @@
|
|
| 25 |
| `ui/login_dialog.py` | 启动/切换账号登录对话框:账号登录(对话框内同步校验)或游客登录,关闭窗口等价游客;结果经 `choice()/server_url()/username()/cookies()` 等访问器读取 |
|
| 26 |
| `ui/guide_dialog.py` | 使用说明窗口(首次启动自动弹出、帮助菜单重开;中英 HTML 模板;「不再显示」持久化到 QSettings)+ `VisibleCheckBox` 复选组件 |
|
| 27 |
| `ui/playback_dialog.py` | 录制回放对话框:统一网格同步播放全部视频 + 传感器渲染,深度槽识别、手部关键点叠加、批量删除 |
|
|
|
|
| 28 |
| `ui/task_page.py` | 启动时的任务选择页:任务表、服务器地址/凭据输入、连接状态、进入采集 |
|
| 29 |
| `ui/upload_dialog.py` | 录制会话一键打包上传对话框 |
|
| 30 |
| `ui/__init__.py` | 空包标记(0 行) |
|
|
@@ -402,6 +403,41 @@
|
|
| 402 |
|
| 403 |
**调用关系**:由 `ui/main_window.py`(`_open_playback`)构造;`tools/tests/test_playback_multifps.py`、`tools/tests/d435_playback_test.py` 直接构造。依赖 `core.session_timeline.load_timeline/SensorTimeline`、`core.session_catalog`(fps/扫描/元数据)、`core.session_loader.SessionLoader`、`core.render_engine` 五种渲染器与 `CONFIG_FILE`/`CONFIG_FILE_LEFT`、`core.sensor_hand_config`、`core.helpers` 路径与扫描函数、`core.uploader.UploadManager.get_upload_status`;可选手部叠加来自 `core.hand_tracking`。
|
| 404 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
### ui/task_page.py
|
| 406 |
|
| 407 |
**作用**:启动时显示的任务选择页(页面栈 index 0)。顶栏只读显示服务器地址(`set_server_display`)与当前身份,附「切换账号」按钮(发射 `switch_account_requested`);中部任务表(4 列),底部「→ 进入采集」与「🗑 删除」。任务数据来自 `data/tasks.json`(`core.task_record.load_tasks`),后端任务经 `merge_backend_tasks` 合并,并 `set_identity` 按身份过滤(游客仅见公共任务);用户选中任务点「进入采集」发射 `task_selected(dict)`,主窗口切换到数据采集页。
|
|
|
|
| 25 |
| `ui/login_dialog.py` | 启动/切换账号登录对话框:账号登录(对话框内同步校验)或游客登录,关闭窗口等价游客;结果经 `choice()/server_url()/username()/cookies()` 等访问器读取 |
|
| 26 |
| `ui/guide_dialog.py` | 使用说明窗口(首次启动自动弹出、帮助菜单重开;中英 HTML 模板;「不再显示」持久化到 QSettings)+ `VisibleCheckBox` 复选组件 |
|
| 27 |
| `ui/playback_dialog.py` | 录制回放对话框:统一网格同步播放全部视频 + 传感器渲染,深度槽识别、手部关键点叠加、批量删除 |
|
| 28 |
+
| `ui/pose_view_qt.py` | 夹爪位姿视图控件:QPainter 手绘的实时轨迹 + 世界坐标轴 + 跟随夹爪的体轴三箭头 + 夹爪模型(两张夹板按开合度张合) |
|
| 29 |
| `ui/task_page.py` | 启动时的任务选择页:任务表、服务器地址/凭据输入、连接状态、进入采集 |
|
| 30 |
| `ui/upload_dialog.py` | 录制会话一键打包上传对话框 |
|
| 31 |
| `ui/__init__.py` | 空包标记(0 行) |
|
|
|
|
| 403 |
|
| 404 |
**调用关系**:由 `ui/main_window.py`(`_open_playback`)构造;`tools/tests/test_playback_multifps.py`、`tools/tests/d435_playback_test.py` 直接构造。依赖 `core.session_timeline.load_timeline/SensorTimeline`、`core.session_catalog`(fps/扫描/元数据)、`core.session_loader.SessionLoader`、`core.render_engine` 五种渲染器与 `CONFIG_FILE`/`CONFIG_FILE_LEFT`、`core.sensor_hand_config`、`core.helpers` 路径与扫描函数、`core.uploader.UploadManager.get_upload_status`;可选手部叠加来自 `core.hand_tracking`。
|
| 405 |
|
| 406 |
+
### ui/pose_view_qt.py
|
| 407 |
+
|
| 408 |
+
**作用**:夹爪位姿视图控件(QWidget 子类),挂在采集页网格的 pose 槽里,把夹爪的实时 SLAM 位姿画成一幅三维示意:**轨迹折线**(跟随夹爪累计的点)、**世界坐标三箭头**(固定在原点)、**跟随夹爪的体轴三箭头**(画的是发布姿态的三列),以及**夹爪模型**(两块夹板按 `set_grip_state` 的开合度张合)。视角可用鼠标拖拽旋转、滚轮缩放、双击复位,视野在轨迹走近边缘时自动扩张。
|
| 409 |
+
|
| 410 |
+
**模块级常量**:
|
| 411 |
+
|
| 412 |
+
| 常量 | 值 | 说明 |
|
| 413 |
+
| --- | --- | --- |
|
| 414 |
+
| `DISPLAY_TRAJECTORY_MAX_POINTS` | `6000` | 显示上限(原 400 太小,见 `docs/postmortem_trajectory_and_rgb.md`);超出按 2 的幂翻倍 stride 抽稀 |
|
| 415 |
+
| `TRAJECTORY_UPDATE_MIN_INTERVAL` | `1/25` 秒 | 轨迹重算节流(位姿本身不受影响) |
|
| 416 |
+
| `SPAN_EXPAND_TRIGGER` / `SPAN_EXPAND_TARGET` / `SPAN_EXPAND_ANIM_SECONDS` | `0.85` / `1.35` / `0.3` | 视野自动扩张的触发比、目标比、动画时长 |
|
| 417 |
+
| `AZIMUTH_DEFAULT` / `ELEVATION_DEFAULT` | `-150°` / `30°` | 初始视角 |
|
| 418 |
+
| `BG` / `TITLE` / `TRAJECTORY` / `PLATE_GRIP` / `PLATE_OPEN` / `CONNECTOR` / `CAM_ORIGIN` | 暗色主题配色 | 背景、标题、轨迹、合拢/张开夹板、连接件、相机原点 |
|
| 419 |
+
| `WORLD_AXES` / `CAM_AXES` | 颜色 + 轴名 | 世界三箭头与跟随夹爪三箭头的配色(`WORLD_AXES` 的标签恰好是 `["X","Y","Z"]`,被测试断言) |
|
| 420 |
+
| `_BODY_RELABEL` | 帧 id → 换基矩阵 | **见下**:新帧 = `M`,旧帧 = 单位阵;把模型顶点从旧体轴搬到新体轴 |
|
| 421 |
+
| `TITLE_TOP` | `2.0` | 画布上两行字(标题、坐标帧)的顶;行高取**字体实测高度**不写死像素(写死会裁掉中文笔画,而帧那行正是「一眼确认改没改」的入口) |
|
| 422 |
+
|
| 423 |
+
**★ 坐标帧契约(v1.3.13)**:发布侧 `core/gripper/slam/protocol.py` 给位姿做了两次左乘 + 一次右乘(右乘 = **体轴改名**),本控件是那个契约的另一半:
|
| 424 |
+
|
| 425 |
+
- `_draw_frame_axes` 画的是 `rotation` 的**三列**(= 夹爪体轴),不是三行。同一张 `M` 也在发布侧给体轴改了名,所以重新锚定那一瞬发布姿态 = `M·Mᵀ` = 单位矩阵,**跟随夹爪的三箭头与世界三箭头重合**,标签同写 下/左/前。
|
| 426 |
+
- `_draw_gripper` 用 `_BODY_RELABEL`(与发布侧同一张 `M`)把模型顶点从旧体轴坐标搬到新体轴坐标,于是 `position + R·(M·v) == position + R_旧·v`,**夹爪的物理朝向与画面逐像素与改前相同**。
|
| 427 |
+
- 后果:**只改发布侧不改这里(或反过来)夹爪会被画歪 120°**,而两种改法各自都"看起来自洽"——别只动一处。测试见 `tools/tests/test_pose_view_qt.py`。
|
| 428 |
+
- 三箭头的轴名来自 `config.settings.GRIPPER_SLAM_FRAME_AXES_CN`,**不自己抄**(屏幕上的字与落进 `info.json` 的 `frame_axes` 必须同源)。
|
| 429 |
+
|
| 430 |
+
**关键方法**:
|
| 431 |
+
|
| 432 |
+
| 方法 | 签名 | 说明 |
|
| 433 |
+
| --- | --- | --- |
|
| 434 |
+
| `update_pose` | `(position, rotation, trajectory=())` | 主入口:更新位姿与轨迹,节流重算 |
|
| 435 |
+
| `set_grip_state` | `(percentage, gripped)` | 夹板开合度与抓取态(决定配色 `PLATE_GRIP`/`PLATE_OPEN`) |
|
| 436 |
+
| `set_localization_backend` / `reset` | `(backend)` / `()` | 标题栏后端标注 / 清空轨迹与视角(换夹爪、重新录制时调) |
|
| 437 |
+
| `paintEvent` / `_draw_static` | — | 全部绘制走 QPainter;静态层(��界轴、模型)进缓存,只有轨迹逐帧重画 |
|
| 438 |
+
|
| 439 |
+
**调用关系**:由 `ui/main_window.py:1616` 惰性 import 后构造(`pose_view = PoseViewQt()`),放进该夹爪的 `entry["pose_view"]`;位姿经 `_on_gripper_pose` → `update_pose`,抓取态经 `set_grip_state`,`reset()` 在换设备/重录时调。`tools/tests/test_pose_view_qt.py` 直接构造并离屏渲染(`QT_QPA_PLATFORM=offscreen`)。依赖 PyQt5、`config.settings`(`GRIPPER_SLAM_FRAME_AXES_CN` 等)、`numpy`。lite 包里**没有本控件**(lite 不含位姿视图)。
|
| 440 |
+
|
| 441 |
### ui/task_page.py
|
| 442 |
|
| 443 |
**作用**:启动时显示的任务选择页(页面栈 index 0)。顶栏只读显示服务器地址(`set_server_display`)与当前身份,附「切换账号」按钮(发射 `switch_account_requested`);中部任务表(4 列),底部「→ 进入采集」与「🗑 删除」。任务数据来自 `data/tasks.json`(`core.task_record.load_tasks`),后端任务经 `merge_backend_tasks` 合并,并 `set_identity` 按身份过滤(游客仅见公共任务);用户选中任务点「进入采集」发射 `task_selected(dict)`,主窗口切换到数据采集页。
|
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# -*- coding: utf-8 -*-
|
| 3 |
+
"""_latest_pose / read_latest_pose 的帧契约:必须是**发布帧**(v1.3.13 修正)。
|
| 4 |
+
|
| 5 |
+
venv/bin/python tools/tests/test_gripper_latest_pose_frame.py
|
| 6 |
+
|
| 7 |
+
背景:这两个符号叫同一个名字,装过两个不同的帧 —— read_latest_pose 的
|
| 8 |
+
docstring 自称 "the dedicated control/GUI channel",而 _handle_event 里的
|
| 9 |
+
`self._latest_pose = pose` 取的是 **native** 那一份:_publish_pose 只重绑定
|
| 10 |
+
它自己的局部名(PoseSample 是 frozen dataclass,rotate_pose_z90 直接 new、
|
| 11 |
+
finish_publish_pose 走 replace),**入参对象本身没有被换帧**。同一函数里
|
| 12 |
+
写进 _pose_buffer / _on_pose 的才是发布帧。
|
| 13 |
+
|
| 14 |
+
它当时零消费者,所以没显形;一旦有人照 docstring 把它接上位姿显示或控制环,
|
| 15 |
+
画出来的是 native 帧的夹爪,与同屏轨迹(发布帧)差 M·Rz(+90°)(位置;姿态
|
| 16 |
+
那侧还多一层**体轴改名** Mᵀ,即 M·Rz(+90°)·R·Mᵀ),症状正是
|
| 17 |
+
v1.3.13 那次「坐标系还是没改」——只不过这次连 M 都少了一步。
|
| 18 |
+
|
| 19 |
+
覆盖:
|
| 20 |
+
1. 同一帧内 _latest_pose 是发布帧(= 独立算出的 M·Rz(+90°)·native),
|
| 21 |
+
与 _pose_buffer 那条路逐分量相同;且**不是** native、也不是中间帧
|
| 22 |
+
2. _latest_pose is not _last_accepted_pose —— 后者按契约留 native 帧
|
| 23 |
+
(连续性判据靠它),两者是同一对象就说明赋值又被写回去了
|
| 24 |
+
3. read_latest_pose() 返回的就是它(含 raw_seq / lost_count)
|
| 25 |
+
4. 没配 pose_paths(落盘未启用)时 _latest_pose 不能变成 None ——
|
| 26 |
+
_publish_pose 在这条分支上早退,谁把返回值写成裸 return 就会踩到
|
| 27 |
+
5. 原点重锚(origin 事件)之后仍然成立
|
| 28 |
+
6. 开关拨到 KSQ_GRIPPER_SLAM_FRAME=right_forward_up 时,_latest_pose 跟着
|
| 29 |
+
变成**中间帧**(那才是这次真正发出去的那一帧)——契约是"永远等于发布
|
| 30 |
+
出口发出去的那一份",不是"永远等于 M·Rz(+90°)·native"。帧是构造时
|
| 31 |
+
解析一次的,所以这里必须另起一个 controller。
|
| 32 |
+
退出码 0 = 全部通过。
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import os
|
| 38 |
+
import sys
|
| 39 |
+
import tempfile
|
| 40 |
+
|
| 41 |
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
| 42 |
+
os.path.abspath(__file__))))
|
| 43 |
+
sys.path.insert(0, REPO_ROOT)
|
| 44 |
+
|
| 45 |
+
import numpy as np # noqa: E402
|
| 46 |
+
|
| 47 |
+
from config import settings # noqa: E402
|
| 48 |
+
from core.gripper.slam.process_controller import ( # noqa: E402
|
| 49 |
+
SlamProcessController,
|
| 50 |
+
SlamState,
|
| 51 |
+
)
|
| 52 |
+
from core.gripper.slam.protocol import ( # noqa: E402
|
| 53 |
+
ProtocolEvent,
|
| 54 |
+
PoseSample,
|
| 55 |
+
parse_slam_line,
|
| 56 |
+
relabel_published_frame,
|
| 57 |
+
rotate_pose_z90,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
_FAILS: list[str] = []
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def check(name: str, cond: bool, detail: str = ""):
|
| 64 |
+
tag = "PASS" if cond else "FAIL"
|
| 65 |
+
print(f" [{tag}] {name}" + (f" — {detail}" if detail else ""))
|
| 66 |
+
if not cond:
|
| 67 |
+
_FAILS.append(name)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def make_controller(pose_paths=(None, None)) -> SlamProcessController:
|
| 71 |
+
return SlamProcessController(
|
| 72 |
+
SlamState(),
|
| 73 |
+
executable="/bin/true",
|
| 74 |
+
vocabulary="/dev/null",
|
| 75 |
+
settings="/dev/null",
|
| 76 |
+
device_config="/dev/null",
|
| 77 |
+
trajectory="/dev/null",
|
| 78 |
+
work_dir="/tmp",
|
| 79 |
+
pose_paths=pose_paths,
|
| 80 |
+
runtime_env_factory=lambda: {},
|
| 81 |
+
cpu_roles={
|
| 82 |
+
"fays_left_orb": (0,),
|
| 83 |
+
"fays_right_orb": (1,),
|
| 84 |
+
"fays_background": (2,),
|
| 85 |
+
},
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def pose_line(timestamp, position, quat=(1.0, 0.0, 0.0, 0.0), host_ns=None):
|
| 90 |
+
"""真实格式的 native stdout 位姿行(走真解析器,别手工造 PoseSample)。"""
|
| 91 |
+
x, y, z = position
|
| 92 |
+
qw, qx, qy, qz = quat
|
| 93 |
+
line = (f"[{timestamp:.6f}] XYZ:({x:.6f},{y:.6f},{z:.6f}) "
|
| 94 |
+
f"Quat:(w={qw:.6f},x={qx:.6f},y={qy:.6f},z={qz:.6f})")
|
| 95 |
+
if host_ns is not None:
|
| 96 |
+
line += f" Host:({host_ns})"
|
| 97 |
+
return line
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def feed(controller, counters, timestamp, position, quat=(1.0, 0.0, 0.0, 0.0)):
|
| 101 |
+
"""喂一行 native 位姿,断言它确实被当成 pose 事件解析。"""
|
| 102 |
+
line = pose_line(timestamp, position, quat)
|
| 103 |
+
event = parse_slam_line(line)
|
| 104 |
+
assert event is not None and event.kind == "pose", (
|
| 105 |
+
f"位姿行没解析成 pose 事件: {line!r} -> {event!r}")
|
| 106 |
+
controller._handle_event(event, counters, arrival_monotonic=timestamp,
|
| 107 |
+
arrival_wall_time=timestamp)
|
| 108 |
+
return event
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def expected_published(position):
|
| 112 |
+
"""独立复算发布帧位置:native → R_z(+90°) → 世界重标 M。"""
|
| 113 |
+
return relabel_published_frame(rotate_pose_z90(PoseSample(
|
| 114 |
+
position=position, rotation=(0.0, 0.0, 0.0, 1.0),
|
| 115 |
+
timestamp=0.0))).position
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def main() -> int:
|
| 119 |
+
# 三个帧在同一物理位姿上必须互不相同,否则下面的断言形同虚设。
|
| 120 |
+
native = (0.1, 0.2, 0.3)
|
| 121 |
+
mid = rotate_pose_z90(PoseSample(position=native, rotation=(0, 0, 0, 1),
|
| 122 |
+
timestamp=0.0)).position
|
| 123 |
+
pub = expected_published(native)
|
| 124 |
+
print(f"[0] 同一物理位姿的三个帧: native={native} 中间帧={tuple(mid)} "
|
| 125 |
+
f"发布帧={tuple(pub)}")
|
| 126 |
+
check("三个帧互不相同(测试前提)",
|
| 127 |
+
len({native, tuple(mid), tuple(pub)}) == 3)
|
| 128 |
+
|
| 129 |
+
for label, pose_paths in (("未配 pose_paths(落盘早退分支)", (None, None)),
|
| 130 |
+
("配了 pose_paths(落盘正常分支)", None)):
|
| 131 |
+
if pose_paths is None:
|
| 132 |
+
tmpdir = tempfile.mkdtemp(prefix="latest_pose_frame_")
|
| 133 |
+
pose_paths = (os.path.join(tmpdir, "orb_pose.json.tmp"),
|
| 134 |
+
os.path.join(tmpdir, "orb_pose.json"))
|
| 135 |
+
print(f"[1] {label}")
|
| 136 |
+
controller = make_controller(pose_paths)
|
| 137 |
+
counters = {"raw": 0, "valid": 0}
|
| 138 |
+
|
| 139 |
+
feed(controller, counters, 1.0, native)
|
| 140 |
+
got = controller.read_latest_pose()
|
| 141 |
+
latest, raw_seq, lost = got
|
| 142 |
+
check("read_latest_pose() 不再是 None(首帧就有值)",
|
| 143 |
+
latest is not None, repr(got))
|
| 144 |
+
if latest is None:
|
| 145 |
+
continue
|
| 146 |
+
check("_latest_pose 位置 = 发布帧(M·Rz(+90)·native)",
|
| 147 |
+
np.allclose(latest.position, pub, atol=1e-9),
|
| 148 |
+
f"实得 {latest.position},发布帧应为 {pub},"
|
| 149 |
+
f"native 是 {native}(差的就是这次没换)")
|
| 150 |
+
check("_latest_pose 位置 != native(旧 bug 的判定)",
|
| 151 |
+
not np.allclose(latest.position, native, atol=1e-9),
|
| 152 |
+
f"实得 {latest.position}")
|
| 153 |
+
check("_latest_pose 位置 != 中间帧(少换一步也不行)",
|
| 154 |
+
not np.allclose(latest.position, mid, atol=1e-9),
|
| 155 |
+
f"实得 {latest.position},中间帧 {tuple(mid)}")
|
| 156 |
+
|
| 157 |
+
# 与 _on_pose / _pose_buffer 那条路同帧:发布出口只有一个
|
| 158 |
+
buffered = controller.pose_buffer_snapshot()
|
| 159 |
+
key = max(buffered)
|
| 160 |
+
check("_latest_pose 与 _pose_buffer 逐分量相同(同一条发布出口)",
|
| 161 |
+
np.allclose(latest.position, buffered[key][:3], atol=1e-12),
|
| 162 |
+
f"latest={latest.position} buffer={buffered[key][:3]}")
|
| 163 |
+
check("首帧发布姿态是单位阵(相对化后重标:体轴与世界轴在原点重合)",
|
| 164 |
+
np.allclose(latest.rotation, (0.0, 0.0, 0.0, 1.0),
|
| 165 |
+
atol=1e-9), f"实得 {latest.rotation}")
|
| 166 |
+
|
| 167 |
+
# ★ 本文件的核心:native 那一份必须留在 _last_accepted_pose 里
|
| 168 |
+
check("_latest_pose is not _last_accepted_pose(后者按契约留 native)",
|
| 169 |
+
latest is not controller._last_accepted_pose)
|
| 170 |
+
check("_last_accepted_pose 仍是 native 帧",
|
| 171 |
+
np.allclose(controller._last_accepted_pose.position, native,
|
| 172 |
+
atol=1e-12),
|
| 173 |
+
f"实得 {controller._last_accepted_pose.position}")
|
| 174 |
+
check("read_latest_pose 的 seq/lost 与内部字段一致",
|
| 175 |
+
(raw_seq, lost) == (controller._pose_raw_seq,
|
| 176 |
+
controller._pose_lost_count),
|
| 177 |
+
f"{raw_seq}/{lost} vs {controller._pose_raw_seq}/"
|
| 178 |
+
f"{controller._pose_lost_count}")
|
| 179 |
+
|
| 180 |
+
# 连续几帧(位姿判据会拒跳变:步长要小)
|
| 181 |
+
for index in range(1, 6):
|
| 182 |
+
spot = (0.1 + 0.002 * index, 0.2, 0.3 + 0.001 * index)
|
| 183 |
+
feed(controller, counters, 1.0 + index, spot)
|
| 184 |
+
expect = expected_published(spot)
|
| 185 |
+
now = controller.read_latest_pose()[0]
|
| 186 |
+
check(f"第 {index + 1} 帧 _latest_pose 仍是发布帧",
|
| 187 |
+
now is not None and np.allclose(now.position, expect,
|
| 188 |
+
atol=1e-9),
|
| 189 |
+
f"实得 {None if now is None else now.position},应为 {expect}")
|
| 190 |
+
|
| 191 |
+
# 原点重锚:重锚后发布帧首帧姿态回到单位阵,位置仍走发布帧
|
| 192 |
+
origin = parse_slam_line("NEW ORIGIN SET")
|
| 193 |
+
assert origin is not None and origin.kind == "origin", repr(origin)
|
| 194 |
+
controller._handle_event(origin, counters)
|
| 195 |
+
feed(controller, counters, 8.0, (0.0, 0.0, 0.0))
|
| 196 |
+
later = controller.read_latest_pose()[0]
|
| 197 |
+
check("原点重锚后 _latest_pose 仍是发布帧(首帧=M·0=原点)",
|
| 198 |
+
later is not None and np.allclose(later.position, (0.0, 0.0, 0.0),
|
| 199 |
+
atol=1e-12),
|
| 200 |
+
f"实得 {None if later is None else later.position}")
|
| 201 |
+
feed(controller, counters, 8.1, (0.0, 0.0, -0.05))
|
| 202 |
+
moved = controller.read_latest_pose()[0]
|
| 203 |
+
check("重锚后下移 0.05m 落在发布帧 +X(下)",
|
| 204 |
+
moved is not None and np.allclose(moved.position, (0.05, 0.0, 0.0),
|
| 205 |
+
atol=1e-9),
|
| 206 |
+
f"实得 {None if moved is None else moved.position}")
|
| 207 |
+
|
| 208 |
+
# [2] 开关拨到旧约定:_latest_pose 依然必须是"这一会话实际发布的那一帧"
|
| 209 |
+
# (回退时发布的就是中间帧),并与 _pose_buffer 同帧。契约不是"永远
|
| 210 |
+
# = M·Rz(+90°)·native",而是"永远 = 发布出口发出去的那一份"。
|
| 211 |
+
print("[2] KSQ_GRIPPER_SLAM_FRAME=right_forward_up(回退旧约定)")
|
| 212 |
+
saved_env = os.environ.get(settings.GRIPPER_SLAM_FRAME_ENV)
|
| 213 |
+
os.environ[settings.GRIPPER_SLAM_FRAME_ENV] = (
|
| 214 |
+
settings.GRIPPER_SLAM_FRAME_LEGACY)
|
| 215 |
+
try:
|
| 216 |
+
controller = make_controller()
|
| 217 |
+
check("controller 确实关掉了重标(本次会话解析一次)",
|
| 218 |
+
controller._publish_relabel is False)
|
| 219 |
+
counters = {"raw": 0, "valid": 0}
|
| 220 |
+
feed(controller, counters, 1.0, native)
|
| 221 |
+
latest = controller.read_latest_pose()[0]
|
| 222 |
+
check("回退时 _latest_pose = 中间帧(实际发布的那一帧)",
|
| 223 |
+
latest is not None and np.allclose(latest.position, mid, atol=1e-9),
|
| 224 |
+
f"实得 {None if latest is None else latest.position},"
|
| 225 |
+
f"中间帧 {tuple(mid)}")
|
| 226 |
+
check("回退时 _latest_pose 仍不是 native",
|
| 227 |
+
latest is not None and not np.allclose(latest.position, native,
|
| 228 |
+
atol=1e-9),
|
| 229 |
+
f"实得 {None if latest is None else latest.position}")
|
| 230 |
+
buffered = controller.pose_buffer_snapshot()
|
| 231 |
+
check("回退时 _latest_pose 与 _pose_buffer 仍同帧",
|
| 232 |
+
latest is not None and np.allclose(
|
| 233 |
+
latest.position, buffered[max(buffered)][:3], atol=1e-12),
|
| 234 |
+
f"latest={None if latest is None else latest.position} "
|
| 235 |
+
f"buffer={buffered[max(buffered)][:3]}")
|
| 236 |
+
finally:
|
| 237 |
+
if saved_env is None:
|
| 238 |
+
os.environ.pop(settings.GRIPPER_SLAM_FRAME_ENV, None)
|
| 239 |
+
else:
|
| 240 |
+
os.environ[settings.GRIPPER_SLAM_FRAME_ENV] = saved_env
|
| 241 |
+
|
| 242 |
+
print()
|
| 243 |
+
if _FAILS:
|
| 244 |
+
print(f"FAILED: {len(_FAILS)} 项 — {_FAILS}")
|
| 245 |
+
return 1
|
| 246 |
+
print("ALL PASS: _latest_pose / read_latest_pose 装的是发布帧"
|
| 247 |
+
"(+X下/+Y左/+Z前),native 只留在 _last_accepted_pose")
|
| 248 |
+
return 0
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
if __name__ == "__main__":
|
| 252 |
+
sys.exit(main())
|
|
@@ -1,20 +1,33 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
-
|
| 10 |
-
|
| 11 |
-
→
|
| 12 |
-
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"""
|
| 19 |
import math
|
| 20 |
import os
|
|
@@ -26,7 +39,9 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(
|
|
| 26 |
os.path.abspath(__file__)))))
|
| 27 |
|
| 28 |
from core.gripper.slam.protocol import (
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
)
|
| 31 |
|
| 32 |
|
|
@@ -48,8 +63,11 @@ def random_unit_quaternion(rng):
|
|
| 48 |
|
| 49 |
|
| 50 |
def main():
|
| 51 |
-
rng = np.random.default_rng(
|
| 52 |
Rzp90 = np.array([[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])
|
|
|
|
|
|
|
|
|
|
| 53 |
# 部署桥接校正矩阵(source 值 R_y(90°)·R_z(−90°)),与新核心
|
| 54 |
# 世界系合成 native 约定;静止首帧 native 姿态 = correction
|
| 55 |
correction = np.array([[0.0, 0.0, 1.0], [-1.0, 0.0, 0.0],
|
|
@@ -57,112 +75,229 @@ def main():
|
|
| 57 |
c = math.sqrt(0.5)
|
| 58 |
failures = []
|
| 59 |
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
axis_cases = [
|
| 62 |
-
# (native 位移, 期望
|
| 63 |
-
((1.0, 0.0, 0.0), (0.0,
|
| 64 |
-
((0.0,
|
| 65 |
-
((0.0, 0.0, 1.0), (
|
| 66 |
-
((0.0, 1.0, 0.0), (
|
|
|
|
|
|
|
| 67 |
]
|
| 68 |
for native, expected, label in axis_cases:
|
| 69 |
pose = PoseSample(position=native, rotation=(0, 0, 0, 1),
|
| 70 |
timestamp=1.0)
|
| 71 |
-
out = rotate_pose_z90(pose)
|
| 72 |
-
if
|
| 73 |
-
failures.append(
|
|
|
|
| 74 |
|
| 75 |
-
# 2.
|
| 76 |
-
# 物理 +
|
| 77 |
-
# native = R_z(−90°)·物理
|
| 78 |
Rzm90 = np.array([[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])
|
| 79 |
-
for phys_axis, label in [
|
| 80 |
-
(np.array([0.0, 1.0, 0.0]), "物理
|
| 81 |
-
(np.array([1.0, 0.0, 0.0]), "物理
|
| 82 |
-
(np.array([0.0, 0.0, 1.0]), "物理
|
|
|
|
| 83 |
]:
|
| 84 |
native = tuple(Rzm90 @ phys_axis)
|
| 85 |
-
out = rotate_pose_z90(PoseSample(
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
|
| 91 |
-
# 3. 随机位姿:
|
| 92 |
for i in range(200):
|
| 93 |
pos = tuple(float(v) for v in rng.uniform(-2, 2, 3))
|
| 94 |
quat = random_unit_quaternion(rng)
|
| 95 |
pose = PoseSample(position=pos, rotation=quat, timestamp=1.0 + i)
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
if tuple(
|
| 99 |
-
failures.append(f"#{i}
|
| 100 |
continue
|
| 101 |
R = quat_to_matrix(*quat)
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
failures.append(f"#{i} R' != R_z(+90°)·R")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
continue
|
| 106 |
if not np.allclose(Rm @ Rm.T, np.eye(3), atol=1e-12):
|
| 107 |
-
failures.append(f"#{i} R'
|
| 108 |
if not np.isclose(np.linalg.det(Rm), 1.0, atol=1e-9):
|
| 109 |
-
failures.append(f"#{i} det(R
|
| 110 |
|
| 111 |
-
# 4. 静止首帧
|
| 112 |
# = R_x(−90°)(部署校正常数残留,非单位矩阵);经原点相对化
|
| 113 |
-
# q0⁻¹⊗q 后 = 单位矩阵
|
|
|
|
|
|
|
|
|
|
| 114 |
q_corr = (-0.5, 0.5, -0.5, 0.5) # R_y(90°)·R_z(−90°) 的四元数
|
| 115 |
-
|
| 116 |
-
|
| 117 |
Rxm90 = np.array([[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, 0.0]])
|
| 118 |
-
if not np.allclose(quat_to_matrix(*
|
| 119 |
-
failures.append("
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
if not np.allclose(
|
| 123 |
-
failures.append("
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
pose = PoseSample(position=(1.0, 2.0, 3.0),
|
| 139 |
rotation=random_unit_quaternion(rng), timestamp=1.0)
|
| 140 |
twice = rotate_pose_z90(rotate_pose_z90(pose))
|
| 141 |
if tuple(twice.position) != (-1.0, -2.0, 3.0):
|
| 142 |
failures.append("双应用位置应为 (−x,−y,z)")
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
if not np.allclose(twice.rotation, expected_quat, atol=1e-12):
|
| 146 |
-
failures.append("双应用四元数应为 (−y,x,w,−z)")
|
| 147 |
-
R2 = quat_to_matrix(*twice.rotation)
|
| 148 |
-
Rz180 = Rzp90 @ Rzp90
|
| 149 |
-
if not np.allclose(R2, Rz180 @ quat_to_matrix(*pose.rotation),
|
| 150 |
atol=1e-12):
|
| 151 |
failures.append("双应用旋转应为 R_z(+180°)·R")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
-
# 7. host_mono_ns(取样帧宿主时刻)必须原样透传:
|
| 154 |
-
#
|
| 155 |
-
#
|
| 156 |
stamped = PoseSample(position=(1.0, 2.0, 3.0),
|
| 157 |
rotation=random_unit_quaternion(rng), timestamp=1.0,
|
| 158 |
host_mono_ns=287607702508638)
|
| 159 |
if rotate_pose_z90(stamped).host_mono_ns != 287607702508638:
|
| 160 |
failures.append("rotate_pose_z90 丢了 host_mono_ns")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
# 无戳(旧 native 二进制)透传 None,不能被伪造成 0
|
| 162 |
if rotate_pose_z90(pose).host_mono_ns is not None:
|
| 163 |
failures.append("无戳位姿的 host_mono_ns 应为 None")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
-
# 8. 轨迹形状不变(等距):相邻点距离一致
|
| 166 |
traj = [
|
| 167 |
PoseSample(position=tuple(float(v) for v in rng.uniform(-1, 1, 3)),
|
| 168 |
rotation=random_unit_quaternion(rng), timestamp=float(i))
|
|
@@ -170,12 +305,24 @@ def main():
|
|
| 170 |
]
|
| 171 |
for a, b in zip(traj, traj[1:]):
|
| 172 |
d1 = np.linalg.norm(np.array(a.position) - np.array(b.position))
|
| 173 |
-
ma, mb =
|
|
|
|
| 174 |
d2 = np.linalg.norm(np.array(ma.position) - np.array(mb.position))
|
| 175 |
if not np.isclose(d1, d2, atol=1e-12):
|
| 176 |
-
failures.append("轨迹相邻点距离被
|
| 177 |
|
| 178 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
for i in range(200):
|
| 180 |
a = random_unit_quaternion(rng)
|
| 181 |
b = random_unit_quaternion(rng)
|
|
@@ -189,8 +336,9 @@ def main():
|
|
| 189 |
failures.append(f"#{i} (a⊗b) 矩阵 != A·B")
|
| 190 |
break
|
| 191 |
|
| 192 |
-
#
|
| 193 |
-
#
|
|
|
|
| 194 |
for i in range(200):
|
| 195 |
q0r = random_unit_quaternion(rng)
|
| 196 |
q_rel = random_unit_quaternion(rng)
|
|
@@ -199,11 +347,12 @@ def main():
|
|
| 199 |
if not np.allclose(recovered, q_rel, atol=1e-12):
|
| 200 |
failures.append(f"#{i} 相对化未还原相对转动")
|
| 201 |
break
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
)
|
| 206 |
-
|
|
|
|
| 207 |
break
|
| 208 |
|
| 209 |
if failures:
|
|
@@ -211,7 +360,10 @@ def main():
|
|
| 211 |
for f in failures[:5]:
|
| 212 |
print(" ", f)
|
| 213 |
return 1
|
| 214 |
-
print("PASS:
|
|
|
|
|
|
|
|
|
|
| 215 |
return 0
|
| 216 |
|
| 217 |
|
|
|
|
| 1 |
+
"""发布位姿坐标帧的数学性质单测(离线,无硬件)。
|
| 2 |
+
|
| 3 |
+
发布帧 = 中间帧 {X=右, Y=正对, Z=上} 再叠一次世界重标(v1.3.13 起)
|
| 4 |
+
= **+X=下 / +Y=左 / +Z=前**,右手系。
|
| 5 |
+
链路(见 process_controller._publish_pose / protocol.finish_publish_pose):
|
| 6 |
+
|
| 7 |
+
native --rotate_pose_z90--> 中间帧 --相对化--> --relabel--> 发布帧
|
| 8 |
+
|
| 9 |
+
- native 约定(2026-09-09 真机实测定案,新 ORB 核心上线后):
|
| 10 |
+
R_z(−90°),native X=正对、Y=左、Z=上(实测:物理 +Y(正对) 位移
|
| 11 |
+
→ native +X;物理 +X(右) → native −Y)
|
| 12 |
+
- 第 1 步 rotate_pose_z90 = 纯左乘 R_z(+90°):位置 (−y,x,z),
|
| 13 |
+
四元数 q_z(+90°)⊗q 化简为 c·(x−y, y+x, z+w, w−z),c=√2/2
|
| 14 |
+
→ 中间帧 X=右、Y=夹爪正对、Z=上(等价 ENU)
|
| 15 |
+
- 第 3 步 relabel:位置**左乘** M,姿态**左右各乘一次** M·R·Mᵀ。
|
| 16 |
+
换基矩阵(行 = 新基在旧坐标下的分量)
|
| 17 |
+
M = R_y(−90°)·R_z(−90°) = [[0,0,-1],[-1,0,0],[0,1,0]],
|
| 18 |
+
det=+1、绕 (1,-1,-1)/√3 转 120°,四元数 q_M=(0.5,-0.5,-0.5,0.5),
|
| 19 |
+
位置 (x,y,z)→(−z,−x,y)
|
| 20 |
+
姿态右边那次 Mᵀ 是**夹爪体轴改名**:体轴从此跟世界轴共用一套词
|
| 21 |
+
(+X=体下 / +Y=体左 / +Z=体前)。只左乘不右乘的话,发出去的姿态是
|
| 22 |
+
"世界换了名、体轴没换名",GUI 里跟随夹爪的三支箭头与世界三箭头差
|
| 23 |
+
一个常数 M(约 120°)——用户判"跟随夹爪的坐标轴还没改"。
|
| 24 |
+
- **原点那一瞬发布姿态 = M·Mᵀ = 单位矩阵**:夹爪三箭头与世界三箭头
|
| 25 |
+
重合,这是"一眼看出用的是哪一帧"的判据(第 4 节钉住)。
|
| 26 |
+
- 相对化与重标的**顺序不再影响结果**:共轭让 M 在两步里都自消,
|
| 27 |
+
(M·R0·Mᵀ)ᵀ(M·R·Mᵀ) = M·R0ᵀ·R·Mᵀ 与"先相对化再重标"逐元素相同。
|
| 28 |
+
第 5 节把这条等价性钉住(旧版顺序是雷,v1.3.13 第二版起不是了)。
|
| 29 |
+
|
| 30 |
+
物理验收判据(第 1、2 节):正对→+Z、左→+Y、下→+X、右→−Y、上→−X。
|
| 31 |
"""
|
| 32 |
import math
|
| 33 |
import os
|
|
|
|
| 39 |
os.path.abspath(__file__)))))
|
| 40 |
|
| 41 |
from core.gripper.slam.protocol import (
|
| 42 |
+
PUBLISH_RELABEL_MATRIX, PoseSample, _PUBLISH_RELABEL_QUAT,
|
| 43 |
+
finish_publish_pose, quat_conjugate, quat_product,
|
| 44 |
+
relabel_published_frame, rotate_pose_z90,
|
| 45 |
)
|
| 46 |
|
| 47 |
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
def main():
|
| 66 |
+
rng = np.random.default_rng(20260928)
|
| 67 |
Rzp90 = np.array([[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])
|
| 68 |
+
# 发布帧重标 M(协议里硬编码的 q_M 的矩阵形式,这里独立写一遍做对照)
|
| 69 |
+
M = np.array([[0.0, 0.0, -1.0], [-1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
|
| 70 |
+
q_M = (0.5, -0.5, -0.5, 0.5)
|
| 71 |
# 部署桥接校正矩阵(source 值 R_y(90°)·R_z(−90°)),与新核心
|
| 72 |
# 世界系合成 native 约定;静止首帧 native 姿态 = correction
|
| 73 |
correction = np.array([[0.0, 0.0, 1.0], [-1.0, 0.0, 0.0],
|
|
|
|
| 75 |
c = math.sqrt(0.5)
|
| 76 |
failures = []
|
| 77 |
|
| 78 |
+
if not np.allclose(quat_to_matrix(*q_M), M, atol=1e-12):
|
| 79 |
+
failures.append("q_M 与 M 不一致(协议常量写错了)")
|
| 80 |
+
if not np.isclose(np.linalg.det(M), 1.0, atol=1e-12):
|
| 81 |
+
failures.append("M 不是纯旋转(det != +1)")
|
| 82 |
+
|
| 83 |
+
# 0. ★协议里的**两个独立字面量**必须互为同一张 M,而且本文件那两个副本
|
| 84 |
+
# 也必须与它们逐元素相同 —— 上面那两条比的是本文件自己声明的两份,
|
| 85 |
+
# 对模块里的常量一个字都没验。
|
| 86 |
+
# 漏掉这条的代价(变异实测):把 PUBLISH_RELABEL_MATRIX 换成
|
| 87 |
+
# [[0,0,-1],[-1,0,0],[0,-1,0]](det = −1,左手系、物理上不可能,
|
| 88 |
+
# 画出来夹爪会**镜像**,而它身上的箭头照样对)—— 五个测试全绿。
|
| 89 |
+
# 两个常量分头写、又只跟自己的副本对照,就是这个盲区。
|
| 90 |
+
module_M = np.asarray(PUBLISH_RELABEL_MATRIX, dtype=float)
|
| 91 |
+
if not np.allclose(module_M, quat_to_matrix(*_PUBLISH_RELABEL_QUAT),
|
| 92 |
+
atol=1e-12):
|
| 93 |
+
failures.append(
|
| 94 |
+
"protocol 的 PUBLISH_RELABEL_MATRIX 与 _PUBLISH_RELABEL_QUAT "
|
| 95 |
+
f"不是同一张 M:矩阵\n{module_M}\n四元数给出\n"
|
| 96 |
+
f"{quat_to_matrix(*_PUBLISH_RELABEL_QUAT)}")
|
| 97 |
+
if not np.isclose(np.linalg.det(module_M), 1.0, atol=1e-12):
|
| 98 |
+
failures.append(
|
| 99 |
+
f"PUBLISH_RELABEL_MATRIX 的 det = {np.linalg.det(module_M):.6f}"
|
| 100 |
+
"(应为 +1)—— det = −1 是镜像:夹爪模型会被画成左右手互换,"
|
| 101 |
+
"而三支体轴箭头看不出问题")
|
| 102 |
+
if not np.allclose(module_M @ module_M.T, np.eye(3), atol=1e-12):
|
| 103 |
+
failures.append("PUBLISH_RELABEL_MATRIX 不是正交阵")
|
| 104 |
+
if not np.allclose(module_M, M, atol=1e-12):
|
| 105 |
+
failures.append(
|
| 106 |
+
f"PUBLISH_RELABEL_MATRIX 与本文件的 M 不一致:\n{module_M}\n{M}")
|
| 107 |
+
|
| 108 |
+
# 1. 轴映射:native 物理轴 → 发布帧
|
| 109 |
+
# native: X=正对 Y=左 Z=上;发布帧: X=下 Y=左 Z=前
|
| 110 |
axis_cases = [
|
| 111 |
+
# (native 位移, 期望发布帧位移, 说明)
|
| 112 |
+
((1.0, 0.0, 0.0), (0.0, 0.0, 1.0), "正对(native X) → +Z 前"),
|
| 113 |
+
((0.0, 1.0, 0.0), (0.0, 1.0, 0.0), "左(native Y) → +Y 左"),
|
| 114 |
+
((0.0, 0.0, 1.0), (-1.0, 0.0, 0.0), "上(native Z) → −X(上 = −下)"),
|
| 115 |
+
((0.0, -1.0, 0.0), (0.0, -1.0, 0.0), "右(native −Y) → −Y 右"),
|
| 116 |
+
((0.0, 0.0, -1.0), (1.0, 0.0, 0.0), "下(native −Z) → +X 下"),
|
| 117 |
+
((-1.0, 0.0, 0.0), (0.0, 0.0, -1.0), "后(native −X) → −Z 后"),
|
| 118 |
]
|
| 119 |
for native, expected, label in axis_cases:
|
| 120 |
pose = PoseSample(position=native, rotation=(0, 0, 0, 1),
|
| 121 |
timestamp=1.0)
|
| 122 |
+
out = relabel_published_frame(rotate_pose_z90(pose))
|
| 123 |
+
if not np.allclose(out.position, expected, atol=1e-12):
|
| 124 |
+
failures.append(
|
| 125 |
+
f"轴映射错误 [{label}]: {out.position} != {expected}")
|
| 126 |
|
| 127 |
+
# 2. 物理轴复现:物理 +Y(正对) → 发布 +Z;物理 +X(右) → 发布 −Y;
|
| 128 |
+
# 物理 +Z(上) → 发布 −X;物理 −Z(下) → 发布 +X
|
| 129 |
+
# native = R_z(−90°)·物理
|
| 130 |
Rzm90 = np.array([[0.0, 1.0, 0.0], [-1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])
|
| 131 |
+
for phys_axis, expected, label in [
|
| 132 |
+
(np.array([0.0, 1.0, 0.0]), np.array([0.0, 0.0, 1.0]), "物理正对"),
|
| 133 |
+
(np.array([1.0, 0.0, 0.0]), np.array([0.0, -1.0, 0.0]), "物理右"),
|
| 134 |
+
(np.array([0.0, 0.0, 1.0]), np.array([-1.0, 0.0, 0.0]), "物理上"),
|
| 135 |
+
(np.array([0.0, 0.0, -1.0]), np.array([1.0, 0.0, 0.0]), "物理下"),
|
| 136 |
]:
|
| 137 |
native = tuple(Rzm90 @ phys_axis)
|
| 138 |
+
out = relabel_published_frame(rotate_pose_z90(PoseSample(
|
| 139 |
+
position=native, rotation=(0, 0, 0, 1), timestamp=1.0)))
|
| 140 |
+
if not np.allclose(np.array(out.position), expected, atol=1e-12):
|
| 141 |
+
failures.append(
|
| 142 |
+
f"{label} 在发布帧里应为 {expected},实际 {out.position}")
|
| 143 |
|
| 144 |
+
# 3. 随机位姿:两步都是纯左乘,且都是正规旋转
|
| 145 |
for i in range(200):
|
| 146 |
pos = tuple(float(v) for v in rng.uniform(-2, 2, 3))
|
| 147 |
quat = random_unit_quaternion(rng)
|
| 148 |
pose = PoseSample(position=pos, rotation=quat, timestamp=1.0 + i)
|
| 149 |
+
# 第 1 步:中间帧
|
| 150 |
+
mid = rotate_pose_z90(pose)
|
| 151 |
+
if tuple(mid.position) != (-pos[1], pos[0], pos[2]):
|
| 152 |
+
failures.append(f"#{i} 中间帧位置应为 (−y,x,z)")
|
| 153 |
continue
|
| 154 |
R = quat_to_matrix(*quat)
|
| 155 |
+
if not np.allclose(quat_to_matrix(*mid.rotation), Rzp90 @ R,
|
| 156 |
+
atol=1e-12):
|
| 157 |
+
failures.append(f"#{i} 中间帧 R' != R_z(+90°)·R")
|
| 158 |
+
continue
|
| 159 |
+
# 第 3 步:发布帧
|
| 160 |
+
pub = relabel_published_frame(mid)
|
| 161 |
+
if not np.allclose(pub.position, (-mid.position[2], -mid.position[0],
|
| 162 |
+
mid.position[1]), atol=1e-12):
|
| 163 |
+
failures.append(f"#{i} 发布帧位置应为 (−z,−x,y)")
|
| 164 |
+
continue
|
| 165 |
+
Rm = quat_to_matrix(*pub.rotation)
|
| 166 |
+
if not np.allclose(Rm, M @ (Rzp90 @ R) @ M.T, atol=1e-12):
|
| 167 |
+
failures.append(f"#{i} 发布帧 R' != M·R_z(+90°)·R·Mᵀ")
|
| 168 |
continue
|
| 169 |
if not np.allclose(Rm @ Rm.T, np.eye(3), atol=1e-12):
|
| 170 |
+
failures.append(f"#{i} 发布帧 R' 非正交")
|
| 171 |
if not np.isclose(np.linalg.det(Rm), 1.0, atol=1e-9):
|
| 172 |
+
failures.append(f"#{i} det(M·R·Mᵀ) != +1(M 是纯旋转)")
|
| 173 |
|
| 174 |
+
# 4. 静止首帧:native = correction → 中间帧 R_z(90°)·correction
|
| 175 |
# = R_x(−90°)(部署校正常数残留,非单位矩阵);经原点相对化
|
| 176 |
+
# q0⁻¹⊗q 后中间帧 = 单位矩阵;**再经重标 = M·I·Mᵀ = 单位矩阵**。
|
| 177 |
+
# 发布帧首帧就是单位矩阵 —— 夹爪体轴与世界轴在原点那一瞬逐轴
|
| 178 |
+
# 重合(+X=下、+Y=左、+Z=前),GUI 里"跟随夹爪的三支箭头"和世界
|
| 179 |
+
# 三箭头指向同一处,用户要的"跟随夹爪的轴也改了"就是这一条。
|
| 180 |
q_corr = (-0.5, 0.5, -0.5, 0.5) # R_y(90°)·R_z(−90°) 的四元数
|
| 181 |
+
mid0 = rotate_pose_z90(PoseSample(position=(0.0, 0.0, 0.0),
|
| 182 |
+
rotation=q_corr, timestamp=1.0))
|
| 183 |
Rxm90 = np.array([[1.0, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0, -1.0, 0.0]])
|
| 184 |
+
if not np.allclose(quat_to_matrix(*mid0.rotation), Rxm90, atol=1e-12):
|
| 185 |
+
failures.append("中间帧首帧应为 R_x(−90°)(常数残留)")
|
| 186 |
+
pub0 = finish_publish_pose(mid0, mid0.rotation)
|
| 187 |
+
R0 = quat_to_matrix(*pub0.rotation)
|
| 188 |
+
if not np.allclose(R0, np.eye(3), atol=1e-12):
|
| 189 |
+
failures.append("发布帧首帧应为单位矩阵(体轴与世界轴在原点重合)")
|
| 190 |
+
for index, name in enumerate("XYZ"):
|
| 191 |
+
if not np.allclose(R0[:, index], np.eye(3)[index], atol=1e-12):
|
| 192 |
+
failures.append(
|
| 193 |
+
f"原点那一瞬体轴 {name} 未与世界 {name} 轴重合:{R0[:, index]}")
|
| 194 |
+
# 静止不动的一串位姿:发布帧姿态逐帧恒等(没有跳变)
|
| 195 |
+
for i in range(5):
|
| 196 |
+
again = finish_publish_pose(
|
| 197 |
+
rotate_pose_z90(PoseSample(position=(0.0, 0.0, 0.0),
|
| 198 |
+
rotation=q_corr,
|
| 199 |
+
timestamp=2.0 + i)),
|
| 200 |
+
mid0.rotation)
|
| 201 |
+
if not np.allclose(quat_to_matrix(*again.rotation), np.eye(3),
|
| 202 |
+
atol=1e-12):
|
| 203 |
+
failures.append(f"静止第 {i} 帧发布姿态不是单位矩阵(有跳变)")
|
| 204 |
+
|
| 205 |
+
# 5. ★体轴改名 + 顺序等价(本文件的核心回归)★
|
| 206 |
+
# native t0 = R_z(−90°)(夹爪正对);t1 = R_z(−180°)(物理右转 90°)。
|
| 207 |
+
# 发布姿态的**体轴列**必须等于物理方向,且体轴的名字已跟世界轴统一:
|
| 208 |
+
# t1 体前(局部 +Z) = −Y(物理右)、体右(局部 −Y) = −Z(物理后)
|
| 209 |
+
mid_t0 = rotate_pose_z90(PoseSample(position=(0.0, 0.0, 0.0),
|
| 210 |
+
rotation=(0.0, 0.0, -c, c),
|
| 211 |
+
timestamp=1.0))
|
| 212 |
+
mid_t1 = rotate_pose_z90(PoseSample(position=(0.0, 0.0, 0.0),
|
| 213 |
+
rotation=(0.0, 0.0, -1.0, 0.0),
|
| 214 |
+
timestamp=2.0))
|
| 215 |
+
if not np.allclose(quat_to_matrix(*mid_t0.rotation), np.eye(3),
|
| 216 |
+
atol=1e-12):
|
| 217 |
+
failures.append("第 5 节前提不成立:中间帧 t0 应为单位矩阵")
|
| 218 |
+
pub_t1 = finish_publish_pose(mid_t1, mid_t0.rotation)
|
| 219 |
+
R1 = quat_to_matrix(*pub_t1.rotation)
|
| 220 |
+
if not np.allclose(R1[:, 2], (0.0, -1.0, 0.0), atol=1e-12):
|
| 221 |
+
failures.append(
|
| 222 |
+
f"体轴改名:右转 90° 后体前(局部 +Z)应为物理右 (0,-1,0),"
|
| 223 |
+
f"实际 {R1[:,2]}")
|
| 224 |
+
if not np.allclose(-R1[:, 1], (0.0, 0.0, -1.0), atol=1e-12):
|
| 225 |
+
failures.append(
|
| 226 |
+
f"体轴改名:右转 90° 后体右(局部 −Y)应为物理后 (0,0,-1),"
|
| 227 |
+
f"实际 {-R1[:,1]}")
|
| 228 |
+
# 顺序等价:先重标再相对化 == 先相对化再重标 —— 共轭让 M 在两步里都自消。
|
| 229 |
+
# (旧版重标只左乘 M,顺序是雷:反过来 M 被整体约掉、姿态留在中间帧
|
| 230 |
+
# 而位置已换帧,GUI 的夹爪模型会画成转了 90° 的姿势。)
|
| 231 |
+
mid_t0b = relabel_published_frame(mid_t0)
|
| 232 |
+
mid_t1b = relabel_published_frame(mid_t1)
|
| 233 |
+
swapped = quat_product(quat_conjugate(mid_t0b.rotation), mid_t1b.rotation)
|
| 234 |
+
if not np.allclose(quat_to_matrix(*swapped), R1, atol=1e-12):
|
| 235 |
+
failures.append("顺序等价性不成立:先重标再相对化给出了不同姿态")
|
| 236 |
+
# 反向钉死两条:①去掉右乘(旧实现只左乘 M)必须给出**不同**的答案,
|
| 237 |
+
# 否则"体轴也改了名"这件事没有回归保护;②旧实现在原点那一瞬是常数 M
|
| 238 |
+
# —— 正是用户看到"跟随夹爪的坐标轴还没改"的原因。
|
| 239 |
+
rel_q = quat_product(quat_conjugate(mid_t0.rotation), mid_t1.rotation)
|
| 240 |
+
left_only = quat_to_matrix(*quat_product(q_M, rel_q))
|
| 241 |
+
if np.allclose(left_only, R1, atol=1e-12):
|
| 242 |
+
failures.append("体轴改名没生效:只左乘 M 与左右各乘一次结果相同")
|
| 243 |
+
left_anchor = quat_to_matrix(*quat_product(
|
| 244 |
+
q_M, quat_product(quat_conjugate(mid_t0.rotation), mid_t0.rotation)))
|
| 245 |
+
if not np.allclose(left_anchor, M, atol=1e-12):
|
| 246 |
+
failures.append("旧实现(只左乘)在原点那一瞬应为常数 M")
|
| 247 |
+
if np.allclose(left_anchor, np.eye(3), atol=1e-12):
|
| 248 |
+
failures.append("旧实现在原点竟然是单位矩阵——那用户不会判没改")
|
| 249 |
+
# relabel=False(过渡期回退旧约定)必须原样返回中间帧:相对化照样做
|
| 250 |
+
# (t1 相对 t0 = R_z(−90°)),只是不再叠 M、体轴也不改名 —— 与发布帧
|
| 251 |
+
# 结果差 M·__·Mᵀ,这条同时把"回退只关掉重标、不关掉相对化"钉住。
|
| 252 |
+
legacy = finish_publish_pose(mid_t1, mid_t0.rotation, relabel=False)
|
| 253 |
+
if not np.allclose(quat_to_matrix(*legacy.rotation), Rzm90, atol=1e-12):
|
| 254 |
+
failures.append("relabel=False 应发布中间帧(此例应为 R_z(−90°))")
|
| 255 |
+
if not np.allclose(M @ quat_to_matrix(*legacy.rotation) @ M.T, R1,
|
| 256 |
+
atol=1e-12):
|
| 257 |
+
failures.append("发布帧结果应 = M·(relabel=False 的结果)·Mᵀ")
|
| 258 |
+
|
| 259 |
+
# 6. 双应用 rotate_pose_z90 = R_z(+180°):位置 (−x,−y,z)
|
| 260 |
pose = PoseSample(position=(1.0, 2.0, 3.0),
|
| 261 |
rotation=random_unit_quaternion(rng), timestamp=1.0)
|
| 262 |
twice = rotate_pose_z90(rotate_pose_z90(pose))
|
| 263 |
if tuple(twice.position) != (-1.0, -2.0, 3.0):
|
| 264 |
failures.append("双应用位置应为 (−x,−y,z)")
|
| 265 |
+
if not np.allclose(quat_to_matrix(*twice.rotation),
|
| 266 |
+
Rzp90 @ Rzp90 @ quat_to_matrix(*pose.rotation),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
atol=1e-12):
|
| 268 |
failures.append("双应用旋转应为 R_z(+180°)·R")
|
| 269 |
+
# 重标是 120° 旋转 ⇒ 阶为 3(不是 2,别照着 r_z90 的"双应用"抄)
|
| 270 |
+
thrice = pose
|
| 271 |
+
for _ in range(3):
|
| 272 |
+
thrice = relabel_published_frame(thrice)
|
| 273 |
+
if not np.allclose(quat_to_matrix(*thrice.rotation),
|
| 274 |
+
quat_to_matrix(*pose.rotation), atol=1e-12):
|
| 275 |
+
failures.append("重标三次应回到原姿态(M³ = I)")
|
| 276 |
+
if not np.allclose(thrice.position, pose.position, atol=1e-12):
|
| 277 |
+
failures.append("重标三次应回到原位置(M³ = I)")
|
| 278 |
|
| 279 |
+
# 7. host_mono_ns(取样帧宿主时刻)必须原样透传:三步变换都不动时基。
|
| 280 |
+
# 丢了它位姿就退回"只能按行号/相机钟对齐",而落盘侧正是靠它与
|
| 281 |
+
# hardware_ns 直接比较来配 slam 点↔视频帧。
|
| 282 |
stamped = PoseSample(position=(1.0, 2.0, 3.0),
|
| 283 |
rotation=random_unit_quaternion(rng), timestamp=1.0,
|
| 284 |
host_mono_ns=287607702508638)
|
| 285 |
if rotate_pose_z90(stamped).host_mono_ns != 287607702508638:
|
| 286 |
failures.append("rotate_pose_z90 丢了 host_mono_ns")
|
| 287 |
+
if relabel_published_frame(stamped).host_mono_ns != 287607702508638:
|
| 288 |
+
failures.append("relabel_published_frame 丢了 host_mono_ns")
|
| 289 |
+
if (finish_publish_pose(rotate_pose_z90(stamped), (0, 0, 0, 1))
|
| 290 |
+
.host_mono_ns != 287607702508638):
|
| 291 |
+
failures.append("finish_publish_pose 丢了 host_mono_ns")
|
| 292 |
# 无戳(旧 native 二进制)透传 None,不能被伪造成 0
|
| 293 |
if rotate_pose_z90(pose).host_mono_ns is not None:
|
| 294 |
failures.append("无戳位姿的 host_mono_ns 应为 None")
|
| 295 |
+
if relabel_published_frame(pose).host_mono_ns is not None:
|
| 296 |
+
failures.append("无戳位姿经重标后 host_mono_ns 应为 None")
|
| 297 |
+
if pose.timestamp != 1.0 or relabel_published_frame(pose).timestamp != 1.0:
|
| 298 |
+
failures.append("重标改动了 timestamp")
|
| 299 |
|
| 300 |
+
# 8. 轨迹形状不变(等距):两步都是旋转,相邻点距离一致
|
| 301 |
traj = [
|
| 302 |
PoseSample(position=tuple(float(v) for v in rng.uniform(-1, 1, 3)),
|
| 303 |
rotation=random_unit_quaternion(rng), timestamp=float(i))
|
|
|
|
| 305 |
]
|
| 306 |
for a, b in zip(traj, traj[1:]):
|
| 307 |
d1 = np.linalg.norm(np.array(a.position) - np.array(b.position))
|
| 308 |
+
ma, mb = relabel_published_frame(rotate_pose_z90(
|
| 309 |
+
a)), relabel_published_frame(rotate_pose_z90(b))
|
| 310 |
d2 = np.linalg.norm(np.array(ma.position) - np.array(mb.position))
|
| 311 |
if not np.isclose(d1, d2, atol=1e-12):
|
| 312 |
+
failures.append("轨迹相邻点距离被换帧改变")
|
| 313 |
|
| 314 |
+
# 8b. finish_publish_pose(p, None) == relabel_published_frame(p)
|
| 315 |
+
# (还没锚定的阶段走无基准分支,两条路径必须等价)
|
| 316 |
+
for i in range(50):
|
| 317 |
+
p = PoseSample(position=tuple(float(v) for v in rng.uniform(-1, 1, 3)),
|
| 318 |
+
rotation=random_unit_quaternion(rng), timestamp=1.0)
|
| 319 |
+
if not np.allclose(
|
| 320 |
+
finish_publish_pose(p, None).rotation,
|
| 321 |
+
relabel_published_frame(p).rotation, atol=1e-12):
|
| 322 |
+
failures.append("#{} 无基准分支与纯重标不等价".format(i))
|
| 323 |
+
break
|
| 324 |
+
|
| 325 |
+
# 9. 四元数辅助:共轭逆 = 单位矩阵;Hamilton 积 = 矩阵左乘
|
| 326 |
for i in range(200):
|
| 327 |
a = random_unit_quaternion(rng)
|
| 328 |
b = random_unit_quaternion(rng)
|
|
|
|
| 336 |
failures.append(f"#{i} (a⊗b) 矩阵 != A·B")
|
| 337 |
break
|
| 338 |
|
| 339 |
+
# 10. 原点相对化:首帧姿态 q0(含部署校正/启动朝向的任意常数
|
| 340 |
+
# 旋转),此后 q0⁻¹⊗q 应还原为相对原点的真实转动;再重标
|
| 341 |
+
# 之后发布姿态 = M·(相对转动)
|
| 342 |
for i in range(200):
|
| 343 |
q0r = random_unit_quaternion(rng)
|
| 344 |
q_rel = random_unit_quaternion(rng)
|
|
|
|
| 347 |
if not np.allclose(recovered, q_rel, atol=1e-12):
|
| 348 |
failures.append(f"#{i} 相对化未还原相对转动")
|
| 349 |
break
|
| 350 |
+
pub = finish_publish_pose(
|
| 351 |
+
PoseSample(position=(0.0, 0.0, 0.0), rotation=q, timestamp=1.0),
|
| 352 |
+
q0r)
|
| 353 |
+
if not np.allclose(quat_to_matrix(*pub.rotation),
|
| 354 |
+
M @ quat_to_matrix(*q_rel) @ M.T, atol=1e-12):
|
| 355 |
+
failures.append(f"#{i} 发布姿态应为 M·(相对转动)·Mᵀ")
|
| 356 |
break
|
| 357 |
|
| 358 |
if failures:
|
|
|
|
| 360 |
for f in failures[:5]:
|
| 361 |
print(" ", f)
|
| 362 |
return 1
|
| 363 |
+
print("PASS: 发布帧(+X下/+Y左/+Z前) 数学性质全部通过"
|
| 364 |
+
"(轴映射/物理轴复现/200 随机位姿 M·R_z90·R·Mᵀ/首帧=单位阵即"
|
| 365 |
+
"体轴与世界轴在原点重合/★体轴改名+顺序等价★/双应用与M³/"
|
| 366 |
+
"时基透传/轨迹等距/四元数积/原点相对化)")
|
| 367 |
return 0
|
| 368 |
|
| 369 |
|
|
@@ -21,14 +21,13 @@
|
|
| 21 |
3. 带 Δq 第二行的变体:首行仍解析出位姿(正则只吃首行)
|
| 22 |
4. Host 值畸形/缺失不污染其他字段
|
| 23 |
5. 宿主戳是 int 不是 float(纳秒精度不能被浮点吃掉)
|
| 24 |
-
6. 落盘链路的
|
| 25 |
-
都不丢戳
|
| 26 |
退出码 0 = 全部通过。
|
| 27 |
"""
|
| 28 |
|
| 29 |
from __future__ import annotations
|
| 30 |
|
| 31 |
-
import dataclasses
|
| 32 |
import os
|
| 33 |
import sys
|
| 34 |
|
|
@@ -37,7 +36,7 @@ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(
|
|
| 37 |
sys.path.insert(0, REPO_ROOT)
|
| 38 |
|
| 39 |
from core.gripper.slam.protocol import ( # noqa: E402
|
| 40 |
-
_parse_pose,
|
| 41 |
)
|
| 42 |
|
| 43 |
# 与 core/gripper/native/.../fayssense_orb_slam.cc 的 printf 逐字对应
|
|
@@ -114,22 +113,36 @@ def main() -> int:
|
|
| 114 |
f"pose={None if got is None else got.position} "
|
| 115 |
f"ns={None if got is None else got.host_mono_ns}")
|
| 116 |
|
| 117 |
-
print("[5] 落盘链路
|
| 118 |
-
# _publish_pose 实际做的是
|
| 119 |
-
# (原点后姿态相对化)。
|
| 120 |
rolled = rotate_pose_z90(pose)
|
| 121 |
check("rotate_pose_z90 透传", rolled.host_mono_ns == 287607702508638,
|
| 122 |
str(rolled.host_mono_ns))
|
| 123 |
zero = (0.7071051, 0.0, 0.0, -0.7071051)
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
print()
|
| 135 |
if _FAILS:
|
|
|
|
| 21 |
3. 带 Δq 第二行的变体:首行仍解析出位姿(正则只吃首行)
|
| 22 |
4. Host 值畸形/缺失不污染其他字段
|
| 23 |
5. 宿主戳是 int 不是 float(纳秒精度不能被浮点吃掉)
|
| 24 |
+
6. 落盘链路的每一步变换(rotate_pose_z90 → 原点后姿态相对化 →
|
| 25 |
+
发布帧重标,即 protocol.finish_publish_pose 的实参路径)都不丢戳
|
| 26 |
退出码 0 = 全部通过。
|
| 27 |
"""
|
| 28 |
|
| 29 |
from __future__ import annotations
|
| 30 |
|
|
|
|
| 31 |
import os
|
| 32 |
import sys
|
| 33 |
|
|
|
|
| 36 |
sys.path.insert(0, REPO_ROOT)
|
| 37 |
|
| 38 |
from core.gripper.slam.protocol import ( # noqa: E402
|
| 39 |
+
_parse_pose, finish_publish_pose, rotate_pose_z90,
|
| 40 |
)
|
| 41 |
|
| 42 |
# 与 core/gripper/native/.../fayssense_orb_slam.cc 的 printf 逐字对应
|
|
|
|
| 113 |
f"pose={None if got is None else got.position} "
|
| 114 |
f"ns={None if got is None else got.host_mono_ns}")
|
| 115 |
|
| 116 |
+
print("[5] 落盘链路各步不丢戳/不改戳(走 _publish_pose 的真实函数)")
|
| 117 |
+
# _publish_pose 实际做的是 rotate_pose_z90 → finish_publish_pose
|
| 118 |
+
# (= 原点后姿态相对化 → 发布帧重标)。每一步都必须原样带过宿主戳。
|
| 119 |
rolled = rotate_pose_z90(pose)
|
| 120 |
check("rotate_pose_z90 透传", rolled.host_mono_ns == 287607702508638,
|
| 121 |
str(rolled.host_mono_ns))
|
| 122 |
zero = (0.7071051, 0.0, 0.0, -0.7071051)
|
| 123 |
+
published = finish_publish_pose(rolled, zero)
|
| 124 |
+
check("相对化+重标后仍在", published.host_mono_ns == 287607702508638,
|
| 125 |
+
str(published.host_mono_ns))
|
| 126 |
+
# 位置**会**被这两步按设计改((−y,x,z) 再 (−z,−x,y)),不能改的是
|
| 127 |
+
# 时间戳:它是相机钟、要跟视频帧对齐用。
|
| 128 |
+
check("timestamp 未被这些步改动",
|
| 129 |
+
published.timestamp == rolled.timestamp == pose.timestamp,
|
| 130 |
+
str(published.timestamp))
|
| 131 |
+
check("位置按 中间帧→发布帧 的复合映射走",
|
| 132 |
+
rolled.position == (0.2, 0.1, 0.3)
|
| 133 |
+
and published.position == (-0.3, -0.2, 0.1),
|
| 134 |
+
f"rolled={rolled.position} published={published.position}")
|
| 135 |
+
# 回退路径(relabel=False)同样不能丢戳
|
| 136 |
+
legacy = finish_publish_pose(rolled, zero, relabel=False)
|
| 137 |
+
check("relabel=False 回退路径也不丢戳",
|
| 138 |
+
legacy.host_mono_ns == 287607702508638
|
| 139 |
+
and legacy.position == rolled.position,
|
| 140 |
+
str(legacy.host_mono_ns))
|
| 141 |
+
# 无基准分支(还没锚定)也不能丢戳
|
| 142 |
+
unanchored = finish_publish_pose(rolled, None)
|
| 143 |
+
check("无基准分支不丢戳",
|
| 144 |
+
unanchored.host_mono_ns == 287607702508638,
|
| 145 |
+
str(unanchored.host_mono_ns))
|
| 146 |
|
| 147 |
print()
|
| 148 |
if _FAILS:
|
|
@@ -23,6 +23,9 @@ slam_trajectory(变长、每点带真实时间戳)信息严格更多,故
|
|
| 23 |
7. pipeline 侧 write_slam_trajectory/_pop_gripper_snapshots:时刻与点
|
| 24 |
锁步、缺戳补 0 不跳过、弹出后清空、全程无戳(旧 native 二进制)
|
| 25 |
不建 _ns 列
|
|
|
|
|
|
|
|
|
|
| 26 |
退出码 0 = 全部通过。
|
| 27 |
"""
|
| 28 |
|
|
@@ -271,6 +274,65 @@ def main() -> int:
|
|
| 271 |
"slam_trajectory" in snap7b
|
| 272 |
and "slam_trajectory_ns" not in snap7b,
|
| 273 |
str(sorted(snap7b)))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
finally:
|
| 275 |
shutil.rmtree(tmp, ignore_errors=True)
|
| 276 |
|
|
|
|
| 23 |
7. pipeline 侧 write_slam_trajectory/_pop_gripper_snapshots:时刻与点
|
| 24 |
锁步、缺戳补 0 不跳过、弹出后清空、全程无戳(旧 native 二进制)
|
| 25 |
不建 _ns 列
|
| 26 |
+
8. 坐标帧标注(v1.3.13):features 的 frame/frame_axes、顶层
|
| 27 |
+
gripper_slam_frame;无 SLAM 的任务不写该键;KSQ_GRIPPER_SLAM_FRAME
|
| 28 |
+
回退/认不出时的解析行为
|
| 29 |
退出码 0 = 全部通过。
|
| 30 |
"""
|
| 31 |
|
|
|
|
| 274 |
"slam_trajectory" in snap7b
|
| 275 |
and "slam_trajectory_ns" not in snap7b,
|
| 276 |
str(sorted(snap7b)))
|
| 277 |
+
|
| 278 |
+
print("[8] 坐标帧标注(v1.3.13):没有它读端分不出新旧约定")
|
| 279 |
+
# 帧名只在 config/settings.py 里定义一处;这里不另抄一份字符串,
|
| 280 |
+
# 抄一份就等于拿常量跟自己比,settings 改错时测试照样绿。
|
| 281 |
+
from config import settings
|
| 282 |
+
feat = feats2
|
| 283 |
+
check("features 带 frame 且是已知帧 id",
|
| 284 |
+
feat.get("frame") in settings.GRIPPER_SLAM_FRAME_AXES,
|
| 285 |
+
repr(feat.get("frame")))
|
| 286 |
+
check("frame_axes 与 settings 的该帧一致",
|
| 287 |
+
feat.get("frame_axes")
|
| 288 |
+
== settings.GRIPPER_SLAM_FRAME_AXES[feat["frame"]],
|
| 289 |
+
str(feat.get("frame_axes")))
|
| 290 |
+
check("新帧三轴 = 下/左/前",
|
| 291 |
+
settings.GRIPPER_SLAM_FRAME_AXES[
|
| 292 |
+
settings.GRIPPER_SLAM_FRAME_NEW]
|
| 293 |
+
== {"+X": "down", "+Y": "left", "+Z": "forward"})
|
| 294 |
+
info8 = json.loads(open(os.path.join(tmp, "traj", "meta",
|
| 295 |
+
"info.json")).read())
|
| 296 |
+
check("顶层 gripper_slam_frame 与 features 同值",
|
| 297 |
+
info8.get("gripper_slam_frame") == feat["frame"],
|
| 298 |
+
repr(info8.get("gripper_slam_frame")))
|
| 299 |
+
check("rig2 列也带同一帧标注",
|
| 300 |
+
read_features(os.path.join(tmp, "dual"))[
|
| 301 |
+
"observation.gripper_2_slam_trajectory"].get("frame")
|
| 302 |
+
== feat["frame"])
|
| 303 |
+
check("无 SLAM 轨迹的任务不塞无关键",
|
| 304 |
+
"gripper_slam_frame" not in json.loads(open(os.path.join(
|
| 305 |
+
tmp, "nopose", "meta", "info.json")).read()),
|
| 306 |
+
"nopose 任务不该有 gripper_slam_frame")
|
| 307 |
+
|
| 308 |
+
print("[8b] 环境变量开关:回退/认不出各自的行为")
|
| 309 |
+
check("未设 ⇒ 新帧(默认)",
|
| 310 |
+
settings.resolve_gripper_slam_frame("")
|
| 311 |
+
== settings.GRIPPER_SLAM_FRAME_NEW)
|
| 312 |
+
check("旧约定名 ⇒ 旧帧",
|
| 313 |
+
settings.resolve_gripper_slam_frame("right_forward_up")
|
| 314 |
+
== settings.GRIPPER_SLAM_FRAME_LEGACY)
|
| 315 |
+
# 「不静默」得真验:一个认不出的取值必须在 stderr 留一行,否则
|
| 316 |
+
# 用户以为帧切换生效了、其实悄悄走的是默认值。
|
| 317 |
+
import contextlib
|
| 318 |
+
import io
|
| 319 |
+
err = io.StringIO()
|
| 320 |
+
with contextlib.redirect_stderr(err):
|
| 321 |
+
settings._SLAM_FRAME_WARNED.discard("nonsense") # 去掉一次性去重
|
| 322 |
+
got8 = settings.resolve_gripper_slam_frame("nonsense")
|
| 323 |
+
check("认不出的取值 ⇒ 出声后回落新帧(不静默)",
|
| 324 |
+
got8 == settings.GRIPPER_SLAM_FRAME_NEW
|
| 325 |
+
and "nonsense" in err.getvalue()
|
| 326 |
+
and settings.GRIPPER_SLAM_FRAME_ENV in err.getvalue(),
|
| 327 |
+
repr(err.getvalue()[:80]))
|
| 328 |
+
check("大小写/空白不敏感",
|
| 329 |
+
settings.resolve_gripper_slam_frame(" Right_Forward_Up ")
|
| 330 |
+
== settings.GRIPPER_SLAM_FRAME_LEGACY)
|
| 331 |
+
# 撤销重标 = 发布中间帧,两条路径必须都在 settings 里有一席之地
|
| 332 |
+
check("旧帧就是中间帧(relabel 关闭态)",
|
| 333 |
+
settings.GRIPPER_SLAM_FRAME_LEGACY == "right_forward_up"
|
| 334 |
+
and settings.GRIPPER_SLAM_FRAME_DEFAULT
|
| 335 |
+
== settings.GRIPPER_SLAM_FRAME_NEW)
|
| 336 |
finally:
|
| 337 |
shutil.rmtree(tmp, ignore_errors=True)
|
| 338 |
|
|
@@ -9,7 +9,13 @@ _draw_static 留在了 `if full_redraw:` 分支里,导致轨迹只在缩放/
|
|
| 9 |
1. 首次绘制(初始化 full_redraw)后静态层存在;
|
| 10 |
2. 快速路径重绘(无缩放/尺寸变化/新轨迹点)后静态层仍然存在;
|
| 11 |
3. 新轨迹点到来(超过节流间隔)触发缓存重算,轨迹点数增加。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
|
|
|
| 13 |
import os
|
| 14 |
import sys
|
| 15 |
import time
|
|
@@ -19,10 +25,24 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(
|
|
| 19 |
os.path.dirname(os.path.abspath(__file__)))))
|
| 20 |
|
| 21 |
import numpy as np
|
| 22 |
-
from PyQt5.
|
|
|
|
| 23 |
from PyQt5.QtWidgets import QApplication
|
| 24 |
|
| 25 |
-
from ui.pose_view_qt import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
# 静态层颜色:原点 #50d890(实心椭圆,保证有纯色像素)
|
| 28 |
ORIGIN_RGB = (0x50, 0xD8, 0x90)
|
|
@@ -79,6 +99,13 @@ def main():
|
|
| 79 |
|
| 80 |
check_decimation()
|
| 81 |
check_projection_equivalence()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
print("PASS: PoseViewQt 轨迹常驻回归测试通过 "
|
| 84 |
f"(origin 像素 {n_first}/{n_second}/{n_third})")
|
|
@@ -144,5 +171,706 @@ def check_projection_equivalence():
|
|
| 144 |
print(" 投影检查: _project_many 与逐点 _project 逐点一致")
|
| 145 |
|
| 146 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
if __name__ == "__main__":
|
| 148 |
main()
|
|
|
|
| 9 |
1. 首次绘制(初始化 full_redraw)后静态层存在;
|
| 10 |
2. 快速路径重绘(无缩放/尺寸变化/新轨迹点)后静态层仍然存在;
|
| 11 |
3. 新轨迹点到来(超过节流间隔)触发缓存重算,轨迹点数增加。
|
| 12 |
+
4. 换帧(v1.3.13:发布帧改为 +X下/+Y左/+Z前)在视图里**看得见**:投影不含
|
| 13 |
+
任何补偿,同一物理点换成新帧坐标就落在不同像素;世界轴标签按发布帧合成,
|
| 14 |
+
指向物理下/左/前。
|
| 15 |
+
5. 帧写在画面上(轴标签的方向词 + 标题下那行帧名),且两条设置下画面差异
|
| 16 |
+
**只**落在文字上——纯标注、不动几何。
|
| 17 |
"""
|
| 18 |
+
import math
|
| 19 |
import os
|
| 20 |
import sys
|
| 21 |
import time
|
|
|
|
| 25 |
os.path.dirname(os.path.abspath(__file__)))))
|
| 26 |
|
| 27 |
import numpy as np
|
| 28 |
+
from PyQt5.QtCore import QPointF, QRectF, Qt
|
| 29 |
+
from PyQt5.QtGui import QColor, QImage, QPainter
|
| 30 |
from PyQt5.QtWidgets import QApplication
|
| 31 |
|
| 32 |
+
from ui.pose_view_qt import (
|
| 33 |
+
BG, CAM_AXES, DISPLAY_TRAJECTORY_MAX_POINTS, PoseViewQt,
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _rgb_array(image: QImage) -> np.ndarray:
|
| 38 |
+
"""QImage → (h, w, 4) uint8 数组(BGRx 序,与 Qt 的 RGB32 一致)。"""
|
| 39 |
+
image = image.convertToFormat(QImage.Format_RGB32)
|
| 40 |
+
ptr = image.bits()
|
| 41 |
+
ptr.setsize(image.byteCount())
|
| 42 |
+
raw = np.frombuffer(ptr, np.uint8).reshape(
|
| 43 |
+
image.height(), image.bytesPerLine())
|
| 44 |
+
return raw[:, :image.width() * 4].reshape(
|
| 45 |
+
image.height(), image.width(), 4).copy()
|
| 46 |
|
| 47 |
# 静态层颜色:原点 #50d890(实心椭圆,保证有纯色像素)
|
| 48 |
ORIGIN_RGB = (0x50, 0xD8, 0x90)
|
|
|
|
| 99 |
|
| 100 |
check_decimation()
|
| 101 |
check_projection_equivalence()
|
| 102 |
+
check_frame_transform()
|
| 103 |
+
check_frame_legibility()
|
| 104 |
+
check_frame_line_not_clipped()
|
| 105 |
+
check_gripper_model_frame_invariant()
|
| 106 |
+
check_main_window_quat_to_matrix()
|
| 107 |
+
check_frame_words_drawn()
|
| 108 |
+
check_gripper_triad_directions()
|
| 109 |
|
| 110 |
print("PASS: PoseViewQt 轨迹常驻回归测试通过 "
|
| 111 |
f"(origin 像素 {n_first}/{n_second}/{n_third})")
|
|
|
|
| 171 |
print(" 投影检查: _project_many 与逐点 _project 逐点一致")
|
| 172 |
|
| 173 |
|
| 174 |
+
def _project_pre_frame(widget, point, span, cx, cy, unit):
|
| 175 |
+
"""v1.3.13 换帧之前那次投影:公式与现在逐字相同,喂的坐标不同。
|
| 176 |
+
|
| 177 |
+
留着它是为说清两件事:①现在 _project 里没有任何换帧换算(喂同一坐标
|
| 178 |
+
必须落同一像素);②同一条物理轨迹换成新帧坐标后像素确实变了 —— 也就是
|
| 179 |
+
换帧在主程序里**看得见**。
|
| 180 |
+
"""
|
| 181 |
+
x, y, z = (float(v) for v in np.asarray(point, dtype=float).reshape(3))
|
| 182 |
+
cos_a, sin_a = math.cos(widget._azimuth), math.sin(widget._azimuth)
|
| 183 |
+
cos_e, sin_e = math.cos(widget._elevation), math.sin(widget._elevation)
|
| 184 |
+
x1 = x * cos_a - y * sin_a
|
| 185 |
+
y1 = (x * sin_a + y * cos_a) * cos_e + z * sin_e
|
| 186 |
+
return QPointF(cx + (x1 + widget._pan_x) / span * unit,
|
| 187 |
+
cy - (y1 + widget._pan_y) / span * unit)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def check_frame_transform():
|
| 191 |
+
"""换帧在视图里看得见:gui 坐标系 == 轨迹系,投影不做任何补偿。
|
| 192 |
+
|
| 193 |
+
v1.3.13 第一版在这里左乘了一个 V = Mᵀ 想让"画面逐像素不变",结果 V·M = I
|
| 194 |
+
把换帧在视图里整个抵消掉,用户在主程序里看到的就是换帧前的画面,判"坐标系
|
| 195 |
+
还是没改"。所以现在的契约是反的:**同一物理点换成发布帧坐标后必须落在不同
|
| 196 |
+
像素**。第 1、2 条分别钉住"没补偿"和"看得见",第 3 条钉住标签的物理含义。
|
| 197 |
+
"""
|
| 198 |
+
from core.gripper.slam.protocol import (
|
| 199 |
+
PoseSample, relabel_published_frame)
|
| 200 |
+
import ui.pose_view_qt as pv
|
| 201 |
+
|
| 202 |
+
# M 的列 = 重标对三个中间帧单位向量的作用。**不另抄一份 M**:抄一份
|
| 203 |
+
# 就等于拿常量跟自己比,M 写错时测试照样绿。
|
| 204 |
+
M = np.stack([
|
| 205 |
+
relabel_published_frame(PoseSample(
|
| 206 |
+
position=tuple(float(v) for v in axis), rotation=(0, 0, 0, 1),
|
| 207 |
+
timestamp=0.0)).position
|
| 208 |
+
for axis in np.eye(3)], axis=1)
|
| 209 |
+
assert not hasattr(pv, "WORLD_TO_VIEW"), (
|
| 210 |
+
"视图里又出现了 WORLD_TO_VIEW:它会把换帧在视图内抵消掉,"
|
| 211 |
+
"主程序就看不见坐标系改过了")
|
| 212 |
+
|
| 213 |
+
widget = PoseViewQt()
|
| 214 |
+
widget._azimuth, widget._elevation = 0.62, -0.31
|
| 215 |
+
widget._pan_x, widget._pan_y = 0.03, -0.017
|
| 216 |
+
span, cx, cy, unit = 0.5, 340.0, 210.0, 200.0
|
| 217 |
+
|
| 218 |
+
def same_pixel(now, old, why):
|
| 219 |
+
assert abs(now.x() - old.x()) < 1e-9 and \
|
| 220 |
+
abs(now.y() - old.y()) < 1e-9, why
|
| 221 |
+
|
| 222 |
+
# 1. 投影 = 换帧老公式(老公式就是没有 V 的那版):喂什么坐标画什么像素,
|
| 223 |
+
# 视图对发布帧不做任何额外换算。
|
| 224 |
+
rng = np.random.default_rng(7)
|
| 225 |
+
for p in rng.uniform(-1.0, 1.0, (200, 3)):
|
| 226 |
+
same_pixel(widget._project(p, span, cx, cy, unit),
|
| 227 |
+
_project_pre_frame(widget, p, span, cx, cy, unit),
|
| 228 |
+
f"投影里多了换帧换算(画面又被抵消)@{p}")
|
| 229 |
+
|
| 230 |
+
# 2. 看得见:同一个物理动作(夹爪往下走 0.4m)在中间帧坐标是 (0,0,-0.4)、
|
| 231 |
+
# 在发布帧坐标是 M·(0,0,-0.4) = (0.4,0,0)=+X 下。老代码画前者、新代码
|
| 232 |
+
# 画后者,两者必须落在明显不同的像素上——一旦有人把 V=Mᵀ 加回来,
|
| 233 |
+
# 这两点会重合,本条立刻红。
|
| 234 |
+
down_mid = np.array([0.0, 0.0, -0.4])
|
| 235 |
+
down_pub = M @ down_mid
|
| 236 |
+
assert np.allclose(down_pub, [0.4, 0.0, 0.0], atol=1e-12), (
|
| 237 |
+
f"物理下在发布帧应是 +X,实得 {down_pub}")
|
| 238 |
+
moved = widget._project(down_pub, span, cx, cy, unit)
|
| 239 |
+
was = _project_pre_frame(widget, down_mid, span, cx, cy, unit)
|
| 240 |
+
delta = math.hypot(moved.x() - was.x(), moved.y() - was.y())
|
| 241 |
+
assert delta > 20.0, (
|
| 242 |
+
f"换帧没改变画面(同一下移只挪了 {delta:.1f}px):视图把换帧抵消掉了")
|
| 243 |
+
|
| 244 |
+
# 3. 世界轴标签按发布帧合成(tip[i] = e_i),含义与 protocol 的物理映射
|
| 245 |
+
# 一致:X 箭头 = 物理下、Y = 左、Z = 前。
|
| 246 |
+
assert [label for _, label in pv.WORLD_AXES] == ["X", "Y", "Z"], (
|
| 247 |
+
f"世界轴标签顺序变了:{pv.WORLD_AXES}")
|
| 248 |
+
physical = {"X": (0.0, 0.0, -1.0), # 下
|
| 249 |
+
"Y": (-1.0, 0.0, 0.0), # 左
|
| 250 |
+
"Z": (0.0, 1.0, 0.0)} # 前
|
| 251 |
+
for index, axis in enumerate(np.eye(3)):
|
| 252 |
+
# 标签画在发布帧 +e_index 的轴上;这个物理方向(中间帧坐标)经
|
| 253 |
+
# protocol 重标后必须正好落在 +e_index,否则屏幕上的 X 指的就不是下。
|
| 254 |
+
name = "XYZ"[index]
|
| 255 |
+
back = relabel_published_frame(PoseSample(
|
| 256 |
+
position=physical[name], rotation=(0, 0, 0, 1),
|
| 257 |
+
timestamp=0.0)).position
|
| 258 |
+
assert np.allclose(back, axis, atol=1e-12), (
|
| 259 |
+
f"世界 {name} 轴标签不等于物理 {name}(重标后 {back})")
|
| 260 |
+
print(" 坐标帧检查: 投影无补偿、同一下移在画面上挪 "
|
| 261 |
+
f"{delta:.0f}px(换帧看得见);X/Y/Z 标签 = 物理下/左/前")
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def check_frame_legibility():
|
| 265 |
+
"""帧必须**写在画面上**,而且只动文字、不动几何。
|
| 266 |
+
|
| 267 |
+
世界轴三箭头的屏幕位置换帧前后逐像素相同(tip 是常数 e_i,投影公式不吃
|
| 268 |
+
数据),所以这一屏能承载"画的是哪一帧"的只有文字:轴标签后的方向词
|
| 269 |
+
(X 下 / Y 左 / Z 前)、标题下那行 `坐标帧 ...`,以及**跟随夹爪那三支
|
| 270 |
+
箭头的标签**(体轴跟世界轴共用一张词表,换帧时一起变)。v1.3.13 第一版
|
| 271 |
+
正是把整个变化在视图里补偿掉了,用户看到的还是旧画面、判"坐标系还是没改"
|
| 272 |
+
(见 ui/pose_view_qt.py 文件头的教训),所以这条测试钉的是"看得见"本身。
|
| 273 |
+
|
| 274 |
+
两条设置下的差异必须**全部**落在文字区(标题两行 + 六个轴标签周围):
|
| 275 |
+
这同时钉住了"纯标注"——有人以后再往几何里加补偿,这里立刻红。
|
| 276 |
+
"""
|
| 277 |
+
from config import settings
|
| 278 |
+
import ui.pose_view_qt as pv
|
| 279 |
+
|
| 280 |
+
app = QApplication.instance() or QApplication([])
|
| 281 |
+
env_key = settings.GRIPPER_SLAM_FRAME_ENV
|
| 282 |
+
saved_env = os.environ.get(env_key)
|
| 283 |
+
span = 0.6174 # 钉死 span:_update_fit_span 有 0.3s 壁钟缓动,
|
| 284 |
+
# 不钉的话两张图会落在不同缩放上,量到的差异不是标注引起的
|
| 285 |
+
traj = tuple(map(tuple, make_path(40)))
|
| 286 |
+
expected = {
|
| 287 |
+
None: (settings.GRIPPER_SLAM_FRAME_NEW,
|
| 288 |
+
{"X": "下", "Y": "左", "Z": "前"}),
|
| 289 |
+
settings.GRIPPER_SLAM_FRAME_LEGACY:
|
| 290 |
+
(settings.GRIPPER_SLAM_FRAME_LEGACY,
|
| 291 |
+
{"X": "右", "Y": "正对", "Z": "上"}),
|
| 292 |
+
}
|
| 293 |
+
shots = {}
|
| 294 |
+
try:
|
| 295 |
+
for frame in expected:
|
| 296 |
+
if frame is None:
|
| 297 |
+
os.environ.pop(env_key, None)
|
| 298 |
+
else:
|
| 299 |
+
os.environ[env_key] = frame
|
| 300 |
+
name, words = expected[frame]
|
| 301 |
+
widget = PoseViewQt()
|
| 302 |
+
widget.resize(680, 420)
|
| 303 |
+
widget.show()
|
| 304 |
+
widget.set_grip_state(50.0, False)
|
| 305 |
+
widget.update_pose(tuple(traj[-1]), np.eye(3), traj)
|
| 306 |
+
widget._span_target = None
|
| 307 |
+
widget._fit_span = span
|
| 308 |
+
widget._cached_span = span
|
| 309 |
+
widget._trajectory_dirty = True
|
| 310 |
+
app.processEvents()
|
| 311 |
+
assert widget._frame_name == name, (
|
| 312 |
+
f"视图认的帧成了 {widget._frame_name},settings 解析出的是 {name}")
|
| 313 |
+
assert widget._frame_words == words, (
|
| 314 |
+
f"轴方向词 {widget._frame_words} != {words}(帧={name})")
|
| 315 |
+
assert widget._frame_text == " ".join(
|
| 316 |
+
f"+{a}{words[a]}" for a in "XYZ"), widget._frame_text
|
| 317 |
+
# 完整帧名(down_left_forward)太长,塞标题会顶出框,放在 tooltip:
|
| 318 |
+
# 想核对 info.json 的 frame 字段的人悬停就能拿到。
|
| 319 |
+
assert name in widget.toolTip(), widget.toolTip()
|
| 320 |
+
img = widget.grab().toImage().convertToFormat(QImage.Format_RGB32)
|
| 321 |
+
ptr = img.bits()
|
| 322 |
+
ptr.setsize(img.byteCount())
|
| 323 |
+
raw = np.frombuffer(ptr, np.uint8).reshape(
|
| 324 |
+
img.height(), img.bytesPerLine())
|
| 325 |
+
shots[frame] = (
|
| 326 |
+
widget,
|
| 327 |
+
raw[:, :img.width() * 4].reshape(
|
| 328 |
+
img.height(), img.width(), 4).copy())
|
| 329 |
+
finally:
|
| 330 |
+
if saved_env is None:
|
| 331 |
+
os.environ.pop(env_key, None)
|
| 332 |
+
else:
|
| 333 |
+
os.environ[env_key] = saved_env
|
| 334 |
+
|
| 335 |
+
new_widget, new_img = shots[None]
|
| 336 |
+
_, legacy_img = shots[settings.GRIPPER_SLAM_FRAME_LEGACY]
|
| 337 |
+
diff = np.any(new_img != legacy_img, axis=2)
|
| 338 |
+
assert diff.sum() > 100, (
|
| 339 |
+
f"换帧/回落旧约定在画面上只差 {diff.sum()}px —— 帧标注没画出来"
|
| 340 |
+
"(或又被补偿掉了)")
|
| 341 |
+
|
| 342 |
+
# 允许差异的区域:标题两行、三个世界轴标签、以及**跟随夹爪那三支箭头
|
| 343 |
+
# 的标签**周围 40px。
|
| 344 |
+
#
|
| 345 |
+
# ⚠ 老实说这条比它自称的弱:40px 的框同时盖住了夹爪**模型**。本用例给
|
| 346 |
+
# 两种帧喂的是同一个 np.eye(3),可是 _body_relabel 按帧取值(新帧 M、
|
| 347 |
+
# 旧帧 I),于是模型那一块像素并不相同(实测 1339px,全落在这些框里)——
|
| 348 |
+
# 也就是说"差异 100% 落在文字区"并不是"只动了文字",其中夹爪模型那部分
|
| 349 |
+
# 是**同一个合成姿态在两种换基下**画出来的、跨帧本就不同,属合理差异,
|
| 350 |
+
# 恰好被框吞掉了。所以:
|
| 351 |
+
# * 真正钉住"标签上有方向词"的是 check_frame_words_drawn(对词做变异);
|
| 352 |
+
# * 真正钉住"模型逐像素不变"的是 check_gripper_model_frame_invariant;
|
| 353 |
+
# * 这条只负责"跨帧差异没有散落到别处去"(几何大动会把它撞红)。
|
| 354 |
+
height, width = diff.shape
|
| 355 |
+
text_zone = np.zeros_like(diff)
|
| 356 |
+
text_zone[:50, :] = True
|
| 357 |
+
cx, cy = width / 2.0, height / 2.0
|
| 358 |
+
unit = min(width, height) / 2.0 * 0.78
|
| 359 |
+
label_points = []
|
| 360 |
+
for index in range(3):
|
| 361 |
+
tip = np.zeros(3)
|
| 362 |
+
tip[index] = span * 0.3
|
| 363 |
+
label_points.append(new_widget._project(tip * 1.3, span, cx, cy, unit))
|
| 364 |
+
# 夹爪三箭头标签的位置照抄 _draw_frame_axes 的算法(tip ×1.15)
|
| 365 |
+
origin = new_widget._position
|
| 366 |
+
length = span * 0.15
|
| 367 |
+
for index in range(3):
|
| 368 |
+
direction = new_widget._rotation[:, index]
|
| 369 |
+
label_points.append(new_widget._project(
|
| 370 |
+
origin + direction * (length * 1.15), span, cx, cy, unit))
|
| 371 |
+
for point in label_points:
|
| 372 |
+
y0, y1 = int(point.y()) - 40, int(point.y()) + 41
|
| 373 |
+
x0, x1 = int(point.x()) - 40, int(point.x()) + 41
|
| 374 |
+
text_zone[max(0, y0):max(0, y1), max(0, x0):max(0, x1)] = True
|
| 375 |
+
outside = int((diff & ~text_zone).sum())
|
| 376 |
+
assert outside == 0, (
|
| 377 |
+
f"轴方向词/帧名改了 {outside}px 文字区之外的像素 —— 标注不是纯标注,"
|
| 378 |
+
"几何被动了(补偿式的实现正是这么开始的)")
|
| 379 |
+
# 而且轴标签那一处**自己**也得变(不能只靠标题行那行字)。
|
| 380 |
+
# ⚠ 这句断言和它的说明词对不上:它并不区分"标签变了"与"夹爪模型变了",
|
| 381 |
+
# 而后者恒真(见上面那段说明),所以哪怕把两处标签的方向词全删掉它也绿
|
| 382 |
+
# (变异实测)。这里只留"世界轴标签框内确有变化"这个弱结论,强结论交给
|
| 383 |
+
# check_frame_words_drawn —— 那边是对词本身做变异,删词必红。
|
| 384 |
+
axis_zone = text_zone.copy()
|
| 385 |
+
axis_zone[:50, :] = False
|
| 386 |
+
world_label_zone = np.zeros_like(diff)
|
| 387 |
+
for point in label_points[:3]:
|
| 388 |
+
y0, y1 = int(point.y()) - 40, int(point.y()) + 41
|
| 389 |
+
x0, x1 = int(point.x()) - 40, int(point.x()) + 41
|
| 390 |
+
world_label_zone[max(0, y0):max(0, y1), max(0, x0):max(0, x1)] = True
|
| 391 |
+
assert (diff & world_label_zone).sum() > 20, (
|
| 392 |
+
"三条世界轴标签的位置上换帧前后没有任何变化(只有标题行变了)")
|
| 393 |
+
print(f" 帧标注检查: 新帧 {expected[None][0]} 轴词 {expected[None][1]};"
|
| 394 |
+
f"旧约定 {expected[settings.GRIPPER_SLAM_FRAME_LEGACY][0]} 轴词 "
|
| 395 |
+
f"{expected[settings.GRIPPER_SLAM_FRAME_LEGACY][1]};"
|
| 396 |
+
f"两者画面差 {int(diff.sum())}px 且 100% 落在文字上")
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def _render_gripper_layer(widget, rotation, body_relabel=None,
|
| 400 |
+
size=(680, 420), span=0.6174):
|
| 401 |
+
"""只画夹爪模型那一层,返回 (h, w, 4) 像素数组。
|
| 402 |
+
|
| 403 |
+
不画世界轴/轨迹/标签:那几个换帧时本来就该变,混进来就分不清"模型
|
| 404 |
+
动了"还是"标注动了"。
|
| 405 |
+
|
| 406 |
+
body_relabel=None ⇒ 用 widget 自己那份(= 走真正的接线,能抓到
|
| 407 |
+
"视图忘了按帧取换基矩阵"这种漏;显式传值只���来构造对照物)。
|
| 408 |
+
"""
|
| 409 |
+
width, height = size
|
| 410 |
+
cx, cy = width / 2.0, height / 2.0
|
| 411 |
+
unit = min(width, height) / 2.0 * 0.78
|
| 412 |
+
widget._rotation = np.asarray(rotation, dtype=float)
|
| 413 |
+
if body_relabel is not None:
|
| 414 |
+
widget._body_relabel = np.asarray(body_relabel, dtype=float)
|
| 415 |
+
widget.set_grip_state(50.0, False)
|
| 416 |
+
image = QImage(width, height, QImage.Format_RGB32)
|
| 417 |
+
image.fill(QColor("#0c1a2b"))
|
| 418 |
+
painter = QPainter(image)
|
| 419 |
+
try:
|
| 420 |
+
widget._draw_gripper(painter, cx, cy, unit, span)
|
| 421 |
+
finally:
|
| 422 |
+
painter.end()
|
| 423 |
+
ptr = image.bits()
|
| 424 |
+
ptr.setsize(image.byteCount())
|
| 425 |
+
raw = np.frombuffer(ptr, np.uint8).reshape(height, image.bytesPerLine())
|
| 426 |
+
return raw[:, :width * 4].reshape(height, width, 4).copy()
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def _quat_to_matrix(qx, qy, qz, qw):
|
| 430 |
+
"""标准 (x,y,z,w) → 3×3,供上面那条用例当独立参照。"""
|
| 431 |
+
return np.array([
|
| 432 |
+
[1 - 2 * (qy * qy + qz * qz), 2 * (qx * qy - qz * qw),
|
| 433 |
+
2 * (qx * qz + qy * qw)],
|
| 434 |
+
[2 * (qx * qy + qz * qw), 1 - 2 * (qx * qx + qz * qz),
|
| 435 |
+
2 * (qy * qz - qx * qw)],
|
| 436 |
+
[2 * (qx * qz - qy * qw), 2 * (qy * qz + qx * qw),
|
| 437 |
+
1 - 2 * (qx * qx + qy * qy)],
|
| 438 |
+
])
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def _render_axes_layer(widget, rotation, size=(680, 420), span=0.6174):
|
| 442 |
+
"""只画「跟随夹爪那三支箭头」那一层,返回 (h, w, 4) 像素数组。
|
| 443 |
+
|
| 444 |
+
与 _render_gripper_layer 同理:把世界轴/轨迹/标题都排除掉,剩下的像素
|
| 445 |
+
才只跟这一个函数有关。
|
| 446 |
+
"""
|
| 447 |
+
width, height = size
|
| 448 |
+
cx, cy = width / 2.0, height / 2.0
|
| 449 |
+
unit = min(width, height) / 2.0 * 0.78
|
| 450 |
+
widget._rotation = np.asarray(rotation, dtype=float)
|
| 451 |
+
image = QImage(width, height, QImage.Format_RGB32)
|
| 452 |
+
image.fill(QColor("#0c1a2b"))
|
| 453 |
+
painter = QPainter(image)
|
| 454 |
+
try:
|
| 455 |
+
widget._draw_frame_axes(painter, cx, cy, unit, span)
|
| 456 |
+
finally:
|
| 457 |
+
painter.end()
|
| 458 |
+
ptr = image.bits()
|
| 459 |
+
ptr.setsize(image.byteCount())
|
| 460 |
+
raw = np.frombuffer(ptr, np.uint8).reshape(height, image.bytesPerLine())
|
| 461 |
+
return raw[:, :width * 4].reshape(height, width, 4).copy()
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
# 词清空 -> 两次渲染必须不同。判据与 _draw_frame_axes 的落笔位置绑定。
|
| 465 |
+
def _label_anchors(widget, span, size=(680, 420)):
|
| 466 |
+
"""六个轴标签的投影锚点:前三个是世界轴,后三个是夹爪轴。"""
|
| 467 |
+
width, height = size
|
| 468 |
+
cx, cy = width / 2.0, height / 2.0
|
| 469 |
+
unit = min(width, height) / 2.0 * 0.78
|
| 470 |
+
points = []
|
| 471 |
+
for index in range(3):
|
| 472 |
+
tip = np.zeros(3)
|
| 473 |
+
tip[index] = span * 0.3
|
| 474 |
+
points.append(widget._project(tip * 1.3, span, cx, cy, unit))
|
| 475 |
+
origin = widget._position
|
| 476 |
+
length = span * 0.15
|
| 477 |
+
for index in range(3):
|
| 478 |
+
points.append(widget._project(
|
| 479 |
+
origin + widget._rotation[:, index] * (length * 1.15),
|
| 480 |
+
span, cx, cy, unit))
|
| 481 |
+
return points
|
| 482 |
+
|
| 483 |
+
|
| 484 |
+
def check_frame_words_drawn():
|
| 485 |
+
"""六个轴标签(3 世界 + 3 夹爪)都必须**真的把方向词画出来**。
|
| 486 |
+
|
| 487 |
+
★ 这条替换掉了原来那句 `axis_zone` 断言("三条世界轴标签的方向词没有随帧
|
| 488 |
+
变化")—— 它是假的:把两处标签词**一起**删掉,它照样通过。满足它的像素
|
| 489 |
+
其实是**夹爪模型**的跨帧差异(新帧 _body_relabel=M、旧帧=I,同一个单位姿态
|
| 490 |
+
画出来并不相同),不是任何标签。变异测试逮到的就是这个。
|
| 491 |
+
|
| 492 |
+
这里改成对词本身做变异:把 widget._frame_words 清空(其它一字不动,标题那行
|
| 493 |
+
用的是构造时定好的 _frame_text,不吃这张表),两次渲染必须不同,且差异要
|
| 494 |
+
分别落在世界轴标签与夹爪轴标签的位置上 —— 任何一处不再把词写上去,立刻红。
|
| 495 |
+
"""
|
| 496 |
+
app = QApplication.instance() or QApplication([])
|
| 497 |
+
span = 0.6174
|
| 498 |
+
widget = PoseViewQt()
|
| 499 |
+
widget.resize(680, 420)
|
| 500 |
+
widget.show()
|
| 501 |
+
widget.set_grip_state(50.0, False)
|
| 502 |
+
widget.update_pose((0.0, 0.0, 0.0), np.eye(3), ())
|
| 503 |
+
app.processEvents()
|
| 504 |
+
|
| 505 |
+
anchors = _label_anchors(widget, span)
|
| 506 |
+
zone = np.zeros((420, 680), dtype=bool)
|
| 507 |
+
for point in anchors:
|
| 508 |
+
y0, y1 = int(point.y()) - 45, int(point.y()) + 46
|
| 509 |
+
x0, x1 = int(point.x()) - 45, int(point.x()) + 46
|
| 510 |
+
zone[max(0, y0):max(0, y1), max(0, x0):max(0, x1)] = True
|
| 511 |
+
|
| 512 |
+
real_words = dict(widget._frame_words)
|
| 513 |
+
assert all(real_words.values()), (
|
| 514 |
+
f"词表本身是空的({real_words})—— 本用例的前提就不成立")
|
| 515 |
+
|
| 516 |
+
def render_static():
|
| 517 |
+
canvas = QImage(680, 420, QImage.Format_RGB32)
|
| 518 |
+
canvas.fill(QColor(BG))
|
| 519 |
+
painter = QPainter(canvas)
|
| 520 |
+
widget._draw_static(painter, 680, 420, 340.0, 210.0,
|
| 521 |
+
210.0 * 0.78, span)
|
| 522 |
+
painter.end()
|
| 523 |
+
return _rgb_array(canvas)
|
| 524 |
+
|
| 525 |
+
def render_axes():
|
| 526 |
+
return _render_axes_layer(widget, np.eye(3))
|
| 527 |
+
|
| 528 |
+
try:
|
| 529 |
+
for site, render in (("世界轴", render_static),
|
| 530 |
+
("夹爪轴", render_axes)):
|
| 531 |
+
with_words = render()
|
| 532 |
+
widget._frame_words = {axis: "" for axis in "XYZ"}
|
| 533 |
+
without_words = render()
|
| 534 |
+
widget._frame_words = dict(real_words)
|
| 535 |
+
diff = np.any(with_words[:, :, :3] != without_words[:, :, :3],
|
| 536 |
+
axis=2)
|
| 537 |
+
assert diff.sum() > 20, (
|
| 538 |
+
f"{site}标签删掉方向词后画面一字未变 —— 方向词根本没画出来"
|
| 539 |
+
f"(差 {int(diff.sum())}px)")
|
| 540 |
+
inside = int((diff & zone).sum())
|
| 541 |
+
assert inside >= diff.sum() * 0.5, (
|
| 542 |
+
f"{site}标签的方向词变了,但只有 {inside}/{int(diff.sum())}px "
|
| 543 |
+
f"落在标签位置上 —— 改动不像只动了那几个字")
|
| 544 |
+
finally:
|
| 545 |
+
widget._frame_words = dict(real_words)
|
| 546 |
+
print(f" 轴标签词检查: 世界轴/夹爪轴两处标签都把方向词画在标签框内"
|
| 547 |
+
f"(删词立刻可见,{len(anchors)} 个标签位)")
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def check_gripper_triad_directions():
|
| 551 |
+
"""跟随夹爪的三支箭头必须取发布姿态的**列**(= 体轴),不是行。
|
| 552 |
+
|
| 553 |
+
★ 变异测试逼出来的:原来所有用例喂给 _draw_frame_axes 的姿态都是
|
| 554 |
+
np.eye(3),而单位阵的行与列**恰好相等**(rotation[i,:] == rotation[:,i]),
|
| 555 |
+
于是把 directions 写成 rotation[0,:]/[1,:]/[2,:] 这种错法整套测试都过
|
| 556 |
+
(实测 exit 0)—— 夹爪体轴的方向根本没有回归保护。
|
| 557 |
+
|
| 558 |
+
这里喂一个行列明显不同的姿态(绕 (1,2,3) 转 55°),再按颜色重心判箭头
|
| 559 |
+
指向哪一边。列错成行时重心会整体跑到另一边,两条断言同时红。
|
| 560 |
+
"""
|
| 561 |
+
app = QApplication.instance() or QApplication([])
|
| 562 |
+
span = 0.6174
|
| 563 |
+
axis = np.array([1.0, 2.0, 3.0])
|
| 564 |
+
axis = axis / np.linalg.norm(axis)
|
| 565 |
+
angle = math.radians(55.0)
|
| 566 |
+
cross = np.array([[0.0, -axis[2], axis[1]],
|
| 567 |
+
[axis[2], 0.0, -axis[0]],
|
| 568 |
+
[-axis[1], axis[0], 0.0]])
|
| 569 |
+
rotation = (np.eye(3) + math.sin(angle) * cross
|
| 570 |
+
+ (1.0 - math.cos(angle)) * (cross @ cross))
|
| 571 |
+
assert not np.allclose(rotation, rotation.T, atol=1e-6), (
|
| 572 |
+
"本用例需要一个行列不同的姿态,否则判不出列/行")
|
| 573 |
+
|
| 574 |
+
widget = PoseViewQt()
|
| 575 |
+
widget.resize(680, 420)
|
| 576 |
+
widget.show()
|
| 577 |
+
widget.update_pose((0.12, -0.08, 0.05), rotation, ())
|
| 578 |
+
app.processEvents()
|
| 579 |
+
|
| 580 |
+
layer = _render_axes_layer(widget, rotation)
|
| 581 |
+
origin = widget._position
|
| 582 |
+
length = span * 0.15
|
| 583 |
+
cx, cy = 340.0, 210.0
|
| 584 |
+
unit = 210.0 * 0.78
|
| 585 |
+
for index, (color, label) in enumerate(CAM_AXES):
|
| 586 |
+
rgb = color
|
| 587 |
+
mask = ((layer[:, :, 2] == rgb.red()) & (layer[:, :, 1] == rgb.green())
|
| 588 |
+
& (layer[:, :, 0] == rgb.blue()))
|
| 589 |
+
assert mask.sum() > 10, (
|
| 590 |
+
f"{label} 轴在夹爪层里找不到纯色像素({int(mask.sum())})"
|
| 591 |
+
"—— 三支箭头没画出来")
|
| 592 |
+
rows, cols = np.nonzero(mask)
|
| 593 |
+
centroid = np.array([cols.mean(), rows.mean()])
|
| 594 |
+
|
| 595 |
+
def projected(vector):
|
| 596 |
+
point = widget._project(origin + vector * length,
|
| 597 |
+
span, cx, cy, unit)
|
| 598 |
+
return np.array([point.x(), point.y()])
|
| 599 |
+
|
| 600 |
+
column = projected(rotation[:, index])
|
| 601 |
+
row_dir = projected(rotation[index, :])
|
| 602 |
+
separation = np.linalg.norm(column - row_dir)
|
| 603 |
+
assert separation > 12.0, (
|
| 604 |
+
f"{label} 轴:行与列在这个姿态下投影只差 {separation:.1f}px,"
|
| 605 |
+
"判不出列/行 —— 换个姿态")
|
| 606 |
+
d_column = np.linalg.norm(centroid - column)
|
| 607 |
+
d_row = np.linalg.norm(centroid - row_dir)
|
| 608 |
+
assert d_column * 2.0 < d_row, (
|
| 609 |
+
f"{label} 轴箭头指向了**行**({rotation[index, :]})而不是列"
|
| 610 |
+
f"({rotation[:, index]}):重心到列向 {d_column:.1f}px、到行向 "
|
| 611 |
+
f"{d_row:.1f}px")
|
| 612 |
+
print(" 夹爪体轴方向检查: 三支箭头都取发布姿态的列(体轴),"
|
| 613 |
+
"非对称姿态下行/列可分")
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
def check_main_window_quat_to_matrix():
|
| 617 |
+
"""从**发布四元数**到视图所画矩阵的那一跳,必须端到端钉住。
|
| 618 |
+
|
| 619 |
+
★ 这条补的是全链路唯一没测过的一环:发布出去的是四元数(落盘、
|
| 620 |
+
orb_pose.json、bridge 信号都是它),而视图吃的是 3×3 —— 中间那次
|
| 621 |
+
转换写在 ui/main_window.py 的 _on_gripper_pose 里(内联字面量),
|
| 622 |
+
没有任何用例调过它(全 tools/ 里 grep 不到 _on_gripper_pose)。
|
| 623 |
+
四元数约定写错((w,x,y,z) 当 (x,y,z,w)、或转置)在这里的后果是
|
| 624 |
+
夹爪整体转错,而协议侧所有测试都只比四元数,**全都看不见**。
|
| 625 |
+
|
| 626 |
+
做法:拿真实协议函数算出"原点那一瞬"的发布位姿(四元数),用一个
|
| 627 |
+
最小桩喂进真的 MainWindow._on_gripper_pose,再看视图里那份矩阵。
|
| 628 |
+
顺带断言落盘的 7 元组与喂进视图的是同一份数(同源)。
|
| 629 |
+
"""
|
| 630 |
+
app = QApplication.instance() or QApplication([])
|
| 631 |
+
from ui.main_window import MainWindow
|
| 632 |
+
|
| 633 |
+
from core.gripper.slam.protocol import (
|
| 634 |
+
PoseSample, finish_publish_pose, rotate_pose_z90,
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
# 真实链路:native → 中间帧 → 相对化(锚点即首帧)→ 重标
|
| 638 |
+
native = PoseSample(position=(0.1, 0.2, 0.3),
|
| 639 |
+
rotation=(0.0, 0.0, 0.0, 1.0), timestamp=1.0)
|
| 640 |
+
mid = rotate_pose_z90(native)
|
| 641 |
+
published = finish_publish_pose(mid, mid.rotation, relabel=True)
|
| 642 |
+
assert np.allclose(published.rotation, (0.0, 0.0, 0.0, 1.0), atol=1e-12), (
|
| 643 |
+
f"协议算出的锚点发布姿态应是单位四元数,实得 {published.rotation}")
|
| 644 |
+
|
| 645 |
+
widget = PoseViewQt()
|
| 646 |
+
widget.resize(680, 420)
|
| 647 |
+
widget.show()
|
| 648 |
+
app.processEvents()
|
| 649 |
+
|
| 650 |
+
written = []
|
| 651 |
+
|
| 652 |
+
class Pipeline:
|
| 653 |
+
def write_slam_trajectory(self, row, prefix=None, timestamp=None,
|
| 654 |
+
host_ns=None):
|
| 655 |
+
written.append((tuple(row), prefix, timestamp, host_ns))
|
| 656 |
+
|
| 657 |
+
class Window:
|
| 658 |
+
_pipeline = Pipeline()
|
| 659 |
+
|
| 660 |
+
def _gripper_entry(self, key):
|
| 661 |
+
return {"pose_view": widget, "snapshot_prefix": "gripper_1_"}
|
| 662 |
+
|
| 663 |
+
host_ns = 123456789
|
| 664 |
+
MainWindow._on_gripper_pose(Window(), "k", published.position,
|
| 665 |
+
published.rotation, (),
|
| 666 |
+
published.timestamp, host_ns)
|
| 667 |
+
|
| 668 |
+
got = np.asarray(widget._rotation, dtype=float)
|
| 669 |
+
assert np.allclose(got, np.eye(3), atol=1e-9), (
|
| 670 |
+
f"锚点发布姿态送进视图后应画成单位矩阵(体轴与世界轴重合),实得\n{got}"
|
| 671 |
+
" —— _on_gripper_pose 里那次四元数→矩阵的转换把帧弄丢了")
|
| 672 |
+
# 三支体轴箭头逐轴的世界方向(列)必须就是世界轴方向
|
| 673 |
+
for index in range(3):
|
| 674 |
+
want = np.eye(3)[:, index]
|
| 675 |
+
assert np.allclose(got[:, index], want, atol=1e-9), (
|
| 676 |
+
f"体轴 {index} 指向 {got[:, index]},应指向 {want}")
|
| 677 |
+
|
| 678 |
+
# 一般转动:转置/换序写错在这一条上会露馅
|
| 679 |
+
q = (0.3, -0.5, 0.2, math.sqrt(1.0 - 0.09 - 0.25 - 0.04))
|
| 680 |
+
MainWindow._on_gripper_pose(Window(), "k", (0.0, 0.0, 0.0), q, (),
|
| 681 |
+
2.0, host_ns)
|
| 682 |
+
got = np.asarray(widget._rotation, dtype=float)
|
| 683 |
+
assert np.allclose(got, _quat_to_matrix(*q), atol=1e-9), (
|
| 684 |
+
f"四元数 {q} 送进视图得到\n{got}\n应为\n{_quat_to_matrix(*q)}"
|
| 685 |
+
"(约定写错:x/y/z/w 顺序或转置)")
|
| 686 |
+
|
| 687 |
+
row, prefix, timestamp, got_host = written[-1]
|
| 688 |
+
assert np.allclose(row[3:], q, atol=1e-12), (
|
| 689 |
+
f"落盘的四元数 {row[3:]} 与喂进视图的 {q} 不是同一份数")
|
| 690 |
+
assert np.allclose(row[:3], (0.0, 0.0, 0.0), atol=1e-12)
|
| 691 |
+
assert timestamp == 2.0 and got_host == host_ns, (
|
| 692 |
+
f"时间基丢了:timestamp={timestamp} host_ns={got_host}")
|
| 693 |
+
print(" 四元数→矩阵检查: 真 _on_gripper_pose 驱动视图,"
|
| 694 |
+
"锚点画成单位阵、一般姿态逐元素正确、落盘与显示同源")
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
def check_gripper_model_frame_invariant():
|
| 698 |
+
"""体轴改名(姿态右乘 Mᵀ + 模型顶点左乘 M)之后,夹爪画出来必须逐像素不变。
|
| 699 |
+
|
| 700 |
+
v1.3.13 第二版起发布姿态是 M·R·Mᵀ,右边那次是给夹爪体轴改名(+X=体下/
|
| 701 |
+
+Y=体左/+Z=体前)。改名不能改物理朝向 —— 画面上夹爪还是那个夹爪,只是
|
| 702 |
+
它身上那三支箭头的名字跟世界轴统一了。这条用**纯像素**钉住"没动朝向":
|
| 703 |
+
|
| 704 |
+
新实现(rotation = M·R·Mᵀ,顶点过 M)= 旧实现(rotation = M·R,顶点不动)
|
| 705 |
+
|
| 706 |
+
两处必须同进同退:只改一处,夹爪会被画歪 120°(实测 90°~120° 量级),
|
| 707 |
+
而两种改法各自看起来都"自洽",很难从画面上看出是谁漏了 —— 所以钉成
|
| 708 |
+
逐像素相等,不靠肉眼。
|
| 709 |
+
"""
|
| 710 |
+
from config import settings
|
| 711 |
+
from core.gripper.slam.protocol import PUBLISH_RELABEL_MATRIX
|
| 712 |
+
M = np.asarray(PUBLISH_RELABEL_MATRIX, dtype=float)
|
| 713 |
+
identity = np.eye(3)
|
| 714 |
+
app = QApplication.instance() or QApplication([])
|
| 715 |
+
env_key = settings.GRIPPER_SLAM_FRAME_ENV
|
| 716 |
+
saved_env = os.environ.get(env_key)
|
| 717 |
+
|
| 718 |
+
def widget_for(frame):
|
| 719 |
+
if frame is None:
|
| 720 |
+
os.environ.pop(env_key, None)
|
| 721 |
+
else:
|
| 722 |
+
os.environ[env_key] = frame
|
| 723 |
+
w = PoseViewQt()
|
| 724 |
+
w.resize(680, 420)
|
| 725 |
+
w.show()
|
| 726 |
+
app.processEvents()
|
| 727 |
+
w._position = np.zeros(3)
|
| 728 |
+
return w
|
| 729 |
+
|
| 730 |
+
try:
|
| 731 |
+
# ① 接线:视图必须按**当前帧**取体轴换基矩阵。这一条单独钉,是因为
|
| 732 |
+
# 下面的像素对照能查出"绘制没用换基",查不出"视图取错了矩阵"。
|
| 733 |
+
new_widget = widget_for(None)
|
| 734 |
+
legacy_widget = widget_for(settings.GRIPPER_SLAM_FRAME_LEGACY)
|
| 735 |
+
old_widget = widget_for(None) # 旧实现的对照快件,见下
|
| 736 |
+
assert np.allclose(new_widget._body_relabel, M, atol=1e-12), (
|
| 737 |
+
f"新约定下视图的体轴换基矩阵成了 {new_widget._body_relabel},应�� M")
|
| 738 |
+
assert np.allclose(legacy_widget._body_relabel, identity, atol=1e-12), (
|
| 739 |
+
f"旧约定下视图不该换基,实得 {legacy_widget._body_relabel}")
|
| 740 |
+
assert not np.allclose(new_widget._body_relabel, identity, atol=1e-12), (
|
| 741 |
+
"新约定下视图的换基矩阵是单位阵 —— 体轴没改名,夹爪会被画歪")
|
| 742 |
+
|
| 743 |
+
# ② 绘制:新实现(发布姿态 M·R·Mᵀ + 顶点过 M)必须与旧实现
|
| 744 |
+
# (发布姿态 M·R,顶点不动)逐像素相同。注意 new_img 走的是
|
| 745 |
+
# widget **自己**那份换基矩阵——接线断了这里就会红。
|
| 746 |
+
cases = {
|
| 747 |
+
"原点那一瞬(发布姿态=M)": identity,
|
| 748 |
+
"一般转动(发布姿态=M·R)": np.array(
|
| 749 |
+
[[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]),
|
| 750 |
+
}
|
| 751 |
+
for label, R in cases.items():
|
| 752 |
+
new_img = _render_gripper_layer(new_widget, M @ R @ M.T)
|
| 753 |
+
# 对照物 = 旧实现那一套(发布姿态 M·R、顶点不动)。专门用一个
|
| 754 |
+
# 快件 widget 演它:_render_gripper_layer 会改写 widget 的换基
|
| 755 |
+
# 矩阵,跟 new_widget 共用会把下一轮的 new_img 也污染掉。
|
| 756 |
+
old_img = _render_gripper_layer(old_widget, M @ R, identity)
|
| 757 |
+
ink = int(np.any(new_img[:, :, :3] != new_img[0, 0, :3],
|
| 758 |
+
axis=2).sum())
|
| 759 |
+
assert ink > 200, (
|
| 760 |
+
f"{label}: 夹爪模型没画出来(墨 {ink}px)——这一条会变成空断言")
|
| 761 |
+
mismatch = int((new_img != old_img).any(axis=2).sum())
|
| 762 |
+
assert mismatch == 0, (
|
| 763 |
+
f"{label}: 体轴改名后夹爪模型动了 {mismatch}px —— 模型顶点该按 "
|
| 764 |
+
"M 换基却没换(或视图取错了换基矩阵),夹爪被画歪了")
|
| 765 |
+
|
| 766 |
+
# ③ 旧约定:换基是单位阵 ⇒ 走的就是改之前那条路,逐像素等于旧实现
|
| 767 |
+
legacy_R = cases["一般转动(发布姿态=M·R)"]
|
| 768 |
+
legacy_img = _render_gripper_layer(legacy_widget, M @ legacy_R)
|
| 769 |
+
assert np.allclose(legacy_widget._body_relabel, identity, atol=1e-12)
|
| 770 |
+
assert int((legacy_img != _render_gripper_layer(
|
| 771 |
+
old_widget, M @ legacy_R, identity)).any(axis=2).sum()) == 0, (
|
| 772 |
+
"旧约定下夹爪模型被改了 —— 回落旧约定必须一字不动")
|
| 773 |
+
finally:
|
| 774 |
+
for w in (new_widget, legacy_widget, old_widget):
|
| 775 |
+
w.close()
|
| 776 |
+
if saved_env is None:
|
| 777 |
+
os.environ.pop(env_key, None)
|
| 778 |
+
else:
|
| 779 |
+
os.environ[env_key] = saved_env
|
| 780 |
+
print(" 夹爪体轴检查: 接线(新约定=M、旧约定=I)+ 模型逐像素不变"
|
| 781 |
+
"(原点与一般转动两种姿态),回落旧约定一字不动")
|
| 782 |
+
|
| 783 |
+
|
| 784 |
+
def _image_to_ink(image: QImage) -> np.ndarray:
|
| 785 |
+
"""QImage → 逐像素 bool(相对左上角底色判"有墨")。"""
|
| 786 |
+
image = image.convertToFormat(QImage.Format_RGB32)
|
| 787 |
+
ptr = image.bits()
|
| 788 |
+
ptr.setsize(image.byteCount())
|
| 789 |
+
raw = np.frombuffer(ptr, np.uint8).reshape(
|
| 790 |
+
image.height(), image.bytesPerLine())
|
| 791 |
+
rgb = raw[:, :image.width() * 4].reshape(
|
| 792 |
+
image.height(), image.width(), 4)[:, :, :3].astype(int)
|
| 793 |
+
return np.abs(rgb - rgb[0, 0]).sum(axis=2) > 12
|
| 794 |
+
|
| 795 |
+
|
| 796 |
+
def _ink_runs(rows):
|
| 797 |
+
"""把行号序列切成连续段,返回 [(起, 止), ...]。"""
|
| 798 |
+
runs = []
|
| 799 |
+
for row in rows:
|
| 800 |
+
if runs and row == runs[-1][1] + 1:
|
| 801 |
+
runs[-1][1] = row
|
| 802 |
+
else:
|
| 803 |
+
runs.append([row, row])
|
| 804 |
+
return [(a, b) for a, b in runs]
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
def check_frame_line_not_clipped():
|
| 808 |
+
"""那行 `坐标帧 …` 必须**整字**画出来,不能被 drawText 按矩形裁掉底部。
|
| 809 |
+
|
| 810 |
+
v1.3.13 第一版把标题两行都画进 16px 高的矩形。DejaVu 12pt 下这串中文的
|
| 811 |
+
boundingRect 是 25px(height() 只有 21,中文墨会低到它之下),而 drawText
|
| 812 |
+
给了 rect 又不换行就是硬裁:实测丢 31% 的墨,「左/前/下/帧」的下横全没了。
|
| 813 |
+
—— 那行字正是用户验收"坐标帧改没改"时唯一能读的东西。
|
| 814 |
+
|
| 815 |
+
上面那条帧标注检查看不见这个:它把 y<50 整条都当成允许差异区,字被裁、
|
| 816 |
+
被画没、被画歪都照样绿。
|
| 817 |
+
|
| 818 |
+
判据:同一串字、同一支笔、同一个顶,画进一个足够高的框当参照,两者墨迹
|
| 819 |
+
相同。跟字体字号无关,客户机上换字体一样成立。顺带钉住"两行是分开的两
|
| 820 |
+
行"——重合会并成一段,下面的段数断言立刻红。
|
| 821 |
+
"""
|
| 822 |
+
app = QApplication.instance() or QApplication([])
|
| 823 |
+
import ui.pose_view_qt as pv
|
| 824 |
+
|
| 825 |
+
span = 0.6174
|
| 826 |
+
width, height = 680, 420
|
| 827 |
+
traj = tuple(map(tuple, make_path(40)))
|
| 828 |
+
widget = PoseViewQt()
|
| 829 |
+
widget.resize(width, height)
|
| 830 |
+
widget.show()
|
| 831 |
+
widget.set_grip_state(50.0, False)
|
| 832 |
+
widget.update_pose(tuple(traj[-1]), np.eye(3), traj)
|
| 833 |
+
widget._span_target = None
|
| 834 |
+
widget._fit_span = span
|
| 835 |
+
widget._cached_span = span
|
| 836 |
+
widget._trajectory_dirty = True
|
| 837 |
+
app.processEvents()
|
| 838 |
+
|
| 839 |
+
canvas = QImage(width, height, QImage.Format_RGB32)
|
| 840 |
+
canvas.fill(QColor(pv.BG))
|
| 841 |
+
painter = QPainter(canvas)
|
| 842 |
+
painter.setFont(widget.font())
|
| 843 |
+
widget._draw_static(painter, width, height, width / 2.0, height / 2.0,
|
| 844 |
+
min(width, height) / 2.0 * 0.78, span)
|
| 845 |
+
painter.end()
|
| 846 |
+
|
| 847 |
+
ink = _image_to_ink(canvas)
|
| 848 |
+
runs = _ink_runs(list(np.where(ink[:80].any(axis=1))[0]))
|
| 849 |
+
assert len(runs) >= 2, (
|
| 850 |
+
f"标题区只找到 {len(runs)} 段文字(应在标题之外还有一行 `坐标帧 …`):"
|
| 851 |
+
f"{runs} —— 帧那一行没画出来,或者两行叠在一起了")
|
| 852 |
+
|
| 853 |
+
top = runs[-1][0]
|
| 854 |
+
band = 40
|
| 855 |
+
text = "坐标帧 " + widget._frame_text
|
| 856 |
+
# 参照:同一支笔、同一个顶,框高到不可能裁到字
|
| 857 |
+
reference = QImage(width, height, QImage.Format_RGB32)
|
| 858 |
+
reference.fill(QColor(pv.BG))
|
| 859 |
+
painter = QPainter(reference)
|
| 860 |
+
painter.setFont(widget.font())
|
| 861 |
+
painter.setPen(pv.TITLE)
|
| 862 |
+
painter.drawText(QRectF(0, top, width, 200.0), Qt.AlignHCenter, text)
|
| 863 |
+
painter.end()
|
| 864 |
+
want = int(_image_to_ink(reference)[top:top + band].sum())
|
| 865 |
+
got = int(ink[top:top + band].sum())
|
| 866 |
+
assert want > 0, f"参照渲染没画出 `{text}`(字体/落笔有问题)"
|
| 867 |
+
assert got >= want, (
|
| 868 |
+
f"帧那一行被裁:画面上 {got}px 墨,完整应是 {want}px(少 "
|
| 869 |
+
f"{100 * (want - got) / want:.0f}%)—— drawText 的矩形框比字矮")
|
| 870 |
+
|
| 871 |
+
print(f" 帧不裁检查: `{text}` 顶在第 {top} 行,墨 {got}px = 不裁参照的 "
|
| 872 |
+
f"{want}px;标题与帧两行分成 {len(runs)} 段不叠")
|
| 873 |
+
|
| 874 |
+
|
| 875 |
if __name__ == "__main__":
|
| 876 |
main()
|
|
@@ -5,6 +5,26 @@
|
|
| 5 |
全量重绘(位姿 30Hz,轨迹点数随会话线性增长,绘制用一次 drawPolyline)。
|
| 6 |
轨迹显示降采样到 DISPLAY_TRAJECTORY_MAX_POINTS 点;左键拖动旋转视角、
|
| 7 |
右键/中键拖动平移、滚轮缩放、双击复位视角。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
|
@@ -17,6 +37,9 @@ from PyQt5.QtCore import Qt, QPointF, QRectF
|
|
| 17 |
from PyQt5.QtGui import QColor, QPainter, QPen, QPolygonF
|
| 18 |
from PyQt5.QtWidgets import QWidget
|
| 19 |
|
|
|
|
|
|
|
|
|
|
| 20 |
# 显示上限。这个值直接决定轨迹看起来光不光滑:降采样按 stride 翻倍,
|
| 21 |
# 画出顶点数始终落在 [cap/2, cap] —— 上限是 400 时,只要会话超过 400 点
|
| 22 |
# (30Hz 下 13 s)顶点数就从 400 掉到 201,每段跨 67 ms,折线开始明显切角,
|
|
@@ -51,6 +74,65 @@ CAM_AXES = (
|
|
| 51 |
(QColor("#50d890"), "Y"),
|
| 52 |
(QColor("#5098f0"), "Z"),
|
| 53 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
PLATE_GRIP = QColor("#e8606c")
|
| 55 |
PLATE_OPEN = QColor("#58a8e8")
|
| 56 |
CONNECTOR = QColor("#4a6880")
|
|
@@ -63,6 +145,31 @@ class PoseViewQt(QWidget):
|
|
| 63 |
def __init__(self, parent=None):
|
| 64 |
super().__init__(parent)
|
| 65 |
self._localization_name = "ORB-SLAM3"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
self._zoom = 1.0
|
| 67 |
self._azimuth = AZIMUTH_DEFAULT # 观测方位角(弧度)
|
| 68 |
self._elevation = ELEVATION_DEFAULT # 观测仰角(弧度)
|
|
@@ -266,6 +373,8 @@ class PoseViewQt(QWidget):
|
|
| 266 |
self._position.reshape(1, 3),
|
| 267 |
np.zeros((1, 3)),
|
| 268 |
])
|
|
|
|
|
|
|
| 269 |
self._update_fit_span(float(np.max(np.abs(all_points))))
|
| 270 |
self._cached_span = self._fit_span / self._zoom
|
| 271 |
cx = width / 2.0
|
|
@@ -278,7 +387,7 @@ class PoseViewQt(QWidget):
|
|
| 278 |
"""锚定索引 0 的固定网格降采样,已绘制顶点索引永不滑动。
|
| 279 |
|
| 280 |
与每帧 linspace 整体重抽不同:stride 只在点数超过
|
| 281 |
-
``
|
| 282 |
顶点只减不挪);末尾始终补上最新点,让轨迹尾部跟着实时位姿。
|
| 283 |
"""
|
| 284 |
length = len(trajectory)
|
|
@@ -318,7 +427,11 @@ class PoseViewQt(QWidget):
|
|
| 318 |
+ (self._span_target - self._span_anim_from) * eased)
|
| 319 |
|
| 320 |
def _project(self, point, span, cx, cy, unit):
|
| 321 |
-
"""等距正交投影:绕 Z 转方位角、
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
x, y, z = (float(value) for value in point)
|
| 323 |
cos_a, sin_a = math.cos(self._azimuth), math.sin(self._azimuth)
|
| 324 |
cos_e, sin_e = math.cos(self._elevation), math.sin(self._elevation)
|
|
@@ -333,8 +446,9 @@ class PoseViewQt(QWidget):
|
|
| 333 |
|
| 334 |
与逐点调 _project 结果逐点相同,只是把三角函数提到循环外并整体
|
| 335 |
向量化(几千点时省掉大部分开销)。整条轨迹必须作为**一条**折线交给
|
| 336 |
-
Qt:逐段 drawLine 是每次 paintEvent 上万次 Python→Qt 调用,正是
|
| 337 |
-
上限
|
|
|
|
| 338 |
"""
|
| 339 |
pts = np.asarray(points, dtype=float).reshape(-1, 3)
|
| 340 |
cos_a, sin_a = math.cos(self._azimuth), math.sin(self._azimuth)
|
|
@@ -350,7 +464,12 @@ class PoseViewQt(QWidget):
|
|
| 350 |
def project(point):
|
| 351 |
return self._project(point, span, cx, cy, unit)
|
| 352 |
|
| 353 |
-
# 世界坐标轴 + 标签
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
world_length = span * 0.3
|
| 355 |
for index, (color, label) in enumerate(WORLD_AXES):
|
| 356 |
tip = np.zeros(3)
|
|
@@ -361,9 +480,18 @@ class PoseViewQt(QWidget):
|
|
| 361 |
painter.drawLine(p0, p1)
|
| 362 |
label_point = project(tip * 1.3)
|
| 363 |
painter.setPen(QPen(color, 1.0))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
painter.drawText(
|
| 365 |
-
QRectF(label_point.x() -
|
| 366 |
-
|
|
|
|
|
|
|
| 367 |
# 原点
|
| 368 |
radius = max(2.0, unit * 0.012)
|
| 369 |
origin = project((0, 0, 0))
|
|
@@ -386,8 +514,26 @@ class PoseViewQt(QWidget):
|
|
| 386 |
if 0 < display_count < self._trajectory_point_count else "")
|
| 387 |
+ "pts"
|
| 388 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
painter.drawText(
|
| 390 |
-
QRectF(0,
|
|
|
|
|
|
|
| 391 |
|
| 392 |
def _draw_frame_axes(self, painter, cx, cy, unit, span):
|
| 393 |
def project(point):
|
|
@@ -395,10 +541,16 @@ class PoseViewQt(QWidget):
|
|
| 395 |
|
| 396 |
origin = self._position
|
| 397 |
rotation = self._rotation
|
| 398 |
-
#
|
| 399 |
-
#
|
| 400 |
-
#
|
| 401 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
directions = (
|
| 403 |
rotation[:, 0],
|
| 404 |
rotation[:, 1],
|
|
@@ -426,12 +578,18 @@ class PoseViewQt(QWidget):
|
|
| 426 |
p1.y() - heading[1] * head - perp[1] * head * 0.5)
|
| 427 |
painter.drawLine(p1, left)
|
| 428 |
painter.drawLine(p1, right)
|
| 429 |
-
# 标签
|
|
|
|
| 430 |
tip_point = project(tip + direction * length * 0.15)
|
| 431 |
painter.setPen(QPen(color, 1.0))
|
|
|
|
|
|
|
|
|
|
| 432 |
painter.drawText(
|
| 433 |
-
QRectF(tip_point.x() -
|
| 434 |
-
|
|
|
|
|
|
|
| 435 |
radius = max(2.0, unit * 0.012)
|
| 436 |
origin_p = project(origin)
|
| 437 |
painter.setPen(Qt.NoPen)
|
|
@@ -454,9 +612,19 @@ class PoseViewQt(QWidget):
|
|
| 454 |
half_gap = maximum_half_gap * (1.0 - percentage / 100.0)
|
| 455 |
position = self._position
|
| 456 |
rotation = self._rotation
|
| 457 |
-
# 局部系 =
|
| 458 |
-
#
|
| 459 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
relative_corners = np.array([
|
| 461 |
[0, -plate_length / 2, -plate_width / 2],
|
| 462 |
[0, plate_length / 2, -plate_width / 2],
|
|
@@ -465,30 +633,28 @@ class PoseViewQt(QWidget):
|
|
| 465 |
])
|
| 466 |
painter.setBrush(Qt.NoBrush)
|
| 467 |
for sign in (1, -1):
|
| 468 |
-
plate_center = base + rotation @
|
| 469 |
-
[sign * half_gap, 0, 0])
|
| 470 |
polygon = QPolygonF([
|
| 471 |
-
project(plate_center + rotation @ corner)
|
| 472 |
for corner in relative_corners])
|
| 473 |
painter.setPen(QPen(plate_color, 1.8))
|
| 474 |
painter.drawPolygon(polygon)
|
| 475 |
painter.setPen(QPen(plate_color, 0.6))
|
| 476 |
painter.drawLine(
|
| 477 |
-
project(plate_center + rotation @
|
| 478 |
[0, -plate_length / 2, 0])),
|
| 479 |
-
project(plate_center + rotation @
|
| 480 |
[0, plate_length / 2, 0])),
|
| 481 |
)
|
| 482 |
painter.setPen(QPen(CONNECTOR, 1.0))
|
| 483 |
for corner_y in (-plate_length / 2, plate_length / 2):
|
| 484 |
-
plate_point = plate_center + rotation @
|
| 485 |
-
|
| 486 |
-
base_point = base + rotation @ np.array(
|
| 487 |
[0, corner_y, sign * half_gap])
|
| 488 |
painter.drawLine(project(plate_point), project(base_point))
|
| 489 |
crossbar = np.array([
|
| 490 |
-
base + rotation @
|
| 491 |
-
base + rotation @
|
| 492 |
])
|
| 493 |
painter.setPen(QPen(CONNECTOR, 1.2))
|
| 494 |
painter.drawLine(project(crossbar[0]), project(crossbar[1]))
|
|
|
|
| 5 |
全量重绘(位姿 30Hz,轨迹点数随会话线性增长,绘制用一次 drawPolyline)。
|
| 6 |
轨迹显示降采样到 DISPLAY_TRAJECTORY_MAX_POINTS 点;左键拖动旋转视角、
|
| 7 |
右键/中键拖动平移、滚轮缩放、双击复位视角。
|
| 8 |
+
|
| 9 |
+
坐标帧:进来的位姿是世界**发布帧**(默认 +X=下 / +Y=左 / +Z=前,
|
| 10 |
+
见 protocol.finish_publish_pose)。本视图不做任何换帧补偿,直接把它当数据帧
|
| 11 |
+
画——gui 坐标系与轨迹系是同一个坐标系。换帧后画面随之整体改变(场景相对
|
| 12 |
+
画布转约 120°,世界轴三箭头屏幕位置不动、含义改成下/左/前),不是逐像素相同。
|
| 13 |
+
|
| 14 |
+
帧**写在画面上**:标题下第二行是 `坐标帧 +X下 +Y左 +Z前`,三支世界轴箭头的
|
| 15 |
+
标签也各带方向词(X 下 / Y 左 / Z 前)。这两处都从 settings 派生(跟
|
| 16 |
+
KSQ_GRIPPER_SLAM_FRAME 开关走),换帧或回落旧约定时逐字变化——因为世界轴
|
| 17 |
+
三箭头的**屏幕位置**换帧前后完全相同(tip 是常数 e_i,不含数据),能承载
|
| 18 |
+
"这一屏画的是哪一帧"的只有文字。详见 _frame_axis_word。
|
| 19 |
+
|
| 20 |
+
**跟随夹爪的那三支箭头**(_draw_frame_axes,画的是发布姿态的三列 = 夹爪
|
| 21 |
+
**体轴**)用的是同一张词表:发布侧给体轴也改了名(protocol.relabel_
|
| 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 |
"""
|
| 29 |
|
| 30 |
from __future__ import annotations
|
|
|
|
| 37 |
from PyQt5.QtGui import QColor, QPainter, QPen, QPolygonF
|
| 38 |
from PyQt5.QtWidgets import QWidget
|
| 39 |
|
| 40 |
+
from config import settings
|
| 41 |
+
from core.gripper.slam.protocol import PUBLISH_RELABEL_MATRIX
|
| 42 |
+
|
| 43 |
# 显示上限。这个值直接决定轨迹看起来光不光滑:降采样按 stride 翻倍,
|
| 44 |
# 画出顶点数始终落在 [cap/2, cap] —— 上限是 400 时,只要会话超过 400 点
|
| 45 |
# (30Hz 下 13 s)顶点数就从 400 掉到 201,每段跨 67 ms,折线开始明显切角,
|
|
|
|
| 74 |
(QColor("#50d890"), "Y"),
|
| 75 |
(QColor("#5098f0"), "Z"),
|
| 76 |
)
|
| 77 |
+
# 夹爪**体轴**换基:发布帧给体轴也改了名(protocol.relabel_published_frame
|
| 78 |
+
# 姿态那个右乘 Mᵀ 就是这件事),画模型时得把顶点从旧体轴坐标搬到新体轴坐标,
|
| 79 |
+
# 否则夹爪会被画歪 120°。
|
| 80 |
+
#
|
| 81 |
+
# 建模约定只写一份,就是"夹爪自己的那套"(体前 = 局部 +Y、体左 = 局部 −X、
|
| 82 |
+
# 体下 = 局部 −Z)——下面所有顶点常量都按这套写。哪个发布帧用哪套名字,在这
|
| 83 |
+
# 张表里查:新约定体轴跟世界轴共用一套词(+X=体下/+Y=体左/+Z=体前),换基
|
| 84 |
+
# 矩阵就是 M;旧约定(中间帧)体轴本来就叫右/正对/上,不换。
|
| 85 |
+
#
|
| 86 |
+
# ★ 只换名字不动物理朝向:rotation 已经被发布侧右乘了 Mᵀ,这里再左乘一次 M,
|
| 87 |
+
# 画出来 position + R·(M·v) == position + R_旧·v —— 逐点与换帧前相同。
|
| 88 |
+
# 两处必须同进同退:只改发布侧不改这里、或只改这里不改发布侧,夹爪都会歪
|
| 89 |
+
# 120°,而且两种改法各自都"看起来自洽",很难从画面上看出是谁漏了。
|
| 90 |
+
_BODY_RELABEL = {
|
| 91 |
+
settings.GRIPPER_SLAM_FRAME_NEW: np.asarray(PUBLISH_RELABEL_MATRIX,
|
| 92 |
+
dtype=float),
|
| 93 |
+
settings.GRIPPER_SLAM_FRAME_LEGACY: np.eye(3),
|
| 94 |
+
}
|
| 95 |
+
# 世界轴标签上要写的物理方向词。**别改上面两个常量** ——
|
| 96 |
+
# tools/tests/test_pose_view_qt.py 断言 WORLD_AXES 的标签恰好是 ["X","Y","Z"]
|
| 97 |
+
# (那是"轴顺序"的契约);方向词在绘制处派生,见 _draw_static。
|
| 98 |
+
#
|
| 99 |
+
# 词表**只在 settings 里有一份**(GRIPPER_SLAM_FRAME_AXES_CN),视图不自己
|
| 100 |
+
# 抄一份:屏幕上写的字和落进 meta/info.json 的 frame_axes 必须是同一张表
|
| 101 |
+
# 出来的,否则哪天改词就只剩一边动,界面和落盘各说各话。翻回旧约定时这三支
|
| 102 |
+
# 箭头自己变成"右/正对/上",否则界面在说谎。
|
| 103 |
+
#
|
| 104 |
+
# 旧约定的 +Y 写"正对"而不是"前"——用户自己的叫法就是「X=右 / Y=正对 / Z=上」,
|
| 105 |
+
# 照抄。两种 forward 混用同一个字,会让人以为两个帧的 Y 是同一个物理方向。
|
| 106 |
+
|
| 107 |
+
# 标题那一行的顶。画布上两行字(标题、帧)都从这个顶开始往下排,行高取
|
| 108 |
+
# **字体实测高度**,不写死像素 —— 写死 16px 时 21px 的中文行会被 drawText
|
| 109 |
+
# 按矩形裁掉底部笔画,而帧那一行正是"一眼确认坐标帧改没改"的入口。
|
| 110 |
+
TITLE_TOP = 2.0
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _frame_axis_word(frame, axis):
|
| 114 |
+
"""frame 这一��下 +axis 的物理方向词。
|
| 115 |
+
|
| 116 |
+
中文优先(settings.GRIPPER_SLAM_FRAME_AXES_CN),未收录的帧回落
|
| 117 |
+
settings 的英文词。回落是刻意的:将来加第三种帧约定时,界面上宁可显示
|
| 118 |
+
英文方向,也不能显示别的帧的方向词,更不能在 paintEvent 里 KeyError
|
| 119 |
+
把整个视图打崩。
|
| 120 |
+
"""
|
| 121 |
+
word = settings.GRIPPER_SLAM_FRAME_AXES_CN.get(frame, {}).get("+" + axis)
|
| 122 |
+
if word:
|
| 123 |
+
return word
|
| 124 |
+
return settings.GRIPPER_SLAM_FRAME_AXES.get(frame, {}).get("+" + axis, "")
|
| 125 |
+
# 这里**没有**视图侧换帧:发布帧(+X=下 / +Y=左 / +Z=前)就是本视图的数据
|
| 126 |
+
# 帧,投影公式直接吃它。gui 坐标系与轨迹系是同一个坐标系,换帧时两者一起
|
| 127 |
+
# 变——这正是用户要的"看得见"。
|
| 128 |
+
#
|
| 129 |
+
# 教训(v1.3.13 第一版,别改回去):当时为了"画面逐像素不变"在 _project /
|
| 130 |
+
# _project_many 里左乘了一个 V = Mᵀ(M 是发布侧那道世界重标)。V·M = I 确实
|
| 131 |
+
# 让轨迹/夹爪/自动 span 全部原样不动——但也把换帧在视图里**整个抵消**了:
|
| 132 |
+
# 用户在主程序里看到的就是换帧前的画面,只有原点那三支细箭头换了指向
|
| 133 |
+
# (实测整幅 0.6% 像素、全落在原点 140×140px 内),于是他判"坐标系还是没改"。
|
| 134 |
+
# 补偿掉的不是噪声,是这次改动本身。
|
| 135 |
+
|
| 136 |
PLATE_GRIP = QColor("#e8606c")
|
| 137 |
PLATE_OPEN = QColor("#58a8e8")
|
| 138 |
CONNECTOR = QColor("#4a6880")
|
|
|
|
| 145 |
def __init__(self, parent=None):
|
| 146 |
super().__init__(parent)
|
| 147 |
self._localization_name = "ORB-SLAM3"
|
| 148 |
+
# 这一屏画的是哪个坐标帧:发布侧每次会话解析一次(process_controller
|
| 149 |
+
# 同样只解析一次),视图也在构造时定一次,别放进 paintEvent 逐帧读
|
| 150 |
+
# 环境变量——30Hz 下那是白读,而且会话中途改环境变量本就不生效。
|
| 151 |
+
self._frame_name = settings.resolve_gripper_slam_frame()
|
| 152 |
+
self._frame_words = {
|
| 153 |
+
axis: _frame_axis_word(self._frame_name, axis) for axis in "XYZ"
|
| 154 |
+
}
|
| 155 |
+
self._frame_text = " ".join(
|
| 156 |
+
f"+{axis}{self._frame_words[axis]}" for axis in "XYZ")
|
| 157 |
+
# 体轴换基矩阵同样会话内定一次。回落取 DEFAULT 那一档(= 会重标),
|
| 158 |
+
# 与 process_controller._publish_relabel 的判据保持一致:那边也是
|
| 159 |
+
# "只有明确回落到 LEGACY 才不重标",认不出的取值一律按新约定走。
|
| 160 |
+
# 两边判据不一致的话,认不出的帧名会让位姿重标了、模型却没换基,
|
| 161 |
+
# 夹爪歪 120° 而画面看不出是谁的错。
|
| 162 |
+
self._body_relabel = _BODY_RELABEL.get(
|
| 163 |
+
self._frame_name,
|
| 164 |
+
_BODY_RELABEL[settings.GRIPPER_SLAM_FRAME_DEFAULT])
|
| 165 |
+
# 方向词那一行很短(measured 194px),而槽最窄能到 280px,放得下;
|
| 166 |
+
# 完整帧名(down_left_forward)太长,塞标题会把标题顶出框,改放
|
| 167 |
+
# tooltip——想核对 info.json 里 frame 字段的人悬停即可。
|
| 168 |
+
self.setToolTip(
|
| 169 |
+
f"坐标帧 {self._frame_name}\n"
|
| 170 |
+
+ "\n".join(f"+{a} = {self._frame_words[a]}" for a in "XYZ")
|
| 171 |
+
+ f"\n改 {settings.GRIPPER_SLAM_FRAME_ENV}="
|
| 172 |
+
f"{settings.GRIPPER_SLAM_FRAME_LEGACY} 可回落旧约定(重启生效)")
|
| 173 |
self._zoom = 1.0
|
| 174 |
self._azimuth = AZIMUTH_DEFAULT # 观测方位角(弧度)
|
| 175 |
self._elevation = ELEVATION_DEFAULT # 观测仰角(弧度)
|
|
|
|
| 373 |
self._position.reshape(1, 3),
|
| 374 |
np.zeros((1, 3)),
|
| 375 |
])
|
| 376 |
+
# 直接用发布帧坐标量 span:换帧只把三个分量带符号重排(M 每行恰好
|
| 377 |
+
# 一个 ±1),max|坐标| 不变,所以自动 span / 默认缩放不跟着换帧跳。
|
| 378 |
self._update_fit_span(float(np.max(np.abs(all_points))))
|
| 379 |
self._cached_span = self._fit_span / self._zoom
|
| 380 |
cx = width / 2.0
|
|
|
|
| 387 |
"""锚定索引 0 的固定网格降采样,已绘制顶点索引永不滑动。
|
| 388 |
|
| 389 |
与每帧 linspace 整体重抽不同:stride 只在点数超过
|
| 390 |
+
``DISPLAY_TRAJECTORY_MAX_POINTS × stride`` 时翻倍(2 的幂,新网格是旧网格的子集,
|
| 391 |
顶点只减不挪);末尾始终补上最新点,让轨迹尾部跟着实时位姿。
|
| 392 |
"""
|
| 393 |
length = len(trajectory)
|
|
|
|
| 427 |
+ (self._span_target - self._span_anim_from) * eased)
|
| 428 |
|
| 429 |
def _project(self, point, span, cx, cy, unit):
|
| 430 |
+
"""等距正交投影:发布帧坐标直接绕 Z 转方位角、绕 X 转仰角,加平移偏移。
|
| 431 |
+
|
| 432 |
+
入参就是发布帧(+X=下 / +Y=左 / +Z=前),没有任何换帧换算——所以
|
| 433 |
+
_draw_static 按 tip[i] = e_i 合成的世界轴尖天然指向下/左/前。
|
| 434 |
+
"""
|
| 435 |
x, y, z = (float(value) for value in point)
|
| 436 |
cos_a, sin_a = math.cos(self._azimuth), math.sin(self._azimuth)
|
| 437 |
cos_e, sin_e = math.cos(self._elevation), math.sin(self._elevation)
|
|
|
|
| 446 |
|
| 447 |
与逐点调 _project 结果逐点相同,只是把三角函数提到循环外并整体
|
| 448 |
向量化(几千点时省掉大部分开销)。整条轨迹必须作为**一条**折线交给
|
| 449 |
+
Qt:逐段 drawLine 是每次 paintEvent 上万次 Python→Qt 调用,这正是
|
| 450 |
+
显示上限要卡在 DISPLAY_TRAJECTORY_MAX_POINTS 这个量级的原因
|
| 451 |
+
(该常量的取值依据见文件头)。
|
| 452 |
"""
|
| 453 |
pts = np.asarray(points, dtype=float).reshape(-1, 3)
|
| 454 |
cos_a, sin_a = math.cos(self._azimuth), math.sin(self._azimuth)
|
|
|
|
| 464 |
def project(point):
|
| 465 |
return self._project(point, span, cx, cy, unit)
|
| 466 |
|
| 467 |
+
# 世界坐标轴 + 标签(tip 在**发布帧**里合成,tip[index] = length ——
|
| 468 |
+
# 红色 X 标签就是发布帧 +X=下,不是画布的某个方向)
|
| 469 |
+
#
|
| 470 |
+
# 标签带物理方向词(X 下 / Y 左 / Z 前,随 settings 走)。这三支箭头的
|
| 471 |
+
# 屏幕位置换帧前后逐像素相同(tip 是常数 e_i,投影公式不吃数据),所以
|
| 472 |
+
# 换帧和"回落旧约定"在画面上唯一能读出区别的地方就是这几个字。
|
| 473 |
world_length = span * 0.3
|
| 474 |
for index, (color, label) in enumerate(WORLD_AXES):
|
| 475 |
tip = np.zeros(3)
|
|
|
|
| 480 |
painter.drawLine(p0, p1)
|
| 481 |
label_point = project(tip * 1.3)
|
| 482 |
painter.setPen(QPen(color, 1.0))
|
| 483 |
+
word = self._frame_words[label]
|
| 484 |
+
text = f"{label} {word}" if word else label
|
| 485 |
+
# 框按**实际字体**量,不写死尺寸:字从"X"变成"X 正对"宽了一倍
|
| 486 |
+
# 多,写死的框在字体比 DejaVu 12pt 大时会把这几个字裁掉
|
| 487 |
+
# (drawText 给了 rect 又不换行就是硬裁,不会省成省略号)。
|
| 488 |
+
# 框中心仍是箭尖 ×1.3 那个点,字不动位置。
|
| 489 |
+
ink = painter.fontMetrics().boundingRect(text)
|
| 490 |
painter.drawText(
|
| 491 |
+
QRectF(label_point.x() - ink.width() / 2.0,
|
| 492 |
+
label_point.y() - ink.height() / 2.0,
|
| 493 |
+
ink.width(), ink.height()),
|
| 494 |
+
Qt.AlignCenter, text)
|
| 495 |
# 原点
|
| 496 |
radius = max(2.0, unit * 0.012)
|
| 497 |
origin = project((0, 0, 0))
|
|
|
|
| 514 |
if 0 < display_count < self._trajectory_point_count else "")
|
| 515 |
+ "pts"
|
| 516 |
)
|
| 517 |
+
# 两行的框都按**实际字体量**,不写死像素。写死 16px 时 drawText 会
|
| 518 |
+
# 按矩形硬裁:默认字体下这一行 17px 的墨只剩 11px,实测丢 31%
|
| 519 |
+
# (左/前/下/帧 的下横全没了)。用 height() 也不够 —— 中文墨迹会低
|
| 520 |
+
# 到 height() 之下(DejaVu 12pt:height 21、boundingRect 25),
|
| 521 |
+
# 所以取 boundingRect,它才是这一串字真正的框。
|
| 522 |
+
# AlignHCenter 不带垂直标志 ⇒ 字从框顶开始,加高框不会挪动字。
|
| 523 |
+
metrics = painter.fontMetrics()
|
| 524 |
+
title_box = metrics.boundingRect(text)
|
| 525 |
+
painter.drawText(
|
| 526 |
+
QRectF(0, TITLE_TOP, width, title_box.height()),
|
| 527 |
+
Qt.AlignHCenter, text)
|
| 528 |
+
# 帧那一行:这一屏画的是哪个坐标帧。它是"一眼确认"的入口——世界轴三
|
| 529 |
+
# 箭头的屏幕位置换帧前后逐像素相同,光看箭头分不出来(见文件头注释)。
|
| 530 |
+
# 字串只有 15 字(实测 ~194px),最窄 280px 的槽也放得下;完整帧名在
|
| 531 |
+
# tooltip。
|
| 532 |
+
frame_text = f"坐标帧 {self._frame_text}"
|
| 533 |
painter.drawText(
|
| 534 |
+
QRectF(0, TITLE_TOP + title_box.height(), width,
|
| 535 |
+
metrics.boundingRect(frame_text).height()),
|
| 536 |
+
Qt.AlignHCenter, frame_text)
|
| 537 |
|
| 538 |
def _draw_frame_axes(self, painter, cx, cy, unit, span):
|
| 539 |
def project(point):
|
|
|
|
| 541 |
|
| 542 |
origin = self._position
|
| 543 |
rotation = self._rotation
|
| 544 |
+
# 跟随���爪的三支箭头 = 发布姿态的三列 = **夹爪体轴**。
|
| 545 |
+
#
|
| 546 |
+
# 体轴跟世界轴共用一套词(protocol.relabel_published_frame 里姿态
|
| 547 |
+
# 那个右乘 Mᵀ 干的就是这件事),所以原点那一瞬发布姿态 = 单位矩阵,
|
| 548 |
+
# 这三支箭头与世界三箭头**重合**;标签也用世界轴同一张词表
|
| 549 |
+
# (X 下 / Y 左 / Z 前),一眼能看出"跟随夹爪的轴也改了"。
|
| 550 |
+
#
|
| 551 |
+
# 旧版这里只左乘 M、体轴不改名:原点那瞬是常数 M,三支箭头与世界
|
| 552 |
+
# 三箭头差约 120°,标签又是光秃秃的 X/Y/Z —— 用户的原话是"还有
|
| 553 |
+
# 一个跟随夹爪移动的坐标轴你还没改"。
|
| 554 |
directions = (
|
| 555 |
rotation[:, 0],
|
| 556 |
rotation[:, 1],
|
|
|
|
| 578 |
p1.y() - heading[1] * head - perp[1] * head * 0.5)
|
| 579 |
painter.drawLine(p1, left)
|
| 580 |
painter.drawLine(p1, right)
|
| 581 |
+
# 标签:跟世界轴同一张词表、同一种度量方式(框按实际字体量,
|
| 582 |
+
# 不然中文方向词会被 drawText 按矩形硬裁掉下半截)
|
| 583 |
tip_point = project(tip + direction * length * 0.15)
|
| 584 |
painter.setPen(QPen(color, 1.0))
|
| 585 |
+
word = self._frame_words[label]
|
| 586 |
+
text = f"{label} {word}" if word else label
|
| 587 |
+
ink = painter.fontMetrics().boundingRect(text)
|
| 588 |
painter.drawText(
|
| 589 |
+
QRectF(tip_point.x() - ink.width() / 2.0,
|
| 590 |
+
tip_point.y() - ink.height() / 2.0,
|
| 591 |
+
ink.width(), ink.height()),
|
| 592 |
+
Qt.AlignCenter, text)
|
| 593 |
radius = max(2.0, unit * 0.012)
|
| 594 |
origin_p = project(origin)
|
| 595 |
painter.setPen(Qt.NoPen)
|
|
|
|
| 612 |
half_gap = maximum_half_gap * (1.0 - percentage / 100.0)
|
| 613 |
position = self._position
|
| 614 |
rotation = self._rotation
|
| 615 |
+
# 局部系 = 夹爪**体轴**,顶点一律按"夹爪自己的那套"写:
|
| 616 |
+
# +Y = 夹爪正前方(基座沿 Y 前伸、板长沿 Y)
|
| 617 |
+
# ±X = 夹爪左右(两块板沿 X 分开)
|
| 618 |
+
# ±Z = 夹爪上下(板的厚度方向)
|
| 619 |
+
# 画之前统一过一道 body(),把它搬到**当前发布帧**的体轴叫法上去:
|
| 620 |
+
# 新约定体轴跟世界轴同名(+X=体下/+Y=体左/+Z=体前),旧约定不动。
|
| 621 |
+
# 几何只有这一份,换帧时既不用改顶点、也不会两处各写一份改漏一处。
|
| 622 |
+
relabel = self._body_relabel
|
| 623 |
+
|
| 624 |
+
def body(vector):
|
| 625 |
+
return relabel @ np.asarray(vector, dtype=float)
|
| 626 |
+
|
| 627 |
+
base = position + rotation @ body([0, base_x, 0])
|
| 628 |
relative_corners = np.array([
|
| 629 |
[0, -plate_length / 2, -plate_width / 2],
|
| 630 |
[0, plate_length / 2, -plate_width / 2],
|
|
|
|
| 633 |
])
|
| 634 |
painter.setBrush(Qt.NoBrush)
|
| 635 |
for sign in (1, -1):
|
| 636 |
+
plate_center = base + rotation @ body([sign * half_gap, 0, 0])
|
|
|
|
| 637 |
polygon = QPolygonF([
|
| 638 |
+
project(plate_center + rotation @ body(corner))
|
| 639 |
for corner in relative_corners])
|
| 640 |
painter.setPen(QPen(plate_color, 1.8))
|
| 641 |
painter.drawPolygon(polygon)
|
| 642 |
painter.setPen(QPen(plate_color, 0.6))
|
| 643 |
painter.drawLine(
|
| 644 |
+
project(plate_center + rotation @ body(
|
| 645 |
[0, -plate_length / 2, 0])),
|
| 646 |
+
project(plate_center + rotation @ body(
|
| 647 |
[0, plate_length / 2, 0])),
|
| 648 |
)
|
| 649 |
painter.setPen(QPen(CONNECTOR, 1.0))
|
| 650 |
for corner_y in (-plate_length / 2, plate_length / 2):
|
| 651 |
+
plate_point = plate_center + rotation @ body([0, corner_y, 0])
|
| 652 |
+
base_point = base + rotation @ body(
|
|
|
|
| 653 |
[0, corner_y, sign * half_gap])
|
| 654 |
painter.drawLine(project(plate_point), project(base_point))
|
| 655 |
crossbar = np.array([
|
| 656 |
+
base + rotation @ body([-maximum_half_gap, 0, 0]),
|
| 657 |
+
base + rotation @ body([maximum_half_gap, 0, 0]),
|
| 658 |
])
|
| 659 |
painter.setPen(QPen(CONNECTOR, 1.2))
|
| 660 |
painter.drawLine(project(crossbar[0]), project(crossbar[1]))
|
|
@@ -861,7 +861,7 @@ data/recordings/<task name>/
|
|
| 861 |
| `observation.gripper_left_force` / `_right_force` | **UMI gripper contact force** `[fx, fy, fz]` in mN (3 dims); the twin column `…_force_ns` carries that sample's capture time (ns, monotonic) |
|
| 862 |
| `observation.gripper_left_force_matrix` / `_right_force_matrix` | **Force matrix**, 10×10×3 = 300 values, integer-encoded — the multiplier is per-recording, read `features["observation.…_force_matrix"].scale` in `meta/info.json` (default ×100); twin column `…_force_matrix_ns` = capture time (ns) |
|
| 863 |
| `observation.gripper_state` | Gripper state `[open %, gripped flag, fz mN]` (3 dims) |
|
| 864 |
-
| `observation.slam_trajectory` | **Gripper SLAM trajectory** (variable length): 8 values per point `[t, x, y, z, qx, qy, qz, qw]` — position in meters in the post-origin frame, quaternion, `t` monotonic in seconds |
|
| 865 |
|
| 866 |
> The force/matrix `_ns` columns exist because forces are sampled latest-wins while RGB takes the oldest queued frame, so row numbers alone drift apart within a take; resample by those timestamps to align modalities.
|
| 867 |
> With a **second gripper**, every one of its columns gets the `gripper_2_` prefix (e.g. `observation.gripper_2_gripper_left_force`), so two grippers never collide.
|
|
@@ -2003,7 +2003,7 @@ data/recordings/<任务名>/
|
|
| 2003 |
| `observation.gripper_left_force` / `_right_force` | **UMI 夹爪接触力** `[fx, fy, fz]`(mN,3 维);孪生列 `…_force_ns` 是该样本的采集时刻(纳秒,单调钟) |
|
| 2004 |
| `observation.gripper_left_force_matrix` / `_right_force_matrix` | **力矩阵** 10×10×3 = 300 维,整型编码 —— 倍率逐段记录在 `meta/info.json` 的 `features["observation.…_force_matrix"].scale`(默认 ×100);孪生列 `…_force_matrix_ns` = 采集时刻(纳秒) |
|
| 2005 |
| `observation.gripper_state` | 夹爪状态 `[开合百分比, 抓握判定, fz(mN)]`(3 维) |
|
| 2006 |
-
| `observation.slam_trajectory` | **夹爪 SLAM 轨迹**(变长):每点 8 值 `[t, x, y, z, qx, qy, qz, qw]` —— 位置是原点相对化后的米制坐标,四元数为姿态,`t` 单调递增(秒) |
|
| 2007 |
|
| 2008 |
> 力/力矩阵的 `_ns` 孪生列存在的理由:力走 latest-wins 单槽(永远取最新),RGB 走队头最旧帧,两条支路的行号在段内会持续错开;按这些时间戳重采样即可把跨模态对齐压到半帧以内。
|
| 2009 |
> 开**第二台夹爪**时,它的所有列统一加 `gripper_2_` 前缀(如 `observation.gripper_2_gripper_left_force`),两台互不串列。
|
|
|
|
| 861 |
| `observation.gripper_left_force` / `_right_force` | **UMI gripper contact force** `[fx, fy, fz]` in mN (3 dims); the twin column `…_force_ns` carries that sample's capture time (ns, monotonic) |
|
| 862 |
| `observation.gripper_left_force_matrix` / `_right_force_matrix` | **Force matrix**, 10×10×3 = 300 values, integer-encoded — the multiplier is per-recording, read `features["observation.…_force_matrix"].scale` in `meta/info.json` (default ×100); twin column `…_force_matrix_ns` = capture time (ns) |
|
| 863 |
| `observation.gripper_state` | Gripper state `[open %, gripped flag, fz mN]` (3 dims) |
|
| 864 |
+
| `observation.slam_trajectory` | **Gripper SLAM trajectory** (variable length): 8 values per point `[t, x, y, z, qx, qy, qz, qw]` — position in meters in the post-origin frame, quaternion, `t` monotonic in seconds. Axis frame: **since v1.3.13 (2026-09-28) `+X=down / +Y=left / +Z=forward`**; anything recorded by v1.3.12 or earlier is `+X=right / +Y=forward / +Z=up` — and those episodes carry **no** `frame` key at all, so "key absent" means the old convention (do not treat a missing key as the default). The frame is declared in `meta/info.json` — read `observation.slam_trajectory.frame` (or top-level `gripper_slam_frame`) instead of assuming either. The **full** app's recording screen shows it too (the lite build has no pose view — read the info.json tag there): under the pose-view title there is a `坐标帧 +X下 +Y左 +Z前` line and each world-axis label carries its direction word (`X 下` / `Y 左` / `Z 前`, switching to `X 右` / `Y 正对` / `Z 上` under the fallback), so you can confirm the convention live; hover the view for the full frame id |
|
| 865 |
|
| 866 |
> The force/matrix `_ns` columns exist because forces are sampled latest-wins while RGB takes the oldest queued frame, so row numbers alone drift apart within a take; resample by those timestamps to align modalities.
|
| 867 |
> With a **second gripper**, every one of its columns gets the `gripper_2_` prefix (e.g. `observation.gripper_2_gripper_left_force`), so two grippers never collide.
|
|
|
|
| 2003 |
| `observation.gripper_left_force` / `_right_force` | **UMI 夹爪接触力** `[fx, fy, fz]`(mN,3 维);孪生列 `…_force_ns` 是该样本的采集时刻(纳秒,单调钟) |
|
| 2004 |
| `observation.gripper_left_force_matrix` / `_right_force_matrix` | **力矩阵** 10×10×3 = 300 维,整型编码 —— 倍率逐段记录在 `meta/info.json` 的 `features["observation.…_force_matrix"].scale`(默认 ×100);孪生列 `…_force_matrix_ns` = 采集时刻(纳秒) |
|
| 2005 |
| `observation.gripper_state` | 夹爪状态 `[开合百分比, 抓握判定, fz(mN)]`(3 维) |
|
| 2006 |
+
| `observation.slam_trajectory` | **夹爪 SLAM 轨迹**(变长):每点 8 值 `[t, x, y, z, qx, qy, qz, qw]` —— 位置是原点相对化后的米制坐标,四元数为姿态,`t` 单调递增(秒)。坐标帧:**v1.3.13(2026-09-28)起 +X=下 / +Y=左 / +Z=前**;v1.3.12 及更早录的是 +X=右 / +Y=正对 / +Z=上 —— 而且那些 episode **没有** `frame` 键,所以「键不在」就等于旧帧,别当成"缺省即新帧"。帧名在 `meta/info.json` 里(`observation.slam_trajectory.frame`,或顶层 `gripper_slam_frame`),**按标注解释,不要假定**。**主程序**录制界面上也写着(极简版没有位姿视图,那边只能读 `meta/info.json` 的标注):位姿视图标题下第二行是 `坐标帧 +X下 +Y左 +Z前`,三支世界坐标轴标签各带方向词(`X 下` / `Y 左` / `Z 前`,回落旧约定时变 `X 右` / `Y 正对` / `Z 上`),当场就能确认用的是哪套;完整帧名悬停可见 |
|
| 2007 |
|
| 2008 |
> 力/力矩阵的 `_ns` 孪生列存在的理由:力走 latest-wins 单槽(永远取最新),RGB 走队头最旧帧,两条支路的行号在段内会持续错开;按这些时间戳重采样即可把跨模态对齐压到半帧以内。
|
| 2009 |
> 开**第二台夹爪**时,它的所有列统一加 `gripper_2_` 前缀(如 `observation.gripper_2_gripper_left_force`),两台互不串列。
|
|
@@ -84,7 +84,7 @@
|
|
| 84 |
| `observation.gripper_state` | 开合百分比 / 抓握判定 / 法向力 |
|
| 85 |
| `observation.gripper_{left,right}_force` | 三轴合力;`_force_ns` 是它的采集时刻 |
|
| 86 |
| `observation.gripper_{left,right}_force_matrix` | 10×10×3 力矩阵(整型编码,倍率见 `meta/info.json` 的 `features.*.scale`) |
|
| 87 |
-
| `observation.slam_trajectory` | SLAM 轨迹(8 个值一个点,时间戳单调) |
|
| 88 |
|
| 89 |
**不存**双目视频与 IMU。录制结束的日志里有一行双目丢帧汇总 —— 只录不显时,那是
|
| 90 |
判断链路是否健康的唯一证据,出问题请把它连同 `logs/` 一起发给开发。
|
|
@@ -138,7 +138,8 @@ cd <部署目录>
|
|
| 138 |
`videos/chunk-000/uvc_rgb/` 有视频,parquet 无深度相关列
|
| 139 |
- [ ] **夹爪**(Linux 包):冷启无 `[错误 B]` → 列表出现「UMI 夹爪 (序列号)」→ 开启 → 日志有
|
| 140 |
链路就绪 → **只开夹爪**录 60s → `videos/…/gripper_rgb/` 有视频、`observation.gripper_state`
|
| 141 |
-
与 `gripper_{left,right}_force*`、`slam_trajectory` 列有值、抓握时力非零、轨迹时间戳单调
|
|
|
|
| 142 |
- [ ] **夹爪**:录制中拔掉夹爪 USB → 弹窗提示 + 条目录回收 + **录制不中断**(其它设备继续录)
|
| 143 |
- [ ] **夹爪**:关窗后 `ps -ef | grep -E "fayssense|ksq-camera-service|fays_vikit"` 零残留
|
| 144 |
- [ ] **夹爪**:插第二台夹爪 → 列名走 `gripper_2_*` 前缀、两条 RGB 各自成档、互不串列
|
|
|
|
| 84 |
| `observation.gripper_state` | 开合百分比 / 抓握判定 / 法向力 |
|
| 85 |
| `observation.gripper_{left,right}_force` | 三轴合力;`_force_ns` 是它的采集时刻 |
|
| 86 |
| `observation.gripper_{left,right}_force_matrix` | 10×10×3 力矩阵(整型编码,倍率见 `meta/info.json` 的 `features.*.scale`) |
|
| 87 |
+
| `observation.slam_trajectory` | SLAM 轨迹(8 个值一个点,时间戳单调)。**坐标帧**:v1.3.13 起 +X=下 / +Y=左 / +Z=前;此前录的是 +X=右 / +Y=正对 / +Z=上。帧名落在 `meta/info.json`(`observation.slam_trajectory.frame` / `frame_axes`,或顶层 `gripper_slam_frame`)—— ⚠极简版**只录不显**,界面上看不到这条信息,读数据时按标注解释、不要假定是哪一套 |
|
| 88 |
|
| 89 |
**不存**双目视频与 IMU。录制结束的日志里有一行双目丢帧汇总 —— 只录不显时,那是
|
| 90 |
判断链路是否健康的唯一证据,出问题请把它连同 `logs/` 一起发给开发。
|
|
|
|
| 138 |
`videos/chunk-000/uvc_rgb/` 有视频,parquet 无深度相关列
|
| 139 |
- [ ] **夹爪**(Linux 包):冷启无 `[错误 B]` → 列表出现「UMI 夹爪 (序列号)」→ 开启 → 日志有
|
| 140 |
链路就绪 → **只开夹爪**录 60s → `videos/…/gripper_rgb/` 有视频、`observation.gripper_state`
|
| 141 |
+
与 `gripper_{left,right}_force*`、`slam_trajectory` 列有值、抓握时力非零、轨迹时间戳单调;
|
| 142 |
+
`meta/info.json` 里 `observation.slam_trajectory.frame` = `down_left_forward`(极简版唯一的坐标帧凭据)
|
| 143 |
- [ ] **夹爪**:录制中拔掉夹爪 USB → 弹窗提示 + 条目录回收 + **录制不中断**(其它设备继续录)
|
| 144 |
- [ ] **夹爪**:关窗后 `ps -ef | grep -E "fayssense|ksq-camera-service|fays_vikit"` 零残留
|
| 145 |
- [ ] **夹爪**:插第二台夹爪 → 列名走 `gripper_2_*` 前缀、两条 RGB 各自成档、互不串列
|