stouchinglhh commited on
Commit
d7a054c
·
1 Parent(s): 2f78cc5

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 CHANGED
@@ -1,6 +1,6 @@
1
  # collector — Multimodal Data Acquisition SDK · 多模态数据采集 SDK
2
 
3
- ![Version](https://img.shields.io/badge/version-1.3.12-blue)
4
  ![Python](https://img.shields.io/badge/python-3.10-blue)
5
  ![License](https://img.shields.io/badge/license-TBD-lightgrey)
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
+ ![Version](https://img.shields.io/badge/version-1.3.13-blue)
4
  ![Python](https://img.shields.io/badge/python-3.10-blue)
5
  ![License](https://img.shields.io/badge/license-TBD-lightgrey)
6
 
 
339
  # 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` 里,最要紧的问题是「板子到底在不在说话」,而主程序原本答不了。夹爪
collector/config/__init__.py CHANGED
@@ -3,7 +3,57 @@
3
  版本号集中定义于此:settings.APP_VERSION 及其他模块均从这里取值。
4
  """
5
 
6
- __version__ = "1.3.12"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/` 是
collector/config/settings.py CHANGED
@@ -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 原值,本档位只决定落盘规格。
collector/core/egodata_writer.py CHANGED
@@ -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):
collector/core/gripper/slam/process_controller.py CHANGED
@@ -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, parse_slam_line, pose_rejection_reason,
24
- quat_conjugate, quat_product, rotate_pose_z90,
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
- self._pose_rotation_zero = None
 
 
 
 
 
 
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
- # S80M 坐标约定(正对=+Y、Z=上、X=右):见
820
- # protocol.rotate_pose_z90(真机实测定案,位置与姿态统一
821
- # 纯左乘 R_z(+90°),新 ORB 核心后 native=X=正对 Y=左 Z=上)。
822
- # 此处是位姿唯一发布入口,UI/落盘/pose.json/回填缓冲全部统一旋转。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
823
  pose = rotate_pose_z90(pose)
824
  # 原点后姿态相对化:C++ 设原点只把位置归零,静止首帧的姿态
825
  # 仍带部署校正/启动朝向的常数旋转(旋转跳变的根源,两轮
826
- # 变换形式修正都没能抵消它)。改为捕获首帧显示姿态为基准,
827
- # 全部姿态左乘基准的逆 → 首帧姿态恒为单位矩阵(帧轴与世界
828
  # 轴对齐,不跳变),此后姿态 = 相对原点的真实转动,与轨迹
829
  # (位置相对原点)同基准。位置不动,保持已验证的轨迹映射。
 
 
 
 
 
 
 
 
 
830
  if self._pose_rotation_zero_pending:
831
- self._pose_rotation_zero = pose.rotation
832
  self._pose_rotation_zero_pending = False
833
- if self._pose_rotation_zero is not None:
834
- pose = replace(
835
- pose,
836
- rotation=quat_product(
837
- quat_conjugate(self._pose_rotation_zero),
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
- self._pose_rotation_zero = None
 
 
 
 
 
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
- self._latest_pose = pose
 
 
 
 
 
 
 
 
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")
collector/core/gripper/slam/protocol.py CHANGED
@@ -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 轨迹坐标约定:绕 Z 轴旋转 +90°(X=右 Y=正对 Z=上)。
 
 
 
 
 
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
collector/docs/data.md CHANGED
@@ -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 单槽,写线程取到的是**当下最新**样本。于是「行号相同」
collector/docs/file_format.md CHANGED
@@ -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
 
collector/docs/tools.md CHANGED
@@ -422,7 +422,7 @@ venv/bin/python tools/audit_frame_gaps.py [任务目录] [选项] # 默认 dat
422
 
423
  ## tests/ 测试
424
 
425
- 测试脚本见 `tools/tests/`(当前 71 份;离线测试加 `QT_QPA_PLATFORM=offscreen` 无需任何硬件,真机测试需对应设备在线)。完整运行命令见 `README.md` 测试节。**下表只列了一部分**(早期 16 份 + 帧空洞相关的 5 份 + 夹爪配对/离线入口),其余按需现查文件名。
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` | 离线 | 位姿视图:轨迹常驻、世界轴与**跟随夹爪的三箭头**重合且标签带方向词、三箭头取**列**(体轴)不取行、夹爪模型换基后逐像素不变、主窗口那次四元数→矩阵转换把帧带上 |
collector/docs/ui.md CHANGED
@@ -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)`,主窗口切换到数据采集页。
collector/tools/tests/test_gripper_latest_pose_frame.py ADDED
@@ -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())
collector/tools/tests/test_gripper_pose_axes.py CHANGED
@@ -1,20 +1,33 @@
1
- """rotate_pose_z90 数学性质单测(离线,无硬件)。
2
-
3
- 验证 S80M 坐标约定(正对=+Y、Z=上、X=右)旋转的几何正确性。
4
- 真机实测定案(2026-09-09 新 ORB 核心上线后):native 输出相对物理系
5
- 为 R_z(−90°)(native X=正对、Y=左、Z=上,与桥接 .cc 注释约定一致;
6
- 旧核心时代为 R_x(+90°),已随换核失效);
7
- 修正 = 位置与姿态统一纯左乘 R_z(+90°)(纯世界重标,无体轴共轭):
8
- - 位置 (x,y,z)→(−y,x,z);四元数 q' = q_z(+90°)⊗q
9
- - 轴映射:native X(正对)→+Y,native −Y(右)→+X,native Z(上)→+Z
10
- - 用户实测复现:物理 +Y → native +X → 显示 +Y;物理 +X → native −Y
11
- → 显示 +X
12
- - 静止首帧姿态 = R_z(90°)·correction(部署校正常数残留 =
13
- R_x(−90°),非单位矩阵);原点后姿态相对化(process_controller
14
- 捕获首帧显示姿态为基准,q0⁻¹⊗q)后首帧恒为单位矩阵——帧轴与
15
- 世界轴对齐,无起始跳变;转体后绘制帧轴 = 真实相对姿态轴
16
- - 随机位姿:R' = R_z(+90°)·R(正规旋转:正交、det=+1)
17
- - 双应用 = R_z(+180°);轨迹相邻点距离不变(旋转是等距变换)
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- PoseSample, quat_conjugate, quat_product, rotate_pose_z90,
 
 
30
  )
31
 
32
 
@@ -48,8 +63,11 @@ def random_unit_quaternion(rng):
48
 
49
 
50
  def main():
51
- rng = np.random.default_rng(20260909)
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
- # 1. 轴映射(native 系:X=正对 Y=左 Z=上 → 目标:X=右 Y=正对 Z=上)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  axis_cases = [
62
- # (native 位移, 期望显示位移, 说明)
63
- ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), "正对(native X) → +Y"),
64
- ((0.0, -1.0, 0.0), (1.0, 0.0, 0.0), "右(native −Y) → +X"),
65
- ((0.0, 0.0, 1.0), (0.0, 0.0, 1.0), "上(native Z) → +Z"),
66
- ((0.0, 1.0, 0.0), (-1.0, 0.0, 0.0), "左(native Y) → −X"),
 
 
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 tuple(out.position) != expected:
73
- failures.append(f"轴映射错误 [{label}]: {out.position} != {expected}")
 
74
 
75
- # 2. 用户实测复现(新核心,2026-09-09):物理 +Y → 显示 +Y;
76
- # 物理 +X → 显示 +X;物理 +Z → 显示 +Z
77
- # native = R_z(−90°)·物理(native X=正对、Y=左、Z=上)
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]), "物理+Y"),
81
- (np.array([1.0, 0.0, 0.0]), "物理+X"),
82
- (np.array([0.0, 0.0, 1.0]), "物理+Z"),
 
