41 Commits

Author SHA1 Message Date
admin 93e654578a 离线拟合 2026-09-24 12:23:40 +08:00
admin 50ff372ca3 离线拟合 2026-09-24 12:22:48 +08:00
admin b362e9bb40 aaa 2026-09-22 23:17:15 +08:00
admin d6c83dd23d 备份0922 2026-09-22 23:09:43 +08:00
admin 9952dbf4a7 O30urdf标定 fix 2026-09-22 11:03:57 +08:00
admin 6830bc6801 O30左手行程标定 2026-09-21 19:50:27 +08:00
admin 8456aa7f2d O30标定修改 2026-09-21 18:16:44 +08:00
admin 54b8f66b2f fix 2026-09-21 11:05:00 +08:00
admin db4c7af3e7 完善 O30 标定时钟同步与准备契约校验 2026-09-20 18:26:41 +08:00
admin 5ee4a3bb7c O30标定逻辑修改 2026-09-20 18:06:44 +08:00
admin 1d866f7a51 O30标定urdf 2026-09-20 11:32:52 +08:00
admin a8eaa4c367 255漂移问题稳定时间改30秒 2026-09-18 13:37:03 +08:00
admin 32b6af62e2 完善行程标定与视觉运动判定 2026-09-17 18:25:36 +08:00
admin af2dc9c38f 新增 O30 右手行程标定功能包并完善相机恢复
支持三机位 Tag 两端边界标定、滑块示教、顺序避让恢复、同步四指扫描和 JSON 离线复算;修复相机时钟异常导致采集线程退出的问题,并忽略本地行程标定输出。
2026-09-17 12:41:53 +08:00
admin 7a04780b52 重构O6验证过 2026-09-16 14:05:08 +08:00
admin 467651fc47 末节采用原 CAD 零位 2026-09-11 17:45:44 +08:00
admin 69c2da6808 标定改造方案:按关节锁定零位,输出实测 JSON 与近似 URDF 2026-09-11 14:43:44 +08:00
admin f94cf2c500 重构 2026-09-11 10:07:45 +08:00
admin c4ad2b968a O12重构一版提交 2026-09-09 15:05:09 +08:00
admin a8ddcc6296 O6gui预设动作修改、O12标定初始化提交 2026-09-04 18:35:04 +08:00
admin 2356bd6247 O12 gui 2026-09-04 15:35:45 +08:00
admin 889e0ea8db o12右手原始urdf 2026-09-04 10:48:19 +08:00
admin e1fb458eff 新增o6/l6左手原始urdf 2026-09-03 17:39:37 +08:00
admin 8de69c34a1 o6右手标定 2026-09-03 10:11:13 +08:00
admin d6b7bd6209 通用 URDF patch engine 抽取 2026-09-02 15:17:50 +08:00
admin 8a749a3687 根据json修正urdf 2026-09-02 14:20:29 +08:00
admin 08fe190b3a O6原始urdf 2026-09-02 13:36:16 +08:00
admin f7aeef87a8 L6右手标定 2026-09-02 13:26:32 +08:00
admin 2b7c1f92e7 原始urdf位置修改 2026-09-01 13:57:37 +08:00
admin 1ed36ecdd8 标定代码结构修改 2026-09-01 11:51:28 +08:00
admin 7f84225ba8 refactor: dispatch formal calibration through model profiles 2026-08-31 19:22:51 +08:00
admin ba9f1b25e8 refactor: establish reusable calibration architecture 2026-08-31 18:33:31 +08:00
admin 0d606c2ba2 refactor: rename calibration package 2026-08-31 18:06:15 +08:00
admin 06c050e446 test: restore calibration regression baseline 2026-08-31 17:58:11 +08:00
admin 286581bcba 大拇指单独标定,yaw正确和稳定性修改 2026-08-31 17:17:39 +08:00
admin 4dadfb954b 大拇指零位正确性稳定性修改 2026-08-30 17:35:23 +08:00
admin 83c69b48c2 标定稳定性 2026-08-27 16:24:01 +08:00
admin 4e594ddb09 G20右手四指独立标定(少thumb_mcp) 2026-08-24 10:12:23 +08:00
admin ef65681230 G20四指单独标定(少末端tag) 2026-08-21 12:21:39 +08:00
admin a609d521a0 g20右手标定 2026-08-11 15:51:36 +08:00
admin 41ff4a61a9 新零位相机外参标定方案 2026-08-07 16:22:24 +08:00
774 changed files with 107256 additions and 14180 deletions
+24 -2
View File
@@ -50,7 +50,8 @@ Thumbs.db
# Runtime and calibration scratch files
/logs/
/MvSdkLog/
# The camera SDK writes logs relative to the launch working directory.
MvSdkLog/
*.tmp
*.log
*.bak
@@ -61,15 +62,34 @@ Thumbs.db
# Reproducible seed profiles remain under
# src/linkerhand_retarget/resource/linkerforce_v2/profiles/.
/profiles/
/calibration_output/
# Calibration outputs may be created from the workspace root or a subdirectory.
calibration_output/
range_calibration_output/
/config/*_three_camera_extrinsics.yaml
# Superseded local O6 camera calibrations. Keep the active extrinsics and
# intrinsics referenced by o6_right_product.yaml available for version control.
/config/o6_three_camera_extrinsics_20260915.yaml
/config/o6_three_camera_extrinsics_intrinsics_20260915.yaml
/config/o6_three_camera_extrinsics_recalibrated.yaml
*.wear_check.json
*.checkpoint.json
*.verification.json
*_mapping_quality.json
# Device-specific robot descriptions derived from local calibration runs
# Includes full/partial zero-calibration outputs and local copies.
/src/linkerhand_calibration/urdf/*/*_calibrated_*.urdf
/src/linkerhand_calibration/urdf/*/*_zero_calibrated*.urdf
/src/linkerhand_calibration/urdf/*/*_transferred_from_*.urdf
# Unreferenced local URDF experiment; never use as a protected CAD input.
/src/linkerhand_calibration/urdf/o6_right/linkerhand_o6_right111.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_cmc_pitch_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_calibrated_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_left/linkerhand_g20_left_zero_calibrated_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_g20_right_cmc_pitch_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_g20_right_calibrated_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_g20_right_zero_calibrated_*.urdf
/src/linkerhand_retarget/linkerhand_retarget/assets/robots/hands/linker_hand/g20_right/linkerhand_G20_RIGHT_tag.urdf
# ROS bag / MCAP recordings and CAN captures
rosbag2_*/
@@ -86,3 +106,5 @@ candump-*
# Local Codex/agent workspace metadata
/.agents/
/.codex/
/.codebuddy/
/.zcode/
+2
View File
@@ -0,0 +1,2 @@
1.不要补丁式修复问题,避免产生屎山代码,要求代码结构清晰可读性好。
2.永远用中文回复。
@@ -0,0 +1,60 @@
schema_version: 1
reference_view: front
cameras:
front:
serial_number: DB2163742
width: 1624
height: 1240
intrinsics_sha256: 5752443dfe64fb47440151d793544ec3a996891b03fa387459a7ac8c2328dc07
side:
serial_number: DB2163749
width: 1624
height: 1240
intrinsics_sha256: 25ca0a3f68ccfd1f85d1bdeb9149052bad00c4d4b1f5a88a438a4a143e07e655
top:
serial_number: DB2163739
width: 1624
height: 1240
intrinsics_sha256: 11cca902dc5493c92d1191cda4d25a30facbb5947428e7141c3ebfe078500446
front_from_view:
front:
translation_xyz_m:
- 0.0
- 0.0
- 0.0
quaternion_xyzw:
- 0.0
- 0.0
- 0.0
- 1.0
side:
translation_xyz_m:
- -0.8438350117109596
- 0.0030839597011880146
- 1.058604518460327
quaternion_xyzw:
- -0.04630065903711772
- 0.6864139609264401
- -0.015210755676403294
- 0.7255761546038824
top:
translation_xyz_m:
- 0.008505305967195427
- -0.5708652170072022
- 1.1230571120977015
quaternion_xyzw:
- 0.7264186042657113
- 0.05091741831973884
- 0.0330385816024251
- -0.6845669288053643
quality:
passed: true
reprojection_rms_px: 0.9332749561975657
maximum_rotation_repeatability_deg: 0.05294934187781907
maximum_translation_repeatability_m: 0.0004960858291558162
front_side_captures: 15
front_top_captures: 15
front_side_candidates: 15
front_top_candidates: 15
front_side_rejected: 0
front_top_rejected: 0
@@ -0,0 +1,60 @@
schema_version: 1
reference_view: front
cameras:
front:
serial_number: DB2163742
width: 1624
height: 1240
intrinsics_sha256: 5752443dfe64fb47440151d793544ec3a996891b03fa387459a7ac8c2328dc07
side:
serial_number: DB2163749
width: 1624
height: 1240
intrinsics_sha256: 25ca0a3f68ccfd1f85d1bdeb9149052bad00c4d4b1f5a88a438a4a143e07e655
top:
serial_number: DB2163739
width: 1624
height: 1240
intrinsics_sha256: 11cca902dc5493c92d1191cda4d25a30facbb5947428e7141c3ebfe078500446
front_from_view:
front:
translation_xyz_m:
- 0.0
- 0.0
- 0.0
quaternion_xyzw:
- 0.0
- 0.0
- 0.0
- 1.0
side:
translation_xyz_m:
- -0.8521079207290448
- 0.0021412076894364238
- 1.0361400609750895
quaternion_xyzw:
- -0.04900255095488596
- 0.676537184995331
- -0.016467372664989974
- 0.7345917321587682
top:
translation_xyz_m:
- 0.0004373257585413154
- -0.5686488899083978
- 1.1253160870539256
quaternion_xyzw:
- 0.7278983925982527
- 0.060947357492050866
- 0.04110103817603059
- -0.6817331254446042
quality:
passed: true
reprojection_rms_px: 0.9071907304479074
maximum_rotation_repeatability_deg: 0.05898535309651316
maximum_translation_repeatability_m: 0.0007701935623164992
front_side_captures: 15
front_top_captures: 15
front_side_candidates: 15
front_top_candidates: 15
front_side_rejected: 0
front_top_rejected: 0
@@ -0,0 +1,60 @@
schema_version: 1
reference_view: front
cameras:
front:
serial_number: DB2163742
width: 1624
height: 1240
intrinsics_sha256: 5752443dfe64fb47440151d793544ec3a996891b03fa387459a7ac8c2328dc07
side:
serial_number: DB2163749
width: 1624
height: 1240
intrinsics_sha256: 25ca0a3f68ccfd1f85d1bdeb9149052bad00c4d4b1f5a88a438a4a143e07e655
top:
serial_number: DB2163739
width: 1624
height: 1240
intrinsics_sha256: 11cca902dc5493c92d1191cda4d25a30facbb5947428e7141c3ebfe078500446
front_from_view:
front:
translation_xyz_m:
- 0.0
- 0.0
- 0.0
quaternion_xyzw:
- 0.0
- 0.0
- 0.0
- 1.0
side:
translation_xyz_m:
- -0.8080948548192018
- -0.008581421683932288
- 1.0532250983464067
quaternion_xyzw:
- -0.021327054698668555
- 0.6912906965521914
- 0.01197673364064859
- 0.7221626461189797
top:
translation_xyz_m:
- 0.04741691749425549
- -0.5804548270182806
- 1.0631239748696568
quaternion_xyzw:
- -0.7015214400964395
- -0.04845091102701706
- -0.028754624245378058
- 0.710417729149672
quality:
passed: true
reprojection_rms_px: 1.0243965778937039
maximum_rotation_repeatability_deg: 0.07214266243246935
maximum_translation_repeatability_m: 0.0008227910293034463
front_side_captures: 15
front_top_captures: 15
front_side_candidates: 15
front_top_candidates: 15
front_side_rejected: 0
front_top_rejected: 0
@@ -0,0 +1,60 @@
schema_version: 1
reference_view: front
cameras:
front:
serial_number: DB2163742
width: 1624
height: 1240
intrinsics_sha256: 5752443dfe64fb47440151d793544ec3a996891b03fa387459a7ac8c2328dc07
side:
serial_number: DB2163749
width: 1624
height: 1240
intrinsics_sha256: 25ca0a3f68ccfd1f85d1bdeb9149052bad00c4d4b1f5a88a438a4a143e07e655
top:
serial_number: DB2163739
width: 1624
height: 1240
intrinsics_sha256: 11cca902dc5493c92d1191cda4d25a30facbb5947428e7141c3ebfe078500446
front_from_view:
front:
translation_xyz_m:
- 0.0
- 0.0
- 0.0
quaternion_xyzw:
- 0.0
- 0.0
- 0.0
- 1.0
side:
translation_xyz_m:
- -0.8138585956461776
- -0.008439712508702443
- 1.0397597678627144
quaternion_xyzw:
- -0.017450615663592975
- 0.6859174861464342
- 0.01494743127074939
- 0.7273164734212502
top:
translation_xyz_m:
- 0.03984872898745615
- -0.5795215841986667
- 1.0606733822933996
quaternion_xyzw:
- -0.7006937955854443
- -0.05305859990878122
- -0.03489268952144818
- 0.7106303469608818
quality:
passed: true
reprojection_rms_px: 0.8784854263662394
maximum_rotation_repeatability_deg: 0.03530519844936986
maximum_translation_repeatability_m: 0.0005675535417705807
front_side_captures: 15
front_top_captures: 15
front_side_candidates: 15
front_top_candidates: 15
front_side_rejected: 0
front_top_rejected: 0
@@ -0,0 +1,60 @@
schema_version: 1
reference_view: front
cameras:
front:
serial_number: DB2163742
width: 1624
height: 1240
intrinsics_sha256: 5752443dfe64fb47440151d793544ec3a996891b03fa387459a7ac8c2328dc07
side:
serial_number: DB2163749
width: 1624
height: 1240
intrinsics_sha256: 25ca0a3f68ccfd1f85d1bdeb9149052bad00c4d4b1f5a88a438a4a143e07e655
top:
serial_number: DB2163739
width: 1624
height: 1240
intrinsics_sha256: 11cca902dc5493c92d1191cda4d25a30facbb5947428e7141c3ebfe078500446
front_from_view:
front:
translation_xyz_m:
- 0.0
- 0.0
- 0.0
quaternion_xyzw:
- 0.0
- 0.0
- 0.0
- 1.0
side:
translation_xyz_m:
- -0.807055290135415
- 0.007757362896197391
- 1.109645424653359
quaternion_xyzw:
- -0.038239828751376485
- 0.7081714897073992
- -0.008111842845009399
- 0.7049574842983983
top:
translation_xyz_m:
- 0.04396900884358515
- -0.5702983486721814
- 1.106333731407498
quaternion_xyzw:
- 0.7207445525815062
- 0.025265750150805705
- 0.010996906531907304
- -0.6926528710978753
quality:
passed: true
reprojection_rms_px: 0.8851530873841623
maximum_rotation_repeatability_deg: 0.02169207759215767
maximum_translation_repeatability_m: 0.00023144069642531324
front_side_captures: 15
front_top_captures: 15
front_side_candidates: 15
front_top_candidates: 15
front_side_rejected: 0
front_top_rejected: 0
@@ -0,0 +1,55 @@
image_width: 1624
image_height: 1240
camera_name: hikrobot_top_DB2163739
camera_matrix:
rows: 3
cols: 3
data:
- 3608.7915725635876
- 0.0
- 814.529414176388
- 0.0
- 3602.9474866035775
- 596.7866652774017
- 0.0
- 0.0
- 1.0
distortion_model: plumb_bob
distortion_coefficients:
rows: 1
cols: 5
data:
- -0.05774549441026762
- -0.5653814259405426
- -0.0034831870836035525
- -0.0007572304719307008
- 0.0
rectification_matrix:
rows: 3
cols: 3
data:
- 1.0
- 0.0
- 0.0
- 0.0
- 1.0
- 0.0
- 0.0
- 0.0
- 1.0
projection_matrix:
rows: 3
cols: 4
data:
- 3590.9833984375
- 0.0
- 813.6314052451635
- 0.0
- 0.0
- 3591.4892578125
- 595.0547132430074
- 0.0
- 0.0
- 0.0
- 1.0
- 0.0
@@ -0,0 +1,55 @@
image_width: 1624
image_height: 1240
camera_name: hikrobot_front_DB2163742
camera_matrix:
rows: 3
cols: 3
data:
- 3520.83963745858
- 0.0
- 971.8024440668337
- 0.0
- 3503.4665942352735
- 636.2092891200001
- 0.0
- 0.0
- 1.0
distortion_model: plumb_bob
distortion_coefficients:
rows: 1
cols: 5
data:
- -0.06456607194260039
- -0.3209716179987898
- 0.0016533290649511723
- 0.014394085090833234
- 0.0
rectification_matrix:
rows: 3
cols: 3
data:
- 1.0
- 0.0
- 0.0
- 0.0
- 1.0
- 0.0
- 0.0
- 0.0
- 1.0
projection_matrix:
rows: 3
cols: 4
data:
- 3485.41357421875
- 0.0
- 980.9585478080553
- 0.0
- 0.0
- 3500.062255859375
- 636.4474700149367
- 0.0
- 0.0
- 0.0
- 1.0
- 0.0
@@ -0,0 +1,55 @@
image_width: 1624
image_height: 1240
camera_name: hikrobot_side_DB2163749
camera_matrix:
rows: 3
cols: 3
data:
- 3564.4557723541934
- 0.0
- 633.5422135270659
- 0.0
- 3549.8257661608695
- 614.1194434338138
- 0.0
- 0.0
- 1.0
distortion_model: plumb_bob
distortion_coefficients:
rows: 1
cols: 5
data:
- -0.023159717917942222
- -0.6611401590594669
- 0.0010272016326466154
- -0.014523561130335804
- 0.0
rectification_matrix:
rows: 3
cols: 3
data:
- 1.0
- 0.0
- 0.0
- 0.0
- 1.0
- 0.0
- 0.0
- 0.0
- 1.0
projection_matrix:
rows: 3
cols: 4
data:
- 3531.000244140625
- 0.0
- 623.4124604711214
- 0.0
- 0.0
- 3551.248291015625
- 614.0312247691472
- 0.0
- 0.0
- 0.0
- 1.0
- 0.0
@@ -0,0 +1,60 @@
schema_version: 1
reference_view: front
cameras:
front:
serial_number: DB2163742
width: 1624
height: 1240
intrinsics_sha256: 3cdecb8fcede41561019882a48a342d7265a44855cf0282ec96ec7f69a9b1ee5
side:
serial_number: DB2163749
width: 1624
height: 1240
intrinsics_sha256: dbe0cdc73460dc1e51c0e3967395d14b4415a71cee952597e0717ad0babd3449
top:
serial_number: DB2163739
width: 1624
height: 1240
intrinsics_sha256: 315d5cd6f28964ad57ff67088222fff3d9679cce076acc3f05a22b76038e5352
front_from_view:
front:
translation_xyz_m:
- 0.0
- 0.0
- 0.0
quaternion_xyzw:
- 0.0
- 0.0
- 0.0
- 1.0
side:
translation_xyz_m:
- -0.8459732881345877
- -0.000555404488719817
- 0.9729938257334647
quaternion_xyzw:
- -0.04404064417946292
- 0.6703485002266404
- -0.01659275028535042
- 0.7405524900654377
top:
translation_xyz_m:
- -0.10806018719194868
- -0.5842672050520756
- 1.0419844264616385
quaternion_xyzw:
- 0.7284460604085318
- -0.016210595429770967
- 0.019511507802845565
- -0.6846333724953536
quality:
passed: true
reprojection_rms_px: 0.46496404895142424
maximum_rotation_repeatability_deg: 0.019287272875226844
maximum_translation_repeatability_m: 0.0002246640979668814
front_side_captures: 15
front_top_captures: 15
front_side_candidates: 15
front_top_candidates: 15
front_side_rejected: 0
front_top_rejected: 0
Submodule src/agillink_omnihand_sdk added at 026740d9fd
@@ -1,621 +0,0 @@
# G20 左手 AprilTag 标定
## 三机位全手一键标定
正式全手入口同时使用三台海康 `MV-CS020-10U/10UM` 黑白全局快门相机,
但只有 `/g20_calibration` 一个节点拥有机械手命令发布权。默认机位绑定为:
```text
front = DB2163742,Tag 0/1/2/3/10
side = DB2163749,Tag 4/5/6/7
top = DB2163739,Tag 8/9
```
11 张 `tag36h11` 的程序角色必须与贴纸所在刚性件一致:
| ID | 机位 | 固定位置/运动件 |
|---:|---|---|
| 0 | 正面 | 正面掌壳固定基准 |
| 1 | 正面 | 拇指 CMC 后连杆 |
| 2 | 正面 | 拇指 MCP 后连杆 |
| 3 | 正面 | 拇指 IP 后末节 |
| 4 | 侧面 | 掌壳侧面固定基准(最底下) |
| 5 | 侧面 | 食指 MCP 后连杆 |
| 6 | 侧面 | 食指 PIP 后连杆 |
| 7 | 侧面 | 食指 DIP 后末节 |
| 8 | 上面 | 上面相机可见的掌壳/底座固定基准 |
| 9 | 上面 | 拇指 CMC yaw 运动件 |
| 10 | 正面 | 食指根部侧摆运动件(index_mcp_roll) |
ID 4 不贴在侧面相机看不到的掌心正面;ID 8 必须始终固定且可见,
ID 9 必须在拇指横摆的完整行程中持续可见。
贴纸不能跨关节、贴在软胶上或在扫描过程中翘起。
每台相机必须有独立的内参文件:
```text
~/.ros/camera_info/hikrobot_DB2163742.yaml
~/.ros/camera_info/hikrobot_DB2163749.yaml
~/.ros/camera_info/hikrobot_DB2163739.yaml
```
先使用禁止运动模式检查三个机位、内参和标签:
```bash
ros2 launch g20_thumb_apriltag_calibration \
three_camera_calibration.launch.py \
serial_number:=G20_LEFT_001 \
commands_enabled:=false
```
分别查看三个相机画面:
```bash
ros2 run image_view image_view --ros-args \
--remap image:=/g20_calibration/front/camera/image_rect
ros2 run image_view image_view --ros-args \
--remap image:=/g20_calibration/side/camera/image_rect
ros2 run image_view image_view --ros-args \
--remap image:=/g20_calibration/top/camera/image_rect
```
安装相机时可以按机位启动红/蓝线对准辅助节点。红线是画面理想水平线,
蓝线是在画面下部检测到的桌边、底座边或临时刚性直尺;两线夹角不超过
`±0.5°` 且上下构图偏差不超过 `±12 px` 时显示 `ALIGNED`。Tag只画绿色
识别框,其角点方向完全不参与红/蓝线角度计算,因此Tag无需为了相机对准而贴正。
下面以正面机位为例,先启动辅助节点:
```bash
ros2 run g20_thumb_apriltag_calibration camera_alignment_view \
--ros-args -p view:=front
```
再打开它发布的叠加画面:
```bash
ros2 run image_view image_view --ros-args \
--remap image:=/g20_camera_alignment_view/image
```
侧面和上面分别把 `view:=front` 改为 `view:=side`、`view:=top`。建议一次只开
一个机位完成调整;侧面或上面没有合适长边时,临时放置与目标机械轴平行的刚性
直尺。调整完成后退出辅助节点和
`image_view`,再进行正式标定,以免额外的200万像素图像订阅影响采集帧率。
这组红/蓝线只检查图像平面滚转角,不检查相机距离、俯仰、偏航,也不会阻止
`/g20_calibration/start`。
确认所有目标关节的 `0~255` 行程安全、MVS 客户端已关闭且没有其他命令发布者后,
重新启动正式流程:
```bash
ros2 launch g20_thumb_apriltag_calibration \
three_camera_calibration.launch.py \
serial_number:=G20_LEFT_001 \
can_interface:=can0
```
状态显示三个机位均“就绪”后只调用一次:
```bash
ros2 topic echo /g20_calibration/status_text
ros2 service call /g20_calibration/start std_srvs/srv/Trigger {}
```
收到 `start` 后,程序先下发并确认以下20通道基准姿态,稳定保持0.5秒后才开始
第一条轨迹扫描:
```text
[255, 255, 255, 255, 255, 255, 127, 127, 127, 127,
255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
```
程序依次完成正面电机 `0/5/15/6`、侧面电机 `1/16`、上面电机 `10` 的三轮
往返扫描。中指、无名指、小指复制食指模板。四指侧摆先以命令255
为原始0角测出总行程,再减去总行程的一半;最终满足命令0为正、命令255为负,
零位命令是实测曲线上最接近角度中点的整数命令。
每个直接测量任务完成 `3轮×2方向=6个扫描方向` 后,程序立即试拟合
该电机对应的所有主动/被动关节。`thumb_cmc_pitch`、`thumb_cmc_roll`、
`thumb_mcp`、`thumb_ip`、`index_mcp_roll`、`index_mcp_pitch` 和 `index_pip` 使用图像平面的
连杆相对中心圆相位,避免小尺寸平面 Tag 的PnP深度双解把稳定的二维圆轨迹扭曲成
错误三维轨迹。上面斜视的 `thumb_cmc_yaw` 和侧面的被动 `index_dip` 继续使用
父Tag坐标系下的三维相对圆。`thumb_ip` 理论上也适合相对三维,但当前正面小Tag的
PnP深度在三轮间不稳定,实测会让三维行程漂移,因此继续采用可重复的二维投影轨迹。
程序分别检查二维圆残差/半径或三维平面RMS/圆残差/半径,并统一检查最小圆弧、
单调修正量、正反程回差、三轮行程一致性和直接零位拟合。主动关节三轮行程最大差
默认不超过3°;被动耦合关节允许不超过10°,但仍必须通过其余质量门限。
任一指标失败时会立即暂停,中文状态显示关节名、实测值和阈值,不再等到42个方向
全部结束。修正现场问题后调用 `resume`,
程序只清除该电机任务的内存样本并重扫它的6个方向;前面已通过的关节保留。
失败样本不从 `raw_samples.jsonl` 删除,而是使用 `attempt` 和 `retry` 记录区分,
便于调试;最终拟合只使用当前通过尝试的内存数据。
标定食指 `index_mcp_roll`(电机6)及其随机复测时,为避免中指遮挡ID 10,
程序将中指、无名指和小指的侧摆电机7/8/9固定为0;开始采样前会同时确认
电机6到达扫描起点且电机7/8/9均已到达0。离开该标定项后恢复统一基准姿态。
该项目还会通过SDK设置接口把五指速度临时设为 `[15,5,15,15,15]`,即只把
食指速度从15降为5;离开该项目后恢复 `[15,15,15,15,15]`。
侧面标定 `index_mcp_pitch`(电机1)和 `index_pip/index_dip`(电机16)时,
五指速度设为 `[15,10,15,15,15]`,即食指屈伸使用第三档速度10;其余直接
测量关节保持普通速度15。
标定 `thumb_cmc_yaw`(电机10)及其随机复测时,程序将
`thumb_cmc_roll`(电机5)固定为145,并在它到位后才开始采样,以保持运动Tag
ID 9的可见性和PnP稳定性。离开该标定项后,电机5恢复基准值255;
最终JSON的 `baseline_command_u8` 不变。
三机位流程默认设置 `validation_enabled:=false`,因此拟合完成后会直接
恢复基准姿态并生成JSON,不再进入 `VALIDATION_MOVE/VALIDATION_CAPTURE`。
此时 `quality.passed` 只由轨迹与零位拟合质量决定,`validation_mae_rad` 和
`validation_p95_rad` 为 `null`。需要恢复随机复测时,启动参数加
`validation_enabled:=true`。
上面机位在Tag二维质量合格但PnP连续无效达到1秒时,会自动重置该机位的单Tag
和双Tag连续性跟踪器,并从下一帧重新建链,早于3秒采集超时。暂停恢复时只要求
当前活动机位就绪;首次调用 `start` 仍要求三个机位全部通过预检。
对外只生成一个精简运行时结果:
```text
calibration_output/G20_LEFT_001/<时间戳>/
g20_left_G20_LEFT_001_calibration.json
```
文件包含21个关节的256项 `angle_rad`、16个主动关节的 `zero_command_u8`、
5个被动标记、模板来源和总体质量。相机、Tag、正反程及每轮质量只进入状态、日志和
`raw_samples.jsonl`,不写入最终运行时 JSON。
下面保留原有正面拇指独立标定说明和兼容入口。
该包启动海康机器人 MVS USB3 Vision 黑白相机、图像校正、`apriltag_ros`、
Linker Hand SDK 和标定状态机,
只扫描 G20 左手命令下标 `0`、`15`。默认使用单终点连续模式:每个方向只发送一次
终点命令,速度保持在固件能稳定响应的 `15`。SDK 以独立时间戳反馈实际 20 维位置,
程序把每帧 AprilTag 三维中心与同一时刻的实际电机位置插值配对并按整数位置分箱。
完成 `255→0→255` 后分别拟合正反方向并检查回差,最终运行时 JSON 将两条曲线逐点
平均,只为每个关节保存一个 256 项 `angle_rad`。最后用 5 个随机静态命令复测精度。
当前默认使用 `trajectory_center_3d`。节点由四个亚像素角点和 `CameraInfo.P`
计算每张 Tag 的三维中心,但不把小尺寸平面 Tag 的 PnP 朝向直接当作关节角:
- 根部扫描先减去掌心 T0 的位置,再用 T3/T4/T5 三条圆轨迹共同拟合 CMC 旋转轴;
每帧三个角度取中位数。
- 尖部扫描用 T4 相对 T3 的圆轨迹直接拟合 MCP。G20 只有电机 15 这一个尖部输入,
URDF 将被动 IP 定义为 `thumb_ip = 1.02 × thumb_mcp`,因此运行时 IP 曲线严格按
这个机械耦合生成。这样不会把不同相机角度下 T5 的平面 PnP 深度偏差误认为 IP
真实运动。
- 程序仍会按 MCP 角将 T5 反向旋转并拟合剩余小圆,但该结果只用于
`trajectory_center_quality.tip` 中的观测一致性诊断,不参与最终 IP 数组。
- 每条曲线都减去命令 255 的测量角,所以最终文件严格满足
`angle_rad[255] == 0.0`;`angle_rad[0]` 是该关节相对零位的最大角度。
这种方法对固定的相机摆放角度、Tag 在同一刚性连杆上的固定位置和贴纸朝向更不敏感。
但相机或贴纸在一次扫描过程中移动、Tag 翘起、角点严重抖动仍会破坏圆轨迹。程序会
检查平面残差、圆残差、轨迹半径、实际弧长和根部三个轨迹点的角度一致性。
PnP 双分支跟踪和整段刚体复核仍保留,用于选出稳定的三维中心及辅助质量检查,不再
直接生成运行时角度。根部扫描用固定的 T3–T4、T4–T5 中心间距共同选择分支;
尖部扫描用固定的 T0–T3 中心间距约束非目标部分。中心间距漂移超过阈值仍会暂停,
避免错误中心进入圆拟合,但 Tag 的 PnP 朝向抖动不会触发该门限。
## 1. 标记和安全检查
- `T0` 必须保留并固定在掌壳,作为整体平移参考;`T3` 固定在拇指根部运动连杆,`T4` 固定在 MCP 后的连杆,
`T5` 固定在最末节。四张 Tag 必须与所在刚性件完全固定,不能跨关节或贴在软胶上。
- 当前实物使用 `tag36h11` 的 ID `0/1/2/3`,依次对应 T0/T3/T4/T5。如果实物 ID 改变,同时修改
`config/front_tags.yaml` 里检测节点和标定节点的两组数组。
- `tag.sizes`/`tag_sizes_m` 必须填写每张 Tag 的实测有效边长(米),当前配置为 `0.010`。
测量检测角点所围成的正方形边长,不包含外围白色留边。
- 当前试标定允许四张 Tag 的有效边长至少 30 px(实测静态约 32~38 px),最终仍由
静止角度 RMS 和随机复测误差决定是否合格。四张 Tag 必须在全行程内均可见。需要短时检查标记时,
启动参数增加 `publish_debug_image:=true`,再订阅
`/g20_thumb_calibration/debug_image`;正式长时间扫描建议保持默认关闭。
- 执行全行程前清空拇指周围空间并准备断开电机电源。确认这只手的下标 0 和 15
均可安全走完整 `255→0→255`。标定节点发现命令话题上另有发布者时不会解锁扫描。
## 2. 安装与构建
```bash
sudo apt-get update
sudo apt-get install -y \
ros-jazzy-image-pipeline \
ros-jazzy-apriltag-ros \
ros-jazzy-apriltag-msgs \
ros-jazzy-camera-calibration \
python3-yaml
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
colcon build --symlink-install \
--packages-select linker_hand_ros2_sdk g20_thumb_apriltag_calibration
source install/setup.bash
```
相机节点直接使用海康 MVS SDK。当前机器的默认安装位置是 `/opt/MVS`,需要存在:
```text
/opt/MVS/lib/64/libMvCameraControl.so
/opt/MVS/Samples/64/Python/MvImport/MvCameraControl_class.py
```
正面相机默认按序列号 `DB2163742` 绑定(MVS 显示的 GUID 是
`2BDFB2163742`),型号校验为 `MV-CS020-10UM`。三台相机同时连接时程序不会按枚举
顺序猜测机位。启动 ROS 节点前必须关闭 MVS 客户端中的相机连接,否则设备可能被占用。
`1624x1240 mono8` 每帧约 2.0 MB,超过 Fast DDS 2.14 默认约 512 KB 的共享内存段。
相机节点和三个标定 launch 会自动加载 `config/fastdds_large_images.xml`,使用 64 MB
共享内存段;否则相机内部虽为 30 Hz,大图订阅端通常只能收到约 1~4 Hz。修改配置后
必须重启相关 ROS 进程才能生效。
首次使用必须先标定该相机和当前镜头的内参。主 launch 默认从
`~/.ros/camera_info/hikrobot_DB2163742.yaml` 加载标准 ROS CameraInfo YAML;文件缺失时
仍可预览 `mono8` 原图,但发布的内参无效,轨迹标定预检不会解锁运动。
先单独启动相机(不会连接机械手,也不会发送关节命令):
```bash
ros2 run g20_thumb_apriltag_calibration hikrobot_camera_node --ros-args \
--remap __ns:=/camera/camera/color \
-p serial_number:=DB2163742 \
-p camera_info_url:=$HOME/.ros/camera_info/hikrobot_DB2163742.yaml
```
测速时优先检查同帧发布的小消息和原图;两者正常值都应接近 30 Hz:
```bash
ros2 topic hz /camera/camera/color/camera_info
ros2 topic hz /camera/camera/color/image_raw
```
使用标定板采集内参。下面的 `8x6` 是内角点数量、`0.020` 是单格边长 20 mm,必须按
实际标定板修改:
```bash
ros2 run camera_calibration cameracalibrator \
--size 8x6 --square 0.020 \
--camera_name hikrobot_front_DB2163742 \
--ros-args \
--remap image:=/camera/camera/color/image_raw \
--remap camera/set_camera_info:=/camera/camera/color/set_camera_info
```
在标定界面完成采样后点击 `CALIBRATE`,确认重投影误差,再点击 `COMMIT`。相机节点会
原子写入上述 YAML,并立即开始发布有效内参。内参只适用于标定时的镜头焦距、对焦、
分辨率和 ROI;改变任何一项都要重新标定。
连接 CAN 后先确认 `can0` 已启动。不要同时运行其他会发布
`/g20/cb_left_hand_control_cmd` 的程序。
## 3. 启动和操作
首次使用时可先用 `commands_enabled:=false` 做预检;SDK 仍会设置速度/扭矩并读取状态,
但标定节点不会发送位置运动命令,也不会允许解锁全行程扫描:
```bash
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
serial_number:=G20_LEFT_001 \
camera_serial_number:=DB2163742 \
commands_enabled:=false
```
确认 T0、T3、T4、T5 在根部和尖部全行程中不会被遮挡,且拇指运动不会碰撞后,
停止预检并启动一个新的正式会话。默认使用 AprilTag 内部 `decimate=1.5` 提升检测
速度,并使用单终点连续运动:
```bash
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
serial_number:=G20_LEFT_001 \
camera_serial_number:=DB2163742 \
can_interface:=can0 \
calibration_speed:=15 \
continuous_motion_mode:=endpoint \
angle_estimation_mode:=trajectory_center_3d \
apriltag_decimate:=1.5 \
use_roi:=false
```
默认关闭 ROI,AprilTag 使用完整的 1624×1240 校正画面。查看实际送入 AprilTag
的完整画面:
```bash
ros2 run image_view image_view --ros-args \
--remap image:=/camera/camera/color/image_rect
```
图像检测链路使用 `sensor_data`(BEST_EFFORT)QoS,只保留最新帧,避免完整分辨率
下可靠队列积压反压相机;这不会裁剪图像,也不会降低相机分辨率。
若以后需要以帧率优先,可传入 `use_roi:=true`;默认 ROI 是原图中的
`x=128, y=192, width=1024, height=528`,也可用 `roi_x`、`roi_y`、
`roi_width`、`roi_height` 覆盖。
监控状态:
```bash
ros2 topic echo /g20_thumb_calibration/status
```
预检通过后状态为 `WAIT_ROOT_CONFIRM`,`reason` 为 `call_start`。只需调用一次:
```bash
ros2 service call /g20_thumb_calibration/start std_srvs/srv/Trigger {}
```
节点随后自动完成下标 0 的 `255→0→255`、下标 15 的 `255→0→255` 和 5 点随机复测,
正常结束状态为 `COMPLETE`,无需在根部和尖部之间再次确认。为安全起见,调用 `start`
前必须一次性确认两个关节的完整行程都已清空。原来的
`confirm_root_full_range`、`confirm_tip_full_range` 服务仍保留用于兼容。
暂停、恢复和终止:
```bash
ros2 service call /g20_thumb_calibration/pause std_srvs/srv/Trigger {}
ros2 service call /g20_thumb_calibration/resume std_srvs/srv/Trigger {}
ros2 service call /g20_thumb_calibration/abort std_srvs/srv/Trigger {}
```
预检要求四 Tag 有效帧率至少 95%,且检测消息频率至少 15 Hz。PnP 有效率也必须
至少 95%,每个候选解的重投影 RMS 不超过 1.5 px。中心轨迹模式以三组相对中心
的静止 RMS 不超过 2 mm、5 mm 范围内位置内点不少于 90% 为硬判据;PnP 朝向抖动
只作为诊断,不会阻止静态捕获。
状态中的
`pnp_rejections` 会指出当前是哪张 Tag 因丢失、重投影/倾角超限或姿态跳变而被拒绝,
`pnp_reprojection_error_px` 显示四张 Tag 最近一次有效解的误差。连续扫描要求
图像与状态的时间差不超过 150 ms、全行程至少得到 40 个有效帧、
至少覆盖 32 个整数位置且相邻实测位置间隔不超过 16。Tag 或同步状态持续丢失 3 秒、
90 秒内未到达终点,或覆盖不足时,节点保持当前命令并进入 `PAUSED`。恢复时会先回到
该方向的起点,再完整重扫这个方向,避免把半程数据混入结果。`abort` 也只停止队列,
不会主动移动机械手。正常扫描和随机复测最后一项均为命令 255。
PnP 跟踪在整个会话中对四张 Tag 都优先保持同一个 IPPE 平面分支;最多 5 秒的短暂检测
间隔不会重新初始化分支。随机复测只有在同步电机反馈与目标相差不超过 2、且稳定
窗口与捕获窗口内三个相对中心的最大偏差都不超过 3 mm 时才会写入,否则继续等待并最终暂停,
不会再生成明知不可靠但字段完整的结果。
单终点连续模式共有 4 个端到端命令:根部和尖部各一个往返。每个方向运动前会先用
实际电机反馈确认已经到达起点,再做一次短暂静态确认;随机验证的“接近位置”只等待
电机反馈到位,不再重复采图。若实际 AprilTag 检测仍低于 15 Hz,先优化检测链路,
不要降低到固件低速区。必须临时回退时可启动
`continuous_motion_mode:=paced`,该模式按步长 8 到位即发下一段。
连续扫描中的主要状态字段:
- `state_zh`/`reason_zh`/`action_zh`:当前阶段、失败原因和下一步操作的中文说明;
原有 `state`/`reason` 英文机器码继续保留。
- `tag_quality`:逐张显示 T0/T3/T4/T5 的边长、hamming、识别置信度、重投影误差、
是否有效和具体中文问题,不再需要手工解析 `/apriltag/detections`。
- `/g20_thumb_calibration/status_text`:适合终端直接查看的多行中文状态。使用
`ros2 topic echo --once /g20_thumb_calibration/status_text --field data`
即可看到原因、建议及四张标签的质量。
- `scan_progress`:4 个方向的完成比例,依次约为 0、0.25、0.5、0.75、1.0。
- `sweep_valid_frames_seen`:当前连续方向已收到的同步有效帧数。
- `sweep_state_span_u8`:当前方向实际覆盖的电机范围,接近 255 才算完整。
- `active_phase`/`active_direction`:当前是根部或尖部、下降或上升方向。
- `pnp_branch_corrections`:四张 Tag 联合跟踪为维持相邻关节姿态连续,而没有选择
单张 Tag 最小重投影分支的累计次数。
- `pnp_trajectory_quality`:最近一个完整方向的整段分支修正帧数,以及相对整段稳健
参考的旋转、相对平移和中心间距漂移。中心轨迹模式只按欧氏中心间距判断:
P95 超过 3 mm 或单帧最大值超过 6 mm 时暂停;旋转及随 Tag 坐标轴表达的相对平移
只保留为诊断。
- `trajectory_center_quality`:四个方向完成并拟合后,显示三维平面/圆残差、拟合半径、
实际弧长、T0/T3 锚点漂移和根部三个轨迹点的角度一致性。其中
`tip.ip_observed_vs_constrained_*` 显示T5残余小圆与URDF被动耦合之间的差异;
它用于发现T5识别误差、标签松动或机构异常,但不会改变最终IP曲线。
根部扫描中 T3/T4/T5 作为完整刚性组共同选择 IPPE 分支,不再把 T3 固定为在线解;
尖部扫描仍固定 T3,只用静止的 T0/T3 约束修正非目标根部姿态。
## 4. 中断恢复和输出
默认会话目录是启动命令当前目录下:
```text
calibration_output/<序列号>/<时间戳>/
```
恢复时必须显式复用原目录,否则会创建新会话:
```bash
ros2 launch g20_thumb_apriltag_calibration front_thumb_calibration.launch.py \
serial_number:=G20_LEFT_001 \
session_dir:=/绝对路径/calibration_output/G20_LEFT_001/20260727_120000
```
恢复会校验序列号、Tag 配置、基准命令、扫描模式、采集参数和代码哈希;
任一项变化都会拒绝混用旧样本,
此时应新建会话。
目录内文件:
- `raw_samples.jsonl`:连续帧按实际整数电机位置分箱后的 Tag 三维中心、姿态辅助统计及复测点;每完成一个
扫描方向后落盘。
- `checkpoint.json`:当前状态和进度。
- `session_manifest.json`:Tag、相机内参、SDK、代码哈希和会话信息。
- `validation.json`:随机复测及全部质量判据。
- `rosbag/`:仅在 `record_bag:=true` 时生成,用于保存相机、检测、命令和状态等诊断数据。
- `g20_left_<序列号>_thumb_angle.json`:精简后的运行时标定文件。
最终文件使用 `schema_version: 2`。每个关节只包含:
```json
{
"motor_index": 0,
"angle_rad": ["按命令0~255索引的256个弧度值"]
}
```
`thumb_ip.angle_rad` 由 `thumb_mcp.angle_rad` 乘
`ip_coupling.multiplier`(默认 `1.02`)得到,二者在命令255处都严格为零。
`thumb_ip` 另外包含 `"passive": true`。正反方向原始曲线不进入最终 JSON,但仍保留
在 `raw_samples.jsonl` 中,并用于最大回差和质量判定。
零位和最大角度可直接读取:
```python
import json
from pathlib import Path
data = json.loads(Path("g20_left_G20_LEFT_001_thumb_angle.json").read_text())
for name, joint in data["joints"].items():
print(name, "zero(rad)=", joint["angle_rad"][255],
"max(rad)=", joint["angle_rad"][0])
```
如果相机或 SDK 已由外部进程启动,可传
`start_camera:=false` 或 `start_sdk:=false`。`camera_serial_number` 同时接受 MVS
序列号和 GUID,但推荐使用稳定且简短的序列号 `DB2163742`。
海康相机默认输出 `1624x1240@30Hz mono8`,全局快门,曝光时间 `5000us`、增益
`0dB`,并使用“只取最新帧”策略避免视觉延迟。现场亮度不足时优先增加照明;必要时可用
`exposure_time_us`、`gain_db` 调整,或临时传 `auto_exposure:=true`。正式轨迹采集建议固定
曝光,避免自动曝光在运动过程中改变角点质量。rosbag 默认关闭;需要诊断留档时增加
`record_bag:=true`。
默认对完整 1624×1240 原图进行畸变校正和 AprilTag 检测。三维中心 PnP 必须使用
`image_rect` 的角点及同一条处理链对应的 `CameraInfo`,启动文件已自动保证二者配对。
校正和 AprilTag 组件运行
在同一个多线程容器内并启用进程内传输,避免在处理链路中重复序列化、复制大图像。
可选 ROI 模式会额外在同一容器内加入裁剪组件并同步修正 `CameraInfo`。标定节点默认
不订阅整幅图像,只订阅检测结果和 TF。
若启用调试图,预览会缩放到 50%、限速 10 Hz 并使用最新帧优先的传输方式,
不影响 AprilTag 的 ROI 输入。
静态预检先在单 Tag 层拒绝高重投影误差,再检查三组相对中心的位置内点率和毫米级 RMS。
当前 30~38 px 的 10 mm Tag 属于试标定尺寸,如果中心位置 RMS 持续不合格,应优先增加照明、缩短
相机距离或提高 Tag 有效像素,而不是放宽最终随机复测精度。
启用 rosbag 后保存裁剪后的原始图像和配套 `CameraInfo`,避免新增一个全分辨率图像
订阅者;同时使用 MCAP `zstd_fast` 压缩并按 10 GiB 分卷。快速标定通常不需要录制;
若用于正式可追溯验收,再启用并检查磁盘空间。
## 5. CMC Pitch 零位角测量
只测量命令 255 时 `thumb_cmc_pitch` 的画面水平投影零位角时,使用独立启动文件。
它只拟合 CMC 的二维零位轨迹圆,不运行完整 0~255 角度映射,也不会生成或修改
URDF:
```bash
ros2 launch g20_thumb_apriltag_calibration \
front_cmc_pitch_zero.launch.py \
serial_number:=G20_LEFT_001
```
该流程只要求 T0(ID 0)和 T3(ID 1)有效。T4/T5 可以留在手上,但丢失不会阻塞。
预检完成后查看中文状态:
```bash
ros2 topic echo --once --full-length \
/g20_thumb_cmc_pitch_zero/status_text \
--field data
```
状态显示“等待开始”后启动三轮测量:
```bash
ros2 service call \
/g20_thumb_cmc_pitch_zero/start \
std_srvs/srv/Trigger {}
```
查看带红色画面水平线、T0/T3标签中心、青色轨迹点、紫色拟合圆心和径向零位线
的调试画面:
```bash
ros2 run image_view image_view --ros-args \
--remap image:=/g20_thumb_cmc_pitch_zero/debug_image
```
画面底部红线是固定的相机水平与构图目标。程序会在画面下部自动寻找一条足够长、
接近水平的物理桌边或高对比参考直线,并画成蓝线。开始前调整相机,使蓝线与红线
重合;画面和 `status_text` 会实时显示红蓝线夹角及垂直偏差。`±0.5°` 和
`±12 px` 只用于显示 `ALIGNED/ADJUST`,完全不参与预检或 `start` 服务判断。
由操作人员确认相机位置后手动开始标定。参考直线应清晰、连续并尽量横跨画面;
该检测不使用 T0/T3 标签朝向。
一条二维直线只能确认相机滚转角和上下构图位置,不能单独证明相机的距离、俯仰、
偏航或完整三维位置。若需要严格复现这些量,还应使用固定相机支架或专用标定板。
每轮只控制电机 0 执行一次 `255→64` 端点运动和一次 `64→255` 返回运动。运动期间
连续采集 `T3中心−T0中心`,按机械手状态分箱后拟合图像平面圆;返回 255 后使用
“T3零位中心→拟合圆心”的固定内向径向矢量计算角度。运动前和返回后各采集30帧静态零位,
三轮轨迹合并后得到最终圆心。其他 19 个命令保持固定基准。任何其他节点同时发布
`/g20/cb_left_hand_control_cmd` 时,`start` 服务会拒绝启动。
T0中心用于消除相机或整只手的平移抖动。T0和T3标签自身的朝向与角点 `+x`
都不参与零位或行程计算;标签可以任意平面内旋转或反贴180°,只需标签平整、
固定且中心始终可见。若轨迹跨度、圆弧、半径、径向RMS/P95或回零误差不合格,
节点暂停或写出 `quality.passed=false`。
完成后只生成:
```text
calibration_output/G20_LEFT_001/<时间戳>/
g20_left_G20_LEFT_001_thumb_cmc_pitch_zero.json
```
核心字段是:
```text
zero_angles.table_projected_zero_rad
```
该值是内向径向零位矢量相对相机画面水平向右方向的角度。它不使用 T0 的方向,
但会用 T0 中心抵消平移;它仍不是真实三维桌面检测,因此会随相机滚转和机械手
摆放改变。
## 6. CMC Roll 零位与行程标定
`thumb_cmc_roll` 复用上节的 T0 平移补偿、T3 中心轨迹分箱和稳健圆拟合,
但控制的是电机 5。每轮执行 `255→0→255`:在 255 零位、0 行程端点和返回
255 后各静态采集 30 帧,因此可以同时测量零位角和完整 `0~255` 实际角行程。
命令 0 是完整行程端点,开始前必须确认拇指没有机械碰撞或硬限位顶死风险。
Roll同样固定使用“T3中心→拟合圆心”的内向径向矢量,不读取T3标签朝向。
```bash
ros2 launch g20_thumb_apriltag_calibration \
front_cmc_roll_calibration.launch.py \
serial_number:=G20_LEFT_001
```
预检通过后启动三轮标定:
```bash
ros2 topic echo --once --full-length \
/g20_thumb_cmc_roll_calibration/status_text \
--field data
ros2 service call \
/g20_thumb_cmc_roll_calibration/start \
std_srvs/srv/Trigger {}
```
调试画面:
```bash
ros2 run image_view image_view --ros-args \
--remap image:=/g20_thumb_cmc_roll_calibration/debug_image
```
Roll 使用与 Pitch 相同的红蓝参考线显示,但是否对齐由操作人员确认,程序不会用
蓝线状态阻止 `start` 进入电机运动。
完成后生成:
```text
calibration_output/G20_LEFT_001/<时间戳>/
g20_left_G20_LEFT_001_thumb_cmc_roll_zero_travel.json
```
核心输出字段:
```text
zero_angles.table_projected_zero_rad
travel.signed_rad
travel.range_rad
```
`travel.signed_rad` 是从命令 255 到 0 的有符号转角,`travel.range_rad` 是三轮
行程大小的中值。只有轨迹圆质量、T0/T3 检出率、三轮零位/行程一致性、端点径向
误差和回零误差全部通过时,`quality.passed` 才为 `true`。
@@ -1,76 +0,0 @@
g20_calibration:
ros__parameters:
command_topic: /g20/cb_left_hand_control_cmd
state_topic: /g20/cb_left_hand_state
info_topic: /g20/cb_left_hand_info
setting_topic: /g20/cb_hand_setting_cmd
front_camera_info_topic: /g20_calibration/front/camera/camera_info
front_detections_topic: /g20_calibration/front/apriltag/detections
side_camera_info_topic: /g20_calibration/side/camera/camera_info
side_detections_topic: /g20_calibration/side/apriltag/detections
top_camera_info_topic: /g20_calibration/top/camera/camera_info
top_detections_topic: /g20_calibration/top/apriltag/detections
# /start先下发并确认这个20通道基准姿态,稳定后才进入第一条扫描。
baseline_command_u8: [255, 255, 255, 255, 255, 255, 127, 127, 127, 127, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
normal_calibration_speed: 15
index_roll_calibration_speed: 5
index_flex_calibration_speed: 10
speed_setting_settle_seconds: 0.25
tag_size_m: 0.010
repetitions: 3
preflight_frames: 60
minimum_detection_rate: 0.95
minimum_detection_hz: 15.0
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 30.0
pnp_maximum_reprojection_error_px: 1.5
pnp_reprojection_tie_px: 1.5
pnp_maximum_pose_jump_deg: 35.0
pnp_maximum_translation_jump_m: 0.04
pnp_maximum_tag_tilt_deg: 75.0
pnp_tracker_reset_seconds: 5.0
top_pnp_invalid_reset_seconds: 1.0
maximum_state_image_skew_ms: 150.0
endpoint_tolerance_u8: 2.0
endpoint_hold_seconds: 0.5
baseline_hold_seconds: 0.5
position_timeout_seconds: 30.0
sweep_timeout_seconds: 90.0
invalid_timeout_seconds: 3.0
minimum_sweep_frames: 40
minimum_state_span_u8: 240.0
minimum_sweep_bins: 32
maximum_bin_gap: 16
trajectory_maximum_plane_rms_m: 0.004
trajectory_maximum_radial_rms_m: 0.004
trajectory_minimum_radius_m: 0.003
trajectory_minimum_arc_deg: 15.0
# 正面拇指pitch/MCP/IP使用二维圆相位,避免平面Tag的PnP深度歧义。
image_trajectory_maximum_radial_rms_px: 2.0
image_trajectory_maximum_radial_p95_px: 3.5
image_trajectory_minimum_radius_px: 20.0
trajectory_maximum_cycle_travel_difference_deg: 3.0
passive_maximum_cycle_travel_difference_deg: 10.0
zero_minimum_radius_px: 20.0
zero_maximum_radial_rms_px: 2.0
zero_maximum_radial_p95_px: 3.5
zero_maximum_round_difference_deg: 1.0
maximum_monotonic_correction_deg: 2.0
maximum_hysteresis_deg: 5.0
passive_maximum_monotonic_correction_deg: 3.0
passive_maximum_hysteresis_deg: 7.5
# 默认跳过耗时的随机复测;需要验收精度时可在launch中设为true。
validation_enabled: false
validation_command_count: 3
validation_frames: 10
validation_seed: 20260804
validation_timeout_seconds: 20.0
maximum_validation_mae_deg: 2.0
maximum_validation_p95_deg: 3.0
@@ -1,5 +0,0 @@
"""Front-camera AprilTag calibration for the left LinkerHand G20 thumb."""
from .core import BASELINE_COMMAND, COMMAND_NAMES
__all__ = ["BASELINE_COMMAND", "COMMAND_NAMES"]
@@ -1,756 +0,0 @@
"""Pure three-camera G20 calibration model and compact runtime schema.
The hardware node records one parent/child AprilTag trajectory for each
directly observable joint. This module deliberately contains no ROS imports:
curve fitting, four-finger splay centring, inheritance, and schema validation
remain deterministic and unit-testable without connected cameras or a hand.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
import math
from typing import Any, Mapping, Sequence
import numpy as np
from .core import BASELINE_COMMAND
from .trajectory import (
_angle_for_circle,
_fit_circle_with_axis,
_fit_joint_curve,
_fit_plane_axis,
_orient_circle_positive,
)
from .zero_calibration import (
_fit_circle,
_trajectory_arc_rad,
circular_median_rad,
maximum_pairwise_angle_difference_rad,
wrap_angle_rad,
)
@dataclass(frozen=True)
class JointSpec:
name: str
motor_index: int
active: bool
view: str | None
parent_role: str | None
child_role: str | None
source_joint: str | None = None
zero_kind: str | None = None
@property
def measured(self) -> bool:
return self.source_joint is None
@dataclass(frozen=True)
class SweepSpec:
view: str
motor_index: int
joints: tuple[str, ...]
@dataclass(frozen=True)
class JointCurveFit:
angle_rad: tuple[float, ...]
decreasing_rad: tuple[float, ...]
increasing_rad: tuple[float, ...]
circle: Mapping[str, Any]
maximum_monotonic_correction_rad: float
maximum_hysteresis_rad: float
quality: Mapping[str, float]
zero_offset_rad: float = 0.0
VIEW_TAGS: dict[str, dict[str, int]] = {
"front": {
"front_base": 0,
"thumb_cmc": 1,
"thumb_mcp": 2,
"thumb_ip": 3,
"index_roll": 10,
},
"side": {
"side_base": 4,
"index_mcp": 5,
"index_pip": 6,
"index_dip": 7,
},
"top": {
"top_base": 8,
"thumb_yaw": 9,
},
}
JOINT_SPECS: dict[str, JointSpec] = {
"thumb_cmc_pitch": JointSpec(
"thumb_cmc_pitch", 0, True, "front", "front_base", "thumb_cmc",
zero_kind="projected",
),
"index_mcp_pitch": JointSpec(
"index_mcp_pitch", 1, True, "side", "side_base", "index_mcp",
zero_kind="projected",
),
"middle_mcp_pitch": JointSpec(
"middle_mcp_pitch", 2, True, None, None, None,
source_joint="index_mcp_pitch", zero_kind="inherited",
),
"ring_mcp_pitch": JointSpec(
"ring_mcp_pitch", 3, True, None, None, None,
source_joint="index_mcp_pitch", zero_kind="inherited",
),
"pinky_mcp_pitch": JointSpec(
"pinky_mcp_pitch", 4, True, None, None, None,
source_joint="index_mcp_pitch", zero_kind="inherited",
),
"thumb_cmc_roll": JointSpec(
"thumb_cmc_roll", 5, True, "front", "front_base", "thumb_cmc",
zero_kind="projected",
),
"index_mcp_roll": JointSpec(
"index_mcp_roll", 6, True, "front", "front_base", "index_roll",
zero_kind="travel_midpoint",
),
"middle_mcp_roll": JointSpec(
"middle_mcp_roll", 7, True, None, None, None,
source_joint="index_mcp_roll", zero_kind="inherited",
),
"ring_mcp_roll": JointSpec(
"ring_mcp_roll", 8, True, None, None, None,
source_joint="index_mcp_roll", zero_kind="inherited",
),
"pinky_mcp_roll": JointSpec(
"pinky_mcp_roll", 9, True, None, None, None,
source_joint="index_mcp_roll", zero_kind="inherited",
),
"thumb_cmc_yaw": JointSpec(
"thumb_cmc_yaw", 10, True, "top", "top_base", "thumb_yaw",
zero_kind="projected",
),
"thumb_mcp": JointSpec(
"thumb_mcp", 15, True, "front", "thumb_cmc", "thumb_mcp",
zero_kind="projected",
),
"index_pip": JointSpec(
"index_pip", 16, True, "side", "index_mcp", "index_pip",
zero_kind="projected",
),
"middle_pip": JointSpec(
"middle_pip", 17, True, None, None, None,
source_joint="index_pip", zero_kind="inherited",
),
"ring_pip": JointSpec(
"ring_pip", 18, True, None, None, None,
source_joint="index_pip", zero_kind="inherited",
),
"pinky_pip": JointSpec(
"pinky_pip", 19, True, None, None, None,
source_joint="index_pip", zero_kind="inherited",
),
"thumb_ip": JointSpec(
"thumb_ip", 15, False, "front", "thumb_mcp", "thumb_ip",
),
"index_dip": JointSpec(
"index_dip", 16, False, "side", "index_pip", "index_dip",
),
"middle_dip": JointSpec(
"middle_dip", 17, False, None, None, None,
source_joint="index_dip",
),
"ring_dip": JointSpec(
"ring_dip", 18, False, None, None, None,
source_joint="index_dip",
),
"pinky_dip": JointSpec(
"pinky_dip", 19, False, None, None, None,
source_joint="index_dip",
),
}
SWEEP_SPECS: tuple[SweepSpec, ...] = (
SweepSpec("front", 0, ("thumb_cmc_pitch",)),
SweepSpec("front", 5, ("thumb_cmc_roll",)),
SweepSpec("front", 15, ("thumb_mcp", "thumb_ip")),
SweepSpec("front", 6, ("index_mcp_roll",)),
SweepSpec("side", 1, ("index_mcp_pitch",)),
SweepSpec("side", 16, ("index_pip", "index_dip")),
SweepSpec("top", 10, ("thumb_cmc_yaw",)),
)
MEASURED_JOINTS: tuple[str, ...] = tuple(
name for name, spec in JOINT_SPECS.items() if spec.measured
)
ACTIVE_JOINTS: tuple[str, ...] = tuple(
name for name, spec in JOINT_SPECS.items() if spec.active
)
PASSIVE_JOINTS: tuple[str, ...] = tuple(
name for name, spec in JOINT_SPECS.items() if not spec.active
)
SPLAY_JOINTS: tuple[str, ...] = (
"index_mcp_roll",
"middle_mcp_roll",
"ring_mcp_roll",
"pinky_mcp_roll",
)
# These directly actuated motions have a fixed parent link during their sweep
# and a motion plane that is close to the active camera's image plane. Their
# projected circles are substantially more repeatable than the difference of
# two independently estimated planar-Tag PnP depths. thumb_ip remains projected
# because its front-view PnP depth is not repeatable enough for a 3-D fit; the
# side-view index_dip and oblique top-view yaw remain parent-relative 3-D.
IMAGE_TRAJECTORY_JOINTS: frozenset[str] = frozenset(
{
"thumb_cmc_pitch",
"thumb_cmc_roll",
"thumb_mcp",
"thumb_ip",
"index_mcp_roll",
"index_mcp_pitch",
"index_pip",
}
)
# Keep the three unmeasured finger-roll motors away from the front camera's
# line of sight while index_mcp_roll is measured.
INDEX_ROLL_CLEARANCE_COMMANDS: dict[int, int] = {
7: 0,
8: 0,
9: 0,
}
# Hold thumb CMC roll at a camera-friendly pose while thumb CMC yaw is
# measured. This keeps the moving top-view tag sufficiently front-facing.
THUMB_YAW_CLEARANCE_COMMANDS: dict[int, int] = {
5: 145,
}
def calibration_auxiliary_commands(spec: SweepSpec) -> dict[int, int]:
"""Return motors that must remain fixed throughout one calibration task."""
if spec.motor_index == 6:
return dict(INDEX_ROLL_CLEARANCE_COMMANDS)
if spec.motor_index == 10:
return dict(THUMB_YAW_CLEARANCE_COMMANDS)
return {}
def build_full_hand_command(
motor_index: int,
command_u8: int,
baseline: Sequence[int] = BASELINE_COMMAND,
) -> list[int]:
if len(baseline) != 20:
raise ValueError("baseline must contain exactly 20 values")
motor = int(motor_index)
if motor not in {spec.motor_index for spec in JOINT_SPECS.values()}:
raise ValueError("motor_index is not a controlled G20 calibration channel")
command = int(command_u8)
if not 0 <= command <= 255:
raise ValueError("command_u8 must be in [0, 255]")
result = [int(value) for value in baseline]
if any(not 0 <= value <= 255 for value in result):
raise ValueError("baseline values must be in [0, 255]")
result[motor] = command
return result
def build_calibration_motion_command(
spec: SweepSpec,
command_u8: int,
baseline: Sequence[int] = BASELINE_COMMAND,
) -> list[int]:
"""Build a sweep command, including any required clearance pose."""
result = build_full_hand_command(
spec.motor_index,
command_u8,
baseline=baseline,
)
for motor_index, auxiliary_command in calibration_auxiliary_commands(
spec
).items():
result[motor_index] = auxiliary_command
return result
def build_calibration_speed_profile(
spec: SweepSpec,
*,
normal_speed: int,
index_roll_speed: int,
index_flex_speed: int,
) -> list[int]:
"""Return G20's five per-finger speeds for one calibration task."""
normal = int(normal_speed)
index_roll = int(index_roll_speed)
index_flex = int(index_flex_speed)
if not all(
0 <= speed <= 255 for speed in (normal, index_roll, index_flex)
):
raise ValueError("calibration speeds must be in [0, 255]")
speeds = [normal] * 5
if spec.motor_index == 6:
speeds[1] = index_roll
elif spec.motor_index in {1, 16}:
speeds[1] = index_flex
return speeds
def _record_vector(record: Mapping[str, Any]) -> np.ndarray:
value = np.asarray(record.get("relative_translation_xyz_m"), dtype=float)
if value.shape != (3,) or not np.all(np.isfinite(value)):
raise ValueError("record relative_translation_xyz_m must contain 3 values")
return value
def _record_image_vector(record: Mapping[str, Any]) -> np.ndarray:
value = np.asarray(record.get("image_relative_xy_px"), dtype=float)
if value.shape != (2,) or not np.all(np.isfinite(value)):
raise ValueError("record image_relative_xy_px must contain 2 values")
return value
def fit_joint_center_curve(
records: Sequence[Mapping[str, Any]],
*,
maximum_plane_rms_m: float = 0.004,
maximum_radial_rms_m: float = 0.004,
minimum_radius_m: float = 0.003,
minimum_arc_rad: float = math.radians(15.0),
) -> JointCurveFit:
"""Fit one command-indexed curve from a parent-frame centre trajectory."""
samples = [dict(record) for record in records]
if len(samples) < 12:
raise ValueError("joint trajectory requires at least 12 samples")
points = np.asarray([_record_vector(record) for record in samples])
axis, common_plane_rms = _fit_plane_axis([points])
circle = _fit_circle_with_axis(points, axis)
circle = _orient_circle_positive(circle, samples, list(points))
values = [_angle_for_circle(point, circle) for point in points]
curves, correction, hysteresis = _fit_joint_curve(samples, values)
quality = {
"plane_rms_m": max(
float(common_plane_rms), float(circle["plane_rms_m"])
),
"radial_rms_m": float(circle["radial_rms_m"]),
"radius_m": float(circle["radius_m"]),
"arc_rad": float(circle["observed_arc_rad"]),
}
failures: list[str] = []
if quality["plane_rms_m"] > float(maximum_plane_rms_m):
failures.append("plane_rms")
if quality["radial_rms_m"] > float(maximum_radial_rms_m):
failures.append("radial_rms")
if quality["radius_m"] < float(minimum_radius_m):
failures.append("radius")
if quality["arc_rad"] < float(minimum_arc_rad):
failures.append("arc")
if failures:
raise ValueError("joint_trajectory_quality_failed:" + ",".join(failures))
return JointCurveFit(
angle_rad=tuple(float(value) for value in curves["angle_rad"]),
decreasing_rad=tuple(
float(value) for value in curves["decreasing_rad"]
),
increasing_rad=tuple(
float(value) for value in curves["increasing_rad"]
),
circle=dict(circle),
maximum_monotonic_correction_rad=float(correction),
maximum_hysteresis_rad=float(hysteresis),
quality=quality,
)
def _signed_image_circle_angle(
point_xy_px: Sequence[float], circle: Mapping[str, Any]
) -> float:
point = np.asarray(point_xy_px, dtype=float)
centre = np.asarray(circle["center_xy_px"], dtype=float)
reference = np.asarray(circle["reference_xy_px"], dtype=float)
vector = point - centre
if point.shape != (2,) or not np.all(np.isfinite(point)):
raise ValueError("image point must contain 2 finite values")
if float(np.linalg.norm(vector)) < 1.0e-9:
raise ValueError("image point lies at the fitted circle centre")
return float(circle["orientation_sign"]) * math.atan2(
float(reference[0] * vector[1] - reference[1] * vector[0]),
float(np.dot(reference, vector)),
)
def fit_joint_image_curve(
records: Sequence[Mapping[str, Any]],
*,
maximum_radial_rms_px: float = 2.0,
maximum_radial_p95_px: float = 3.5,
minimum_radius_px: float = 20.0,
minimum_arc_rad: float = math.radians(15.0),
) -> JointCurveFit:
"""Fit angular phase from a stable projected parent/child circle."""
samples = [dict(record) for record in records]
if len(samples) < 12:
raise ValueError("joint image trajectory requires at least 12 samples")
points = np.asarray([_record_image_vector(record) for record in samples])
centre, radius = _fit_circle(points)
radial_error = np.abs(np.linalg.norm(points - centre, axis=1) - radius)
quality = {
"radial_rms_px": float(np.sqrt(np.mean(np.square(radial_error)))),
"radial_p95_px": float(np.percentile(radial_error, 95.0)),
"radius_px": float(radius),
"arc_rad": float(_trajectory_arc_rad(points, centre)),
}
failures: list[str] = []
if quality["radial_rms_px"] > float(maximum_radial_rms_px):
failures.append("radial_rms")
if quality["radial_p95_px"] > float(maximum_radial_p95_px):
failures.append("radial_p95")
if quality["radius_px"] < float(minimum_radius_px):
failures.append("radius")
if quality["arc_rad"] < float(minimum_arc_rad):
failures.append("arc")
if failures:
raise ValueError(
"joint_image_trajectory_quality_failed:" + ",".join(failures)
)
endpoint_points = np.asarray(
[
point
for record, point in zip(samples, points)
if int(record["command_u8"]) == 255
]
)
if endpoint_points.size == 0:
raise ValueError("joint image trajectory is missing command 255")
reference = np.median(endpoint_points, axis=0) - centre
reference_norm = float(np.linalg.norm(reference))
if reference_norm < 1.0e-9:
raise ValueError("joint image trajectory endpoint is degenerate")
reference /= reference_norm
circle: dict[str, Any] = {
"space": "image_2d",
"center_xy_px": [float(value) for value in centre],
"reference_xy_px": [float(value) for value in reference],
"orientation_sign": 1.0,
"radius_px": float(radius),
}
values = [_signed_image_circle_angle(point, circle) for point in points]
command_zero_values = [
value
for record, value in zip(samples, values)
if int(record["command_u8"]) == 0
]
if not command_zero_values:
raise ValueError("joint image trajectory is missing command 0")
if float(np.median(command_zero_values)) < 0.0:
circle["orientation_sign"] = -1.0
values = [-value for value in values]
curves, correction, hysteresis = _fit_joint_curve(samples, values)
return JointCurveFit(
angle_rad=tuple(float(value) for value in curves["angle_rad"]),
decreasing_rad=tuple(
float(value) for value in curves["decreasing_rad"]
),
increasing_rad=tuple(
float(value) for value in curves["increasing_rad"]
),
circle=circle,
maximum_monotonic_correction_rad=float(correction),
maximum_hysteresis_rad=float(hysteresis),
quality=quality,
)
def fit_measured_joint_curve(
joint_name: str,
records: Sequence[Mapping[str, Any]],
*,
maximum_plane_rms_m: float = 0.004,
maximum_radial_rms_m: float = 0.004,
minimum_radius_m: float = 0.003,
minimum_arc_rad: float = math.radians(15.0),
image_maximum_radial_rms_px: float = 2.0,
image_maximum_radial_p95_px: float = 3.5,
image_minimum_radius_px: float = 20.0,
) -> JointCurveFit:
"""Select the stable trajectory representation for one measured joint."""
if str(joint_name) in IMAGE_TRAJECTORY_JOINTS:
return fit_joint_image_curve(
records,
maximum_radial_rms_px=image_maximum_radial_rms_px,
maximum_radial_p95_px=image_maximum_radial_p95_px,
minimum_radius_px=image_minimum_radius_px,
minimum_arc_rad=minimum_arc_rad,
)
return fit_joint_center_curve(
records,
maximum_plane_rms_m=maximum_plane_rms_m,
maximum_radial_rms_m=maximum_radial_rms_m,
minimum_radius_m=minimum_radius_m,
minimum_arc_rad=minimum_arc_rad,
)
def center_splay_curve(fit: JointCurveFit) -> tuple[JointCurveFit, int, float]:
"""Centre a measured 255->0 splay travel on its angular midpoint."""
raw = np.asarray(fit.angle_rad, dtype=float)
if raw.shape != (256,) or not np.all(np.isfinite(raw)):
raise ValueError("splay curve must contain 256 finite values")
if np.any(np.diff(raw) > 1.0e-7):
raise ValueError("raw splay curve must be non-increasing")
travel_midpoint = 0.5 * float(raw[0] + raw[255])
if travel_midpoint <= 0.0:
raise ValueError("splay travel must be positive")
centred = raw - travel_midpoint
# Make the public endpoint symmetry exact after decimal rounding.
endpoint = round(0.5 * float(raw[0] - raw[255]), 8)
centred[0] = endpoint
centred[255] = -endpoint
centred = np.asarray([round(float(value), 8) for value in centred])
zero_command = int(np.argmin(np.abs(centred)))
return (
replace(
fit,
angle_rad=tuple(float(value) for value in centred),
zero_offset_rad=round(float(travel_midpoint), 8),
),
zero_command,
round(float(travel_midpoint), 8),
)
def measure_joint_vector(fit: JointCurveFit, vector_xyz_m: Sequence[float]) -> float:
"""Measure one vector with a fitted model, including splay centring."""
return float(
_angle_for_circle(vector_xyz_m, fit.circle) - fit.zero_offset_rad
)
def measure_joint_observation(
fit: JointCurveFit,
*,
vector_xyz_m: Sequence[float],
image_vector_xy_px: Sequence[float],
) -> float:
"""Measure an observation in the same space used to fit its curve."""
if fit.circle.get("space") == "image_2d":
return float(
_signed_image_circle_angle(image_vector_xy_px, fit.circle)
- fit.zero_offset_rad
)
return measure_joint_vector(fit, vector_xyz_m)
def fit_projected_zero(
records: Sequence[Mapping[str, Any]],
*,
minimum_radius_px: float = 20.0,
minimum_arc_rad: float = math.radians(15.0),
maximum_radial_rms_px: float = 2.0,
maximum_radial_p95_px: float = 3.5,
maximum_round_difference_rad: float = math.radians(1.0),
) -> float:
"""Return the image/table-projected zero from three sweep rounds."""
samples = [dict(record) for record in records]
points = np.asarray(
[record.get("image_relative_xy_px") for record in samples], dtype=float
)
if points.ndim != 2 or points.shape[1] != 2 or len(points) < 12:
raise ValueError("projected zero requires 2-D trajectory samples")
if not np.all(np.isfinite(points)):
raise ValueError("projected zero samples must be finite")
centre, radius = _fit_circle(points)
radial_error = np.abs(np.linalg.norm(points - centre, axis=1) - radius)
radial_rms = float(np.sqrt(np.mean(np.square(radial_error))))
radial_p95 = float(np.percentile(radial_error, 95.0))
arc = _trajectory_arc_rad(points, centre)
if radius < minimum_radius_px:
raise ValueError("projected_zero_radius_too_small")
if arc < minimum_arc_rad:
raise ValueError("projected_zero_arc_too_small")
if radial_rms > maximum_radial_rms_px:
raise ValueError("projected_zero_radial_rms_too_large")
if radial_p95 > maximum_radial_p95_px:
raise ValueError("projected_zero_radial_p95_too_large")
cycles = sorted({int(record["cycle"]) for record in samples})
if len(cycles) < 3:
raise ValueError("projected zero requires three sweep rounds")
angles: list[float] = []
for cycle in cycles:
zero_points = np.asarray(
[
record["image_relative_xy_px"]
for record in samples
if int(record["cycle"]) == cycle
and int(record["command_u8"]) == 255
],
dtype=float,
)
if zero_points.size == 0:
raise ValueError("projected zero is missing command 255")
point = np.median(zero_points, axis=0)
inward = centre - point
if float(np.linalg.norm(inward)) < 1.0e-9:
raise ValueError("projected zero point is degenerate")
angles.append(
wrap_angle_rad(math.atan2(-float(inward[1]), float(inward[0])))
)
if maximum_pairwise_angle_difference_rad(angles) > maximum_round_difference_rad:
raise ValueError("projected_zero_round_difference_too_large")
return round(float(circular_median_rad(angles)), 8)
def build_compact_payload(
*,
serial_number: str,
measured_fits: Mapping[str, JointCurveFit],
projected_zeros_rad: Mapping[str, float],
splay_zero_command_u8: int,
splay_midpoint_rad: float,
validation_errors_rad: Sequence[float],
passed: bool,
baseline: Sequence[int] = BASELINE_COMMAND,
) -> dict[str, Any]:
if set(measured_fits) != set(MEASURED_JOINTS):
raise ValueError("measured_fits must contain all directly measured joints")
expected_projected = {
name for name, spec in JOINT_SPECS.items()
if spec.zero_kind == "projected"
}
if set(projected_zeros_rad) != expected_projected:
raise ValueError("projected_zeros_rad has the wrong joint set")
if not 0 <= int(splay_zero_command_u8) <= 255:
raise ValueError("splay zero command must be in [0, 255]")
joints: dict[str, dict[str, Any]] = {}
for name, spec in JOINT_SPECS.items():
source_name = spec.source_joint or name
fit = measured_fits[source_name]
joint: dict[str, Any] = {
"motor_index": int(spec.motor_index),
"angle_rad": [round(float(value), 8) for value in fit.angle_rad],
}
if spec.active:
joint["zero_command_u8"] = (
int(splay_zero_command_u8)
if name in SPLAY_JOINTS
else 255
)
else:
joint["passive"] = True
if spec.source_joint is not None:
joint["source_joint"] = spec.source_joint
elif spec.zero_kind == "projected":
joint["zero_angles"] = {
"table_projected_zero_rad": round(
float(projected_zeros_rad[name]), 8
)
}
elif spec.zero_kind == "travel_midpoint":
joint["zero_angles"] = {
"travel_midpoint_rad": round(float(splay_midpoint_rad), 8)
}
joints[name] = joint
errors = np.abs(np.asarray(validation_errors_rad, dtype=float))
mae = float(np.mean(errors)) if errors.size else float("nan")
p95 = float(np.percentile(errors, 95.0)) if errors.size else float("nan")
payload = {
"schema_version": 3,
"model": "G20",
"side": "left",
"serial_number": str(serial_number),
"angle_unit": "rad",
"command_range": [0, 255],
"baseline_command_u8": [int(value) for value in baseline],
"joints": joints,
"quality": {
"passed": bool(passed),
"validation_mae_rad": (
None if not math.isfinite(mae) else round(mae, 8)
),
"validation_p95_rad": (
None if not math.isfinite(p95) else round(p95, 8)
),
},
}
validate_compact_payload(payload)
return payload
def validate_compact_payload(payload: Mapping[str, Any]) -> None:
expected_top = {
"schema_version", "model", "side", "serial_number", "angle_unit",
"command_range", "baseline_command_u8", "joints", "quality",
}
if set(payload) != expected_top:
raise ValueError("compact calibration has unexpected top-level fields")
if payload["schema_version"] != 3:
raise ValueError("schema_version must be 3")
if payload["model"] != "G20" or payload["side"] != "left":
raise ValueError("payload must describe a left G20")
if payload["angle_unit"] != "rad" or payload["command_range"] != [0, 255]:
raise ValueError("payload angle or command units are invalid")
baseline = payload["baseline_command_u8"]
if not isinstance(baseline, list) or len(baseline) != 20:
raise ValueError("baseline_command_u8 must contain 20 values")
joints = payload["joints"]
if not isinstance(joints, Mapping) or set(joints) != set(JOINT_SPECS):
raise ValueError("payload must contain exactly 21 G20 joints")
for name, spec in JOINT_SPECS.items():
joint = joints[name]
allowed = {"motor_index", "angle_rad"}
allowed.add("zero_command_u8" if spec.active else "passive")
if spec.source_joint is not None:
allowed.add("source_joint")
elif spec.zero_kind in {"projected", "travel_midpoint"}:
allowed.add("zero_angles")
if set(joint) != allowed:
raise ValueError(f"{name} has unexpected fields")
if int(joint["motor_index"]) != spec.motor_index:
raise ValueError(f"{name} has the wrong motor_index")
curve = np.asarray(joint["angle_rad"], dtype=float)
if curve.shape != (256,) or not np.all(np.isfinite(curve)):
raise ValueError(f"{name}.angle_rad must contain 256 finite values")
if np.any(np.diff(curve) > 1.0e-7):
raise ValueError(f"{name}.angle_rad must be non-increasing")
if spec.active:
zero = joint["zero_command_u8"]
if not isinstance(zero, int) or not 0 <= zero <= 255:
raise ValueError(f"{name}.zero_command_u8 is invalid")
elif joint.get("passive") is not True:
raise ValueError(f"{name} must be marked passive")
if spec.source_joint is not None:
if joint.get("source_joint") != spec.source_joint:
raise ValueError(f"{name} has the wrong source_joint")
source = joints[spec.source_joint]
if joint["angle_rad"] != source["angle_rad"]:
raise ValueError(f"{name} must copy its source curve exactly")
if spec.active and joint["zero_command_u8"] != source["zero_command_u8"]:
raise ValueError(f"{name} must copy its source zero command")
index_roll = np.asarray(joints["index_mcp_roll"]["angle_rad"], dtype=float)
if not index_roll[0] > 0.0 or not index_roll[255] < 0.0:
raise ValueError("index_mcp_roll endpoints must be positive then negative")
if abs(float(index_roll[0] + index_roll[255])) > 1.0e-7:
raise ValueError("index_mcp_roll endpoints must be symmetric")
for name, spec in JOINT_SPECS.items():
if name not in SPLAY_JOINTS and spec.source_joint != "index_mcp_roll":
curve = np.asarray(joints[name]["angle_rad"], dtype=float)
if abs(float(curve[255])) > 1.0e-6:
raise ValueError(f"{name}.angle_rad[255] must be zero")
quality = payload["quality"]
if set(quality) != {
"passed", "validation_mae_rad", "validation_p95_rad"
}:
raise ValueError("quality must contain only compact summary fields")
File diff suppressed because it is too large Load Diff
@@ -1,979 +0,0 @@
"""Square AprilTag pose estimation with planar ambiguity tracking.
The AprilTag detections contain accurately refined image corners. This module
uses OpenCV's IPPE square solver directly so the calibration node can inspect
both planar PnP solutions instead of accepting an occasionally flipped TF
pose.
"""
from __future__ import annotations
from dataclasses import dataclass, replace
from itertools import product
import math
from typing import Mapping, Sequence
import cv2
import numpy as np
from scipy.spatial.transform import Rotation
@dataclass(frozen=True)
class SquareTagPose:
"""One tag-to-camera pose candidate returned by IPPE."""
quaternion_xyzw: tuple[float, float, float, float]
translation_xyz_m: tuple[float, float, float]
reprojection_error_px: float
def _relative_pose(
parent: SquareTagPose,
child: SquareTagPose,
) -> tuple[Rotation, np.ndarray]:
parent_rotation = Rotation.from_quat(parent.quaternion_xyzw)
child_rotation = Rotation.from_quat(child.quaternion_xyzw)
relative_rotation = parent_rotation.inv() * child_rotation
relative_translation = parent_rotation.inv().apply(
np.asarray(child.translation_xyz_m, dtype=float)
- np.asarray(parent.translation_xyz_m, dtype=float)
)
return relative_rotation, relative_translation
def select_rigid_group_trajectory(
frames: Sequence[Mapping[str, Sequence[SquareTagPose]]],
*,
roles: Sequence[str],
fixed_pairs: Sequence[tuple[str, str]],
reprojection_scale_px: float,
rotation_scale_rad: float,
translation_scale_m: float,
pair_geometry: str = "pose",
) -> tuple[
list[dict[str, SquareTagPose]],
dict[str, float | str],
]:
"""Resolve planar branches using geometry that should stay rigid.
Every possible branch combination in the first frame is treated as a
candidate rigid reference. For each such reference, every later frame
independently chooses the combination with the lowest reprojection plus
geometric-drift cost. ``pose`` compares relative rotation and translation;
``distance`` compares only Euclidean centre distances and therefore does
not allow planar-PnP orientation jitter into centre-trajectory angles.
The globally cheapest reference and path win.
"""
role_names = tuple(str(role) for role in roles)
pair_names = tuple((str(parent), str(child)) for parent, child in fixed_pairs)
if not frames:
raise ValueError("at least one PnP frame is required")
if len(set(role_names)) != len(role_names) or not role_names:
raise ValueError("roles must be non-empty and unique")
if any(
parent not in role_names or child not in role_names
for parent, child in pair_names
):
raise ValueError("fixed_pairs must reference roles")
reprojection_scale = float(reprojection_scale_px)
rotation_scale = float(rotation_scale_rad)
translation_scale = float(translation_scale_m)
geometry_mode = str(pair_geometry)
if min(reprojection_scale, rotation_scale, translation_scale) <= 0.0:
raise ValueError("trajectory selection scales must be positive")
if geometry_mode not in {"pose", "distance"}:
raise ValueError("pair_geometry must be pose or distance")
combinations_by_frame: list[list[dict[str, SquareTagPose]]] = []
for frame in frames:
candidate_lists = [tuple(frame.get(role, ())) for role in role_names]
if any(not candidates for candidates in candidate_lists):
raise ValueError("every frame must contain every requested role")
combinations_by_frame.append(
[
dict(zip(role_names, combination))
for combination in product(*candidate_lists)
]
)
best_total = float("inf")
best_path: list[dict[str, SquareTagPose]] | None = None
def emission(
combination: Mapping[str, SquareTagPose],
reference_pairs: Mapping[
tuple[str, str], tuple[Rotation, np.ndarray]
],
reference_distances: Mapping[tuple[str, str], float],
) -> tuple[float, float, float]:
reprojection_cost = sum(
pose.reprojection_error_px
for pose in combination.values()
) / reprojection_scale
rotation_drifts: list[float] = []
translation_drifts: list[float] = []
distance_drifts: list[float] = []
for pair, (
reference_rotation,
reference_translation,
) in reference_pairs.items():
rotation, translation = _relative_pose(
combination[pair[0]],
combination[pair[1]],
)
rotation_drifts.append(
float(
(reference_rotation.inv() * rotation).magnitude()
)
)
translation_drifts.append(
float(
np.linalg.norm(
translation - reference_translation
)
)
)
current_distance = float(
np.linalg.norm(
np.asarray(
combination[pair[1]].translation_xyz_m,
dtype=float,
)
- np.asarray(
combination[pair[0]].translation_xyz_m,
dtype=float,
)
)
)
distance_drifts.append(
abs(current_distance - reference_distances[pair])
)
if geometry_mode == "distance":
geometry_cost = sum(distance_drifts) / translation_scale
else:
geometry_cost = (
sum(rotation_drifts) / rotation_scale
+ sum(translation_drifts) / translation_scale
)
return (
reprojection_cost + geometry_cost,
max(rotation_drifts, default=0.0),
(
max(distance_drifts, default=0.0)
if geometry_mode == "distance"
else max(translation_drifts, default=0.0)
),
)
def transition_cost(
previous: Mapping[str, SquareTagPose],
current: Mapping[str, SquareTagPose],
) -> float:
rotation_motion = sum(
rotation_distance_rad(
previous[role].quaternion_xyzw,
current[role].quaternion_xyzw,
)
for role in role_names
)
translation_motion = sum(
float(
np.linalg.norm(
np.asarray(current[role].translation_xyz_m)
- np.asarray(previous[role].translation_xyz_m)
)
)
for role in role_names
)
if geometry_mode == "distance":
return translation_motion / translation_scale
return (
rotation_motion / rotation_scale
+ translation_motion / translation_scale
)
for reference_index, reference_combination in enumerate(
combinations_by_frame[0]
):
reference_pairs = {
pair: _relative_pose(
reference_combination[pair[0]],
reference_combination[pair[1]],
)
for pair in pair_names
}
reference_distances = {
pair: float(
np.linalg.norm(
np.asarray(
reference_combination[pair[1]].translation_xyz_m,
dtype=float,
)
- np.asarray(
reference_combination[pair[0]].translation_xyz_m,
dtype=float,
)
)
)
for pair in pair_names
}
first_emission = emission(
reference_combination,
reference_pairs,
reference_distances,
)
previous_costs = np.full(
len(combinations_by_frame[0]),
np.inf,
dtype=float,
)
previous_costs[reference_index] = first_emission[0]
back_pointers: list[list[int]] = []
for frame_index in range(1, len(combinations_by_frame)):
previous_combinations = combinations_by_frame[frame_index - 1]
combinations = combinations_by_frame[frame_index]
frame_emissions = [
emission(
combination,
reference_pairs,
reference_distances,
)
for combination in combinations
]
current_costs = np.full(len(combinations), np.inf, dtype=float)
frame_back_pointers: list[int] = []
for current_index, combination in enumerate(combinations):
transition_costs = [
previous_costs[previous_index]
+ transition_cost(
previous_combination,
combination,
)
for previous_index, previous_combination in enumerate(
previous_combinations
)
]
best_previous = int(np.argmin(transition_costs))
frame_back_pointers.append(best_previous)
current_costs[current_index] = (
transition_costs[best_previous]
+ frame_emissions[current_index][0]
)
back_pointers.append(frame_back_pointers)
previous_costs = current_costs
final_index = int(np.argmin(previous_costs))
total = float(previous_costs[final_index])
path_indices = [final_index]
for frame_back_pointers in reversed(back_pointers):
path_indices.append(
frame_back_pointers[path_indices[-1]]
)
path_indices.reverse()
path = [
combinations[index]
for combinations, index in zip(
combinations_by_frame,
path_indices,
)
]
if total < best_total:
best_total = total
best_path = path
if best_path is None:
raise RuntimeError("trajectory branch selection produced no path")
# The marker-to-marker mounting transforms are unknown, so the rigid
# reference must be estimated from the complete sweep. Using frame zero
# as both the optimisation seed and the reported quality reference made
# one noisy endpoint frame look like drift in every other frame. A
# rotation medoid and component-wise translation median are insensitive
# to that endpoint noise while still exposing a persistent mirror branch.
robust_reference_pairs: dict[
tuple[str, str], tuple[Rotation, np.ndarray]
] = {}
for pair in pair_names:
pair_poses = [
_relative_pose(frame[pair[0]], frame[pair[1]])
for frame in best_path
]
pair_rotations = [pose[0] for pose in pair_poses]
angular_costs = np.asarray(
[
sum(
float((candidate.inv() * other).magnitude())
for other in pair_rotations
)
for candidate in pair_rotations
],
dtype=float,
)
rotation_medoid = pair_rotations[int(np.argmin(angular_costs))]
translation_median = np.median(
np.asarray([pose[1] for pose in pair_poses], dtype=float),
axis=0,
)
robust_reference_pairs[pair] = (
rotation_medoid,
translation_median,
)
rotation_drifts_by_frame: list[float] = []
translation_drifts_by_frame: list[float] = []
pair_distances_by_pair = {
pair: np.asarray(
[
np.linalg.norm(
np.asarray(frame[pair[1]].translation_xyz_m, dtype=float)
- np.asarray(
frame[pair[0]].translation_xyz_m, dtype=float
)
)
for frame in best_path
],
dtype=float,
)
for pair in pair_names
}
robust_pair_distances = {
pair: float(np.median(distances))
for pair, distances in pair_distances_by_pair.items()
}
distance_drifts_by_frame: list[float] = []
for frame in best_path:
frame_rotation_drifts: list[float] = []
frame_translation_drifts: list[float] = []
frame_distance_drifts: list[float] = []
for pair, (
reference_rotation,
reference_translation,
) in robust_reference_pairs.items():
rotation, translation = _relative_pose(
frame[pair[0]], frame[pair[1]]
)
frame_rotation_drifts.append(
float((reference_rotation.inv() * rotation).magnitude())
)
frame_translation_drifts.append(
float(np.linalg.norm(translation - reference_translation))
)
distance = float(
np.linalg.norm(
np.asarray(frame[pair[1]].translation_xyz_m, dtype=float)
- np.asarray(
frame[pair[0]].translation_xyz_m, dtype=float
)
)
)
frame_distance_drifts.append(
abs(distance - robust_pair_distances[pair])
)
rotation_drifts_by_frame.append(
max(frame_rotation_drifts, default=0.0)
)
translation_drifts_by_frame.append(
max(frame_translation_drifts, default=0.0)
)
distance_drifts_by_frame.append(
max(frame_distance_drifts, default=0.0)
)
rotation_drifts = np.asarray(rotation_drifts_by_frame, dtype=float)
translation_drifts = np.asarray(
translation_drifts_by_frame, dtype=float
)
distance_drifts = np.asarray(distance_drifts_by_frame, dtype=float)
return best_path, {
"total_cost": float(best_total),
"pair_geometry": geometry_mode,
"maximum_pair_rotation_drift_rad": float(
np.max(rotation_drifts, initial=0.0)
),
"p95_pair_rotation_drift_rad": float(
np.percentile(rotation_drifts, 95.0)
),
"median_pair_rotation_drift_rad": float(
np.median(rotation_drifts)
),
"maximum_pair_translation_drift_m": float(
np.max(translation_drifts, initial=0.0)
),
"p95_pair_translation_drift_m": float(
np.percentile(translation_drifts, 95.0)
),
"maximum_pair_distance_drift_m": float(
np.max(distance_drifts, initial=0.0)
),
"p95_pair_distance_drift_m": float(
np.percentile(distance_drifts, 95.0)
),
"median_pair_distance_drift_m": float(
np.median(distance_drifts)
),
}
def _as_camera_matrix(camera_matrix: Sequence[Sequence[float]]) -> np.ndarray:
matrix = np.asarray(camera_matrix, dtype=np.float64)
if matrix.shape != (3, 3):
raise ValueError("camera_matrix must have shape (3, 3)")
if not np.all(np.isfinite(matrix)):
raise ValueError("camera_matrix must be finite")
if matrix[0, 0] <= 0.0 or matrix[1, 1] <= 0.0:
raise ValueError("camera focal lengths must be positive")
return matrix
def square_object_points(tag_size_m: float) -> np.ndarray:
"""Return IPPE-square points matching apriltag_msgs corner order.
``apriltag_ros`` reports bottom-left, bottom-right, top-right, top-left.
OpenCV's ``SOLVEPNP_IPPE_SQUARE`` requires the same physical corners in
the order below.
"""
size = float(tag_size_m)
if not math.isfinite(size) or size <= 0.0:
raise ValueError("tag_size_m must be finite and positive")
half = size / 2.0
return np.asarray(
[
[-half, half, 0.0],
[half, half, 0.0],
[half, -half, 0.0],
[-half, -half, 0.0],
],
dtype=np.float64,
)
def solve_square_tag_ippe(
corners_xy: Sequence[Sequence[float]],
*,
tag_size_m: float,
camera_matrix: Sequence[Sequence[float]],
) -> list[SquareTagPose]:
"""Return every finite, positive-depth IPPE pose for one square tag."""
image_points = np.asarray(corners_xy, dtype=np.float64)
if image_points.shape != (4, 2):
raise ValueError("corners_xy must have shape (4, 2)")
if not np.all(np.isfinite(image_points)):
raise ValueError("corners_xy must be finite")
intrinsic = _as_camera_matrix(camera_matrix)
object_points = square_object_points(tag_size_m)
distortion = np.zeros((4, 1), dtype=np.float64)
solved, rotation_vectors, translations, _ = cv2.solvePnPGeneric(
object_points,
image_points,
intrinsic,
distortion,
flags=cv2.SOLVEPNP_IPPE_SQUARE,
)
if not solved:
return []
candidates: list[SquareTagPose] = []
for rotation_vector, translation in zip(rotation_vectors, translations):
rotation_matrix, _ = cv2.Rodrigues(rotation_vector)
translation_vector = np.asarray(translation, dtype=float).reshape(3)
camera_points = (
rotation_matrix @ object_points.T
+ translation_vector.reshape(3, 1)
).T
if np.min(camera_points[:, 2]) <= 0.0:
continue
projected, _ = cv2.projectPoints(
object_points,
rotation_vector,
translation_vector,
intrinsic,
distortion,
)
residual = projected.reshape(4, 2) - image_points
reprojection_error = float(
np.sqrt(np.mean(np.sum(residual * residual, axis=1)))
)
quaternion = Rotation.from_matrix(rotation_matrix).as_quat()
if not (
np.all(np.isfinite(quaternion))
and np.all(np.isfinite(translation_vector))
and math.isfinite(reprojection_error)
):
continue
candidates.append(
SquareTagPose(
quaternion_xyzw=tuple(float(value) for value in quaternion),
translation_xyz_m=tuple(
float(value) for value in translation_vector
),
reprojection_error_px=reprojection_error,
)
)
return candidates
def rotation_distance_rad(
first_xyzw: Sequence[float],
second_xyzw: Sequence[float],
) -> float:
first = Rotation.from_quat(np.asarray(first_xyzw, dtype=float))
second = Rotation.from_quat(np.asarray(second_xyzw, dtype=float))
return float((first.inv() * second).magnitude())
def select_continuous_pose(
candidates: Sequence[SquareTagPose],
*,
previous: SquareTagPose | None,
maximum_reprojection_error_px: float,
reprojection_tie_px: float,
maximum_pose_jump_rad: float,
maximum_translation_jump_m: float,
maximum_tag_tilt_rad: float,
) -> tuple[SquareTagPose | None, str]:
"""Select the best IPPE branch using image fit and temporal continuity."""
maximum_error = float(maximum_reprojection_error_px)
tie_error = float(reprojection_tie_px)
maximum_rotation = float(maximum_pose_jump_rad)
maximum_translation = float(maximum_translation_jump_m)
maximum_tilt = float(maximum_tag_tilt_rad)
if min(
maximum_error,
maximum_rotation,
maximum_translation,
maximum_tilt,
) <= 0.0:
raise ValueError("PnP selection thresholds must be positive")
if tie_error < 0.0:
raise ValueError("reprojection_tie_px must be non-negative")
eligible: list[SquareTagPose] = []
for candidate in candidates:
if candidate.reprojection_error_px > maximum_error:
continue
normal = Rotation.from_quat(candidate.quaternion_xyzw).as_matrix()[:, 2]
tilt = math.acos(float(np.clip(abs(normal[2]), 0.0, 1.0)))
if tilt > maximum_tilt:
continue
eligible.append(candidate)
if not eligible:
return None, "no_pose_within_reprojection_or_tilt_limit"
eligible.sort(key=lambda item: item.reprojection_error_px)
best = eligible[0]
if previous is None:
return best, ""
# Temporal continuity must only break a genuine planar-PnP tie. The old
# implementation normalised reprojection error by the permissive 1.5 px
# rejection limit, which allowed a stale mirror branch at 0.25 px to beat
# the true branch at e.g. 0.05 px merely because it was closer to the
# preceding (already wrong) pose. Once one IPPE solution has a meaningful
# image-fit advantage, trust it and allow the tracker to leave the stale
# branch even if that correction is a large pose jump.
competitive = [
candidate
for candidate in eligible
if candidate.reprojection_error_px
<= best.reprojection_error_px + tie_error
]
if len(competitive) == 1:
return best, ""
previous_translation = np.asarray(previous.translation_xyz_m, dtype=float)
scored: list[tuple[float, SquareTagPose]] = []
for candidate in competitive:
rotation_jump = rotation_distance_rad(
previous.quaternion_xyzw,
candidate.quaternion_xyzw,
)
translation_jump = float(
np.linalg.norm(
np.asarray(candidate.translation_xyz_m, dtype=float)
- previous_translation
)
)
if (
rotation_jump > maximum_rotation
or translation_jump > maximum_translation
):
continue
score = (
(
candidate.reprojection_error_px
- best.reprojection_error_px
)
/ max(tie_error, np.finfo(float).eps)
+ rotation_jump / maximum_rotation
+ translation_jump / maximum_translation
)
scored.append((float(score), candidate))
if not scored:
return None, "pose_jump"
selected = min(scored, key=lambda item: item[0])[1]
previous_quaternion = np.asarray(previous.quaternion_xyzw, dtype=float)
selected_quaternion = np.asarray(selected.quaternion_xyzw, dtype=float)
if float(np.dot(previous_quaternion, selected_quaternion)) < 0.0:
selected = replace(
selected,
quaternion_xyzw=tuple(
float(value) for value in -selected_quaternion
),
)
return selected, ""
class SquareTagPoseTracker:
"""Maintain the selected planar-PnP branch independently for each tag."""
def __init__(
self,
*,
maximum_reprojection_error_px: float,
reprojection_tie_px: float,
maximum_pose_jump_rad: float,
maximum_translation_jump_m: float,
maximum_tag_tilt_rad: float,
reset_after_seconds: float,
) -> None:
self.maximum_reprojection_error_px = float(
maximum_reprojection_error_px
)
self.reprojection_tie_px = float(reprojection_tie_px)
self.maximum_pose_jump_rad = float(maximum_pose_jump_rad)
self.maximum_translation_jump_m = float(maximum_translation_jump_m)
self.maximum_tag_tilt_rad = float(maximum_tag_tilt_rad)
self.reset_after_ns = int(float(reset_after_seconds) * 1_000_000_000)
if self.reset_after_ns <= 0:
raise ValueError("reset_after_seconds must be positive")
self._previous: dict[str, tuple[int, SquareTagPose]] = {}
self.last_candidates_by_role: dict[
str, tuple[SquareTagPose, ...]
] = {}
self.branch_correction_counts: dict[str, int] = {}
def reset(self) -> None:
self._previous.clear()
self.last_candidates_by_role.clear()
self.branch_correction_counts.clear()
def estimate(
self,
role: str,
corners_xy: Sequence[Sequence[float]],
*,
tag_size_m: float,
camera_matrix: Sequence[Sequence[float]],
stamp_ns: int,
reprojection_tie_px: float | None = None,
) -> tuple[SquareTagPose | None, str]:
try:
candidates = solve_square_tag_ippe(
corners_xy,
tag_size_m=tag_size_m,
camera_matrix=camera_matrix,
)
except (ValueError, cv2.error):
self.last_candidates_by_role[str(role)] = ()
return None, "pnp_solve_failed"
if not candidates:
self.last_candidates_by_role[str(role)] = ()
return None, "pnp_solve_failed"
usable_candidates: list[SquareTagPose] = []
for candidate in candidates:
normal = Rotation.from_quat(
candidate.quaternion_xyzw
).as_matrix()[:, 2]
tilt = math.acos(
float(np.clip(abs(normal[2]), 0.0, 1.0))
)
if (
candidate.reprojection_error_px
<= self.maximum_reprojection_error_px
and tilt <= self.maximum_tag_tilt_rad
):
usable_candidates.append(candidate)
self.last_candidates_by_role[str(role)] = tuple(
usable_candidates
)
if not usable_candidates:
return None, "no_pose_within_reprojection_or_tilt_limit"
previous_record = self._previous.get(str(role))
previous: SquareTagPose | None = None
if previous_record is not None:
previous_stamp, previous_pose = previous_record
elapsed = int(stamp_ns) - previous_stamp
if 0 <= elapsed <= self.reset_after_ns:
previous = previous_pose
selected, reason = select_continuous_pose(
usable_candidates,
previous=previous,
maximum_reprojection_error_px=(
self.maximum_reprojection_error_px
),
reprojection_tie_px=(
self.reprojection_tie_px
if reprojection_tie_px is None
else float(reprojection_tie_px)
),
maximum_pose_jump_rad=self.maximum_pose_jump_rad,
maximum_translation_jump_m=self.maximum_translation_jump_m,
maximum_tag_tilt_rad=self.maximum_tag_tilt_rad,
)
if selected is not None:
if previous is not None:
rotation_jump = rotation_distance_rad(
previous.quaternion_xyzw,
selected.quaternion_xyzw,
)
translation_jump = float(
np.linalg.norm(
np.asarray(selected.translation_xyz_m, dtype=float)
- np.asarray(
previous.translation_xyz_m,
dtype=float,
)
)
)
if (
rotation_jump > self.maximum_pose_jump_rad
or translation_jump
> self.maximum_translation_jump_m
):
key = str(role)
self.branch_correction_counts[key] = (
self.branch_correction_counts.get(key, 0) + 1
)
self._previous[str(role)] = (int(stamp_ns), selected)
return selected, reason
class SquareTagGroupPoseTracker:
"""Choose all tag branches together using thumb-chain continuity.
A 30 px planar tag has two IPPE solutions whose reprojection errors can
exchange order from one frame to the next. Tracking each tag
independently can therefore choose an incompatible pair for a relative
joint such as T4->T5. This tracker enumerates the small Cartesian product
(at most 2**4 combinations) and favours the combination that keeps both
the camera poses and all adjacent relative poses continuous.
"""
def __init__(
self,
*,
roles: Sequence[str],
adjacent_pairs: Sequence[tuple[str, str]],
maximum_pose_jump_rad: float,
maximum_translation_jump_m: float,
relative_rotation_scale_rad: float,
relative_translation_scale_m: float,
reprojection_scale_px: float,
reprojection_weight: float,
reset_after_seconds: float,
) -> None:
self.roles = tuple(str(role) for role in roles)
self.adjacent_pairs = tuple(
(str(parent), str(child))
for parent, child in adjacent_pairs
)
if not self.roles or len(set(self.roles)) != len(self.roles):
raise ValueError("roles must be non-empty and unique")
if any(
parent not in self.roles or child not in self.roles
for parent, child in self.adjacent_pairs
):
raise ValueError("adjacent_pairs must reference roles")
self.maximum_pose_jump_rad = float(maximum_pose_jump_rad)
self.maximum_translation_jump_m = float(
maximum_translation_jump_m
)
self.relative_rotation_scale_rad = float(
relative_rotation_scale_rad
)
self.relative_translation_scale_m = float(
relative_translation_scale_m
)
self.reprojection_scale_px = float(reprojection_scale_px)
self.reprojection_weight = float(reprojection_weight)
reset_seconds = float(reset_after_seconds)
if min(
self.maximum_pose_jump_rad,
self.maximum_translation_jump_m,
self.relative_rotation_scale_rad,
self.relative_translation_scale_m,
self.reprojection_scale_px,
reset_seconds,
) <= 0.0:
raise ValueError("group tracking scales must be positive")
if self.reprojection_weight < 0.0:
raise ValueError("reprojection_weight must be non-negative")
self.reset_after_ns = int(reset_seconds * 1_000_000_000)
self._previous: dict[str, SquareTagPose] = {}
self._previous_stamp_ns: int | None = None
self.branch_correction_counts: dict[str, int] = {}
def reset(self) -> None:
self._previous.clear()
self._previous_stamp_ns = None
self.branch_correction_counts.clear()
def select(
self,
candidates_by_role: Mapping[str, Sequence[SquareTagPose]],
*,
stamp_ns: int,
) -> tuple[dict[str, SquareTagPose] | None, str]:
"""Return one mutually consistent pose for every configured role."""
candidate_lists = [
tuple(candidates_by_role.get(role, ()))
for role in self.roles
]
if any(not candidates for candidates in candidate_lists):
return None, "group_missing_pose_candidates"
combinations = [
dict(zip(self.roles, combination))
for combination in product(*candidate_lists)
]
minimum_errors = {
role: min(
candidate.reprojection_error_px
for candidate in candidates
)
for role, candidates in zip(self.roles, candidate_lists)
}
stamp = int(stamp_ns)
previous_is_fresh = (
self._previous_stamp_ns is not None
and 0 <= stamp - self._previous_stamp_ns
<= self.reset_after_ns
and set(self._previous) == set(self.roles)
)
if not previous_is_fresh:
selected = min(
combinations,
key=lambda combination: sum(
pose.reprojection_error_px
for pose in combination.values()
),
)
else:
previous_relative = {
pair: _relative_pose(
self._previous[pair[0]],
self._previous[pair[1]],
)
for pair in self.adjacent_pairs
}
scored: list[tuple[float, dict[str, SquareTagPose]]] = []
for combination in combinations:
absolute_rotation_motion = 0.0
absolute_translation_motion = 0.0
rejected = False
for role in self.roles:
rotation_motion = rotation_distance_rad(
self._previous[role].quaternion_xyzw,
combination[role].quaternion_xyzw,
)
translation_motion = float(
np.linalg.norm(
np.asarray(
combination[role].translation_xyz_m,
dtype=float,
)
- np.asarray(
self._previous[role].translation_xyz_m,
dtype=float,
)
)
)
if (
rotation_motion > self.maximum_pose_jump_rad
or translation_motion
> self.maximum_translation_jump_m
):
rejected = True
break
absolute_rotation_motion += rotation_motion
absolute_translation_motion += translation_motion
if rejected:
continue
relative_rotation_motion = 0.0
relative_translation_motion = 0.0
for pair in self.adjacent_pairs:
rotation, translation = _relative_pose(
combination[pair[0]],
combination[pair[1]],
)
old_rotation, old_translation = previous_relative[pair]
relative_rotation_motion += float(
(old_rotation.inv() * rotation).magnitude()
)
relative_translation_motion += float(
np.linalg.norm(translation - old_translation)
)
reprojection_penalty = sum(
max(
0.0,
combination[role].reprojection_error_px
- minimum_errors[role],
)
for role in self.roles
) / self.reprojection_scale_px
score = (
absolute_rotation_motion
/ self.maximum_pose_jump_rad
+ absolute_translation_motion
/ self.maximum_translation_jump_m
+ relative_rotation_motion
/ self.relative_rotation_scale_rad
+ relative_translation_motion
/ self.relative_translation_scale_m
+ self.reprojection_weight * reprojection_penalty
)
scored.append((float(score), combination))
if not scored:
return None, "group_pose_jump"
selected = min(scored, key=lambda item: item[0])[1]
aligned: dict[str, SquareTagPose] = {}
for role in self.roles:
pose = selected[role]
if previous_is_fresh:
old_quaternion = np.asarray(
self._previous[role].quaternion_xyzw,
dtype=float,
)
quaternion = np.asarray(
pose.quaternion_xyzw,
dtype=float,
)
if float(np.dot(old_quaternion, quaternion)) < 0.0:
pose = replace(
pose,
quaternion_xyzw=tuple(
float(value) for value in -quaternion
),
)
best_reprojection = min(
candidate_lists[self.roles.index(role)],
key=lambda candidate: candidate.reprojection_error_px,
)
if pose != best_reprojection:
self.branch_correction_counts[role] = (
self.branch_correction_counts.get(role, 0) + 1
)
aligned[role] = pose
self._previous = aligned
self._previous_stamp_ns = stamp
return dict(aligned), ""
@@ -1,306 +0,0 @@
"""Chinese, operator-facing diagnostics for three-camera calibration."""
from __future__ import annotations
from typing import Any, Mapping
STATE_NAMES_ZH = {
"PREFLIGHT": "设备和标签预检",
"WAIT_START": "等待开始标定",
"RETURN_BASELINE": "正在返回基准姿态",
"PREPARE_SWEEP": "正在到达扫描起点",
"SWEEP": "正在采集轨迹",
"FITTING": "正在拟合轨迹和零位",
"VALIDATION_MOVE": "正在移动到随机复测位置",
"VALIDATION_CAPTURE": "正在采集随机复测数据",
"PAUSED": "标定已暂停",
"ABORTED": "标定已终止",
"COMPLETE": "标定已完成",
}
VIEW_NAMES_ZH = {
"front": "正面",
"side": "侧面",
"top": "上面",
}
JOINT_NAMES_ZH = {
"thumb_cmc_pitch": "拇指CMC俯仰",
"thumb_cmc_roll": "拇指CMC滚转",
"thumb_mcp": "拇指MCP",
"thumb_ip": "拇指IP(被动)",
"index_mcp_roll": "食指MCP侧摆",
"index_mcp_pitch": "食指MCP屈伸",
"index_pip": "食指PIP",
"index_dip": "食指DIP(被动)",
"thumb_cmc_yaw": "拇指CMC侧摆",
}
def _format_u8(value: Any) -> str:
if value is None:
return "尚无反馈"
return f"{float(value):.1f}"
def _task_text(active: Mapping[str, Any]) -> str:
if not active:
return "尚无活动任务"
view = VIEW_NAMES_ZH.get(str(active.get("view", "")), str(active.get("view", "")))
if active.get("kind") == "fit_failure":
joints = active.get("joints", [])
joint_text = "/".join(
JOINT_NAMES_ZH.get(str(joint), str(joint)) for joint in joints
)
return (
f"{view}机位,{joint_text}拟合检查失败,"
f"电机{active.get('motor_index')},"
f"第{active.get('attempt', 1)}次尝试"
)
if active.get("kind") == "validation":
return (
f"{view}机位,随机复测,电机{active.get('motor_index')},"
f"目标命令{active.get('command_u8')}"
)
joints = active.get("joints", [])
joint_text = "/".join(
JOINT_NAMES_ZH.get(str(joint), str(joint)) for joint in joints
)
start = active.get("start_u8")
target = active.get("target_u8")
cycle = active.get("cycle", "?")
repetitions = active.get("repetitions", "?")
return (
f"{view}机位,{joint_text},电机{active.get('motor_index')},"
f"第{cycle}/{repetitions}轮,{start}→{target}"
)
def three_camera_reason_zh(
state: str,
reason: str,
active: Mapping[str, Any],
) -> tuple[str, str]:
"""Translate a reason code and provide one concrete operator action."""
reason = str(reason)
sample = active.get("sample", {}) if active else {}
missing = [int(value) for value in sample.get("missing_endpoint_u8", [])]
sample_range = (
f"{_format_u8(sample.get('minimum_u8'))}~"
f"{_format_u8(sample.get('maximum_u8'))}"
)
tolerance = sample.get("endpoint_tolerance_u8", "?")
if reason == "sweep_missing_endpoint_bin":
missing_text = "、".join(str(value) for value in missing) or "0或255"
return (
f"本方向已有{active.get('valid_frames', 0)}帧同步有效数据,但缺少"
f"电机端点{missing_text}附近的有效分箱;采样到的实际电机范围为"
f"{sample_range},端点容差为±{tolerance}。这通常表示电机虽然运动到"
"端点,但该时刻没有同时取得有效Tag图像和电机状态。",
"确认当前机位所需Tag在整个行程(尤其缺失端点)均可见,然后调用"
"/g20_calibration/resume;程序会重新扫描当前方向,不要调用start。",
)
if reason == "sweep_bins_too_few":
return (
f"有效电机分箱只有{sample.get('bin_count', 0)}个,要求至少"
f"{sample.get('minimum_bin_count', '?')}个;当前采样范围{sample_range}。",
"检查Tag连续识别和电机状态频率,修正后调用resume重新扫描当前方向。",
)
if reason == "sweep_bin_gap_too_large":
return (
f"轨迹相邻有效电机分箱的最大空缺为{sample.get('maximum_bin_gap', '?')},"
f"允许值不超过{sample.get('allowed_maximum_bin_gap', '?')}。",
"检查运动中Tag是否间歇丢失;修正遮挡、反光或对焦后调用resume。",
)
if reason == "synchronised_tag_state_timeout":
return (
"运动过程中连续超过允许时间没有取得“所需Tag全部有效且能与电机状态"
"按时间戳配对”的图像帧。",
"查看下面活动机位的缺失Tag,确认状态话题仍在更新;修正后调用resume,"
"程序会重扫当前方向。",
)
if reason == "sweep_start_position_timeout":
return (
f"电机{active.get('motor_index')}未在规定时间到达扫描起点"
f"{active.get('start_u8')},当前实际值{_format_u8(active.get('actual_u8'))}。",
"检查CAN、机械手使能和是否存在机械卡阻,确认安全后调用resume。",
)
if reason == "sweep_timeout":
return (
"当前方向在规定时间内未完成端点到达、有效帧数和行程覆盖要求。",
"检查电机实际值、Tag连续识别和标定速度,修正后调用resume。",
)
if reason == "return_baseline_timeout":
return (
"一个或多个标定电机未在规定时间返回基准命令。",
"检查机械手状态、CAN和机械卡阻,确认安全后调用resume。",
)
if reason == "validation_move_timeout":
return (
"随机复测时电机未在规定时间到达目标命令。",
"检查机械手状态和机械卡阻,确认安全后调用resume。",
)
if reason == "validation_capture_timeout":
return (
"随机复测位置没有采集到足够的同步有效Tag帧。",
"检查当前机位Tag可见性后调用resume。",
)
if reason == "joint_fit_check_failed":
metric_names = {
"plane_rms_mm": "平面拟合RMS",
"radial_rms_mm": "圆半径拟合RMS",
"radius_mm": "拟合半径",
"image_radial_rms_px": "二维圆半径拟合RMS",
"image_radial_p95_px": "二维圆半径误差P95",
"image_radius_px": "二维拟合半径",
"arc_deg": "实测圆弧",
"monotonic_correction_deg": "最大单调修正",
"hysteresis_deg": "最大正反程差",
"cycle_travel_range_deg": "三轮行程差",
}
metric_units = {
"plane_rms_mm": "mm",
"radial_rms_mm": "mm",
"radius_mm": "mm",
"image_radial_rms_px": "px",
"image_radial_p95_px": "px",
"image_radius_px": "px",
"arc_deg": "°",
"monotonic_correction_deg": "°",
"hysteresis_deg": "°",
"cycle_travel_range_deg": "°",
}
details: list[str] = []
for failure in active.get("failures", []):
joint = JOINT_NAMES_ZH.get(
str(failure.get("joint")), str(failure.get("joint"))
)
metric = str(failure.get("metric", ""))
if metric in metric_names:
comparison = str(failure.get("comparison", ""))
requirement = "不超过" if comparison == "maximum" else "至少"
unit = metric_units[metric]
detail = (
f"{joint}的{metric_names[metric]}为"
f"{float(failure.get('actual', 0.0)):.2f}{unit},"
f"要求{requirement}{float(failure.get('limit', 0.0)):.2f}{unit}"
)
cycle_travel = failure.get("cycle_travel_deg", [])
if cycle_travel:
detail += "(三轮=" + "/".join(
f"{float(value):.2f}°" for value in cycle_travel
) + ")"
details.append(detail)
else:
cycle = failure.get("cycle")
cycle_text = "" if cycle is None else f"第{cycle}轮"
details.append(
f"{joint}的{cycle_text}{metric or '轨迹'}拟合失败:"
f"{failure.get('reason', '未知原因')}"
)
detail_text = ";".join(details) or "当前关节的轨迹拟合未通过"
return (
detail_text + "。程序已在当前关节结束后立即停止后续步骤。",
"修正Tag位置、遮挡或机械行程后调用"
"/g20_calibration/resume;程序只清除当前失败关节的数据"
f"并重扫{active.get('directions_to_rescan', 6)}个方向,不要调用start。",
)
if reason in {"waiting_for_three_cameras_tags_and_sdk", "preflight_lost"}:
return (
"正在等待三台相机内参、帧率、全部必需Tag以及机械手SDK同时就绪。",
"根据下面每个机位的缺失Tag和有效率排查;全部就绪后程序会进入等待开始状态。",
)
if reason == "call_start":
return (
"三机位预检已经通过,等待操作员确认开始。",
"清空机械手运动范围后调用/g20_calibration/start。",
)
if reason == "operator_pause":
return "操作员主动暂停了标定。", "确认安全后调用/g20_calibration/resume。"
if reason == "operator_abort":
return "操作员终止了本次标定,程序保持终止时的当前姿态。", "需要重新启动一次新标定。"
if reason == "collecting_timestamp_synchronised_tag_centres":
return "正在按时间戳配对Tag图像和电机状态并采集当前轨迹。", "无需操作,保持相机、标签和底座不动。"
if reason == "capturing_random_validation_pose":
return "正在当前随机命令位置采集复测数据。", "无需操作,保持设备不动。"
if reason in {"calibration_passed", "calibration_complete"}:
return "轨迹、零位和随机复测已经完成。", "检查结果路径和quality.passed。"
if reason == "quality_failed":
return "标定流程完成,但拟合或随机复测质量没有达到验收阈值。", "检查最终JSON的quality以及启动终端中的拟合日志。"
if reason.startswith("prepare_") or state == "PREPARE_SWEEP":
return "正在把当前电机移动到本方向的扫描起点并等待稳定。", "无需操作。"
if state == "RETURN_BASELINE":
return "正在把已使用的标定电机恢复到统一基准命令。", "无需操作。"
if state == "FITTING":
return "所有扫描已经完成,正在拟合21个关节的轨迹和零位。", "无需操作。"
return f"未分类原因码:{reason}", "保留该原因码和启动终端日志用于进一步定位。"
def render_three_camera_status_text_zh(payload: Mapping[str, Any]) -> str:
"""Render the complete operator status; the JSON topic remains unchanged."""
state = str(payload.get("state", ""))
active = payload.get("active", {})
reason_zh, action_zh = three_camera_reason_zh(
state, str(payload.get("reason", "")), active
)
progress = float(payload.get("progress", 0.0))
completed = payload.get("completed_sweeps", 0)
total = payload.get("total_sweeps", 0)
lines = [
f"状态:{STATE_NAMES_ZH.get(state, state)}({state})",
f"原因:{reason_zh}",
f"建议:{action_zh}",
f"进度:{progress:.1%}(已完成{completed}/{total}个扫描方向)",
f"当前任务:{_task_text(active)}",
]
if state == "RETURN_BASELINE":
lines.append(
f"正在确认基准姿态:{payload.get('baseline_command_u8', [])}"
)
if active and active.get("kind") != "fit_failure":
sample = active.get("sample", {})
motion_progress = active.get("motion_progress")
motion_text = (
"未知" if motion_progress is None else f"{float(motion_progress):.1%}"
)
lines.append(
"运动采样:"
f"目标{active.get('target_u8', active.get('command_u8', '?'))},"
f"实际{_format_u8(active.get('actual_u8'))},"
f"本方向{motion_text},有效帧{active.get('valid_frames', 0)},"
f"实际采样范围{_format_u8(sample.get('minimum_u8'))}~"
f"{_format_u8(sample.get('maximum_u8'))}"
)
auxiliary = active.get("auxiliary_motors", [])
if auxiliary:
lines.append(
"避挡姿态:"
+ ",".join(
f"电机{item.get('motor_index')}目标"
f"{item.get('command_u8')}、实际"
f"{_format_u8(item.get('actual_u8'))}"
for item in auxiliary
)
)
speed = active.get("speed", {})
if speed:
lines.append(
"阶段速度:五指目标"
f"{speed.get('commanded_finger_speed')},SDK报告"
f"{speed.get('reported_finger_speed')}"
)
lines.append("机位:")
for name, view in payload.get("views", {}).items():
missing = view.get("missing_tag_ids", [])
missing_text = "无" if not missing else ",".join(map(str, missing))
lines.append(
f"- {VIEW_NAMES_ZH.get(str(name), str(name))}:"
f"{'就绪' if view.get('ready') else '等待'},"
f"{float(view.get('detection_hz', 0.0)):.1f}Hz,"
f"全部必需Tag同时有效率{float(view.get('valid_rate', 0.0)):.1%},"
f"当前缺失Tag={missing_text}"
)
lines.append(f"结果:{payload.get('result_path') or '尚未生成'}")
return "\n".join(lines)
File diff suppressed because it is too large Load Diff
@@ -1,272 +0,0 @@
"""Launch front-camera trajectory-circle CMC pitch zero measurement."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import re
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import (
DeclareLaunchArgument,
LogInfo,
OpaqueFunction,
SetEnvironmentVariable,
)
from launch.conditions import IfCondition
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import ComposableNodeContainer, Node
from launch_ros.descriptions import ComposableNode
from launch_ros.parameter_descriptions import ParameterValue
def _launch_stack(context):
serial_number = LaunchConfiguration("serial_number").perform(context)
if (
not serial_number
or serial_number == "UNSET"
or re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None
or serial_number in {".", ".."}
):
raise RuntimeError(
"serial_number is required and may contain only letters, "
"digits, dot, underscore and dash"
)
requested_session = LaunchConfiguration("session_dir").perform(context)
output_root = Path(
LaunchConfiguration("output_root").perform(context)
).expanduser().resolve()
if requested_session:
session_dir = Path(requested_session).expanduser().resolve()
else:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session_dir = output_root / serial_number / timestamp
session_dir.mkdir(parents=True, exist_ok=True)
tag_config = LaunchConfiguration("tag_config").perform(context)
zero_config = LaunchConfiguration("zero_config").perform(context)
camera = Node(
package="g20_thumb_apriltag_calibration",
executable="hikrobot_camera_node",
name="hikrobot_camera",
namespace="/camera/camera/color",
output="screen",
emulate_tty=True,
condition=IfCondition(LaunchConfiguration("start_camera")),
parameters=[
{
"serial_number": LaunchConfiguration("camera_serial_number"),
"expected_model": LaunchConfiguration("camera_model"),
"camera_name": LaunchConfiguration("camera_name"),
"frame_id": LaunchConfiguration("camera_frame_id"),
"image_width": ParameterValue(
LaunchConfiguration("image_width"), value_type=int
),
"image_height": ParameterValue(
LaunchConfiguration("image_height"), value_type=int
),
"frame_rate": ParameterValue(
LaunchConfiguration("camera_frame_rate"), value_type=float
),
"exposure_time_us": ParameterValue(
LaunchConfiguration("exposure_time_us"), value_type=float
),
"gain_db": ParameterValue(
LaunchConfiguration("gain_db"), value_type=float
),
"auto_exposure": ParameterValue(
LaunchConfiguration("auto_exposure"), value_type=bool
),
"camera_info_url": LaunchConfiguration("camera_info_url"),
}
],
)
raw_topic = "/camera/camera/color/image_raw"
camera_info_topic = "/camera/camera/color/camera_info"
rect_topic = "/camera/camera/color/image_rect"
vision_container = ComposableNodeContainer(
name="g20_thumb_zero_vision_container",
namespace="/",
package="rclcpp_components",
executable="component_container_mt",
composable_node_descriptions=[
ComposableNode(
package="image_proc",
plugin="image_proc::RectifyNode",
name="rectify_color",
namespace="/camera/camera/color",
remappings=[
("image", raw_topic),
("camera_info", camera_info_topic),
("image_rect", rect_topic),
],
parameters=[{"queue_size": 1}],
extra_arguments=[{"use_intra_process_comms": True}],
),
ComposableNode(
package="apriltag_ros",
plugin="AprilTagNode",
name="apriltag",
namespace="/apriltag",
parameters=[
tag_config,
{
"detector.decimate": ParameterValue(
LaunchConfiguration("apriltag_decimate"),
value_type=float,
)
},
],
remappings=[
("image_rect", rect_topic),
("camera_info", camera_info_topic),
],
extra_arguments=[{"use_intra_process_comms": True}],
),
],
output="screen",
emulate_tty=True,
)
sdk = Node(
package="linker_hand_ros2_sdk",
executable="linker_hand_sdk",
name="linker_hand_sdk",
output="screen",
condition=IfCondition(LaunchConfiguration("start_sdk")),
parameters=[
{
"hand_type": "left",
"hand_joint": "G20",
"can": LaunchConfiguration("can_interface"),
"modbus": "None",
"topic_prefix": "/g20",
"move_on_startup": False,
"startup_speed": ParameterValue(
LaunchConfiguration("calibration_speed"),
value_type=int,
),
"startup_torque": 80,
"state_poll_rate": 10.0,
"repeat_position_commands": False,
"is_touch": False,
}
],
)
zero_node = Node(
package="g20_thumb_apriltag_calibration",
executable="cmc_pitch_zero_node",
name="g20_thumb_cmc_pitch_zero",
output="screen",
parameters=[
zero_config,
{
"serial_number": serial_number,
"session_dir": str(session_dir),
"commands_enabled": ParameterValue(
LaunchConfiguration("commands_enabled"),
value_type=bool,
),
"image_topic": rect_topic,
"publish_debug_image": ParameterValue(
LaunchConfiguration("publish_debug_image"),
value_type=bool,
),
},
],
)
return [
LogInfo(msg=f"G20 CMC pitch zero session: {session_dir}"),
LogInfo(
msg=(
"Only T0(ID 0) and T3(ID 1) are required; "
"T4/T5 detections are ignored"
)
),
LogInfo(
msg=(
"Motor 0 performs three 255->64->255 sweeps; "
"zero angles come from the fitted T3-centre trajectory radius"
)
),
camera,
vision_container,
sdk,
zero_node,
]
def generate_launch_description() -> LaunchDescription:
package_share = Path(
get_package_share_directory("g20_thumb_apriltag_calibration")
)
return LaunchDescription(
[
SetEnvironmentVariable(
name="FASTRTPS_DEFAULT_PROFILES_FILE",
value=str(
package_share / "config" / "fastdds_large_images.xml"
),
),
DeclareLaunchArgument("serial_number", default_value="UNSET"),
DeclareLaunchArgument(
"camera_serial_number", default_value="DB2163742"
),
DeclareLaunchArgument(
"camera_model", default_value="MV-CS020-10UM"
),
DeclareLaunchArgument(
"camera_name", default_value="hikrobot_front_DB2163742"
),
DeclareLaunchArgument(
"camera_frame_id", default_value="camera_color_optical_frame"
),
DeclareLaunchArgument("image_width", default_value="1624"),
DeclareLaunchArgument("image_height", default_value="1240"),
DeclareLaunchArgument("camera_frame_rate", default_value="30.0"),
DeclareLaunchArgument("exposure_time_us", default_value="5000.0"),
DeclareLaunchArgument("gain_db", default_value="0.0"),
DeclareLaunchArgument("auto_exposure", default_value="false"),
DeclareLaunchArgument(
"camera_info_url",
default_value=str(
Path.home()
/ ".ros"
/ "camera_info"
/ "hikrobot_DB2163742.yaml"
),
),
DeclareLaunchArgument("can_interface", default_value="can0"),
DeclareLaunchArgument("calibration_speed", default_value="15"),
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
DeclareLaunchArgument("commands_enabled", default_value="true"),
DeclareLaunchArgument(
"publish_debug_image", default_value="true"
),
DeclareLaunchArgument("start_camera", default_value="true"),
DeclareLaunchArgument("start_sdk", default_value="true"),
DeclareLaunchArgument(
"output_root",
default_value=str(Path.cwd() / "calibration_output"),
),
DeclareLaunchArgument("session_dir", default_value=""),
DeclareLaunchArgument(
"zero_config",
default_value=str(
package_share / "config" / "cmc_pitch_zero.yaml"
),
),
DeclareLaunchArgument(
"tag_config",
default_value=str(
package_share / "config" / "front_tags.yaml"
),
),
OpaqueFunction(function=_launch_stack),
]
)
@@ -1,276 +0,0 @@
"""Launch front-camera trajectory-circle CMC roll zero/travel calibration."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import re
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import (
DeclareLaunchArgument,
LogInfo,
OpaqueFunction,
SetEnvironmentVariable,
)
from launch.conditions import IfCondition
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import ComposableNodeContainer, Node
from launch_ros.descriptions import ComposableNode
from launch_ros.parameter_descriptions import ParameterValue
def _launch_stack(context):
serial_number = LaunchConfiguration("serial_number").perform(context)
if (
not serial_number
or serial_number == "UNSET"
or re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None
or serial_number in {".", ".."}
):
raise RuntimeError(
"serial_number is required and may contain only letters, "
"digits, dot, underscore and dash"
)
requested_session = LaunchConfiguration("session_dir").perform(context)
output_root = Path(
LaunchConfiguration("output_root").perform(context)
).expanduser().resolve()
if requested_session:
session_dir = Path(requested_session).expanduser().resolve()
else:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session_dir = output_root / serial_number / timestamp
session_dir.mkdir(parents=True, exist_ok=True)
tag_config = LaunchConfiguration("tag_config").perform(context)
calibration_config = LaunchConfiguration(
"calibration_config"
).perform(context)
camera = Node(
package="g20_thumb_apriltag_calibration",
executable="hikrobot_camera_node",
name="hikrobot_camera",
namespace="/camera/camera/color",
output="screen",
emulate_tty=True,
condition=IfCondition(LaunchConfiguration("start_camera")),
parameters=[
{
"serial_number": LaunchConfiguration("camera_serial_number"),
"expected_model": LaunchConfiguration("camera_model"),
"camera_name": LaunchConfiguration("camera_name"),
"frame_id": LaunchConfiguration("camera_frame_id"),
"image_width": ParameterValue(
LaunchConfiguration("image_width"), value_type=int
),
"image_height": ParameterValue(
LaunchConfiguration("image_height"), value_type=int
),
"frame_rate": ParameterValue(
LaunchConfiguration("camera_frame_rate"), value_type=float
),
"exposure_time_us": ParameterValue(
LaunchConfiguration("exposure_time_us"), value_type=float
),
"gain_db": ParameterValue(
LaunchConfiguration("gain_db"), value_type=float
),
"auto_exposure": ParameterValue(
LaunchConfiguration("auto_exposure"), value_type=bool
),
"camera_info_url": LaunchConfiguration("camera_info_url"),
}
],
)
raw_topic = "/camera/camera/color/image_raw"
camera_info_topic = "/camera/camera/color/camera_info"
rect_topic = "/camera/camera/color/image_rect"
vision_container = ComposableNodeContainer(
name="g20_thumb_roll_vision_container",
namespace="/",
package="rclcpp_components",
executable="component_container_mt",
composable_node_descriptions=[
ComposableNode(
package="image_proc",
plugin="image_proc::RectifyNode",
name="rectify_color",
namespace="/camera/camera/color",
remappings=[
("image", raw_topic),
("camera_info", camera_info_topic),
("image_rect", rect_topic),
],
parameters=[{"queue_size": 1}],
extra_arguments=[{"use_intra_process_comms": True}],
),
ComposableNode(
package="apriltag_ros",
plugin="AprilTagNode",
name="apriltag",
namespace="/apriltag",
parameters=[
tag_config,
{
"detector.decimate": ParameterValue(
LaunchConfiguration("apriltag_decimate"),
value_type=float,
)
},
],
remappings=[
("image_rect", rect_topic),
("camera_info", camera_info_topic),
],
extra_arguments=[{"use_intra_process_comms": True}],
),
],
output="screen",
emulate_tty=True,
)
sdk = Node(
package="linker_hand_ros2_sdk",
executable="linker_hand_sdk",
name="linker_hand_sdk",
output="screen",
condition=IfCondition(LaunchConfiguration("start_sdk")),
parameters=[
{
"hand_type": "left",
"hand_joint": "G20",
"can": LaunchConfiguration("can_interface"),
"modbus": "None",
"topic_prefix": "/g20",
"move_on_startup": False,
"startup_speed": ParameterValue(
LaunchConfiguration("calibration_speed"),
value_type=int,
),
"startup_torque": 80,
"state_poll_rate": 10.0,
"repeat_position_commands": False,
"is_touch": False,
}
],
)
calibration_node = Node(
package="g20_thumb_apriltag_calibration",
executable="cmc_roll_calibration_node",
name="g20_thumb_cmc_roll_calibration",
output="screen",
parameters=[
calibration_config,
{
"serial_number": serial_number,
"session_dir": str(session_dir),
"commands_enabled": ParameterValue(
LaunchConfiguration("commands_enabled"),
value_type=bool,
),
"image_topic": rect_topic,
"publish_debug_image": ParameterValue(
LaunchConfiguration("publish_debug_image"),
value_type=bool,
),
},
],
)
return [
LogInfo(msg=f"G20 CMC roll calibration session: {session_dir}"),
LogInfo(
msg=(
"Only T0(ID 0) and T3(ID 1) are required; "
"T4/T5 detections are ignored"
)
),
LogInfo(
msg=(
"Motor 5 performs three 255->0->255 sweeps; "
"static captures measure both zero and angular travel"
)
),
camera,
vision_container,
sdk,
calibration_node,
]
def generate_launch_description() -> LaunchDescription:
package_share = Path(
get_package_share_directory("g20_thumb_apriltag_calibration")
)
return LaunchDescription(
[
SetEnvironmentVariable(
name="FASTRTPS_DEFAULT_PROFILES_FILE",
value=str(
package_share / "config" / "fastdds_large_images.xml"
),
),
DeclareLaunchArgument("serial_number", default_value="UNSET"),
DeclareLaunchArgument(
"camera_serial_number", default_value="DB2163742"
),
DeclareLaunchArgument(
"camera_model", default_value="MV-CS020-10UM"
),
DeclareLaunchArgument(
"camera_name", default_value="hikrobot_front_DB2163742"
),
DeclareLaunchArgument(
"camera_frame_id", default_value="camera_color_optical_frame"
),
DeclareLaunchArgument("image_width", default_value="1624"),
DeclareLaunchArgument("image_height", default_value="1240"),
DeclareLaunchArgument("camera_frame_rate", default_value="30.0"),
DeclareLaunchArgument("exposure_time_us", default_value="5000.0"),
DeclareLaunchArgument("gain_db", default_value="0.0"),
DeclareLaunchArgument("auto_exposure", default_value="false"),
DeclareLaunchArgument(
"camera_info_url",
default_value=str(
Path.home()
/ ".ros"
/ "camera_info"
/ "hikrobot_DB2163742.yaml"
),
),
DeclareLaunchArgument("can_interface", default_value="can0"),
DeclareLaunchArgument("calibration_speed", default_value="15"),
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
DeclareLaunchArgument("commands_enabled", default_value="true"),
DeclareLaunchArgument(
"publish_debug_image", default_value="true"
),
DeclareLaunchArgument("start_camera", default_value="true"),
DeclareLaunchArgument("start_sdk", default_value="true"),
DeclareLaunchArgument(
"output_root",
default_value=str(Path.cwd() / "calibration_output"),
),
DeclareLaunchArgument("session_dir", default_value=""),
DeclareLaunchArgument(
"calibration_config",
default_value=str(
package_share
/ "config"
/ "cmc_roll_zero_travel.yaml"
),
),
DeclareLaunchArgument(
"tag_config",
default_value=str(
package_share / "config" / "front_tags.yaml"
),
),
OpaqueFunction(function=_launch_stack),
]
)
@@ -1,391 +0,0 @@
"""Launch the complete front-camera G20 thumb calibration stack."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import re
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import (
DeclareLaunchArgument,
ExecuteProcess,
LogInfo,
OpaqueFunction,
SetEnvironmentVariable,
)
from launch.conditions import IfCondition
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import ComposableNodeContainer, Node
from launch_ros.descriptions import ComposableNode
from launch_ros.parameter_descriptions import ParameterValue
def _launch_stack(context):
serial_number = LaunchConfiguration("serial_number").perform(context)
if not serial_number or serial_number == "UNSET":
raise RuntimeError(
"serial_number is required, for example serial_number:=G20_LEFT_001"
)
if (
re.fullmatch(r"[A-Za-z0-9_.-]+", serial_number) is None
or serial_number in {".", ".."}
):
raise RuntimeError(
"serial_number may contain only letters, digits, dot, underscore and dash"
)
requested_session = LaunchConfiguration("session_dir").perform(context)
output_root = Path(LaunchConfiguration("output_root").perform(context)).resolve()
if requested_session:
session_dir = Path(requested_session).expanduser().resolve()
else:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session_dir = output_root / serial_number / timestamp
session_dir.mkdir(parents=True, exist_ok=True)
bag_path = session_dir / "rosbag"
calibration_config = LaunchConfiguration("calibration_config").perform(context)
tag_config = LaunchConfiguration("tag_config").perform(context)
use_roi_text = LaunchConfiguration("use_roi").perform(context).strip().lower()
if use_roi_text not in {"true", "false"}:
raise RuntimeError("use_roi must be true or false")
use_roi = use_roi_text == "true"
roi_values = {}
for name in ("roi_x", "roi_y", "roi_width", "roi_height"):
text = LaunchConfiguration(name).perform(context)
try:
roi_values[name] = int(text)
except ValueError as error:
raise RuntimeError(f"{name} must be an integer") from error
if roi_values["roi_x"] < 0 or roi_values["roi_y"] < 0:
raise RuntimeError("roi_x and roi_y must be non-negative")
if roi_values["roi_width"] <= 0 or roi_values["roi_height"] <= 0:
raise RuntimeError("roi_width and roi_height must be positive")
try:
image_width = int(LaunchConfiguration("image_width").perform(context))
image_height = int(LaunchConfiguration("image_height").perform(context))
except ValueError as error:
raise RuntimeError("image_width and image_height must be integers") from error
if image_width <= 0 or image_height <= 0:
raise RuntimeError("image_width and image_height must be positive")
if use_roi:
if (
roi_values["roi_x"] + roi_values["roi_width"] > image_width
or roi_values["roi_y"] + roi_values["roi_height"] > image_height
):
raise RuntimeError(
"ROI lies outside camera image "
f"{image_width}x{image_height}"
)
camera = Node(
package="g20_thumb_apriltag_calibration",
executable="hikrobot_camera_node",
name="hikrobot_camera",
namespace="/camera/camera/color",
output="screen",
emulate_tty=True,
condition=IfCondition(LaunchConfiguration("start_camera")),
parameters=[
{
"serial_number": LaunchConfiguration("camera_serial_number"),
"expected_model": LaunchConfiguration("camera_model"),
"camera_name": LaunchConfiguration("camera_name"),
"frame_id": LaunchConfiguration("camera_frame_id"),
"image_width": ParameterValue(
LaunchConfiguration("image_width"), value_type=int
),
"image_height": ParameterValue(
LaunchConfiguration("image_height"), value_type=int
),
"frame_rate": ParameterValue(
LaunchConfiguration("camera_frame_rate"), value_type=float
),
"exposure_time_us": ParameterValue(
LaunchConfiguration("exposure_time_us"), value_type=float
),
"gain_db": ParameterValue(
LaunchConfiguration("gain_db"), value_type=float
),
"auto_exposure": ParameterValue(
LaunchConfiguration("auto_exposure"), value_type=bool
),
"camera_info_url": LaunchConfiguration("camera_info_url"),
}
],
)
vision_components = []
if use_roi:
processed_image_raw_topic = "/g20_thumb_roi/image_raw"
processed_camera_info_topic = "/g20_thumb_roi/camera_info"
processed_image_rect_topic = "/g20_thumb_roi/image_rect"
vision_components.append(
ComposableNode(
package="image_proc",
plugin="image_proc::CropDecimateNode",
name="crop_color_roi",
namespace="/g20_thumb_roi",
remappings=[
("in/image_raw", "/camera/camera/color/image_raw"),
("in/camera_info", "/camera/camera/color/camera_info"),
("out/image_raw", processed_image_raw_topic),
("out/camera_info", processed_camera_info_topic),
],
parameters=[
{
"queue_size": 5,
"decimation_x": 1,
"decimation_y": 1,
"offset_x": roi_values["roi_x"],
"offset_y": roi_values["roi_y"],
"width": roi_values["roi_width"],
"height": roi_values["roi_height"],
}
],
extra_arguments=[{"use_intra_process_comms": True}],
)
)
rectifier_namespace = "/g20_thumb_roi"
rectifier_name = "rectify_color_roi"
else:
processed_image_raw_topic = "/camera/camera/color/image_raw"
processed_camera_info_topic = "/camera/camera/color/camera_info"
processed_image_rect_topic = "/camera/camera/color/image_rect"
rectifier_namespace = "/camera/camera/color"
rectifier_name = "rectify_color"
vision_components.append(
ComposableNode(
package="image_proc",
plugin="image_proc::RectifyNode",
name=rectifier_name,
namespace=rectifier_namespace,
remappings=[
("image", processed_image_raw_topic),
("camera_info", processed_camera_info_topic),
("image_rect", processed_image_rect_topic),
],
parameters=[{"queue_size": 1}],
extra_arguments=[{"use_intra_process_comms": True}],
)
)
vision_components.append(
ComposableNode(
package="apriltag_ros",
plugin="AprilTagNode",
name="apriltag",
namespace="/apriltag",
parameters=[
tag_config,
{
"detector.decimate": ParameterValue(
LaunchConfiguration("apriltag_decimate"),
value_type=float,
)
},
],
remappings=[
("image_rect", processed_image_rect_topic),
("camera_info", processed_camera_info_topic),
],
extra_arguments=[{"use_intra_process_comms": True}],
)
)
vision_container = ComposableNodeContainer(
name="g20_thumb_vision_container",
namespace="/",
package="rclcpp_components",
executable="component_container_mt",
composable_node_descriptions=vision_components,
output="screen",
emulate_tty=True,
)
sdk = Node(
package="linker_hand_ros2_sdk",
executable="linker_hand_sdk",
name="linker_hand_sdk",
output="screen",
condition=IfCondition(LaunchConfiguration("start_sdk")),
parameters=[
{
"hand_type": "left",
"hand_joint": "G20",
"can": LaunchConfiguration("can_interface"),
"modbus": "None",
"topic_prefix": "/g20",
"move_on_startup": False,
"startup_speed": ParameterValue(
LaunchConfiguration("calibration_speed"),
value_type=int,
),
"startup_torque": 80,
"state_poll_rate": 10.0,
"repeat_position_commands": False,
"is_touch": False,
}
],
)
calibration = Node(
package="g20_thumb_apriltag_calibration",
executable="calibration_node",
name="g20_thumb_calibration",
output="screen",
parameters=[
calibration_config,
tag_config,
{
"serial_number": serial_number,
"session_dir": str(session_dir),
"commands_enabled": LaunchConfiguration("commands_enabled"),
"calibration_speed": ParameterValue(
LaunchConfiguration("calibration_speed"),
value_type=int,
),
"continuous_motion_mode": ParameterValue(
LaunchConfiguration("continuous_motion_mode"),
value_type=str,
),
"angle_estimation_mode": ParameterValue(
LaunchConfiguration("angle_estimation_mode"),
value_type=str,
),
"camera_serial_number": LaunchConfiguration("camera_serial_number"),
"rosbag_path": str(bag_path),
"camera_info_topic": processed_camera_info_topic,
"image_topic": processed_image_rect_topic,
"publish_debug_image": LaunchConfiguration(
"publish_debug_image"
),
},
],
)
bag = ExecuteProcess(
condition=IfCondition(LaunchConfiguration("record_bag")),
cmd=[
"ros2",
"bag",
"record",
"--storage",
"mcap",
"--storage-preset-profile",
"zstd_fast",
"--max-bag-size",
"10737418240",
"--output",
str(bag_path),
processed_image_raw_topic,
processed_camera_info_topic,
"/apriltag/detections",
"/tf",
"/g20/cb_left_hand_control_cmd",
"/g20/cb_left_hand_state",
"/g20/cb_left_hand_info",
"/g20_thumb_calibration/status",
],
output="screen",
)
actions = [
LogInfo(msg=f"G20 thumb calibration session: {session_dir}"),
LogInfo(
msg=(
"G20 thumb image ROI: "
f"x={roi_values['roi_x']}, y={roi_values['roi_y']}, "
f"width={roi_values['roi_width']}, "
f"height={roi_values['roi_height']}"
if use_roi
else "G20 thumb image ROI: disabled"
)
),
camera,
vision_container,
]
actions.extend([sdk, calibration, bag])
return actions
def generate_launch_description() -> LaunchDescription:
package_share = Path(
get_package_share_directory("g20_thumb_apriltag_calibration")
)
default_output = str(Path.cwd() / "calibration_output")
return LaunchDescription(
[
SetEnvironmentVariable(
name="FASTRTPS_DEFAULT_PROFILES_FILE",
value=str(
package_share / "config" / "fastdds_large_images.xml"
),
),
DeclareLaunchArgument("serial_number", default_value="UNSET"),
DeclareLaunchArgument(
"camera_serial_number", default_value="DB2163742"
),
DeclareLaunchArgument(
"camera_model", default_value="MV-CS020-10UM"
),
DeclareLaunchArgument(
"camera_name", default_value="hikrobot_front_DB2163742"
),
DeclareLaunchArgument(
"camera_frame_id", default_value="camera_color_optical_frame"
),
DeclareLaunchArgument("image_width", default_value="1624"),
DeclareLaunchArgument("image_height", default_value="1240"),
DeclareLaunchArgument("camera_frame_rate", default_value="30.0"),
DeclareLaunchArgument("exposure_time_us", default_value="5000.0"),
DeclareLaunchArgument("gain_db", default_value="0.0"),
DeclareLaunchArgument("auto_exposure", default_value="false"),
DeclareLaunchArgument(
"camera_info_url",
default_value=str(
Path.home()
/ ".ros"
/ "camera_info"
/ "hikrobot_DB2163742.yaml"
),
),
DeclareLaunchArgument(
"publish_debug_image", default_value="false"
),
DeclareLaunchArgument("use_roi", default_value="false"),
DeclareLaunchArgument("roi_x", default_value="128"),
DeclareLaunchArgument("roi_y", default_value="192"),
DeclareLaunchArgument("roi_width", default_value="1024"),
DeclareLaunchArgument("roi_height", default_value="528"),
DeclareLaunchArgument("can_interface", default_value="can0"),
DeclareLaunchArgument("calibration_speed", default_value="15"),
DeclareLaunchArgument(
"continuous_motion_mode", default_value="endpoint"
),
DeclareLaunchArgument(
"angle_estimation_mode",
default_value="trajectory_center_3d",
),
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
DeclareLaunchArgument("commands_enabled", default_value="true"),
DeclareLaunchArgument("start_camera", default_value="true"),
DeclareLaunchArgument("start_sdk", default_value="true"),
DeclareLaunchArgument("record_bag", default_value="false"),
DeclareLaunchArgument("output_root", default_value=default_output),
DeclareLaunchArgument("session_dir", default_value=""),
DeclareLaunchArgument(
"calibration_config",
default_value=str(package_share / "config" / "calibration.yaml"),
),
DeclareLaunchArgument(
"tag_config",
default_value=str(package_share / "config" / "front_tags.yaml"),
),
OpaqueFunction(function=_launch_stack),
]
)
@@ -1,341 +0,0 @@
"""Launch three Hikrobot views and one complete-G20 calibration owner."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import re
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import (
DeclareLaunchArgument,
ExecuteProcess,
LogInfo,
OpaqueFunction,
SetEnvironmentVariable,
)
from launch.conditions import IfCondition
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import ComposableNodeContainer, Node
from launch_ros.descriptions import ComposableNode
from launch_ros.parameter_descriptions import ParameterValue
VIEWS = ("front", "side", "top")
def _launch_stack(context):
hand_serial = LaunchConfiguration("serial_number").perform(context)
if (
not hand_serial
or hand_serial == "UNSET"
or re.fullmatch(r"[A-Za-z0-9_.-]+", hand_serial) is None
or hand_serial in {".", ".."}
):
raise RuntimeError("serial_number must be a safe non-empty hand serial")
requested_session = LaunchConfiguration("session_dir").perform(context)
output_root = Path(
LaunchConfiguration("output_root").perform(context)
).expanduser().resolve()
if requested_session:
session_dir = Path(requested_session).expanduser().resolve()
else:
session_dir = (
output_root
/ hand_serial
/ datetime.now().strftime("%Y%m%d_%H%M%S")
)
session_dir.mkdir(parents=True, exist_ok=True)
camera_serials = {
view: LaunchConfiguration(f"{view}_camera_serial").perform(context)
for view in VIEWS
}
if any(not serial for serial in camera_serials.values()):
raise RuntimeError("all three camera serial numbers are required")
if len(set(camera_serials.values())) != 3:
raise RuntimeError("front/side/top camera serial numbers must be unique")
cameras = []
components = []
raw_topics = []
info_topics = []
detection_topics = []
for view in VIEWS:
namespace = f"/g20_calibration/{view}/camera"
raw_topic = f"{namespace}/image_raw"
info_topic = f"{namespace}/camera_info"
rect_topic = f"{namespace}/image_rect"
detector_namespace = f"/g20_calibration/{view}/apriltag"
detection_topic = f"{detector_namespace}/detections"
raw_topics.append(raw_topic)
info_topics.append(info_topic)
detection_topics.append(detection_topic)
cameras.append(
Node(
package="g20_thumb_apriltag_calibration",
executable="hikrobot_camera_node",
name="hikrobot_camera",
namespace=namespace,
output="screen",
emulate_tty=True,
condition=IfCondition(LaunchConfiguration("start_cameras")),
parameters=[
{
"serial_number": LaunchConfiguration(
f"{view}_camera_serial"
),
"expected_model": LaunchConfiguration("camera_model"),
"camera_name": LaunchConfiguration(
f"{view}_camera_name"
),
"frame_id": f"g20_calibration_{view}_optical_frame",
"image_width": 1624,
"image_height": 1240,
"frame_rate": ParameterValue(
LaunchConfiguration("camera_frame_rate"),
value_type=float,
),
"exposure_time_us": ParameterValue(
LaunchConfiguration("exposure_time_us"),
value_type=float,
),
"gain_db": ParameterValue(
LaunchConfiguration("gain_db"), value_type=float
),
"auto_exposure": ParameterValue(
LaunchConfiguration("auto_exposure"), value_type=bool
),
"camera_info_url": LaunchConfiguration(
f"{view}_camera_info_url"
),
}
],
)
)
components.extend(
[
ComposableNode(
package="image_proc",
plugin="image_proc::RectifyNode",
name=f"rectify_{view}",
namespace=namespace,
remappings=[
("image", raw_topic),
("camera_info", info_topic),
("image_rect", rect_topic),
],
parameters=[{"queue_size": 1}],
extra_arguments=[{"use_intra_process_comms": True}],
),
ComposableNode(
package="apriltag_ros",
plugin="AprilTagNode",
name="apriltag",
namespace=detector_namespace,
parameters=[
LaunchConfiguration("tag_config"),
{
"detector.decimate": ParameterValue(
LaunchConfiguration("apriltag_decimate"),
value_type=float,
)
},
],
remappings=[
("image_rect", rect_topic),
("camera_info", info_topic),
],
extra_arguments=[{"use_intra_process_comms": True}],
),
]
)
vision = ComposableNodeContainer(
name="g20_three_camera_vision",
namespace="/",
package="rclcpp_components",
executable="component_container_mt",
composable_node_descriptions=components,
output="screen",
emulate_tty=True,
)
sdk = Node(
package="linker_hand_ros2_sdk",
executable="linker_hand_sdk",
name="linker_hand_sdk",
output="screen",
condition=IfCondition(LaunchConfiguration("start_sdk")),
parameters=[
{
"hand_type": "left",
"hand_joint": "G20",
"can": LaunchConfiguration("can_interface"),
"modbus": "None",
"topic_prefix": "/g20",
"move_on_startup": False,
"startup_speed": ParameterValue(
LaunchConfiguration("calibration_speed"), value_type=int
),
"startup_torque": 80,
"state_poll_rate": 10.0,
"repeat_position_commands": False,
"is_touch": False,
}
],
)
calibration = Node(
package="g20_thumb_apriltag_calibration",
executable="three_camera_calibration_node",
name="g20_calibration",
output="screen",
emulate_tty=True,
parameters=[
LaunchConfiguration("calibration_config"),
{
"serial_number": hand_serial,
"session_dir": str(session_dir),
"commands_enabled": ParameterValue(
LaunchConfiguration("commands_enabled"), value_type=bool
),
"normal_calibration_speed": ParameterValue(
LaunchConfiguration("calibration_speed"), value_type=int
),
"index_roll_calibration_speed": ParameterValue(
LaunchConfiguration("index_roll_calibration_speed"),
value_type=int,
),
"index_flex_calibration_speed": ParameterValue(
LaunchConfiguration("index_flex_calibration_speed"),
value_type=int,
),
"validation_enabled": ParameterValue(
LaunchConfiguration("validation_enabled"), value_type=bool
),
},
],
)
bag = ExecuteProcess(
condition=IfCondition(LaunchConfiguration("record_bag")),
cmd=[
"ros2",
"bag",
"record",
"--storage",
"mcap",
"--storage-preset-profile",
"zstd_fast",
"--max-bag-size",
"10737418240",
"--output",
str(session_dir / "rosbag"),
*raw_topics,
*info_topics,
*detection_topics,
"/g20/cb_left_hand_control_cmd",
"/g20/cb_left_hand_state",
"/g20/cb_left_hand_info",
"/g20_calibration/status",
],
output="screen",
)
return [
LogInfo(msg=f"G20 three-camera session: {session_dir}"),
LogInfo(
msg=(
"Camera mapping: front="
f"{camera_serials['front']} side={camera_serials['side']} "
f"top={camera_serials['top']}"
)
),
*cameras,
vision,
sdk,
calibration,
bag,
]
def generate_launch_description() -> LaunchDescription:
package_share = Path(
get_package_share_directory("g20_thumb_apriltag_calibration")
)
info_root = Path.home() / ".ros" / "camera_info"
return LaunchDescription(
[
SetEnvironmentVariable(
name="FASTRTPS_DEFAULT_PROFILES_FILE",
value=str(package_share / "config" / "fastdds_large_images.xml"),
),
DeclareLaunchArgument("serial_number", default_value="UNSET"),
DeclareLaunchArgument(
"front_camera_serial", default_value="DB2163742"
),
DeclareLaunchArgument(
"side_camera_serial", default_value="DB2163749"
),
DeclareLaunchArgument(
"top_camera_serial", default_value="DB2163739"
),
DeclareLaunchArgument("camera_model", default_value="MV-CS020-10U"),
DeclareLaunchArgument(
"front_camera_name", default_value="hikrobot_front_DB2163742"
),
DeclareLaunchArgument(
"side_camera_name", default_value="hikrobot_side_DB2163749"
),
DeclareLaunchArgument(
"top_camera_name", default_value="hikrobot_top_DB2163739"
),
DeclareLaunchArgument(
"front_camera_info_url",
default_value=str(info_root / "hikrobot_DB2163742.yaml"),
),
DeclareLaunchArgument(
"side_camera_info_url",
default_value=str(info_root / "hikrobot_DB2163749.yaml"),
),
DeclareLaunchArgument(
"top_camera_info_url",
default_value=str(info_root / "hikrobot_DB2163739.yaml"),
),
DeclareLaunchArgument("camera_frame_rate", default_value="30.0"),
DeclareLaunchArgument("exposure_time_us", default_value="5000.0"),
DeclareLaunchArgument("gain_db", default_value="0.0"),
DeclareLaunchArgument("auto_exposure", default_value="false"),
DeclareLaunchArgument("apriltag_decimate", default_value="1.5"),
DeclareLaunchArgument("can_interface", default_value="can0"),
DeclareLaunchArgument("calibration_speed", default_value="15"),
DeclareLaunchArgument(
"index_roll_calibration_speed", default_value="5"
),
DeclareLaunchArgument(
"index_flex_calibration_speed", default_value="10"
),
DeclareLaunchArgument("validation_enabled", default_value="false"),
DeclareLaunchArgument("commands_enabled", default_value="true"),
DeclareLaunchArgument("start_cameras", default_value="true"),
DeclareLaunchArgument("start_sdk", default_value="true"),
DeclareLaunchArgument("record_bag", default_value="false"),
DeclareLaunchArgument(
"output_root",
default_value=str(Path.cwd() / "calibration_output"),
),
DeclareLaunchArgument("session_dir", default_value=""),
DeclareLaunchArgument(
"calibration_config",
default_value=str(
package_share / "config" / "three_camera_calibration.yaml"
),
),
DeclareLaunchArgument(
"tag_config",
default_value=str(
package_share / "config" / "three_camera_tags.yaml"
),
),
OpaqueFunction(function=_launch_stack),
]
)
@@ -1,3 +0,0 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
@@ -1,4 +0,0 @@
[develop]
script_dir=$base/lib/g20_thumb_apriltag_calibration
[install]
install_scripts=$base/lib/g20_thumb_apriltag_calibration
@@ -1,56 +0,0 @@
from glob import glob
from setuptools import find_packages, setup
package_name = "g20_thumb_apriltag_calibration"
setup(
name=package_name,
version="0.1.0",
packages=find_packages(),
data_files=[
(
"share/ament_index/resource_index/packages",
["resource/" + package_name],
),
("share/" + package_name, ["package.xml", "README.md"]),
(
"share/" + package_name + "/config",
glob("config/*.yaml") + glob("config/*.xml"),
),
("share/" + package_name + "/launch", glob("launch/*.launch.py")),
],
install_requires=["setuptools", "numpy", "scipy", "PyYAML"],
tests_require=["pytest"],
zip_safe=True,
maintainer="lxp",
maintainer_email="support@linker-robotics.com",
description="Three-view Hikrobot AprilTag calibration for the complete left G20 hand",
license="MIT",
entry_points={
"console_scripts": [
(
"hikrobot_camera_node = "
"g20_thumb_apriltag_calibration.hikrobot_camera:main"
),
"calibration_node = g20_thumb_apriltag_calibration.node:main",
(
"cmc_pitch_zero_node = "
"g20_thumb_apriltag_calibration.zero_node:main"
),
(
"cmc_roll_calibration_node = "
"g20_thumb_apriltag_calibration.zero_node:main"
),
(
"three_camera_calibration_node = "
"g20_thumb_apriltag_calibration.three_camera_node:main"
),
(
"camera_alignment_view = "
"g20_thumb_apriltag_calibration.alignment_view:main"
),
],
},
)
@@ -1,432 +0,0 @@
from __future__ import annotations
from dataclasses import replace
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from g20_thumb_apriltag_calibration.acquisition import (
ContinuousSweepCollector,
Observation,
PointCollector,
StateSample,
TagQuality,
aggregate_observations,
aggregate_sweep_observations,
interpolate_state_u8,
required_resume_views,
tag_quality_is_valid,
update_pnp_reset_watchdog,
)
from g20_thumb_apriltag_calibration.core import PAIR_NAMES
def test_pnp_watchdog_resets_after_one_continuous_invalid_second() -> None:
since, reset = update_pnp_reset_watchdog(
detection_good=True,
pnp_valid=False,
now=10.0,
invalid_since=None,
reset_after_seconds=1.0,
)
assert since == 10.0
assert reset is False
since, reset = update_pnp_reset_watchdog(
detection_good=True,
pnp_valid=False,
now=11.01,
invalid_since=since,
reset_after_seconds=1.0,
)
assert since == 11.01
assert reset is True
def test_pnp_watchdog_clears_on_valid_pose_or_bad_detection() -> None:
assert update_pnp_reset_watchdog(
detection_good=True,
pnp_valid=True,
now=11.0,
invalid_since=10.0,
reset_after_seconds=1.0,
) == (None, False)
assert update_pnp_reset_watchdog(
detection_good=False,
pnp_valid=False,
now=11.0,
invalid_since=10.0,
reset_after_seconds=1.0,
) == (None, False)
def test_resume_requires_only_the_active_view() -> None:
assert required_resume_views("top") == ("top",)
assert required_resume_views("front") == ("front",)
assert required_resume_views(None) == ("front", "side", "top")
with pytest.raises(ValueError, match="unknown"):
required_resume_views("rear")
def _observation(index: int, angle_rad: float = 0.0) -> Observation:
quaternion = tuple(
float(value)
for value in Rotation.from_rotvec([0.0, angle_rad, 0.0]).as_quat()
)
return Observation(
stamp_ns=index,
received_at=index / 30.0,
relative_quaternion_xyzw={pair: quaternion for pair in PAIR_NAMES},
tag_quality={
role: TagQuality(hamming=0, decision_margin=50.0, edge_pixels=45.0)
for role in ("t0", "t3", "t4", "t5")
},
state_u8=tuple(float(value) for value in range(20)),
)
def _point_observation(
index: int,
*,
angle_rad: float = 0.0,
motor_value: float = 100.0,
) -> Observation:
state = [255.0] * 20
state[0] = float(motor_value)
return replace(
_observation(index, angle_rad),
state_u8=tuple(state),
state_stamp_ns=index,
state_sync_error_ns=5_000_000,
)
def test_stable_window_then_thirty_frame_capture() -> None:
collector = PointCollector(
stable_frames=15,
capture_frames=30,
minimum_settle_seconds=0.4,
maximum_stable_spread_rad=np.deg2rad(0.3),
)
collector.start(0.0)
result = None
for index in range(15):
result = collector.add(_observation(index), index / 30.0)
assert result is None
assert collector.state == "capturing"
assert collector.stable_frames_seen == 15
assert collector.capture_frames_seen == 0
for index in range(15, 45):
result = collector.add(_observation(index), index / 30.0)
assert result is not None
assert collector.capture_frames_seen == 30
assert result["valid_frames"] == 30
assert len(result["state_u8_median"]) == 20
def test_translation_stability_ignores_planar_orientation_jitter() -> None:
collector = PointCollector(
stable_frames=3,
capture_frames=3,
minimum_settle_seconds=0.0,
stability_mode="translation",
maximum_stable_translation_spread_m=0.001,
)
collector.start(0.0)
result = None
for index in range(6):
observation = replace(
_observation(index, angle_rad=np.deg2rad(10.0 * index)),
tag_translation_xyz_m={
"t0": (0.00, 0.00, 0.50),
"t3": (0.03, 0.00, 0.50),
"t4": (0.06, 0.00, 0.50),
"t5": (0.09, 0.00, 0.50),
},
)
result = collector.add(observation, index / 30.0)
assert result is not None
assert collector.state == "complete"
assert collector.stable_spread_rad == {}
assert max(collector.stable_spread_m.values()) == 0.0
def test_point_capture_waits_for_synchronised_target_state() -> None:
collector = PointCollector(
stable_frames=3,
capture_frames=3,
minimum_settle_seconds=0.0,
maximum_stable_spread_rad=np.deg2rad(0.3),
)
collector.start(
0.0,
required_state_index=0,
required_state_u8=100.0,
maximum_state_error_u8=2.0,
)
for index in range(3):
collector.add(
_point_observation(index, motor_value=108.0),
index / 30.0,
)
assert collector.state == "settling"
assert collector.stable_frames_seen == 0
assert collector.reason == "motor_position_out_of_tolerance"
result = None
for index in range(3, 9):
result = collector.add(
_point_observation(index, motor_value=101.0),
index / 30.0,
)
assert result is not None
assert collector.state == "complete"
assert result["state_u8_median"][0] == 101.0
def test_unstable_capture_frames_are_not_aggregated() -> None:
collector = PointCollector(
stable_frames=3,
capture_frames=3,
minimum_settle_seconds=0.0,
maximum_stable_spread_rad=np.deg2rad(0.3),
)
collector.start(0.0)
for index in range(3):
collector.add(_observation(index), index / 30.0)
assert collector.state == "capturing"
result = None
# The capture block is internally stable but belongs to a different
# planar-PnP branch than the preceding stable window.
for index, angle_deg in enumerate((25.0, 25.0, 25.0), start=3):
result = collector.add(
_observation(index, np.deg2rad(angle_deg)),
index / 30.0,
)
assert result is None
assert collector.state == "settling"
assert collector.capture_frames_seen == 0
assert collector.reason.endswith("capture_not_stable")
def test_position_drift_during_capture_restarts_settling() -> None:
collector = PointCollector(
stable_frames=3,
capture_frames=3,
minimum_settle_seconds=0.0,
)
collector.start(
0.0,
required_state_index=0,
required_state_u8=100.0,
maximum_state_error_u8=2.0,
)
for index in range(3):
collector.add(
_point_observation(index, motor_value=100.0),
index / 30.0,
)
assert collector.state == "capturing"
collector.add(
_point_observation(3, motor_value=108.0),
0.1,
)
assert collector.state == "settling"
assert collector.capture_frames_seen == 0
assert collector.reason == "motor_position_out_of_tolerance"
def test_missing_tags_eventually_pauses_point_collector() -> None:
collector = PointCollector(settle_timeout_seconds=5.0)
collector.start(10.0)
collector.poll(15.01)
assert collector.state == "failed"
assert collector.reason == "settle_timeout"
def test_unstable_window_does_not_enter_capture() -> None:
collector = PointCollector(maximum_stable_spread_rad=np.deg2rad(0.3))
collector.start(0.0)
for index in range(15):
angle = np.deg2rad(1.0 if index % 2 else -1.0)
collector.add(_observation(index, angle), index / 30.0)
assert collector.state == "settling"
assert collector.reason.endswith("not_stable")
assert max(collector.stable_spread_rad.values()) > np.deg2rad(0.3)
def test_isolated_invalid_frame_is_skipped_without_losing_valid_window() -> None:
collector = PointCollector()
collector.start(0.0)
for index in range(14):
collector.add(_observation(index), index / 30.0)
collector.mark_invalid_frame()
assert collector.stable_frames_seen == 14
collector.add(_observation(15), 0.5)
assert collector.state == "capturing"
assert collector.reason == ""
def test_three_consecutive_invalid_frames_clear_stability_window() -> None:
collector = PointCollector()
collector.start(0.0)
for index in range(14):
collector.add(_observation(index), index / 30.0)
for _ in range(3):
collector.mark_invalid_frame()
assert collector.stable_frames_seen == 0
collector.add(_observation(15), 0.5)
assert collector.state == "settling"
def test_bad_tag_quality_is_filtered() -> None:
good = TagQuality(hamming=0, decision_margin=31.0, edge_pixels=40.0)
bad_hamming = TagQuality(hamming=1, decision_margin=50.0, edge_pixels=50.0)
thresholds = {
"maximum_hamming": 0,
"minimum_decision_margin": 30.0,
"minimum_edge_pixels": 40.0,
}
assert tag_quality_is_valid(good, **thresholds)
assert not tag_quality_is_valid(bad_hamming, **thresholds)
def test_aggregate_keeps_worst_tag_quality() -> None:
observations = [_observation(0), _observation(1)]
aggregate = aggregate_observations(observations)
assert aggregate["tag_quality"]["t0"] == {
"minimum_decision_margin": 50.0,
"minimum_edge_pixels": 45.0,
"maximum_hamming": 0,
}
def test_aggregate_keeps_median_tag_centres() -> None:
observations = [
replace(
_observation(index),
tag_translation_xyz_m={
role: (0.01 * index, 0.02, 0.50)
for role in ("t0", "t3", "t4", "t5")
},
)
for index in range(3)
]
aggregate = aggregate_observations(observations)
assert aggregate["tag_translation_xyz_m"]["t4"] == pytest.approx(
[0.01, 0.02, 0.50]
)
def test_pnp_reprojection_error_is_filtered_and_aggregated() -> None:
good = TagQuality(
hamming=0,
decision_margin=50.0,
edge_pixels=40.0,
reprojection_error_px=0.4,
)
bad = replace(good, reprojection_error_px=1.6)
thresholds = {
"maximum_hamming": 0,
"minimum_decision_margin": 30.0,
"minimum_edge_pixels": 30.0,
"maximum_reprojection_error_px": 1.5,
}
assert tag_quality_is_valid(good, **thresholds)
assert not tag_quality_is_valid(bad, **thresholds)
observations = [
replace(
_observation(index),
tag_quality={
role: replace(good, reprojection_error_px=error)
for role in ("t0", "t3", "t4", "t5")
},
)
for index, error in enumerate((0.2, 0.7))
]
aggregate = aggregate_observations(observations)
assert (
aggregate["tag_quality"]["t0"][
"maximum_reprojection_error_px"
]
== 0.7
)
def test_state_is_interpolated_at_camera_timestamp() -> None:
before = tuple([255.0] + [0.0] * 19)
after = tuple([235.0] + [0.0] * 19)
samples = [
StateSample(stamp_ns=1_000_000_000, position_u8=before),
StateSample(stamp_ns=1_100_000_000, position_u8=after),
]
matched = interpolate_state_u8(
samples,
1_025_000_000,
maximum_skew_ns=60_000_000,
)
assert matched is not None
state, skew = matched
assert state[0] == 250.0
assert skew == 25_000_000
assert (
interpolate_state_u8(
samples,
1_300_000_000,
maximum_skew_ns=60_000_000,
)
is None
)
def _synchronised_observation(
index: int, motor_value: float
) -> Observation:
state = [255.0] * 20
state[0] = motor_value
return replace(
_observation(index),
state_u8=tuple(state),
state_stamp_ns=index,
state_sync_error_ns=5_000_000,
)
def test_continuous_sweep_completes_after_full_span_and_endpoint_hold() -> None:
collector = ContinuousSweepCollector(
endpoint_hold_seconds=0.2,
minimum_valid_frames=20,
minimum_state_span_u8=240.0,
)
collector.start(0.0, motor_index=0, start_u8=255, target_u8=0)
result = None
for index, value in enumerate(np.linspace(255.0, 0.0, 60)):
result = collector.add(
_synchronised_observation(index, float(value)),
index * 0.05,
)
assert result is None
for offset in range(1, 6):
result = collector.add(
_synchronised_observation(60 + offset, 0.0),
3.0 + offset * 0.05,
)
if result is not None:
break
assert result is not None
assert collector.state == "complete"
assert collector.state_span_u8 == 255.0
bins = aggregate_sweep_observations(
result,
motor_index=0,
start_u8=255,
target_u8=0,
endpoint_tolerance_u8=2.0,
)
assert 0 in bins
assert 255 in bins
assert len(bins) >= 50
@@ -1,39 +0,0 @@
"""Tests for the independent physical-line alignment overlay."""
import pytest
from g20_thumb_apriltag_calibration.alignment_view import (
summarize_alignment_measurements,
)
def _measurement(angle: float, offset: float, y: float) -> dict:
return {
"line_xyxy_px": [0.0, y, 100.0, y - angle * 100.0],
"angle_rad": angle,
"vertical_offset_px": offset,
}
def test_line_summary_smooths_only_physical_line_measurements() -> None:
"""The overlay smooths scene lines without any Tag orientation input."""
result = summarize_alignment_measurements(
[
_measurement(-0.02, -4.0, 80.0),
None,
_measurement(0.00, 0.0, 82.0),
_measurement(0.02, 4.0, 84.0),
]
)
assert result is not None
assert result["angle_rad"] == pytest.approx(0.0)
assert result["vertical_offset_px"] == pytest.approx(0.0)
assert result["line_xyxy_px"] == pytest.approx([0.0, 82.0, 100.0, 82.0])
assert result["detected_frames"] == 3
assert result["window_frames"] == 4
def test_line_summary_returns_none_without_scene_line() -> None:
"""No blue line is fabricated when the scene has no valid long edge."""
assert summarize_alignment_measurements([None, None]) is None
@@ -1,273 +0,0 @@
from pathlib import Path
from xml.etree import ElementTree
import yaml
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
def test_fastdds_profile_has_capacity_for_full_resolution_images() -> None:
root = ElementTree.parse(
PACKAGE_ROOT / "config" / "fastdds_large_images.xml"
).getroot()
namespace = {"dds": "http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles"}
profiles = root.find("dds:profiles", namespace)
assert profiles is not None
segment = profiles.find(
".//dds:transport_descriptor[dds:type='SHM']/dds:segment_size",
namespace,
)
assert segment is not None
assert int(segment.text) >= 64 * 1024 * 1024
participant = profiles.find("dds:participant", namespace)
assert participant is not None
assert participant.attrib["is_default_profile"] == "true"
def test_front_tag_parameters_match_namespaced_detector() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "front_tags.yaml").read_text()
)
detector = config["/apriltag/apriltag"]["ros__parameters"]
calibration = config["g20_thumb_calibration"]["ros__parameters"]
assert detector["tag"]["ids"] == calibration["tag_ids"]
assert detector["tag"]["frames"] == calibration["tag_frames"]
assert detector["tag"]["sizes"] == calibration["tag_sizes_m"]
assert detector["tag"]["ids"] == [0, 1, 2, 3]
assert detector["qos_profile"] == "sensor_data"
assert detector["detector"]["decimate"] == 1.5
assert detector["detector"]["refine"] is True
assert detector["detector"]["debug"] is False
def test_three_camera_tag_ids_and_topics_are_disjoint() -> None:
tags = yaml.safe_load(
(PACKAGE_ROOT / "config" / "three_camera_tags.yaml").read_text()
)
expected = {
"front": [0, 1, 2, 3, 10],
"side": [4, 5, 6, 7],
"top": [8, 9],
}
all_ids = set()
for view, ids in expected.items():
key = f"/g20_calibration/{view}/apriltag/apriltag"
parameters = tags[key]["ros__parameters"]
assert parameters["tag"]["ids"] == ids
assert parameters["qos_profile"] == "sensor_data"
assert parameters["detector"]["decimate"] == 1.5
all_ids.update(ids)
assert all_ids == set(range(11))
def test_three_camera_calibration_has_hard_preflight_and_three_rounds() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "three_camera_calibration.yaml").read_text()
)
parameters = config["g20_calibration"]["ros__parameters"]
assert parameters["baseline_command_u8"] == [
255,
255,
255,
255,
255,
255,
127,
127,
127,
127,
255,
255,
255,
255,
255,
255,
255,
255,
255,
255,
]
assert parameters["setting_topic"] == "/g20/cb_hand_setting_cmd"
assert parameters["normal_calibration_speed"] == 15
assert parameters["index_roll_calibration_speed"] == 5
assert parameters["index_flex_calibration_speed"] == 10
assert parameters["speed_setting_settle_seconds"] >= 0.2
assert parameters["top_pnp_invalid_reset_seconds"] == 1.0
assert parameters["repetitions"] == 3
assert parameters["validation_enabled"] is False
assert parameters["minimum_detection_rate"] == 0.95
assert parameters["minimum_detection_hz"] == 15.0
assert parameters["minimum_state_span_u8"] >= 240.0
assert parameters["minimum_sweep_bins"] >= 32
assert parameters["maximum_bin_gap"] <= 16
assert parameters["position_timeout_seconds"] >= 20.0
assert parameters["zero_maximum_round_difference_deg"] <= 1.0
assert parameters["image_trajectory_maximum_radial_rms_px"] <= 2.0
assert parameters["image_trajectory_maximum_radial_p95_px"] <= 3.5
assert parameters["image_trajectory_minimum_radius_px"] >= 20.0
assert parameters["trajectory_maximum_cycle_travel_difference_deg"] <= 3.0
assert parameters["passive_maximum_cycle_travel_difference_deg"] <= 10.0
assert parameters["passive_maximum_monotonic_correction_deg"] <= 3.0
assert parameters["passive_maximum_hysteresis_deg"] <= 7.5
for view in ("front", "side", "top"):
assert parameters[f"{view}_camera_info_topic"].startswith(
f"/g20_calibration/{view}/"
)
assert parameters[f"{view}_detections_topic"] == (
f"/g20_calibration/{view}/apriltag/detections"
)
def test_trial_uses_centre_trajectory_and_thirty_pixel_tags() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "calibration.yaml").read_text()
)
parameters = config["g20_thumb_calibration"]["ros__parameters"]
assert parameters["angle_estimation_mode"] == "trajectory_center_3d"
assert parameters["passive_ip_multiplier"] == 1.02
assert parameters["pnp_minimum_valid_rate"] == 0.95
assert parameters["pnp_maximum_reprojection_error_px"] <= 1.5
assert parameters["pnp_reprojection_tie_px"] == 1.5
assert parameters["pnp_tracker_reset_seconds"] == 5.0
assert parameters["pnp_group_relative_rotation_scale_deg"] == 5.0
assert parameters["pnp_group_relative_translation_scale_m"] == 0.01
assert parameters["pnp_group_reprojection_weight"] == 0.05
assert parameters["pnp_trajectory_reprojection_scale_px"] == 0.1
assert parameters["pnp_rigid_rotation_scale_deg"] == 5.0
assert parameters["pnp_rigid_p95_accepted_drift_deg"] == 8.0
assert parameters["pnp_rigid_maximum_accepted_drift_deg"] == 15.0
assert (
parameters["pnp_rigid_p95_accepted_distance_drift_m"]
<= 0.003
)
assert (
parameters["pnp_rigid_maximum_accepted_distance_drift_m"]
<= 0.006
)
assert parameters["pnp_maximum_pose_jump_deg"] <= 35.0
assert parameters["trajectory_maximum_plane_rms_m"] <= 0.004
assert parameters["trajectory_maximum_radial_rms_m"] <= 0.004
assert parameters["trajectory_minimum_radius_m"] >= 0.005
assert parameters["trajectory_minimum_arc_deg"] >= 15.0
assert (
parameters["trajectory_maximum_root_role_disagreement_deg"]
<= 5.0
)
assert parameters["trajectory_maximum_anchor_drift_m"] <= 0.005
assert parameters["trajectory_static_translation_outlier_m"] <= 0.005
assert (
parameters["trajectory_maximum_static_translation_rms_m"]
<= 0.002
)
assert parameters["minimum_edge_pixels"] == 30.0
assert parameters["maximum_static_std_deg"] == 3.0
assert parameters["minimum_pose_inlier_rate"] == 0.90
assert parameters["repetitions"] == 1
assert parameters["command_step"] == 8
assert parameters["scan_mode"] == "continuous"
assert parameters["continuous_motion_mode"] == "endpoint"
assert parameters["auto_start_tip"] is True
assert parameters["minimum_detection_hz"] == 15.0
assert parameters["stable_frames"] == 5
assert parameters["capture_frames"] == 8
assert parameters["maximum_stable_spread_deg"] == 3.0
assert parameters["maximum_stable_translation_spread_m"] <= 0.003
assert parameters["validation_command_count"] == 5
assert parameters["continuous_minimum_bins"] >= 32
assert parameters["continuous_maximum_bin_gap"] <= 16
assert parameters["continuous_segment_minimum_seconds"] >= 0.1
assert parameters["continuous_segment_timeout_seconds"] >= 5.0
assert parameters["continuous_prepare_timeout_seconds"] >= 30.0
def test_cmc_pitch_zero_config_uses_three_trajectory_circle_rounds() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "cmc_pitch_zero.yaml").read_text()
)
parameters = config["g20_thumb_cmc_pitch_zero"]["ros__parameters"]
assert parameters["t0_id"] == 0
assert parameters["t3_id"] == 1
assert parameters["joint_name"] == "thumb_cmc_pitch"
assert parameters["motor_index"] == 0
assert parameters["zero_command_u8"] == 255
assert parameters["measure_travel"] is False
assert parameters["baseline_command_u8"] == [
255,
255,
255,
255,
255,
255,
193,
148,
105,
42,
245,
255,
255,
255,
255,
255,
255,
255,
255,
255,
]
assert parameters["repetitions"] == 3
assert parameters["zero_capture_frames"] == 30
assert parameters["trajectory_command_u8"] <= 64
assert parameters["trajectory_minimum_state_span_u8"] >= 160.0
assert parameters["trajectory_minimum_bins"] >= 18
assert parameters["trajectory_minimum_arc_deg"] >= 20.0
assert parameters["trajectory_maximum_radial_rms_px"] <= 2.0
assert parameters["trajectory_maximum_p95_radial_error_px"] <= 3.5
assert parameters["minimum_detection_rate"] == 0.95
assert parameters["minimum_edge_pixels"] == 40.0
assert parameters["maximum_static_position_rms_px"] <= 1.5
assert "maximum_axis_alignment_deg" not in parameters
assert "maximum_static_std_deg" not in parameters
assert "maximum_anchor_drift_deg" not in parameters
assert parameters["camera_alignment_enabled"] is True
assert parameters["camera_alignment_max_angle_deg"] <= 0.5
assert parameters["camera_alignment_max_vertical_offset_px"] <= 12.0
assert parameters["camera_alignment_required_frames"] >= 10
assert parameters["camera_alignment_minimum_detection_rate"] <= 0.8
def test_cmc_roll_config_measures_full_endpoint_travel() -> None:
config = yaml.safe_load(
(PACKAGE_ROOT / "config" / "cmc_roll_zero_travel.yaml").read_text()
)
parameters = config[
"g20_thumb_cmc_roll_calibration"
]["ros__parameters"]
assert parameters["t0_id"] == 0
assert parameters["t3_id"] == 1
assert parameters["joint_name"] == "thumb_cmc_roll"
assert parameters["motor_index"] == 5
assert parameters["zero_command_u8"] == 255
assert parameters["trajectory_command_u8"] == 0
assert parameters["measure_travel"] is True
assert parameters["baseline_command_u8"][5] == 255
assert parameters["repetitions"] == 3
assert parameters["zero_capture_frames"] == 30
assert parameters["trajectory_minimum_state_span_u8"] >= 240.0
assert parameters["trajectory_minimum_bins"] >= 30
assert parameters["minimum_travel_deg"] >= 20.0
assert parameters["maximum_travel_difference_deg"] <= 1.0
assert parameters["maximum_round_difference_deg"] == 1.0
assert "maximum_axis_alignment_deg" not in parameters
assert "maximum_static_std_deg" not in parameters
assert "maximum_anchor_drift_deg" not in parameters
assert parameters["camera_alignment_enabled"] is True
assert parameters["camera_alignment_max_angle_deg"] <= 0.5
assert parameters["camera_alignment_max_vertical_offset_px"] <= 12.0
assert parameters["camera_alignment_required_frames"] >= 10
assert parameters["camera_alignment_minimum_detection_rate"] <= 0.8
@@ -1,244 +0,0 @@
from __future__ import annotations
import copy
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from g20_thumb_apriltag_calibration.core import (
BASELINE_COMMAND,
DIRECTION_DECREASING,
DIRECTION_INCREASING,
PAIR_IP,
PAIR_MCP,
PAIR_ROOT,
PHASE_ROOT,
PHASE_TIP,
build_command,
create_final_payload,
fit_calibration_curves,
image_plane_tag_quaternion_xyzw,
isotonic_nonincreasing,
relative_quaternion_xyzw,
rotation_inlier_fraction,
rotation_rms_rad,
scan_targets,
validate_final_payload,
)
def _quaternion(base: Rotation, axis: np.ndarray, angle: float) -> list[float]:
value = base * Rotation.from_rotvec(axis * angle)
return [float(component) for component in value.as_quat()]
def _synthetic_records(
repetitions: int = 3,
command_step: int = 1,
) -> list[dict]:
bases = {
PAIR_ROOT: Rotation.from_euler("xyz", [0.2, -0.1, 0.3]),
PAIR_MCP: Rotation.from_euler("xyz", [-0.15, 0.1, 0.25]),
PAIR_IP: Rotation.from_euler("xyz", [0.05, 0.2, -0.2]),
}
axes = {
PAIR_ROOT: np.asarray([0.2, 0.9, -0.1], dtype=float),
PAIR_MCP: np.asarray([-0.1, 0.3, 0.95], dtype=float),
PAIR_IP: np.asarray([0.05, -0.2, 0.98], dtype=float),
}
axes = {key: value / np.linalg.norm(value) for key, value in axes.items()}
records: list[dict] = []
for phase in (PHASE_ROOT, PHASE_TIP):
for cycle, direction, command in scan_targets(
repetitions, command_step
):
progress = (255 - command) / 255.0
branch = (
0.008 * np.sin(np.pi * progress)
if direction == DIRECTION_INCREASING
else 0.0
)
root = 0.82 * progress + branch if phase == PHASE_ROOT else 0.0
mcp = 1.16 * progress + branch if phase == PHASE_TIP else 0.0
ip = 1.018 * mcp + 0.001 * np.sin(np.pi * progress)
rotations = {
PAIR_ROOT: _quaternion(bases[PAIR_ROOT], axes[PAIR_ROOT], root),
PAIR_MCP: _quaternion(bases[PAIR_MCP], axes[PAIR_MCP], mcp),
PAIR_IP: _quaternion(bases[PAIR_IP], axes[PAIR_IP], ip),
}
records.append(
{
"kind": "sample",
"phase": phase,
"cycle": cycle,
"direction": direction,
"command_u8": command,
"relative_quaternion_xyzw": rotations,
}
)
return records
def test_build_command_changes_only_selected_channel() -> None:
result = build_command(15, 37)
assert len(result) == 20
assert result[15] == 37
assert result[:15] == list(BASELINE_COMMAND[:15])
assert result[16:] == list(BASELINE_COMMAND[16:])
roll_result = build_command(5, 20)
assert roll_result[5] == 20
assert roll_result[:5] == list(BASELINE_COMMAND[:5])
assert roll_result[6:] == list(BASELINE_COMMAND[6:])
with pytest.raises(ValueError):
build_command(6, 10)
def test_three_cycle_scan_has_every_integer_in_both_directions() -> None:
targets = scan_targets(3, command_step=1)
assert len(targets) == 3 * 2 * 256
assert targets[0] == (0, DIRECTION_DECREASING, 255)
assert targets[255] == (0, DIRECTION_DECREASING, 0)
assert targets[256] == (0, DIRECTION_INCREASING, 0)
assert targets[511] == (0, DIRECTION_INCREASING, 255)
def test_quick_scan_has_bounded_sparse_grid_and_endpoints() -> None:
targets = scan_targets(1, command_step=8)
assert len(targets) == 66
assert targets[0] == (0, DIRECTION_DECREASING, 255)
assert targets[32] == (0, DIRECTION_DECREASING, 0)
assert targets[33] == (0, DIRECTION_INCREASING, 0)
assert targets[-1] == (0, DIRECTION_INCREASING, 255)
increasing = [
command
for _, direction, command in targets
if direction == DIRECTION_INCREASING
]
assert max(np.diff(increasing)) == 8
def test_relative_rotation_cancels_camera_orientation() -> None:
camera_to_parent = Rotation.from_euler("xyz", [0.4, -0.2, 0.1])
parent_to_child = Rotation.from_rotvec([0.1, 0.3, -0.2])
camera_to_child = camera_to_parent * parent_to_child
actual = Rotation.from_quat(
relative_quaternion_xyzw(
camera_to_parent.as_quat(), camera_to_child.as_quat()
)
)
assert (parent_to_child.inv() * actual).magnitude() < 1e-10
def test_image_plane_tag_rotation_uses_ordered_opposite_edges() -> None:
angle = np.deg2rad(27.0)
x_axis_image = np.asarray([np.cos(angle), -np.sin(angle)])
y_axis_image = np.asarray([np.sin(angle), np.cos(angle)])
corners = np.asarray(
[
-x_axis_image - y_axis_image,
x_axis_image - y_axis_image,
1.1 * x_axis_image + y_axis_image,
-0.9 * x_axis_image + y_axis_image,
]
)
actual = Rotation.from_quat(image_plane_tag_quaternion_xyzw(corners))
expected = Rotation.from_rotvec([0.0, 0.0, angle])
assert (expected.inv() * actual).magnitude() < 1e-10
def test_static_rms_rejects_isolated_planar_pnp_flip() -> None:
quaternions = [
Rotation.from_rotvec([0.0, np.deg2rad(0.1 * np.sin(index)), 0.0]).as_quat()
for index in range(149)
]
quaternions.append(
Rotation.from_rotvec([0.0, np.deg2rad(27.0), 0.0]).as_quat()
)
threshold = np.deg2rad(5.0)
assert rotation_rms_rad(quaternions) > np.deg2rad(2.0)
assert rotation_rms_rad(
quaternions, outlier_threshold_rad=threshold
) < np.deg2rad(0.5)
assert rotation_inlier_fraction(
quaternions, outlier_threshold_rad=threshold
) == pytest.approx(149 / 150)
def test_isotonic_projection_is_nonincreasing() -> None:
projected = isotonic_nonincreasing([3.0, 2.0, 2.4, 1.0, 0.0])
assert np.all(np.diff(projected) <= 0.0)
assert projected.tolist() == pytest.approx([3.0, 2.2, 2.2, 1.0, 0.0])
def test_fit_produces_complete_runtime_payload() -> None:
fit = fit_calibration_curves(_synthetic_records())
assert fit.joints["thumb_cmc_pitch"]["decreasing_rad"][0] == pytest.approx(
0.82, abs=2e-3
)
assert fit.joints["thumb_mcp"]["decreasing_rad"][0] == pytest.approx(
1.16, abs=2e-3
)
assert fit.joints["thumb_ip"]["passive"] is True
assert fit.ip_coupling["r_squared"] > 0.999
for joint in fit.joints.values():
assert len(joint["angle_rad"]) == 256
assert len(joint["decreasing_rad"]) == 256
assert len(joint["increasing_rad"]) == 256
assert joint["angle_rad"][255] == 0.0
assert joint["decreasing_rad"][255] == 0.0
assert joint["increasing_rad"][255] == 0.0
payload = create_final_payload(
serial_number="G20_LEFT_TEST",
fit=fit,
validation_errors_rad=[0.01, -0.02],
passed=True,
)
assert set(payload) == {
"schema_version",
"model",
"side",
"serial_number",
"angle_unit",
"command_range",
"zero_command_u8",
"baseline_command_u8",
"joints",
"ip_coupling",
"quality",
}
assert payload["schema_version"] == 2
for joint in payload["joints"].values():
assert "angle_rad" in joint
assert "decreasing_rad" not in joint
assert "increasing_rad" not in joint
validate_final_payload(payload)
invalid = copy.deepcopy(payload)
invalid["joints"]["thumb_mcp"]["angle_rad"].pop()
with pytest.raises(ValueError):
validate_final_payload(invalid)
def test_sparse_fit_interpolates_complete_monotonic_runtime_payload() -> None:
fit = fit_calibration_curves(
_synthetic_records(repetitions=1, command_step=8)
)
for joint in fit.joints.values():
combined = np.asarray(joint["angle_rad"], dtype=float)
assert combined.shape == (256,)
assert combined[255] == 0.0
assert np.all(np.diff(combined) <= 1e-10)
for direction in ("decreasing_rad", "increasing_rad"):
curve = np.asarray(joint[direction], dtype=float)
assert curve.shape == (256,)
assert curve[255] == 0.0
assert np.all(np.diff(curve) <= 1e-10)
assert fit.joints["thumb_cmc_pitch"]["decreasing_rad"][0] == pytest.approx(
0.82, abs=2e-3
)
assert fit.joints["thumb_mcp"]["decreasing_rad"][0] == pytest.approx(
1.16, abs=2e-3
)
@@ -1,105 +0,0 @@
from g20_thumb_apriltag_calibration.acquisition import TagQuality
from g20_thumb_apriltag_calibration.diagnostics import (
build_tag_quality_diagnostics,
render_status_text_zh,
status_guidance_zh,
)
def _tag_config() -> dict[str, dict]:
return {
role: {"id": tag_id, "frame": role, "size_m": 0.01}
for role, tag_id in zip(("t0", "t3", "t4", "t5"), range(4))
}
def test_invalid_t0_edge_has_specific_chinese_guidance() -> None:
qualities = {
role: TagQuality(
hamming=0,
decision_margin=100.0,
edge_pixels=29.4 if role == "t0" else 34.0,
)
for role in _tag_config()
}
diagnostics = build_tag_quality_diagnostics(
_tag_config(),
qualities,
{"t0": "tag_quality_invalid"},
{},
maximum_hamming=0,
minimum_decision_margin=30.0,
minimum_edge_pixels=30.0,
maximum_reprojection_error_px=1.5,
)
assert diagnostics["t0"]["individual_valid"] is False
assert diagnostics["t0"]["edge_pixels"] == 29.4
assert "边长29.4px" in diagnostics["t0"]["summary_zh"]
assert diagnostics["t3"]["individual_valid"] is True
reason_zh, action_zh = status_guidance_zh(
"PAUSED",
"point_capture_failed:invalid_tag_frame",
diagnostics,
)
assert "掌心T0" in reason_zh
assert "边长29.4px" in reason_zh
assert "相机稍微靠近" in action_zh
assert "resume" in action_zh
text = render_status_text_zh(
"标定已暂停",
reason_zh,
action_zh,
diagnostics,
)
assert "状态:标定已暂停" in text
assert "掌心T0(ID 0):异常,边长29.4px" in text
assert "拇指根部T3(ID 1):正常" in text
def test_missing_tag_is_reported_without_manual_topic_parsing() -> None:
diagnostics = build_tag_quality_diagnostics(
_tag_config(),
{},
{"t5": "tag_not_detected"},
{},
maximum_hamming=0,
minimum_decision_margin=30.0,
minimum_edge_pixels=30.0,
maximum_reprojection_error_px=1.5,
)
assert diagnostics["t5"]["detected"] is False
assert "未检测到" in diagnostics["t5"]["summary_zh"]
reason_zh, action_zh = status_guidance_zh(
"PAUSED",
"point_capture_failed:invalid_tag_frame",
diagnostics,
)
assert "拇指末节T5" in reason_zh
assert "四张标签同时可见" in action_zh
def test_low_detection_frequency_has_direct_chinese_action() -> None:
reason_zh, action_zh = status_guidance_zh(
"PREFLIGHT",
"detection_hz_too_low:11.29",
{},
)
assert "11.29Hz" in reason_zh
assert "额外订阅" in action_zh
def test_synchronised_timeout_explains_resume_not_start() -> None:
reason_zh, action_zh = status_guidance_zh(
"PAUSED",
"continuous_sweep_failed:synchronised_tag_state_timeout",
{},
)
assert "运动过程中连续3秒" in reason_zh
assert "当前画面恢复正常" in reason_zh
assert "resume" in action_zh
assert "不要调用start" in action_zh
@@ -1,335 +0,0 @@
import math
import numpy as np
import pytest
from g20_thumb_apriltag_calibration.full_hand import (
ACTIVE_JOINTS,
IMAGE_TRAJECTORY_JOINTS,
JOINT_SPECS,
MEASURED_JOINTS,
PASSIVE_JOINTS,
SPLAY_JOINTS,
SWEEP_SPECS,
VIEW_TAGS,
build_calibration_motion_command,
build_calibration_speed_profile,
build_compact_payload,
build_full_hand_command,
center_splay_curve,
fit_joint_center_curve,
fit_joint_image_curve,
fit_measured_joint_curve,
fit_projected_zero,
measure_joint_observation,
validate_compact_payload,
)
def _records() -> list[dict[str, object]]:
commands = list(range(0, 256, 16))
if commands[-1] != 255:
commands.append(255)
records: list[dict[str, object]] = []
centre = np.asarray([0.006, -0.004, 0.012])
radius = 0.025
image_centre = np.asarray([30.0, -12.0])
image_radius = 100.0
for cycle in range(3):
for direction, sequence in (
("decreasing", reversed(commands)),
("increasing", commands),
):
for command in sequence:
angle = 0.70 * (255.0 - command) / 255.0
point = centre + np.asarray(
[radius * math.cos(angle), radius * math.sin(angle), 0.0]
)
# At command 255 the inward vector points along image +x, so
# table_projected_zero_rad is exactly zero.
image_point = image_centre + np.asarray(
[
-image_radius * math.cos(angle),
image_radius * math.sin(angle),
]
)
records.append(
{
"cycle": cycle,
"direction": direction,
"command_u8": command,
"relative_translation_xyz_m": point.tolist(),
"image_relative_xy_px": image_point.tolist(),
}
)
return records
def test_joint_layout_covers_16_active_and_5_passive_joints() -> None:
assert len(JOINT_SPECS) == 21
assert len(ACTIVE_JOINTS) == 16
assert len(PASSIVE_JOINTS) == 5
assert {tag for tags in VIEW_TAGS.values() for tag in tags.values()} == set(
range(11)
)
assert VIEW_TAGS["front"]["index_roll"] == 10
assert VIEW_TAGS["side"] == {
"side_base": 4,
"index_mcp": 5,
"index_pip": 6,
"index_dip": 7,
}
assert VIEW_TAGS["top"] == {"top_base": 8, "thumb_yaw": 9}
assert [spec.motor_index for spec in SWEEP_SPECS] == [0, 5, 15, 6, 1, 16, 10]
def test_joint_trajectory_spaces_match_observation_geometry() -> None:
assert IMAGE_TRAJECTORY_JOINTS == {
"thumb_cmc_pitch",
"thumb_cmc_roll",
"thumb_mcp",
"thumb_ip",
"index_mcp_roll",
"index_mcp_pitch",
"index_pip",
}
records = _records()
for name in IMAGE_TRAJECTORY_JOINTS:
fit = fit_measured_joint_curve(name, records)
assert fit.circle["space"] == "image_2d"
for name in ("index_dip", "thumb_cmc_yaw"):
assert "space" not in fit_measured_joint_curve(name, records).circle
def test_full_hand_command_changes_exactly_one_controlled_motor() -> None:
result = build_full_hand_command(6, 27)
assert result[6] == 27
assert result[:6] == [255] * 6
with pytest.raises(ValueError, match="controlled"):
build_full_hand_command(11, 27)
def test_index_roll_motion_moves_other_three_roll_motors_out_of_view() -> None:
baseline = [255] * 20
baseline[6:10] = [127, 127, 127, 127]
index_roll = next(spec for spec in SWEEP_SPECS if spec.motor_index == 6)
result = build_calibration_motion_command(index_roll, 27, baseline)
assert result[6:10] == [27, 0, 0, 0]
assert result[:6] == baseline[:6]
assert result[10:] == baseline[10:]
def test_non_index_roll_motion_keeps_clearance_motors_at_baseline() -> None:
baseline = [255] * 20
baseline[6:10] = [127, 127, 127, 127]
thumb_pitch = next(spec for spec in SWEEP_SPECS if spec.motor_index == 0)
result = build_calibration_motion_command(thumb_pitch, 17, baseline)
assert result[0] == 17
assert result[6:10] == [127, 127, 127, 127]
def test_thumb_yaw_motion_holds_thumb_roll_at_camera_clearance_pose() -> None:
baseline = [255] * 20
baseline[6:10] = [127, 127, 127, 127]
thumb_yaw = next(spec for spec in SWEEP_SPECS if spec.motor_index == 10)
result = build_calibration_motion_command(thumb_yaw, 27, baseline)
assert result[5] == 145
assert result[10] == 27
assert result[:5] == baseline[:5]
assert result[6:10] == baseline[6:10]
assert result[11:] == baseline[11:]
def test_only_index_roll_uses_the_slow_index_finger_speed() -> None:
index_roll = next(spec for spec in SWEEP_SPECS if spec.motor_index == 6)
index_pitch = next(spec for spec in SWEEP_SPECS if spec.motor_index == 1)
assert build_calibration_speed_profile(
index_roll,
normal_speed=15,
index_roll_speed=5,
index_flex_speed=10,
) == [15, 5, 15, 15, 15]
assert build_calibration_speed_profile(
index_pitch,
normal_speed=15,
index_roll_speed=5,
index_flex_speed=10,
) == [15, 10, 15, 15, 15]
index_pip = next(spec for spec in SWEEP_SPECS if spec.motor_index == 16)
assert build_calibration_speed_profile(
index_pip,
normal_speed=15,
index_roll_speed=5,
index_flex_speed=10,
) == [15, 10, 15, 15, 15]
thumb_pitch = next(spec for spec in SWEEP_SPECS if spec.motor_index == 0)
assert build_calibration_speed_profile(
thumb_pitch,
normal_speed=15,
index_roll_speed=5,
index_flex_speed=10,
) == [15, 15, 15, 15, 15]
def test_calibration_speed_profile_rejects_out_of_range_values() -> None:
index_roll = next(spec for spec in SWEEP_SPECS if spec.motor_index == 6)
with pytest.raises(ValueError, match="speeds"):
build_calibration_speed_profile(
index_roll,
normal_speed=15,
index_roll_speed=256,
index_flex_speed=10,
)
def test_curve_fit_and_projected_zero_recover_synthetic_geometry() -> None:
records = _records()
fit = fit_joint_center_curve(records)
assert fit.angle_rad[255] == pytest.approx(0.0, abs=1.0e-8)
assert fit.angle_rad[0] == pytest.approx(0.70, abs=1.0e-4)
assert fit.maximum_hysteresis_rad == pytest.approx(0.0, abs=1.0e-8)
assert fit_projected_zero(records) == pytest.approx(0.0, abs=1.0e-6)
def test_thumb_image_curve_avoids_corrupted_pnp_depth() -> None:
records = _records()
for record in records:
command = int(record["command_u8"])
record["relative_translation_xyz_m"] = [
0.001 * command,
0.0,
0.0,
]
fit = fit_measured_joint_curve("thumb_mcp", records)
assert fit.circle["space"] == "image_2d"
assert fit.angle_rad[0] == pytest.approx(0.70, abs=0.02)
assert fit.angle_rad[255] == pytest.approx(0.0)
assert math.degrees(fit.maximum_monotonic_correction_rad) < 0.01
assert math.degrees(fit.maximum_hysteresis_rad) < 0.01
command_zero = next(
record for record in records if int(record["command_u8"]) == 0
)
observed = measure_joint_observation(
fit,
vector_xyz_m=command_zero["relative_translation_xyz_m"],
image_vector_xy_px=command_zero["image_relative_xy_px"],
)
assert observed == pytest.approx(0.70, abs=0.02)
def test_image_curve_rejects_insufficient_projected_arc() -> None:
records = _records()
for record in records:
command = float(record["command_u8"])
angle = math.radians(2.0) * (255.0 - command) / 255.0
record["image_relative_xy_px"] = [
100.0 * math.cos(angle),
100.0 * math.sin(angle),
]
with pytest.raises(
ValueError, match="joint_image_trajectory_quality_failed:arc"
):
fit_joint_image_curve(records)
def test_splay_uses_angular_midpoint_not_fixed_command_midpoint() -> None:
fit = fit_joint_center_curve(_records())
centred, zero_command, midpoint = center_splay_curve(fit)
assert midpoint == pytest.approx(0.35, abs=1.0e-4)
assert centred.angle_rad[0] == pytest.approx(0.35, abs=1.0e-4)
assert centred.angle_rad[255] == pytest.approx(-0.35, abs=1.0e-4)
assert zero_command in {127, 128}
assert abs(centred.angle_rad[zero_command]) <= 0.002
def test_compact_payload_contains_only_runtime_fields_and_inheritance() -> None:
base = fit_joint_center_curve(_records())
splay, zero_command, midpoint = center_splay_curve(base)
measured = {
name: splay if name == "index_mcp_roll" else base
for name in MEASURED_JOINTS
}
projected = {
name: 0.01 * index
for index, (name, spec) in enumerate(JOINT_SPECS.items())
if spec.zero_kind == "projected"
}
payload = build_compact_payload(
serial_number="G20_LEFT_001",
measured_fits=measured,
projected_zeros_rad=projected,
splay_zero_command_u8=zero_command,
splay_midpoint_rad=midpoint,
validation_errors_rad=[0.01, -0.02],
passed=True,
)
validate_compact_payload(payload)
assert set(payload) == {
"schema_version",
"model",
"side",
"serial_number",
"angle_unit",
"command_range",
"baseline_command_u8",
"joints",
"quality",
}
assert len(payload["joints"]) == 21
assert all(len(joint["angle_rad"]) == 256 for joint in payload["joints"].values())
assert sum("zero_command_u8" in joint for joint in payload["joints"].values()) == 16
assert sum(joint.get("passive") is True for joint in payload["joints"].values()) == 5
assert payload["joints"]["middle_mcp_roll"]["source_joint"] == "index_mcp_roll"
assert (
payload["joints"]["middle_mcp_roll"]["angle_rad"]
== payload["joints"]["index_mcp_roll"]["angle_rad"]
)
assert payload["joints"]["index_mcp_roll"]["zero_angles"] == {
"travel_midpoint_rad": pytest.approx(0.35, abs=1.0e-4)
}
for name in SPLAY_JOINTS:
assert payload["joints"][name]["zero_command_u8"] == zero_command
def test_compact_payload_allows_skipped_random_validation() -> None:
base = fit_joint_center_curve(_records())
splay, zero_command, midpoint = center_splay_curve(base)
measured = {
name: splay if name == "index_mcp_roll" else base
for name in MEASURED_JOINTS
}
projected = {
name: 0.0
for name, spec in JOINT_SPECS.items()
if spec.zero_kind == "projected"
}
payload = build_compact_payload(
serial_number="G20_LEFT_001",
measured_fits=measured,
projected_zeros_rad=projected,
splay_zero_command_u8=zero_command,
splay_midpoint_rad=midpoint,
validation_errors_rad=[],
passed=True,
)
validate_compact_payload(payload)
assert payload["quality"] == {
"passed": True,
"validation_mae_rad": None,
"validation_p95_rad": None,
}
@@ -1,104 +0,0 @@
from pathlib import Path
import pytest
import yaml
from g20_thumb_apriltag_calibration.hikrobot_camera import (
DeviceDescriptor,
decode_c_string,
load_camera_calibration,
resolve_camera_info_path,
select_device,
)
def test_decode_c_string_stops_at_first_null() -> None:
assert decode_c_string(b"DB2163742\0ignored") == "DB2163742"
def test_select_device_accepts_serial_or_guid() -> None:
devices = [
DeviceDescriptor(
index=0,
model="MV-CS020-10UM",
serial="DB2163742",
guid="2BDFB2163742",
),
DeviceDescriptor(
index=1,
model="MV-CS020-10UM",
serial="DB2163739",
guid="2BDFB2163739",
),
]
assert select_device(devices, "DB2163742", "MV-CS020-10UM").index == 0
assert select_device(devices, "2BDFB2163739", "MV-CS020-10UM").index == 1
def test_select_device_never_guesses_when_multiple_cameras_exist() -> None:
devices = [
DeviceDescriptor(0, "MV-CS020-10UM", "one", "guid-one"),
DeviceDescriptor(1, "MV-CS020-10UM", "two", "guid-two"),
]
with pytest.raises(RuntimeError, match="selector is required"):
select_device(devices, "", "MV-CS020-10UM")
def test_select_device_rejects_wrong_model() -> None:
devices = [DeviceDescriptor(0, "other", "DB2163742", "guid")]
with pytest.raises(RuntimeError, match="expected a model"):
select_device(devices, "DB2163742", "MV-CS020-10UM")
def test_load_standard_camera_calibration(tmp_path: Path) -> None:
path = tmp_path / "camera.yaml"
path.write_text(
yaml.safe_dump(
{
"image_width": 1624,
"image_height": 1240,
"camera_name": "hikrobot_front_DB2163742",
"camera_matrix": {
"rows": 3,
"cols": 3,
"data": [1000.0, 0.0, 812.0, 0.0, 1001.0, 620.0, 0.0, 0.0, 1.0],
},
"distortion_model": "plumb_bob",
"distortion_coefficients": {
"rows": 1,
"cols": 5,
"data": [0.1, -0.2, 0.0, 0.0, 0.1],
},
"rectification_matrix": {
"rows": 3,
"cols": 3,
"data": [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
},
"projection_matrix": {
"rows": 3,
"cols": 4,
"data": [1000.0, 0.0, 812.0, 0.0, 0.0, 1001.0, 620.0, 0.0, 0.0, 0.0, 1.0, 0.0],
},
},
sort_keys=False,
),
encoding="utf-8",
)
calibration = load_camera_calibration(path)
assert calibration.width == 1624
assert calibration.height == 1240
assert calibration.k[0] == 1000.0
assert calibration.p[5] == 1001.0
def test_camera_info_url_only_accepts_local_files(tmp_path: Path) -> None:
path = resolve_camera_info_path(str(tmp_path / "front.yaml"))
assert path == (tmp_path / "front.yaml").resolve()
assert resolve_camera_info_path("") is None
with pytest.raises(ValueError, match="filesystem path"):
resolve_camera_info_path("package://example/front.yaml")
@@ -1,542 +0,0 @@
from __future__ import annotations
import cv2
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from g20_thumb_apriltag_calibration.pnp import (
SquareTagGroupPoseTracker,
SquareTagPose,
SquareTagPoseTracker,
rotation_distance_rad,
select_continuous_pose,
select_rigid_group_trajectory,
solve_square_tag_ippe,
square_object_points,
)
def _camera_matrix() -> np.ndarray:
return np.asarray(
[
[650.0, 0.0, 640.0],
[0.0, 650.0, 360.0],
[0.0, 0.0, 1.0],
]
)
def _project(
rotation: Rotation,
translation_xyz_m: np.ndarray,
*,
tag_size_m: float = 0.01,
) -> np.ndarray:
rotation_vector, _ = cv2.Rodrigues(rotation.as_matrix())
corners, _ = cv2.projectPoints(
square_object_points(tag_size_m),
rotation_vector,
translation_xyz_m,
_camera_matrix(),
np.zeros((4, 1)),
)
return corners.reshape(4, 2)
def test_ippe_recovers_known_square_tag_pose() -> None:
expected_rotation = Rotation.from_euler(
"xyz", [10.0, -15.0, 25.0], degrees=True
)
expected_translation = np.asarray([0.02, -0.01, 0.25])
candidates = solve_square_tag_ippe(
_project(expected_rotation, expected_translation),
tag_size_m=0.01,
camera_matrix=_camera_matrix(),
)
assert len(candidates) == 2
actual = min(candidates, key=lambda item: item.reprojection_error_px)
assert (
rotation_distance_rad(
expected_rotation.as_quat(),
actual.quaternion_xyzw,
)
< 1.0e-8
)
assert np.allclose(actual.translation_xyz_m, expected_translation)
assert actual.reprojection_error_px < 1.0e-8
def test_temporal_selection_breaks_near_reprojection_tie() -> None:
previous = SquareTagPose(
quaternion_xyzw=(0.0, 0.0, 0.0, 1.0),
translation_xyz_m=(0.0, 0.0, 0.25),
reprojection_error_px=0.2,
)
continuous = SquareTagPose(
quaternion_xyzw=tuple(
Rotation.from_euler("y", 2.0, degrees=True).as_quat()
),
translation_xyz_m=(0.001, 0.0, 0.25),
reprojection_error_px=0.11,
)
flipped = SquareTagPose(
quaternion_xyzw=tuple(
Rotation.from_euler("y", 55.0, degrees=True).as_quat()
),
translation_xyz_m=(0.0, 0.0, 0.25),
reprojection_error_px=0.1,
)
selected, reason = select_continuous_pose(
[flipped, continuous],
previous=previous,
maximum_reprojection_error_px=1.5,
reprojection_tie_px=0.03,
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
maximum_tag_tilt_rad=np.deg2rad(75.0),
)
assert reason == ""
assert selected == continuous
def test_clear_reprojection_advantage_releases_stale_mirror_branch() -> None:
stale_mirror = SquareTagPose(
quaternion_xyzw=tuple(
Rotation.from_euler("y", 55.0, degrees=True).as_quat()
),
translation_xyz_m=(0.0, 0.0, 0.25),
reprojection_error_px=0.25,
)
true_pose = SquareTagPose(
quaternion_xyzw=tuple(
Rotation.from_euler("y", 2.0, degrees=True).as_quat()
),
translation_xyz_m=(0.001, 0.0, 0.25),
reprojection_error_px=0.05,
)
continued_mirror = SquareTagPose(
quaternion_xyzw=tuple(
Rotation.from_euler("y", 54.0, degrees=True).as_quat()
),
translation_xyz_m=(0.0, 0.0, 0.25),
reprojection_error_px=0.24,
)
selected, reason = select_continuous_pose(
[continued_mirror, true_pose],
previous=stale_mirror,
maximum_reprojection_error_px=1.5,
reprojection_tie_px=0.03,
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
maximum_tag_tilt_rad=np.deg2rad(75.0),
)
assert reason == ""
assert selected == true_pose
def test_active_motion_can_prioritise_continuous_branch() -> None:
previous = SquareTagPose(
quaternion_xyzw=tuple(
Rotation.from_euler("y", 55.0, degrees=True).as_quat()
),
translation_xyz_m=(0.0, 0.0, 0.25),
reprojection_error_px=0.25,
)
continuous = SquareTagPose(
quaternion_xyzw=tuple(
Rotation.from_euler("y", 54.0, degrees=True).as_quat()
),
translation_xyz_m=(0.0, 0.0, 0.25),
reprojection_error_px=0.24,
)
discontinuous = SquareTagPose(
quaternion_xyzw=tuple(
Rotation.from_euler("y", 2.0, degrees=True).as_quat()
),
translation_xyz_m=(0.001, 0.0, 0.25),
reprojection_error_px=0.05,
)
selected, reason = select_continuous_pose(
[continuous, discontinuous],
previous=previous,
maximum_reprojection_error_px=1.5,
reprojection_tie_px=1.5,
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
maximum_tag_tilt_rad=np.deg2rad(75.0),
)
assert reason == ""
assert selected == continuous
def test_tracker_recovers_after_timestamp_gap() -> None:
tracker = SquareTagPoseTracker(
maximum_reprojection_error_px=1.5,
reprojection_tie_px=1.5,
maximum_pose_jump_rad=np.deg2rad(5.0),
maximum_translation_jump_m=0.04,
maximum_tag_tilt_rad=np.deg2rad(75.0),
reset_after_seconds=0.5,
)
first_rotation = Rotation.from_euler("y", 0.0, degrees=True)
second_rotation = Rotation.from_euler("y", 20.0, degrees=True)
first, first_reason = tracker.estimate(
"t0",
_project(first_rotation, np.asarray([0.0, 0.0, 0.25])),
tag_size_m=0.01,
camera_matrix=_camera_matrix(),
stamp_ns=1_000_000_000,
)
rejected, rejection_reason = tracker.estimate(
"t0",
_project(second_rotation, np.asarray([0.0, 0.0, 0.25])),
tag_size_m=0.01,
camera_matrix=_camera_matrix(),
stamp_ns=1_100_000_000,
)
recovered, recovered_reason = tracker.estimate(
"t0",
_project(second_rotation, np.asarray([0.0, 0.0, 0.25])),
tag_size_m=0.01,
camera_matrix=_camera_matrix(),
stamp_ns=1_700_000_000,
)
assert first is not None
assert first_reason == ""
assert rejected is None
assert rejection_reason == "pose_jump"
assert recovered is not None
assert recovered_reason == ""
def _pose(
rotation_deg: float,
x_m: float,
reprojection_error_px: float,
) -> SquareTagPose:
return SquareTagPose(
quaternion_xyzw=tuple(
Rotation.from_euler(
"y", rotation_deg, degrees=True
).as_quat()
),
translation_xyz_m=(x_m, 0.0, 0.25),
reprojection_error_px=reprojection_error_px,
)
def test_group_tracker_prevents_incompatible_t4_t5_branch_switch() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("t0", "t3", "t4", "t5"),
adjacent_pairs=(
("t0", "t3"),
("t3", "t4"),
("t4", "t5"),
),
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
relative_rotation_scale_rad=np.deg2rad(5.0),
relative_translation_scale_m=0.01,
reprojection_scale_px=0.1,
reprojection_weight=0.05,
reset_after_seconds=5.0,
)
first = {
"t0": (_pose(0.0, 0.00, 0.05),),
"t3": (_pose(5.0, 0.03, 0.05),),
"t4": (_pose(15.0, 0.06, 0.05),),
"t5": (_pose(25.0, 0.09, 0.05),),
}
selected_first, first_reason = tracker.select(
first,
stamp_ns=1_000_000_000,
)
assert first_reason == ""
assert selected_first is not None
# The per-tag minimum-error solutions move only a few degrees and can
# therefore fool independent trackers. Together they change T4->T5 by
# 8 deg; the slightly higher-error pair preserves the physical chain.
continuous_t4 = _pose(16.0, 0.061, 0.20)
continuous_t5 = _pose(26.0, 0.091, 0.20)
independent_best_t4 = _pose(19.0, 0.061, 0.05)
independent_best_t5 = _pose(21.0, 0.091, 0.05)
second = {
"t0": (_pose(0.2, 0.00, 0.05),),
"t3": (_pose(5.2, 0.03, 0.05),),
"t4": (independent_best_t4, continuous_t4),
"t5": (independent_best_t5, continuous_t5),
}
selected, reason = tracker.select(
second,
stamp_ns=1_033_000_000,
)
assert reason == ""
assert selected is not None
assert selected["t4"] == continuous_t4
assert selected["t5"] == continuous_t5
assert tracker.branch_correction_counts == {"t4": 1, "t5": 1}
def test_group_tracker_keeps_same_pair_across_sweep_turnaround() -> None:
tracker = SquareTagGroupPoseTracker(
roles=("t4", "t5"),
adjacent_pairs=(("t4", "t5"),),
maximum_pose_jump_rad=np.deg2rad(35.0),
maximum_translation_jump_m=0.04,
relative_rotation_scale_rad=np.deg2rad(5.0),
relative_translation_scale_m=0.01,
reprojection_scale_px=0.1,
reprojection_weight=0.05,
reset_after_seconds=5.0,
)
true_t4 = _pose(30.0, 0.06, 0.05)
true_t5 = _pose(65.0, 0.09, 0.05)
selected, _ = tracker.select(
{"t4": (true_t4,), "t5": (true_t5,)},
stamp_ns=1_000_000_000,
)
assert selected is not None
return_t4 = _pose(29.5, 0.06, 0.20)
return_t5 = _pose(64.5, 0.09, 0.20)
mirror_t4 = _pose(33.0, 0.06, 0.04)
mirror_t5 = _pose(57.0, 0.09, 0.04)
selected, reason = tracker.select(
{
"t4": (mirror_t4, return_t4),
"t5": (mirror_t5, return_t5),
},
stamp_ns=1_033_000_000,
)
assert reason == ""
assert selected == {"t4": return_t4, "t5": return_t5}
def test_whole_trajectory_recovers_rigid_group_from_mirror_drift() -> None:
roles = ("t3", "t4", "t5")
mount_rotations = {
"t3": Rotation.identity(),
"t4": Rotation.from_euler("z", 20.0, degrees=True),
"t5": Rotation.from_euler("z", -15.0, degrees=True),
}
mount_positions = {
"t3": np.asarray([0.0, 0.0, 0.0]),
"t4": np.asarray([0.025, 0.0, 0.0]),
"t5": np.asarray([0.05, 0.0, 0.0]),
}
false_factors = {"t3": 0.5, "t4": -0.5, "t5": 1.0}
frames = []
true_frames = []
for angle_deg in np.linspace(0.0, 45.0, 30):
group_rotation = Rotation.from_euler(
"y", angle_deg, degrees=True
)
origin = np.asarray([0.0, 0.0, 0.3])
candidates = {}
truths = {}
for role in roles:
true_rotation = group_rotation * mount_rotations[role]
true_translation = origin + group_rotation.apply(
mount_positions[role]
)
true_pose = SquareTagPose(
quaternion_xyzw=tuple(true_rotation.as_quat()),
translation_xyz_m=tuple(true_translation),
reprojection_error_px=0.10,
)
false_rotation = true_rotation * Rotation.from_euler(
"x",
false_factors[role] * angle_deg,
degrees=True,
)
false_pose = SquareTagPose(
quaternion_xyzw=tuple(false_rotation.as_quat()),
translation_xyz_m=tuple(
true_translation
+ np.asarray(
[
0.0,
false_factors[role] * angle_deg / 10000.0,
0.0,
]
)
),
reprojection_error_px=0.05,
)
candidates[role] = (false_pose, true_pose)
truths[role] = true_pose
frames.append(candidates)
true_frames.append(truths)
selected, quality = select_rigid_group_trajectory(
frames,
roles=roles,
fixed_pairs=(("t3", "t4"), ("t4", "t5")),
reprojection_scale_px=0.1,
rotation_scale_rad=np.deg2rad(5.0),
translation_scale_m=0.01,
)
for role in roles:
assert (
rotation_distance_rad(
selected[-1][role].quaternion_xyzw,
true_frames[-1][role].quaternion_xyzw,
)
< 1.0e-8
)
assert quality["maximum_pair_rotation_drift_rad"] < np.deg2rad(5.0)
assert quality["p95_pair_rotation_drift_rad"] < np.deg2rad(5.0)
def test_trajectory_quality_uses_robust_rigid_reference() -> None:
frames = []
for index in range(30):
child_rotation = Rotation.identity()
if index == 0:
child_rotation = Rotation.from_euler(
"x", 10.0, degrees=True
)
frames.append(
{
"parent": (
SquareTagPose(
quaternion_xyzw=tuple(
Rotation.identity().as_quat()
),
translation_xyz_m=(0.0, 0.0, 0.3),
reprojection_error_px=0.1,
),
),
"child": (
SquareTagPose(
quaternion_xyzw=tuple(child_rotation.as_quat()),
translation_xyz_m=(0.03, 0.0, 0.3),
reprojection_error_px=0.1,
),
),
}
)
_, quality = select_rigid_group_trajectory(
frames,
roles=("parent", "child"),
fixed_pairs=(("parent", "child"),),
reprojection_scale_px=0.1,
rotation_scale_rad=np.deg2rad(5.0),
translation_scale_m=0.01,
)
assert quality["maximum_pair_rotation_drift_rad"] == pytest.approx(
np.deg2rad(10.0)
)
assert quality["p95_pair_rotation_drift_rad"] == pytest.approx(0.0)
assert quality["median_pair_rotation_drift_rad"] == pytest.approx(0.0)
def test_distance_geometry_ignores_planar_orientation_drift() -> None:
frames = []
for angle_deg in np.linspace(0.0, 45.0, 30):
group = Rotation.from_euler("y", angle_deg, degrees=True)
origin = np.asarray([0.0, 0.0, 0.3])
parent_position = origin
child_position = origin + group.apply([0.04, 0.0, 0.0])
# The centres form a perfect rigid pair, while the planar-PnP parent
# orientation contains a pose-dependent error.
parent_rotation = group * Rotation.from_euler(
"z", 0.25 * angle_deg, degrees=True
)
frames.append(
{
"parent": (
SquareTagPose(
quaternion_xyzw=tuple(parent_rotation.as_quat()),
translation_xyz_m=tuple(parent_position),
reprojection_error_px=0.1,
),
),
"child": (
SquareTagPose(
quaternion_xyzw=tuple(group.as_quat()),
translation_xyz_m=tuple(child_position),
reprojection_error_px=0.1,
),
),
}
)
_, quality = select_rigid_group_trajectory(
frames,
roles=("parent", "child"),
fixed_pairs=(("parent", "child"),),
reprojection_scale_px=0.1,
rotation_scale_rad=np.deg2rad(5.0),
translation_scale_m=0.01,
pair_geometry="distance",
)
assert quality["pair_geometry"] == "distance"
assert quality["maximum_pair_distance_drift_m"] < 1.0e-10
assert quality["p95_pair_distance_drift_m"] < 1.0e-10
assert quality["p95_pair_translation_drift_m"] > 0.001
def test_distance_geometry_rejects_pose_branch_with_changing_length() -> None:
frames = []
true_children = []
for index in range(30):
parent = SquareTagPose(
quaternion_xyzw=tuple(Rotation.identity().as_quat()),
translation_xyz_m=(0.0, 0.0, 0.3),
reprojection_error_px=0.1,
)
true_child = SquareTagPose(
quaternion_xyzw=tuple(Rotation.identity().as_quat()),
translation_xyz_m=(0.04, 0.0, 0.3),
reprojection_error_px=0.1,
)
false_child = SquareTagPose(
quaternion_xyzw=tuple(
Rotation.from_euler("x", 10.0, degrees=True).as_quat()
),
translation_xyz_m=(
0.04,
0.020 * np.sin(np.pi * index / 29.0),
0.3,
),
reprojection_error_px=0.05,
)
frames.append(
{
"parent": (parent,),
"child": (false_child, true_child),
}
)
true_children.append(true_child)
selected, quality = select_rigid_group_trajectory(
frames,
roles=("parent", "child"),
fixed_pairs=(("parent", "child"),),
reprojection_scale_px=0.1,
rotation_scale_rad=np.deg2rad(5.0),
translation_scale_m=0.002,
pair_geometry="distance",
)
assert selected[len(selected) // 2]["child"] == (
true_children[len(true_children) // 2]
)
assert quality["p95_pair_distance_drift_m"] < 1.0e-6
@@ -1,35 +0,0 @@
from g20_thumb_apriltag_calibration.storage import (
append_jsonl,
atomic_write_json,
completed_scan_keys,
load_json,
load_jsonl,
)
def test_jsonl_checkpoint_and_resume_keys(tmp_path) -> None:
raw_path = tmp_path / "raw_samples.jsonl"
record = {
"kind": "sample",
"phase": "root",
"cycle": 0,
"direction": "decreasing",
"command_u8": 255,
}
append_jsonl(raw_path, record)
append_jsonl(raw_path, {"kind": "validation", "command_u8": 10})
loaded = load_jsonl(raw_path)
assert loaded[0] == record
assert completed_scan_keys(loaded) == {("root", 0, "decreasing", 255)}
checkpoint_path = tmp_path / "checkpoint.json"
atomic_write_json(checkpoint_path, {"state": "PAUSED", "records": 1})
assert load_json(checkpoint_path) == {"state": "PAUSED", "records": 1}
def test_resume_ignores_only_a_truncated_final_jsonl_record(tmp_path) -> None:
raw_path = tmp_path / "raw_samples.jsonl"
append_jsonl(raw_path, {"kind": "sample", "phase": "root"})
with raw_path.open("a", encoding="utf-8") as stream:
stream.write('{"kind":"sample"\n\n')
assert load_jsonl(raw_path) == [{"kind": "sample", "phase": "root"}]
@@ -1,217 +0,0 @@
import json
import numpy as np
from g20_thumb_apriltag_calibration.three_camera_diagnostics import (
render_three_camera_status_text_zh,
)
def test_missing_endpoint_has_specific_chinese_reason_and_recovery() -> None:
payload = {
"state": "PAUSED",
"reason": "sweep_missing_endpoint_bin",
"progress": 0.1429,
"completed_sweeps": 6,
"total_sweeps": 42,
"active": {
"kind": "sweep",
"view": "front",
"motor_index": 5,
"joints": ["thumb_cmc_roll"],
"cycle": 1,
"repetitions": 3,
"start_u8": 255,
"target_u8": 0,
"actual_u8": 0.4,
"motion_progress": 0.998,
"valid_frames": 239,
"sample": {
"minimum_u8": 0.4,
"maximum_u8": 248.2,
"bin_count": 180,
"minimum_bin_count": 32,
"maximum_bin_gap": 3,
"allowed_maximum_bin_gap": 16,
"missing_endpoint_u8": [255],
"endpoint_tolerance_u8": 2.0,
},
},
"views": {
"front": {
"ready": False,
"detection_hz": 30.04,
"valid_rate": 0.0,
"missing_tag_ids": [2],
"pnp_rejections": {},
"group_pnp_reason": "group_pose_jump",
"pnp_invalid_seconds": 0.8,
"pnp_reset_count": 2,
},
"side": {
"ready": True,
"detection_hz": 30.02,
"valid_rate": 1.0,
"missing_tag_ids": [],
},
},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "标定已暂停(PAUSED)" in text
assert "缺少电机端点255" in text
assert "实际电机范围为0.4~248.2" in text
assert "第1/3轮,255→0" in text
assert "已完成6/42个扫描方向" in text
assert "当前缺失Tag=2" in text
assert "group_pose_jump" not in text
assert "PnP拒绝" not in text
assert "自动重置" not in text
assert "/g20_calibration/resume" in text
def test_preflight_lists_missing_tags_in_chinese() -> None:
payload = {
"state": "PREFLIGHT",
"reason": "waiting_for_three_cameras_tags_and_sdk",
"progress": 0.0,
"completed_sweeps": 0,
"total_sweeps": 0,
"active": {},
"views": {
"top": {
"ready": False,
"detection_hz": 30.0,
"valid_rate": 0.0,
"missing_tag_ids": [9],
}
},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "设备和标签预检(PREFLIGHT)" in text
assert "当前缺失Tag=9" in text
assert "全部必需Tag同时有效率0.0%" in text
def test_joint_fit_failure_names_metric_and_selective_retry() -> None:
payload = {
"state": "PAUSED",
"reason": "joint_fit_check_failed",
"progress": 18 / 42,
"completed_sweeps": 18,
"total_sweeps": 42,
"active": {
"kind": "fit_failure",
"view": "front",
"motor_index": 6,
"joints": ["index_mcp_roll"],
"attempt": 1,
"directions_to_rescan": 6,
"failures": [
{
"joint": "index_mcp_roll",
"metric": "arc_deg",
"actual": 3.98,
"limit": 15.0,
"comparison": "minimum",
}
],
},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "食指MCP侧摆的实测圆弧为3.98°" in text
assert "要求至少15.00°" in text
assert "只清除当前失败关节的数据并重扫6个方向" in text
assert "第1次尝试" in text
assert "运动采样:" not in text
def test_return_baseline_prints_the_exact_command() -> None:
baseline = [255] * 20
baseline[6:10] = [127, 127, 127, 127]
payload = {
"state": "RETURN_BASELINE",
"reason": "return_baseline_before_next_sweep",
"progress": 0.0,
"completed_sweeps": 0,
"total_sweeps": 42,
"baseline_command_u8": baseline,
"active": {},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "正在返回基准姿态(RETURN_BASELINE)" in text
assert f"正在确认基准姿态:{baseline}" in text
def test_status_numeric_diagnostics_are_json_serializable() -> None:
bins = [0, 16, 255]
payload = {
"state": "SWEEP",
"reason": "collecting_timestamp_synchronised_tag_centres",
"active": {
"sample": {
# np.diff返回NumPy标量;节点必须在放入状态前转成原生int。
"maximum_bin_gap": int(max(np.diff(bins), default=0)),
}
},
}
encoded = json.dumps(payload, ensure_ascii=False)
assert '"maximum_bin_gap": 239' in encoded
def test_index_roll_status_prints_clearance_motor_feedback() -> None:
payload = {
"state": "SWEEP",
"reason": "collecting_timestamp_synchronised_tag_centres",
"progress": 0.43,
"completed_sweeps": 18,
"total_sweeps": 42,
"active": {
"kind": "sweep",
"view": "front",
"motor_index": 6,
"joints": ["index_mcp_roll"],
"cycle": 1,
"repetitions": 3,
"start_u8": 255,
"target_u8": 0,
"actual_u8": 44.0,
"motion_progress": 0.827,
"valid_frames": 971,
"sample": {"minimum_u8": 40.0, "maximum_u8": 253.0},
"auxiliary_motors": [
{"motor_index": 7, "command_u8": 0, "actual_u8": 0.0},
{"motor_index": 8, "command_u8": 0, "actual_u8": 1.0},
{"motor_index": 9, "command_u8": 0, "actual_u8": 0.0},
],
"speed": {
"commanded_finger_speed": [15, 5, 15, 15, 15],
"reported_finger_speed": [15, 5, 15, 15, 15],
},
},
"views": {},
"result_path": "",
}
text = render_three_camera_status_text_zh(payload)
assert "避挡姿态:电机7目标0、实际0.0" in text
assert "电机8目标0、实际1.0" in text
assert "电机9目标0、实际0.0" in text
assert "阶段速度:五指目标[15, 5, 15, 15, 15]" in text
assert "SDK报告[15, 5, 15, 15, 15]" in text
@@ -1,170 +0,0 @@
import json
import math
from types import SimpleNamespace
from g20_thumb_apriltag_calibration.core import (
DIRECTION_DECREASING,
DIRECTION_INCREASING,
)
from g20_thumb_apriltag_calibration.full_hand import SWEEP_SPECS
from g20_thumb_apriltag_calibration.three_camera_node import (
G20ThreeCameraCalibrationNode,
SweepItem,
)
def _sweep_items() -> list[SweepItem]:
return [
SweepItem(spec, cycle, direction)
for spec in SWEEP_SPECS
for cycle in range(3)
for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING)
]
def _image_cycle_records(travels_rad: list[float]) -> list[dict]:
commands = list(range(0, 256, 16)) + [255]
records = []
for cycle, travel in enumerate(travels_rad):
for direction in (DIRECTION_DECREASING, DIRECTION_INCREASING):
for command in commands:
angle = travel * (255.0 - command) / 255.0
records.append(
{
"cycle": cycle,
"direction": direction,
"command_u8": command,
"relative_translation_xyz_m": [0.01, 0.0, 0.0],
"image_relative_xy_px": [
100.0 * math.cos(angle),
100.0 * math.sin(angle),
],
}
)
return records
def _fit_check_node(records_by_joint: dict) -> SimpleNamespace:
node = SimpleNamespace(
records_by_joint=records_by_joint,
repetitions=3,
trajectory_maximum_plane_rms_m=0.004,
trajectory_maximum_radial_rms_m=0.004,
trajectory_minimum_radius_m=0.003,
trajectory_minimum_arc_rad=math.radians(15.0),
image_trajectory_maximum_radial_rms_px=2.0,
image_trajectory_maximum_radial_p95_px=3.5,
image_trajectory_minimum_radius_px=20.0,
trajectory_maximum_cycle_travel_difference_rad=math.radians(3.0),
passive_maximum_cycle_travel_difference_rad=math.radians(10.0),
maximum_monotonic_correction_rad=math.radians(2.0),
maximum_hysteresis_rad=math.radians(5.0),
passive_maximum_monotonic_correction_rad=math.radians(3.0),
passive_maximum_hysteresis_rad=math.radians(7.5),
zero_minimum_radius_px=20.0,
zero_maximum_radial_rms_px=2.0,
zero_maximum_radial_p95_px=3.5,
zero_maximum_round_difference_rad=math.radians(1.0),
)
node._fit_joint_records = lambda name, records, relaxed=False: (
G20ThreeCameraCalibrationNode._fit_joint_records(
node, name, records, relaxed=relaxed
)
)
return node
def test_fit_failure_rewinds_to_failed_specs_first_direction(tmp_path) -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 6)
node = SimpleNamespace(
sweep_items=_sweep_items(),
sweep_attempts={spec.motor_index: 1},
retry_sweep_spec=None,
fit_failure={},
sweep_index=24,
raw_path=tmp_path / "raw_samples.jsonl",
repetitions=3,
)
node._sweep_spec_start_index = lambda selected: (
G20ThreeCameraCalibrationNode._sweep_spec_start_index(node, selected)
)
node._pause = lambda reason: setattr(node, "paused_reason", reason)
G20ThreeCameraCalibrationNode._pause_for_provisional_fit_failure(
node,
spec,
[
{
"joint": "index_mcp_roll",
"metric": "arc_deg",
"actual": 3.98,
"limit": 15.0,
"comparison": "minimum",
}
],
)
assert node.sweep_index == 18
assert node.retry_sweep_spec == spec
assert node.fit_failure["directions_to_rescan"] == 6
assert node.paused_reason == "joint_fit_check_failed"
def test_resume_discards_only_failed_specs_samples(tmp_path) -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 15)
records = {
"thumb_mcp": [{"old": "mcp"}],
"thumb_ip": [{"old": "ip"}],
"thumb_cmc_pitch": [{"keep": True}],
}
node = SimpleNamespace(
retry_sweep_spec=spec,
records_by_joint=records,
sweep_attempts={spec.motor_index: 1},
raw_path=tmp_path / "raw_samples.jsonl",
paused_reason="joint_fit_check_failed",
fit_failure={"failures": ["old"]},
)
selected = G20ThreeCameraCalibrationNode._prepare_failed_sweep_retry(node)
assert selected == spec
assert records["thumb_mcp"] == []
assert records["thumb_ip"] == []
assert records["thumb_cmc_pitch"] == [{"keep": True}]
assert node.sweep_attempts[15] == 2
assert node.retry_sweep_spec is None
assert node.fit_failure == {}
event = json.loads((tmp_path / "raw_samples.jsonl").read_text())
assert event == {
"kind": "retry",
"view": "front",
"motor_index": 15,
"joints": ["thumb_mcp", "thumb_ip"],
"attempt": 2,
"reason": "joint_fit_check_failed",
}
def test_provisional_fit_rejects_inconsistent_cycle_travel() -> None:
spec = next(item for item in SWEEP_SPECS if item.motor_index == 0)
node = _fit_check_node(
{
"thumb_cmc_pitch": _image_cycle_records(
[math.radians(47.0), math.radians(47.2), math.radians(55.0)]
)
}
)
failures = G20ThreeCameraCalibrationNode._provisional_fit_failures(
node, spec
)
cycle_failure = next(
item
for item in failures
if item["metric"] == "cycle_travel_range_deg"
)
assert cycle_failure["joint"] == "thumb_cmc_pitch"
assert cycle_failure["actual"] == 8.0
assert cycle_failure["limit"] == 3.0
@@ -1,295 +0,0 @@
from __future__ import annotations
import numpy as np
import pytest
from scipy.spatial.transform import Rotation
from g20_thumb_apriltag_calibration.core import (
DIRECTION_DECREASING,
DIRECTION_INCREASING,
PAIR_IP,
PAIR_MCP,
PAIR_ROOT,
PHASE_ROOT,
PHASE_TIP,
create_final_payload,
)
from g20_thumb_apriltag_calibration.trajectory import (
_regularize_coupled_zero_tail,
fit_center_trajectory_curves,
maximum_center_non_target_drift_rad,
measure_center_trajectory_angles,
)
def _quat(angle: float) -> list[float]:
return [
float(value)
for value in Rotation.from_rotvec([0.0, 0.0, angle]).as_quat()
]
def _records(
*,
camera_rotation: Rotation = Rotation.identity(),
camera_translation: np.ndarray = np.zeros(3),
tag_shift: float = 0.0,
) -> list[dict]:
commands = list(range(0, 256, 8))
if commands[-1] != 255:
commands.append(255)
directions = (
(DIRECTION_DECREASING, list(reversed(commands))),
(DIRECTION_INCREASING, commands),
)
t0 = np.asarray([0.0, 0.0, 0.55])
root_centre = np.asarray([0.025, -0.010, 0.55])
root_points = {
"t3": np.asarray([0.060 + tag_shift, -0.005, 0.55]),
"t4": np.asarray([0.090, 0.002 + tag_shift, 0.55]),
"t5": np.asarray([0.120, 0.009, 0.55 + tag_shift]),
}
mcp_centre = np.asarray([0.055, -0.004, 0.0])
t3_tip = np.asarray([0.0, 0.0, 0.55])
t4_reference = t3_tip + np.asarray([0.080, 0.006 + tag_shift, 0.0])
ip_centre = t3_tip + np.asarray([0.095, 0.006, 0.0])
t5_reference = t3_tip + np.asarray([0.125, 0.008 + tag_shift, 0.0])
def camera(point: np.ndarray) -> np.ndarray:
return camera_rotation.apply(point) + camera_translation
records: list[dict] = []
for phase in (PHASE_ROOT, PHASE_TIP):
for direction, ordered_commands in directions:
for command in ordered_commands:
progress = (255.0 - command) / 255.0
root_angle = 0.80 * progress if phase == PHASE_ROOT else 0.0
mcp_angle = 1.15 * progress if phase == PHASE_TIP else 0.0
ip_angle = 1.02 * mcp_angle if phase == PHASE_TIP else 0.0
if phase == PHASE_ROOT:
root_rotation = Rotation.from_rotvec(
[0.0, 0.0, root_angle]
)
positions = {
"t0": t0,
**{
role: root_centre
+ root_rotation.apply(point - root_centre)
for role, point in root_points.items()
},
}
else:
mcp_rotation = Rotation.from_rotvec(
[0.0, 0.0, mcp_angle]
)
ip_rotation = Rotation.from_rotvec(
[0.0, 0.0, ip_angle]
)
# The MCP centre below is expressed relative to T3.
mcp_world = t3_tip + mcp_centre
t4 = mcp_world + mcp_rotation.apply(
t4_reference - mcp_world
)
ip_at_zero = ip_centre
t5_inside_parent = ip_at_zero + ip_rotation.apply(
t5_reference - ip_at_zero
)
t5 = mcp_world + mcp_rotation.apply(
t5_inside_parent - mcp_world
)
positions = {
"t0": t0,
"t3": t3_tip,
"t4": t4,
"t5": t5,
}
records.append(
{
"kind": "sample",
"phase": phase,
"cycle": 0,
"direction": direction,
"command_u8": command,
"relative_quaternion_xyzw": {
PAIR_ROOT: _quat(root_angle),
PAIR_MCP: _quat(mcp_angle),
PAIR_IP: _quat(ip_angle),
},
"tag_translation_xyz_m": {
role: [
float(value) for value in camera(point)
]
for role, point in positions.items()
},
}
)
return records
def test_centre_trajectory_recovers_three_joint_angles_and_zero() -> None:
records = _records()
fit = fit_center_trajectory_curves(
records,
maximum_plane_rms_m=0.001,
maximum_radial_rms_m=0.001,
maximum_anchor_drift_m=0.001,
)
assert fit.measurement_mode == "trajectory_center_3d"
assert fit.joints["thumb_cmc_pitch"]["angle_rad"][0] == pytest.approx(
0.80, abs=2.0e-3
)
assert fit.joints["thumb_mcp"]["angle_rad"][0] == pytest.approx(
1.15, abs=2.0e-3
)
assert fit.joints["thumb_ip"]["angle_rad"][0] == pytest.approx(
1.173, abs=3.0e-3
)
for joint in fit.joints.values():
assert joint["angle_rad"][255] == 0.0
payload = create_final_payload(
serial_number="G20_LEFT_TRAJECTORY_TEST",
fit=fit,
validation_errors_rad=[0.01, -0.01],
passed=True,
)
assert payload["zero_command_u8"] == 255
for joint in payload["joints"].values():
assert len(joint["angle_rad"]) == 256
assert joint["angle_rad"][255] == 0.0
def test_centre_trajectory_is_invariant_to_camera_and_tag_offset() -> None:
reference = fit_center_trajectory_curves(_records())
changed = fit_center_trajectory_curves(
_records(
camera_rotation=Rotation.from_euler(
"xyz", [0.35, -0.25, 0.20]
),
camera_translation=np.asarray([0.12, -0.04, 0.08]),
tag_shift=0.004,
)
)
for joint_name in ("thumb_cmc_pitch", "thumb_mcp", "thumb_ip"):
assert changed.joints[joint_name]["angle_rad"] == pytest.approx(
reference.joints[joint_name]["angle_rad"],
abs=6.0e-3,
)
def test_passive_ip_uses_mimic_constraint_despite_distal_pnp_bias() -> None:
reference = fit_center_trajectory_curves(_records())
biased_records = _records(
camera_rotation=Rotation.from_euler(
"xyz", [-0.28, 0.31, -0.16]
),
camera_translation=np.asarray([-0.08, 0.03, 0.11]),
)
for record in biased_records:
if record["phase"] != PHASE_TIP:
continue
progress = (255.0 - float(record["command_u8"])) / 255.0
bias = np.asarray(
[
0.0012 * np.sin(1.7 * progress),
0.0008 * progress * progress,
-0.0006 * np.sin(2.3 * progress),
]
)
record["tag_translation_xyz_m"]["t5"] = [
float(value)
for value in (
np.asarray(
record["tag_translation_xyz_m"]["t5"], dtype=float
)
+ bias
)
]
biased = fit_center_trajectory_curves(biased_records)
for fit in (reference, biased):
mcp = np.asarray(fit.joints["thumb_mcp"]["angle_rad"])
ip = np.asarray(fit.joints["thumb_ip"]["angle_rad"])
assert ip == pytest.approx(1.02 * mcp, abs=1.1e-8)
assert fit.ip_coupling["multiplier"] == pytest.approx(1.02)
assert fit.ip_coupling["offset_rad"] == 0.0
assert fit.ip_coupling["constrained_r_squared"] == 1.0
assert fit.ip_coupling["r_squared"] > 0.98
assert biased.joints["thumb_ip"]["angle_rad"] == pytest.approx(
reference.joints["thumb_ip"]["angle_rad"],
abs=6.0e-3,
)
assert (
biased.trajectory_quality["tip"][
"ip_observed_vs_constrained_max_rad"
]
> 0.0
)
def test_passive_ip_multiplier_is_configurable() -> None:
fit = fit_center_trajectory_curves(
_records(),
passive_ip_multiplier=0.97,
)
mcp = np.asarray(fit.joints["thumb_mcp"]["angle_rad"])
ip = np.asarray(fit.joints["thumb_ip"]["angle_rad"])
assert ip == pytest.approx(0.97 * mcp, abs=1.1e-8)
def test_static_measurement_uses_fitted_serial_tip_model() -> None:
records = _records()
fit = fit_center_trajectory_curves(records)
command = 128
record = next(
item
for item in records
if item["phase"] == PHASE_TIP
and item["direction"] == DIRECTION_DECREASING
and item["command_u8"] == command
)
measured = measure_center_trajectory_angles(
fit.trajectory_models,
record["tag_translation_xyz_m"],
)
progress = (255.0 - command) / 255.0
assert measured["thumb_mcp"] == pytest.approx(
1.15 * progress, abs=2.0e-3
)
assert measured["thumb_ip"] == pytest.approx(
1.02 * 1.15 * progress, abs=3.0e-3
)
def test_non_target_drift_is_measured_without_tag_orientations() -> None:
records = _records()
fit = fit_center_trajectory_curves(records)
assert maximum_center_non_target_drift_rad(
records, fit.trajectory_models
) == pytest.approx(0.0, abs=3.0e-3)
def test_short_ip_zero_tail_uses_coupled_mcp_shape() -> None:
mcp = np.linspace(1.0, 0.0, 256)
ip = 0.6 * mcp
ip[248:] = 0.0
regularized = _regularize_coupled_zero_tail(ip, mcp)
assert regularized[:248] == pytest.approx(ip[:248])
assert np.all(regularized[248:255] > 0.0)
assert np.all(np.diff(regularized) <= 1.0e-12)
assert regularized[255] == 0.0
assert regularized[248:255] == pytest.approx(
0.6 * mcp[248:255]
)
def test_long_or_unresolved_ip_zero_tail_is_not_invented() -> None:
mcp = np.linspace(1.0, 0.0, 256)
ip = 0.6 * mcp
ip[220:] = 0.0
regularized = _regularize_coupled_zero_tail(ip, mcp)
assert regularized == pytest.approx(ip)
@@ -1,340 +0,0 @@
from __future__ import annotations
import math
import cv2
from g20_thumb_apriltag_calibration.zero_calibration import (
build_trajectory_zero_angle_payload,
build_trajectory_zero_travel_payload,
circular_median_rad,
detect_reference_alignment_line,
fit_image_circle_trajectory,
measure_zero_from_circle,
signed_angle_difference_rad,
summarize_zero_frames,
validate_zero_angle_payload,
validate_zero_travel_payload,
)
import numpy as np
import pytest
def _detect_reference_line(image: np.ndarray, reference_y: float) -> dict | None:
return detect_reference_alignment_line(
image,
reference_y_px=reference_y,
roi_y_min_ratio=0.55,
roi_y_max_ratio=0.98,
minimum_length_ratio=0.30,
maximum_candidate_angle_rad=math.radians(15.0),
)
def _frame(
table_angle_rad: float,
*,
state_u8: float = 255.0,
t0_xy: tuple[float, float] = (500.0, 300.0),
circle_xy: tuple[float, float] = (-120.0, 80.0),
radius_px: float = 90.0,
) -> dict[str, float]:
# table_angle_rad uses a y-up convention while image y grows downwards.
relative = np.asarray(circle_xy) + radius_px * np.asarray(
[math.cos(table_angle_rad), -math.sin(table_angle_rad)]
)
t0 = np.asarray(t0_xy)
t3 = t0 + relative
return {
"state_u8": state_u8,
"t0_x_px": float(t0[0]),
"t0_y_px": float(t0[1]),
"t3_x_px": float(t3[0]),
"t3_y_px": float(t3[1]),
}
def _trajectory(noise_px: float = 0.15) -> list[dict[str, float]]:
rng = np.random.default_rng(7)
observations: list[dict[str, float]] = []
for states in (
np.linspace(255.0, 64.0, 70),
np.linspace(64.0, 255.0, 70),
):
for state in states:
fraction = (255.0 - state) / (255.0 - 64.0)
angle = math.radians(10.0 + 65.0 * fraction)
shift = rng.normal(0.0, 0.35, size=2)
frame = _frame(
angle,
state_u8=float(state),
t0_xy=(500.0 + shift[0], 300.0 + shift[1]),
)
frame["t3_x_px"] += float(rng.normal(0.0, noise_px))
frame["t3_y_px"] += float(rng.normal(0.0, noise_px))
observations.append(frame)
return observations
def _fit(observations: list[dict[str, float]] | None = None) -> dict:
return fit_image_circle_trajectory(
_trajectory() if observations is None else observations,
bin_size_u8=8.0,
minimum_frames=45,
minimum_bins=18,
minimum_state_span_u8=160.0,
minimum_radius_px=20.0,
minimum_arc_rad=math.radians(20.0),
maximum_radial_rms_px=2.0,
maximum_p95_radial_error_px=3.5,
)
def _zero_summary(angle_rad: float) -> dict[str, float]:
frames = [
_frame(
angle_rad + math.radians(index - 14.5) * 1.0e-4,
)
for index in range(30)
]
return summarize_zero_frames(frames)
def _measurement(
table_rad: float,
*,
radial_error_px: float = 0.2,
) -> dict[str, float]:
return {
"table_rad": table_rad,
"zero_radial_error_px": radial_error_px,
}
def _round(
table_rad: float,
*,
return_delta: float = 0.002,
) -> dict[str, dict[str, float]]:
return {
"zero_before": _measurement(table_rad),
"zero_after": _measurement(
table_rad + return_delta,
),
}
def _travel_round(
zero_table_rad: float,
travel_rad: float,
*,
return_delta: float = 0.002,
) -> dict[str, dict[str, float]]:
round_value = _round(
zero_table_rad,
return_delta=return_delta,
)
round_value["travel_endpoint"] = _measurement(
zero_table_rad + travel_rad,
)
return round_value
def test_circular_statistics_cross_pi_without_jumping() -> None:
values = [math.radians(179.0), math.radians(-179.0), math.pi]
result = circular_median_rad(values)
assert abs(signed_angle_difference_rad(result, math.pi)) < math.radians(1.1)
def test_detect_reference_line_reports_signed_angle_and_offset() -> None:
height, width = 720, 1280
reference_y = 0.90 * (height - 1)
expected_angle = math.radians(2.0)
image = np.zeros((height, width, 3), dtype=np.uint8)
half_span = 560.0
vertical_change = math.tan(expected_angle) * half_span
cv2.line(
image,
(80, int(round(reference_y + vertical_change))),
(1200, int(round(reference_y - vertical_change))),
(255, 255, 255),
5,
)
result = _detect_reference_line(image, reference_y)
assert result is not None
assert result["angle_rad"] == pytest.approx(expected_angle, abs=0.004)
assert abs(result["vertical_offset_px"]) <= 5.0
def test_detect_reference_line_rejects_non_horizontal_scene() -> None:
image = np.zeros((720, 1280, 3), dtype=np.uint8)
cv2.line(image, (640, 420), (640, 700), (255, 255, 255), 5)
assert _detect_reference_line(image, 0.90 * 719.0) is None
def test_circle_fit_recovers_center_radius_and_rejects_anchor_translation() -> None:
fit = _fit()
assert fit["centre_relative_xy_px"] == pytest.approx(
[-120.0, 80.0], abs=0.8
)
assert fit["radius_px"] == pytest.approx(90.0, abs=0.8)
assert fit["arc_rad"] >= math.radians(60.0)
assert fit["radial_rms_px"] < 0.5
assert fit["passed"] is True
def test_circle_fit_is_robust_to_sparse_bad_tag_centres() -> None:
observations = _trajectory()
for index in (9, 31, 57, 92, 121):
observations[index]["t3_x_px"] += 18.0
observations[index]["t3_y_px"] -= 15.0
fit = _fit(observations)
assert fit["centre_relative_xy_px"] == pytest.approx(
[-120.0, 80.0], abs=1.5
)
assert fit["radius_px"] == pytest.approx(90.0, abs=1.5)
def test_zero_uses_fixed_inward_radius_and_ignores_t3_rotation() -> None:
circle = _fit()
trajectory_angle = math.radians(10.0)
expected_table = math.radians(-170.0)
summary = _zero_summary(trajectory_angle)
summary_with_t3_forward = {**summary, "t3_rad": math.radians(31.0)}
summary_with_t3_flipped = {
**summary,
"t3_rad": math.radians(-149.0),
}
result = measure_zero_from_circle(circle, summary)
forward = measure_zero_from_circle(circle, summary_with_t3_forward)
flipped = measure_zero_from_circle(circle, summary_with_t3_flipped)
assert result["table_rad"] == pytest.approx(expected_table, abs=0.01)
assert forward == result
assert flipped == result
def test_circle_fit_rejects_short_state_span() -> None:
observations = [
_frame(math.radians(10.0 + index * 0.1), state_u8=255.0 - index)
for index in range(50)
]
with pytest.raises(ValueError, match="trajectory_state_span_too_small"):
_fit(observations)
def test_payload_remains_small_and_contains_circle_derived_angles() -> None:
rounds = [
_round(-0.30),
_round(-0.299),
_round(-0.302),
]
payload, report = build_trajectory_zero_angle_payload(
serial_number="G20_LEFT_TEST",
rounds=rounds,
trajectory_quality=_fit(),
maximum_round_difference_rad=math.radians(1.0),
maximum_return_error_rad=math.radians(1.0),
maximum_zero_radial_error_px=4.0,
detection_rate=0.99,
minimum_detection_rate=0.95,
)
validate_zero_angle_payload(payload)
assert payload["schema_version"] == 2
assert set(payload["zero_angles"]) == {"table_projected_zero_rad"}
assert payload["zero_angles"]["table_projected_zero_rad"] == pytest.approx(
-0.299
)
assert payload["quality"]["passed"] is True
assert report["measurement_method"] == (
"t3_center_to_circle_centre_image_trajectory"
)
@pytest.mark.parametrize(("detection_rate", "radial_error"), [(0.8, 0.2), (0.99, 8.0)])
def test_quality_fails_for_detection_or_zero_circle_error(
detection_rate: float, radial_error: float
) -> None:
rounds = [
{
"zero_before": _measurement(
-0.30, radial_error_px=radial_error
),
"zero_after": _measurement(
-0.299, radial_error_px=radial_error
),
}
for _ in range(3)
]
payload, _ = build_trajectory_zero_angle_payload(
serial_number="G20_LEFT_TEST",
rounds=rounds,
trajectory_quality=_fit(),
maximum_round_difference_rad=math.radians(1.0),
maximum_return_error_rad=math.radians(1.0),
maximum_zero_radial_error_px=4.0,
detection_rate=detection_rate,
minimum_detection_rate=0.95,
)
assert payload["quality"]["passed"] is False
def test_roll_payload_contains_zero_and_measured_travel() -> None:
rounds = [
_travel_round(-0.30, 1.201),
_travel_round(-0.299, 1.199),
_travel_round(-0.302, 1.200),
]
payload, report = build_trajectory_zero_travel_payload(
serial_number="G20_LEFT_TEST",
joint_name="thumb_cmc_roll",
zero_command_u8=255,
travel_endpoint_command_u8=0,
rounds=rounds,
trajectory_quality=_fit(),
maximum_round_difference_rad=math.radians(1.0),
maximum_travel_difference_rad=math.radians(1.0),
minimum_travel_rad=math.radians(20.0),
maximum_return_error_rad=math.radians(1.0),
maximum_zero_radial_error_px=4.0,
detection_rate=0.99,
minimum_detection_rate=0.95,
)
validate_zero_travel_payload(payload)
assert payload["joint"] == "thumb_cmc_roll"
assert payload["schema_version"] == 2
assert payload["zero_command_u8"] == 255
assert payload["travel_endpoint_command_u8"] == 0
assert set(payload["zero_angles"]) == {"table_projected_zero_rad"}
assert payload["travel"]["signed_rad"] == pytest.approx(1.2, abs=0.003)
assert payload["travel"]["range_rad"] == pytest.approx(1.2, abs=0.003)
assert payload["quality"]["passed"] is True
assert report["signed_travel_rounds_rad"] == pytest.approx(
[1.2, 1.198, 1.199], abs=0.003
)
def test_roll_quality_rejects_inconsistent_or_short_travel() -> None:
rounds = [
_travel_round(-0.30, travel)
for travel in (0.10, 0.12, 0.14)
]
payload, _ = build_trajectory_zero_travel_payload(
serial_number="G20_LEFT_TEST",
joint_name="thumb_cmc_roll",
zero_command_u8=255,
travel_endpoint_command_u8=0,
rounds=rounds,
trajectory_quality=_fit(),
maximum_round_difference_rad=math.radians(1.0),
maximum_travel_difference_rad=math.radians(1.0),
minimum_travel_rad=math.radians(20.0),
maximum_return_error_rad=math.radians(1.0),
maximum_zero_radial_error_px=4.0,
detection_rate=0.99,
minimum_detection_rate=0.95,
)
assert payload["quality"]["passed"] is False
+79 -11
View File
@@ -9,6 +9,20 @@ class HandConfig:
joint_names_en: Optional[List[str]] = None
init_pos: List[int] = field(default_factory=list)
preset_actions: Optional[Dict[str, List[int]]] = None
preset_action_overrides: Optional[Dict[str, Dict[str, List[int]]]] = None
# Integer GUI values are divided by this scale before publishing.
# Legacy hands use raw u8 values (scale=1); O12 uses milliradians.
position_scale: int = 1
position_unit: str = "u8"
def get_preset_actions(self, hand_type: str) -> Dict[str, List[int]]:
"""Return preset actions with hand-specific values applied."""
actions = dict(self.preset_actions or {})
if self.preset_action_overrides:
actions.update(
self.preset_action_overrides.get(hand_type.lower(), {})
)
return actions
# ------------------------------------------------------------------
# 常量字典(仅构建一次)
@@ -75,10 +89,10 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
"点赞": [255, 0, 0, 0, 0, 255, 162, 162, 144, 100, 210, 255, 255, 255, 255, 255, 0, 0, 0, 0],
"握拳": [96, 0, 0, 0, 0, 0, 193, 158, 128, 91, 132, 255, 255, 255, 255, 144, 0, 0, 0, 0],
"张开": [255, 255, 255, 255, 255, 255, 193, 148, 105, 42, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"OK": [148, 110, 255, 255, 255, 44, 164, 100, 114, 127, 178, 255, 255, 255, 255, 94, 71, 255, 255, 255],
"拇指对中指": [191, 255, 55, 255, 255, 96, 95, 100, 114, 127, 105, 255, 255, 255, 255, 94, 255, 108, 255, 255],
"拇指对无名指": [191, 255, 255, 72, 255, 115, 95, 100, 114, 127, 60, 255, 255, 255, 255, 94, 255, 255, 97, 255],
"拇指对小指": [191, 255, 255, 255, 55, 0, 95, 100, 114, 121, 70, 255, 255, 255, 255, 94, 255, 255, 255, 100],
"OK": [0, 0, 255, 255, 255, 138, 147, 148, 105, 42, 109, 255, 255, 255, 255, 255, 211, 255, 255, 255],
"拇指对中指": [0, 255, 0, 255, 255, 107, 149, 148, 105, 42, 109, 255, 255, 255, 255, 255, 225, 202, 255, 255],
"拇指对无名指": [0, 255, 255, 0, 255, 88, 171, 148, 105, 42, 59, 255, 255, 255, 255, 255, 255, 255, 206, 254],
"拇指对小指": [0, 255, 255, 255, 0, 32, 170, 148, 105, 42, 109, 255, 255, 255, 255, 255, 255, 255, 255, 203],
"准备1": [255, 0, 0, 0, 0, 255, 162, 162, 144, 100, 210, 255, 255, 255, 255, 255, 0, 0, 0, 0],
"壹": [96, 255, 0, 0, 0, 0, 190, 161, 127, 80, 68, 255, 255, 255, 255, 144, 255, 0, 0, 0],
"贰": [96, 255, 255, 0, 0, 0, 190, 66, 127, 80, 68, 255, 255, 255, 255, 144, 255, 255, 0, 0],
@@ -100,8 +114,6 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
"动作9": [255, 255, 255, 255, 255, 255, 125, 129, 125, 130, 210, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"根部1": [0, 0, 0, 0, 0, 255, 125, 129, 125, 130, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"根部2": [255, 255, 255, 255, 255, 255, 125, 129, 125, 130, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"根部1": [0, 0, 0, 0, 0, 255, 125, 129, 125, 130, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"根部2": [255, 255, 255, 255, 255, 255, 125, 129, 125, 130, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"末端1": [6, 0, 0, 0, 0, 255, 125, 129, 125, 130, 219, 255, 255, 255, 255, 125, 0, 0, 0, 0],
"末端2": [6, 0, 0, 0, 0, 255, 125, 129, 125, 130, 219, 255, 255, 255, 255, 255, 255, 255, 255, 255],
"末端3": [6, 0, 0, 0, 0, 255, 125, 129, 125, 130, 219, 255, 255, 255, 255, 125, 0, 0, 0, 0],
@@ -224,13 +236,20 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
"叁": [92, 87, 255, 255, 255, 0],
"肆": [92, 87, 255, 255, 255, 255],
"伍": [255, 255, 255, 255, 255, 255],
"OK": [139, 91, 103, 250, 250, 250],
"OK": [95, 75, 116, 255, 255, 255],
"拇指对中指": [88, 2, 255, 114, 255, 255],
"点赞": [250, 79, 0, 0, 0, 0],
"握拳": [102, 18, 0, 0, 0, 0],
}
},
preset_action_overrides={
"right": {
"OK": [95, 83, 122, 255, 255, 255],
"拇指对中指": [95, 8, 255, 114, 255, 255],
},
},
),
"L6": HandConfig(
joint_names_en=["thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch", "middle_mcp_pitch", "pinky_mcp_pitch", "ring_mcp_pitch"],
joint_names_en=["thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"],
joint_names=["大拇指弯曲", "大拇指横摆", "食指弯曲", "中指弯曲", "无名指弯曲", "小拇指弯曲"],
init_pos=[250] * 6,
preset_actions={
@@ -240,7 +259,7 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
"叁": [0, 39, 255, 255, 255, 0],
"肆": [0, 0, 255, 255, 255, 255],
"伍": [255, 255, 255, 255, 255, 255],
"OK": [74, 13, 153, 255, 255, 255],
"OK": [62, 5, 151, 255, 255, 255],
"点赞": [255, 255, 0, 0, 0, 0],
"握拳": [79, 11, 0, 0, 0, 0],
"序列动作1": [250, 250, 250, 250, 250, 250],
@@ -256,7 +275,56 @@ _HAND_CONFIGS: Dict[str, HandConfig] = {
"拇指压感准备1": [139, 18, 130, 0, 0, 0],
"拇指压感测试": [39, 30, 122, 250, 250, 250],
"拇指压感准备2": [139, 18, 130, 0, 0, 0]
}
},
preset_action_overrides={
"right": {
"OK": [58, 6, 153, 255, 255, 255],
},
},
),
"O12": HandConfig(
# O12 active-angle API order. Keep this independent from the
# physical motor order exposed by some SDK metadata.
joint_names_en=[
"thumb_roll", "thumb_abad", "thumb_mcp", "thumb_pip",
"index_abad", "index_mcp", "index_pip",
"middle_abad", "middle_mcp", "middle_pip",
"ring_mcp", "pinky_mcp",
],
joint_names=[
"拇指旋转", "拇指侧摆", "拇指根部", "拇指中部",
"食指侧摆", "食指根部", "食指中部",
"中指侧摆", "中指根部", "中指中部",
"无名指根部", "小指根部",
],
# Values are milliradians so QSlider can retain 0.001 rad resolution.
init_pos=[0] * 12,
preset_actions={
"展开": [0] * 12,
"食指轻弯": [0, 0, 0, 0, 0, 300, 400, 0, 0, 0, 0, 0],
"半握": [
300, -200, -200, -500,
0, 650, 700, 0, 650, 800, 700, 700,
],
},
preset_action_overrides={
"right": {
"拇指对食指": [
245, -766, -818, 0, 0, 1046, 0, 0, 0, 0, 0, 0,
],
"拇指对中指": [
489, -843, -685, 0, 0, 0, 0, 0, 1147, 0, 0, 0,
],
"拇指对无名指": [
699, -779, -827, 0, 0, 0, 0, 0, 0, 0, 690, 0,
],
"拇指对小指": [
786, -1065, -827, 0, 0, 0, 0, 0, 0, 0, 0, 645,
],
},
},
position_scale=1000,
position_unit="rad",
),
}
HAND_CONFIGS = MappingProxyType(_HAND_CONFIGS)
+180 -37
View File
@@ -1,5 +1,6 @@
import sys
import time, json
import math
import threading
from dataclasses import dataclass
from typing import List, Dict
@@ -33,6 +34,16 @@ _CANONICAL_COMMAND_NAMES = {
"thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch",
"middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch",
],
"L6": [
"thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch",
"middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch",
],
"O12": [
"thumb_roll", "thumb_abad", "thumb_mcp", "thumb_pip",
"index_abad", "index_mcp", "index_pip",
"middle_abad", "middle_mcp", "middle_pip",
"ring_mcp", "pinky_mcp",
],
}
_CANONICAL_COMMAND_BOUNDS = {
@@ -43,17 +54,38 @@ _CANONICAL_COMMAND_BOUNDS = {
*[(0, 255)] * 5,
],
"O6": [(0, 255)] * 6,
"L6": [(0, 255)] * 6,
}
# Integer slider bounds in milliradians, derived from the O12 active-angle
# limits. Right and left thumb signs differ; all other limits are shared.
_O12_COMMAND_BOUNDS_MRAD = {
"right": [
(0, 942), (-1387, 0), (-827, 0), (-1291, 0),
(-261, 261), (0, 1352), (0, 1530), (-261, 261),
(0, 1357), (0, 1815), (0, 1535), (0, 1535),
],
"left": [
(-942, 0), (0, 1387), (-827, 0), (-1291, 0),
(-261, 261), (0, 1352), (0, 1530), (-261, 261),
(0, 1357), (0, 1815), (0, 1535), (0, 1535),
],
}
class ROS2NodeManager(QObject):
"""ROS2节点管理器,处理ROS通信"""
status_updated = pyqtSignal(str, str) # 状态类型, 消息内容
feedback_updated = pyqtSignal(object)
def __init__(self, node_name: str = "hand_control_node"):
super().__init__()
self.node = None
self.publisher = None
self.publisher_arc = None
self.state_subscription = None
self.speed_pub = None
self.torque_pub = None
self.joint_state = JointState()
self.joint_state.header = Header()
@@ -83,27 +115,40 @@ class ROS2NodeManager(QObject):
self.node.get_parameter('topic_prefix').value
)
if self.is_arc == True:
if self.hand_joint == "O12":
topic_base = f'/o12/{self.hand_type}'
self.publisher = self.node.create_publisher(
JointState, f'{topic_base}/joint_cmd', 10
)
self.state_subscription = self.node.create_subscription(
JointState,
f'{topic_base}/joint_states',
self._o12_feedback_callback,
10,
)
elif self.is_arc == True:
# 创建发布者
self.publisher_arc = self.node.create_publisher(
JointState,
self.topic(f'/cb_{self.hand_type}_hand_control_cmd_arc'),
10
)
# 创建发布者
self.publisher = self.node.create_publisher(
JointState,
self.topic(f'/cb_{self.hand_type}_hand_control_cmd'),
10
)
# 创建旧型号发布者
if self.hand_joint != "O12":
self.publisher = self.node.create_publisher(
JointState,
self.topic(f'/cb_{self.hand_type}_hand_control_cmd'),
10
)
self.snapshot_publisher = self.node.create_publisher(
JointState, self.topic('/calibration_pose_snapshot'), 10
)
# 新增 speed / torque 发布者
self.speed_pub = self.node.create_publisher(
String, self.topic('/cb_hand_setting_cmd'), 10)
self.torque_pub = self.node.create_publisher(
String, self.topic('/cb_hand_setting_cmd'), 10)
if self.hand_joint != "O12":
self.speed_pub = self.node.create_publisher(
String, self.topic('/cb_hand_setting_cmd'), 10)
self.torque_pub = self.node.create_publisher(
String, self.topic('/cb_hand_setting_cmd'), 10)
self.status_updated.emit(
"info",
f"ROS2节点初始化成功: {self.topic_prefix or '/'} "
@@ -136,15 +181,36 @@ class ROS2NodeManager(QObject):
while rclpy.ok() and self.node:
rclpy.spin_once(self.node, timeout_sec=0.1)
def _o12_feedback_callback(self, msg: JointState):
"""Forward 12-channel O12 radian feedback safely into the Qt thread."""
if len(msg.position) != 12:
self.status_updated.emit(
"error", f"O12位置反馈长度错误: {len(msg.position)},应为12"
)
return
self.feedback_updated.emit([float(value) for value in msg.position])
def command_bounds(self, count: int):
if self.hand_joint == "O12":
bounds = _O12_COMMAND_BOUNDS_MRAD.get(self.hand_type)
else:
bounds = _CANONICAL_COMMAND_BOUNDS.get(self.hand_joint)
if not bounds or len(bounds) != count:
return [(0, 255)] * count
return bounds
def bound_positions(self, positions: List[int]) -> List[int]:
bounds = _CANONICAL_COMMAND_BOUNDS.get(self.hand_joint)
if not bounds or len(bounds) != len(positions):
return list(positions)
bounds = self.command_bounds(len(positions))
return [
max(minimum, min(maximum, int(value)))
for value, (minimum, maximum) in zip(positions, bounds)
]
def wire_positions(self, positions: List[int]) -> List[float]:
positions = self.bound_positions(positions)
scale = _HAND_CONFIGS[self.hand_joint].position_scale
return [float(value) / float(scale) for value in positions]
def publish_joint_state(self, positions: List[int]):
"""发布关节状态消息"""
if not self.publisher or not self.node:
@@ -153,15 +219,21 @@ class ROS2NodeManager(QObject):
try:
positions = self.bound_positions(positions)
wire_positions = self.wire_positions(positions)
self.joint_state.header.stamp = self.node.get_clock().now().to_msg()
self.joint_state.position = [float(pos) for pos in positions]
self.joint_state.position = wire_positions
# self.joint_state.velocity = [0.1] * len(positions)
# self.joint_state.effort = [0.01] * len(positions)
# 如果有关节名称,添加到消息中
#hand_config = HandConfig.from_hand_type(self.hand_joint)
hand_config = _HAND_CONFIGS[self.hand_joint]
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
if canonical_names and len(canonical_names) == len(positions):
# O12 SDK 1.1.8 may expose names in physical motor order while the
# position array uses active-angle order. An empty name array makes
# the documented position order unambiguous to the driver.
if self.hand_joint == "O12":
self.joint_state.name = []
elif canonical_names and len(canonical_names) == len(positions):
self.joint_state.name = canonical_names
elif len(hand_config.joint_names) == len(positions):
if hand_config.joint_names_en != None:
@@ -170,7 +242,7 @@ class ROS2NodeManager(QObject):
self.joint_state.name = hand_config.joint_names
self.publisher.publish(self.joint_state)
if self.is_arc == True:
if self.is_arc == True and self.hand_joint != "O12":
if self.hand_joint == "O6":
if self.hand_type == "left":
pose = range_to_arc_left(positions,self.hand_joint)
@@ -205,14 +277,21 @@ class ROS2NodeManager(QObject):
positions = self.bound_positions(positions)
self.publish_joint_state(positions)
self.joint_state.header.stamp = self.node.get_clock().now().to_msg()
self.joint_state.position = [float(pos) for pos in positions]
self.joint_state.position = self.wire_positions(positions)
canonical_names = _CANONICAL_COMMAND_NAMES.get(self.hand_joint)
if canonical_names:
if canonical_names and self.hand_joint != "O12":
self.joint_state.name = canonical_names
elif self.hand_joint == "O12":
self.joint_state.name = []
self.snapshot_publisher.publish(self.joint_state)
self.status_updated.emit("info", "当前标定姿势快照已发布")
def publish_speed(self, val: int):
if self.hand_joint == "O12":
self.status_updated.emit(
"warning", "O12不使用旧版u8速度接口;请通过角度轨迹限制速度"
)
return
if self.hand_joint.upper() in ("O6", "L6"):
joint_len = 6
elif self.hand_joint == "L7":
@@ -232,6 +311,11 @@ class ROS2NodeManager(QObject):
self.speed_pub.publish(msg)
def publish_torque(self, val: int):
if self.hand_joint == "O12":
self.status_updated.emit(
"warning", "O12不使用旧版u8扭矩接口;位置GUI已禁用该设置"
)
return
if self.hand_joint.upper() in ("O6", "L6"):
joint_len = 6
elif self.hand_joint == "L7":
@@ -273,11 +357,17 @@ class HandControlGUI(QWidget):
# 设置ROS管理器
self.ros_manager = ros_manager
self.ros_manager.status_updated.connect(self.update_status)
self.ros_manager.feedback_updated.connect(self.on_feedback_updated)
# 获取手部配置
self.hand_joint = self.ros_manager.hand_joint
self.hand_type = self.ros_manager.hand_type
self.hand_config = _HAND_CONFIGS[self.hand_joint]
self.preset_actions = self.hand_config.get_preset_actions(self.hand_type)
self.feedback_positions = None
# O12 only publishes after an explicit slider/preset action. This
# prevents opening or closing the real hand merely by launching GUI.
self.command_dirty = self.hand_joint != "O12"
# 初始化UI
self.init_ui()
@@ -288,6 +378,20 @@ class HandControlGUI(QWidget):
self.publish_timer.timeout.connect(self.publish_joint_state)
self.publish_timer.start()
def format_position(self, value: int) -> str:
"""Format an integer GUI value in the hand's actual command unit."""
scale = self.hand_config.position_scale
if self.hand_config.position_unit == "rad":
radians = float(value) / float(scale)
return f"{radians:.3f} rad / {math.degrees(radians):.1f}°"
return str(value)
def command_positions(self) -> List[float]:
"""Return current slider values converted to wire units."""
return self.ros_manager.wire_positions(
[slider.value() for slider in self.sliders]
)
def init_ui(self):
"""初始化用户界面"""
# 设置窗口属性
@@ -465,13 +569,11 @@ class HandControlGUI(QWidget):
for i, (name, value) in enumerate(zip(
self.hand_config.joint_names, self.hand_config.init_pos
)):
bounds = _CANONICAL_COMMAND_BOUNDS.get(self.hand_joint)
minimum, maximum = (
bounds[i] if bounds and i < len(bounds) else (0, 255)
)
bounds = self.ros_manager.command_bounds(len(self.hand_config.init_pos))
minimum, maximum = bounds[i]
value = max(minimum, min(maximum, int(value)))
# 创建标签
label = QLabel(f"{name}: {value}")
label = QLabel(f"{name}: {self.format_position(value)}")
label.setMinimumWidth(120)
# 创建滑动条
@@ -535,9 +637,9 @@ class HandControlGUI(QWidget):
def create_system_preset_buttons(self, parent_layout):
"""创建系统预设动作按钮"""
self.preset_buttons = [] # 清空按钮列表
if self.hand_config.preset_actions:
if self.preset_actions:
buttons = []
for idx, (name, positions) in enumerate(self.hand_config.preset_actions.items()):
for idx, (name, positions) in enumerate(self.preset_actions.items()):
button = QPushButton(name)
button.setProperty("category", "preset")
button.clicked.connect(
@@ -564,6 +666,9 @@ class HandControlGUI(QWidget):
# —— 2. 新增:速度与扭矩设置(每行一个)——
quick_set_gb = QGroupBox("快速设置")
if self.hand_joint == "O12":
quick_set_gb.setEnabled(False)
quick_set_gb.setToolTip("O12不使用旧版0-255速度/扭矩设置接口")
qv_layout = QVBoxLayout(quick_set_gb)
# 速度行
@@ -692,7 +797,10 @@ class HandControlGUI(QWidget):
"""滑动条值改变事件处理"""
if 0 <= index < len(self.slider_labels):
joint_name = self.hand_config.joint_names[index]
self.slider_labels[index].setText(f"{joint_name}: {value}")
self.slider_labels[index].setText(
f"{joint_name}: {self.format_position(value)}"
)
self.command_dirty = True
# 更新数值显示
self.update_value_display()
@@ -700,10 +808,23 @@ class HandControlGUI(QWidget):
def update_value_display(self):
"""更新数值显示面板内容"""
# 获取所有滑动条的当前值
values = [slider.value() for slider in self.sliders]
# 格式化显示为列表形式
self.value_display.setText(f"{values}")
command = self.command_positions()
command_text = ", ".join(f"{value:.3f}" for value in command)
lines = [f"目标({self.hand_config.position_unit}): [{command_text}]"]
if self.feedback_positions is not None:
feedback_text = ", ".join(
f"{value:.3f}" for value in self.feedback_positions
)
lines.append(f"反馈(rad): [{feedback_text}]")
self.value_display.setText("\n".join(lines))
def on_feedback_updated(self, positions):
"""Display actual O12 joint feedback without moving command sliders."""
self.feedback_positions = list(positions)
self.update_value_display()
if hasattr(self, "connection_status"):
self.connection_status.setText("O12右手已连接并收到位置反馈")
self.connection_status.setObjectName("StatusInfo")
def on_preset_action_clicked(self, positions: List[int]):
"""预设动作按钮点击事件处理"""
@@ -742,7 +863,25 @@ class HandControlGUI(QWidget):
self.cycle_button.setText("循环运行预设动作")
self.reset_preset_buttons_color()
self.status_updated.emit("warning", "已停止所有动作")
if self.hand_joint == "O12" and self.feedback_positions is not None:
# Hold the latest measured pose instead of merely stopping topic
# publication while a previous position target is still active.
scale = self.hand_config.position_scale
hold_values = [
int(round(value * scale)) for value in self.feedback_positions
]
hold_values = self.ros_manager.bound_positions(hold_values)
for slider, value in zip(self.sliders, hold_values):
slider.blockSignals(True)
slider.setValue(value)
slider.blockSignals(False)
self.command_dirty = True
self.publish_joint_state()
self.update_value_display()
self.status_updated.emit("warning", "已下发当前反馈位置并保持")
else:
self.command_dirty = False
self.status_updated.emit("warning", "已停止连续动作和新的位置发布")
def on_save_pose_clicked(self):
"""发布当前滑块姿势,供hand_pose_capture写入profile。"""
@@ -751,7 +890,7 @@ class HandControlGUI(QWidget):
def on_cycle_clicked(self):
"""循环运行预设动作按钮点击事件处理"""
if not self.hand_config.preset_actions:
if not self.preset_actions:
QMessageBox.warning(self, "无预设动作", "当前手部型号没有预设动作可循环运行")
return
@@ -774,19 +913,19 @@ class HandControlGUI(QWidget):
def run_next_action(self):
"""运行下一个预设动作"""
if not self.hand_config.preset_actions:
if not self.preset_actions:
return
# 重置所有按钮颜色
self.reset_preset_buttons_color()
# 计算下一个动作索引
self.current_action_index = (self.current_action_index + 1) % len(self.hand_config.preset_actions)
self.current_action_index = (self.current_action_index + 1) % len(self.preset_actions)
# 获取下一个动作
action_names = list(self.hand_config.preset_actions.keys())
action_names = list(self.preset_actions.keys())
action_name = action_names[self.current_action_index]
action_positions = self.hand_config.preset_actions[action_name]
action_positions = self.preset_actions[action_name]
# 执行动作
self.on_preset_action_clicked(action_positions)
@@ -811,6 +950,7 @@ class HandControlGUI(QWidget):
"""关节类型改变事件处理"""
self.hand_joint = joint_type
self.hand_config = _HAND_CONFIGS[self.hand_joint]
self.preset_actions = self.hand_config.get_preset_actions(self.hand_type)
# 更新手部信息
info_text = f"""手部类型: {self.hand_type}
@@ -829,8 +969,11 @@ class HandControlGUI(QWidget):
def publish_joint_state(self):
"""发布当前关节状态"""
if self.hand_joint == "O12" and not self.command_dirty:
return
positions = [slider.value() for slider in self.sliders]
self.ros_manager.publish_joint_state(positions)
self.command_dirty = False
def update_status(self, status_type: str, message: str):
"""更新状态显示"""
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Start the HCAN-connected O12 right hand and its safe position GUI."""
from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription, TimerAction
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import PathJoinSubstitution
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare
def generate_launch_description():
o12_driver = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
PathJoinSubstitution([
FindPackageShare("omnihand_node"),
"launch",
"omnihand_pro_2025_node.launch.py",
])
)
)
o12_gui = Node(
package="gui_control",
executable="gui_control",
name="o12_right_gui_control",
output="screen",
parameters=[{
"hand_type": "right",
"hand_joint": "O12",
# Commands are coalesced, so dragging a slider sends at most 10 Hz.
"topic_hz": 10,
"is_arc": False,
"topic_prefix": "",
}],
)
return LaunchDescription([
o12_driver,
# Give the CANFD driver a moment to claim and initialise the adapter.
TimerAction(period=1.5, actions=[o12_gui]),
])
+1
View File
@@ -13,6 +13,7 @@
<test_depend>python3-pytest</test_depend>
<exec_depend>linker_hand_ros2_sdk</exec_depend>
<exec_depend>omnihand_node</exec_depend>
<export>
<build_type>ament_python</build_type>
+23
View File
@@ -0,0 +1,23 @@
from gui_control.config.constants import HAND_CONFIGS
def test_l6_gui_uses_the_sdk_channel_order() -> None:
assert HAND_CONFIGS["L6"].joint_names_en == [
"thumb_cmc_pitch",
"thumb_cmc_roll",
"index_mcp_pitch",
"middle_mcp_pitch",
"ring_mcp_pitch",
"pinky_mcp_pitch",
]
def test_l6_ok_preset_is_hand_specific() -> None:
config = HAND_CONFIGS["L6"]
assert config.get_preset_actions("left")["OK"] == [
62, 5, 151, 255, 255, 255,
]
assert config.get_preset_actions("right")["OK"] == [
58, 6, 153, 255, 255, 255,
]
+46
View File
@@ -0,0 +1,46 @@
from gui_control.config.constants import HAND_CONFIGS
def test_o12_gui_uses_active_angle_order_and_milliradians() -> None:
config = HAND_CONFIGS["O12"]
assert config.joint_names_en == [
"thumb_roll", "thumb_abad", "thumb_mcp", "thumb_pip",
"index_abad", "index_mcp", "index_pip",
"middle_abad", "middle_mcp", "middle_pip",
"ring_mcp", "pinky_mcp",
]
assert config.position_scale == 1000
assert config.position_unit == "rad"
assert len(config.init_pos) == 12
assert all(len(pose) == 12 for pose in config.preset_actions.values())
def test_o12_right_fingertip_preset_actions_use_requested_radians() -> None:
config = HAND_CONFIGS["O12"]
actions = config.get_preset_actions("right")
assert actions["拇指对食指"] == [
245, -766, -818, 0, 0, 1046, 0, 0, 0, 0, 0, 0,
]
assert actions["拇指对中指"] == [
489, -843, -685, 0, 0, 0, 0, 0, 1147, 0, 0, 0,
]
assert actions["拇指对无名指"] == [
699, -779, -827, 0, 0, 0, 0, 0, 0, 0, 690, 0,
]
assert actions["拇指对小指"] == [
786, -1065, -827, 0, 0, 0, 0, 0, 0, 0, 0, 645,
]
assert all(len(actions[name]) == 12 for name in (
"拇指对食指", "拇指对中指", "拇指对无名指", "拇指对小指",
))
def test_o12_fingertip_preset_actions_are_right_hand_only() -> None:
actions = HAND_CONFIGS["O12"].get_preset_actions("left")
assert "拇指对食指" not in actions
assert "拇指对中指" not in actions
assert "拇指对无名指" not in actions
assert "拇指对小指" not in actions
+18
View File
@@ -0,0 +1,18 @@
from gui_control.config.constants import HAND_CONFIGS
def test_o6_ok_preset_is_hand_specific() -> None:
config = HAND_CONFIGS["O6"]
assert config.get_preset_actions("left")["OK"] == [
95, 75, 116, 255, 255, 255,
]
assert config.get_preset_actions("right")["OK"] == [
95, 83, 122, 255, 255, 255,
]
assert config.get_preset_actions("left")["拇指对中指"] == [
88, 2, 255, 114, 255, 255,
]
assert config.get_preset_actions("right")["拇指对中指"] == [
95, 8, 255, 114, 255, 255,
]
@@ -7,6 +7,7 @@ from enum import Enum
from utils.open_can import OpenCan
from can.exceptions import CanError
from utils.color_msg import ColorMsg
from utils.feedback import ReceivedPositionFrames
current_dir = os.path.dirname(os.path.abspath(__file__))
target_dir = os.path.abspath(os.path.join(current_dir, ".."))
sys.path.append(target_dir)
@@ -178,6 +179,10 @@ class LinkerHandG20Can:
# 串联控制数据存储
self.x41, self.x42, self.x43, self.x44, self.x45 = [], [], [], [], []
frame_layout = [[(frame, index) for index in range(6)] for frame in range(0x41, 0x46)]
layout = self.joint_state_to_cmd_state(frame_layout)
self.position_feedback = ReceivedPositionFrames(
{frame: 6 for frame in range(0x41, 0x46)}, [item if item != 0 else None for item in layout])
self.x49, self.x4A, self.x4B, self.x4C, self.x4D = [0] * 6, [0] * 6, [0] * 6, [0] * 6, [0] * 6
self.x51, self.x52, self.x53, self.x54, self.x55 = [], [], [], [], []
self.x59, self.x5A, self.x5B, self.x5C, self.x5D = [], [], [], [], []
@@ -354,6 +359,8 @@ class LinkerHandG20Can:
response_data = msg.data[1:]
if len(list(response_data)) == 0:
return
if frame_type in range(0x41, 0x46):
self.position_feedback.accept(frame_type, response_data, msg.timestamp)
# 并联控制指令响应
if frame_type == 0x01: self.x01 = list(response_data)
elif frame_type == 0x02: self.x02 = list(response_data)
@@ -1040,6 +1047,9 @@ class LinkerHandG20Can:
return None
return self.joint_state_to_cmd_state(state=state)
def get_feedback_snapshot(self):
return self.position_feedback.snapshot()
def get_current_pub_status(self):
"""API接口:获取手指当前状态"""
self.get_current_status()
@@ -1,9 +1,12 @@
from collections import deque
import can
import time, sys
import threading
import numpy as np
from utils.open_can import OpenCan
from utils.color_msg import ColorMsg
from utils.feedback import ReceivedPositionFrames
from can.exceptions import CanError
@@ -15,6 +18,7 @@ class LinkerHandL6Can:
self.open_can = OpenCan(load_yaml=yaml)
self.x01 = [0] * 6 # 关节位置
self.position_feedback = ReceivedPositionFrames({0x01: 6}, [(0x01, i) for i in range(6)])
self.x02 = [-1] * 6 # 转矩限制
self.x05 = [0] * 6 # 速度
self.x07 = [-1] * 6 # 加速度
@@ -57,6 +61,14 @@ class LinkerHandL6Can:
self.normal_force, self.tangential_force, self.tangential_force_dir, self.approach_inc = [[-1] * 6 for _ in range(4)]
self.is_lock = False
self.version = None
# L6 replies to a six-byte 0x01 position command with an immediate
# byte-for-byte echo on the same CAN ID. A zero-payload 0x01 state
# query also replies on that ID, but with the measured positions.
# Keep the two transactions distinct so command echoes never enter
# the published feedback stream used by calibration.
self._position_echo_lock = threading.Lock()
self._pending_position_echoes = deque(maxlen=32)
self._position_echo_timeout_seconds = 0.02
# Start the receiving thread
self.running = True
self.receive_thread = threading.Thread(target=self.receive_response)
@@ -111,6 +123,11 @@ class LinkerHandL6Can:
frame_property_value = int(frame_property.value) if hasattr(frame_property, 'value') else frame_property
data = [frame_property_value] + [int(val) for val in data_list]
msg = can.Message(arbitration_id=self.can_id, data=data, is_extended_id=False)
if frame_property_value == 0x01 and len(data_list) == 6:
with self._position_echo_lock:
self._pending_position_echoes.append(
(time.monotonic(), tuple(int(value) for value in data_list))
)
try:
self.bus.send(msg)
except can.CanError as e:
@@ -201,7 +218,24 @@ class LinkerHandL6Can:
except:
return
if frame_type == 0x01: # 0x01
self.x01 = list(response_data)
response = tuple(int(value) for value in response_data)
now = time.monotonic()
is_position_echo = False
with self._position_echo_lock:
while (
self._pending_position_echoes
and now - self._pending_position_echoes[0][0]
> self._position_echo_timeout_seconds
):
self._pending_position_echoes.popleft()
for pending in tuple(self._pending_position_echoes):
if pending[1] == response:
self._pending_position_echoes.remove(pending)
is_position_echo = True
break
if not is_position_echo:
self.x01 = list(response)
self.position_feedback.accept(frame_type, response, msg.timestamp)
elif frame_type == 0x02: # 0x02
self.x02 = list(response_data)
elif frame_type == 0x05: # Set speed
@@ -295,6 +329,9 @@ class LinkerHandL6Can:
def get_current_pub_status(self):
return self.x01
def get_feedback_snapshot(self):
return self.position_feedback.snapshot()
def get_speed(self):
#self.send_frame(0x05, [],sleep=0.003)
#print("L6暂不支持读取实时速度")
@@ -391,7 +428,10 @@ class LinkerHandL6Can:
return self.x35
def get_finger_order(self):
return ["thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
# L6 channel 1 is the physical CMC roll actuator. Older SDK releases
# exposed the channel as ``thumb_cmc_yaw`` even though the wire order
# and mechanism have always been roll.
return ["thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
def show_fun_table(self):
pass
@@ -4,6 +4,7 @@ import threading
import numpy as np
from utils.open_can import OpenCan
from utils.color_msg import ColorMsg
from utils.feedback import ReceivedPositionFrames
from can.exceptions import CanError
@@ -15,6 +16,7 @@ class LinkerHandO6Can:
self.open_can = OpenCan(load_yaml=yaml)
self.x01 = [0] * 6 # 关节位置
self.position_feedback = ReceivedPositionFrames({0x01: 6}, [(0x01, i) for i in range(6)])
self.x02 = [-1] * 6 # 转矩限制
self.x05 = [0] * 6 # 速度
self.x07 = [-1] * 6 # 加速度
@@ -224,6 +226,7 @@ class LinkerHandO6Can:
return
if frame_type == 0x01: # 0x01
self.x01 = list(response_data)
self.position_feedback.accept(frame_type, response_data, msg.timestamp)
elif frame_type == 0x02: # 0x02
self.x02 = list(response_data)
elif frame_type == 0x05: # Set speed
@@ -317,6 +320,9 @@ class LinkerHandO6Can:
def get_current_pub_status(self):
return self.x01
def get_feedback_snapshot(self):
return self.position_feedback.snapshot()
def get_speed(self):
self.send_frame(0x05, [],sleep=0.002)
#print("L6暂不支持读取实时速度")
@@ -372,7 +372,7 @@ class LinkerHandL6RS485:
return [0] * 6
def get_finger_order(self):
return ["thumb_cmc_pitch", "thumb_cmc_yaw", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
return ["thumb_cmc_pitch", "thumb_cmc_roll", "index_mcp_pitch", "middle_mcp_pitch", "ring_mcp_pitch", "pinky_mcp_pitch"]
# --------------------------------------------------
# 便捷方法
@@ -207,6 +207,14 @@ class LinkerHandApi:
getter = getattr(self.hand, "get_cached_current_status", None)
return getter() if getter is not None else None
@property
def supports_feedback_timestamps(self):
return callable(getattr(self.hand, "get_feedback_snapshot", None))
def get_feedback_snapshot(self):
getter = getattr(self.hand, "get_feedback_snapshot", None)
return getter() if getter is not None else None
def get_state_for_pub(self):
return self.hand.get_current_pub_status()
@@ -0,0 +1,65 @@
"""Atomic CAN position snapshots with the original receive timestamps."""
from dataclasses import dataclass
import math
from threading import Lock
@dataclass(frozen=True)
class FeedbackSnapshot:
positions: tuple
channel_stamps_ns: tuple[int, ...]
@property
def stamp_ns(self):
return max(self.channel_stamps_ns)
def as_dict(self, names):
return {"schema": "can_feedback_v1", "name": list(names),
"position": list(self.positions), "channel_stamps_ns": list(self.channel_stamps_ns),
"stamp_ns": self.stamp_ns}
class ReceivedPositionFrames:
"""Keep receipt identity even if stationary feedback has identical values.
``layout`` lists the frame and value index for each public SDK channel.
None represents a protocol-reserved constant-zero slot, whose freshness
is bounded by the oldest actual frame. No polling/publication clock is
allowed to update a snapshot's timestamps.
"""
def __init__(self, sizes, layout):
self.sizes, self.layout = dict(sizes), tuple(layout)
self._frames = {}
self._lock = Lock()
def accept(self, frame, values, timestamp):
if frame not in self.sizes or len(values) != self.sizes[frame]:
return False
if not isinstance(timestamp, (int, float)) or not math.isfinite(timestamp) or timestamp <= 0:
return False
stamp = int(round(timestamp * 1_000_000_000))
with self._lock:
previous = self._frames.get(frame)
if previous is not None and stamp <= previous[0]:
return False
self._frames[frame] = (stamp, tuple(values))
return True
def snapshot(self):
with self._lock:
if self._frames.keys() != self.sizes.keys():
return None
oldest = min(value[0] for value in self._frames.values())
positions, stamps = [], []
for item in self.layout:
if item is None:
positions.append(0)
stamps.append(oldest)
else:
frame, index = item
stamp, values = self._frames[frame]
positions.append(values[index])
stamps.append(stamp)
return FeedbackSnapshot(tuple(positions), tuple(stamps))
@@ -168,6 +168,8 @@ class LinkerHand(Node):
self.last_position_command_time = None
self.last_hand_state = [-1] * 10
self.last_hand_state_stamp_ns = None
self._last_timed_feedback_stamps = None
self.last_hand_vel = [-1] * 10
self.force = [[-1] * 5] * 4
self.matrix_dic = {
@@ -245,6 +247,7 @@ class LinkerHand(Node):
COMMAND_QOS,
)
self.hand_state_pub = self.create_publisher(JointState, self.topic(f'/cb_{self.hand_type}_hand_state'),10)
self.timed_state_pub = self.create_publisher(String, self.topic(f'/cb_{self.hand_type}_hand_state_timed'), 30)
self.hand_info_pub = self.create_publisher(String, self.topic(f'/cb_{self.hand_type}_hand_info'), 10)
if self.is_touch == True:
if self.modbus != "None":
@@ -391,7 +394,7 @@ class LinkerHand(Node):
self.last_hand_vel_cmd = None
def _poll_state_if_due(self):
if self.hand_state_pub.get_subscription_count() < 1:
if self.hand_state_pub.get_subscription_count() < 1 and self.timed_state_pub.get_subscription_count() < 1:
return
now = time.monotonic()
if state_reads_deferred(
@@ -400,8 +403,7 @@ class LinkerHand(Node):
self.command_quiet_period,
self.defer_state_reads_while_commanding,
):
# pub_state continues to publish the last completed state as a
# heartbeat. A fresh blocking read is made after motion settles.
# A repeated heartbeat retains its original measurement time.
return
if not state_poll_due(
self.last_state_poll_time, now, self.state_poll_period
@@ -411,6 +413,7 @@ class LinkerHand(Node):
# another read on the following timer callback.
self.last_state_poll_time = now
self.last_hand_state = self.api.get_state()
self.last_hand_state_stamp_ns = self.get_clock().now().nanoseconds
time.sleep(0.003)
if state_poll_due(
self.last_velocity_poll_time, now, self.velocity_poll_period
@@ -478,9 +481,7 @@ class LinkerHand(Node):
def pub_state(self):
while True:
if self.hand_state_pub.get_subscription_count() > 0:
msg = self.joint_state_msg(self.last_hand_state, self.last_hand_vel)
self.hand_state_pub.publish(msg)
self._publish_feedback()
if self.is_touch == True and self.touch_type == 1 and self.modbus == "None" and self.touch_pub.get_subscription_count() > 0:
msg = Float32MultiArray()
msg.data = [float(val) for sublist in self.force for val in sublist]
@@ -498,6 +499,26 @@ class LinkerHand(Node):
self.hand_info_pub.publish(msg)
time.sleep(self.hz)
def _publish_feedback(self):
"""Publish one atomic measured state; never give cached values a new time."""
if self.api.supports_feedback_timestamps:
snapshot = self.api.get_feedback_snapshot()
if snapshot is None:
return
positions, stamp = snapshot.positions, snapshot.stamp_ns
if (snapshot.channel_stamps_ns != self._last_timed_feedback_stamps
and self.timed_state_pub.get_subscription_count() > 0):
message = String()
message.data = json.dumps(snapshot.as_dict(self.api.get_finger_order()))
self.timed_state_pub.publish(message)
self._last_timed_feedback_stamps = snapshot.channel_stamps_ns
else:
positions, stamp = self.last_hand_state, self.last_hand_state_stamp_ns
if stamp is None:
return
if self.hand_state_pub.get_subscription_count() > 0:
self.hand_state_pub.publish(self.joint_state_msg(positions, self.last_hand_vel, stamp_ns=stamp))
def pub_matrix_mass(self, dic):
"""发布矩阵数据合值 单位g 克 JSON格式"""
msg = String()
@@ -556,10 +577,12 @@ class LinkerHand(Node):
msg.data = json.dumps(self.matrix_dic)
self.matrix_touch_pub.publish(msg)
def joint_state_msg(self, pose,vel=[]):
def joint_state_msg(self, pose, vel=(), *, stamp_ns=None):
joint_state = JointState()
joint_state.header = Header()
joint_state.header.stamp = self.get_clock().now().to_msg()
if stamp_ns is None:
stamp_ns = self.get_clock().now().nanoseconds
joint_state.header.stamp.sec, joint_state.header.stamp.nanosec = divmod(int(stamp_ns), 1_000_000_000)
joint_state.name = self.api.get_finger_order()
joint_state.position = [float(x) for x in pose]
if len(vel) > 1:
@@ -0,0 +1,73 @@
"""A CAN receipt, rather than a heartbeat, defines position sample identity."""
import json
from types import SimpleNamespace
from linker_hand_ros2_sdk.LinkerHand.utils.feedback import ReceivedPositionFrames
from linker_hand_ros2_sdk.linker_hand import LinkerHand
def test_cached_unchanged_and_out_of_order_frames_keep_measurement_identity():
frames = ReceivedPositionFrames({1: 2}, [(1, 0), (1, 1)])
assert frames.snapshot() is None
assert not frames.accept(1, [4], 1.)
assert not frames.accept(1, [4, 5], 0.)
assert frames.accept(1, [4, 5], 1.)
first = frames.snapshot()
assert first == frames.snapshot()
assert not frames.accept(1, [8, 9], .9)
assert not frames.accept(1, [8, 9], 1.)
assert frames.snapshot() == first
assert frames.accept(1, [4, 5], 2.) # A new stationary observation is real.
assert frames.snapshot().positions == first.positions
assert frames.snapshot().stamp_ns == 2_000_000_000
assert first.stamp_ns == 1_000_000_000
def test_split_finger_snapshot_does_not_retimestamp_held_channels():
frames = ReceivedPositionFrames({1: 2, 2: 2}, [(2, 1), (1, 0), None])
frames.accept(1, [10, 11], 1.)
assert frames.snapshot() is None
frames.accept(2, [20, 21], 1.02)
first = frames.snapshot()
assert first.positions == (21, 10, 0)
assert first.channel_stamps_ns == (1_020_000_000, 1_000_000_000, 1_000_000_000)
frames.accept(2, [20, 22], 1.04)
assert frames.snapshot().channel_stamps_ns == (1_040_000_000, 1_000_000_000, 1_000_000_000)
def test_sdk_republication_cannot_refresh_cached_state_timestamps():
frames = ReceivedPositionFrames({1: 2}, [(1, 0), (1, 1)])
joint_messages, timed_messages = [], []
publisher = lambda messages: SimpleNamespace(get_subscription_count=lambda: 1, publish=messages.append)
host = SimpleNamespace(api=SimpleNamespace(supports_feedback_timestamps=True,
get_feedback_snapshot=frames.snapshot, get_finger_order=lambda: ['a', 'b']),
_last_timed_feedback_stamps=None, last_hand_vel=[], hand_state_pub=publisher(joint_messages),
timed_state_pub=publisher(timed_messages))
host.joint_state_msg = lambda *args, **kwargs: LinkerHand.joint_state_msg(host, *args, **kwargs)
LinkerHand._publish_feedback(host)
assert not joint_messages and not timed_messages
frames.accept(1, [10, 20], 100.)
LinkerHand._publish_feedback(host)
LinkerHand._publish_feedback(host)
assert len(joint_messages) == 2 and len(timed_messages) == 1
assert all(message.header.stamp.sec == 100 for message in joint_messages)
assert json.loads(timed_messages[0].data)['channel_stamps_ns'] == [100_000_000_000]*2
frames.accept(1, [10, 20], 101.)
LinkerHand._publish_feedback(host)
assert len(timed_messages) == 2 and joint_messages[-1].header.stamp.sec == 101
def test_g20_timed_layout_matches_the_public_sdk_order():
from linker_hand_ros2_sdk.LinkerHand.core.can.linker_hand_g20_can import LinkerHandG20Can
hand = LinkerHandG20Can.__new__(LinkerHandG20Can)
layout = hand.joint_state_to_cmd_state([[(frame, index) for index in range(6)] for frame in range(0x41, 0x46)])
frames = ReceivedPositionFrames({frame: 6 for frame in range(0x41, 0x46)}, [item or None for item in layout])
raw = []
for finger, frame in enumerate(range(0x41, 0x46)):
raw.append([finger*10+i for i in range(6)])
frames.accept(frame, raw[-1], 100.+finger*.01)
snapshot = frames.snapshot()
assert snapshot.positions == tuple(hand.joint_state_to_cmd_state(raw))
assert snapshot.channel_stamps_ns[0] == 100_000_000_000
assert snapshot.channel_stamps_ns[4] == 100_040_000_000
@@ -0,0 +1,75 @@
import ast
from collections import deque
from pathlib import Path
import sys
import threading
import time
from types import SimpleNamespace
PACKAGE = Path(__file__).resolve().parents[1] / "linker_hand_ros2_sdk/LinkerHand/core"
EXPECTED = [
"thumb_cmc_pitch",
"thumb_cmc_roll",
"index_mcp_pitch",
"middle_mcp_pitch",
"ring_mcp_pitch",
"pinky_mcp_pitch",
]
def _finger_order(path: Path, class_name: str) -> list[str]:
module = ast.parse(path.read_text(encoding="utf-8"))
selected = next(
item
for item in module.body
if isinstance(item, ast.ClassDef) and item.name == class_name
)
method = next(
item
for item in selected.body
if isinstance(item, ast.FunctionDef) and item.name == "get_finger_order"
)
returned = next(item for item in method.body if isinstance(item, ast.Return))
return ast.literal_eval(returned.value)
def test_l6_can_and_rs485_publish_the_same_physical_channel_order() -> None:
assert _finger_order(PACKAGE / "can/linker_hand_l6_can.py", "LinkerHandL6Can") == EXPECTED
assert _finger_order(
PACKAGE / "rs485/linker_hand_l6_rs485.py", "LinkerHandL6RS485"
) == EXPECTED
def test_l6_can_position_echo_does_not_replace_measured_feedback() -> None:
linker_hand_root = PACKAGE.parent
sys.path.insert(0, str(linker_hand_root))
try:
from core.can.linker_hand_l6_can import LinkerHandL6Can
from utils.feedback import ReceivedPositionFrames
finally:
sys.path.remove(str(linker_hand_root))
hand = LinkerHandL6Can.__new__(LinkerHandL6Can)
hand.can_id = 0x27
hand.x01 = [10, 20, 30, 40, 50, 60]
hand.position_feedback = ReceivedPositionFrames({0x01: 6}, [(0x01, i) for i in range(6)])
hand._position_echo_lock = threading.Lock()
command = (255, 2, 253, 253, 253, 253)
hand._pending_position_echoes = deque(
[(time.monotonic(), command)], maxlen=32
)
hand._position_echo_timeout_seconds = 0.02
hand.process_response(
SimpleNamespace(arbitration_id=0x27, data=bytes((0x01, *command)), timestamp=100.)
)
assert hand.x01 == [10, 20, 30, 40, 50, 60]
assert not hand._pending_position_echoes
measured = (250, 3, 252, 252, 252, 252)
hand.process_response(
SimpleNamespace(arbitration_id=0x27, data=bytes((0x01, *measured)), timestamp=101.)
)
assert hand.x01 == list(measured)
assert hand.get_feedback_snapshot().channel_stamps_ns == (101_000_000_000,) * 6
Submodule src/linkerhand-l30-sdk added at 0103fc55f9
+8
View File
@@ -0,0 +1,8 @@
# 标定包协作约定
- 全程用中文;保留工作区已有修改。
- 测试选择遵循 [TESTING.md](TESTING.md)。日常运行受影响用例及必要契约,常规验证目标 60 秒内。
- 不默认运行全量 pytest、colcon test 或所有型号整手拟合。公共契约变更与发布节点再扩大范围。
- 同一代码版本已通过的测试不无理由重复执行;修复失败后只重跑失败或受修改影响的用例,并记录耗时。
- 优先复用不可变输入及已生成产物,在验收边界测试拒绝条件;不为每种错误重复生成整手标定。
- 软件回放通过与实机连续通过分开报告;不能为缩短测试或减少暂停放宽精度、可观测性和发布门限。
@@ -0,0 +1,71 @@
# O30 右手标定稳定性与产物正确性审查
本次只分析并修改软件,没有启动 SDK、相机或机械手运动。实机指令下发后反馈不变的问题按硬件问题排除。原始日志、断点、Profile、保护输入哈希及运动顺序均未改写。
## 结论
流程确有软件层面的稳定性缺陷,不能把历次暂停全部归因于标签或硬件。本次修复准备观测准入、短暂缺帧的恢复分类,并统一在线与离线的完整采集验收入口。已有的姿态可观测性、独立留出验证及 JSON/URDF 回读要求继续保留。
“少暂停”与“结果正确”需要分别处理:短暂坏帧应在采集层过滤或使用有次数上限的重试;真正无法区分的三维姿态、移动的固定参考、改变的标签安装或不完整数据,应明确拒绝生成通过产物。相同机位重复同一动作并不一定能提供区分姿态所需的新信息。
## 已定位并修改的问题
| 问题 | 原有行为及影响 | 本次修改 |
| --- | --- | --- |
| 无效观测覆盖有效准备帧 | 每个指令区间保存最后一帧;后来的空候选或不合法候选可以覆盖先前有效帧,随后整个运动求解被拒绝 | 在写入区间缓存前,复用求解器已有的候选质量检查;图像求解所需的根参考必须已冻结。只有完整有效帧才能替换缓存,原始坏帧仍保存在日志中 |
| 缺帧错误未进入重试策略 | 底层返回 `insufficient_image_frames` 或 `motion_candidates_missing_or_invalid`,已有恢复策略却只识别另一部分错误名称 | 将这些暂态错误纳入同一个恢复策略,每个尚未建立零位的任务/关节组最多重新执行一次准备动作;持续失败仍暂停 |
| 在线与离线的收尾检查边界不一致 | 离线入口检查完整扫描单元,在线直接进入最终拟合;若恢复数据只有旧通过标记或最新尝试不完整,缺少同一个入口检查 | 抽出 `runtime/artifacts/capture_validation.py`,最终生成入口统一检查配置身份、固定参考、相机内参、每个单元最新尝试及实际样本质量;两种产品入口均要求采集证据 |
| 大日志的重复扫描和额外内存 | 每个单元都遍历完整日志;离线计算日志哈希时额外一次性读取整个文件 | 按单元、任务各建一次索引,复用原有质量规则;日志哈希改为流式计算 |
候选准入只使用预先规定的检测质量条件,不根据拟合后的误差选择保留帧;保留的两种候选均进入原有比较,不填补不存在的另一种候选,也不改变最终精度阈值。准备记录增加 `preparation_observation_rejections`,可查到被拒绝观测的原因与数量。
若一个最新尝试同时存在通过与失败标记,拒绝通过;若只有较早尝试完成、较新尝试未完成,也拒绝通过。失败原因写入 `capture_validation.json`,并在拟合前返回,不能更新 `latest_passed`。
## 真实日志验证
### 无名指 MCP 准备阶段
来源:`calibration_output/O30_RIGHT_001/20260919_204235/raw_samples.jsonl`,侧面机位、运动版本 159。
- 原报告选择了 105 帧,其中 8 个区间的最终帧存在空候选,整体报 `motion_candidates_missing_or_invalid`。
- 从原日志抽取该段 430 条侧面观测,保存为 `test/fixtures/o30_ring_preparation_admission.json.gz`;不修改其角点、指令、反馈或候选。
- 新准入规则拒绝 14 条不合格候选观测,保留 97 个有效运动区间,求解不再因整段混入空候选而失败。
- 这段历史数据还存在候选族不完整和图像重投影失败,最终仍未授权模型。该验证证明准入缺陷已消除,不能用来宣称这段实机标定已经通过。
### 已保存的 96 个单元
来源:`calibration_output/O30_RIGHT_001/20260919_214048/raw_samples.jsonl`。
- 保存的会话具有唯一启动记录、唯一固定参考和三个相机模型;与当前产品的保护输入哈希及采集策略兼容。
- 离线提取日志中实际用于覆盖率和稳态质量检查的字段,执行相同单元验收:前 96 个单元通过,第 97 个 `middle_dip_side / cycle=0 / increasing` 因未采集而拒绝;没有将历史断点视为完整产物。
- 该检查不拟合历史图像,不修改断点;不是完整运动来源与最终图像验收的替代品。
- 正式断点启动仍使用 `passed` 恢复,直接复用通过单元。整套数据的完整检查放在最终生成阶段,不加入恢复启动路径。
## 目前合理且继续保留的边界
1. 几何求解与当前零位图像采集分别计时。已有实现会在参考复用后开启独立采集窗口,避免求解或加载耗时吃掉零位采样时间;此次未重复修改这一已修复逻辑。
2. 已冻结零位不能被局部重试替换;已成功安装部分机位模型、保持关节发生移动或固定参考冲突时,不自动重走准备动作。
3. 三维候选族无法区分时仍不授权。之前对正面、侧面共享标签、测得的父关节几何及其不确定度的修正继续保留;没有把父几何当作绝对真值。
4. 运动顺序仍是拇指、四指屈伸、四指侧摆。恢复不回到整手起点;本次没有增加未声明的辅助动作。
## JSON 与修正 URDF 的正确性检查
正式产物必须依次通过:
1. 保护输入、相机和固定参考身份检查;所有应采集单元的最新尝试完成,样本覆盖率、稳态节点和训练/验证输入范围合格。
2. 零位、运动模型、跨会话来源及共享标签证据一致;训练数据与独立验证图像不重叠。
3. 方向分别拟合的指令映射和零位通过独立留出检查,不能用训练拟合误差替代验收。
4. 先写标定 JSON,再从落盘 JSON 重建修正 URDF,回读核对两份文件对应关系。
5. 检查授权修改范围、运动学、角度和最终文件对应的独立像素重投影;标准 ROS URDF 加载检查通过后才允许发布。
6. 发布控制器复核候选文件哈希和取消状态,最后更新通过指针。采集完整性检查通过不等于整套产物已通过。
O30 当前契约有 20 个独立主动关节;末节 DIP/IP 的五个 CAD 零位假设必须在产物中明确保留其来源,不能写成五个已经独立实测的绝对零位。本次不改变这一契约。要声称这些绝对零位也经过实测,还需要相应独立观测。
## 软件验证范围
- 新增回归覆盖坏帧覆盖、全部坏帧、未冻结根参考、保留两种候选、真实无名指准备数据,以及在线/离线收尾对失败单元、未完成新尝试、配置和内参变化的拒绝。
- 恢复回归覆盖实际底层缺帧错误、一次重试后成功和持续失败后暂停;继续检查已通过单元复用、异步求解和零位窗口。
- 产物回归覆盖 O30 20 通道非线性映射、独立角点验证、JSON 重建 URDF、授权字段约束、进程收尾与发布取消。
- 共 198 项不同的相关回归测试通过,其中新增 18 项用例/参数组合。首批 28 项通过,综合批次 181 项通过(504.86 秒),去除重叠后为 198 项;最终收尾检查修改后又单独复跑 20 项,全部通过。Python 编译与 `git diff --check` 通过。
- 软件回归及合成数据验收不代表实机精度已达标;当前仍没有完成整手最终产物验收。
- 安装环境解析到当前源码目录,新增的共享验收模块可直接导入;没有启动实机程序验证。
@@ -0,0 +1,111 @@
# O30 自动生成链路修复与实采验证
2026-09-23。本次实际修改生产代码并只读回放两批实采数据,未驱动硬件。
采集与离线求解已解耦,行程端点约束及 JSON→URDF 导出链已修复;当前生成的是
**未通过实测精度认证的候选**,不能作为正式正确版本发布。
修复前分析见 [O30_CURRENT_FLOW_REVIEW.md](O30_CURRENT_FLOW_REVIEW.md)。
剩余误差已按机位、标签和指令进一步定位,见
[O30_REMAINING_ISSUES.md](O30_REMAINING_ISSUES.md)。
## 1. 收敛后的流程
1. 启动前保存本次有效 Product、Profile、CAD URDF/mesh、内外参及标签配置。
默认配置同步已确认的四指 SDK 方向、中节标签绑定和新外参;历史会话仍按自身
快照及外参回放,不能将新外参套在旧图像上。
2. 保留既有路线、速度、2+1 分区、108 方向、一次局部补采及停滞重试保护。
采集完成先归位封存,关闭 SDK、相机、监控及 ROS 控制上下文,再写采集结果。
完整返回 0、缺项返回 3;均不进入拟合,也不因离线失败重采。
3. 使用会话 `capture_product.yaml` 和 `raw_samples.jsonl` 单独离线拟合。
原始文件与配置、mesh 均受清单哈希保护。验证轮 3 不参与参数优化或候选选择。
4. 先校验草稿 JSON,再读回 JSON 生成 URDF;共用一个导出函数,检查实际输出文件
FK 与标准加载。独立报告 JSON 双向曲线和纯 URDF 上下限线性驱动的精度。
5. 草稿允许保留失败证据用于诊断,精度失败不会更新 `latest_passed`。
普通单批离线入口及参数见 [README.md](README.md#低精度离线修正草稿o30)。
本次跨批验证脚本保存在仓库根目录下
`calibration_output/software_fix_20260923_auto_urdf_v2/replay.py`。
它调用包内拟合、范围证据、导出和验收函数,不另写一套 URDF 生成逻辑。
脚本对输入和实现哈希有缓存约束;代码变化后不能直接复用旧参数冒充重新验证。
## 2. 行程问题的修复
换向处通常只保存到达端点的方向标签;离开端点的另一个方向没有独立端点观测。
旧模型仍给两个方向分别分配端点自由参数,缺少直接约束的端点可能保留初值,
或由邻近指令外推,并进入最终 URDF 极值。这会使文件可加载而行程依据不足。
新模型对这类端点施加明确的换向连续性约束:有观测的到达端点同时决定未观测的
离开端点。如果两方向均有端点观测,则保留独立参数;若连来源端点都缺失,拒绝
导出该行程。该假设不等同于两次独立测量,使用
`observed_turnaround_continuity_v1` 明确记录。
优化只移除声明为从属的参数,其他秩亏仍拒绝;图像 Jacobian、角度导数和范围
不确定度均沿同一依赖传播,避免只修改导出数字。证据记录训练样本、轮次和方向,
JSON 回读检查端点连续性及来源。测试确认任意扰动从属参数不改变残差或行程。
普通 URDF 的上下限只表示可动范围,不能编码 SDK 指令对应的非线性、双向曲线。
因此增加 `draft_urdf_linear_holdout.json`,专门检查
`q = lower + (upper - lower) × u / 255`(按 SDK 方向处理)的实际误差。
曲线验证通过不能被当成该线性驱动方式通过;零位只应用一次,非零指令基准也不
强行当作线性驱动的零角度。
## 3. 两批真实数据的结果
| 数据 | 原始方向完成量 | 训练/验证帧数 | 用途 |
| --- | ---: | ---: | --- |
| 旧外参 `20260923_142201` | 107/108 | 4769 / 2681 | 重新拟合全手参考 |
| 新外参 `20260923_183138` | 24/24 | 922 / 516 | 固定旧四指参考后更新拇指 |
仅取封存通过的最后尝试及有效稳态帧,不拼接失败尝试。旧数据缺第 1 训练轮
`fingers_mcp_roll_front / increasing / linked_increasing`,不能由验证轮补齐。
旧四指侧摆保留此前确认的竖直定义基准,四项在 JSON 标为用户定义;五个末端
零位保留 CAD。不能将这九项说成此次新独立测量的零位。
跨批旧参考仍是条件参考,其系统误差没有完整传播,不能据此正式认证新拇指。
两个训练阶段均在原有预算内收敛,先冻结参数再验证。原始数据哈希保持不变。
最终候选位于:
- `calibration_output/software_fix_20260923_auto_urdf_v2/artifacts/o30_right_draft.urdf`
- `calibration_output/software_fix_20260923_auto_urdf_v2/artifacts/draft_calibration.json`
同目录 mesh 自包含;JSON 重建 URDF 字节一致,SHA256 为
`ecb36313311292cb1ebe37d8069ba18234ac1dd6e5ccda39902d10e74ed862ac`。
最终文件 FK 最大差 `6.66e-16`,ROS 标准加载及 MuJoCo 20 关节加载/前向计算通过。
根目录另有早期诊断文件,本次最终路径以 `result.json` 的 `artifacts/` 路径为准。
以下角度误差来自冻结模型的独立验证轮,单位为度;测量本身未通过全部图像及
不确定度门限,所以它们用于定位问题,不是物理真值精度证书。
| 拇指关节 | 生成上限 | 曲线最大角度误差 | 纯 URDF 线性最大误差 |
| --- | ---: | ---: | ---: |
| CMC roll | 52.916 | 0.837 | 0.910 |
| CMC yaw | 110.843 | 1.549 | 3.483 |
| MCP | 105.135 | 0.748 | 1.325 |
| IP | 85.484 | 1.066 | 2.625 |
当前不能发布的具体原因:
- 新拇指曲线角度子项通过,但独立角度测量最大重投影误差仍为 7.194 px,最大
3σ 不确定度约 1.733°,未通过原门限,不能只报告角度子项通过。
- 纯 URDF 线性驱动下 yaw 最大误差 3.483°;IP 的平均/95 分位误差为
1.557°/2.571°,这两个关节的角度子项失败。
- 旧四指曲线测量最大重投影误差 16.468 px、最大 3σ 约 8.713°;线性驱动验证
的独立角度求解未收敛,保留 `raw_joint_holdout_angle_measurement_failed`。
- 旧采集不完整,且跨批条件参考尚不满足正式认证。
下一步应先用已封存图像定位失败机位/标签的重投影残差和参考不确定度,验证
标签安装、相机几何及观测条件。只有数据证明需要时,再决定补采范围或更改模型。
需要保持 SDK 字节轨迹时,运行端应使用经验证的 JSON 曲线得到弧度并驱动 URDF;
若产品要求单靠上下限线性驱动,则必须以该独立报告为准,不能只扩大上限消除表象。
## 4. 软件回归与证据
采集/局部/重装组 43 项、离线导出/端点组 43 项、更新审计组 3 项,以及真实
coordinator 的完整 108 方向虚拟时钟集成 1 项通过。最新配置及 mesh 封存修改后,
对应 4 项边界检查再次通过;它们与前述用例重叠,不重复累计。
完整调度用例耗时约 97 秒,其余组分别约 17、34、0.3 和 2.4 秒。
软件调度回归不代表本轮实机连续采集通过。
产物目录的 `verification.json`、`mujoco_acceptance.json`、`curve_holdout.json`、
`linear_holdout.json`、`training_frozen.json`、`result.json` 及 `test_evidence/`
分别保存来源核验、加载、精度、冻结输入和软件测试记录。
本次未放宽精度/可观测性门限,未替换仿真默认模型,未发布正式版本。
@@ -0,0 +1,180 @@
# O30 当前采集与自动生成链路复核
本文为修复前的只读复核记录;后续实际改动、两批数据重拟合及验收结论见
[O30_AUTOMATIC_URDF_FIX.md](O30_AUTOMATIC_URDF_FIX.md)。
2026-09-23。目标是完全由程序从采集数据生成正确、可复现的 JSON 和修正 URDF。
本次评估程序生成版,不用人工调整版作为拟合结果或精度依据。
结论:轻量采集、离线求解的方向合理,但目前还不能把“采完”或“草稿能加载”视为
“自动生成正确 URDF”。优先收敛采集边界和配置,再修复行程证据约束,最后用冻结
数据验证实际驱动方式。当前没有证据支持靠增大拇指上限或增加优化迭代解决全部偏差。
本轮只读复核代码和已有真实数据,运行针对性软件测试;未运动硬件、未重新拟合、
未修改生产代码/配置或替换模型。已有工作区改动和被删除的历史测试均保留。
## 1. 当前流程与合理之处
实际入口:`runtime/runner.py`;默认 `raw_joint_2_plus_1` / `raw_joint_images_v2`。
1. 启动检查、固定参考锁定、归基准;17 个任务覆盖 20 个关节。
2. 每任务训练轮 0/1、独立验证轮 3,共 108 个方向单元。
3. 移动标签在线只记录角点;保持反馈、同步、固定参考及硬件保护。质量不足的方向
立即补一次,不拼接失败尝试;恢复保留预算。硬件停滞重试与图像质量补采分别处理。
4. 归位、封存原始数据,状态为 `CAPTURE_COMPLETE`,随后关闭 SDK/相机/监控。
5. **启动器实际上还会自动调用草稿拟合**;显式 `--offline-raw` 则可单独离线运行。
值得保留:在线不依赖移动标签几何消歧;端点按已发送指令核验,不要求反馈恰好
达到 0/255;离线只取同步、有效稳态帧;验证轮不用于训练;原始证据封存;正式
发布保持来源、候选不确定度、独立验证及 JSON→URDF 回读门禁。
拟合固定 CAD 拓扑、连杆和关节轴,联合估计掌坐标、标签安装、关节零位和双向
分段指令曲线。15 个零位可估,五个末端零位沿用 CAD 假设;显式竖直侧摆草稿
另把四个侧摆零位设为用户定义基准,因此该草稿实际只估 11 个零位。
这些假设必须继续写进产物,不能改称全部零位都由视觉独立测得。
## 2. 优先处理的问题
### P0:采集完成仍绑定草稿求解结果
`runtime/runner.py:271` 在关闭控制栈后调用 `finish_captured_draft()`;
`runtime/artifacts/raw_joint_draft.py:352` 只有“数据完整且完整目标草稿生成”才返回 0。
因此即使采集完整,离线不收敛也会使整个命令返回 3。关闭控制栈的顺序是正确的,
但采集任务的耗时和成功语义仍受拟合影响,README 的“后续单独离线”也不完全准确。
建议默认入口结束于归位、封存和采集报告,退出码只描述采集;拟合由显式离线命令
执行。若保留一键流程,作为明确选项组合两个阶段,分别记录退出状态;拟合失败
不改变采集结论,更不据此安排重采。不要另建采集执行器。
### P0:默认配置与有效离线配置没有收敛
对照当前默认 Profile 与生成程序草稿使用的
`calibration_output/splay_recapture_20260923_132346/offline_confirmed_middle_tags_profile.yaml`:
| 项目 | 默认 Profile | 草稿拟合 Profile |
| --- | --- | --- |
| 四指侧摆通道 2–5 的 SDK→URDF 方向 | −1 | +1 |
| 正面 ID12–15 安装连杆 | `*_metacarpals` | `*_middle` |
| 默认产品外参 | 20260922_101452 | 新拇指批次使用 20260923_181333 |
ID17 在两者中均已绑定 `thumb_proximal`。这些差异不是哈希损坏:默认配置自己的
`--validate-only` 可以通过,但这只证明内部结构和文件身份一致,不证明与现场安装一致。
原始角点仍有价值,不能因为默认建模配置错误就全部废弃数据。
建议把已确认的模型方向/标签连杆修订纳入版本化配置;每次采集保存完整配置包,
包括 Product、Profile、源 URDF、内外参与标签尺寸。旧数据按自己的快照回放;
模型修订另存,保持原采集身份。外参按实际安装选择,不能无条件把“最新文件”
应用到所有历史数据。新旧相机批次应各用各的外参。
### P1:未观测的方向端点可以改变“实测行程”
这是本次用当前代码重新复现的确定问题,不是照片推测。
读取新拇指真实训练帧及已保存参数,核验原始数据 SHA256 与封存清单,不重新求解:
| 关节 | 程序 URDF 上限 ° | 超出已观测正程端点 ° | 未观测回程参数加 5° 后上限变化 |
| --- | ---: | ---: | ---: |
| CMC roll | 52.9662 | 0.0520 | +5° |
| CMC yaw | 110.9948 | 0.1495 | +5° |
| MCP | 106.0734 | 0.9447 | +5° |
| IP | 85.4810 | 0.0018 | +5° |
四项的训练 Jacobian 对应列范数都是 0;修改后训练残差逐元素完全不变。
255 端点有正程方向历史的图像,没有独立 decreasing/255 图像。回程起点沿用
上一实际推进方向是正确记录,不能把它伪标成独立回程观测。
原因链:`UrdfCommandImages` 为每方向分配完整曲线 → `JointMapping.bounds_rad`
取两条完整曲线包络 → `prepare_raw_motion_ranges()` 将它直接写入范围证据。
当前范围证据检查了基准、轮次及图像身份,没有约束每个极值的方向有效域。
增量更新工具虽然冻结无观测参数,却仍保留其初值,并未阻止初值进入导出范围。
本批模型调用全参数协方差检查会报 `urdf_images_geometry_rank_deficient`;上述
零列已足以造成秩亏。该检查包含固定旧四指的参数,不能当成一次完整正式拟合
运行结果,但说明“方向单元采全”不保证全部自由参数可辨识。
建议统一处理模型、曲线和导出的有效域:每方向节点记录训练支持、可辨识性和
来源;未观测自由参数不进入实测范围。若使用共享端点/连续性约束,必须显式
标为模型假设并检查数据是否支持。JSON 读取、范围证据、URDF 导出及协方差应
使用同一份契约,不能只在最后裁剪 limit,也不能靠伪逆把未知量变成零不确定度。
必要回归:扰动无观测参数不得改变认证范围;训练/验证不串用;方向切换端点身份
正确;缺证据时明确报告范围不足;JSON 回读重建保持同一有效域。
### P1:JSON 曲线精度不等于纯 URDF 线性驱动精度
审查程序生成文件:`offline_combined_new_thumb_draft/o30_right_draft.urdf`,
SHA256:`9d6e71276ffcc2108cf0776cdee08a23803de9f048b1a42b7ca76e98b40e48a2`。
复核保存的独立第三轮报告,最大角度误差如下;本轮没有用第三轮重新拟合:
| 关节 | JSON 双向曲线 ° | 纯 URDF 线性驱动 ° |
| --- | ---: | ---: |
| roll | 0.837 | 0.919 |
| yaw | 1.538 | 3.516 |
| MCP | 0.749 | 2.285 |
| IP | 1.070 | 2.621 |
曲线模式的四关节角度子项通过,但整体像素/不确定度仍失败;两个模式的整体
报告均为 `passed=false`。这些角度是条件图像模型的估计,不是独立编码器真值。
当前模型的修正关系是 `T_origin_corrected = T_origin_CAD · R_axis(delta)`,
JSON 给出相对修正零位的 `q_output=f(command, direction)`。文件回读 FK 一致
证明写入关系一致,不证明 delta 或 f 是真实物理值。
当前纯 URDF 驱动使用 `q=lower+(upper-lower)*u/255`。上下限能表达角度范围,
不能同时保存中间点非线性和方向差异。此前冻结同一模型的训练诊断中,yaw 在
SDK80 处线性值比正程曲线大约 2.90°,在 SDK181 处大约 1.32°;不是全区间都
“行程偏小”。当前 yaw 的未观测端点影响约 0.15°,也不足以解释全部偏差。
目标应明确为程序同时生成可信几何 URDF 和指令映射。如果使用纯 URDF 线性
驱动,就必须用该驱动方式独立验收零位、两端、中段及组合姿态,并如实报告无法
用一条直线达到的精度;不能把扩大物理行程作为非线性补偿。
### P1:实际重复性与组合姿态仍未达到自动认证条件
已有现场记录中,同基准指令返回后,正面固定 ID0 角点 RMS 位移约 0.092 px,
拇指 ID1/ID2 分别约 7.39/16.23 px,反馈接近。证据说明存在需要解释的重复性
差异,不能仅凭一次对照唯一归因于硬件回差、标签松动或零位错误。
当前双向曲线都强制基准值为 0,单个固定零位不能自动解释不一致的基准观测。
建议先用已有各轮基准帧做相同路径/方向/其他通道状态下的重复性统计,单独报告
跨路径差异。保持既有原始帧,不为降低残差删掉不利观测,也不立即增加在线几何
暂停。若固定条件下仍无法重复,增加优化迭代不能获得可信的确定性映射。
新拇指与旧四指组合依赖冻结的旧四指及标签安装参数。它是条件草稿;旧参考误差
未完整传播,旧整手仍有 1/108 方向缺项。新拇指 24/24 不能补齐这些认证缺口。
组合生成还依赖输出目录中的专用脚本,没有形成统一的跨批次正式入口。
## 3. 建议实施顺序及验收边界
1. **先稳定采集交付**:收敛配置与快照,默认只采集并封存;保留原路线、速度、
2+1 分区、一次补采和硬件保护。采集报告明确已完成/缺项/中断、是否安全归位。
2. **用已有数据修离线契约**:先解决方向有效域与未观测参数,再用相同冻结输入
比较旧/新结果。统一共享模型和导出模块,避免另建拇指拟合器或手调常量。
3. **形成单一可复现离线入口**:训练模型选择 → 冻结 → 独立轮验证 → JSON 落盘 →
重建 URDF → 实际驱动方式的文件/FK/角度验收。跨批次模型显式携带外参及参考
不确定度。失败另存报告,不覆盖已发布模型、不自动重采。
4. **最后评估实机精度**:先区分基准、端点、中间映射、组合几何的误差,再决定
是否缺少必要观测。只有数据能支持轴线/安装修正时才扩展模型,不能靠任意放开
CAD 轴吸收外参和重复性误差。现有单关节验证不能认证任意多轴组合。
采集稳定、求解成功、文件正确、实机精度通过应有独立状态。满足前三项仍不能
自动承诺第四项;本轮没有把软件通过表述为现场已能自动生成准确 URDF。
## 4. 本轮验证与证据
- 采集、预算、断点、重装隔离、局部范围和增量更新:46 passed,16.93 秒;
按测试约定未重复运行耗时完整整手虚拟时钟用例。
- 草稿、离线初始化、训练隔离及文件生成相关:39 passed,24.41 秒。
- 默认正式 CLI `--validate-only` 通过;它不能检测与现场安装的配置漂移。
- 初次测试命令覆盖了 ROS 的 PYTHONPATH,收集失败;修正环境后运行上述分组。
一次 `python -m runtime.runner` 未调用 main、没有实际校验,改为直接调用 CLI main 后确认通过。
- 真实冻结数据的端点敏感性检查已完成;既有 85 项测试通过没有覆盖并消除该漏洞。
本轮机器可读证据:
- [端点审计](../../calibration_output/software_review_20260923_current_flow/endpoint_audit.json)
- [配置差异、程序原稿与角度误差](../../calibration_output/software_review_20260923_current_flow/review_summary.json)
- [采集相关测试](../../calibration_output/software_review_20260923_current_flow/capture_tests.json)
- [离线相关测试](../../calibration_output/software_review_20260923_current_flow/offline_tests.json)
既有现场重复性证据:
`calibration_output/thumb_recapture_20260923_175829/live_comparison_20260923/repeatability.json`。
@@ -0,0 +1,148 @@
# O30 右手离线修正逻辑复核(2026-09-23)
本轮使用封存会话 `splay_recapture_20260923_132346/O30_RIGHT_001/20260923_142201`。
原始数据未改写,没有 SDK 运动;训练轮 0/1 共 4769 帧,独立验证轮 3 共 2681 帧。
采集仍为 107/108 个方向完整。独立验证未参与参数拟合、候选选择或绑定判断。
## 已修正的问题
1. **标签所属连杆错误。** ID12–ID15 原配置为 `*_metacarpals`,程序因此认为 MCP/PIP 动作不会带动这些标签。
训练数据中四标签随 MCP 移动约 93–105 像素、随 PIP 移动约 37–49 像素,DIP 动作时仅约 0.2–0.3 像素。
小指对应 MCP/PIP 观测中掌部参考角点仅变化约 0.08/0.19 像素。
用户确认四标签都固定在中节或随中节运动的支架上,最终离线绑定为 `pinky_middle`、`ring_middle`、
`middle_middle`、`index_middle`。保持角色名称、ID、原始角点和 SDK 指令不变。
原始采集配置保留;本批求解必须使用下述新的离线配置,不能继续使用旧绑定配置。
2. **PnP 候选排序被误当成轨迹身份。** 实际 ID17 在 SDK 64→96 期间交换候选排序,固定取第 0/1 项会产生超过 40° 的虚假跳变。
草稿与正式求解的初始化现在复用候选连续关联,保留两个候选族,缺失的候选不复制成第二份证据。
初始化不用于授权唯一分支;原有在线严格连续性检查保持不变。最终拟合仍直接使用二维角点。
3. **扫描起点被上一段回程方向排除。** 初始化现在按正式扫描/分段归属纳入起点,四指补段只纳入实际运动的关节。
前向迟滞模型仍使用原始逐通道方向历史,不改写历史来伪造上行或下行。
4. **欠约束零位可能被漏判。** 当 Jacobian 行数小于列数时,保留完整右零空间,防止精简 SVD 遗漏不可观测方向。
5. **行程证据报告不准确。** 行程和基准样本只引用主视角有效可见标签及实际训练轮次,排除验证轮和无标签图像。
单训练轮草稿使用明确的规划选项;正式范围验收仍拒绝单轮证据。
6. **生成与验收混淆。** 终端分别显示全手参数生成、独立验证和草稿发布状态;结果清单记录模型绑定修正。
## 最终本批结果
目录:`calibration_output/splay_recapture_20260923_132346/offline_confirmed_middle_tags_draft/`。
- `o30_right_draft.urdf` 与 `draft_calibration.json` 必须配套使用,SDK 指令不能直接作为 URDF 弧度。
- 11 个拟合零位、4 个用户定义的竖直侧摆零位、5 个保留 CAD 的末端零位;20 个拟合运动范围。
- 训练坐标 RMS:旧竖直草稿 5.5647 px → 本版 1.4867 px。
- JSON 与最终 URDF FK 最大差 5.55e-16;标准加载、原始文件哈希检查通过。
- 18/20 个关节的独立角度检查通过,包括四指全部关节及 `thumb_cmc_yaw`。
- `thumb_cmc_yaw` 独立角度 MAE 0.658°、P95 1.950°、最大 2.180°。
- `thumb_cmc_roll` 最大 10.306°、`thumb_mcp` 最大 4.262°,二者角度检查未通过。
- 全局最大测量重投影误差 16.47 px,最大角度置信界约 8.71°;全手精度仍未通过。
角度子项通过不等于零位绝对精度或任意多关节联动已认证。
- 没有更新 `latest_passed`,没有替换用户正在运行的 MuJoCo 模型。
`offline_motion_logic_review` 和 `offline_corrected_tag_links_draft` 是绑定核实过程中的对照产物,
已分别留下被最终中节绑定取代的说明,不能将其中的支座/近端绑定当成最终确认。
最终确认及像素依据见同级 `offline_confirmed_middle_tags.json`、`tag_motion_dependencies.json`。
## 只读离线重放
在项目根目录运行;输出目录必须是尚不存在的新目录:
```bash
source /opt/ros/jazzy/setup.bash
source install/setup.bash
OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 ros2 run linkerhand_calibration calibrate_hand \
--config calibration_output/splay_recapture_20260923_132346/offline_confirmed_middle_tags_product.yaml \
--workspace /home/lxp/projects/linkerhand_retarget_ros2 \
--offline-raw calibration_output/splay_recapture_20260923_132346/O30_RIGHT_001/20260923_142201/raw_samples.jsonl \
--offline-draft --draft-upright-splay-zero \
--offline-output calibration_output/splay_recapture_20260923_132346/offline_confirmed_middle_tags_replay
```
## 回归范围
新增 7 项集中回归均通过:真实候选换序、起点方向/四指补段、候选缺失及严格连续性门限、
欠约束零位、两个单训练轮草稿与正式拒绝边界、确认中节绑定的原始配置隔离及关节依赖。
其余受影响的草稿初始化、重复观测目标等价、JSON 导出、独立验证失败记录、CLI 和关闭控制链测试分组通过。
首次测试有 4 项因 shell 未加载 ROS 缺少 rclpy,2 项因新增夹具字段名错误失败;修正后对应项通过。
未恢复被外部删除的历史测试,也没有把离线回放当作实机验收。
## 新版仿真仍有拇指偏差的复核
只读订阅确认当前拇指 SDK 指令仍为 roll=55、yaw=181、MCP=122、IP=0;
实机反馈约为 56、176、120、4。仿真与新草稿文件哈希相同,使用配套指令映射。
新旧映射在当前 yaw 指令下输出分别为 79.164°、79.207°;新旧拇指末端 FK
仅差 0.219 mm 和 0.271°,因此上一轮修改并没有实质解决这个拇指姿态差异。
当前联合求解器固定 CAD 的关节轴线和拓扑,只拟合掌姿态、标签安装、零位和指令曲线。
训练轮中 MCP 轴的连续 PnP 假设与该模型轴存在偏差:正面 ID1 最接近的候选约 10.2°,
同机位末端标签约 9.94°,顶机位 ID17 约 13.09°。这些是几何诊断线索,不能排除相机
外参和候选歧义的影响,也不能直接将其作为允许写入 URDF 的轴修正。
静止标签的微小旋转不作为轴证据;原始候选与运动量完整保存在诊断 JSON 中。
原采集主要为单关节扫描,MCP 扫描将 yaw 保持在 SDK 80;当前 roll/yaw/MCP 的
55/181/122 组合不属于已直接观测的组合姿态。独立轮次中的角度子项通过并不认证
绝对关节轴/零位或任意组合运动。后续应优先验证拇指几何约束及组合姿态,不能用降低
误差阈值、照片手调 yaw 或再次导出同一参数代替修正。
诊断:`offline_confirmed_middle_tags_draft/thumb_current_pose_model_audit.json`。
本次仅诊断,未改求解参数、仿真模型或实机姿态。
## yaw 行程专项复核
本轮保持 CAD 转轴及全部拟合参数不变,只读训练角点、URDF/JSON 和当前 ROS 指令。
两个训练轮的正反扫描均有 SDK 0/255 对应的顶机位 ID17 有效图像,每个端点 3 帧。
SDK=255 的实际方向历史为 increasing(反向扫描起点也沿用此前的上行历史),没有把扫描
名称当成实际指令方向,更没有要求反馈必须等于 255 才认可发送端点。
当前输出曲线:SDK 80→32.97°、128→54.11°、181→79.16°、223→98.26°、255→113.39°。
URDF 上限为 113.3957°,高于原 CAD 约 107.25° 的名义范围。单独加载 MuJoCo 后对
181/223/255 与三种方向状态逐项执行映射和 mj_forward,qpos 与映射完全一致,未发生限位截断。
当前实机控制话题仍是 yaw=181,反馈约 176;这不能当成实机已到全行程终点。
保留两个连续 IPPE 假设、分别检查两训练轮正反向后,原始角点估计的相对总转角为
114.54°–115.15°,181 处约 80.00°–80.99°。与当前曲线相比,存在约 1°–2° 的偏小线索,
但这是姿态候选诊断,不能直接当成独立物理真值或据此放大行程。
当前零位修正为 −5.13°;它影响绝对朝向,不等于减少 5.13° 的相对行程。
明显组合姿态差异尚不能只由这约 1°–2° 的相对行程差解释。
原始诊断与完整逐停点数据:`offline_confirmed_middle_tags_draft/yaw_range_review.json`。
仿真核验:`linker_hand_mujoco_ros2/reports/yaw_mujoco_range_check.json`。
未发送实机动作,未改轴、放大曲线或替换任何模型。
## 当前中间指令的现场图像诊断
用户明确为同一中间指令下仿真偏小。为避免只对单关节历史数据推断组合姿态,
在现有 55/181/122/0 拇指指令保持不变时,短时读取三相机图像;没有机械手控制发布。
侧面可见 ID1/ID2,各 8 帧;原始文件单独存入 `current_thumb_pose_readonly`,不加入训练。
固定当前几何/零位/安装的图像角度诊断:仅调 yaw 最佳约 +2.16°;四关节共同解释时
约 roll +4.44°、yaw +0.82°、MCP −0.73°、IP +0.41°。残差与不确定度仍较大,
yaw 条件 3σ 约 5.96°,未包括共享几何不确定度,不能据此写入新修正。
这支持继续区分中段映射、绝对零位及组合姿态误差,不能直接扩大 URDF 上限。
详见 `current_thumb_pose_readonly/README.md` 与 `thumb_pose_measurement.json`。
## 纯 URDF 模式下分开复核零位与组合姿态
后续仿真按用户要求默认仅使用 URDF 线性映射。对已保存 55/181/122/0 组合图像重新诊断:
只调 yaw 为 +1.581°,同时拟合四拇指角度时 yaw 为 −0.503°;不能沿用旧曲线模式的 +2.160°。
训练两轮的条件零位复核为 yaw −5.331° / −4.907°,并未独立证明绝对零位正确。
发现最终原始记录存在相同输入冲突:训练轮 0 的四指侧摆,13:47:45 上行末端与 13:53:29
下行起点,控制指令、方向历史、相机内参及拇指反馈相同,掌部 ID0 中心只差 0.041 px,
拇指 ID1/ID2 中心分别差 6.264/15.468 px。7 帧与原始 JSONL 逐字段核对一致。
这些辅助拇指观测进入了零位求解;重复观测聚合虽保留原目标,却不能解决输入本身的不一致。
诊断性暂不使用四指侧摆任务辅助拇指观测后,yaw 零位 −5.130°→−5.018°,
指令 181 的拟合角度仅 79.164°→79.185°,因此此问题不足以解释全部 yaw 差异。
细节见采集目录 `current_thumb_pose_readonly/ZERO_AND_COMBINATION_REVIEW.md`;对应三个 JSON
保留条件、证据和敏感性结果。未改 CAD 转轴、原始数据、仿真 URDF、生产求解器或正式发布状态,
未使用第三轮拟合,也未发送实机动作。
### 对故障归因的修正
“观测有差异”不等于“离线实现有 bug”。再次核查发现,重复观测压缩完整保留组内平方误差;
7 帧实测数据的完整目标与压缩目标差值小于 1e-11 px²。现有草稿如实记录独立验收失败并禁止正式发布。
在现有确定性模型下采用最小二乘折中是合理实现,不能只凭不同采集段图像不一致就要求删除观测或修改拟合。
前面对“必须修正离线数据使用逻辑”的表述过强,应以此处结论为准:软件缺陷尚未证实;
相机视角、线性驱动近似、实际回位差异和固定几何模型的适用范围仍是待区分解释。
详见 `current_thumb_pose_readonly/hypothesis_reassessment.json`。本轮仅复核证据及更正说明,未修改生产代码或模型。
@@ -0,0 +1,208 @@
# O30 轻量标定实现与验收边界
2026-09-23。O30 正式入口为 `raw_joint_2_plus_1` / `raw_joint_images_v2`。
采集结束与 URDF 验收是独立结果,软件验证不代表实机通过。
## 正式采集流程
启动检查 → 17 个任务、20 个关节连续两轮训练及一轮验证 → 当前失败方向立即补一次 → 安全归位 → 封存数据并关闭控制栈。
- 保留原运动路线、避让、四指侧摆分段、训练及验证停点和速度。108 个正式方向单元,训练轮身份 0、1,独立验证轮身份 3。
- 移动标签只保存角点、身份、检测质量、相机参数、指令、反馈及时间。在线不运行移动标签 PnP、分支确认、父模型或辅助消歧;固定基准保护保留。每个任务只要求原指定主机位覆盖。
- 停点由稳态效果统一有界等待。中间指令步不再要求额外固定反馈位移;整个方向使用真实同向反馈推进保护,4 秒累计实际运动无推进即停止,计划内静止采样不消耗运动预算,切换停点不重置预算。
- 按用户最新要求,端点以实际发送的 SDK 指令为准,不要求反馈等于 0/255。有效稳态图中同步记录的已发送端点指令也用于端点核验,不重复要求扫描阶段另有一张端点图;稳态图不混入运动覆盖、反馈行程或分箱统计。没有实际发到的目标值不能作为端点证据。
- 反馈失联、硬件故障、控制权冲突、越界、固定参考移动仍停止;避让和归位到位要求不变。按用户补充要求,停滞后等待 3 秒、从当前指令位置继续同一方向一次;再次停滞停止,不循环重发。
- 当前方向质量不足立即按原准备路径回到起点再采一次。仍不足记录缺项、继续下一方向;不队尾补采、不循环回访、不合并两个失败尝试。重启保留已消耗预算。
- 结束状态为 `CAPTURE_COMPLETE`,不是标定通过。`raw_capture_manifest.json` 独立记录流程结束、覆盖完整、尝试次数、缺项、配置和证据文件哈希。完整返回码为 0,缺项返回码为 3;两者都关闭控制栈。
- 在线不启动最终拟合。后续 `--offline-raw` 只读封存数据,单独保存输出,缺项或拟合失败都不触发运动。
- 离线可重新计算已完成方向的采集质量,并在 `acquisition_reassessment` 保存原失败原因和新检查结果;不改写原始记录、不拼接尝试,不接受中断或未完成的方向。断点恢复仍按原记录消耗补采次数。
- 新会话计划为 `capture_plan_v7_raw_joint`,立即补采语义与 v2 角点绑定。旧 v1 数据保持离线读取,禁止混入新会话续采。断点仍要求安装条件可核验。
## 历史实机卡点
`20260922_233830` 只有拇指旋转的 6/108 单元通过,拇指 MCP 指令 32、反馈 0 三次失败。
此前拇指旋转指令 223/255、反馈 189/195 也曾停滞。本次针对逐停点额外推进要求进行结构调整,
不能由这些历史日志单独推断真实死区、阻挡或反馈异常;当前实机结果须另行记录。
## 离线求解与发布
- 未认证草稿按任务接受至少一轮完整训练,并使用另一轮已通过采集质量检查的完整方向。
例如侧摆第二轮缺一方向时,第一轮完整仍可保留四指关节链;初始化取完整训练轮。
完整方向仍按原始质量门限复核,不拼接两次失败尝试,第三轮不补训练缺项。
逐任务覆盖、缺项和算法身份写入独立输出;采集完整性与正式发布规则不变。
- 草稿可显式复用同批数据的局部训练结果作初值,核对原始文件/配置身份与训练选择记录,
共享参数仍全部重新拟合;第三轮不用于初始化或选择。重复预测做加权等价合并,
保留组内散差和原始逐图误差,以减少内存和重复计算。草稿数值停止容差单独记录。
- `UrdfCommandImages` 复用共享图像优化入口,直接沿原 CAD 拓扑计算四指及非平行拇指。一个掌变换、每个实体标签一个共享安装;不引入各阶段独立六自由度变换。
- 五根轴的方向与空间布局初始化掌坐标,子轴补充非平行约束;所有允许零位和双向单调指令映射在原始图像目标中联合求解。首次观测及行程端点不作为物理零位。
- 最多 64 个初始化,每个初始化的安装预优化与联合优化合计最多 200 次求值。正式后台进程沿用 600 秒上限;预算耗尽保留失败原因,不自动增加动作。
- 训练候选先检查原重投影门限,再对不同数值解族做有分辨率余量的配对图像损失检验(0.03 px、Holm 家族错误率 0.01)。同一保持姿态和重复初始化不重复计为独立证据。所有未被训练证据排除的候选均保留;发布代表按物理输出的最坏误差选择,不按最低像素分数选零位。
- 完整相关协方差和候选间差异传播到零位、绝对指令角、轴方向、轴位置、FK 位置及旋转。保留原角度/空间门限;标签安装不唯一不单独否决。审计的 FK 范围为训练网格和单关节姿态,不宣称任意多轴组合已验收。
- 第三轮冻结全部共享几何、安装、零位和指令映射。仅为独立测量估计每张图自己的角度;按图像块批量运算,各图之间不共享测量变量。第三轮不能参与训练、候选选择或参考修正。
- 沿用现有文件名和 `latest_passed`。仅授权修改 `origin.rpy` 与限位;拇指 IP、四指 DIP 保留 CAD 零位。检查 JSON 重建、实际文件 FK、授权字段及标准 robot_state_publisher 加载后,才允许原子发布。
## 使用与结果
首次新协议必须显式新采集,旧会话及原 66 个通过单元只保留回归用途:
```bash
ros2 run linkerhand_calibration calibrate_hand \
--config src/linkerhand_calibration/config/o30_right_product.yaml \
--no-resume --camera-optical-observations /绝对路径/optical_observations.json
```
采集结束后,使用同一批只读数据离线修正;默认不发布:
```bash
ros2 run linkerhand_calibration calibrate_hand \
--config src/linkerhand_calibration/config/o30_right_product.yaml \
--offline-raw /绝对路径/会话/raw_samples.jsonl \
--offline-output /绝对路径/新的离线结果目录
```
`--publish-offline` 仅在全部原精度与文件检查通过后才允许更新 `latest_passed`。
原始索引、压缩证据、导入证据和 `raw_capture_manifest.json` 须一起保留;不得编辑封存数据。
后续恢复省略 `--no-resume`,仍需提供当前安装条件下的光学观测。若安装参考不可核验,按拒绝原因处理,不能改哈希绕过。
2026-09-22 用户明确要求复用已完成的相机标定:可将上述光学观测参数换为
`--reuse-camera-calibration`。此入口仍核对已有相机标定质量、受保护文件和实时
CameraInfo;日志及发布审计记录 `current_optical_verification: not_performed`,
不宣称本次重新做过光学核验。第三轮独立关节精度、候选不确定度与 URDF 发布检查不变。
现有 `three_camera_extrinsics.launch.py` 增加 `verification_extrinsics_file`:指定受保护外参文件、另设 JSON `output_file`,沿用棋盘格采集及保存操作。该模式不重拟合相机参数,保存同步多姿态角点,并验证冻结内外参;每个机位至少 15 组,跨机位同步不超过 50 ms,同时检查图像/倾角覆盖及原重投影门限。正常外参标定模式不变。核验模式的角点 JSON 用于上述启动参数。
关键文件:
- `raw_joint_result.json`:分别记录原始采集完整、拟合完成、精度通过;包含缺失单元、失败原因和禁止自动几何重采标志。
- `raw_joint_training.json` / `raw_joint_training_selection.json`:初始化预算、训练排除依据、全部候选审计。
- `raw_joint_candidates.npz`:保留候选参数和完整协方差。
- `raw_joint_holdout.json`:每个保留候选的冻结验证。
- `calibration_report.json` / `release_manifest.json`:协议、原始证据、安装参考、光学观测及候选集合身份;最终发布证据。
## 历史软件验证(2026-09-22)
- 全手运动效果测试覆盖 17 个任务、三轮正式往返、原避让和归位;实际 coordinator 虚拟时钟覆盖小指 18 个方向、全部已发送指令,无在线求解。
- 当时的有界队尾补采、跨恢复预算、首单元中断发现、协议/参考拒绝、固定参考保护、光学缺失时禁止启动均有测试。当前 v2 已改为当前方向立即补一次。
- ID5、ID9、ID12 的真实历史双候选角点可作为采集数据;ID10 真实缺测不能变成完整扫描。
- 独立 XML FK 真值:64 个初始化全部收敛,约 193 秒;32 个超过原图像门限,16 个被训练配对证据排除,剩余 16 个全部通过候选集合不确定度及第三轮验收。该批次生成合成 JSON/URDF,并通过标准 ROS 加载和最终文件 FK 回读。
- 合成产物位于 `calibration_output/software_review_20260922_raw_joint/known_truth/`,仅有软件测试指针 `synthetic_only_passed`;没有更新实机 `latest_passed`。
- 该软件产物保留生成时的检验审计;当前更严格的 0.01 家族错误率复核另存 `current_training_selection.json`,保留候选集合完全相同,未重复运行优化器或改写原验收文件。
- 当日没有重新启动电机;这些合成结果不能证明实际机械手的准确性。当前相机安装的光学核验和实机姿态对照仍须单独验收。
## 本次实施验证(2026-09-23)
软件完整 coordinator 模拟:108 方向均通过、没有补采,安全归位后封存且不再发送指令;
旧合成报告的 v1/v2 协议重建产生相同 URDF,原候选不确定度拒绝条件仍有效。
采集、立即补采、封存和只读离线检查详见 `TESTING.md`。
实机 `20260923_104837` 去程通过,拇指旋转回程指令降到 162 而反馈仍为 253,
累计实际运动无推进 4.005 秒后保护停止。角点记录亦未显示明显回程运动。
控制栈已退出,未重复强推、未自动归位;本次仅完成 1/108 方向,未生成通过的 URDF。
随后用户明确允许此类硬件停滞重试一次,已采用等待 3 秒后继续一次的策略;
新会话 `20260923_110651` 使用更新后的计划身份,不导入上述旧策略会话。
该新会话实际完成 35/108 个方向,原在线记录 29 个通过、6 个端点覆盖不足,
在小指 PIP 第三轮回程收到 `o30_sdk_identity_mismatch` 后停止。此时反馈约 29.81 Hz、
无活动硬件故障;通用“通信失联”提示不能证明物理断线。原日志没有保存异常身份报文,
无法确定具体不匹配字段;现已补充保留异常身份字段并单独显示身份错误,停止条件不变。
未触发硬件停滞重试,控制栈已退出,未完成归位和整手封存,未再次启动运动。
按最终端点规则对原始数据只读复核,35 个完整执行方向全部通过,原 6 个端点误判均消除;
还缺 73 个方向,禁止发布。复核报告位于
`calibration_output/software_review_20260923_lightweight/interrupted_capture_review.json`,
记录源数据哈希、算法身份、逐方向重算证据;源文件哈希未变化。
本次实机尚未完成全手流程和 URDF 精度验收。
同批数据随后做了真实拇指四关节局部图像拟合,发现 ID17 的安装连杆配置错误。
用户确认 ID17 位于随 MCP 弯曲的近端指节,仅顶机位可见;现改为 `thumb_proximal`,
同步产品配置哈希,保留机位、标签尺寸、任务和路线。原始记录与旧配置身份不改写。
仅训练数据的绑定对照使最大标签重投影 RMS 从 87.575 px 降到 8.047 px,
仍未通过原 1.5 px 门限,不能称为 URDF 修正成功或证明两轮足够。
具体结果与修改前配置快照见
`calibration_output/software_review_20260923_lightweight/thumb_fit_audit/README.md`。
本轮没有实机运动,没有使用第三轮选择或调整新绑定候选,也未发布 URDF。
## 用户允许先生成低精度修正文件(2026-09-23)
新增 `--offline-draft`,与正式精度认证分开。复用 `UrdfCommandImages` 的图像残差、
参数约束及优化器,仅为部分已采集关节增加轴线初始化;训练选解后冻结,第三轮仅报告。
能估计的零位与行程一起导出。单根手指的根零位与掌坐标存在等价变换,固定其 CAD
零位;其余估计零位须不落入图像雅可比的数值零空间。原已声明末端 CAD 零位仍明确列出。
不以收敛或多个初值一致冒充实际精度认证,草稿不更新 `latest_passed`。
实际旧数据生成于 `calibration_output/O30_RIGHT_001/draft_20260923_110651_v2/`:
拇指侧摆零位 -2.761403°,MCP 零位 -1.639888°;四关节行程分别为旋转 52.171947°、
侧摆 115.878573°、MCP 104.415960°、IP 82.065095°。其余缺测关节保持原 CAD。
完整结构 URDF 已通过 JSON 读回重建、最终 FK(最大差 4.44e-16)和标准 ROS 加载。
训练最大标签误差仍约 8.047 px,独立验证未通过原精度要求,报告如实保留。
原始数据哈希未变,没有实机运动。拇指旋转与 IP 的绝对零位未标为实测修正。
第一次导出发现有效稳态窗口中也存在无同步指令的原始帧,范围证据生成误读了这些帧。
已让正式/草稿共用范围证据函数,并统一通过既有 `fitting_frames` 选择同步完整记录;
保留全部原始日志,不把缺失指令填成零。失败 v1 结果保留,成功产物另存 v2。
## 全手草稿目标与采集后自动导出(历史,已由独立离线入口取代)
按用户要求,完整目标明确为 15 个非末端零位与全部 20 个运动范围;仅五指末端零位
保留 CAD。`correction_scope` 列出目标、实际估计与缺失关节,`full_target_generated`
只在目标均覆盖且文件校验通过后成立。旧局部拇指草稿不等于全手完成。
正常 CAPTURE_COMPLETE 后,启动器关闭 SDK/相机进程、销毁监控节点与自建 ROS 上下文,
刷新日志,然后才运行离线草稿。缺数据或求解失败都只保存结果,不进入补采或归位运动。
每次启动保存受保护 Profile 原始字节快照;后续仅修正安装连杆时可自动匹配旧会话身份。
上述自动拟合行为已在后续收敛中移除:默认采集现在归位、封存、关闭控制后直接退出,
仅写 `capture_result.json`;使用会话 `capture_product.yaml` 和 `--offline-raw` 单独拟合。
运动路线、2+1 分区、一次质量补采及硬件保护保持。配置快照一起进入封存清单。
独立 XML FK 图像软件检查使用当前 ID17 近端指节绑定、原正式采样网格和机位:
单个有界初始化收敛(12 次联合迭代),15 个零位参数均可观测,雅可比秩 463/463;
已知真值的最大零位差 0.001054°。复用这次拟合输出完整 URDF,确认 15 个 origin.rpy
实际改变、五个末端 origin 不变、20 个运动范围写入,最终 FK 最大差 5.55e-16、标准
ROS 加载通过。报告位于 `calibration_output/software_review_20260923_full_draft/`。
这是必要的全手软件检查,不是实机精度结论;没有以合成结果代替完整实机流程。
# 2026-09-23 ID10 调整与断点保留
实机 `20260923_120425` 保存 78 个通过方向(拇指、小指、无名指、中指),食指 MCP
六方向及 PIP 首方向两次都因 ID10 缺图失败,后续方向由操作者中止。原始日志、角点、
配置和哈希记录完整保留;这是中断会话,未宣称全手结束或归位。
操作者最初确认 ID10 仍在食指中节,后补充说明主要调整的是遮挡 ID10 的 ID15,且
ID15 仍在原来的侧摆支座;两者均未更换连杆。新增离线准备草稿断点入口,保留
连续通过前缀,并按 CAD 拓扑隔离受影响手指的历史观测;新数据采用新安装参数,
其他方向的补采预算不变。普通恢复及正式发布门限保持原义;该调整流程的未复核
安装明确标为草稿,不通过正式发布入口。
第一次启动恢复固定基准漂移不超过 0.325 px,但尚未测量的食指正面 ID15 归位位置
相对历史偏移 5.945 px,保护拒绝且未开始正式扫描。修正为整条受影响手指的历史
观测隔离,避免借用旧食指辅助观测绑定新安装,同时不放宽其他标签与固定基准门限。
## 2026-09-23 四指侧摆重采的恢复检查
操作者授权仅重采末项四指侧摆,在独立输出目录保存前 96 个方向的完整前缀,
旧侧摆全部记录保留在原会话,正式剩余计划为 12 个方向。两次实际启动在扫描前
被活动标签基准检查拒绝:`132507` 的 ID4/ID5 差异为 6.416/12.295 px,
`132938` 为 5.182/9.673 px;固定基准最大漂移分别为 0.264/0.219 px。
操作者确认小指标签与支架未调整。相同 SDK 指令及稳定反馈不能独立证明恢复了
相同物理姿态,因此在线不再将活动标签像素差直接判定为安装改变。
原始采集恢复仅将活动标签差异记录为 `requires_offline_review`,继续导入原证据
与尝试次数;不修改原始角点、旧基准或质量结论。固定基准和受保护输入检查仍为
阻断条件,失联、硬件故障及停滞保护不变。离线正式验收保留严格安装核验,并拒绝
带未复核安装记录的数据;草稿允许分析此类数据,但不得宣称精度或安装一致性通过。
本次只运行软件检查及已保存参考的只读回放,机械手未运动,后续由操作者启动采集。
## 2026-09-23 中指补段和四指联动的指令核对
`135113` 的中指向上补段(13:54:05.985–13:54:12.319)保存 625 条实际发送
记录,四指向量由 `[0,0,0,0]` 到 `[0,80,0,0]`;没有中指补段越过 80 的记录。
下一轮联动才继续增加中指指令。联动指令为 `[64,124,64,64]` 时,实际反馈为
`[59,120,0,55]`:无名指未动,而其他手指继续推进。操作者确认存在接触挤压,
因此补段范围正确不能证明同步联动的实体避让安全,现有 4 秒保护也不是同步到位保证。
状态增加子段名称及各通道声明范围,命令/反馈标明当前通道;实际下发日志增加
子段、方向、尝试次数和通道身份。这些只是显示及诊断改动,不改变运动路线、
速度或保护预算,不宣称解决接触问题。三轮/归位/立即补采相关检查 3 passed
(1.48 秒),另核对三轮全部 12 个方向目标及子段显示。未启动机械手,未操作
操作者运行的范围检查 SDK。实际指令审计保存在该会话的 `middle_lower_command_audit.json`。
@@ -0,0 +1,83 @@
# O30 剩余精度问题:封存数据定位结果
2026-09-24。只读复核旧外参全手 `20260923_142201` 和新外参拇指
`20260923_183138`,使用各自封存配置和本轮已冻结的参数。诊断脚本与 JSON 报告位于
`calibration_output/software_fix_20260923_auto_urdf_v2/`。没有修改原始证据、候选 JSON/URDF、
采集路线或精度门限;没有硬件运动。本轮新增的只是只读诊断脚本和这份报告。
## 已确认的三个独立问题
**字节到关节角不能仅靠 URDF 上下限线性换算。**逐字节比较程序生成的 JSON 曲线
与同一 URDF 的线性插值:拇指 yaw 上行在字节 96 的差为 3.167°,IP 上行在字节 128
的差为 2.273°;两端差均约为 0。这解释了为什么扩大 URDF 上限不能修正中间行程。
物理几何仍由 URDF 提供,仿真/ROS 的字节输入必须先经认证 JSON 的对应方向曲线
映射为关节弧度。当前 JSON 精度未认证,不能通过改变加载标志直接用于正式控制。
逐字节结果见 `driver_gap.json`。
**新拇指 roll 的正面图像有重复出现的空间残差。**冻结拟合在训练轮的 thumb IP
标签最大前向误差 7.238 px,验证轮独立测角后最大 7.194 px。训练轮字节 0 的
thumb IP 标签误差中位数约 2.093 px,字节 255 约 7.114 px;MCP 与 IP 标签在
高角度的误差方向接近。高误差在训练和验证轮均出现,继续增加同一模型的优化
迭代没有足够依据。旧全手的最坏观测另在顶部 thumb yaw 标签、thumb MCP 任务中,
最大 16.468 px,不应混作新拇指 roll 的误差。
受限的几何假设检验:冻结其他参数后,仅用新批次训练轮拟合一个相对字节 0
的 CMC roll 转轴平移,得到局部 y≈+0.052 mm、z≈−1.969 mm。用该值回放独立
验证轮,未做每图角度修正的 thumb IP 最大前向误差从 7.672 px 降到 5.261 px,
MCP 从 6.793 px 降到 3.057 px;依旧未通过 1.5 px 图像门限。
这只能说明“共同上游几何变化”可解释一部分误差,不能区分真实转轴位置、
标签随运动的位移或受力形变,更不能据此修改 URDF 的 `origin.xyz`。
试探代码及结果见 `probe_thumb_pivot.py` / `thumb_pivot_probe.json`。
**旧全手参考在新批次的辅助食指端点存在偏差。**新拇指 roll 任务明确令食指侧摆
通道保持字节 0,其他拇指任务为 255。该食指标签在验证轮字节 0 的误差中位数
5.192 px,在 255 时为 1.158 px。只用新批次训练图像试探一个 +0.974° 的
食指字节 0 临时角度修正,验证轮中位误差降到 1.954 px,仍高于门限。
这支持旧参考的端点角度有偏差,但不支持用单个辅助保持姿态改写整条四指曲线。
旧全手原始采集仍少一个训练方向(107/108),旧参考系统误差也没有完整传入
新拇指不确定度。试探代码及结果见 `probe_reference.py` / `reference_probe.json`。
## 观测条件与下一次采集
新批次拇指 roll 的三轮稳态窗口中,顶部相机分别保存了 32、29、37 帧,
**全部只有固定 `top_base` 标签**;随拇指运动的顶部 `thumb_cmc_yaw` 标签没有
一帧有效观测。正面相机同时看到了 thumb MCP/IP 标签。这足以发现投影误差,
却不足以从独立视角确认 CMC roll 转轴的三维位置。
逐帧来源计数见 `thumb_roll_visibility.json`。
下一批采集前的顺序:
1. 在当前拇指 roll 全程的 0、各中间节点和 255,确认顶部或另一独立视角持续
看到**随拇指运动的标签**,并且正面仍可看到 MCP/IP 与固定参考。先用静态
预览确认可见性,避免整手运动后才发现动态标签缺失。ID17 当前固定在
`thumb_proximal`;若能刚性固定在 yaw 子连杆 `thumb_metacarpals_base2` 并保持
全行程可见,可减少 MCP 运动对 roll/yaw 观测的混合,但必须更新标签连杆绑定。
移动相机需要重测外参;只移动标签需要重估其安装位姿。两者均需建立新批次,
不能沿用旧标签安装证据。
侧面机位当前仅声明四指运动标签;若用于 yaw,需要侧面实际可见的拇指运动
标签与固定参考,并接入 Profile 与离线求解。若侧面与顶部共用物理 ID17,
还需让求解器保存各机位观测并共用同一个标签安装参数,不能将两个机位当成
两个独立安装。
2. 对拇指 roll 的端点分别核对两轮方向、反馈稳定、标签角点和刚性;检查字节 255
是否存在遮挡、标签与手指碰撞或安装件受力。当前同一误差在训练/验证重复出现,
应优先排查物理条件和 CAD 转轴,再决定是否扩大几何拟合参数。
3. 若目标是**正式全手 URDF**,在同一有效外参和安装条件下采完整 108 方向的
全手 2+1 数据。旧批次缺失方向不能用新外参的几帧直接拼成已完成旧批次。
新拇指 24/24 可作诊断和拟合初值,但不替代同批全手独立认证。
4. 用新批数据先冻结训练解,再独立验收图像、零位、端点和整段曲线;另行验证
JSON 曲线驱动的 URDF。若确有多视角证据支持转轴位置偏差,再扩展共同几何
模型和 URDF `origin.xyz` 的授权字段,并验证 JSON 回读和最终文件 FK。
当前求解器只导出零位角与限位,增加图像本身不会自动修改转轴位置。
新批次 yaw 字节 16 下降方向的单张正面图像出现 3σ≈1.733° 的测角不确定度。
相同保持指令与反馈下,顶部也有三张动态标签图像。将该保持窗口的正面与顶部
观测作为同一角度的**诊断**时,3σ≈0.230°;时间跨度约 0.242 s。
这表明当前逐张单视角测角方式可能高估该稳态窗口的不确定度,但重复图像存在
共同几何误差,不能简单按独立噪声缩小正式置信区间。联合多视角验收须显式
处理曝光同步与相关误差;即便解决这项不确定度,前述 7 px 图像残差仍会拒绝发布。
数据见 `multiview_angle_probe.json`。
当前程序生成候选继续保持 `accuracy_passed=false`、`publication_allowed=false`;
仿真默认和 `latest_passed` 均未切换。保存的报告保留了每个受影响标签、任务、
方向和样本时间戳,见 `old_residual_diagnostics.json` 与
`new_residual_diagnostics.json`,便于新批次做同项对照。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,119 @@
# O30 标定稳定性评估(2026-09-20)
当前结论:已有精度拒绝和产物发布边界应保留,但现有逐关节准备、立即冻结、
再向下游传递的观测组织方式,尚不足以支撑稳定的一次整手标定。
不能将局部软件回放通过或某个候选精度达标等同于整手稳定。
后续修复进展:已实现准备契约隔离、发布入口预算核验、按漂移调整相机刷新间隔、
启动锚点年龄扣除及辅助观测继承预算前置检查。详见 `O30_RIGHT_CALIBRATION.md`
末节。下文保留评估时的事实和整体目标;不能将这些局部结构修复视为所有重构或
整手实机稳定验收已完成。当前独立范围标定占用设备,尚无新版实机验收结果。
## 最新实机证据
会话 `calibration_output/O30_RIGHT_001/20260920_175156`,复用 12 个扫描单元后,
在小指 DIP 准备阶段以 `OBS-POSE-AMBIGUOUS-117` 暂停。
完整证据为该目录的 `assistant_resume_review.json`、`node_status.json`、
`raw_samples.jsonl` 第 117841 行及三个 `camera_timing_*.jsonl`。
| 项目 | 实测结果 | 含义 |
| --- | --- | --- |
| DIP 准备图像 | 228 帧,114 训练 / 114 留出 | 增加采样已实际生效 |
| 候选 0 轴向不确定度界 | 2.915°,门限 3° | 合格但裕量很小 |
| 候选 1 轴向不确定度界 | 3.035° | 不能发布,但仍能解释图像 |
| 两候选留出 RMS | 0.350 / 0.395 px | 像素拟合接近,不足以直接选三维解 |
| 校正后的比较 p 值 | 0.09375,要求 ≤ 0.01 | 歧义拒绝符合现有契约 |
| 继承 PIP 轴向不确定度界 | 2.274° | 占用下游大部分精度预算 |
| 旁观辅助观测 | 两次 `observer_image_validation_failed` | 本次没有提供可用的额外约束 |
| SDK | 约 29.8 Hz,无报告故障 | 没有证据把直接停因归为 SDK 故障 |
`axis_uncertainty_95_rad` 是现有字段名;实现使用局部线性化协方差的三倍标准差界,
并保守加入继承项。它不是包含内参、标签系统偏差在内的实测绝对精度保证。
## 为什么此前修复后仍会停
1. **改动没有更新整条依赖链。** 密集采样用于新的 DIP 准备;恢复则保留已冻结的
旧 PIP 模型。参考复核仅确认当前安装/姿态可以复用,不会把旧模型重新训练成新模型。
因而恢复成功和下游可观测并不是同一个条件。
2. **单视角平面标签仍有不同三维解释。** 增加同一运动弧上的帧数可以改善随机误差,
不保证增加区分两个候选所需的信息。本次两候选都收敛,不能靠增加求解次数解决。
也不能删掉略超精度门限、但仍可解释图像的备选候选。
3. **辅助观测路径没有预先核算可行性。** `fit_observer_pose` 将父模型不确定度加入
辅助相对姿态的界,再要求旋转不超过 1°。旧 PIP 已有约 2.27°,沿用这个来源时,
即使像素验收过关也无法满足该界。本次实际首先失败于像素验收,不能把两者混为一项。
4. **准备策略的组合复杂度超过已完成的实机覆盖。** 当前存在共享 Tag 交接、父几何
传递、旁观辅助、侧摆辅助及进入姿态联合准备等路径,部分组合显式不支持。
各路径局部回放通过,不构成它们在整手遮挡、回访和断点恢复中的组合保证。
现有基准锁定、证据哈希、训练/留出隔离、禁止失败发布和有界恢复是合理的。
需要调整的是观测规划、冻结范围、恢复依赖和验收顺序,而不是放宽拒绝条件。
### 最新像素的父模型敏感性实验
重新估计历史 PIP 准备模型,并仅在隔离的深拷贝中重算最新一轮 228 帧 DIP 的父姿态,
按相同门限求解:PIP 继承轴向界由约 2.274° 降为 1.574°,DIP 候选 0 的轴向界
为 2.213°,校正 p 值为 0.002227,离线解析成功。相较原实机的 2.915° / 0.09375,
这支持“旧父模型是本次主要限制,必须连同依赖链更新”的判断。
该实验使用历史 PIP 图像和最新 DIP 图像,没有实机重采,也没有重新完成当前父姿态
准入。脚本显式使用离线准入标记,未改变原始记录,不允许发布。
不能据此声称实机下次必过,更不能把重新计算的父模型注入旧正式产物。
结果和复现脚本存于 `software_review_stability_architecture/latest_parent_counterfactual.json`
及同目录 `parent_counterfactual.py`。脚本建立来源注册表时曾两次缺少依赖而被正式入口
拒绝,补齐历史 MCP/PIP 来源后才完成实验;拒绝记录保存在该目录的验证摘要中。
## 独立存在的时钟问题
约 374 秒内,front / side / top 分别出现 33 / 31 / 27 次时钟失效并重同步;
曝光时间序列的最大图像间隔分别约 372 / 338 / 371 ms。
这不是此次歧义判定的直接错误码,但会削弱采集连续性,必须单独消除。
三相机的主机时间与设备计时差在该轮累计同向变化约 201–203 ms,提示应优先检查
共用的主机时间基础。另做 30 秒只读探测,墙钟相对 `CLOCK_MONOTONIC_RAW` 变化
约 -13.9 ms;主机启用了时间同步。这些现象不足以证明历史失效就是 NTP 导致,
也不能据此直接关时间同步或扩大同步容差。
旧日志仅保存成功的锁存和失效原因,没有保存触发拒绝的锁存事务,缺少定位证据。
本次补充了两个独立时钟的事务边界、被拒绝的原始锁存和旧锚点,拒绝时立即落盘。
曝光时间映射、2 ms 锁存界、时钟连续性界和图像新鲜度界均未修改。
对应相机时序测试 12 项通过,耗时 0.33 秒。这是诊断能力修复,不是时钟故障已解决。
## 应采用的流程和实施顺序
1. **先验收基础设施。** 固定本次控制进程的 ROS 发现范围;确保同一设备只有一个
控制者。三相机进行覆盖预期标定时长的连续采集检查,以原始双时钟证据定位并修复
同步异常。ROS 图隔离不能替代物理设备独占检查。
2. **正式训练前完成整条手指的观测准备。** 每条依赖链先检查可见性、运动覆盖、
候选可区分性和完整精度预算,再整体接受准备模型。父模型应有经过数据论证的
下游预算,不能只按自身 3° 门限提前冻结。
3. **独立信息不足时改变预先声明的观测设计。** 优先评估保持其他关节约束的不同
上游姿态,必要时采用经过验证的跨视角共同观测。额外动作需纳入运动计划、
原始证据和独立留出;不得观察留出结果后反复挑方案直到通过。
三台相机存在并不代表当前求解已使用多视角。本轮预检中光学内参和相对外参的
实测状态均为 `unverified`,跨视角方案必须先补足对应验证。
4. **按依赖链管理准备版本和断点。** 准备策略升级或父模型需要更新时,在新会话中
明确重建受影响的父子准备模型及其派生数据;保留无依赖的数据和历史原件。
不得将新父模型与旧角度、旧零位或旧发布证据直接拼接。完全冻结后的训练版本
不能在正式留出失败后局部重训并继续沿用同一留出验收。
5. **统一有限恢复。** 对短暂缺帧执行有界、可回放的局部恢复;基准变化、设备故障、
真正不可观测或证据损坏仍应终止/暂停。恢复必须由具体证据触发,同一信息不足的
运动不能无限重走。启动索引和重求解保持在实时控制回调之外。
6. **最后验证完整交付。** 连续完成整手训练与独立验收,验证 JSON 的证据、数值、
单位和关节覆盖,从该 JSON 重建修正 URDF 并回读核验,最后才更新发布指针。
末节 CAD 零位保留假设必须继续在产物中明确,不能称所有零位均已独立实测。
以上是结构调整的实施要求,并非这些重构已经完成。禁止用新增重试、降低门限或
跳过候选比较来替代观测设计。
## 稳定性的验收定义
- 故障回放覆盖断帧、时钟跳变、SDK 失联、参考移动、候选歧义和恢复版本不兼容;
暂态在规定预算内恢复,其余明确拒绝,均无错误发布。
- 固定代码、配置和现场布置后,至少连续三次完整实机运行无需人工介入,均完成独立
验收并生成可回读的 JSON/URDF,同时报告关节结果重复性。三次是工程验收建议,
不是数学上的成功率证明,也未改写当前产品 `required_independent_passes: 1`。
- 单独验证正常中断后的断点恢复与一次完整运行结果一致;不能仅验证复用单元数量。
- 保留每次失败和软件版本;不能只汇总成功运行或把反事实重放计为实机通过。
当前尚未达到上述验收。本轮没有发布标定 JSON 或修正 URDF,不能宣称流程稳定或
保证下一次一次通过。
File diff suppressed because it is too large Load Diff
+945
View File
@@ -0,0 +1,945 @@
# LinkerHand 多型号统一标定
G20、L6、O6、O12、O30 使用同一个产品启动器、在线状态机、采集器、拟合器、标准 URDF
验收和发布器。型号差异来自 `config/profiles/*.yaml` 和 SDK Adapter,不再调用型号节点。
本包按观测关节冻结实机 baseline 零位,输出实测曲线及明确声明的复制曲线组成的 JSON v3,
以及保留线性 mimic 联动的修正 URDF;报告区分独立实测、迁移和 CAD 零位假设。
保留 CAD 连杆尺寸、轴位置、mesh、惯量。完整运动由 JSON 的全部关节角度配合修正 URDF 计算。
软件仿真通过不等于实机精度通过;当前原始 G20/L6/O12 存在需要确认的 mimic/限位冲突。
本轮实现与验证见 [v3 实施记录](CALIBRATION_V3_IMPLEMENTATION.md)。
O30 右手的构建、相机外参重标和完整操作说明见 [O30 右手标定](O30_RIGHT_CALIBRATION.md)。
O30 当前使用反馈判断运动、稳定及零位恢复;允许指令与反馈存在固定偏差。最终 JSON 仍为指令→视觉角度,反馈映射只作诊断。
测试的分层、选择范围与耗时记录见 [测试说明](TESTING.md)。
## O30 当前默认:轻量采集,离线修正
默认 `raw_joint_2_plus_1` / `raw_joint_images_v2`:移动标签仅保存二维角点,
每任务两轮训练加一轮验证;当前失败方向立即补采一次,仍失败记录缺项并继续。
采完归位、封存数据、关闭控制栈,终态 `CAPTURE_COMPLETE` 不等于 URDF 通过。
后续使用 `--offline-raw` 读取同一批数据修正,失败不再触发运动。
启动、离线命令和协议边界见 [O30 轻量标定说明](O30_RAW_PROTOCOL_IMPLEMENTATION.md)。
以下分阶段和自适应章节为历史显式试验入口,不是 O30 当前默认流程。
## O30 分阶段训练试验
新增显式选项 `--training-policy staged_2_to_3`,该历史策略以 `fixed` 为对照。Tag 数量、编号、尺寸、位置、
相机布局、完整网格、运动速度和验收门限不变。新策略按原任务顺序执行全手轮次 0、全手轮次 1,
进行训练检查;必要时仅集中补一次轮次 2,随后冻结整手模型,最后采集独立验证轮次 3。
局部零位补采包含同轮几何依赖,分段任务整体补采,共享掌部问题扩大到全手。补采后仍不合格即失败。
```bash
# 仅校验配置;不连接、启动硬件
ros2 run linkerhand_calibration calibrate_hand --config \
src/linkerhand_calibration/config/o30_right_product.yaml \
--training-policy staged_2_to_3 --validate-only
```
准备阶段会保留已有上游运动中可见的相关末节 Tag 原始角点及合法候选;缺失旁观 Tag 不阻塞
上游任务。末节保持指令、反馈、参考和独立图像检查全部合格后,测得的相对位姿才能约束后续准备。
上游图像及几何不确定度进入验收;原有图像无法区分的姿态仍拒绝。旧日志不会补造缺失角点。
回访复核原零位后复用原模型。训练进程、任务索引和局部拟合在会话内复用;冻结文件
`frozen_training.json` 绑定配置、源 URDF、计划、输入证据和求解版本。在线最终验收不重新训练;
离线审计仍复算决定。新策略断点仅复用完整任务访问,重试额度跨断点保留,冻结后不回退到训练。
同一会话的 `raw_samples.jsonl` 和 `frozen_training.json` 应一起保留,离线回放须指定相同策略。
两轮全部通过时,扫描单元从 144 减到 108、正式稳态停点从 1390 减到 1052;这不是总耗时降幅。
阶段重排增加回访和参考复核,实机对照须计入这些时间。`stage_timing.json` 区分阶段耗时、开始到
终态耗时及开始前断点加载耗时,终态后等待退出不计入开始到终态指标。独立验证失败不得回炉训练。
JSON→URDF 重建、文件与像素验收、成对发布、失败不更新通过指针,以及五个末节 CAD 零位声明均保留。
软件检查与实机验收分开记录。完整实机通过、人工干预不增加且同条件总耗时至少降低 10% 后,
才能评估切换默认策略;本次软件实现不自动启动硬件。实现与验收说明见
[分阶段标定实施记录](STAGED_CALIBRATION_IMPLEMENTATION.md)。
## O30 自适应训练试验
该历史策略以 `fixed` 为对照:三轮训练加一轮独立验证。新会话可显式选择 `adaptive_2_to_3`:两轮训练后
检查覆盖、稳态网格、双向重复性和可计算的几何零位质量;证据不足补第三轮,第三轮仍不合格
就失败。每个方向的缺样恢复仍最多同速重扫一次,准备缺样仍最多局部恢复一次。
速度、双向运动顺序、节点、准备路径及最终精度门限均保留。
```bash
# 只检查配置,不连接硬件
ros2 run linkerhand_calibration calibrate_hand --config \
src/linkerhand_calibration/config/o30_right_product.yaml \
--training-policy adaptive_2_to_3 --validate-only
# 完成现场准备后,显式开始全新试验会话
ros2 run linkerhand_calibration calibrate_hand --config \
src/linkerhand_calibration/config/o30_right_product.yaml \
--training-policy adaptive_2_to_3 --no-resume
```
旧 `adaptive_2_to_3` 顺序中,O30 掌部共享几何依赖最后采集的四指侧摆。前面的任务无法可靠提前计算零位置信区间,
因此保守保留第三轮;最后一个任务满足全部门限时才减轮。本版完整计划的稳态停点最多从
1390 降到 1340(约 3.6%),不承诺总耗时同比下降。所有任务都省一轮时的 1052 个停点只是
理论下界,该旧顺序下不能实现。上面的分阶段策略在两轮全手证据齐全后统一判断,不受此逐任务调度限制。
采集计划 `capture_plan_v1` 绑定任务、运动分段、准备路径、网格与实际训练/验证身份。
轮次 ID 保持不变:减轮后依次采集 0、1、3,界面显示第 1、2、3 轮;ID 3 始终为独立验证。
`training_decision` 保存增补/冻结/失败决定、输入与计划哈希、冻结局部模型哈希及统计。
几何依赖未齐时明确记录 `deferred`,不能将局部曲线一致性冒充零位认证。
零位仍按真实独立轮次计算 Student t 置信区间,1° 半宽不变;曲线重复性使用既有角度误差门限。
重复图像不会增加独立轮数,验证数据不参与训练、增补决策或模型选择。
最终产物仍依次经过训练冻结、独立验证、落盘 JSON、从 JSON 重建 URDF、图像与运动学回读、
成对发布。Tag 安装拟合及运动范围证据也使用实际训练轮集合。五个末节 CAD 零位假设及已验证
运动范围的精度声明保留;失败候选不会更新通过指针。
旧断点沿用固定策略,新旧策略不能拼接。自适应断点只复用连续完整任务;需要重做某个任务时,
其后依赖旧训练输入的任务一并重新采集。离线复算自适应会话也须传入同一 `--training-policy`。
原始日志与已发布产物不被改写。训练预检在有总时限的独立进程执行,超时或取消会清理子进程。
新会话的 `stage_timing.json` 按控制周期边界汇总准备、几何计算、等待、采样、恢复、断点加载及最终验收耗时。
已有固定三轮训练日志可作只读对照,保留同一独立验证集:
```bash
PYTHONPATH=src/linkerhand_calibration python3 -m linkerhand_calibration.runtime.training_replay \
--raw-samples calibration_output/O30_RIGHT_001/20260919_214048/raw_samples.jsonl \
--source-urdf src/linkerhand_calibration/urdf/o30_right/linkerhand_O30i_right-V2_0819.urdf \
--output /tmp/o30-training-comparison.json
```
此工具复用采集索引,仅比较已完成任务的训练与角度验证,不生成发布产物,也不认证全手零位。
2026-09-19 21:40 会话的 12 个完整任务在两种训练轮数下均通过原独立角度门限;其共享几何尚未
齐全,不能据此批准两轮零位标定。约 2.88 GB 日志索引加载实测约 25 秒,作为后续性能工作的基线;
本次没有改动日志存储格式。
## 启动
```bash
cd /home/lxp/projects/linkerhand_retarget_ros2
source /opt/ros/jazzy/setup.bash
colcon build --packages-select linkerhand_calibration --symlink-install
source install/setup.bash
# 无运动、无 SDK 启动的配置检查
ros2 run linkerhand_calibration calibrate_hand --config \
src/linkerhand_calibration/config/o12_right_product.yaml --validate-only
# 正式启动:自动启动 SDK、相机、检测及标定,READY 后自动开始运动
ros2 run linkerhand_calibration calibrate_hand --config \
src/linkerhand_calibration/config/o12_right_product.yaml
```
其他型号替换为 `g20_right_product.yaml`、`l6_right_product.yaml`、`o6_right_product.yaml`、`o30_right_product.yaml`。
AprilTag 检测参数直接读取产品指定的受保护 Tag YAML,统一启动文件不再覆盖其中的采样分辨率。
`calibrate_g20_right` 是同一 runner 的旧命令别名。不要同时运行 GUI、单独 SDK 或其他控制器。
`--commands-disabled` 只预览,不发送运动;`--no-resume` 强制新采集。Ctrl+C 中止,不自动快速张手。
产品配置的 `resume_mode: passed` 表示操作者确认安装位置未变:只读取已通过的完整任务,
不回放历史图像、不重新判定旧采样质量,也不复核已完成关节的物理零位;未完成任务整组重新采集。
O30 当前使用此模式。配置/设备身份核对、实时保护和最终产物验收仍执行,恢复策略写入原始日志。
默认 `resume_mode: verify` 会逐帧回读校验历史图像;若大断点超过默认 120 秒,可用
`--startup-timeout-seconds 600` 显式延长节点初始化及设备就绪等待。此参数不改变运动、反馈新鲜度、
堵转保护或精度验收门槛,也不会跳过断点基准和关节零位复核。
断点预校验会共享不可变模型的解析结果,并在至少 256 帧时使用最多 4 个独立进程回放图像;
工作进程只做离线计算,在实时采集开始前退出。每帧角点、姿态、样本绑定及原始记录的内容哈希仍完整检查,
不跨会话缓存校验结论;小断点及收尾进程沿用串行校验。
标定节点在启动校验后将长期驻留的对象移出循环垃圾回收扫描,避免大断点触发数秒停顿;
实时新增对象仍正常回收,节点退出后恢复原对象的回收管理。反馈超时保护不变。
重新开始时先安全回基准,仍必须确保现场没有障碍物。
### JSON 到修正 URDF
统一收尾顺序为:拟合与独立角度验证 → 保存标定 JSON → 重新读取 JSON 修正原始 URDF → 文件回读与整链验证 → 发布。
JSON 的 `urdf_correction` 保存原始 URDF SHA256 和必要的关节字段修正,重建不依赖内存拟合对象。
原始 URDF 不被覆盖;关节拓扑、字段授权、完整 mimic 范围和 JSON 查表范围在文件安装前检查。
新 v3 使用 `urdf_correction.schema_version: 4`、`joint_angle_source: calibration_json`,
并记录 `mimic_policy: preserve_source_mimic`:所有型号保留本侧原始 `<mimic>` 元素,
不改来源、倍率、偏移或省略的默认属性;正式标定不再拟合 mimic 参数。JSON 保留各关节
非线性曲线及方向分支,精度验收使用 JSON 提供的全部关节角。父子链、轴和连杆不变,
被动关节与驱动关节仍绑定同一 SDK 通道,所有数值编辑仍按 Profile 授权。
`mimic_envelopes` 单独记录基础范围、原始联动可达范围和导出范围;在明确的实测范围策略下,
导出范围取二者的最小包络,实测范围证据与查找表不变。未声明该策略的 CAD 限位,以及
另行声明的硬件安全限位,仍是硬边界;冲突时报告具体关节和所需范围,不压低倍率或放宽容差。
关节 origin 按标定零位修正后,原始 mimic 作为输出坐标中的近似预览,不作为准确物理约束。
旧 correction v1、移除 mimic 的 v2、拟合线性近似的 v3 产物继续兼容读取与重建。
导出规则单独版本化,不改变采集配置指纹或旧原始记录,已发布产物不能原地覆盖。
已生成的 JSON 可单独重建对应 URDF,无需相机或 SDK:
```bash
ros2 run linkerhand_calibration rebuild_calibrated_urdf \
--config src/linkerhand_calibration/config/o6_right_product.yaml \
--calibration-json /path/to/calibration.json \
--output /path/to/new_corrected.urdf
```
输出路径必须尚不存在。没有 `urdf_correction` 的历史 JSON 不会被猜测或自动补全。
同一 JSON 和源 URDF 可逐字节重建相同修正文件;发布器会核对此项一致性,读取器要求相应验收记录。
历史无修正元数据的已发布文件继续沿用原有读取规则。
完整文件验收失败时保留候选 JSON/URDF 和诊断,便于复核;只有 `release_manifest.json`
和成功发布指针才代表通过,运行时读取器拒绝把未发布候选当作合格标定。
| Profile | SDK 位置通道 | 扫描任务 | Tag | 输出格式 |
| --- | ---: | ---: | ---: | --- |
| G20/right/g20_right_19/v1 | 20 | 16 | 19 | unified v3,256 项指令查表 |
| L6/right/l6_right_8/v1 | 6 | 3 | 8 | unified v3,256 项指令查表 |
| O6/right/o6_right_8/v1 | 6 | 3 | 8 | unified v3,256 项指令查表 |
| O12/right/o12_right_16/v1 | 12 | 11 | 16 | unified v3,rad 输入节点查表 |
任务数、主动关节数和实测关节数不必相等。一条运动可观测多个主动/被动关节。
观测策略由 `measurement.transferred_motion_sources` 声明,目标关节指向直接实测的来源关节:
| 型号 | 独立采集 | 复制的运动映射 |
| --- | --- | --- |
| O6/L6 | 拇指、小指 | 食指/中指/无名指的 MCP、DIP 分别复制小指对应关节 |
| O12 | 拇指、小指、中指、食指 | 无名指 MCP/PIP/DIP 分别复制小指对应关节 |
| G20 / 全关节模式 | 每个关节 | 不声明复制关系 |
复制保留接收关节自己的 SDK 通道、URDF 名称与 CAD 几何,不要求接收关节另贴 Tag 或采集零位帧。
复制参数不等于接收关节独立实测;接收关节仍须有明确的绝对零位来源及合法机械范围。
`zero.cad_zero_assumptions` 显式记录“实机 baseline 与原 CAD 末节零位一致”的使用前提,
相应 DIP(及拇指末节 IP)δ=0、自身 `origin` 不改。这是假设,不是视觉测得的绝对 CAD 朝向。
按用户确认,O12 的 `pinky_pip`、`ring_pip` 和主动 `thumb_mcp` 也采用原始 URDF 零位作为 baseline 假设。
运动曲线仍按实际观测/明确复制关系生成,报告标记 `assumed_source_cad_zero`,不称为独立实测零偏。
正式入口在驱动硬件前检查运动覆盖及零位来源,缺失依据时直接指出缺项,不采完才发现无法发布。
启动检查分别列出 `unmeasured_joints`(未独立观测)、`unresolved_motion_joints`(也未声明复制)
及 `missing_baseline_geometry`。旧迁移字段单独存在不会自动授权新的 v3 曲线复制。
本次沿用现有 Tag 和避让布局声明延后零位动作,没有把新增动作或缺失 Tag 布局标为实机验证通过。
每任务的运动模型采集四个往返,共八趟单程:前三轮训练,第四轮独立双向验证。
四个产品配置统一使用 `command_capture_mode: separate`:这八趟均为完整连续扫描,随后另做一组稳态映射训练往返和一组独立验证往返。
主扫描不逐点停顿;稳态点直接执行移动、稳定反馈、新图像采样,不重复等待两次到位。
稳态训练点由前三轮连续数据生成 `command_sampling_plan`:在原节点网格上寻找最少的
保留节点,各关节、各方向的预测曲线插值变化均不超过 0.25°;端点、基准、中间支撑点及
显式增加的非线性节点保留。缺少完整训练数据或曲线噪声过大时使用原网格。
这份计划只决定停在哪里,每个保留点的静态角度仍由新图像独立测量,不用连续运动角度
冒充静态角度。独立验证网格保持原样,不参与节点选择。计划及训练来源哈希随原始记录保存,
拟合、离线回放和断点恢复均重新核验;缺点、篡改或最终误差超限仍禁止发布。
上一段已经完成到位/稳定检查且下一准备目标完全相同时,可复用一秒内、反馈未变化的
完整保持姿态,省去重复准备等待;有实际位移、反馈变化或记录过期时执行原准备流程。
原训练网格每方向通常为 9 点,并保留行程内的 baseline;O6 小指额外包含 239,共 10 点。
新流程在这份网格上按上述规则精简训练停点。
验证使用交错点加端点(O6 小指包含 247),两类数据不混用。
日志中的运动轮编号为 0–3,独立指令映射训练和验证编号为 4、5;界面分别显示“稳态指令映射训练/独立验证”。
其他采用 `interleaved` 的配置仍在四轮各方向中穿插稳态点,并使用三轮稳态训练、第四轮稳态验证。
行进图像用于旋转/几何拟合,停稳后的新图像用于 SDK 指令映射;同一张图像不会复制成两类样本。
每点至少 3 个稳定同步图像,轨迹结束后最多等待 2 秒,不能用精确指令/反馈相等判断稳定。
关节零位冻结另需至少 10 张独立有效图像。`acquisition.joint_zero_timeout_seconds`
单独限定首张合格零位图像后的采集窗口,四个产品配置均为 5 秒;几何求解后的跟踪恢复仍最多等待 2 秒。
反馈不稳定会清空当前零位候选,但不会重置总采集期限;图像数量、1° 稳定性及分支证据要求不变。
稳定窗口使用每条新收到的 SDK 反馈,按消息时间戳去重,不再只取控制定时器看到的最新一条。
定时器延迟不能制造“独立反馈不足”;期间真实运动仍会使窗口失效。
候选清空时的 `steady_capture_reset` 保存反馈窗口和丢弃样本数量,便于区分抖动、断流和调度问题。
零位样本的 `capture_timing` 记录接收、历史快照和提交时间,用于检查处理延迟。
图像数量未齐时先返回缺样结果,不在控制定时器中反复校验全部姿态。
四轮及稳态采集完成、数据落盘后关闭图像采集;拟合只使用已保存的相机和参考证据。
之后的新图像和相机消息不再改变拟合输入,也不触发采集暂停;SDK 反馈、故障保护及操作者中止持续生效。
拟合或文件验收失败结束为 `FAILED`,保存 `failure_diagnostic.json` 中的失败阶段、原始数据路径和异常堆栈。
这类失败应先离线复算,不能仅因状态失败就重复四轮运动。运动采集期间的固定参考检查保持生效。
O6 的顶部 ID7 与正面 ID2 实际固定在同一末节,Profile 将 ID7 明确绑定到 `rh_thumb_distal`。
`thumb_yaw` 是它承担的侧摆观测角色,不是其安装连杆名称。整链回放包含该 Tag 上游的侧摆、弯曲和末节角度,
保持关节沿用记录的到达方向。新绑定改变了 Profile 哈希,旧会话不会被自动复用为新配置的通过结果。
固定 Tag 的 ID 解码成功不代表边框完整。独立的视觉边框检查进程按完全相同的图像时间戳配对整流图像与检测结果,
检查当前检测四边的黑白边界支持;部分遮挡产生的畸变四边形会被记为 `detection_quality_rejected`,
不能进入 PnP、关节样本或固定基准移动计数。配对缓存有界,不用邻近时间的图像替代。
检查使用当前检测四边形,清晰 Tag 发生真实位移仍进入原有 5 px/连续 10 帧保护。
控制进程只订阅 `detections_checked` 小体积观测;三路大图像的传输、配对和边框处理不占用 SDK 控制节点的回调队列。
ROS 宿主将准备几何求解和逐帧图像角度求解交给独立计算进程,避免与 SDK 反馈争用 Python 执行资源。
准备请求仍限时 120 秒;逐帧请求最多三个在途,超时或工作进程不可用的图像不生成有效样本。
工作进程只接收不可变图像/模型输入,没有 ROS 或 SDK 控制接口。返回后仍检查当前图像、跟踪版本和会话版本,
不能将迟到结果提交到下一运动段。`motion_solver_diagnostic.jsonl` 记录请求、计算耗时、线程 CPU 时间及提交结果。
当前任务需要的固定 Tag 无有效观测时,仍按原采样规则等待或暂停;其他机位的遮挡不误报为移动。
稳态点缺失与连续扫描不足统一在方向结束处理,最多同速重扫一次。
首次关节零位准备、避让及收尾属于独立的必要动作,不计入四个正式往返;不会每轮重新锁定零位。
分段加减速和停点仍有耗时,八趟行程不等于耗时恰好降为原来的三分之一。
新产品会话记录 `capture_schedule_version=unified_schedule_v3_separate_mapping`;旧 `interleaved` 配置仍记录 v2。
采集模式或保护配置不同的旧断点不与新流程拼接,
历史原始数据仍保留原语义供对应配置离线读取和分析。训练/验证身份与精度阈值保持不变。
字节拟合不再改写原始 command/feedback,也不将接近端点的读数伪装成 0/255。
## O6 右手拇指短程诊断
当前 O6 实物在 2026-09-16 复测的 Tag 黑色码区边长为 **16.5 mm**,棋盘格边长仍为
**27 mm**。O6 Profile、检测器配置和标定节点配置已同步采用 `0.0165 m`,产品配置记录
对应文件指纹。该数值属于这套实物标记,不是所有型号或所有打印批次的统一尺寸。
更换标记时应实测黑框,并同步维护上述配置;旧 16 mm 会话保留原尺寸记录,不作为
新尺寸的恢复断点。Tag 尺寸修正不改变 JSON 中非线性运动曲线与修正 URDF 的配合方式。
本套 O6 的 2026-09-16 实采数据已生成通过验收的产物,目录为
`calibration_output/O6_RIGHT_001/20260916_134154_source_mimic_165mm/`,
`latest_partial_passed` 已指向该目录。JSON 和修正 URDF 应配套使用;当前 8 Tag 布局
直接测量拇指、小指,其余三指沿用配置中的曲线迁移关系。采集完成后的在线收尾曾发生
系统 OOM,最终产物由完整原始数据的正式离线流程验收;日志读取内存优化另经独立进程
验证,不能将此记录描述为在线全过程无中断。详细证据见 `CALIBRATION_PIPELINE_REVIEW.md`。
此目录用同一份原始数据重新验收,5 组 mimic 的完整元素与原始 URDF 一致:
拇指倍率 **1.86**、其他四个末节 **0.89**,偏移均为 **0**。
11 个关节的指令/反馈查找表及几何零位保持不变;仅拇指 IP 的 URDF 下限从
−0.115187° 调整到 −0.183816°,覆盖原始联动所需范围,实测范围证据仍为原值。
拇指 SDK 指令 0 时,IP 的 URDF 预览为 **63.68°**,JSON 为 **66.35°**;
这是线性预览与实测非线性的区别。完整 JSON+URDF 和独立原始图像继续满足原精度门限。
`20260916_123347_json_driven_165mm/`(移除 mimic)及
`20260916_124718_mimic_json_165mm/`(拟合倍率和偏移)为历史版本,已被本次发布替代。
旧目录均保留;始终使用同一 manifest 指定的 JSON、报告和 URDF。
定位 Tag 姿态跳变时,可沿用相同产品配置添加诊断选项:
```bash
ros2 run linkerhand_calibration calibrate_hand \
--config src/linkerhand_calibration/config/o6_right_product.yaml \
--diagnostic-thumb
```
诊断选择原 Profile 中的 `thumb_yaw_top` 和 `thumb_pitch_ip_front`,各采一次往返,
观测拇指 CMC yaw、CMC pitch、IP 三个关节。保留原速度、完整扫描行程、准备/避让、
固定基准锁定、按关节尝试零位确认和安全收尾;其余手指不执行扫描任务,但开始时仍会安全回到 baseline。
本次诊断只启动正面和顶部机位:开始时一次锁定正面 ID0、顶部 ID6;运动观测使用顶部 ID6/ID7、正面 ID0/ID1/ID2。
侧面不参与本次诊断,ID3 缺失或双解、侧面相机未连接均不会阻塞;因此无需为拇指诊断调整 ID3 或改动相机外参。
范围由本次全部任务的主/辅观测依赖统一确定,启动、ROS 订阅、设备检查、基准锁定、指纹和进度使用同一范围。
它不会随当前任务切换而重新锁定;正面和顶部的配置变化、固定基准漂移及所需 Tag 数据不足仍按原策略处理。
正式全手标定继续使用 Profile 声明的全部机位和固定基准,外参文件及保护输入的完整校验保持不变。
“短程”缩短的是任务和轮次数,不截断需要复现异常的运动行程。
本模式跳过稳态映射采样与三轮训练/第四轮验收,收尾后显示 `DIAGNOSTIC_COMPLETE`
并正常退出。诊断按当前运动 Tag 的原始图像覆盖推进,持续姿态歧义不阻断原始候选采集;
缺少当前运动 Tag 角点或同步反馈导致方向覆盖不足时,仍最多同速重扫一次,严重故障和中止行为不变。
归零准备证据为 `unresolved` 时,仅诊断模式允许继续收集原始观测:必须先到达 Profile 声明的零位指令,
SDK 反馈稳定,当前运动 Tag 具有足量、带会话和运动版本的图像,才保存独立的 `diagnostic_joint_zero_attempt`。
它表示访问并观察了声明姿态,不表示已建立关节零位;不写入 `zero_references`,方向切换及重扫不重新归零。
该组关节后续仅保存原始角点和候选,即使普通跟踪恢复,也不能生成 `joint_sample` 或伪造有效角度。
缺少零位原始图像、SDK 不稳定、固定基准移动及已冻结参考冲突仍按现有策略暂停;正式标定仍须先确认分支和冻结零位。
会话目录保存 `raw_samples.jsonl`(含角点和 IPPE 候选)、实际成功冻结的关节零位与 `diagnostic_capture.json`。
`diagnostic_observation_frame` 在零位尝试和扫描阶段单独记录各帧候选,包括未接受的姿态,不能当作关节角度样本。
报告 `unit_quality` 分别记录原始观测覆盖与 `accepted_pose_quality` 可信姿态覆盖;
`joint_zero_status`、`missing_joint_zero_references` 列出各关节真实零位状态,`motion_initialization_summary` 保留各候选的关节残差。
进度显示原始观测覆盖,并单列可信姿态数量和未确认关节;暂停时保持“已暂停”,不会被零位动作名称覆盖。
可信姿态不足会在退出时明确提示。相邻帧姿态跳变和图像身份用于定位问题,诊断完成不代表标定精度通过。
每方向的 `observation_timing` 分别统计原始/可见/同步图像数、缺命令与缺反馈图像数、最大图像及同步观测间隔;
缺命令与缺反馈可以发生在同一帧,不能相加当作总丢帧数。少于两帧的间隔记为 `null`。
方向失败时这些信息同时进入暂停诊断。反馈跨度 100% 仅表示两端都有观测,不代表中间没有缺口。
原始帧的 `timing` 保存回调接收、历史快照及最新命令/反馈时间戳,用于区分消息延迟与状态匹配问题;
接收到快照的时间包含线程调度和锁等待,不能单独解释为写盘耗时。未匹配反馈时同步误差为 `null`,不会显示为 0 ms。
可额外加 `--record-bag` 保存现有录制话题;仅检查入口与配置可加 `--validate-only`,不会启动硬件。
诊断始终完整新采,不复用断点,不拟合或发布标定 JSON/URDF,也不更新发布指针。
原始会话带有诊断标记,正式离线拟合和断点发现会拒绝将其用作正式标定数据。
当前选项只开放给 O6 右手;其他型号仍走原来的正式流程。原始 URDF、Profile 和设备配置无需修改。
诊断任务预设在 `profiles/diagnostics.py` 声明,运行时只选择既有 Profile 任务,复用公共调度与采集组件。
诊断采集完成后,runner 先关闭相机和 SDK 进程,再自动运行公共图像几何分析,生成独立的
`diagnostic_analysis.json` 和可直接阅读的 `diagnostic_analysis.md`。耗时优化不进入实时状态锁或相机回调。
分析按任务、机位、方向和尝试分别保留原始图像身份,比较所有候选族的三维 PnP 残差、深度误差比例、
图像域关节链拟合以及隔帧和前后区间验证。验证时几何参数冻结,只从当前图像反解几何角度,不使用 SDK 角度映射。
图像误差小不等于毫米精度通过,优化收敛也不代表分支唯一;分析结果不能写回跟踪器、零位或正式标定产物。
未采到的方向和旧会话缺少的归零前证据会明确列出,不拼接不同会话,不使用第四轮重新拟合。
图像分析失败或被取消时,已完成的采集及原始文件仍保留,不会改标成 SDK 故障。
已保存的数据可直接离线复算,无需启动相机或重复运动:
```bash
ros2 run linkerhand_calibration analyze_calibration_capture \
--config src/linkerhand_calibration/config/o6_right_product.yaml \
--raw calibration_output/O6_RIGHT_001/20260914_192637/raw_samples.jsonl \
--output calibration_output/O6_RIGHT_001/20260914_192637/image_review
```
这些修改解决诊断采集被未确认零位阻塞及报告不完整的问题。现有真实 IP 数据仍有分段几何不一致,
正式分支门槛、拟合器、URDF 修正和独立精度验收保持原要求;软件回归不能替代实机精度结论。
### O6 右手 yaw 方向差保持诊断
需要区分“到达指令后仍在缓慢稳定”和“保持后仍存在方向差”时,使用独立选项:
```bash
ros2 run linkerhand_calibration calibrate_hand \
--config src/linkerhand_calibration/config/o6_right_product.yaml \
--diagnostic-yaw-hold
```
此模式仅选择 `thumb_yaw_top`,只启动顶部机位,使用固定基准 ID6 和运动 Tag ID7。
每次 Start 都重新采集会话基准并尝试确认 yaw 零位,不复用旧会话;同一会话的两个方向共用首次冻结零位,
不会分别归零。未能确认零位时按诊断规则保留原始观测,并明确标记无法生成可信角度。
在原速度和行程下执行一次往返。每个方向扫描后先回到该方向的起点,再沿同一方向访问三个保持点:
递减方向为 `255 → 192 → 128 → 64`,递增方向为 `0 → 64 → 128 → 192`。
每个保持点在指令轨迹完成后至少保持 10 秒,两方向合计六次保持;反馈确认可能使实际等待稍长。
保持过程继续记录图像角点、候选、实际指令和同步反馈,用于比较到达后的角度随时间变化及双向最终差异。
保持阶段的原始观测单独标记,不计入连续扫描的覆盖统计;遮挡、设备故障、中止及安全收尾仍沿用公共策略。
`--diagnostic-yaw-hold` 与 `--diagnostic-thumb` 互斥,不能同时使用离线拟合、发布或禁用运动选项。
可加 `--validate-only` 只检查入口与配置,或加 `--record-bag` 留存现有录制话题。
结果保存在 `raw_samples.jsonl` 和 `diagnostic_capture.json`,记录保持指令及时间;
不拟合或发布标定 JSON/URDF,不形成正式断点,也不算精度验收。
本诊断用于查清实机稳定时间和方向差,不改变最终单值 `angle_rad` 的验收要求。
## 启动前相机检查
`--validate-only` 检查相机配置文件、序列号、内外参指纹和已有外参质量记录,不采集现场图像。
正式启动还检查实时 `CameraInfo` 的宽高及 K/D/R/P 是否与受保护外参中的记录一致;
参数不匹配、无效或消息过期时不能开始,并显示具体机位和原因。
**配置匹配不等于现场内外参有效。** 当前只有手上的 Tag,各机位使用不同 Tag,
没有声明跨机位共视或已知的 Tag 间几何关系;手掌和贴纸又允许在开始前移动、重贴。
因此光学内参与相对外参的现场有效性记录为 `unverified`,不能用重新锁定掌心 Tag、
较小的 PnP 残差或未变化的配置哈希代替实测,也不能由 Tag 位置变化断定相机被碰。
`READY` 只表示设备与消息满足现有开始条件。显示上述能力提示后沿用现有启动行为,
不要求各手指 Tag 可见,不增加人工确认或新的暂停条件;`READY` 不表示物理相机参数已验证通过。
会话目录中的 `camera_preflight.json` 和 `raw_samples.jsonl` 保存检查结果与依据,
开始前仅在结果变化时记录,接受 Start 前再检查并冻结一次;后续状态中的检查结果指这次启动检查。
单凭这些记录不能判定是否需要重新标定。`expected_cameras` 是配置期望身份,不是现场实测结果。
镜头、焦距或成像设置变化后应重新核验内参;相机之间相对位置变化后应重新核验外参。
未来同一个物理 Tag 被多个机位共同看到时,可以检查相对外参一致性,但当前尚未实现该能力。
本次检查不修改 Profile、SDK 或原始 URDF。
## 基准、避让与恢复
- 开始前可以调整手掌、相机和 Tag;预览数据不会成为正式参考。
- 开始后清空预览缓存,先安全回基准,再为每机位固定 Tag 收集至少 10 个有效帧。
缺少基准时保持并显示缺少的 ID,不因为等待时间长暂停。
- 锁定后禁止人工移动手掌、相机、支架、Tag 粘贴位置;程序驱动关节和避让仍是允许的。
- 全手 baseline 时只要求固定参考可见,不要求四指运动 Tag 同时可见。
每关节在 `motion.joint_zero_references` 指定任务前,按声明的到达姿态和避让分组回到自身 baseline,
反馈稳定后收集至少 10 个稳定视觉帧,冻结 `JointZeroReference`。此后方向、轮次、重扫均不重新归零。
例如 G20 食指开始前,已完成四轮的其他手指可以保持非零避让;这些完整指令和反馈写入参考。
task.start 不代表零位,例如侧摆 baseline=127、扫描起点=255,会分别执行两种姿态。
- 相机相对位置改变时须重新确认/标定外参;仅重新锁定掌心 Tag 不能替代外参标定。
- 固定 Tag 被避让遮住时可用已锁定参考继续采集;软件不能保证检测完全不可见对象的移动。
运动 Tag 的刚性和几何一致性仍须通过最终独立验证。
- 采集断点版本为 `unified_engine_v8_all_view_images`;另以 `pose_tracking_policy_version=confirmed_branch_v8_training_model_selection`
记录姿态分支策略。缺少此字段或版本不同的旧断点不能自动或手工恢复到新会话,历史文件保持原样供离线诊断。
首先检查配置哈希与整场固定参考;各关节轮到时,在同一完整保持姿态核对原零位与父子 Tag 位姿
(5 px、2°、5 mm),通过后才导入对应已完成扫描单元,并保留原零位记录。
整场参考不匹配则放弃旧数据;逐关节安装验证失败则暂停,禁止在旧样本上新建零位拼接。
排除原因后用 `--no-resume` 开始新会话。旧会话文件不修改。
旧日志的读取、凭据回放与单元质量检查在实时回调启动前完成,不占用运动/反馈回调。
现场验证通过后,每个控制周期最多导入 64 条历史记录;全部落盘后才跳过对应单元。
导入期间持续处理反馈、固定基准监测和中止,原 1 秒反馈超时保护不变。
连续图像、候选和样本按图像批量追加并刷新到系统缓存,避免持有控制状态锁逐条 `fsync` 阻塞反馈和运动定时器。
固定参考、关节零位、方向完成和暂停记录仍强制同步到磁盘;拟合、报告哈希、发布和关闭前另有同步屏障。
断点只复用通过且持久化的完整方向,意外断电时未完成方向不能拼入旧数据;同步失败不得发布。
此存储策略不改变 50 ms 图像/状态配对门限、方向覆盖门限、拟合公式或精度验收条件。
O6/L6/G20 标定读取 SDK 的 `*_hand_state_timed` 反馈接口;位置与每个通道的 CAN 接收时间
一同保存。G20 五指的分帧反馈分别插值到图像时间,重复发布的缓存不能刷新反馈超时或增加
稳态独立样本。标准 `JointState` 仍可供其他程序使用,其时间戳保留测量时间。
`sdk_feedback_sample` 原始记录支持复查时间配对;旧版本只保存发布时间,不能事后补成真实
CAN 测量时间,也不能与新版断点混用。O12 继续使用独立 HCAN 同步读取接口。
共同坐标约定:`q_CAD = q_output + delta`;每个关节在自身冻结 baseline 时 `q_output=0`。
局部零位通过采样时的完整保持姿态和运动链进入同一掌部参考。固定 Tag 不提供未知末端连杆的 CAD 朝向。
`zero.known_baseline_geometry` 可声明有独立依据的 CAD 角度、误差界与来源;不能填入猜测的 0、旧 mimic
或 Tag 安装偏角冒充几何证据。相应 `origin.rpy` 和限位修正仍须在 Profile 中明确授权。
O12 避让:小指和无名指到 Profile 的最大弯曲指令;标定食指前,中指 MCP/PIP 到最大弯曲指令,
中指侧摆为 0 rad。到位采用完成轨迹、正确方向、至少 80% 请求位移和短时稳定,
不要求反馈数值精确等于命令。SDK 指令最大值与实测 URDF 角度不是同一个量。
O12 使用 HCAN device 0/channel 0 的已锁定 vendor Python wheel,不检查 `can0`。
连接参数由本包的 `config/o12_sdk.yaml` 保存,并通过产品配置中的 SHA256 校验;
不依赖第三方 SDK 子仓库中未提交的示例 YAML 修改。
只读反馈桥可在第一次运动命令之前取得状态,不会发送零位命令来“激活回读”。
POSITION 和活动故障由 SDK 确认;无法单独回读温度时使用 SDK 错误位过热保护,不伪造温度。
字节 SDK 当前没有独立硬件故障遥测,状态会明确注明这一能力边界。
## 哪些情况暂停
实时仅因人工中止、竞争控制器、活动硬件故障/模式错误、真实失联、反馈超过 1 秒未更新、
物理越限、明显运动要求下超过 Profile 的无推进等待时间(O30 为 4 秒,其他型号为 2 秒)、
已锁固定基准连续 10 帧漂移超过 5 px 而暂停。
开始后相机投影参数变化也会拒绝继续使用混合坐标数据。
当前关节持续缺失零位观测会暂停并列出关节、机位和所需 Tag;尚未轮到的手指遮挡不阻塞当前任务。
零位和稳态点的反馈稳定窗口以最新独立反馈为终点,并保留跨越窗口起点的一条反馈。
控制定时器的调度抖动或重复读取同一反馈不会清空稳定观测;真实反馈波动或反馈过期仍会使当前窗口失效。
零位仍需至少 10 帧且姿态波动不超过 1°;拟合验收门限不变。
短时 Tag 丢失、PnP/同步失败、低检测率/低反馈 Hz、正常跟随滞后、固有耦合和非目标小幅运动
不会实时中断轨迹。无效视觉帧直接丢弃。
所有型号的独立 Tag 姿态筛选共用 `core/geometry/pnp.py` 中的近似同误差阈值,默认 `0.03 px`。
ROS 和离线采集使用相同默认值,型号 YAML 不重复声明该值。`1.5 px` 是图像质量拒绝阈值,
不能用它判断两种姿态是否同样可信;仅在误差差值不超过近似同误差阈值时使用时间连续性。
分支容差覆盖值必须有限、非负且小于图像质量拒绝阈值。
独立 Tag 首次接受姿态前,需要默认至少 5 帧、跨越至少 0.1 秒的图像证据确认。
近似同误差的候选只有旋转相差不超过 1°、平移相差不超过 1 mm 时才视为同一姿态;这些参数统一定义于
`core/geometry/tag_pose/parameters.py`,ROS 通过 `pnp_branch_confirmation_frames`、
`pnp_branch_confirmation_minimum_seconds`、`pnp_branch_equivalence_deg` 加载,离线采集使用相同默认值。
这只是图像分支的一致性确认,不能把多帧低重投影误差视为物理姿态真值。
以下独立图像跟踪规则保留用于预览和历史兼容;新会话的运动 Tag 首次零位还必须通过后述公共运动几何确认。
参考冻结前,明显更优但与历史姿态发生大跳变的新分支也必须经过确认,过渡帧不进入零位或拟合样本。
运动 Tag 参考冻结后,若原分支仍有连续、图像质量合格的候选,但另一候选的重投影误差明显更低,
该帧标为 `pose_branch_ambiguous` 并丢弃,内部保留原分支轨迹等待图像证据恢复;
不能仅凭另一候选连续数帧得分更高,就判定冻结参考损坏。不会切换分支、重建零位或把歧义帧送入拟合。
如果持续不存在符合原分支连续性约束的候选,仍在确认后暂停;长期失联后无法确认分支一致时也会明确拒绝。
正式标定方向结束仍检查可信样本、覆盖与稳态点,最多同速重扫一次;诊断模式另报原始观测与可信姿态覆盖。
需要更换分支或重新安装 Tag 时,必须重新开始新会话,
不能把旧冻结参考与新分支数据拼接。原有 0.03 px 容差、35° 跳变、75° 倾角及 1.5 px 重投影质量阈值保持不变。
真实角点噪声、采集时序和最终运动精度仍须通过独立实测验证。
### 首次零位前的公共运动几何确认
新生产会话复用 Profile 已声明的**最后一段归零准备运动**,在 `zero_approach` 中保存父子 Tag 的原始候选,
到 `joint_zero` 后先确认分支,再采集原有的稳定零位帧。没有增加扫描轮次、额外试探动作或型号专用求解器。
几何确认与 SDK 反馈稳定分别检查;几何通过不会提前开启零位采集窗口。
计算在状态锁外进行,期间持续接收 SDK 反馈并保持声明姿态;计算本身有 120 秒上限。
几何确认后,先在原有 2 秒观察窗口内等待恢复后的首个有效零位样本,再用
`joint_zero_timeout_seconds` 限定的独立窗口采齐稳定帧(字段默认 2 秒,四型号产品配置均为 5 秒)。
窗口不会无限延长。正式采集中,每组尚未冻结零位的关节最多自动恢复一次:可恢复的图像
准备失败重新执行该组原有的归零准备路径并采集全新图像;几何已经冻结但零位图像不足时,
保持原姿态和模型,只重新开启一次零位图像窗口。原始失败和恢复动作写入 `joint_zero_recovery`。
已冻结的零位、部分已经提交的多视角几何、保持姿态变化、设备故障不重新拟合;恢复后仍
不足时明确暂停,保留原有反馈、固定基准保护及用户中止能力。
中止、暂停或运动版本改变后,迟到的计算结果不能提交。
前面的避让段、预览、训练扫描和第四轮不参与首次确认。准备段同时核对其他通道的完整保持指令及反馈;
开始后不能改变相机、固定基准或 Tag 安装关系。
生产入口 `core/geometry/tag_pose/production_image_motion.py` 枚举 IPPE 候选家族,
使用父子转动副 `R = exp(theta × axis) R_ref`、`t = pivot + R × mount` 拟合原始角点的重投影残差。
稀疏线性子问题使用 `atol=btol=1e-10`,避免近似计算误差导致外层耗尽迭代预算;
外层收敛条件、默认 150 次迭代预算和各项精度门限保持不变,记录每个候选的求解状态及迭代次数。
父 Tag 本身运动时联合求解整条已声明的观测链;已冻结的上游几何不会重新拟合。
角度来自当前图像,不使用 SDK 到角度的先验、旧 mimic 或 CAD 零位假设选择分支。
单帧 PnP 深度噪声较大时,不再将其三维平移估计直接作为关节运动的真值。
当源 URDF 中两个转轴相邻且平行,并且观测 Tag 实际固定在对应连杆上时,
`profiles/observations.py` 提取零位修正后仍不变的平行关系和垂直轴距。
`CadImageHingeBundle` 消去三个多余几何变量,使准备模型遵守最终 URDF 保留的几何;
不修改原骨长/轴向、不增加权重或 CAD 零位假设。不满足条件的观测链使用原模型。
新冻结模型保存源 URDF 哈希和约束,最终处理从源文件重新推导核验;旧模型保持可读。
修改前的原始图像对照实验及适用边界见 [流程评估](CALIBRATION_PIPELINE_REVIEW.md)。
准备段至少需要 24 帧,训练与留出图像身份分离,默认各最多 72 帧。
训练帧取交错序列,未进入训练的图像才可留出;不因训练帧增加而复用验证图像。
局部几何可观测性检查计入逐帧角度的不确定性,轴线与转心的不确定度界分别不超过 3° 和 3 mm。
候选只按训练图像的平方误差选择;留出数据不用于选优,所选候选验证失败不能改选另一候选。
候选家族还须在准备段留出图像上区分:按运动角度分组,比较与拟合一致的平方误差损失,
差值为 `alternative_rms² - (candidate_rms + 0.03 px)²`,保留原像素容差。
同时通过单侧均值和符号秩检验,再对完整比较集采用 Holm 逐步校正,名义族错误水平保持 0.01。
`image_model_selection.py` 统一负责候选选择、同帧角色对齐、损失比较和多重校正;
诊断记录原始/校正后概率、选择策略和实际源 URDF 约束,失败时也保留这些证据。
缺失、未收敛或无法评估的替代家族不能被当作已排除;几何等价的家族才可合并。
这些检查是图像几何的条件性证据,不是独立毫米精度证明,不能替代第四轮 JSON/URDF 验收。
确认成功后冻结轴线、参考旋转、转心、安装关系及其来源图像。
`image_motion_projection.py` 后续每帧只反解当前图像角度,不重新拟合这些几何参数。
保留 1.5 px 重投影、75° 倾角及单帧角度不确定度检查;信息不足或角度仍有歧义时丢弃该帧,
按现有方向覆盖和最多一次同速重扫处理。35° 连续性和失联后的确认仍由公共 tracker 检查。
Tag 中心接近转轴本身不再是必然失败条件,是否可解取决于全部角点提供的几何信息。
准备段暂时缺帧或候选不确定时,每组尚未冻结零位的关节最多沿原归零路径补采一次;
已确认几何但零位图像短缺时,保持几何和姿态,只重新打开一次图像窗口。
两个恢复方式共享一次额度,保存 `joint_zero_recovery`,不延长成无限重试。
多机位已有部分几何冻结、保持姿态改变、设备故障和诊断采集不会触发重新拟合。
恢复后仍无法确认时不冻结零位、不发布;已有零位和旧数据不能被重新解释后拼接。
`motion_branch_observation` 保存准备段图像、角点、候选及 SDK 状态;`motion_branch_initialization` 保存各假设残差、
冻结模型、来源图像和内容哈希。新姿态来源为 `image_constrained_current_image`,保留原始 IPPE 候选,
同时记录 `frozen_image_model` 与 `image_model_sha256`。每个运动 Tag 的 `motion_evidence_id` 绑定首次证据,
`JointZeroReference.branch_references` 将它与零位、机位和分支版本关联。
正式读取与收尾校验这些关联,缺少证据、篡改来源或删除表头均不能降级为旧图像确认;
`motion_branch_provenance.json` 只证明证据链完整,不是精度证书。
恢复时共享来源只导入一次,关节零位及历史验证记录在该关节实际验证后导入,支持恢复期间再次中止。
正式回放用同一冻结模型复算同帧角点,核对父链、相机、Tag 尺寸及实际提交姿态,不重新拟合模型。
旧 `confirmed_branch_v1/v2/v3` 及 v4 三维候选约束记录仍按原语义读取,不能自动升级或恢复成新 v5 证据。
`motion_evidence.py` 的旧三维筛选入口保留供历史兼容和诊断。这里 v5 仅指姿态策略,最终标定 JSON 仍为 schema v3。
真实 O6 `20260914_175815` 的旧三维筛选只读回放中,yaw 与 pitch 分别存在唯一通过几何条件的家族;
但 pitch→IP 的四种父子组合全部超差,IP 转心 RMS 约 4.9–11.4 mm,完整拇指链仍为 `unresolved`。
这批图像来自旧零位冻结之后的单轮诊断扫描,只能用于反事实回归,不能代替新准备段证据、
覆盖旧零位或证明 JSON+URDF 精度。
可复算摘要见[本次分支修复回放](../../calibration_output/O6_RIGHT_001/20260914_175815/motion_branch_fix_review/README.md),
测试夹具同时保留“yaw/pitch 可分”和“IP 不得强行通过”两类断言。
后续 `20260914_200044` 的图像求解回放使用其真实归零准备段冻结模型,后续 838 帧可复算通过当前图像检查;
这仍是单轮诊断回放,不是完整四轮精度验收,也不会生成正式 JSON 或 URDF。
`20260915_104446` 正式实采中,拇指三个关节各冻结 10 帧零位并完成四轮,
pitch/IP 的旋转及指令单表子项均通过;yaw 旋转通过,但指令单表 MAE 为 1.0454°,超过 1°。
两场独立会话均观察到稳定方向差,该批观测即使允许每个指令任取一个输出角,MAE 下界仍超过 1°。
这不是通过重新拟合单表能够消除的误差;方向分支的诊断通过也不能冒充单表通过。
详见[拇指实测子项报告](../../calibration_output/O6_RIGHT_001/20260915_104446/motion_holdout_review/README.md)。
后续 `20260915_111401` yaw 保持实测在 64/128/192 各双向保持 10 秒;末 3 秒的方向差
分别为 2.2965°/2.7885°/2.1248°,每个窗口均有 90 帧可靠观测,同一目标的 SDK 反馈相同。
0.5–1 秒至末段的角度变化最多约 0.038°,延长等待没有消除方向差。
本轮仅启动顶部机位,不能据此确定具体机械原因或证明整手精度;继续保留单值 JSON 与原验收门槛。
复现脚本、时间曲线与来源哈希见[双向保持实测](../../calibration_output/O6_RIGHT_001/20260915_111401/yaw_hold_review/README.md)。
`20260915_104446` 会话在小指准备求解超时后停止,未完成整手空间/FK 验收、未发布 JSON/URDF。
小指同源准备段离线求解只需约 3.42 秒,但候选仍未达到统计区分要求;运行时超时来源尚待定位,
详见[小指准备段核查](../../calibration_output/O6_RIGHT_001/20260915_104446/side_preparation_review/README.md)。
后续会话另存 `motion_solver_diagnostic.jsonl`,记录请求来源哈希、版本、求解耗时和提交接受/拒绝,
以区分求解未返回与迟到结果被丢弃;该文件不参与测量授权,也不修改已冻结的原始记录。
此诊断接线在上述实采之后添加,不能补证该次超时原因。
固定基准另有明确的静止约束:锁定后始终使用首次冻结的相机局部姿态,将其投影到当前角点验证,
不因 IPPE 两支重投影误差排名反转而更换姿态或暂停。仍执行原有 1.5 px 重投影和 75° 倾角限制,
并保留当前 IPPE 候选与原有角点漂移监测。当前图像无法验证冻结姿态时丢弃依赖该基准的观测,
记录 `fixed_reference_pose_unverified`,不将几帧图像质量不合格判为不可恢复的姿态分支冲突。
恢复须默认至少 5 个合格帧且跨越至少 0.1 秒,始终核验同一冻结姿态,恢复完成前也禁止缓存兜底。
尚未使用该机位的动作可以继续;当前任务持续缺少有效数据时,仍按原有零位超时、稳态采样和最多一次同速重扫处理。
固定基准实际连续 10 帧漂移超过 5 px 仍使整场暂停,包括当前任务未使用的机位。
此约束仅用于 Profile 声明的 `fixed_reference`,
不用于随关节运动的 Tag,也不证明初始分支物理正确或当前外参已经实测验证。
方向结束才检查 ≥40 个有效同步样本、≥32 分箱、内部空白 ≤行程的 1/16、规定的有效行程覆盖,
以及 Profile 声明的端点/双视角观测。数据不足只同速重扫一次;第二次失败暂停。
拟合或最终文件验收失败不重新自动运动、不更新发布指针。
`independent_rotation_holdout_failed` 表示前三轮冻结旋转模型未通过第四轮检查,发生在空间求解前。
错误原因包含失败关节、机位和 MAE/P95/最大误差;`fit_diagnostics.json.rotation_holdout`
另存完整 SO(3)、轴向、偏轴统计及最坏图像身份。验收使用完整旋转误差,不能仅凭投影角度曲线重复判定通过。
新采样行的 `pnp_observation_evidence` 保存父子 Tag 的当前角点、尺寸、已选姿态和候选诊断,
相机矩阵引用同会话的 `rectified_camera_model`。缓存固定基准明确标注来源,不伪造当前帧的角点或候选。
可见且通过冻结姿态投影验证的基准标为 `verified_fixed_reference`,保存当前角点、IPPE 候选和实际投影误差;
完全未检测到时的缓存仍标为 `cached_fixed_reference`。候选回放保持同一冻结基准并复核前者的当前角点证据。
短程诊断报告以 `fixed_reference_summary` 单独统计基准的当前图像投影误差,
不会把固定输出姿态的零跳变误当作独立测量结果。
不合格姿态事件另外记录当时的动作阶段、指令与同步反馈,便于区分准备动作期间的异常与正式采样异常。
角点与候选记录用于诊断;其中分支状态、版本及实际选中姿态也用于核验冻结参考的一致性,
不使历史采集自动获得缺失的观测证据。O12 的轨迹几何评分仍只使用训练轮,
但正式收尾不得用评分更好的另一支替换已冻结的训练或第四轮姿态;超过同一公共等价界时明确拒绝发布。
历史记录仍可在离线诊断入口分析候选,不能借旧格式标记绕过正式产物的冻结分支检查。
所有型号显示相同进度:阶段、任务/轮次/方向、分机位 Tag ID、命令/反馈/单位/速度、
有效数据、基准、断点来源,以及中文原因、建议和原始诊断。
ROS 收尾拟合在独立进程执行:只读取采集结束时已同步的日志前缀,后续图像不进入拟合;
主进程继续接收反馈并检查固定基准,阶段事件按原顺序回传,最终发布仍由主进程持锁完成。
中止时终止计算子进程,不允许子进程更新发布指针。`finalization_timing.json` 保存实际耗时。
标题明确显示型号、左右手和序列号。开始前等待状态列出缺少或过期的相机内参、检测消息和 SDK 条件;
空 Tag 检测消息可以证明视觉链路工作,不要求开始前已经识别全部 Tag。
收到开始请求时重新检查设备状态;实时 CameraInfo 参数必须与受保护外参中的指纹一致。
开始前相机消息的新鲜度窗口为 2 秒,该窗口不用于扫描中的实时暂停。
## 唯一生产代码链
```text
linkerhand_calibration/
├── config/profiles/ 唯一型号定义(受保护 YAML)
├── config/*_product.yaml 设备、序列号、相机、路径与哈希
├── urdf/ 原始 CAD/mesh,不覆盖
└── linkerhand_calibration/
├── profiles/ 加载、静态验证、Tag/CAD 观测关系
├── core/domain/ Profile、样本、CalibrationResult、状态
├── core/geometry/ 相机、变换、PnP 兼容导出
│ └── tag_pose/ IPPE、连续跟踪、冻结前运动证据、刚性组和轨迹选择
├── core/fitting/ 运动曲线、线性耦合、固定安装
│ └── spatial_solver/ 空间观测、基座、零位、统计与独立验收
├── core/urdf/ 授权修正、FK、范围与最终文件验收
├── runtime/runner.py 唯一产品启动器
├── runtime/coordinator.py 会话协调、采集提交、运动和收尾接线
├── runtime/parameters.py 已解析的运行参数;无 ROS 类型
├── runtime/inputs.py 相机/检测输入、时钟与发布接口
├── runtime/cameras.py 受保护相机身份与消息新鲜度
├── runtime/camera_preflight.py 启动前配置检查结果与内外参实测能力边界
├── runtime/session.py 唯一在线状态机
├── runtime/execution.py Profile 任务与运动效果调度
├── runtime/motion_execution.py 平滑轨迹与位移到位判定
├── runtime/capture.py 多机位视觉/反馈采集
├── runtime/branch_initialization.py 最后归零准备段证据与一次性几何确认
├── runtime/branch_tracking.py 冻结约束选候选,原子提交同帧姿态与证据
├── runtime/motion_provenance.py 读取和收尾核验来源内容、零位与样本关联
├── runtime/scan_quality.py 唯一方向数据门
├── runtime/reference_lock.py 开始后固定基准
├── runtime/resume.py 整场断点证据检查
├── runtime/joint_resume.py 逐关节原零位验证后导入旧样本
├── runtime/safety.py 最小实时保护
├── runtime/status.py 统一状态与中文显示
├── runtime/snapshot.py 类型化状态快照与旧状态映射
├── runtime/adapters/ SDK 协议,不包含标定业务
├── runtime/ros/ ROS 参数加载、消息转换、订阅/服务/定时器
├── runtime/artifacts/ 收尾控制、后台 finalizer、serializer、原子发布
└── compat/ 历史格式、布局与离线诊断;不提供旧在线流程
```
`calibrate_hand → runner → runtime/ros/entrypoint → UnifiedCalibrationNode → CalibrationCoordinator → SessionExecution`
是所有型号的生产调用链。节点直接继承 ROS `Node`,组合 `RosCalibrationIO` 和协调器,
不通过父子类业务回调执行标定。SDK 绑定接收明确的反馈新鲜度、时钟、健康订阅和发布接口。
空间入口 `core/fitting/spatial.py` 保留显式兼容导出,计算顺序在
`spatial_solver/solve.py`:输入准备 → 仅训练求解 → 可观测性/轮间统计 → 冻结结果的独立验证 → 结果装配。
`TrainingProblem` 不包含第四轮观测,训练几何中的掌部姿态也按训练轮筛选。
基座策略由 Profile 的几何约束选择;曲线、零位坐标约定、权重、阈值和原始 URDF 授权修正规则不变。
PnP 的原导入路径继续可用。`tag_pose/parameters.py` 集中定义跟踪默认值,
ROS 加载器将角度参数由 deg 转为 rad,离线采集直接使用同一套默认值。
公共分支容差仍为 `0.03 px`,重投影质量和倾角限制保持一致;首次确认、参考冻结与失联恢复
使用同一公共分支策略,ROS 和离线链路均不能绕过它。
`CalibrationSession.phase` 是流程状态来源。协调器在状态锁内开始、中止、推进和提交观测;
耗时姿态计算在锁外进行,提交时核对会话版本、采集对象、运动对象及稳态采样边界。
Start 更换预览采集对象并重新锁定基准,暂停/中止使尚未完成的旧观测失效。
收尾由 `FinalizationController` 组合 worker 和发布器,worker 只上报阶段事件、准备候选文件;
协调器核实全部验收阶段后才能提交。锁顺序固定为状态锁 → worker 锁,
拟合失败维持当前姿态,中止后禁止提交,成功产物只提交一次。
老的单相机拇指/CMC 在线节点和 launch 已退出安装入口;需要局部测量时应增加经过验证的 Profile,
不能重新启用旧状态机。老版本离线诊断不是新的标准 URDF 发布证据。
## 新增型号
必需文件:
```text
config/profiles/new_hand.yaml
config/new_hand_product.yaml
urdf/new_hand/raw.urdf
urdf/new_hand/meshes/*
```
Profile 声明:
1. Adapter、SDK 通道顺序、单位、正负方向、命令/反馈范围、基准值、独立速度槽布局。
2. Tag ID/尺寸/机位/安装 link,以及每次运动的父子观测关系;不明确的 link 不能靠名称猜。
3. 扫描任务、速度、准备/避让/收尾 waypoint;每关节首次零位任务、完整保持指令及固定到达姿态。
4. 声明空间可观测约束、已知 baseline 几何或明确接受的 CAD 零位假设;每个运动关节须独立观测或显式指定对应实测来源。
5. 每关节允许修正的 `origin.rpy`、`limit.lower/upper`、`mimic.multiplier/offset`。
这仍需要正确的可观测性设计:单轴加一对未知安装角的 Tag 不能自动辨识绝对 CAD 零位。
不能为了减少配置,默认把 SDK 零点或最大指令当成 CAD 零位。可参考最简单的 L6/O6 Profile,
删除不适用关系后重新声明;不要盲目复制另一型号的空间约束。
复用 SDK 不增加 Python;新协议只增加 `runtime/adapters/` 实现及协议工厂注册。
没有型号节点、runner、状态机、质量/断点/发布器;新数学只能增加通用拟合原语。
新型号使用 `artifacts.output_schema_version: 3`;产物为
`format: unified_calibration_v3, schema_version: 3`。详细双映射保留在独立标定报告中。
旧 generic v1、v4/v6/v7 只保留历史兼容工具,不把反馈表改名为指令表来伪造兼容。
v1/v2 按原语义读取,不会自动升级为独立实测;保留的 v2 数学入口用于历史回归,不能证明 v3 精度。
配置错字、错误绑定、未知修正字段在启动前失败;修改受保护文件后应审查并更新对应 SHA256。
## 产物与离线验证
会话目录保存原始样本、冻结 Tag 安装、拟合诊断、标准 URDF 验收、JSON、URDF。
有效发布由 `release_manifest.json` 及产品发布指针共同证明;单独的 `PASS` 摘要或存在 URDF 文件不等于已发布。
manifest 记录 JSON、URDF、标定报告三文件 SHA256、受保护输入和独立 holdout 证据。任一验收失败都不会替换原发布指针。
`measurement_contract.json` 提前列出 Tag/link、零位观测关系、CAD 保留/迁移与原始 mimic 限位冲突。
主 JSON 以原始 URDF 关节名为键,单值关节包含 `sdk_channel` 和 `angle_rad`;
O12 等 rad 输入还包含一一对应的 `input_values`(在有效节点间线性插值,不外推)。
字节型号每条曲线固定 256 项:SDK 指令 `v` 对应所选曲线的第 `v` 项,只能在训练指令实际覆盖 0–255 时导出。
输出均是修正 URDF 的 q_output,不再加零偏。顶层记录身份、单位、完整 `baseline_command` 和共同零位约定。
独立观测的主动和被动关节直接导出各自视觉曲线,同一驱动通道可绑定多个关节。
显式复制的关节使用来源关节的同一组节点与角度,不重新拟合、归零或由 mimic 推导;接收通道保持不变。
默认单值曲线在指定的稳态训练数据中约束 baseline=0,正反向诊断分支共用冻结参考、保留回差。
独立稳态验证分别检验两个方向;超差时禁止发布,不隐藏切换分支。
O6 侧摆 `rh_thumb_cmc_yaw` 通过 `artifacts.directional_command_joints` 显式启用双向指令曲线。
v3 JSON 顶层保存同名声明,所列关节额外包含 `increasing_rad` / `decreasing_rad`;
方向指原始 SDK 数值增加/减少,与关节角度正负无关。其他关节继续使用单值表。
两条曲线分别只用所声明的稳态训练数据拟合,共用已冻结零位、各自满足 baseline=0;
独立稳态验证按实采方向读取最终序列化曲线,仍要求 MAE≤1°、P95≤2°、最大≤3°。
报告分别保存运动与指令映射的训练/验证分区及来源图像,限位推导仍不得使用任一验证分区。
`angle_rad` 保留为均值参考;双向关节在非 baseline 输入且方向未知时禁止用均值代替。
读取器须从 baseline 初始化,停住时保持到达方向,允许端点换向;行程中途反转尚无小回环标定证据,会明确拒绝。
字节指令的方向判断与查表共用 `floor(v+0.5)` 量化规则;连续小数步进不能绕过方向切换检查。
两条主回环曲线不构成任意反转、负载或多轴工况的精度证明。旧读取器会拒绝新增关节字段,须同步更新。
修正 URDF 保留原被动关节 `<mimic>`,单独拖动父关节滑条时,末节按
`q_child ≈ multiplier × q_parent + offset` 联动。标准 URDF 不能将查找表表达为 mimic。
前三轮成对视觉角度用于拟合线性近似,优化变量为父关节两端对应的子关节角度;
两端都限制在子关节原有输出范围内,因此整个联动行程合法,无需放大限位或裁剪小负角。
偏移是近似直线在 baseline 的误差,不能当作新的物理零位;报告明确保存
`baseline_error_rad`、`physical_zero_correction: false` 和独立留出误差。
JSON 指令/反馈曲线、几何零位不因近似拟合改变。需要实测非线性运动时使用完整 JSON,
消费程序显式设置全部关节角,并避免 URDF mimic 再次覆盖末节角度。
原始 CAD 限位默认仍是硬边界。Profile 可逐关节显式声明 `calibrated_motion_envelope`,
将该 CAD 限位视为本次要测定的运动范围;只有已授权的 `limit.lower/upper` 可据此修改。
O6 右手采用此策略,范围由前三轮运动反馈映射、独立稳态训练映射的实测包络及共同零位确定,
不增加安全余量,不用第四轮扩展范围,也不为容纳 mimic 越界而放大范围。
SDK 安全指令域不变,IP/DIP 的 CAD 零位不变。复制关节的范围证据仍属于来源关节。
报告和 manifest 的验收数据保存 `motion_range_evidence`,包含来源限位、零偏、训练轮次、
样本身份与新范围,并在收尾重新验证。O6 尚无独立机械安全角度界的实测证据,
该策略不代表对碰撞、过载或任意多轴姿态作出安全认证。
验收分别记录:最终 JSON 对独立稳态验证角度;完整 JSON+URDF FK 对独立验证 Tag 位姿;
仅使用 URDF mimic 的近似损失。前两项决定实测精度通过,第三项如实报告,不冒充非线性精度。
manifest 同时保存 `urdf_joint_angle_source: calibration_json`、`urdf_mimic_policy` 和两种回放结果。
门限保持角度 MAE≤1°、P95≤2°、最大≤3°,Tag 位置 P95≤3 mm。
平行转轴的零位计算先消除轴上参考点的任意坐标,再比较相机平面中的轴间方向;
改变同一轴线的表示点不能改变标定零位。这是所有型号共用的几何规则。
独立精度结论只覆盖实际观测的关节和 Tag;复制关节另检查来源、查表一致性、通道与机械范围,
不把来源关节的第四轮误差称为接收关节精度。混合产物 manifest 使用
`measured_and_transferred_json_urdf_v3`,列出实测和复制覆盖。
FK 使用训练冻结的安装与基座;第四轮不拟合零位、曲线、Tag 安装或基座。
正式图像采集还要求核验第四轮及独立稳态验证的原始 Tag 四角,沿用冻结的安装、基座
和受哈希保护的相机参数。主观测的独立三维位姿必须解释同帧原始角点,四角重投影 RMS
不得超过原 1.5 px;最终落盘 JSON+修正 URDF 与独立位姿的差异仍按上述角度和位置门限检查。
最终 FK 的总像素误差单独报告,不能将包含允许运动误差的总量等同于图像测量噪声。
额外机位没有独立三维测量时,仅检查是否存在满足原物理误差范围、且重投影误差不超过
1.5 px 的局部刚性位姿。这个数值见证不是真实位姿测量,不写回安装、零位、曲线或相机;
各运动任务分别检查,不能用其他任务中的静止图像稀释误差。它不能替代必要的独立三维验收。
缺少独立测量或原始图像、相机身份不一致、测量像素或物理误差超限均不能发布。
失败时保留 `final_image_diagnostics.json`;通过后在 manifest 记录 `final_file_image_holdout`
及规则版本 `independent_measurement_and_physical_consistency_v2`,分别列出独立精度与额外视角相容性。
训练阶段若 Tag 与整条运动链不满足刚性一致性,`tag_installation_diagnostics.json`
一次列出全部 Tag 的角度/位置 P95、门限和通过状态,不只显示遇到的第一个失败 Tag。
该失败不能直接归因于硬件松动,也不能作为自动重做整场采集的依据。
`calibration_report.json` 保存完整的 `command_to_rad` / `feedback_to_rad`、输入域、方向分支、
实测关节的零位原始参考帧、完整指令/反馈/到达方向、来源和训练/holdout 指标。
复制关节记录 `independently_measured: false`、`transferred_from_joint` 与来源参考链接,
不生成自己的伪零位样本或独立精度指标。绝对零位来源另由 `zero_method`、
`cad_zero_assumption` 或 `zero_transferred_from_joint` 表达。报告中的 `applicability` 记录保持姿态;
未采集多姿态证据不能宣称任意多轴工况已验证。主 JSON 用于指令查表,不能误作反馈转换。
旧 unified v1 会话继续只读兼容,不自动覆盖或转换历史产物;新输出单表必须重新通过最终文件验收。
统一消费桥可直接读取通过验证的 manifest(纯消息转换,不会发送实机命令):
```bash
ros2 run linkerhand_calibration calibrated_joint_state_bridge --ros-args \
-p calibration_file:=<session/release_manifest.json> -p input_kind:=command \
-p input_topic:=<SDK原始指令话题> -p output_topic:=/calibration/verification/joint_states
```
需要反馈角度显示时显式选择 `input_kind:=feedback`,桥读取配套报告中的反馈映射。
桥检查三文件哈希、拒绝越域输入,
v3 全部关节角由 JSON 显式计算,不加第二次零偏。完整 FK 使用
`independent_mimic_angles=True`,以 JSON 给出的末节角度覆盖线性近似。
标准 `robot_state_publisher` 和普通 URDF 网页查看器会执行 mimic,适合检查近似联动;
仅把完整 JointState 发给标准 RSP,不能让它停止覆盖被动角。
消费实测非线性曲线的仿真程序必须明确采用 JSON 角度,关闭重复的线性被动更新。
仿真输出不能与实机反馈话题同名。
MuJoCo 是用户后续使用的外部验收项目,本轮不改动它、不增加 MuJoCo 依赖,自动标定不要求打开 GUI。
外部对比须确认加载的模型来自这份 URDF,指令转换使用配套 JSON;不能用实机反馈直接驱动仿真来证明指令映射。
现有 MuJoCo 脚本尚不能视为支持 v3:后续须消费完整关节映射并停止冲突的线性被动更新。
```bash
# 同一算法离线回放新策略的完整采集;不启动硬件,默认不发布
ros2 run linkerhand_calibration calibrate_hand --config <product.yaml> \
--offline-raw <raw_samples.jsonl> --offline-output <新的输出目录>
# 只比较两份 URDF,不修改,不控制机械手
ros2 run linkerhand_calibration compare_calibration_urdfs \
--reference <参考.urdf> --candidate <本次.urdf> --output <新报告.json>
```
离线回放也检查原始会话策略、SDK 输入域、配置/相机证据、每方向完整数据及最终文件。
旧会话不能自动获得当前哈希或新版本认证。`--publish-offline` 必须显式给出,且不会绕过验证。
当前坐标规则、生产职责与验收范围见 [v3 实施记录](CALIBRATION_V3_IMPLEMENTATION.md)。
早期需求审查见 [2026-09-10 需求审查](CALIBRATION_CODE_REVIEW_20260910.md);
当前修改依据、实机结果与未完成的精度验收见 [流程评估](CALIBRATION_PIPELINE_REVIEW.md)。
相机和反馈目前均采用下述测量时间;时间配对正确仍须经过独立空间验收。
### 实时调度边界
公共 ROS 节点顺序处理控制和反馈消息;耗时图像估计由每视角有界工作队列执行。
图像最多在原同步容差内等候真实反馈时间覆盖,队列不反向持锁查询会话。
增加执行器线程数不能提高共享状态的处理能力;反馈超时仍按真实测量判断。
暂停或验收失败的会话仅提供诊断/候选文件,不能当作已发布的标定结果。
调度对照依据与验证范围见 `CALIBRATION_PIPELINE_REVIEW.md` 第七轮评估。
### 相机与反馈时间
连续扫描将 CAN 实测反馈与相机曝光中点配对。MVS 取帧/图像发布时间不参与替代曝光时间;
设备时钟由独立锁存换算到 ROS 时钟,换算依据保存在会话的 `camera_timing_<view>.jsonl`。
运行中的时钟校验失败会暂停出图并重新执行独立锁存校验,通过后恢复采集;校验失败期间不会继续使用旧映射,也不会永久退出采集线程。恢复前后的图像时间戳仍须严格递增;若主机时钟向后跳变,早于已发布图像的帧继续丢弃。
四型号共用 `unified_engine_v8_all_view_images`,保留设备曝光时间策略;旧采集只能按其原策略诊断,不能混用。
报告中的 `feedback_u8` 表示来自字节通道的测量值:插值到曝光时间后可以是小数,
拟合、内存映射和报告读取均按声明的分段线性曲线计算,不再次取整。
`command_u8` 仍按实际发送的整数字节查表;旧版整数表保留原读取规则。
相机时钟修正不会将被动关节的非线性曲线转成 URDF mimic;仿真仍须读取 JSON 查找表。
### 同一运动中的所有机位原始角点
四轮扫描、必要稳态点和关节基准采样期间,所有机位均保存 `image_observation_frame`。
记录包含曝光时间、完整指令/反馈及方向、轮次与尝试编号、相机矩阵和可见 Tag 的二维角点。
它经过原检测质量与采集窗口检查,但不依赖 PnP 分支或任务的主机位。
缺少同步反馈时保留明确的空值,不能将该帧当成有效角度测量。
例如 O6 顶部 ID7 与正面 ID2 属于同一末节:弯曲和侧摆时两机位的可见角点都应保存,
以便检查整条运动链。其他机位的记录不会增加停点或运动次数,也不会替代主机位的采样覆盖要求。
同机位重复帧不重复入库;暂停、切换运动和结束采集时仍按同一会话身份撤销迟到数据。
最终 JSON/URDF 的图像验收使用通过采集检查的第四轮和独立稳态验证窗口内全部机位角点。
主观测必须能与原始图像记录逐项对应,同一图像不能重复计数;其他动作下的有效角点也直接
投影验收,不先拟合成三维位姿。缺少主观测的原始记录或二者 SDK 条件不一致时拒绝发布。
这不表示联合求解或外参自校准已经通过验证。
## 低精度离线修正草稿(O30)
最新程序修复、两批实采数据验证及未通过的精度项目见
[O30_AUTOMATIC_URDF_FIX.md](O30_AUTOMATIC_URDF_FIX.md)。
当前目标是 **15 个非末端关节零位+20 个关节运动范围**;拇指 IP 与四指 DIP 的五个
末端零位保留 CAD,其运动范围仍由数据拟合。完整全手数据可提供局部拇指缺少的掌坐标
约束;只采部分任务时,不能将局部输出当作上述全手目标已完成。
O30 原始采集正常结束后,启动器完成归位封存、关闭 SDK/相机及控制上下文后退出。
`capture_result.json` 只报告采集结果,完整返回 0、缺项返回 3,不自动拟合。
每个新会话保存可供离线使用的 `capture_product.yaml`、Profile、源 URDF、内外参和
标签配置快照,并随原始证据封存。离线失败不改变采集状态,也不会再次运动。
也可手动从已完成任务生成完整结构的修正 URDF,再优化精度:
```bash
ros2 run linkerhand_calibration calibrate_hand \
--config /绝对路径/会话/capture_product.yaml \
--offline-raw /绝对路径/会话/raw_samples.jsonl \
--offline-draft --offline-output /绝对路径/新的草稿目录
```
草稿复用现有图像拟合器、零位约束和 URDF 写入器。训练数据估计可观测的零位及
双向指令曲线,验证数据仅报告误差;超过原精度门限仍可生成文件。
未独立观测的反向换向端点采用有来源的连续性约束,不保留可任意改变行程的自由参数;
约束写入 `motion_range_evidence.command_support`。缺少端点依据时拒绝生成行程。
`draft_urdf_linear_holdout.json` 单独报告纯 URDF 上下限线性驱动的误差;曲线精度
不能替代线性驱动验收,未通过精度验收的草稿不更新正式发布指针。
每个任务至少有一轮完整训练即可参与草稿求解,另一训练轮已通过采集质量检查的
完整方向也保留;不拼接失败尝试,不用第三轮补训练缺项。初始化使用该任务已完整的
训练轮,逐任务覆盖与选择理由写入 `draft_result.json` 的 `training_selection`。
父关节观测约束仍保留;草稿可覆盖全手与原始采集完整是两个独立结果。
缺测关节保留原 CAD,单根手指无法观测的根零位及已声明的 CAD 末端零位单独列出。
不从数值不可观测的自由度导出任意零位。JSON 读回、字段授权、运动范围、最终 FK、
标准 URDF 加载和来源哈希检查仍必须通过。
离线模型纠错可使用单独的 Profile/Product 文件,只允许修改标签连杆绑定和
`sdk_to_joint_direction`;原始会话仍按保存的 `capture_profile.yaml` 校验。
基准 SDK 指令、通道、采样路线和速度不允许借此更改。新旧方向及配置哈希写入
`draft_result.json`,不重写原始记录,也不影响在线控制。
求解前以训练轮的双向运动和两种平面姿态候选检查 CAD 平行根轴的方向一致性;
所有候选均反向时拒绝拟合,记录 `draft_sdk_to_urdf_direction_conflict`。
不能靠零位补偿错误的 SDK/URDF 方向。文件能加载及参数数量齐全,也不等于物理零位正确。
现场明确确认四指在采集基准指令下竖直时,离线命令可增加
`--draft-upright-splay-zero`。此选项先检查源 CAD 的四指零位为掌坐标竖直,
再从求解变量中移除四个侧摆零位,重新拟合其他零位与全部指令曲线;不是导出后改姿态。
JSON 将这四项标为 `operator_defined`,分别统计 11 个拟合零位、4 个用户定义零位及
5 个 CAD 末端零位,不能把四个定义值当作独立测量。原始基准指令、采集配置和记录不变;
该选项不能进入在线采集或正式发布。
同批数据已有局部草稿时,可增加 `--draft-initialization-from /绝对路径/已有草稿目录`。
入口核对原始数据及配置身份,只读取训练选出的起始姿态和曲线;所有待求参数仍在
全手训练图像中联合调整。不同批次、使用验证选择的结果不能作为该初值。
其文件哈希和用途写入新报告,不覆盖已有草稿。
全手草稿按相同标签、相同祖先关节指令/方向及相机参数合并重复预测,使用样本数权重
及组内散差保持原始最小二乘目标、梯度与信息矩阵;误差报告仍逐张原始图像计算。
草稿数值停止 `ftol=1e-5`,正式求解保持 `1e-9`;这不改变任何精度、可观测性或
发布门限,草稿无论数值收敛与否都不能自动更新 `latest_passed`。
草稿最多保留四个收敛初值的候选并按训练误差选择,报告已计算初值数量及是否穷尽;
不以这个有限搜索声称已排除全部几何解。正式候选搜索与发布验收保持原规则。
输出 `o30_right_draft.urdf`、`draft_calibration.json`、`draft_result.json` 及训练/验证报告。
草稿 JSON 使用独立格式,不作为正式控制映射或精度证书自动加载;URDF 可标准加载。
该入口允许读取中断会话的完整方向,不启动 SDK、不补采、不更新 `latest_passed`。
`--offline-draft` 不能与 `--publish-offline` 同用,正式精度验收入口保持不变。
若旧数据的标签连杆配置有误,提供 `--capture-profile /绝对路径/原Profile.yaml`;
新会话已保存 `capture_profile.yaml` 时会自动使用该快照。
先核对原 Profile 与会话哈希,再仅允许当前 Profile 修正标签安装连杆。
标签身份、尺寸、运动策略、相机与 CAD 来源不允许借此改变,原始记录保持只读。
采集中途重新安装移动标签时,不能直接沿用该标签原来的安装参数。O30 草稿流程可先
离线建立新断点,再按普通断点恢复:
```bash
ros2 run linkerhand_calibration calibrate_hand \
--config src/linkerhand_calibration/config/o30_right_product.yaml \
--prepare-remount-from /绝对路径/原会话 --remounted-tag-id 10
```
此步骤不启动相机或 SDK。只允许保留调整标签之前连续通过的任务前缀;原会话不修改,
新断点按 CAD 拓扑排除受影响手指的全部旧标签角点,从首个受影响任务重新采集,
其他方向的补采次数保持原值。未开始新扫描的准备断点可扩大排除范围;已开始的尝试
不能借此重新获得预算。
原会话保存一次性后继指针,后续重启使用普通恢复,不能再次从旧安装重置尝试次数。
原始角点续采时,活动标签在同一 SDK 基准指令下的像素/反馈差异及不可见情况
列为“待离线复核”,不将回位姿态差异直接判成标签重装,不据此暂停采集。
固定基准、配置身份及运动保护仍须通过。前后安装参考、差异和原补采预算均保留;
未核实差异只允许进入未认证修正草稿,正式发布仍拒绝未复核的安装关系。
标签必须仍在原配置连杆上;若移到了其他指节,先修正连杆绑定。
@@ -0,0 +1,92 @@
# 固定 Tag 的分阶段标定实施记录
默认 `fixed` 保留。新策略通过 `--training-policy staged_2_to_3` 显式启用;当前满足其
“实测指令发布、完整交错网格”能力约束的内置配置为 O30。通用实现不按手型号或相机名称分支。
本次没有启动硬件、修改 Tag/相机配置或更新已发布产物。
## 执行和证据
- `capture_plan_v2` 描述各阶段、原任务顺序及实际训练轮。引擎按轮次排列现有扫描单元,
继续使用原基准、避让、进入/退出路径。首轮数据直接用于正式训练。
- 两轮全手数据齐全后检查曲线重复性、空间几何、零位置信度和 Tag 安装。训练问题使用
参数、原因、范围和任务依赖描述;局部零位缺陷补齐同轮依赖,共享根部几何缺陷扩大到全手。
- 第三轮只允许一个集中补采批次,轮次固定为 2。仍不合格即失败。独立验证始终是轮次 3。
- 每次回访保存新零位复核记录,原零位、安装模型与证据 ID 不被替换。新/旧分支编号均有
独立授权,历史扫描不会被后来一次复核覆盖。训练不足、持续姿态歧义和参考变化不靠重复验证解决。
- 上游准备阶段按物理 Tag 关系保存旁观角点、候选及原指令/反馈身份,沿用有界运动分箱。
末节保持基准指令和反馈稳定才可采用。候选使用固定时间划分、原像素门限、角度分层的独立
误差比较及 Holm 控制;不能用最低误差强选不可区分的候选。
- 相对位姿估计传播同图父 Tag 的像素不确定度,并保守累加父模型几何不确定度;后续末节
准备仍通过原运动、图像、可观测性和候选区分门限。回读核对模型身份、原始角点、固定取样、
保持通道和测量结果。短暂内部恢复仅沿既有带日志的参考延续规则复用来源。
旁观证据与已有共享 Tag 桥接并存时,策略标识同时保留两者的来源和约束方式。
## 计算和冻结
训练工作进程在会话内复用。传输、计算和返回均受同一超时/取消约束。任务索引缓存已选
训练快照,增补只使受影响任务的局部模型失效,随后重算全局空间解。
`staged_training_decision` 记录输入与计划哈希、结构化问题、实际独立轮数、置信区间和
增补/冻结/失败决定。`frozen_training.json` 使用显式 JSON 数值类型,包含双向映射、空间解、
实际应用零偏、统计、掌部姿态和 Tag 安装;绑定完整配置、源 URDF、训练记录、零位和求解版本。
修改求解语义时必须升级求解版本,不能用缓存替代证据。
在线最终验收加载已冻结结果,不再次运行曲线和空间训练优化。产物顺序仍为:独立角度验证、
写 JSON、从落盘 JSON 重建 URDF、文件/限位/方向/通道/单位/像素检查、成对发布。
离线审计复算训练决定并比对冻结结果。模型或证据改变时拒绝发布。五个末节绝对零位仍为
明确的 CAD 假设,精度声明限于经过独立验证的运动范围和姿态。
## 断点和恢复
新旧计划不得混用。旧策略保持原恢复语义。新策略仅复用连续完整任务访问;部分方向不算
访问完成。固定参考通过后,先导入原模型和原零位,再逐次复核当前零位,最后才跳过原访问。
集中补采与冻结决定按日志顺序恢复;已冻结而缺失原训练前缀或冻结文件时拒绝回退训练。
恢复父模型时一并恢复其已绑定的有界旁观原始帧,缺失角点不补造;当前零位仍须重新复核。
扫描时间必须晚于所绑定访问的零位复核;导入历史扫描保留当时的复核证据,不能伪造新采集时间。
准备缺样额度与同速重扫额度跨断点保留;已经耗尽的恢复不能通过重新加载断点获得新额度。
## 验证与局限
### 共享 Tag 改变进入姿态时的联合准备(2026-09-20)
拇指 CMC roll 与 MCP 使用同一个 Tag,但两任务间 yaw 基准不同,不能直接把前一个零位
当作 MCP 零位。本次保留既有 yaw 进入动作,把其原始图像与 MCP 自身准备图像联合求解:
前端绑定已经实测的 roll 零位,后端用保持指令及稳定反馈确认与 MCP 的共同姿态。
SDK 指令只标识动作和保持状态,不充当相机测得的角度。没有新增运动或修改 Tag、速度、路径。
采样分箱及训练/留出划分事先固定,完整枚举两段动作的候选组合;训练决定模型,留出只可
否决。维持原像素、几何不确定度和候选区分门限。目标几何的不确定度计算恢复全部自由度,
避免把固定来源零位误当作额外精度。辅助进入数据缺失时,在拟合前记录原因并使用原准备入口;
进入数据违反参考/安装/保持通道约束,或联合拟合及留出失败时,不能退回另一种求解碰运气。
`preparation_transition_v1` 绑定来源零位、来源模型、实际运动依赖、完整原始帧哈希和固定
选样身份。日志回读重新核对源 URDF 依赖、来源零位图像,并复算联合模型、核对冻结模型哈希。
取消和跨会话会清除未完成的进入证据。该机制沿用现有准备工作进程及状态机,普通冻结图像模型
和最终 JSON→URDF 发布契约不变。
真实会话 `20260920_125306` 的 MCP 原始图像经运行时采集入口回放后通过:原方法仍因候选
不可区分而拒绝;联合模型的最大训练/留出帧 RMS 分别约 1.117/1.083 px(原门限 1.5 px)。
四个初始化组合中两个收敛到等价几何,另两个违反原像素门限。原中指 DIP 不充分证据回归仍拒绝。
这证明该次 MCP 停点的软件问题得到改善,不代表整手物理精度、总耗时或一次连续通过已经验收。
本次优化未启动硬件,也未生成或发布新的整手 JSON/URDF;默认策略继续为 `fixed`。
软件验证与原始回放报告保存在
`calibration_output/O30_RIGHT_001/software_review_preparation_transition/`。
测试按快速单元、回放、产物集成、历史兼容分层;命令见 `TESTING.md`。整手输入和产物由
共享夹具提供,篡改、缺字段、取消等直接进入对应拒绝边界。修正了一个拿不完整采集数据
调用最终化、却期待后置运动证据错误的旧用例,改为直接检验其负责的运动验收边界。
已覆盖:两轮冻结、一次局部/全手补采、补采失败、真实依赖的混合轮数、冻结后不再训练、
留出隔离、回访复核、有界恢复、完整访问恢复、模型篡改拒绝、取消发布和 O30 JSON→URDF 一致性。
真实中指 DIP 旧数据缺少旁观证据时仍拒绝。新增合成独立证据能区分候选时才通过。
各次测试的源码指纹、耗时、失败及修复后复查关系保存于工作区
`calibration_output/O30_RIGHT_001/software_review_staged_training/software_validation.json`。
O30 产物集成使用原正式采集证据入口及独立 FK 合成图像,复用兼容的几何运动授权。
它覆盖文件链路与证据契约;在线图像准备算法另由原真实 DIP 回放和新增旁观图像用例检查。
该合成产物用例不是整手实机图像模型选择、连续运动或物理精度证明。
实机工作仍需在全新会话完成:所有尝试均计入,比较总耗时、完整通过、人工干预和失败原因。
记录开始到终态、准备、稳定等待、采样、恢复、训练、文件验收及开始前断点加载开销;比较时
不能漏掉加载开销或只比较扫描时间。达到正确性门限、人工干预未增加且总耗时改善至少 10%
才考虑切换默认。大日志存储/全量解码重构未与本次采集规则一起进行。
+782
View File
@@ -0,0 +1,782 @@
# 标定测试的执行范围
2026-09-24 O30 剩余误差诊断:只读两批封存数据及已冻结参数,旧/新独立验证图像
分别 2681/516 帧;按机位/标签/任务/指令拆分残差,受限试探仅用训练图像选参数、
验证轮报告结果。顶部相机在新拇指 roll 三轮只见固定标签;跨批食指端点偏差及
拇指 roll 高角度重复性残差已定位。JSON/URDF 逐字节差、来源哈希和所有试探结果
记录于 [O30_REMAINING_ISSUES.md](O30_REMAINING_ISSUES.md)。未修改生产拟合、采集、
门限或已生成候选;诊断结果不计作正式精度测试通过,也未驱动硬件。
2026-09-23 自动 URDF 修复:采集/局部/重装组 43 passed(17.06 秒),离线草稿及
换向端点组 43 passed(34.18 秒),更新审计 3 passed(0.33 秒)。完整真实
coordinator 的虚拟时钟 108 方向集成 1 passed(97.21 秒),用于核对完整调度和
训练/验证隔离,未启动硬件。最后补齐快照 mesh 与封存路径检查后,仅复查受影响
4 项,4 passed(2.38 秒);此组与前面重叠,不重复累计。
早期失败包括旧夹具仍使用已纠正的标签/方向配置、近端插值掩盖未观测换向端点;
分别修正历史场景声明、改用有来源的端点连续性约束后通过,没有降低验收门限。
旧外参全手与新外参拇指均按自身配置回放,训练重新收敛,JSON 重建 URDF 字节
一致,FK 最大差 6.66e-16,ROS/MuJoCo 加载通过;独立精度仍未通过,未发布。
证据、物理精度限制及候选路径见 [O30_AUTOMATIC_URDF_FIX.md](O30_AUTOMATIC_URDF_FIX.md)。
2026-09-23 离线逻辑复核:复用连续候选关联替代逐帧 PnP 排序,扫描起点按扫描身份纳入;
补全欠约束 SVD 零空间,范围证据记录实际可见训练样本和轮次,单轮仅允许未认证草稿。
新增 7 项集中回归通过;其余受影响项分组通过。首次 24 passed/4 failed/2 errors(22.21 秒)
中的失败分别为未 source ROS 和新增夹具字段名错误;修正后对应及 CLI 10 passed(2.13 秒)。
范围边界组 5 passed(1.20 秒),中节绑定/历史数据隔离组 2 passed(1.12 秒)。
用户确认 ID12–ID15 随中节运动后,新离线配置将四标签绑定至 `*_middle`;封存数据回放生成
`offline_confirmed_middle_tags_draft`,训练坐标 RMS 1.4867 px,最终 FK 差 5.55e-16。
四指角度子项通过,但拇指 roll/MCP 及全局图像/不确定度未通过;未发布,未控制实机。
详细问题、证据和复现命令见 [O30_OFFLINE_LOGIC_REVIEW.md](O30_OFFLINE_LOGIC_REVIEW.md)。
2026-09-23 四指侧摆竖直基准:新增显式离线选项 `--draft-upright-splay-zero`,
四项作为用户定义基准移出自由零位参数,全部行程与其余零位重新拟合。
新增/CLI 用例 6 passed(13.04 秒),相关范围、缺项、导出及生命周期用例
18 passed(16.88 秒)。`offline_upright_zero_draft` 最终 URDF 的四指正面侧偏均为
0°;JSON/FK 最大差 8.89e-16,标准加载与来源哈希检查通过。
报告区分 11 个拟合零位、4 个用户定义零位、5 个 CAD 末端零位和 20 个拟合范围。
本次独立验证已算出角度误差,但整手精度仍未通过,未更新 latest_passed。
原始数据保持不变,未进行硬件运动;上一版侧摆偏移 −6.6° 至 −9.4° 的草稿已附拒绝说明。
2026-09-23 物理零位复核:旧 `offline_full_hand_draft` 因四指 SDK/URDF 方向反转而
错误估计约 −20° MCP 零位,已附拒绝说明,不能将其文件检查通过理解为零位正确。
独立离线配置纠正方向后,`offline_corrected_zero_draft_v2` 的四指 MCP 为
+0.385°、−0.306°、−0.690°、−1.475°,没有强制置零;JSON/URDF FK 最大差
7.78e-16、标准加载及原始数据哈希检查通过。独立验证求解器仍报 IndexError,
保留 traceback,精度/发布均为 False;没有硬件动作。
本次 `test_raw_draft.py` 27 passed(30.15 秒),新增实采角点方向回归、离线模型
修订边界及验证求解异常不冒充精度通过的检查。原始数据仍为 107/108 单元。
全手草稿候选搜索收敛为最多四个收敛结果,明确记录非穷尽;相关数值不可观测拒绝、
候选预算和训练初值契约 3 passed、21 deselected(0.90 秒)。
实机封存数据 `20260923_142201` 已生成全手草稿于
`calibration_output/splay_recapture_20260923_132346/offline_full_hand_draft/`:
15 个非末端零位、20 个范围,五个末端零位保留 CAD;原始采集仍为 107/108。
复用已计算且收敛的同目标训练候选,重算原始像素误差确认一致,再运行正常草稿验收;
缓存文件、脚本和来源哈希见 `numerical_fit_reuse.json`。JSON/最终 URDF FK 最大差
6.66e-16,标准加载通过。独立验证角度求解失败,四个零位接近 ±20° 模型边界;
精度和发布均未通过,`latest_passed` 未更新。没有硬件动作。
2026-09-23 同批数据全手离线求解支持:最终 `test_raw_draft.py` 23 passed(14.47 秒)。
新增重复观测合并的原始目标、梯度和信息矩阵等价检查,原始图像误差仍逐帧报告;
训练初值拒绝不同数据批次或使用验证选择的结果,相关 CLI 不能进入在线运动。
首次等价测试的近零梯度逐元素断言受浮点消减影响(最大绝对差 4.62e-7、全梯度相对差
3.32e-13),改为完整梯度范数误差小于 1e-11 后通过,未改模型或采集精度门限。
试验过的自适应参数缩放未在合成全手收敛检查中通过,已撤回;最终沿用原参数缩放。
仅未认证草稿的数值停止容差为 1e-5,正式求解默认值及发布检查不变。
2026-09-23 离线草稿支持单轮完整训练:`test_raw_draft.py` 20 passed(8.42 秒)。
覆盖任一训练轮缺四指联动上行时仍保留全手求解范围、初始化选择另一完整训练轮、
两轮互补缺项或完整验证轮不能冒充完整训练轮,以及原有不可观测零位拒绝、JSON
重建和只读离线生命周期契约。未改变正式采集完整性或发布门限;未恢复历史测试。
实机数据的实际离线求解另行记录,不把上述软件测试当作精度验收。
2026-09-23 原始续采的活动标签基准检查:12 passed、30 deselected(1.37 秒)。
覆盖活动标签像素/反馈差异记录后继续导入、缺少基准记录的离线复核标记、固定基准
移动仍拒绝、非有限反馈拒绝、正式发布拒绝未复核安装、草稿仍检查来源完整性,
以及现有标签重装/次数持久化契约。真实 coordinator 检查确认没有因活动标签
基准差异暂停,且将未复核状态写入日志。未启动 SDK 或相机。
两份实机停止记录 `132507` / `132938` 的固定参考与 96 个方向断点只读复核位于
`calibration_output/splay_recapture_20260923_132346/software_review_resume_reference.json`。
不重判旧采样质量、不修改角点;下一任务为四指侧摆第 1 轮正向联动。
这只证明恢复检查和任务位置正确,不代表后续实机运动或 URDF 精度验收通过。
2026-09-22 正式 2+1 局部失败调度收敛:新增 test_taskwise_recovery.py,覆盖
完整依赖任务组延后、训练/验证顺序不变、已完成前缀不变、一次回访预算、真实
coordinator 的带视角错误处理、多视角参考变化不被掩盖、失败后归位且不进入拟合、
无可复用零位时的恢复日志及预算持久化、未解决状态禁止发布。
首组 30 项通过、1 项失败(13.93 秒);失败为既有观察器迁移测试仍断言
rigid_pair_v1,当前配置实际为 rigid_pair_candidates_v2,修正过时断言。
随后调度/恢复/旧 staged 契约/执行组 44 项通过、1 项未选(6.20 秒);passed
断点/分阶段训练/真实图像共享姿态交接组 59 项通过(22.11 秒)。集中任务准入
判断后,受影响恢复组及完整合成 JSON→URDF 验收 18 项通过(14.10 秒)。各组
有重叠,不累计为全量回归。最后一组包含最终文件重建一致性、独立图像验收、
CAD 冻结零位与授权字段保护。
正式 CLI --validate-only 通过,确认 capture_plan_v5、17 任务、训练 0/1、验证 3,
受保护输入一致。git diff --check 通过。本轮无实机运动,未改求解器/精度门限,
不代表全手稳定性验收;旧 v4/v15 断点与新调度隔离。
2026-09-22 用户指定无名指侧摆基准由 85 改为 127:同步 Profile、准备向量、
ROS 配置、产品指纹及当前夹具。Profile/局部任务组首次 14 项通过、1 项旧基准
断言失败(21.25 秒);修正该断言后单项通过(0.55 秒)。正式 CLI 配置校验及
diff 检查通过。全手新会话 194750 在启动前被已有控制/反馈/状态发布者保护拒绝,
未启动本次 SDK、未发送运动,不算实机标定通过;旧基准断点不复用。
2026-09-22 固定 190634 的绝对零位可观测性检查:新增
`test_zero_evidence_coverage.py`,覆盖原零位输入拒绝不变、训练/验证缺失列表
分离、明确缺失掌定位轴线,以及真实 O30 URDF 下的 MCP 零位/未知掌坐标精确
等价变换;已知掌坐标时同一零位变化可检测。三项通过,0.45 秒;既有
`test_spatial_solve_order.py` 真实空间拟合回放一项通过,0.86 秒。
仅增强输入覆盖报错,不改变零位/精度/运动策略。本次无实机运动,未发布产物;
不能把完整小指屈伸数据误当作全手绝对掌坐标参考,详情见稳定性报告最新节。
2026-09-22 DIP 父姿态/安装/角度不确定度:复用 `JointPairImages`,新增独立图像
局部角度协方差,保留共享相关项。原组 8 项通过,0.89 秒;新增“不确定度不可用
不得删除竞争模型”后受影响 3 项通过,0.44 秒。临时密度参数边界 3 项通过,
0.43 秒;密度对照未显示稳定收益,参数及其专属测试随后撤除。补齐重复图像和
角色身份检查,最终该模块 9 项通过,0.95 秒。各组有重复,不累计为全量回归。
真实 190634 前两轮默认预算 16 初值全部收敛,不确定度结果仍为非发布诊断;
72 帧/阶段对照中 2 初值独立图像求解失败,保留负面证据,不放宽优化器/候选
门限、不触发额外运动。第三轮在本轮未用于训练或选择,正式零位和产物仍未验收。
2026-09-22 复用 190634 离线曲线检查:补充原图像模型的纯前向名义像素误差
`image_motion_prediction_errors`,禁止验证时重拟合角度,检查训练/映射来源重叠、
有限角度、模型不变与错误预测可见。受影响组首次 3 项通过,0.47 秒;核对正式
验收语义后撤除对名义总像素误差额外施加 1.5 px 的判决,仅返回误差,再测该项
1 项通过,0.44 秒。正式物理验收规则及测量精度门限不变。
真实 MCP/PIP 冻结指令曲线及 DIP 两个保留候选的第三轮条件角度检查均通过;
DIP 安装候选仍未区分,不等于物理零位或 JSON/URDF 发布通过。细节及一次
缺少旁观标签的读取失败记录在稳定性报告,本轮未运动机械手。
2026-09-22 取消辅助侧摆并回到原交接方法:撤除本轮显式辅助开关,整指实验
拒绝任何辅助计划。采集/实际指令三轮/入口组首次 36 项通过、2 项因未 source
工作区而找不到 ROS package 失败,17.96 秒;补齐环境后仅重跑失败两项,
2 项通过、0.60 秒,没有为测试修改生产路径。
双侧交接回归首组六项因历史夹具与现 Profile 的姿态/标签尺寸不符而初始化失败;
修复测试夹具后首次写错 dataclass 字段,未进入求解;改用 baseline_u8 后六项
通过、1.86 秒,其余受同夹具影响的 23 项通过、4.86 秒。历史 113/16.5 mm
仅用于旧证据回放,生产仍为 85/16 mm。包含交接后模型选择、旧零位不变、
坏图像双侧拒绝、来源哈希和读回防篡改;不代表当前实机发布已通过。
当前 182949 真实数据原 MCP/PIP 及交接回放见稳定性报告:第三轮几何检查通过,
但未完成冻结指令映射、物理零位和最终产物验收。本轮未再次驱动机械手。
2026-09-22 用户指定一次辅助侧摆对照:复用已有 ObserverPreparation,整指实验
只允许 DIP 前一个有界动作,不继承全手或扩幅预算。真实 coordinator 虚拟测试
`test_chain_capture_runtime.py -k 'complete_chain and True'` 1 项通过、15.94 秒,
确认只有一次 52→115→52 辅助段,MCP/PIP/DIP 实际全部指令仍各三次往返,
不生成零位或产物。单动作/全手拒绝/多动作拒绝三个预算边界通过、0.83 秒。
实机 182949 在 MCP/PIP 十二方向完成及一次辅助段完成后,因早期父参考图像差异
暂停,DIP 未开始。离线有无辅助的双标签安装估计均未通过候选验收;未放宽门限,
未发布。具体证据见稳定性报告,不能将软件调度通过写成实机全流程通过。
2026-09-22 小指实际三轮采集:`test_chain_capture.py`、
`test_chain_capture_runtime.py`、`test_diagnostic_presets_cli.py` 共 33 项通过,
17.23 秒。实际 coordinator 虚拟时钟统计全部发送指令,包含准备/收尾,断言三个
关节恰好三次完整往返;保存三份第一轮训练回程参考,禁止验证轮、错误方向/
轮次、缺帧、标签缺失或不稳定反馈替换来源。DIP 标签持续缺失仍按原超时暂停,
原始证据不生成授权零位或角度、不发布。新增旧正式准备不受影响与同一参考只写
一次的两个边界通过,0.57 秒。本轮未驱动硬件,也未声称正式离线产物链已接通。
2026-09-22 正式入口收敛:O30 默认改为 `taskwise_2_plus_1`,不默认声明辅助动作,
产品指纹同步;历史合成夹具显式生成 fixed 数据全集,再由正式夹具选择验收分区。
`test_taskwise_two_round.py`、`test_o30_profile.py`、诊断 CLI 与交错运动受影响组
41 项通过,18.57 秒;含一次正式合成 20 关节 JSON→URDF 及独立图像验收,
新增 CAD 零位不变和仅允许字段变化断言。启动参数/分区/恢复/旧 staged 兼容组
41 项通过、6 项未选,18.30 秒;未运行不相关全型号拟合。旧固定策略测试显式
选 fixed,不通过改变生产策略保留过时默认。以上是软件测试,不是实机正式通过。
新增 2+1 实际 launch 参数传递用例 1 项通过,0.84 秒;CLI `--validate-only`
确认默认 17 任务、训练 0/1、验证 3,受保护指纹一致,未启动硬件。检查脚本首次
把 CLI 的 JSON 和尾部说明当成单个 JSON 解析失败;改为解码首个 JSON 后检查通过,
未改生产输出。`git diff --check` 通过。
只读旧数据的插值对照仍不满足精度,未将 PCHIP 引入正式代码,未新增运动。
2026-09-22 用户指定无名指侧摆基准改为 85:同步 Profile、全部相关零位/准备向量、
ROS 参数、产品指纹及合成夹具;其他通道不变。来源/指纹、侧摆零位路径、采样节点/
速度和局部任务契约共 4 项通过、1.97 秒。额外只读校验参数与 Profile 基准一致,
所有零位的 ring_yaw=85;范围示教文件仅修改本机 baseline.target[4],历史反馈
不改写。未启动实机运动,不改旧会话证据;新基准须重新采集,不复用旧断点。
2026-09-22 整指延后求解采集:显式 `o30_pinky_chain` 实验入口使用原任务路径、
原 SDK/基准保护及交错停点,逐任务轮次 0、1、3;原始父参考回访不授予零位或
角度权限。`test_chain_capture.py` 与旧旁观隔离失败项复查 15 项通过、1.58 秒;
完整真实 coordinator 虚拟时钟 18 单元集成测试通过、19.43 秒;缺失 DIP 标签时
原参考超时仍暂停,1 项通过、2.21 秒。验证轮不得进入旧分段拟合器,1 项通过、
0.72 秒;旧参考权限/虚拟任务契约 2 项通过、0.86 秒。
预设、旁观、冻结验收回归首组 55 项通过、1 项失败、2.08 秒:旧用例把启动日志
固定为单条,未考虑已有 camera_preflight;改为断言调用前后记录完全不变,未删
生产日志。完整虚拟夹具初次因设备心跳未刷新、合成标签投影小于原 30 px 门限
失败,修夹具心跳与焦距后通过,没有放宽生产门限。以上各组不累计为全量回归,
更不代表实机几何或 JSON/URDF 发布通过。
新增显式 CLI 训练策略/禁恢复契约 1 项通过、0.67 秒。实机 `165627` 三任务
18 单元连续采集完成,无暂停、无重扫,安全返回;独立冻结预测仍拒绝,具体精度
与候选差异见稳定性报告。没有因采集完成重跑不相关的全型号产物测试。
2026-09-22 联合模型冻结指令验证:新增 `test_frozen_chain_validation.py`,
16 项通过、0.61 秒;测试夹具改为通过构造接口提供窄支持域后,仅复查受影响项,
1 项通过、0.53 秒。覆盖已知真值的双向映射、验证期禁止重拟合、错误指令与零位
不得被吸收、5 px 阶段平移不得被重新配准、训练/候选选择图像隔离、独立轮次、
稳态目标、保持通道、机位/内参/标签尺寸、非有限或非整数线端指令和范围外拒绝。
复用已有几何合成夹具,无新增实机运动。`FrozenChainCommands` 是非授权的验收
组件,未接入运行时;它不证明物理零位来源、完整采样节点覆盖或候选不确定度。
未将该组软件测试标记为正式 2+1 或 JSON/URDF 验收通过。
2026-09-22 实时诊断内存修复:不再为诊断强制保留全场完整记录或训练几何;
方向结束验收与状态显示共用当前扫描窗口,完整报告在安全返回、关闭采集后从
不可变日志按需读取。`test_diagnostic_coordinator.py`、
`test_diagnostic_reference_flow.py`、`test_capture_runtime_index.py` 首轮 28 项通过、
2 项旧测试因直接解析压缩索引失败,115.33 秒(超过日常目标;后续仅复查受影响项)。
改用正式 `load_jsonl` 后失败两项通过,18.25 秒;新增当前扫描身份/容器绑定断言后
只复查一项完整流程,25.96 秒(与实机并行时耗时)。没有改变生产哈希或准入规则。
只读旧证据保留模型回放及四倍记录量压力数据见
`software_review_20260922_stability/retention_validation_20260922.json`。
实机 `151025` 完成拇指 CMC roll、MCP 四方向,一次通过原始覆盖;最大 GC
0.183 秒、未发生反馈超时,因小指侧摆持续升到 72°C 主动中止。不是全手通过。
用户确认仍可运动后,先只读确认温度下降且无故障,再运行 `151821`:491.96 秒、
48069 条记录,前六任务的十二方向原始覆盖均一次完成,实测 RSS 最高约 341 MiB、
GC 最大 0.239 秒、控制回调最大 0.114 秒,无反馈超时。进入 DIP 时因上游 MCP/PIP
均无可信零位,`parent_reference_current_session_source_missing` 正确阻止父参考复用;
尚未修改此诊断依赖行为。全手其余任务及正式 2+1、JSON/URDF 均未通过验收。
2026-09-22 机械手移动后新会话 `20260922_145145`:全手单轮非发布诊断计划
17 任务、20 关节、36 扫描单元,不恢复旧零位、不增加辅助动作。完成前五任务
共十个方向的原始覆盖;拇指 MCP 正向重扫一次,拇指 IP 与小指 MCP 零位未确认。
小指 PIP 准备阶段发生 1.783 秒 generation-2 GC 停顿,同期反馈时效保护暂停。
未完成全手测试,未发布 JSON/URDF;不能称作正式 2+1 验收。
全手入口/报告/分析受影响组 47 项通过(6.32 秒)。随后修复诊断多通道分段身份
及各指独立 command/feedback 索引,`test_diagnostic_quality.py` 28 项通过
(1.99 秒);四个分段均检查独立通道覆盖、错误分段拒绝、静止中指不得借其他指
运动通过。该修正在本次实机停止后完成,尚未实机验证,且不修复 GC 阻塞。
候选输出角度及共享不确定度诊断:`test_finger_chain_diagnostic.py` 新增轴符号/
2π 等价消除、真实零位差保留、与独立角度重拟合数值导数对照、相关协方差、
训练留出身份隔离及验证相机/尺寸/角色不变边界。首组 2 项通过(8.39 秒);
增加输入一致性边界后仅重跑受影响验证组,6 项通过(6.79 秒),不累计为全量。
旁观机位诊断入口使用 `test_diagnostic_presets_cli.py`、
`test_diagnostic_observation_scope.py`、`test_diagnostic_yaw_hold_plan.py`,39 项
通过(1.56 秒),覆盖扫描单元不变、序列化兼容、设备/参考保护和非发布隔离。
真实 `142553` 小指一次往返原始采集完成,但 DIP 安装候选仍未区分;
`143540` 既有四指侧摆旁观测试因无名指硬件堵转暂停,用户要求重试后的只读
检查报过温,未再运动。细节见稳定性报告,软件通过不代表实机正式标定通过。
2026-09-22 用户最终确认 O30 标签黑框 16×16 mm 后,同步正式 Profile、检测器、
标定覆盖表及产品指纹。`test_o30_profile.py` 的来源指纹、跨配置尺寸一致性、
图像采集受影响组 3 项通过,0.80 秒;CLI `--validate-only` 通过,
`git diff --check` 通过。本次没有实机运动或发布,不代表 DIP 多解已经解决。
下文 16.5 mm 的确认及“正式配置未改”描述保留为此前实验阶段记录。
整指三阶段联合诊断使用 `test_finger_chain_diagnostic.py`:已知几何恢复、
Schur 与完整协方差对照、只拟合留出角度、64 组初值的每一位实际参与初始化、
参考漂移不得被隐藏、URDF 结构去冗余、分阶段参考以及共同标签尺度诊断。
初组 10 项通过,1.17 秒,耗时记录为
`software_review_20260922_stability/finger_chain_tests.json`。开发中仅重跑修改所
影响的诊断组或新边界,没有重跑未受影响的旧模型和整手产物链。随后新增消除
逐图像角度等价 2π 绕回,受影响验证与新边界 4 项通过,0.83 秒;只从六份已保存
结果重算分箱比较,没有重复拟合,主要歧义结论不变。
真实 `121712` 的六组 64 初值回放均保留独立结果;存在三维多解和尺度一致性
问题,详见稳定性报告。此模块不能授权实机角度或发布,不等同于正式 2+1 通过。
用户随后要求固定 16.0/16.5 mm 对照,新增第七份 64 初值结果;比较脚本已核对
两组来源哈希、图像分区、结构约束和参考策略相同。16.0 mm 像素误差较低但
候选仍不唯一,正式配置未改;未因这次仅诊断输入变更重复无关测试。
完整父候选与联合父姿态诊断:`test_joint_pair_diagnostic.py` 检查已知真值恢复、
父姿态/角度 Schur 协方差与完整逆矩阵一致、固定几何验证、重复图像拒绝、
16 个失败初值保留及不能按验证误差选训练候选;不生成发布模型。
首次与配置策略组 13 项通过、1.42 秒;向量化/共用有界求解器后受影响 5 项
通过、0.69 秒;新增拒绝边界 1 项通过、0.42 秒,验证接口变更受影响 2 项
通过、0.65 秒,候选选择边界 1 项通过、0.39 秒。
`test_rigid_pair_observer.py -k 'not independent_parent_branches'` 的历史真实回读、
篡改和新旧父候选调用边界 9 项通过、20.37 秒;迁移/准备契约/已知真值候选组
32 项通过、5.64 秒。没有重跑未受影响的独立补采重拟合或整手产物链。
新版无辅助动作配置只读验证通过;`121712` 经新运行时桥接入口正确拒绝歧义。
联合诊断的真实 16 初值回放有像素改善,但父几何和候选比较仍不通过,详见稳定性
报告;这些软件分组不累计为一次全量测试,也不等同于实机或 JSON/URDF 验收。
共享零位及双端交接反馈契约复用 `staged_revisit_v2`:新 O30/u8 跨次容差 10,
窗内稳定性 2,旧无版本记录仍为 4。`test_shared_tag_pose_bridge.py` 与
`test_shared_pose_handoff.py` 首轮 45 项通过、3 项失败,19.03 秒;失败为两个
JSON 列表/元组直接相等断言,以及一个未完成任务本不应导入的旧测试假设。
改为反序列化后的物理参考相等、区分完成/未完成任务归属,不修改生产恢复规则。
针对复查及新增反馈契约篡改组 5 项通过、4.27 秒;源窗口的 7/10/11 边界、
旧版门限及窗内抖动新增组 3 项通过、1.30 秒。没有无理由重复完整拟合。
真实 `114118` 只读 PIP 回放在新反馈契约下通过独立父姿态检查和准备解算;
不把此软件结果视为 DIP 或整手实机通过。
随后 `121712` 无辅助侧摆实机完成六方向原始采集、无需重扫;MCP/PIP 和
DIP 父参考通过,DIP 几何因约 1.964 px / 4.864° 继续拒绝,未发布。
同批只读约束消融保留拒绝结果,详见稳定性报告;没有将删除约束作为修复方案。
实际启动验证纠正了上轮导出判断:固定 2+1 分区不是执行决策,应从策略派生,
不能填入禁止 YAML 导出的 `task_training_cycles`。修正后新策略/观测策略组
15 项通过、16.87 秒,包含正式合成 JSON→URDF;补充有效 YAML 完整往返后,
只重跑该项,1 项通过、1.04 秒。单次 v3 预算记录由实机暴露,新增
`test_single_trajectory_action_declares_budget_and_cannot_escalate`,与原两次预算恢复
用例共 2 项通过、0.78 秒。实机 `113510` 的旧记录保留拒绝;`114118` 的新记录
284 帧通过辅助证据验收,但因 PIP 父模型缺失停止,不能写成 DIP 精度通过。
2+1 辅助观测接入修正:`test_observer_policy.py` 同时检查 staged 与严格 2+1,
`test_taskwise_two_round.py` 检查实际 v3 依赖/动作编译而非只检查参数接受,且扫描顺序不变。
初次 quick 组 13 项通过、1 项导出断言失败、1 项未选(3.22 秒):运行期分区按现有契约
不可导出为可复用 YAML,修正测试预期后该项通过(1.14 秒)。后续策略与真实双标签回放组
17 项通过、107.59 秒,其中独立父分支真实补采回放耗时 65.68 秒;未再重复整手产物测试。
修正后的实际 CLI `--validate-only` 通过。真实小指的零新增运动反事实对照仍因几何精度
不足拒绝,详见 `O30_STABILITY_REVIEW_20260922.md`,不能称为实机修复完成。
2026-09-22 严格逐任务 2+1 用 `test_taskwise_two_round.py`:quick 部分检查
逐任务顺序、轮间不重复准备、禁止补轮、断点分区及不可观测不跨任务推迟;
integration 部分复用正式 O30 夹具,验证 20 关节拟合、独立留出及 JSON 重建相同 URDF。
与 `test_diagnostic_quality.py` 共 28 项通过,16.08 秒。诊断压缩证据读取用
`test_diagnostic_analysis.py`,19 项通过、2.24 秒。旧准备契约测试直接读取
压缩索引导致一次失败,改用公共 `load_jsonl` 后只复查受影响组,30 项通过、
1 项未选,3.32 秒。各组不累计为全量通过;实机边界见
`O30_STABILITY_REVIEW_20260922.md`。
辅助侧摆端点离开条件使用 `test_witness_endpoint.py`。真实夹具
`o30_witness_terminal_gap.json.gz` 来自 `20260920_183257` 的 226 帧完整侧摆窗口,
末尾连续三帧缺命令,仅余两帧有效尾部。真实记录必须继续拒绝;追加的模拟后续帧
仅验证实时等待门,不能作为实机或发布证据。与 `test_preparation_witness.py`、
`test_preparation_sync.py` 共 53 项通过,14.89 秒,检查在线和离线共用端点窗口、
原超时上限、旧记录兼容、数据篡改和辅助拟合失败仍拒绝。
准备契约升级用 `test_preparation_contract.py`,覆盖新旧断点分离、父子/预算/动作
变化、历史记录保留、原会话边界、节点写入和发布入口禁止旧模型冒充新预算。
时钟自适应刷新用 `test_camera_timing.py`,正负平滑漂移在模拟 30 Hz 的 60 秒内
曝光时间误差保持小于原 1 ms 连续性预算,同时验证真跳变仍拒绝及启动锚点年龄。
辅助精度预算前置用 `test_observer_pose.py` 和 `test_observer_reference.py`。
此次增量验证分组记录在 `software_review_stability_repair`:初始针对组 79 项、8.84 秒;
证据/真实 PIP-DIP 回放/runner/合成完整产物组 91 项、58.99 秒;修正启动锚点刷新
时机后的相机组 15 项、0.47 秒。组间有重复用例,不将累计次数称为一次全量回归。
实际目录的断点选择只读验证约 17 ms,排除了旧准备契约;配置 validate-only 通过。
这些结果不替代新版本的连续实机验收。
相机时钟诊断变更使用 `test_camera_timing.py`:同时覆盖原始计时单位、跳变、陈旧帧、
重同步顺序及拒绝事务可重放。新增 `CLOCK_MONOTONIC_RAW` 仅记录诊断边界,不能
替换 ROS 时间或放宽同步门限。2026-09-20 针对组 12 项通过,0.33 秒,记录于
`calibration_output/O30_RIGHT_001/software_review_stability_architecture/camera_timing_tests.json`。
这不表示实机重复失同步已经修复;整体评估见 `O30_STABILITY_ASSESSMENT_20260920.md`。
日常修改运行受影响用例和必要契约用例,目标为 60 秒内完成。不要因为修改了一行代码就运行
整手拟合或全量 `colcon test`。公共产物契约变更需要覆盖相关型号,发布前再执行全量回归。
测试保留原文件位置,避免破坏现有共享夹具。`test/conftest.py` 集中维护分类;函数上的显式标记优先。
| 标记 | 内容 | 何时执行 |
| --- | --- | --- |
| `quick` | 局部求解、调度、恢复、隔离和拒绝边界 | 日常选择受影响文件 |
| `replay` | 已记录的真实图像、准备失败和姿态歧义 | 修改对应观测或恢复逻辑 |
| `integration` | 完整拟合、产物回读、进程或 ROS 链路 | 修改公共契约或发布前 |
| `legacy` | 历史格式、旧型号算法及兼容入口 | 修改兼容契约或发布前 |
从工作区根目录准备环境:
```bash
source /opt/ros/jazzy/setup.bash
source install/setup.bash
export PYTHONPATH="$PWD/src/linkerhand_calibration:${PYTHONPATH:-}"
export PYTHONDONTWRITEBYTECODE=1
```
采集计划、训练接口日常验证(按改动选择文件,不必每次全部执行):
```bash
python3 -m pytest -q -m quick \
src/linkerhand_calibration/test/test_capture_plan.py \
src/linkerhand_calibration/test/test_adaptive_training.py \
src/linkerhand_calibration/test/test_command_motion.py \
--durations=8 --timings-json=/tmp/calibration-quick.json
```
涉及零位恢复时加 `test_zero_recovery.py`;涉及断点时加 `test_prepared_resume.py`、
`test_passed_resume.py`;涉及发布时加 `test_artifact_publication.py`。真实中指 DIP 歧义回归位于
`test_measured_transfer.py::test_real_middle_dip_fits_but_remains_ambiguous`,其正确结果仍为拒绝不充分证据。
`test_prepared_resume.py::test_resume_tick_uses_prepared_data_and_still_checks_current_reference`
同时覆盖 O30 分阶段恢复:历史证据索引须在启动准备时完成,实时回调不能再次整理整份历史;
参考变化仍拒绝复用。五个型号、参考保持/变化共 10 项约 2 秒。真实 `20260920_141822`
在该索引阶段发生控制回调阻塞,修复后的 `20260920_142046` 已实际复用 4 个扫描单元;
后续因小指零位 ID4 检测断续停止,不代表整手通过。
O30 回访反馈重复性修改,先运行 `test_staged_training.py -k revisit`:真实反馈 48→41、
10 的边界、超限、同批不稳定、图像/安装变化以及 v1/v2 回读共 8 项约 1.2 秒。
涉及复核记录格式时,追加 `test_staged_artifacts.py` 的 JSON→URDF 一致性和阶段拒绝组,
共享一次整手产物,5 项约 31 秒;无需为每个拒绝条件重复完整拟合。
分阶段调度、旁观证据和恢复的快速检查:
```bash
python3 -m pytest -q -m quick \
src/linkerhand_calibration/test/test_staged_training.py \
src/linkerhand_calibration/test/test_staged_runtime.py \
src/linkerhand_calibration/test/test_observer_pose.py \
src/linkerhand_calibration/test/test_observer_reference.py \
--durations=8 --timings-json=/tmp/calibration-staged-quick.json
```
`test_observer_reference.py` 共享一次上下游图像求解,角点、来源、安装和取样篡改直接检验
证据回读边界。`test_staged_runtime.py` 的进程复用用例及整手训练用例标为 `integration`。
只有训练/冻结/发布契约变化时,才运行下面的分阶段整手组;本次初次完整组 19 项约 45 秒,
后增加的回访时序拒绝用例随产物组单独复查,未重复其余已通过的完整拟合:
```bash
python3 -m pytest -q \
src/linkerhand_calibration/test/test_staged_training.py \
src/linkerhand_calibration/test/test_staged_runtime.py \
src/linkerhand_calibration/test/test_staged_artifacts.py \
--durations=8 --timings-json=/tmp/calibration-staged-integration.json
```
该组共享整手输入和产物,包含实际混合轮数统计、冻结后不再训练、一次集中补采、失败终止、
断点及 JSON→URDF 链路。下面的旧策略整手组仅在旧策略相关逻辑也受影响时追加。
共享 Tag 的进入姿态联合准备修改,运行真实 MCP 的专门回放组:
```bash
python3 -m pytest -q \
src/linkerhand_calibration/test/test_preparation_transition.py \
--durations=8 --timings-json=/tmp/calibration-preparation-transition.json
```
该组共享一次真实图像求解,检查正式运行时继续、原始证据回读、缺失辅助数据、留出隔离、
保持通道/参考变化、篡改拒绝及取消清理,通常约 10 秒。只有候选选择公共逻辑受影响时追加
`test_image_model_selection.py` 的相关用例;已有通过的完整拟合不为文档或断言修正重复执行。
本次验证记录位于 `calibration_output/O30_RIGHT_001/software_review_preparation_transition/`,
保留失败与针对性复查关系,不把软件回放写成实机通过。
测量几何候选的精度资格与图像评估分离,回归位于 `test_measured_transfer.py` 的
`test_real_ip_compares_uncertain_alternative_before_authorizing`、
`test_uncertain_alternative_cannot_be_frozen` 和
`test_uncertain_alternative_still_vetoes_when_images_do_not_distinguish`。
三项共享 `20260920_140424` 的 129 帧真实 IP 准备及父几何证据;精度不足的候选仍参与
留出区分,不能冻结;无法区分仍拒绝。相关旧中指 DIP、候选选择及回读边界共 17 项
耗时 5.39 秒,后补否决边界 1 项耗时 2.16 秒。记录位于
`calibration_output/O30_RIGHT_001/software_review_ip_alternative/`,不代表整手实机通过。
完整 O30 产物链路按需单独执行:
```bash
python3 -m pytest -q \
src/linkerhand_calibration/test/test_o30_artifacts.py::test_o30_nonlinear_twenty_channel_finalization \
--durations=8 --timings-json=/tmp/calibration-o30-integration.json
```
该用例从正式采集证据入口进入,检查采样完成、训练决策回放、运动授权、受保护相机文件、
原始角点、JSON→URDF 重建和回读。输入来自独立 FK 合成场景,使用兼容的几何运动授权;
不模拟在线图像模型选择,不替代实机连续采集及精度验收。末节 CAD 零位假设仍须保留原语义。
整手输入由会话级夹具共享,只读使用;需要篡改的用例先复制。反馈缺失、反馈卡住已改为局部
映射契约测试,不再各生成一套整手 JSON/URDF。篡改、取消和失败指针检查优先复用验收边界。
`--timings-json` 记录各用例 setup/call/teardown 耗时、结果、层级和 Python/YAML 源码指纹。
同一版本通过的测试不无理由复跑;修复失败后只重跑失败及受新改动影响的用例。裸 `pytest`
仍会运行全量,标记不会悄悄关闭测试。分层收集可用 `--collect-only -m replay` 等命令检查。
上一版逐任务自适应方案的软件验证记录保存在工作区
`calibration_output/O30_RIGHT_001/software_review_adaptive_training/software_validation.json`,
同目录保留各组耗时记录和历史数据对照。O30 正式产物链路与混合轮数统计约 44 秒,
恢复、断点及真实 DIP 歧义回归约 19 秒,在线训练评估与取消约 6 秒。记录保留开发期间
的失败及修复后复查关系,不将不同代码版本的增量检查称为一次最终全量回归。
本次分阶段方案记录在
`calibration_output/O30_RIGHT_001/software_review_staged_training/software_validation.json`。
同目录保留各次运行的源码指纹、耗时及开发失败,报告标明修复关系。新旧策略、旁观图像、
原真实 DIP 拒绝和文件链路均分别记录;不重复旧的 2.88 GB 日志性能测量,不宣称实机已通过。
遮挡感知准备策略的日常检查使用 `test_preparation_witness.py`:真实无名指停点保留为
`fixtures/o30_ring_mcp_ambiguous.json.gz`,合成独立侧摆源明确标注,模块共享一次原始
拟合和一次新增证据。覆盖逐指避让后单通道往返、整数 SDK 目标、回访不重采、
训练/留出隔离、姿态改变、完整窗口、篡改、强制证据缺失及正式读回。只读策略为
`--preparation-policy occlusion_aware_witness_v1 --validate-only`,不启动硬件。
最终遮挡版:该文件加 Profile 加载、采集计划、原图像采集契约,共 58 项约 14.12 秒。
源码迭代与现场遮挡澄清导致的增量复查分别记录,不把开发过程累计次数称为一次回归。
记录目录为 `calibration_output/O30_RIGHT_001/software_review_preparation_witness/`。
新策略仍需完整实机对照;原 O30 JSON→URDF 及取消发布两项共享整手夹具约 32.12 秒,
没有逐个拒绝条件重新拟合整手,也没有运行全量 pytest/colcon。
准备优化器统一恢复检查位于 `test_image_bundle_optimizer.py`。快速用例检查计算次数、
内存上限、不收敛/异常/非有限值/误差增大时继续拒绝;真实回放标记为 `replay`,共享
一次 `20260920_154722` 拇指 IP 求解。夹具保留真实父参考、上游准备记录及其原始角点,
通过正式准备、来源几何和父参考验收;不能仅用孤立合成输出代替这些入口。
该次真实候选原先耗尽 150 次稀疏求值,使用同一训练证据暖启动直接解法后,再 6 次
收敛,随后被原 1.5 px 门限拒绝,合格候选得以通过。未收敛候选仍否决,不参与绕过
门限的比较;留出数据不决定计算恢复。主回归 24 项约 32.20 秒,覆盖旧 IP 精度不足
候选、真实中指/无名指歧义拒绝、转接/共享姿态回读、小指历史预算、训练/留出隔离和
工作进程取消。新增用例开发失败及针对性复查分别保存于
`calibration_output/O30_RIGHT_001/software_review_solver_recovery/`。
未重跑整手拟合和 JSON→URDF 产物组;本次改变的是准备求解及其证据绑定,产物生成、
最终角度/像素门限没有修改。软件回放通过不代表整手实机已经通过。
准备采样有效性与辅助适用性使用 `test_preparation_sync.py`:夹具来自真实
`20260920_160630` 小指完整准备窗口,保留两帧原始缺指令值。检查完整原始窗口回读、
排除记录绑定、缺值行上的保持关节变化、持续缺样/端点/身份变化拒绝,以及拟合前
辅助不适用时主模型独立验收,拟合后不改路线。与 `test_preparation_witness.py` 的
原歧义和篡改检查合计 41 项、10.58 秒;历史 v1 和拟合失败拒绝另 3 项、3.97 秒。
后补缺候选不可视为转角不足的边界单独执行;没有无理由重跑前述已通过组。
验证记录位于 `calibration_output/O30_RIGHT_001/software_review_preparation_admission/`。
双端共享姿态交接使用 `test_shared_pose_handoff.py`,夹具来自 `20260920_163002`,
包括原 MCP 模型/零位/像素来源、上游回基准原始窗口和 PIP 准备完整证据。
共享一次 PIP 拟合,检查原失败复现、新双端验证、原门限、正式回读、数据篡改、验证轮
隔离、源图像先于全部新准备数据、断点依赖导入/去重、恢复代次和四指模型归属。
DIP 用真实像素模拟后续访问,经 `ObservationCapture`、父参考确认和文件回读,
下游模型外壳明确为合成数据,不能宣称整指实机通过。
本轮父参考、有限恢复、旧断点及旧版共享姿态 62 项通过,20.95 秒;最终针对组
25 项通过,4.11 秒(含 1 项已受修改影响的旧父参考回读)。随后新增时序边界与导入
去重只执行相应用例。现有 O30 正式采集证据入口 JSON→URDF 集成单独运行一次,
31.24 秒。开发中的夹具缺来源、策略字段绑定和 ROS 环境收集失败保留原记录,
不计为通过;没有全量 pytest 或 colcon。完整记录见
`calibration_output/O30_RIGHT_001/software_review_shared_pose/`。
准备观测预算的实机像素回归使用 `test_preparation_density.py`,夹具为
`20260920_171710` 的完整小指 PIP/DIP 准备窗口、上游模型和双端交接图像。新 PIP
模型重新投影原 DIP 像素后,验证更完整的训练/留出分组、源模型精度传递、候选区分
及模型回读;冻结旧父模型仍拒绝,四指多关节求解不扩容。此回放带有明确的离线姿态
准入标记,不声称它是一次可发布的实机采集。正式产物另用已有 staged 产物组检验。
`test_checkpoint_startup.py` 检查断点加载心跳、独立 900 秒上限、恢复后设备/服务
时限、异常清理及无断点不发布加载状态。相关启动/共享姿态/真实密集回放 98 项
14.79 秒;产物回读、阶段隔离和取消发布 8 项 24.11 秒。前一批 85 项通过、1 项
失败是历史测试将最大像素误差写死为 0.75;新增留出图像为 0.752,实际契约为
1.5 px。现已断言正式契约,并复查完整共享姿态回读。环境收集失败、反事实回放
拒绝分类断言修正和后续通过记录均保留,不混算为一次完整实机回归。
扩展到准备采集/辅助证据入口后,发现在线密集预算未传入离线重拟合,已补齐
`image_sampling_policy` 的版本、报告与角色哈希绑定、重放参数及帧数上限校验。
后续来源/辅助/真实回放组 121 项通过、2 项为测试断言失败;原断言分别固定了错误
文本和旧采样子集模型。修正为准确拒绝码及相同预算的离线对照后,仅复查失败用例、
受影响的启动与产物边界,16 项通过,32.54 秒。发布线程清理再检查 7 项通过。
记录完整保存在 `software_review_stability_density`,没有将多版本增量测试宣称为
一次全量回归。实机尝试 `20260920_174317` 在发起标定运动前被现有 ROS 控制节点占用
阻止,未产生可交付 JSON/URDF。
完整已声明归零路径用作辅助观测时,运行 `test_declared_witness_path.py`:
只有完全相同避让姿态下的既有单关节归零路径可超过默认四分之一行程,
任意扩幅、其他关节变化及未经声明的侧摆全行程仍拒绝。
`test_conditioned_witness.py` 使用 `20260920_191613` 的真实中指 PIP/MCP 图像,
覆盖原来只作事后检查时的共享姿态失败、独立源验证后参与共享零位拟合、
正式证据回读以及端点变化/策略降级拒绝。旧 v1/v2 辅助证据继续按原含义读取;
新 v3 的共享姿态条件绑定进准备契约,不能复用旧契约模型。
`test_rigid_pair_observer.py` 使用 `20260920_192330` 的中指 MCP/PIP/DIP 原始
图像,验证双 Tag 联合拟合、原 1°/1 mm 来源门限、DIP 有界父几何传递和正式证据
回读;保持关节变化、源验证角点变化、当前端点变化及删除必需证据均拒绝。
`test_observer_pose.py` 另用已知真值和故意偏置的父姿态初始化验证相对安装恢复,
防止把历史父模型当成精确测量。真实夹具包含上游完整来源和会话头。
受影响的旧观测/共享姿态/几何传递/配置/准备契约组 86 项通过,28.21 秒;
新刚体对与 staged JSON/URDF 产物链组 14 项通过,38.29 秒。
初次缺 ROS 环境的收集失败不计入通过;上述结果属于软件回放,不能代替整手实机验收。
staged `passed` 续采检查 `test_passed_resume.py` 的真实 coordinator 导入边界:
已通过访问必须先完整落盘才能跳过,未完成和未来轮次不被授权;导入中止和反馈失效仍暂停。
相关 staged 调度和断点启动组首次 21 项通过、4 项夹具失败(O6 不支持 staged、
旧合成观测缺会话字段);修正夹具后只重跑受影响的 6 项,1.39 秒全部通过。
统一观测策略使用 `test_observer_policy.py`:覆盖整图依赖选择、断开的角色关系、
历史模式不变、通过关节策略保持、幂等、序列化和执行校验。
`test_checkpoint_observer_upgrade.py` 检查完整审计及拒绝改动既有模型、训练决策、
运动/精度契约和保护哈希。`test_rigid_pair_observer.py` 增加真实食指夹具
`o30_index_rigid_pair.json.gz`,明确验证有歧义的数据不会因开启新策略而误通过;
中指原通过、篡改拒绝和正式证据回读用例继续保留。
本轮策略/迁移/准备契约/真实回放组 30 项通过、19.43 秒,另 1 项因没有 source
ROS 环境导入失败;恢复环境后只重跑该项及受影响的配置/观测兼容组,18 项通过、
2.29 秒。启动参数和准备辅助兼容组 32 项通过、9.99 秒,共 80 项通过。
最初把食指真实数据预设为可通过的实验出现来源歧义,已记录为负例;未降低任何
精度、候选区分或发布门限。这些结果是软件验证,不是整手实机通过。
有界独立观测使用 `test_observer_preparation.py`:检查按共同祖先生成动作、四分之一
行程限制、已通过测量不变、画面内旋转不算视角覆盖、模拟真值恢复、命令/反馈/路径/
完成记录篡改拒绝、原始证据导入、运动预算跨重启保持、固定端点等待上限及真实食指
提前触发。`o30_observer_initial_hold.json.gz` 保存 `20260920_210259` 的初始静止
记录,用于检查稳定窗口与图像时效分离,以及未开始侧摆的计划可以审计后继续。
初次兼容组 40 项通过、1 项测试替身缺少新增控制器,20.78 秒;补齐测试替身后,
新流程及受影响用例 14 项通过、2.80 秒。准备契约、调度、全相机原始像素与既有
staged JSON/URDF 产物链 41 项通过、29.74 秒。采集计划兼容组 12 项通过、4.39 秒。
修正实机暴露的时效契约及未开始动作的恢复边界后,新流程和旧刚体对回放 22 项
通过、19.98 秒。各组有重复用例,不累计为一次全量通过。
观测补采有限序列与父标签跨次反馈契约:`test_observer_preparation.py`、
`test_parent_reference_reuse.py`、`test_parent_reference_provenance.py` 验证真实
`20260920_211220` 的完整小幅补采、247→255 的跨次反馈、原图像精度检查、
历史 v1 语义、两步预算及未开始动作的恢复。父参考组 58 项通过,7.69 秒;
序列/迁移/准备契约组 36 项通过,5.63 秒;序列/调度组 27 项通过,6.33 秒;
状态显示组 8 项通过,1.47 秒。新增预算用例初次因测试夹具命令和反馈共享列表
重复缩放失败,修正夹具后该项通过,0.81 秒;反馈规则篡改拒绝项初次即通过。
记录在 `software_review_active_observer_sequence`,这些分组有重复,不累计成全量回归。
训练中的双 Tag 父姿态分支优化:`test_rigid_pair_observer.py` 与
`test_observer_preparation.py` 共 25 项通过,47.56 秒,包含 213013 实机补采源、
旧中指正式证据回读、源不足拒绝、预算与保留断点。逐帧多初值最大 320 次评估,
全局交替最多三次;旧冻结父姿态路径保留原 80 次评估。独立 DIP 几何仍因精度不足拒绝,
该组通过只表示观测源/兼容机制通过,不能宣称整手或本批 DIP 通过。
## 原 MCP/PIP 训练回程参考适配与无侧摆实测(2026-09-22 19:06)
本轮新适配器使用已有隔离求解工作进程,采集源有界且不读取第三轮;只授权父参考
检查,不授权物理零位、角度样本或产物发布。完整 coordinator 合成运动集成首次
1 项通过,16.43 秒;增加图像身份及模型证据断言后,`test_chain_capture.py` 与
`test_chain_capture_runtime.py` 28 项通过,18.52 秒。新增来源分箱、训练冻结、
清理后旧结果拒绝、未解模型拒绝等边界随后 4 项通过,0.84 秒。
工作进程、旧初始化及 CLI 组 25 项通过,7.44 秒;包括真实 spawn 对新请求/结果
类型的传递。实测后仅更新描述文字,CLI/分析输出受影响组 30 项通过,1.83 秒。
以上存在重叠用例,不相加为一次全量回归,未重复运行所有已通过用例。
实机 `20260922_190634` 完成小指 MCP/PIP/DIP 每任务连续 2+1:18 个方向单元
无暂停、无重扫;原始命令核查各三次往返,无侧摆或准备额外圈。冻结在线模型
后的第三轮几何检查 MCP/PIP 各 60/60 通过,最大 RMS 0.295269/0.448776 px;
DIP 入口父模型复用通过。`frozen_reference_live_audit.json` 留存逐帧结果。
这些是采集与几何检查通过,不等于 DIP 曲线、物理零位或 JSON/URDF 发布验收。
## 整指运动学联合模型离线检查(2026-09-22)
新增 `test_kinematic_chain_diagnostic.py` 和 `test_chain_candidate_audit.py`,
针对性运行 8 项通过,4.03 秒。覆盖共享铰链下的阶段参考姿态、合成真值恢复、
Schur 协方差与完整逆矩阵一致、冻结模型拒绝无法解释的阶段平移、相机坐标系
相关协方差,以及只按训练状态合并数值等价解、保留失败解和不等价候选。
真实 `20260922_142553` 回放完整枚举 64 个双标签初值,全部收敛;训练状态分成
4 个物理解族,原 0.01 / Holm 门限检验后保留 2 族。保留族的条件输出角度包络
为 MCP 0.136425°、PIP 0.197536°、DIP 0.655645°。原始拟合结果不重写,
分族和相机几何审计只读取保存参数,不重新拟合。证据位于
`calibration_output/O30_RIGHT_001/software_review_20260922_stability/` 下的
`kinematic_chain_camera_geometry_audit_142553.json`。
这不是正式标定稳定性通过:轴距采用精确 URDF 约束,验证仍逐帧估角,且反向数据
用于候选检验。尚缺物理零位绑定、冻结双向指令映射、未使用第三轮、完整输出误差
及 JSON/URDF 往返验收。新模块为非授权离线模型,未绕过正式父参考或发布门限。
## O30 raw_joint 正式链路(2026-09-22)
用户指定停滞重试(2026-09-22):仅 O30 的 `mechanical_stall` 增加同一运动
首次尝试加两次重试,每次重发前等待 3 秒,第 3 次失败停止。等待不发送新位置
指令,不作为有效采样;失联、控制权冲突及其他保护仍检查。真实 coordinator
虚拟时钟验证三次失败、两次等待和等待期间保护:3 passed,1.00 秒。
既有安全及反馈组 44 passed;另一条旧启动测试补充显式复用相机参数后通过。
测试夹具曾缺少 SDK 心跳,已补齐模拟心跳,未因此改变生产通信保护。
新默认协议及完整边界见 `O30_RAW_PROTOCOL_IMPLEMENTATION.md`。
针对性验证(不同组有重叠,不相加宣称一次全量回归):
- 原始采集、断点恢复、存储回归:35 passed,44.24 秒。覆盖首单元尚未通过的中断发现、跨恢复补采预算、实际 coordinator 三轮指令、缺失光学观测禁止运动。
- 全手运动效果及显式旧协议路径:3 passed,4.12 秒。原有两个接受关节角夹具改为显式 `TASKWISE_TWO_ROUND`,不修改新协议质量门限。
- 光学核验及候选集合门限:8 passed,5.84 秒。错误内外参、覆盖、序列号、同步拒绝;物理超差拒绝,标签安装不唯一不额外否决。
- 共享 CAD 图像导数、历史失败角点和发布回读:10 passed,51.30 秒;批量独立角度验证修正稀疏索引读取后,发布组 3 passed,10.92 秒。
- 外参工具、原子发布、状态及 O30 launch 公共契约:30 passed,2.32 秒。
- 最终候选门禁、共享整指/运动链及真实失败回归:23 passed,12.25 秒。正式终结入口缺少来源证据时,拒绝求解并保存失败状态:1 passed,4.76 秒。
- 光学核验采集同步上限统一为 50 ms 后,外参工具受影响组:10 passed,0.57 秒。源码编译与 `git diff --check` 通过。
- ID5/9/12 与 ID10 历史回归来自 `o30_raw_failure_observations.json.gz`,仅用于旧角点观察能力回归,不能恢复为新协议数据。
- `o30_training_candidate_parameters.npz` 保存两个合成训练解的参数,供配对损失/保留候选边界测试复用,不含任何实机标定授权。
完整合成真值数值回归:64 个初始化全部收敛,约 192.82 秒;每个候选总求值不超过 200。
训练像素门限排除 32 个,训练分层配对检验排除 16 个,剩余 16 个全部通过候选协方差与第三轮验证。
训练检验使用原 0.03 px 分辨率余量和 0.01 Holm 家族门限;第三轮不参与选择。
保留候选的零位三倍标准差与候选差异联合包络约 0.100°,绝对指令角约 0.192°,轴位置约 0.125 mm,FK 位置约 0.260 mm。
产物通过实际 robot_state_publisher、结构授权、JSON 重建及最终 FK 回读;后半程验收和发布约 10.38 秒。
只写入 `calibration_output/software_review_20260922_raw_joint/known_truth/` 和独立软件测试指针;实机 `latest_passed` 未修改。
未进行本次新协议全手实测或实机姿态对照;不能把以上软件结果当作现场精度证明。
## O30 轻量原始角点采集与立即补采(2026-09-23)
新增集中回归 `test_raw_lightweight.py`,不恢复外部删除的历史测试。覆盖当前方向立即补一次、
中断预算、仅固定标签在线跟踪、死区/平台、堵转/反向/噪声、静止采样冻结计时、
不稳定终点有界退出、采完禁止发送、封存校验、旧协议读取与混合拒绝、旧合成 URDF 重建。
首组 6 passed、5 failed(4.25 秒):五个失败是扫描测试没有先进入任务避让姿态,
修正夹具起始向量后只复查该组,5 passed(0.98 秒)。后续边界组 10 passed、
1 failed(4.18 秒):竞争控制测试缺少 Start 后反馈,补齐后单项 1 passed(0.77 秒)。
完整真实 coordinator 虚拟时钟、单主机位移动标签、108 方向、无补采、归位封存与
训练/验证分区检查 1 passed(85.13 秒),属于必要整手调度集成,不是实机通过。
旧合成产物 v1/v2 JSON→URDF 重建与终态入口 3 passed(0.87 秒)。
删重复图像记录并保留未稳定原始帧后,受影响采集/补采恢复/封存/兼容组
7 passed(6.25 秒)。各组存在重叠,不累计为一次全量测试。
正式 CLI 配置校验通过。实机 `20260923_104837` 去程通过,拇指旋转回程指令 162、
反馈 253,累计实际运动无推进 4.005 秒触发保护;未重复启动,控制栈已退出,
停在当前位置,未执行自动归位。仅 1/108 方向完成,未封存为完整采集、未发布 URDF。
同段移动标签角点最大位移约 0.47 px、固定基准约 0.50 px,未见明显回程运动;
不能据此删除停滞保护,也不能宣称全手稳定采集通过。
补充只读离线入口检查初次失败(3.61 秒):虚拟相机夹具缺少实际入口要求的
`rectified_camera_model` 日志,补齐夹具后在后续组通过,确认缺项拒绝发生于
离线验收、原始文件哈希不变且无新运动。
用户随后明确授权硬件停滞重试一次:复用现有 StallRetry,O30 原始采集限制为
首次尝试加一次重试,等待 3 秒,同方向共享预算;其他硬件保护不进入重试。
首组 6 passed、1 failed(4.19 秒):测试将保护停止发送的最后保持指令也误认为
等待期间推进,修正断言后四种实际 coordinator 场景 4 passed(1.31 秒)。
运动保护代码和计划身份同步更新,旧策略会话不得混入新断点。
补充端点阶段切换与反馈抖动回归:180 ms 图像延迟单项通过(7.87 秒),
扩大到 300 ms 并增加 4 u8 往返抖动后复现 3 个失败、4 个通过(14.95 秒)。
隔离模块验证有限端点等待和推进历史最高值修正,8 passed(7.98 秒),未影响当时运行的实机。
用户随后明确端点只看实际发送 SDK 指令:最终采用有效稳态记录中的已发送指令核验端点,
不额外增加端点等待、不要求反馈到 0/255,也不把稳态图混入运动覆盖。
真实 coordinator 300 ms 延迟、反馈端点仅 10/240、离线误判复核与中断拒绝,
加上方向推进、补采、封存及旧协议边界,17 passed(10.04 秒)。
SDK 身份错误保留原字段、其他硬件保护和单次停滞重试、旧合成 URDF 重建,
9 passed(1.86 秒)。
实机 `20260923_110651` 完成 35 个方向,原记录 29 个通过、6 个端点误判;
在小指 PIP 验证回程因 `o30_sdk_identity_mismatch` 停止,反馈仍约 29.81 Hz。
未触发停滞重试,未归位封存,未更新 `latest_passed`。异常身份字段在旧日志中缺失,
不能将此状态直接等同于物理通信断开。修正后只读复核同一数据,35 个完整方向均通过,
缺 73 个方向,禁止发布,源数据哈希未变化;报告见实现文档。
最终端点与推进规则下,完整 coordinator 单主机位、108 方向、归位封存及
训练/验证分区回归:1 passed(87.51 秒)。该必要整手集成仍仅为软件验证。
源码编译和 `git diff --check` 通过;未恢复外部删除的历史测试。
2026-09-23 用户确认 ID17 在拇指近端指节后修正安装连杆及产品哈希。
正式配置加载、拓扑反馈依赖 0/1/6、仅顶机位、17 任务/108 方向校验通过;
临时校验脚本首次误用 TagSpec.id,改为 tag_id 后通过,生产加载未失败。
既有原始角点、立即补采和旧协议边界回归 3 passed(0.85 秒)。
现有真实数据的局部拟合揭示原绑定错误,但修正后最大训练标签误差仍为 8.047 px,
原精度门限未通过;软件回归通过不代表实机 URDF 可发布。未重新运动或改写源证据。
2026-09-23 低精度离线草稿:新增集中 `test_raw_draft.py`,加已有合成正式 URDF
重建检查,13 passed(1.55 秒)。覆盖真实零位写入与 FK、缺测关节不改、JSON 零位/
曲线/来源篡改拒绝、不可观测零位拒绝、精度超限仍允许草稿、旧 Profile 仅允许标签
连杆修正、同步缺失帧不供给范围证据,以及 CLI 不进入运动或正式发布。
实测旧数据的正式 CLI 草稿路径已运行,生成完整结构 URDF 并通过标准 ROS 加载;
JSON/URDF FK 最大差 4.44e-16,两个拇指零位和四个行程实际改变。
独立精度验收仍失败,输出 `accuracy_passed=false`,没有更新 `latest_passed`。
第一次实数据导出因原始帧含空指令失败,修复共享证据过滤后 v2 成功;未删除失败审计。
2026-09-23 全手 15 零位/20 行程目标及采集后自动导出:受影响范围与真实 runner 生命周期
5 passed(1.75 秒),验证完整目标、部分数据不能冒充全手,以及成功/缺项/求解失败三种
情况下 SDK 栈、监控节点和自建 ROS 上下文均先关闭,离线阶段从不重新启动运动。
补充 Profile 原字节快照后,仅复查三个生命周期场景,3 passed(1.66 秒)。
正式 CLI --validate-only 通过,明确列出 15 个零位和 20 个行程目标。
必要的一次全手数学检查:独立 XML FK 生成 2028 帧训练角点,463 参数图像模型单初值
12 次联合迭代收敛;包括生成数据和秩检查共 31.82 秒,最大零位差 0.001054°。
复用保存参数进行完整 JSON→URDF 导出与标准加载,不重复拟合;15 个零位修正、五个
末端零位保留、20 个行程、最终 FK 最大差 5.55e-16 均通过。非实机验收、不更新正式指针。
2026-09-23 ID10 调整后的草稿断点:新增 `test_raw_remount.py`,验证 78 个连续通过方向
保留、旧 ID10 角点排除、原始像素不改、已用补采次数保留、重复调整声明拒绝、未知/
固定标签拒绝,以及固定基准和已知标签漂移仍拒绝。基准不可见标签只能进入草稿,不能
正式发布。首轮 5 passed,测试夹具共享列表导致 1 failed;修正夹具后,对失败项、CLI、
中断预算及受影响草稿契约运行 19 passed(2.74 秒)。没有恢复已删除的历史测试。
随后按 CAD 拓扑隔离整条受影响手指,验证旧观测不混用、已开始扫描禁止扩大排除范围,
受影响 6 passed(1.14 秒)。
2026-09-23 中指侧摆 0–80 短段:按已声明段长度缩放反馈分箱密度,保留图像/指令覆盖、
实际运动分辨率、4 秒推进及其他硬件保护。相关短段、堵转/反向/噪声、硬件保护、离线
生命周期与范围证据检查 14 passed(3.02 秒)。只读复核 `20260923_125329` 的两个
第二次完整尝试均通过,原始失败记录未改写,结果保存为 `short_segment_reassessment.json`。
## 离线零位方向回归
`test_raw_draft.py` 中的 `parallel_root_motion` 用同一批不变角点核对平行根轴方向,
四指 SDK/URDF 符号反转时必须拒绝,不能通过关节零位吸收错误。
`old_capture_model_correction` 验证离线绑定/方向修订仍保留原始采集身份,并拒绝速度变更。
方向用例来自首轮实采角点节选 `fixtures/o30_recorded_root_directions.json.gz`,
保留来源 SHA256,不含验证轮。其余数值回归仍使用合成数据;不发送硬件指令。
2026-09-23 拇指独立原始采集:局部范围编译保留 `raw_joint_2_plus_1`,清除被保留关节的
零位测量声明;训练策略允许经过范围契约验证的 O30 局部配置。复用原执行器和补采预算,
未修改拟合模型或运动保护。新增范围往返序列化、24 单元原路线/分区一致、立即补一次与归位后
结束测试,连同全手原调度两项回归共 4 passed(1.03 秒)。
实机会话 `thumb_recapture_20260923_175829/O30_RIGHT_001/20260923_180129` 在启动归位阶段
MCP 指令 0/反馈 120,两次无推进后停止;正式扫描 0/24,不能记为实机采集通过。
标定控制链已关闭,原全手数据未修改,等待现场机械接触观察。
2026-09-23 新外参拇指与旧四指离线组合:修复独立验证总判定的 NumPy bool_ JSON
序列化失败;新增冻结旧参考参数的图像拟合更新与显式局部验证范围。参数按语义转移,
不共享新旧相机外参,不改变 CAD 轴,无观测增量冻结,零位存在自由规范时拒绝。
`test_raw_joint_update.py test_raw_draft.py` 共 37 passed(20.74 秒);随后加入零位
可观测性保护,更新工具相关 3 passed(0.19 秒)。全手默认验证仍拒绝缺失关节,
局部报告不得视为全手验收。未恢复删除的历史测试。
实采组合草稿通过 JSON 重建、最终 FK(7.36e-16)、标准加载与 MuJoCo 编译。
曲线模型角度检查通过不等于 URDF 线性驱动通过:纯 URDF yaw 最大误差约 3.52°,
精度未认证,不更新 latest_passed。详见
`calibration_output/thumb_recapture_20260923_175829/offline_combined_new_thumb_draft/README.md`。
@@ -44,7 +44,7 @@ g20_thumb_calibration:
minimum_detection_hz: 15.0
maximum_hamming: 0
minimum_decision_margin: 30.0
# Trial threshold for the current 10 mm tags (observed at 32-38 px).
# Trial threshold for small/far tags (historically observed at 32-38 px).
# Final acceptance is still guarded by static RMS and random validation.
minimum_edge_pixels: 30.0
# Current 30 px tags measure about 0.50-0.53 deg RMS while stationary.
@@ -60,11 +60,8 @@ g20_thumb_calibration:
# 30-38 px tags are usable, but only if IPPE gives a tight image fit and
# a pose continuous with the preceding frame.
pnp_maximum_reprojection_error_px: 1.5
# All four tags keep a temporally continuous IPPE solution throughout the
# complete session. With 30 px planar tags, tiny reprojection differences
# do not reliably identify the physical branch and previously caused
# stationary T0 to flip by about 25 deg between scan and validation.
pnp_reprojection_tie_px: 1.5
# Independent IPPE selection inherits the shared near-tie tolerance from
# core/geometry/pnp.py; continuity cannot override a clear image-fit lead.
pnp_maximum_pose_jump_deg: 35.0
pnp_maximum_translation_jump_m: 0.04
pnp_maximum_tag_tilt_deg: 75.0
@@ -6,7 +6,7 @@
# from back-pressuring image_proc's reliable image publisher.
qos_profile: sensor_data
family: 36h11
size: 0.01
size: 0.016
profile: false
max_hamming: 0
detector:
@@ -20,11 +20,11 @@
tag:
ids: [0, 1, 2, 3]
frames: [tag_t0, tag_t3, tag_t4, tag_t5]
sizes: [0.010, 0.010, 0.010, 0.010]
sizes: [0.016, 0.016, 0.016, 0.016]
g20_thumb_calibration:
ros__parameters:
tag_roles: [t0, t3, t4, t5]
tag_ids: [0, 1, 2, 3]
tag_frames: [tag_t0, tag_t3, tag_t4, tag_t5]
tag_sizes_m: [0.010, 0.010, 0.010, 0.010]
tag_sizes_m: [0.016, 0.016, 0.016, 0.016]
@@ -0,0 +1,40 @@
schema_version: 3
profile_id: G20/right/g20_right_19/v1
profile_config: package://linkerhand_calibration/config/profiles/g20_right_19.yaml
profile_config_sha256: 958f74c476730636d2dadb58a30296a7cdf0b25ad00345252754f9c1b6b50c7f
model: G20
side: right
tag_layout: g20_right_19
serial_number: G20_RIGHT_001
can_interface: can0
output_root: calibration_output
cameras:
front:
serial_number: DB2163742
camera_name: hikrobot_front_DB2163742
camera_info: ~/.ros/camera_info/hikrobot_DB2163742.yaml
side:
serial_number: DB2163749
camera_name: hikrobot_side_DB2163749
camera_info: ~/.ros/camera_info/hikrobot_DB2163749.yaml
top:
serial_number: DB2163739
camera_name: hikrobot_top_DB2163739
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
artifacts:
source_urdf: package://linkerhand_calibration/urdf/g20_right/linkerhand_g20_right.urdf
source_urdf_sha256: eeb6ffb0e95d2a6acd4c26331ae68062e0d74160de4b552b4f6d395cce5ca4e8
camera_extrinsics: config/g20_three_camera_extrinsics.yaml
camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80
calibration_config: src/g20_thumb_apriltag_calibration/config/three_camera_calibration.yaml
calibration_config_sha256: 4927506d787d665c16f0209ee4d052654e648bb25beaa1913ed0d416f449d523
tag_config: src/g20_thumb_apriltag_calibration/config/three_camera_tags_g20_right_19.yaml
tag_config_sha256: b1ab45e97ae42d57b0a3a63c725107b5aa3828c06b2ced16f8222d6e9ebadc41
release:
# Each task already contains three training cycles plus an isolated fourth
# holdout, so a second complete hardware session duplicates hours of motion.
required_independent_passes: 1
static_repeatability_deg: 1.0
@@ -0,0 +1,59 @@
/l6_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [0, 1, 2]
frames: [front_base, thumb_pitch, thumb_dip]
sizes: [0.016, 0.016, 0.016]
/l6_calibration/side/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [3, 4, 5]
frames: [side_base, pinky_pitch, pinky_dip]
sizes: [0.016, 0.016, 0.016]
/l6_calibration/top/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [6, 7]
frames: [top_base, thumb_roll]
sizes: [0.016, 0.016]
@@ -0,0 +1,39 @@
schema_version: 3
profile_id: L6/right/l6_right_8/v1
profile_config: package://linkerhand_calibration/config/profiles/l6_right_8.yaml
profile_config_sha256: d241e87ded3a29d416a876b27f1a251f8daf2f98e3676ab277264f1bdfaf5c60
model: L6
side: right
tag_layout: l6_right_8
namespace: /l6_calibration
serial_number: L6_RIGHT_001
can_interface: can0
output_root: calibration_output
cameras:
front:
serial_number: DB2163742
camera_name: hikrobot_front_DB2163742
camera_info: ~/.ros/camera_info/hikrobot_DB2163742.yaml
side:
serial_number: DB2163749
camera_name: hikrobot_side_DB2163749
camera_info: ~/.ros/camera_info/hikrobot_DB2163749.yaml
top:
serial_number: DB2163739
camera_name: hikrobot_top_DB2163739
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
artifacts:
source_urdf: package://linkerhand_calibration/urdf/l6_right/linkerhand_l6v3.1_right.urdf
source_urdf_sha256: 298c1fbf5189648911426f530b50bdbeea4830cab9c54e20f46c532485df4666
camera_extrinsics: config/g20_three_camera_extrinsics.yaml
camera_extrinsics_sha256: dd623572df3cb83fdefcbe92204dab54a60f2c68eb3a8c9bdb08407e8f0e5d80
calibration_config: package://linkerhand_calibration/config/l6_three_camera_calibration.yaml
calibration_config_sha256: 090d82a5609e8b981c1b8c6ebeaf4c2f477323209ae3db3b9d8713ac6d6169b5
tag_config: package://linkerhand_calibration/config/l6_right_8_tags.yaml
tag_config_sha256: be1499eb947b61d2fe360ae2c92307a87710480fae8a9dd4cd171fc959fdcbf5
release:
required_independent_passes: 1
static_repeatability_deg: 1.0
@@ -0,0 +1,66 @@
l6_calibration:
ros__parameters:
command_topic: /l6/cb_right_hand_control_cmd
state_topic: /l6/cb_right_hand_state
setting_topic: /l6/cb_hand_setting_cmd
front_camera_info_topic: /l6_calibration/front/camera/camera_info
front_detections_topic: /l6_calibration/front/apriltag/detections
side_camera_info_topic: /l6_calibration/side/camera/camera_info
side_detections_topic: /l6_calibration/side/apriltag/detections
top_camera_info_topic: /l6_calibration/top/camera/camera_info
top_detections_topic: /l6_calibration/top/apriltag/detections
baseline_command_u8: [255, 255, 255, 255, 255, 255]
# L6_RIGHT_001 measured a 250->5 travel of only ~0.9 s at speed 10,
# which left fewer than 32 useful feedback bins. Speed 1 is still only a
# firmware ceiling: different L6 motors complete a full stroke in 0.7-1.3 s.
# A 100 Hz cosine trajectory therefore sets the actual, model-level pace.
preflight_speed_u8: 1
formal_speed_u8: 1
speed_settle_seconds: 0.2
command_trajectory_full_range_seconds: 6.0
torque_u8: 80
repetitions: 4
preflight_checkpoints_u8: [255, 127, 0]
tag_size_m: 0.016
tag_size_override_ids: [0, 1, 2, 3, 4, 5, 6, 7]
tag_size_overrides_m: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
minimum_detection_rate: 0.95
# Per-Tag quality remains >=95%. With three independently detected Tags,
# the fully joined frame rate may be 0.95^3 ~= 85.7%.
minimum_joint_frame_rate: 0.85
minimum_feedback_hz: 25.0
maximum_state_image_skew_ms: 50.0
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 30.0
pnp_maximum_reprojection_error_px: 1.5
pnp_maximum_pose_jump_deg: 35.0
pnp_maximum_translation_jump_m: 0.04
pnp_maximum_tag_tilt_deg: 75.0
pnp_tracker_reset_seconds: 5.0
minimum_sweep_frames: 40
minimum_state_span_u8: 240.0
minimum_sweep_bins: 32
maximum_bin_gap: 16
maximum_monotonic_correction_deg: 2.0
passive_maximum_monotonic_correction_deg: 3.0
maximum_validation_mae_deg: 1.0
maximum_validation_p95_deg: 2.0
maximum_validation_error_deg: 3.0
mimic_minimum_multiplier: 0.5
mimic_maximum_multiplier: 1.5
mimic_maximum_cycle_range: 0.03
mimic_maximum_residual_p95_deg: 2.0
endpoint_tolerance_u8: 2.0
endpoint_hold_seconds: 1.0
motor_stall_timeout_seconds: 2.0
position_timeout_seconds: 30.0
sweep_timeout_seconds: 90.0
automatic_sweep_retry_limit: 1
non_target_motion_tolerance_u8: 3.0
fixed_base_maximum_corner_drift_px: 5.0
fixed_base_movement_confirmation_frames: 10
@@ -0,0 +1,41 @@
/o12_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector: {threads: 4, decimate: 1.0, blur: 0.0, refine: true, sharpening: 0.25, debug: false}
pose_estimation_method: pnp
tag:
ids: [0, 1, 2, 3, 12, 13]
frames: [front_base, thumb_cmc, thumb_mcp, thumb_dip, middle_roll, index_roll]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
/o12_calibration/side/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector: {threads: 4, decimate: 1.0, blur: 0.0, refine: true, sharpening: 0.25, debug: false}
pose_estimation_method: pnp
tag:
ids: [4, 5, 6, 7, 8, 9, 10, 11]
frames: [side_base, pinky_mcp, pinky_pip, pinky_dip, middle_pip, middle_dip, index_pip, index_dip]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
/o12_calibration/top/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector: {threads: 4, decimate: 1.0, blur: 0.0, refine: true, sharpening: 0.25, debug: false}
pose_estimation_method: pnp
tag:
ids: [14, 15]
frames: [top_base, thumb_yaw]
sizes: [0.016, 0.016]
@@ -0,0 +1,47 @@
schema_version: 3
profile_id: O12/right/o12_right_16/v1
profile_config: package://linkerhand_calibration/config/profiles/o12_right_16.yaml
profile_config_sha256: 652c2a82dfc7532b4f62c42cebdad57b169435cf950c5f2ee910cc67c7972621
model: O12
side: right
tag_layout: o12_right_16
namespace: /o12_calibration
serial_number: O12_RIGHT_001
output_root: calibration_output
sdk:
driver: o12_sdk_bridge
python_package: src/agillink_omnihand_sdk/linux/x64/python/omnihand-1.1.8-cp312-cp312-linux_x86_64.whl
package_sha256: cae7a0d5bce7e7c9d72cc90a0dd15e152f11e2a1ecf04cb6761e8171399ff170
transport: hcan
setup: src/agillink_omnihand_sdk/linux/x64/ros2/jazzy/setup.bash
config: package://linkerhand_calibration/config/o12_sdk.yaml
config_sha256: 1e3c0942b32128943fbba27846a69a8483af6da9551d99ed687e45c4321ade9c
cameras:
front:
serial_number: DB2163742
camera_name: hikrobot_front_DB2163742
camera_info: ~/.ros/camera_info/hikrobot_DB2163742.yaml
side:
serial_number: DB2163749
camera_name: hikrobot_side_DB2163749
camera_info: ~/.ros/camera_info/hikrobot_DB2163749.yaml
top:
serial_number: DB2163739
camera_name: hikrobot_top_DB2163739
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
artifacts:
source_urdf: package://linkerhand_calibration/urdf/o12_right/linkerhand_o12_t3_right-0703.urdf
source_urdf_sha256: 75b3c18992a3640d2d7d36719da5ff75428ded477b97d9cf29adbe43a6eb27eb
camera_extrinsics: config/o12_three_camera_extrinsics.yaml
camera_extrinsics_sha256: 5a515d0706f4e67d5e26bfddb348b519817bd72e885ea9e43997e41016176e53
calibration_config: package://linkerhand_calibration/config/o12_three_camera_calibration.yaml
calibration_config_sha256: c2fcdd532e22c15013413e655311de2ca87107c23a96d5c043e8f340f232eebd
tag_config: package://linkerhand_calibration/config/o12_right_16_tags.yaml
tag_config_sha256: 41001c3afba74cc01eb524a75dc58561a37e9364fab029ea12d879156a008dab
release:
required_independent_passes: 1
static_repeatability_deg: 1.0
@@ -0,0 +1,45 @@
# OmniHand Pro 2025 (O12) Configuration
# connection_type: "zlgcan" | "hcan" | "socketcan" | "rs485" | "usb"
# request_interval_ms: min gap between requests, SDK default 0 = no throttling, range 0..100
# frame_recv_timeout_ms: single-frame reply wait, SDK default 50, range 10..1000
/**:
ros__parameters:
# This workstation uses one O12 right hand through HCAN device 0/channel 0.
# Leave left_hand undefined so the node does not try to open a second hand.
right_hand:
hand_device_id: 1
connection_type: "hcan"
canfd_device_id: 0
canfd_channel_id: 0
# O12 POSITION streams set-points and has no G20-style firmware speed
# interpolation, so do not throttle the 50 Hz calibration command stream.
request_interval_ms: 0
frame_recv_timeout_ms: 100
show_data_details: false
# Alternative ZLG CANFD example:
# right_hand:
# hand_device_id: 1
# connection_type: "zlgcan"
# canfd_device_id: 0
# canfd_channel_id: 0
# request_interval_ms: 0
# frame_recv_timeout_ms: 50
# show_data_details: false
# socketcan example
# left_hand:
# hand_device_id: 1
# connection_type: "socketcan"
# can_interface: "can0"
# request_interval_ms: 0
# frame_recv_timeout_ms: 50
# show_data_details: true
# right_hand:
# hand_device_id: 1
# connection_type: "socketcan"
# can_interface: "can1"
# request_interval_ms: 0
# frame_recv_timeout_ms: 50
# show_data_details: true
@@ -0,0 +1,53 @@
o12_calibration:
ros__parameters:
command_topic: /o12/right/joint_cmd
state_topic: /o12/right/joint_states
front_camera_info_topic: /o12_calibration/front/camera/camera_info
front_detections_topic: /o12_calibration/front/apriltag/detections
side_camera_info_topic: /o12_calibration/side/camera/camera_info
side_detections_topic: /o12_calibration/side/apriltag/detections
top_camera_info_topic: /o12_calibration/top/camera/camera_info
top_detections_topic: /o12_calibration/top/apriltag/detections
# O12 standard interface is radians. Scan the complete reviewed SDK range;
# the source CAD limits are outputs to correct, not acquisition limits.
# Raw 0..2000 mixed control is forbidden.
command_rate_hz: 50.0
repetitions: 4
tag_size_m: 0.016
tag_size_override_ids: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
tag_size_overrides_m: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
minimum_detection_rate: 0.95
minimum_joint_frame_rate: 0.85
minimum_feedback_hz: 35.0
maximum_state_image_skew_ms: 50.0
minimum_sweep_frames: 40
minimum_state_span_fraction: 0.90
minimum_sweep_bins: 32
maximum_bin_gap: 2
endpoint_tolerance_rad: 0.01
endpoint_hold_seconds: 0.5
motor_stall_timeout_seconds: 2.0
position_timeout_seconds: 60.0
sweep_timeout_seconds: 180.0
automatic_sweep_retry_limit: 1
non_target_motion_tolerance_rad: 0.015
maximum_temperature_c: 70
# 当前 O12 固件返回空温度报告。仍发起查询;5秒无结果后依赖已验证的
# joint_error_states bit1 过热保护,并在会话记录中明确标注降级。
temperature_report_required: false
temperature_fallback_after_seconds: 5.0
# 快速标定档:各任务按4倍请求,但拇指pitch、侧摆、屈曲及避让均有独立硬上限。
motion_speed_scale: 4.0
# 正弦速度加减速时间;中段保持关节限速,避免位置余弦全程低速。
trajectory_ramp_seconds: 0.4
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 30.0
fixed_base_maximum_corner_drift_px: 5.0
fixed_base_movement_confirmation_frames: 10
pnp_maximum_reprojection_error_px: 1.5
pnp_maximum_pose_jump_deg: 35.0
pnp_maximum_translation_jump_m: 0.04
pnp_maximum_tag_tilt_deg: 75.0
pnp_tracker_reset_seconds: 5.0
@@ -0,0 +1,57 @@
/o30_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [0, 1, 2, 12, 13, 14, 15]
frames: [front_base, thumb_mcp, thumb_ip, pinky_mcp_roll, ring_mcp_roll, middle_mcp_roll, index_mcp_roll]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
/o30_calibration/side/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [3, 4, 5, 6, 7, 8, 9, 10, 11]
frames: [side_base, pinky_pip, pinky_dip, ring_pip, ring_dip, middle_pip, middle_dip, index_pip, index_dip]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
/o30_calibration/top/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [16, 17]
frames: [top_base, thumb_cmc_yaw]
sizes: [0.016, 0.016]
@@ -0,0 +1,40 @@
schema_version: 3
profile_id: O30/right/o30_right_18/v1
profile_config: package://linkerhand_calibration/config/profiles/o30_right_18.yaml
profile_config_sha256: 0de1efee3ca95a52d04f289f4a118d2119b81e4a84e247f56f2176ea64130b39
model: O30
side: right
tag_layout: o30_right_18
namespace: /o30_calibration
serial_number: O30_RIGHT_001
can_interface: can0
output_root: calibration_output
resume_mode: verify
cameras:
front:
serial_number: DB2163742
camera_name: hikrobot_front_DB2163742
camera_info: ~/.ros/camera_info/hikrobot_DB2163742.yaml
side:
serial_number: DB2163749
camera_name: hikrobot_side_DB2163749
camera_info: ~/.ros/camera_info/hikrobot_DB2163749.yaml
top:
serial_number: DB2163739
camera_name: hikrobot_top_DB2163739
camera_info: ~/.ros/camera_info/hikrobot_DB2163739.yaml
artifacts:
source_urdf: package://linkerhand_calibration/urdf/o30_right/linkerhand_O30i_right-V2_0819.urdf
source_urdf_sha256: 2be8428498ed8c17d7c39dee50e76ece6d362310cf26cd43834b8f4c79021b5b
camera_extrinsics: config/o30_three_camera_extrinsics_20260923_181333.yaml
camera_extrinsics_sha256: e65a09b31bf10aed2fe0926585d446d209929fe7fc84f55f641b0b08a9ef41aa
calibration_config: package://linkerhand_calibration/config/o30_three_camera_calibration.yaml
calibration_config_sha256: aeb7826ac00e7ab4c32b131319b34bc11a849c668f268eec2bb4fa12fb081970
tag_config: package://linkerhand_calibration/config/o30_right_18_tags.yaml
tag_config_sha256: baabc4269883dc1fa124f37a95b812734f04456a31f8d8ce52cf2a541df2cdfe
release:
required_independent_passes: 1
static_repeatability_deg: 1.0
sdk:
driver: linker_hand_o30_ros2_sdk
transport: libcanbus
@@ -0,0 +1,58 @@
o30_calibration:
ros__parameters:
command_topic: /cb_right_hand_control_cmd
state_topic: /cb_right_hand_state
setting_topic: /cb_right_hand_setting_cmd
front_camera_info_topic: /o30_calibration/front/camera/camera_info
front_detections_topic: /o30_calibration/front/apriltag/detections
side_camera_info_topic: /o30_calibration/side/camera/camera_info
side_detections_topic: /o30_calibration/side/apriltag/detections
top_camera_info_topic: /o30_calibration/top/camera/camera_info
top_detections_topic: /o30_calibration/top/apriltag/detections
baseline_command_u8: [0, 0, 255, 196, 127, 52, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
baseline_speed_u8: 200
preflight_speed_u8: 200
formal_speed_u8: 200
speed_settle_seconds: 0.2
command_trajectory_full_range_seconds: 20.02765316663493
torque_u8: 200
repetitions: 4
preflight_checkpoints_u8: [0, 127, 255]
tag_size_m: 0.016
tag_size_override_ids: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]
tag_size_overrides_m: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016,
0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
minimum_detection_rate: 0.95
minimum_joint_frame_rate: 0.85
minimum_feedback_hz: 25.0
maximum_state_image_skew_ms: 50.0
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 30.0
pnp_maximum_reprojection_error_px: 1.5
pnp_maximum_pose_jump_deg: 35.0
pnp_maximum_translation_jump_m: 0.04
pnp_maximum_tag_tilt_deg: 75.0
pnp_tracker_reset_seconds: 5.0
minimum_sweep_frames: 40
minimum_state_span_u8: 240.0
minimum_sweep_bins: 32
maximum_bin_gap: 16
maximum_monotonic_correction_deg: 2.0
passive_maximum_monotonic_correction_deg: 3.0
maximum_validation_mae_deg: 1.0
maximum_validation_p95_deg: 2.0
maximum_validation_error_deg: 3.0
mimic_minimum_multiplier: 0.5
mimic_maximum_multiplier: 2.2
mimic_maximum_cycle_range: 0.03
mimic_maximum_residual_p95_deg: 2.0
endpoint_tolerance_u8: 2.0
endpoint_hold_seconds: 1.0
motor_stall_timeout_seconds: 4.0
position_timeout_seconds: 60.0
sweep_timeout_seconds: 90.0
automatic_sweep_retry_limit: 1
non_target_motion_tolerance_u8: 3.0
fixed_base_maximum_corner_drift_px: 5.0
fixed_base_movement_confirmation_frames: 10
@@ -0,0 +1,59 @@
/o6_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.0165
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [0, 1, 2]
frames: [front_base, thumb_pitch, thumb_ip]
sizes: [0.0165, 0.0165, 0.0165]
/o6_calibration/side/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.0165
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [3, 4, 5]
frames: [side_base, pinky_pitch, pinky_dip]
sizes: [0.0165, 0.0165, 0.0165]
/o6_calibration/top/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.0165
max_hamming: 0
detector:
threads: 4
decimate: 1.0
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [6, 7]
frames: [top_base, thumb_yaw]
sizes: [0.0165, 0.0165]
@@ -0,0 +1,39 @@
schema_version: 3
profile_id: O6/right/o6_right_8/v1
profile_config: package://linkerhand_calibration/config/profiles/o6_right_8.yaml
profile_config_sha256: 72bf56b13eb2a0d4d14a5be42838b2ffc176722f470fe1a3ae5fd84604e1df4d
model: O6
side: right
tag_layout: o6_right_8
namespace: /o6_calibration
serial_number: O6_RIGHT_001
can_interface: can0
output_root: calibration_output
cameras:
front:
serial_number: DB2163742
camera_name: hikrobot_front_DB2163742
camera_info: config/o6_camera_intrinsics_20260915/hikrobot_DB2163742.yaml
side:
serial_number: DB2163749
camera_name: hikrobot_side_DB2163749
camera_info: config/o6_camera_intrinsics_20260915/hikrobot_DB2163749.yaml
top:
serial_number: DB2163739
camera_name: hikrobot_top_DB2163739
camera_info: config/o6_camera_intrinsics_20260915/hikrobot_DB2163739.yaml
artifacts:
source_urdf: package://linkerhand_calibration/urdf/o6_right/linkerhand_o6_right.urdf
source_urdf_sha256: 8f184faad699fbf771e388f109a4e8793b5cb190c33a87b2eba8491a3a37dd62
camera_extrinsics: config/o6_three_camera_extrinsics_20260915_192517.yaml
camera_extrinsics_sha256: d057183eaba592149ec4c63c4ceab8a70e1a0c9d7cf2460a1c8b7e86a6ca6638
calibration_config: package://linkerhand_calibration/config/o6_three_camera_calibration.yaml
calibration_config_sha256: 49ce8a0d317695994c5b306e3a3a28d0470b8769c77f1173ca935178430587db
tag_config: package://linkerhand_calibration/config/o6_right_8_tags.yaml
tag_config_sha256: 219b8aa906fc9e2a13f3bbede46f542ff176ef2c7f60190f0dd2508993f04593
release:
required_independent_passes: 1
static_repeatability_deg: 1.0
@@ -0,0 +1,63 @@
o6_calibration:
ros__parameters:
command_topic: /o6/cb_right_hand_control_cmd
state_topic: /o6/cb_right_hand_state
setting_topic: /o6/cb_hand_setting_cmd
front_camera_info_topic: /o6_calibration/front/camera/camera_info
front_detections_topic: /o6_calibration/front/apriltag/detections
side_camera_info_topic: /o6_calibration/side/camera/camera_info
side_detections_topic: /o6_calibration/side/apriltag/detections
top_camera_info_topic: /o6_calibration/top/camera/camera_info
top_detections_topic: /o6_calibration/top/apriltag/detections
baseline_command_u8: [255, 255, 255, 255, 255, 255]
# O6 has a different speed scale from L6. Motion is still bounded by the
# six-second cosine command trajectory; these values are firmware limits.
baseline_speed_u8: 80
preflight_speed_u8: 60
formal_speed_u8: 40
speed_settle_seconds: 0.2
command_trajectory_full_range_seconds: 6.0
torque_u8: 80
repetitions: 4
preflight_checkpoints_u8: [255, 127, 0]
tag_size_m: 0.0165
tag_size_override_ids: [0, 1, 2, 3, 4, 5, 6, 7]
tag_size_overrides_m: [0.0165, 0.0165, 0.0165, 0.0165, 0.0165, 0.0165, 0.0165, 0.0165]
minimum_detection_rate: 0.95
minimum_joint_frame_rate: 0.85
minimum_feedback_hz: 25.0
maximum_state_image_skew_ms: 50.0
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 30.0
pnp_maximum_reprojection_error_px: 1.5
pnp_maximum_pose_jump_deg: 35.0
pnp_maximum_translation_jump_m: 0.04
pnp_maximum_tag_tilt_deg: 75.0
pnp_tracker_reset_seconds: 5.0
minimum_sweep_frames: 40
minimum_state_span_u8: 240.0
minimum_sweep_bins: 32
maximum_bin_gap: 16
maximum_monotonic_correction_deg: 2.0
passive_maximum_monotonic_correction_deg: 3.0
maximum_validation_mae_deg: 1.0
maximum_validation_p95_deg: 2.0
maximum_validation_error_deg: 3.0
mimic_minimum_multiplier: 0.5
mimic_maximum_multiplier: 2.2
mimic_maximum_cycle_range: 0.03
mimic_maximum_residual_p95_deg: 2.0
endpoint_tolerance_u8: 2.0
endpoint_hold_seconds: 1.0
motor_stall_timeout_seconds: 2.0
position_timeout_seconds: 30.0
sweep_timeout_seconds: 90.0
automatic_sweep_retry_limit: 1
non_target_motion_tolerance_u8: 3.0
fixed_base_maximum_corner_drift_px: 5.0
fixed_base_movement_confirmation_frames: 10
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,418 @@
schema_version: 1
profile_id: L6/right/l6_right_8/v1
namespace: /l6_calibration
sdk_adapter: legacy_byte_sdk
command:
sdk_to_joint_direction: [-1, -1, -1, -1, -1, -1]
names:
- thumb_cmc_pitch
- thumb_cmc_roll
- index_mcp_pitch
- middle_mcp_pitch
- ring_mcp_pitch
- pinky_mcp_pitch
baseline_u8:
- 255
- 255
- 255
- 255
- 255
- 255
command_index_by_joint:
rh_thumb_cmc_pitch: 0
rh_thumb_cmc_roll: 1
rh_index_mcp_pitch: 2
rh_middle_mcp_pitch: 3
rh_ring_mcp_pitch: 4
rh_pinky_mcp_pitch: 5
disabled_indices: []
urdf_joint_by_joint:
rh_thumb_cmc_pitch: rh_thumb_cmc_pitch
rh_thumb_cmc_roll: rh_thumb_cmc_roll
rh_index_mcp_pitch: rh_index_mcp_pitch
rh_middle_mcp_pitch: rh_middle_mcp_pitch
rh_ring_mcp_pitch: rh_ring_mcp_pitch
rh_pinky_mcp_pitch: rh_pinky_mcp_pitch
feedback_name_aliases:
thumb_cmc_yaw: thumb_cmc_roll
speed_slot_by_command_index:
'0': 0
'1': 1
'2': 2
'3': 3
'4': 4
'5': 5
unit: u8
baseline: []
lower_bounds: []
upper_bounds: []
feedback_lower_bounds: []
feedback_upper_bounds: []
feedback_by_index: false
vision:
views:
- name: front
tags:
- role: front_base
fixed_reference: true
id: 0
- role: thumb_pitch
fixed_reference: false
id: 1
- role: thumb_dip
fixed_reference: false
id: 2
- name: side
tags:
- role: side_base
fixed_reference: true
id: 3
- role: pinky_pitch
fixed_reference: false
id: 4
- role: pinky_dip
fixed_reference: false
id: 5
- name: top
tags:
- role: top_base
fixed_reference: true
id: 6
- role: thumb_roll
fixed_reference: false
id: 7
common_frame: calibration_common
extrinsic_reference_view: front
extrinsics_quality_limits:
reprojection_rms_px: 1.2
maximum_rotation_repeatability_deg: 0.3
maximum_translation_repeatability_m: 0.0015
minimum_capture_counts:
front_side_captures: 15
front_top_captures: 15
motion:
tasks:
- key: thumb_roll_top
view: top
command_index: 1
joints:
- rh_thumb_cmc_roll
auxiliary_commands:
- - 0
- 255
validation_only: false
start_u8: 255
end_u8: 0
preflight_speed_u8: 1
formal_speed_u8: 1
start: null
end: null
preflight_speed: null
formal_speed: null
- key: thumb_pitch_dip_front
view: front
command_index: 0
joints:
- rh_thumb_cmc_pitch
- rh_thumb_dip
auxiliary_commands:
- - 1
- 255
validation_only: false
start_u8: 255
end_u8: 0
preflight_speed_u8: 1
formal_speed_u8: 1
start: null
end: null
preflight_speed: null
formal_speed: null
- key: pinky_pitch_dip_side
view: side
command_index: 5
joints:
- rh_pinky_mcp_pitch
- rh_pinky_dip
auxiliary_commands: []
validation_only: false
start_u8: 255
end_u8: 0
preflight_speed_u8: 1
formal_speed_u8: 1
start: null
end: null
preflight_speed: null
formal_speed: null
preparation_waypoints_u8: []
safe_return_waypoints_u8: []
speed_parameters:
preflight_u8: 1
formal_u8: 1
speed_settle_seconds: 0.2
command_trajectory_full_range_seconds: 6.0
torque_u8: 80
endpoint_hold_seconds: 1.0
stall_timeout_seconds: 2.0
precheck_sweeps: false
steady_command_checkpoints: false
joint_zero_references:
rh_thumb_cmc_roll:
task_key: thumb_roll_top
command: [255, 255, 255, 255, 255, 255]
approach_commands:
- [255, 0, 255, 255, 255, 255]
rh_thumb_cmc_pitch:
task_key: thumb_pitch_dip_front
command: [255, 255, 255, 255, 255, 255]
approach_commands:
- [0, 255, 255, 255, 255, 255]
rh_thumb_dip:
task_key: thumb_pitch_dip_front
command: [255, 255, 255, 255, 255, 255]
approach_commands:
- [0, 255, 255, 255, 255, 255]
rh_pinky_mcp_pitch:
task_key: pinky_pitch_dip_side
command: [255, 255, 255, 255, 255, 255]
approach_commands:
- [255, 255, 255, 255, 255, 0]
rh_pinky_dip:
task_key: pinky_pitch_dip_side
command: [255, 255, 255, 255, 255, 255]
approach_commands:
- [255, 255, 255, 255, 255, 0]
measurement:
transferred_motion_sources:
rh_index_mcp_pitch: rh_pinky_mcp_pitch
rh_index_dip: rh_pinky_dip
rh_middle_mcp_pitch: rh_pinky_mcp_pitch
rh_middle_dip: rh_pinky_dip
rh_ring_mcp_pitch: rh_pinky_mcp_pitch
rh_ring_dip: rh_pinky_dip
measurements:
rh_thumb_cmc_roll:
joint: rh_thumb_cmc_roll
kind: relative_rotation
view: top
parent_role: top_base
child_role: thumb_roll
validation_source: null
pose_axis_line_required: true
rh_thumb_cmc_pitch:
joint: rh_thumb_cmc_pitch
kind: relative_rotation
view: front
parent_role: front_base
child_role: thumb_pitch
validation_source: null
pose_axis_line_required: true
rh_thumb_dip:
joint: rh_thumb_dip
kind: relative_rotation
view: front
parent_role: thumb_pitch
child_role: thumb_dip
validation_source: null
pose_axis_line_required: true
rh_pinky_mcp_pitch:
joint: rh_pinky_mcp_pitch
kind: relative_rotation
view: side
parent_role: side_base
child_role: pinky_pitch
validation_source: null
pose_axis_line_required: true
rh_pinky_dip:
joint: rh_pinky_dip
kind: relative_rotation
view: side
parent_role: pinky_pitch
child_role: pinky_dip
validation_source: null
pose_axis_line_required: true
cross_view_sources: {}
image_curve_joints: []
directional_zero: true
cross_view_roll_curve: false
stable_cross_view_cone_bias: false
zero:
cad_zero_assumptions:
rh_index_dip: User accepts original CAD joint zero as the physical baseline; not
independently measured.
rh_middle_dip: User accepts original CAD joint zero as the physical baseline; not
independently measured.
rh_pinky_dip: User accepts original CAD joint zero as the physical baseline; not
independently measured.
rh_ring_dip: User accepts original CAD joint zero as the physical baseline; not
independently measured.
rh_thumb_dip: User accepts original CAD joint zero as the physical baseline; not
independently measured.
active_joints:
- rh_index_mcp_pitch
- rh_middle_mcp_pitch
- rh_pinky_mcp_pitch
- rh_ring_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_roll
passive_joints:
- rh_index_dip
- rh_middle_dip
- rh_pinky_dip
- rh_ring_dip
- rh_thumb_dip
direct_zero_joints:
- rh_pinky_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_roll
axis_joints:
- rh_pinky_dip
- rh_pinky_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_roll
- rh_thumb_dip
mechanical_endpoint_joints: []
post_solve_endpoint_joints: []
mimic_source_by_joint:
rh_thumb_dip: rh_thumb_cmc_pitch
rh_index_dip: rh_index_mcp_pitch
rh_middle_dip: rh_middle_mcp_pitch
rh_ring_dip: rh_ring_mcp_pitch
rh_pinky_dip: rh_pinky_mcp_pitch
cad_frozen_joints:
- rh_index_dip
- rh_middle_dip
- rh_pinky_dip
- rh_ring_dip
- rh_thumb_dip
endpoint_anchor_by_joint: {}
fitted_mimic_joints:
- rh_pinky_dip
- rh_thumb_dip
coupling_model_by_joint:
rh_thumb_dip: linear_mimic
rh_pinky_dip: linear_mimic
rh_index_dip: linear_mimic
rh_middle_dip: linear_mimic
rh_ring_dip: linear_mimic
transferred_zero_sources: {rh_index_mcp_pitch: rh_pinky_mcp_pitch, rh_middle_mcp_pitch: rh_pinky_mcp_pitch, rh_ring_mcp_pitch: rh_pinky_mcp_pitch}
transferred_mimic_sources: {rh_index_dip: rh_pinky_dip, rh_middle_dip: rh_pinky_dip, rh_ring_dip: rh_pinky_dip}
spatial:
base_pose_strategy: thumb_serial
root_anchor_joints: [rh_thumb_cmc_roll]
orientation_anchor_joint: rh_pinky_mcp_pitch
directed_base_axis_joints: [rh_thumb_cmc_roll, rh_pinky_mcp_pitch]
depth_free_axis_projection: true
axis_order: [rh_thumb_cmc_roll, rh_thumb_cmc_pitch, rh_thumb_dip, rh_pinky_mcp_pitch, rh_pinky_dip]
axis_parent_joint: {rh_thumb_cmc_pitch: rh_thumb_cmc_roll}
phase_parent_joint: {rh_thumb_dip: rh_thumb_cmc_pitch, rh_pinky_dip: rh_pinky_mcp_pitch}
offset_observer_joint: {rh_thumb_cmc_roll: rh_thumb_cmc_pitch, rh_thumb_cmc_pitch: rh_thumb_dip, rh_pinky_mcp_pitch: rh_pinky_dip}
quality:
training_cycles:
- 0
- 1
- 2
holdout_cycle: 3
hard_threshold_keys:
- maximum_mimic_residual_rad
- maximum_state_image_skew_ms
- maximum_validation_error_rad
- minimum_detection_rate
retry_metric_scope: {}
isolated_holdout: true
scope:
calibrate_joints:
partial:
- rh_pinky_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_roll
frozen_joints:
partial:
- rh_index_mcp_pitch
- rh_middle_mcp_pitch
- rh_ring_mcp_pitch
default_scope: partial
artifacts:
output_schema_version: 3
calibration_filename: l6_right_{serial_number}_partial_calibration.json
corrected_urdf_filename: linkerhand_l6_right_{serial_number}_partial_zero_calibrated.urdf
protected_input_fields:
- calibration_config_sha256
- camera_extrinsics_sha256
- profile_config_sha256
- source_urdf_sha256
- tag_config_sha256
publication_pointer: latest_partial_passed
session_compatibility_tokens:
- feedback_curves_v6
- l6_partial_v1
publish_corrected_urdf: true
acquisition:
command_capture_mode: separate
joint_zero_timeout_seconds: 5.0
policy_version: unified_engine_v8_all_view_images
mapping_probe_maximum_rad: 0.0
automatic_rescan_limit: 1
minimum_valid_samples: 40
minimum_bins: 32
maximum_unobserved_fraction: 0.0625
legacy_minimum_span_01: 0.9411764705882353
physical_first_cycle_minimum_span_01: 0.85
physical_repeat_minimum_fraction: 0.9
stall_timeout_seconds: 2.0
feedback_stale_seconds: 1.0
fixed_reference_minimum_frames: 10
fixed_reference_maximum_drift_px: 5.0
fixed_reference_confirmation_frames: 10
urdf:
authorized_fields:
rh_pinky_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_thumb_cmc_roll:
- limit.lower
- limit.upper
- origin.rpy
rh_index_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_thumb_cmc_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_middle_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_ring_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_ring_dip:
- mimic.multiplier
- mimic.offset
rh_thumb_dip:
- mimic.multiplier
- mimic.offset
rh_index_dip:
- mimic.multiplier
- mimic.offset
rh_pinky_dip:
- mimic.multiplier
- mimic.offset
rh_middle_dip:
- mimic.multiplier
- mimic.offset
joint_coverage:
rh_pinky_mcp_pitch: measured_static_dynamic
rh_thumb_cmc_roll: measured_static_dynamic
rh_thumb_cmc_pitch: measured_static_dynamic
rh_middle_mcp_pitch: transferred_static_dynamic
rh_index_mcp_pitch: transferred_static_dynamic
rh_ring_mcp_pitch: transferred_static_dynamic
rh_pinky_dip: measured_dynamic_cad_static
rh_middle_dip: transferred_dynamic_cad_static
rh_ring_dip: transferred_dynamic_cad_static
rh_thumb_dip: measured_dynamic_cad_static
rh_index_dip: transferred_dynamic_cad_static
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,472 @@
schema_version: 1
profile_id: O6/right/o6_right_8/v1
namespace: /o6_calibration
sdk_adapter: legacy_byte_sdk
command:
sdk_to_joint_direction: [-1, -1, -1, -1, -1, -1]
names:
- thumb_cmc_pitch
- thumb_cmc_yaw
- index_mcp_pitch
- middle_mcp_pitch
- ring_mcp_pitch
- pinky_mcp_pitch
baseline_u8:
- 255
- 255
- 255
- 255
- 255
- 255
command_index_by_joint:
rh_thumb_cmc_pitch: 0
rh_thumb_cmc_yaw: 1
rh_index_mcp_pitch: 2
rh_middle_mcp_pitch: 3
rh_ring_mcp_pitch: 4
rh_pinky_mcp_pitch: 5
disabled_indices: []
urdf_joint_by_joint:
rh_thumb_cmc_pitch: rh_thumb_cmc_pitch
rh_thumb_cmc_yaw: rh_thumb_cmc_yaw
rh_index_mcp_pitch: rh_index_mcp_pitch
rh_middle_mcp_pitch: rh_middle_mcp_pitch
rh_ring_mcp_pitch: rh_ring_mcp_pitch
rh_pinky_mcp_pitch: rh_pinky_mcp_pitch
feedback_name_aliases: {}
speed_slot_by_command_index:
'0': 0
'1': 1
'2': 2
'3': 3
'4': 4
'5': 5
unit: u8
baseline: []
lower_bounds: []
upper_bounds: []
feedback_lower_bounds: []
feedback_upper_bounds: []
feedback_by_index: false
vision:
views:
- name: front
tags:
- role: front_base
fixed_reference: true
id: 0
size_m: 0.0165
- role: thumb_pitch
fixed_reference: false
id: 1
size_m: 0.0165
- role: thumb_ip
fixed_reference: false
id: 2
size_m: 0.0165
- name: side
tags:
- role: side_base
fixed_reference: true
id: 3
size_m: 0.0165
- role: pinky_pitch
fixed_reference: false
id: 4
size_m: 0.0165
- role: pinky_dip
fixed_reference: false
id: 5
size_m: 0.0165
- name: top
tags:
- role: top_base
fixed_reference: true
id: 6
size_m: 0.0165
- role: thumb_yaw
fixed_reference: false
id: 7
size_m: 0.0165
link: rh_thumb_distal
common_frame: calibration_common
extrinsic_reference_view: front
extrinsics_quality_limits:
reprojection_rms_px: 1.5
maximum_rotation_repeatability_deg: 0.3
maximum_translation_repeatability_m: 0.0015
minimum_capture_counts:
front_side_captures: 15
front_top_captures: 15
motion:
tasks:
- key: thumb_yaw_top
view: top
command_index: 1
joints:
- rh_thumb_cmc_yaw
auxiliary_commands: []
validation_only: false
start_u8: 255
end_u8: 0
preflight_speed_u8: 60
formal_speed_u8: 40
start: null
end: null
preflight_speed: null
formal_speed: null
- key: thumb_pitch_ip_front
view: front
command_index: 0
joints:
- rh_thumb_cmc_pitch
- rh_thumb_ip
auxiliary_commands: []
validation_only: false
start_u8: 255
end_u8: 0
preflight_speed_u8: 60
formal_speed_u8: 40
start: null
end: null
preflight_speed: null
formal_speed: null
- key: pinky_pitch_dip_side
view: side
command_index: 5
joints:
- rh_pinky_mcp_pitch
- rh_pinky_dip
auxiliary_commands: []
validation_only: false
start_u8: 255
end_u8: 0
preflight_speed_u8: 60
formal_speed_u8: 40
start: null
end: null
preflight_speed: null
formal_speed: null
preparation_waypoints_u8: []
safe_return_waypoints_u8: []
speed_parameters:
baseline_u8: 80
preflight_u8: 60
formal_u8: 40
speed_settle_seconds: 0.2
command_trajectory_full_range_seconds: 6.0
torque_u8: 80
endpoint_hold_seconds: 1.0
stall_timeout_seconds: 2.0
precheck_sweeps: false
steady_command_checkpoints: false
joint_zero_references:
rh_thumb_cmc_yaw:
task_key: thumb_yaw_top
command: [255, 255, 255, 255, 255, 255]
approach_commands:
- [255, 0, 255, 255, 255, 255]
rh_thumb_cmc_pitch:
task_key: thumb_pitch_ip_front
command: [255, 255, 255, 255, 255, 255]
approach_commands:
- [0, 255, 255, 255, 255, 255]
rh_thumb_ip:
task_key: thumb_pitch_ip_front
command: [255, 255, 255, 255, 255, 255]
approach_commands:
- [0, 255, 255, 255, 255, 255]
rh_pinky_mcp_pitch:
task_key: pinky_pitch_dip_side
command: [255, 255, 255, 255, 255, 255]
approach_commands:
- [255, 255, 255, 255, 255, 0]
rh_pinky_dip:
task_key: pinky_pitch_dip_side
command: [255, 255, 255, 255, 255, 255]
approach_commands:
- [255, 255, 255, 255, 255, 0]
measurement:
transferred_motion_sources:
rh_index_mcp_pitch: rh_pinky_mcp_pitch
rh_index_dip: rh_pinky_dip
rh_middle_mcp_pitch: rh_pinky_mcp_pitch
rh_middle_dip: rh_pinky_dip
rh_ring_mcp_pitch: rh_pinky_mcp_pitch
rh_ring_dip: rh_pinky_dip
measurements:
rh_thumb_cmc_yaw:
joint: rh_thumb_cmc_yaw
kind: relative_rotation
view: top
parent_role: top_base
child_role: thumb_yaw
validation_source: null
pose_axis_line_required: true
rh_thumb_cmc_pitch:
joint: rh_thumb_cmc_pitch
kind: relative_rotation
view: front
parent_role: front_base
child_role: thumb_pitch
validation_source: null
pose_axis_line_required: true
rh_thumb_ip:
joint: rh_thumb_ip
kind: relative_rotation
view: front
parent_role: thumb_pitch
child_role: thumb_ip
validation_source: null
pose_axis_line_required: true
rh_pinky_mcp_pitch:
joint: rh_pinky_mcp_pitch
kind: relative_rotation
view: side
parent_role: side_base
child_role: pinky_pitch
validation_source: null
pose_axis_line_required: true
rh_pinky_dip:
joint: rh_pinky_dip
kind: relative_rotation
view: side
parent_role: pinky_pitch
child_role: pinky_dip
validation_source: null
pose_axis_line_required: true
cross_view_sources: {}
image_curve_joints: []
directional_zero: true
cross_view_roll_curve: false
stable_cross_view_cone_bias: false
zero:
cad_zero_assumptions:
rh_index_dip: User accepts original CAD joint zero as the physical baseline; not
independently measured.
rh_middle_dip: User accepts original CAD joint zero as the physical baseline; not
independently measured.
rh_pinky_dip: User accepts original CAD joint zero as the physical baseline; not
independently measured.
rh_ring_dip: User accepts original CAD joint zero as the physical baseline; not
independently measured.
rh_thumb_ip: User accepts original CAD joint zero as the physical baseline; not
independently measured.
active_joints:
- rh_index_mcp_pitch
- rh_middle_mcp_pitch
- rh_pinky_mcp_pitch
- rh_ring_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_yaw
passive_joints:
- rh_index_dip
- rh_middle_dip
- rh_pinky_dip
- rh_ring_dip
- rh_thumb_ip
direct_zero_joints:
- rh_pinky_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_yaw
axis_joints:
- rh_pinky_dip
- rh_pinky_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_yaw
- rh_thumb_ip
mechanical_endpoint_joints: []
post_solve_endpoint_joints: []
mimic_source_by_joint:
rh_thumb_ip: rh_thumb_cmc_pitch
rh_index_dip: rh_index_mcp_pitch
rh_middle_dip: rh_middle_mcp_pitch
rh_ring_dip: rh_ring_mcp_pitch
rh_pinky_dip: rh_pinky_mcp_pitch
cad_frozen_joints:
- rh_index_dip
- rh_middle_dip
- rh_pinky_dip
- rh_ring_dip
- rh_thumb_ip
endpoint_anchor_by_joint: {}
fitted_mimic_joints:
- rh_pinky_dip
- rh_thumb_ip
coupling_model_by_joint:
rh_thumb_ip: linear_mimic
rh_index_dip: linear_mimic
rh_middle_dip: linear_mimic
rh_ring_dip: linear_mimic
rh_pinky_dip: linear_mimic
transferred_zero_sources: {rh_index_mcp_pitch: rh_pinky_mcp_pitch, rh_middle_mcp_pitch: rh_pinky_mcp_pitch, rh_ring_mcp_pitch: rh_pinky_mcp_pitch}
transferred_mimic_sources: {rh_index_dip: rh_pinky_dip, rh_middle_dip: rh_pinky_dip, rh_ring_dip: rh_pinky_dip}
spatial:
base_pose_strategy: thumb_serial
root_anchor_joints: [rh_thumb_cmc_yaw]
orientation_anchor_joint: rh_pinky_mcp_pitch
directed_base_axis_joints: [rh_thumb_cmc_yaw, rh_pinky_mcp_pitch]
depth_free_axis_projection: true
axis_order: [rh_thumb_cmc_yaw, rh_thumb_cmc_pitch, rh_thumb_ip, rh_pinky_mcp_pitch, rh_pinky_dip]
axis_parent_joint: {rh_thumb_cmc_pitch: rh_thumb_cmc_yaw}
phase_parent_joint: {rh_thumb_ip: rh_thumb_cmc_pitch, rh_pinky_dip: rh_pinky_mcp_pitch}
offset_observer_joint: {rh_thumb_cmc_yaw: rh_thumb_cmc_pitch, rh_thumb_cmc_pitch: rh_thumb_ip, rh_pinky_mcp_pitch: rh_pinky_dip}
quality:
training_cycles:
- 0
- 1
- 2
holdout_cycle: 3
hard_threshold_keys:
- maximum_mimic_residual_rad
- maximum_state_image_skew_ms
- maximum_validation_error_rad
- minimum_detection_rate
retry_metric_scope: {}
isolated_holdout: true
scope:
calibrate_joints:
partial:
- rh_pinky_mcp_pitch
- rh_thumb_cmc_pitch
- rh_thumb_cmc_yaw
frozen_joints:
partial:
- rh_index_mcp_pitch
- rh_middle_mcp_pitch
- rh_ring_mcp_pitch
default_scope: partial
artifacts:
output_schema_version: 3
directional_command_joints:
- rh_thumb_cmc_yaw
calibration_filename: o6_right_{serial_number}_partial_calibration.json
corrected_urdf_filename: linkerhand_o6_right_{serial_number}_partial_zero_calibrated.urdf
protected_input_fields:
- calibration_config_sha256
- camera_extrinsics_sha256
- profile_config_sha256
- source_urdf_sha256
- tag_config_sha256
publication_pointer: latest_partial_passed
session_compatibility_tokens:
- feedback_curves_v6
- o6_partial_v1
publish_corrected_urdf: true
acquisition:
joint_zero_timeout_seconds: 5.0
command_capture_mode: separate
steady_training_nodes: 9
steady_extra_training_nodes:
pinky_pitch_dip_side: [239.0]
policy_version: unified_engine_v8_all_view_images
mapping_probe_maximum_rad: 0.0
automatic_rescan_limit: 1
minimum_valid_samples: 40
minimum_bins: 32
maximum_unobserved_fraction: 0.0625
legacy_minimum_span_01: 0.9411764705882353
physical_first_cycle_minimum_span_01: 0.85
physical_repeat_minimum_fraction: 0.9
stall_timeout_seconds: 2.0
feedback_stale_seconds: 1.0
fixed_reference_minimum_frames: 10
fixed_reference_maximum_drift_px: 5.0
fixed_reference_confirmation_frames: 10
urdf:
limit_policies:
rh_pinky_mcp_pitch:
source_limits: calibrated_motion_envelope
evidence_source: User accepts original CAD zeros but requests measured SDK motion amplitudes; source CAD limits are nominal motion envelopes.
rh_thumb_cmc_pitch:
source_limits: calibrated_motion_envelope
evidence_source: User accepts original CAD zeros but requests measured SDK motion amplitudes; source CAD limits are nominal motion envelopes.
rh_middle_mcp_pitch:
source_limits: calibrated_motion_envelope
evidence_source: User accepts original CAD zeros but requests measured SDK motion amplitudes; source CAD limits are nominal motion envelopes.
rh_index_mcp_pitch:
source_limits: calibrated_motion_envelope
evidence_source: User accepts original CAD zeros but requests measured SDK motion amplitudes; source CAD limits are nominal motion envelopes.
rh_ring_mcp_pitch:
source_limits: calibrated_motion_envelope
evidence_source: User accepts original CAD zeros but requests measured SDK motion amplitudes; source CAD limits are nominal motion envelopes.
rh_thumb_cmc_yaw:
source_limits: calibrated_motion_envelope
evidence_source: User accepts original CAD zeros but requests measured SDK motion amplitudes; source CAD limits are nominal motion envelopes.
rh_pinky_dip:
source_limits: calibrated_motion_envelope
evidence_source: User accepts original CAD zeros but requests measured SDK motion amplitudes; source CAD limits are nominal motion envelopes.
rh_thumb_ip:
source_limits: calibrated_motion_envelope
evidence_source: User accepts original CAD zeros but requests measured SDK motion amplitudes; source CAD limits are nominal motion envelopes.
rh_middle_dip:
source_limits: calibrated_motion_envelope
evidence_source: User accepts original CAD zeros but requests measured SDK motion amplitudes; source CAD limits are nominal motion envelopes.
rh_ring_dip:
source_limits: calibrated_motion_envelope
evidence_source: User accepts original CAD zeros but requests measured SDK motion amplitudes; source CAD limits are nominal motion envelopes.
rh_index_dip:
source_limits: calibrated_motion_envelope
evidence_source: User accepts original CAD zeros but requests measured SDK motion amplitudes; source CAD limits are nominal motion envelopes.
authorized_fields:
rh_pinky_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_index_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_thumb_cmc_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_middle_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_ring_mcp_pitch:
- limit.lower
- limit.upper
- origin.rpy
rh_thumb_cmc_yaw:
- limit.lower
- limit.upper
- origin.rpy
rh_ring_dip:
- mimic.multiplier
- mimic.offset
- limit.lower
- limit.upper
rh_index_dip:
- mimic.multiplier
- mimic.offset
- limit.lower
- limit.upper
rh_pinky_dip:
- mimic.multiplier
- mimic.offset
- limit.lower
- limit.upper
rh_thumb_ip:
- mimic.multiplier
- mimic.offset
- limit.lower
- limit.upper
rh_middle_dip:
- mimic.multiplier
- mimic.offset
- limit.lower
- limit.upper
joint_coverage:
rh_pinky_mcp_pitch: measured_static_dynamic
rh_thumb_cmc_pitch: measured_static_dynamic
rh_middle_mcp_pitch: transferred_static_dynamic
rh_index_mcp_pitch: transferred_static_dynamic
rh_ring_mcp_pitch: transferred_static_dynamic
rh_thumb_cmc_yaw: measured_static_dynamic
rh_pinky_dip: measured_dynamic_cad_static
rh_thumb_ip: measured_dynamic_cad_static
rh_middle_dip: transferred_dynamic_cad_static
rh_ring_dip: transferred_dynamic_cad_static
rh_index_dip: transferred_dynamic_cad_static
@@ -0,0 +1,44 @@
$schema: https://json-schema.org/draft/2020-12/schema
title: LinkerHand calibration profile
type: object
additionalProperties: false
required:
- schema_version
- profile_id
- namespace
- sdk_adapter
- command
- vision
- motion
- measurement
- zero
- quality
- scope
- artifacts
- urdf
properties:
schema_version: {const: 1}
profile_id: {type: string, pattern: "^[A-Za-z0-9_-]+/(left|right)/[a-z0-9_-]+/v[1-9][0-9]*$"}
namespace: {type: string, pattern: "^/[^/].*[^/]$"}
sdk_adapter: {type: string, minLength: 1}
command: {type: object}
vision: {type: object}
motion: {type: object}
measurement: {type: object}
zero: {type: object}
quality: {type: object}
scope: {type: object}
artifacts: {type: object}
acquisition: {type: object}
urdf:
type: object
required: [authorized_fields]
properties:
authorized_fields:
type: object
additionalProperties:
type: array
uniqueItems: true
items:
enum: [origin.rpy, limit.lower, limit.upper, mimic.multiplier, mimic.offset]
joint_coverage: {type: object}
@@ -0,0 +1,14 @@
$schema: https://json-schema.org/draft/2020-12/schema
title: LinkerHand calibration product
type: object
required: [schema_version, profile_id, serial_number, cameras, artifacts]
properties:
schema_version: {type: integer, minimum: 2}
profile_id: {type: string}
profile_config: {type: string}
serial_number: {type: string, pattern: "^[A-Za-z0-9_.-]+$"}
namespace: {type: string}
sdk: {type: object}
cameras: {type: object, minProperties: 1}
artifacts: {type: object}
release: {type: object}
@@ -0,0 +1,191 @@
g20_calibration:
ros__parameters:
command_topic: /g20/cb_left_hand_control_cmd
state_topic: /g20/cb_left_hand_state
info_topic: /g20/cb_left_hand_info
setting_topic: /g20/cb_hand_setting_cmd
front_camera_info_topic: /g20_calibration/front/camera/camera_info
front_detections_topic: /g20_calibration/front/apriltag/detections
side_camera_info_topic: /g20_calibration/side/camera/camera_info
side_detections_topic: /g20_calibration/side/apriltag/detections
top_camera_info_topic: /g20_calibration/top/camera/camera_info
top_detections_topic: /g20_calibration/top/apriltag/detections
# /start先下发并确认这个20通道基准姿态,稳定后才进入第一条扫描。
baseline_command_u8: [255, 255, 255, 255, 255, 255, 127, 127, 127, 127, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
normal_calibration_speed: 15
index_roll_calibration_speed: 5
index_flex_calibration_speed: 10
# 19-Tag产品预检仍使用上面保守速度;只有正反预检都留出至少双倍正式分箱余量,
# 才把非roll任务正式扫描最多提速1.5倍。四指roll受0.5°回差门限约束,
# 始终保持速度5;任一方向采样余量不足也保持原速度。
adaptive_formal_speed_enabled: true
adaptive_formal_speed_max_scale: 1.5
adaptive_formal_speed_minimum_bins: 64
adaptive_formal_speed_maximum_bin_gap: 8
speed_setting_settle_seconds: 0.25
# tag36h11尺寸是检测角点围成的黑色正方形边长,不包含外围白边。
# 19张Tag的黑色码区外边长均为16 mm。自定义PnP必须与
# apriltag_ros逐ID尺寸一致,禁止用纸张/白边尺寸代替码区尺寸。
tag_size_m: 0.016
# ROS 2无法从YAML空数组推断整数/浮点数组类型。这四个
# 末端Tag仍显式写16 mm,防止节点启动时得到未初始化参数。
tag_size_override_ids: [7, 14, 16, 18]
tag_size_overrides_m: [0.016, 0.016, 0.016, 0.016]
repetitions: 3
# 19-Tag产品正式零位使用前三轮训练、最后一轮完全留出;旧11-Tag仍读取repetitions=3。
g20_right_19_repetitions: 4
preflight_frames: 60
minimum_detection_rate: 0.95
minimum_detection_hz: 15.0
minimum_feedback_hz: 25.0
maximum_hamming: 0
minimum_decision_margin: 30.0
minimum_edge_pixels: 30.0
pnp_maximum_reprojection_error_px: 1.5
pnp_maximum_pose_jump_deg: 35.0
pnp_maximum_translation_jump_m: 0.04
pnp_maximum_tag_tilt_deg: 75.0
pnp_tracker_reset_seconds: 5.0
# 标定任务不再用第一帧决定平面Tag的IPPE分支;静止端点联合8帧选择整组最稳定解。
pnp_group_initialization_frames: 8
# 侧面当前任务所需Tag在初始化端点的贴面法向应一致;用此先验消除静态IPPE镜像双解。
pnp_group_normal_alignment_scale_deg: 5.0
pnp_group_maximum_normal_alignment_deg: 15.0
# 三个拇指顶部任务共用预检时冻结的Tag 8位姿。Tag 8仍须实时可见;
# 任一角点相对会话基准漂移超过2 px并连续5帧时,判定标定中基准被移动。
fixed_base_maximum_corner_drift_px: 5.0
fixed_base_movement_confirmation_frames: 10
# 仅在拇指MCP/IP同步运动且至少一个候选落入可信区间时,用源URDF mimic
# 关系辅助选择IPPE分支;若全部候选超限则退回纯视觉,绝不丢帧,也不生成、
# 缩放或替代被动IP的自身Tag实测曲线。
thumb_ip_pnp_coupling_multiplier: 1.03
thumb_ip_pnp_coupling_scale_deg: 3.0
thumb_ip_pnp_maximum_coupling_residual_deg: 7.5
top_pnp_invalid_reset_seconds: 1.0
# 三维位姿必须与实测20通道状态严格按时间戳配对。
maximum_state_image_skew_ms: 50.0
axis_maximum_plane_rms_m: 0.003
# 被动耦合轴只用轨迹确定轴线位置,允许更大的轴向深度噪声;径向和跨轮
# 轴线一致性仍沿用严格检查。
passive_axis_maximum_plane_rms_m: 0.004
axis_maximum_radial_rms_m: 0.003
# 整段相对SE(3)运动拟合轴线点;端视关节会投影掉单目PnP光轴深度。
axis_maximum_pose_line_rms_m: 0.001
# 仅用于运动平面在三维中可观测的斜视关节;近图像平面关节使用姿态轴
# 约束三维圆,不让单目平面Tag的深度噪声自由决定转轴方向。
axis_maximum_rotation_circle_difference_deg: 1.0
# 单轴模型残差与跨轮重复误差分开判定:主动刚性关节要求更严;被动耦合
# 关节允许可重复的非理想单轴分量,但仍须通过0.75°跨轮轴差及最终轮留出。
active_maximum_rotation_orthogonal_rms_deg: 2.5
passive_maximum_rotation_orthogonal_rms_deg: 7.5
zero_maximum_axis_cycle_difference_deg: 0.75
# 零位无法改变父子轴夹角;超过该值属于CAD/PnP几何错误,不能吸收到零位。
zero_maximum_axis_cone_mismatch_deg: 5.0
zero_maximum_observability_condition_number: 10000000000.0
zero_maximum_offset_deg: 20.0
# 四指MCP roll保留严格的装配保护范围。thumb CMC三轴由多轴视觉几何
# 求解且不假定电气端点等于CAD上限;thumb_mcp及四指MCP pitch/PIP
# 静态零位由实测全行程与CAD机械端点联合求解,不写死为0。
zero_finger_maximum_offset_deg: 3.0
# 只对实物已确认等同CAD端点的关节使用该限制;CMC电气端点不作此假设。
mechanical_endpoint_maximum_offset_deg: 5.0
endpoint_tolerance_u8: 2.0
# 请求命令与固件反馈是两个标定域。稳态检查点允许小幅死区,但反馈
# 必须已经稳定;大残差仍由机械卡滞保护处理。
steady_checkpoint_command_feedback_tolerance_u8: 8.0
steady_checkpoint_maximum_feedback_range_u8: 2.0
# 电机10在命令0时实测会稳定反馈为4;该0端使用±4。
thumb_yaw_zero_endpoint_tolerance_u8: 4.0
# 右手电机10在命令255时多次实测稳定反馈为250;仅右手该端点使用±5。
right_thumb_yaw_255_endpoint_tolerance_u8: 5.0
# 右手小指PIP电机19在命令0时固件反馈稳定饱和为5;仅其0端使用±5。
pinky_pip_zero_endpoint_tolerance_u8: 5.0
endpoint_hold_seconds: 0.5
# roll零位127必须从两个方向到位并静止采集,禁止用运动中经过127的帧判回差。
baseline_hold_seconds: 0.5
minimum_baseline_hold_frames: 10
# unified_engine_v2 不执行每任务全行程预检;保留参数仅兼容旧配置读取。
task_precheck_hold_seconds: 2.0
position_timeout_seconds: 30.0
sweep_timeout_seconds: 90.0
# 启动宽限1秒后,反馈连续2秒没有至少1个u8的进展,按机械卡滞立即暂停;
# 这类硬故障不自动重试。
motor_stall_timeout_seconds: 2.0
motor_stall_startup_grace_seconds: 1.0
motor_stall_minimum_progress_u8: 1.0
invalid_timeout_seconds: 3.0
minimum_sweep_frames: 40
minimum_state_span_u8: 240.0
minimum_sweep_bins: 32
maximum_bin_gap: 16
# 可恢复的采样失败自动重扫当前方向;超过次数才暂停等待人工处理。
automatic_sweep_retry_limit: 1
# 轨迹拟合失败优先只重扫失败轮次;零位/URDF模型失败不重复运动。
automatic_fit_retry_limit: 0
automatic_motion_retry_limit: 0
# 留空为正式标定;设为pinky/ring/middle/index时只采该指正面+侧面roll,
# 即使正面baseline回差失败也继续完成侧面对照,并永久锁定本会话URDF发布。
cross_view_roll_diagnostic_finger: ""
# 过程检查允许25%的黄色预警带,最终验收仍使用下面的严格门限。
provisional_warning_ratio: 1.25
retry_minimum_speed: 3
retry_speed_scales: [1.0]
retry_endpoint_hold_seconds: [0.5]
trajectory_maximum_plane_rms_m: 0.004
trajectory_maximum_radial_rms_m: 0.004
trajectory_minimum_radius_m: 0.003
trajectory_minimum_arc_deg: 15.0
# 以下二维参数只供旧轨迹工具兼容,三机位v4零位不使用二维投影。
image_trajectory_maximum_radial_rms_px: 2.0
image_trajectory_maximum_radial_p95_px: 3.5
image_trajectory_minimum_radius_px: 20.0
trajectory_maximum_cycle_travel_difference_deg: 3.0
passive_maximum_cycle_travel_difference_deg: 10.0
maximum_monotonic_correction_deg: 2.0
# 旧布局仍用连续扫描正反程差门限;19-Tag产品的连续运动包含速度相关滞后,
# 由方向曲线和最终留出验证建模,不再重复硬判。其绝对正反程门禁使用下面
# 的九点稳态command_maximum_direction_gap_deg。
maximum_hysteresis_deg: 2.0
# 19-Tag产品模式额外要求每轮正反方向在各自baseline处绕实测关节轴的角度差
# 不超过0.5°;四指roll例外:127以255→127为唯一物理零位,反向分支
# 保留实测偏差,并改为检查分支间隙上限及跨轮稳定性。
baseline_maximum_hysteresis_deg: 0.5
directional_zero_maximum_branch_gap_deg: 2.0
directional_zero_maximum_branch_gap_range_deg: 0.3
cross_view_roll_maximum_branch_gap_difference_deg: 0.3
# 正面roll是Tag中心的二维投影角,侧面roll是三维姿态角。允许一个有界的
# 固定比例吸收Tag安装倾角/偏置带来的投影缩放,再严格比较两条曲线形状;
# 比例过大、方向相反、形状RMS及两视角各自的四轮重复性仍会失败。
cross_view_roll_maximum_shape_rms_deg: 1.25
cross_view_roll_maximum_projection_scale_ratio: 1.5
passive_maximum_monotonic_correction_deg: 3.0
passive_maximum_hysteresis_deg: 2.0
# 九点姿态先按实测反馈重新对齐,再用此门限检查固有机械回差;请求命令域
# 的两条运行曲线仍原样保留固件方向死区,不能把command/feedback差算成回差。
command_maximum_direction_gap_deg: 2.0
# 默认无额外随机动作;19-Tag产品最终一轮始终作为不可关闭的留出验证。
validation_enabled: false
# 第四轮留出求解后必须再走8个固定安全组合姿态;三机位规定Tag全部可见
# 且实测20通道到位才允许发布。只保存Tag位姿,不保存原始图像。
# Developer diagnostic only. The formal fourth sweep cycle already gives
# every isolated PIP/DIP pair an independent holdout.
combination_validation_enabled: false
combination_validation_frames: 10
combination_maximum_position_p95_m: 0.003
combination_maximum_orientation_p95_deg: 2.0
validation_command_count: 3
validation_frames: 10
validation_seed: 20260804
validation_timeout_seconds: 20.0
maximum_validation_mae_deg: 1.0
maximum_validation_p95_deg: 2.0
# 19-Tag产品模式使用更严格的任一点及静态零偏95%置信区间门限。
maximum_validation_error_deg: 3.0
zero_maximum_confidence_half_width_deg: 1.5
@@ -3,7 +3,7 @@
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.010
size: 0.016
profile: false
max_hamming: 0
detector:
@@ -17,14 +17,14 @@
tag:
ids: [0, 1, 2, 3, 10]
frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, index_roll]
sizes: [0.010, 0.010, 0.010, 0.010, 0.010]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016]
/g20_calibration/side/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.010
size: 0.016
profile: false
max_hamming: 0
detector:
@@ -38,14 +38,14 @@
tag:
ids: [4, 5, 6, 7]
frames: [side_base, index_mcp, index_pip, index_dip]
sizes: [0.010, 0.010, 0.010, 0.010]
sizes: [0.016, 0.016, 0.016, 0.016]
/g20_calibration/top/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.010
size: 0.016
profile: false
max_hamming: 0
detector:
@@ -59,4 +59,4 @@
tag:
ids: [8, 9]
frames: [top_base, thumb_yaw]
sizes: [0.010, 0.010]
sizes: [0.016, 0.016]
@@ -0,0 +1,62 @@
/g20_calibration/front/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [0, 1, 2, 3, 10, 11, 12, 13]
frames: [front_base, thumb_cmc, thumb_mcp, thumb_ip, pinky_roll, ring_roll, middle_roll, index_roll]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016, 0.016]
/g20_calibration/side/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [4, 5, 6, 15, 17]
frames: [side_base, ring_pip, pinky_pip, middle_pip, index_pip]
sizes: [0.016, 0.016, 0.016, 0.016, 0.016]
/g20_calibration/top/apriltag/apriltag:
ros__parameters:
image_transport: raw
qos_profile: sensor_data
family: 36h11
size: 0.016
profile: false
max_hamming: 0
detector:
threads: 4
decimate: 1.5
blur: 0.0
refine: true
sharpening: 0.25
debug: false
pose_estimation_method: pnp
tag:
ids: [8, 9]
frames: [top_base, thumb_yaw]
sizes: [0.016, 0.016]

Some files were not shown because too many files have changed in this diff Show More