83
  ]:
84
  native = tuple(Rzm90 @ phys_axis)
85
- out = rotate_pose_z90(PoseSample(position=native,
86
- rotation=(0, 0, 0, 1),
87
- timestamp=1.0))
88
- if not np.allclose(np.array(out.position), phys_axis, atol=1e-12):
89
- failures.append(f"{label} 修正后 != 物理轴: {out.position}")
90
 
91
- # 3. 随机位姿:R' = R_z(+90°)·R(纯左乘),正规性
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
- out = rotate_pose_z90(pose)
97
- expected_pos = (-pos[1], pos[0], pos[2])
98
- if tuple(out.position) != expected_pos:
99
- failures.append(f"#{i} position 应为 (−y,x,z)")
100
  continue
101
  R = quat_to_matrix(*quat)
102
- Rm = quat_to_matrix(*out.rotation)
103
- if not np.allclose(Rm, Rzp90 @ R, atol=1e-12):
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' not orthonormal")
108
  if not np.isclose(np.linalg.det(Rm), 1.0, atol=1e-9):
109
- failures.append(f"#{i} det(R') != +1")
110
 
111
- # 4. 静止首帧姿态:native = correction → 显示 R_z(90°)·correction
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
- out = rotate_pose_z90(PoseSample(position=(0.0, 0.0, 0.0),
116
- rotation=q_corr, timestamp=1.0))
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(*out.rotation), Rxm90, atol=1e-12):
119
- failures.append("静止首帧显示姿态应为 R_x(−90°)(常数残留)")
120
- q0 = out.rotation # process_controller 捕获的首帧基准
121
- zeroed = quat_product(quat_conjugate(q0), q0)
122
- if not np.allclose(zeroed, (0.0, 0.0, 0.0, 1.0), atol=1e-12):
123
- failures.append("原点相对化后首帧姿态应为单位矩阵(帧轴=世界轴)")
124
-
125
- # 5. 转体跟踪:夹爪转向右(物理 R_z(−90°),原点 ENTER=I)→
126
- # native = correction·R_z(−90°);显示 = R_z(90°)·native,再经
127
- # 相对化(q0⁻¹⊗q,q0 取自用例 4)→ R_z(−90°),帧 Y 轴 = 右
128
- q_z_m90 = (0.0, 0.0, -c, c)
129
- q_native = quat_product(q_corr, q_z_m90)
130
- out = rotate_pose_z90(PoseSample(position=(0.0, 0.0, 0.0),
131
- rotation=q_native, timestamp=1.0))
132
- q_disp = quat_product(quat_conjugate(q0), out.rotation)
133
- drawn_y = quat_to_matrix(*q_disp)[:, 1]
134
- if not np.allclose(drawn_y, (1.0, 0.0, 0.0), atol=1e-12):
135
- failures.append(f"转向右后帧 Y 轴应为 (1,0,0),实际 {drawn_y}")
136
-
137
- # 6. 双应用 = R_z(+180°):位置 (−x,−y,z),四元数 (−y,x,w,−z)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- expected_quat = (-pose.rotation[1], pose.rotation[0],
144
- pose.rotation[3], -pose.rotation[2])
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(取样帧宿主时刻)必须原样透传:rotate_pose_z90 是
154
- # 纯世界重标、不动时基。丢了它位姿就退回"只能按行号/相机钟对齐",
155
- # 而落盘侧正是靠它与 hardware_ns 直接比较来配 slam 点↔视频帧。
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 = rotate_pose_z90(a), rotate_pose_z90(b)
 
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
- # 8. 四元数辅助:共轭逆 = 单位矩阵;Hamilton 积 = 矩阵左乘
 
 
 
 
 
 
 
 
 
 
 
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
- # 9. 原点相对化:首帧姿态 q0(含部署校正/启动朝向的任意常数
193
- # 旋转),此后 q0⁻¹⊗q 应还原为相对原点的真实转动
 
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
- if not np.allclose(
203
- quat_to_matrix(*recovered),
204
- quat_to_matrix(*q_rel), atol=1e-12,
205
- ):
206
- failures.append(f"#{i} 相对化矩阵不一致")
 
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: rotate_pose_z90 数学性质全部通过(轴映射/实测复现/200 随机位姿/静止残留+相对化/转体跟踪/双应用/轨迹等距/四元数积)")
 
 
 
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
 
collector/tools/tests/test_gripper_pose_host_stamp.py CHANGED
@@ -21,14 +21,13 @@
21
  3. 带 Δq 第二行的变体:首行仍解析出位姿(正则只吃首行)
22
  4. Host 值畸形/缺失不污染其他字段
23
  5. 宿主戳是 int 不是 float(纳秒精度不能被浮点吃掉)
24
- 6. 落盘链路的两步变换(rotate_pose_z90 + dataclasses.replace)
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, quat_conjugate, quat_product, rotate_pose_z90,
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 实际做的是:rotate_pose_z90 → dataclasses.replace
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
- relativized = dataclasses.replace(
125
- rolled,
126
- rotation=quat_product(quat_conjugate(zero), rolled.rotation))
127
- check("dataclasses.replace 相对化后仍在",
128
- relativized.host_mono_ns == 287607702508638,
129
- str(relativized.host_mono_ns))
130
- check("位置/时间戳未被这两步改动",
131
- relativized.position == rolled.position
132
- and relativized.timestamp == rolled.timestamp)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:
collector/tools/tests/test_gripper_slam_columns.py CHANGED
@@ -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
 
collector/tools/tests/test_pose_view_qt.py CHANGED
@@ -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.QtGui import QImage
 
23
  from PyQt5.QtWidgets import QApplication
24
 
25
- from ui.pose_view_qt import DISPLAY_TRAJECTORY_MAX_POINTS, PoseViewQt
 
 
 
 
 
 
 
 
 
 
 
 
 
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()
collector/ui/pose_view_qt.py CHANGED
@@ -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
- ``400 × stride`` 时翻倍(2 的幂,新网格是旧网格的子集,
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 转方位角、再绕视图 X 转仰角,加平移偏移。"""
 
 
 
 
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
- 上限只能卡在 400 的原因。
 
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() - 12, label_point.y() - 12,
366
- 24, 24), Qt.AlignCenter, label)
 
 
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, 2, width, 16), Qt.AlignHCenter, text)
 
 
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
- # 位姿数据已在 ingestion 处统一旋转(正对=+Y,
399
- # protocol.rotate_pose_z90,真机实测校准),帧轴直接按旋转
400
- # 矩阵列绘制即与轨迹/世界轴一致:X(红)=右、Y(绿)=夹爪正对、
401
- # Z(蓝)=上
 
 
 
 
 
 
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() - 10, tip_point.y() - 10, 20, 20),
434
- Qt.AlignCenter, label)
 
 
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
- # 局部系 = 新约定(正对=+Y、X=右):基座沿 +Y 前伸,板长沿 Y,
458
- # 两板沿 X(左右)分开
459
- base = position + rotation @ np.array([0, base_x, 0])
 
 
 
 
 
 
 
 
 
 
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 @ np.array(
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 @ np.array(
478
  [0, -plate_length / 2, 0])),
479
- project(plate_center + rotation @ np.array(
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 @ np.array(
485
- [0, corner_y, 0])
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 @ np.array([-maximum_half_gap, 0, 0]),
491
- base + rotation @ np.array([maximum_half_gap, 0, 0]),
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]))
collector/使用手册.md CHANGED
@@ -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`),两台互不串列。
collector/使用说明_lite.md CHANGED
@@ -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 各自成档、互不串列